Add the itwhisper template
A
LICENSE
@@ -0,0 +1,21 @@+MIT License + +Copyright (c) 2026 Pablo Murad + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
@@ -0,0 +1,35 @@+# itwhisper + +A one-file template for a small garden of linked pages. + +The whole site is index.html. Pages, links and backlinks come from an +indented outline kept inside the file. There is no build step, no +database and no tracking. + +## Requirements + +A text editor and a web browser. + +## Usage + +Open index.html in a browser. The sample garden is the manual: it +explains the outline, the formatting and how to publish. + +Then edit the outline in the `<script id="garden" type="text/plain">` +block and replace it with your own: + + my garden + a line of text. + a page + a line inside the page, with a [[link back|my garden]]. + about + say hello: me[at]example[dot]org + +The first line is the home page. Other lines at the far left go in the +menu. A line with lines under it becomes a page. + +Upload index.html to any static host. + +## License + +MIT. See `LICENSE`.
@@ -0,0 +1,199 @@+<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"/> +<meta name="viewport" content="width=device-width, initial-scale=1.0"/> +<title>itwhisper</title> +<meta name="description" content="a small garden of linked pages, kept in one html file."/> +<style> +:root{ color-scheme:light; --f:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + --bg:#ffffff; --fg:#111111; --mut:#808080; --code:#f0f0f0; } +@media (prefers-color-scheme:dark){ :root{ color-scheme:dark; --bg:#111111; --fg:#e6e6e6; --mut:#8c8c8c; --code:#262626; } } +*{ box-sizing:border-box } +html{ font-family:var(--f); background:var(--bg); color:var(--fg); line-height:1.6; font-size:13px } +body{ margin:0 } +a{ color:inherit } +.wrp{ max-width:850px; margin:0 auto; width:100%; padding:0 1rem } +.bar{ position:sticky; top:0; background:var(--bg); display:flex; align-items:baseline; gap:.9rem; flex-wrap:wrap; padding:.7rem 0 } +.bar .home{ text-decoration:none; font-weight:700 } +.bar nav{ display:flex; gap:.9rem; flex-wrap:wrap } +.bar nav a{ text-decoration:none; opacity:.8 } .bar nav a:hover{ opacity:1; text-decoration:underline } +main{ padding:1.2rem 0 5rem } +h1.pg{ font-size:1em; font-weight:700; margin:.2rem 0 1.1rem } +.node{ display:flex; align-items:baseline; gap:.5rem; margin:.12rem 0 } +.node .bul{ color:var(--mut); text-decoration:none; user-select:none; flex:0 0 auto; width:1ch } +a.bul:hover{ color:var(--fg) } +.node .tx{ flex:1 1 auto; min-width:0; overflow-wrap:anywhere } +a.door, a.wl, a.ext{ text-decoration:none } +a.door:hover, a.wl:hover, a.ext:hover{ opacity:.6 } +.wl.missing{ opacity:.45 } +a.ext::after{ content:'\2197'; font-size:.75em; opacity:.6; padding-left:1px } +code{ background:var(--code); padding:0 .3em; border-radius:2px } +em{ color:var(--mut); font-style:italic } +.back{ margin-top:2.6rem } +.back h2{ font-size:1em; font-weight:400; color:var(--mut); margin:0 0 .5rem } +.back ul{ list-style:none; margin:0; padding:0 } .back li{ margin:.2rem 0 } +.back a{ text-decoration:none } .back a:hover{ text-decoration:underline } .back .ctx{ color:var(--mut) } +</style> +<script id="garden" type="text/plain"> +itwhisper + a small garden of linked pages, kept in one html file. + there is no build step, no database and nothing watching the reader. the file is the whole site. + this garden is also the manual. read it here, then open index.html in a text editor and write your own over it. + start here + 1. download index.html and open it in your browser. you are reading it now. + 2. open the same file in a text editor and find the line `<script id="garden" type="text/plain">`. + 3. everything on these pages is written below that line, as an indented outline. change something, save, and reload the browser. + 4. when you like what you see, put the file on a web server. see [[publishing]]. + kin: [[pages and lines]] · [[formatting]] + pages and lines + one line in the outline is one line on the page. blank lines are skipped. + a line with nothing indented under it is plain text, like this one. + a line with lines indented under it becomes a page. it shows as a link with a › in front, like [[a page inside this one]]. + a page inside this one + you are inside "a page inside this one". each page shows only its own lines, and the pages under it show as links. + a page can hold other pages, as deep as you like. + the very first line of the outline is the home page. the name in the corner takes you back to it. + indent with spaces or with tabs, but pick one and keep to it. a tab counts as four spaces. + every page gets an address from its title. this one is `#pages-and-lines`, so you can link to it from anywhere. + rename a page and its address changes with it. old links to it land on the home page. + two pages with the same title get `#title` and `#title-2`, and `[[title]]` goes to the first one. keep titles different and you never think about it. + formatting + a word between asterisks turns *quiet and slanted*. + square brackets followed by an address in parentheses make an outside link: [example.org](https://example.org). it opens in a new tab. + backticks show text as code, exactly as typed: `ssh bbs.example.org`, or `*not slanted*`. + a title between double square brackets links to that page: [[start here]]. + put a | after the title to show other words: [[formatting|this very page]]. + a link to a title that does not exist shows faded and goes nowhere, like [[a page nobody wrote yet]]. the browser console names each one, which is handy for typos. + there is no other markup. no headings, no images, no bold. + links back + when a line links to a page, that page lists the line at its bottom, under "whispered from". there is nothing to set up. + go into [[start here]] and look at the bottom. this line shows up there. + many gardens end a page with a kin line naming related pages, like the one in [[start here]]. kin is not special syntax, only a line with links in it. call it whatever you like. + the menu and the name + every line at the far left after the first becomes a page in the menu at the top. here they are [[publishing]] and [[about]]. + the name in the top left corner is the `<title>` tag in the head of the file. the `description` tag under it is what link previews and search engines show. change both. + colors and font are in the `<style>` block near the top. the page follows the reader's light or dark setting. +publishing + the site is the single file index.html. there is nothing else to upload. + any host that serves static files will do: neocities, codeberg pages, github pages, or a folder on your own server. + opening the file straight from your computer works too, links and all. + to update the site, edit the file and upload it again. +about + itwhisper is a one-file template for a garden of linked pages, made by [Pablo Murad](https://pablomurad.com). his own garden is [sussurra.me](https://sussurra.me). + it is free software under the MIT license. copy it and change it as you like, and keep the license with it. + source: [git.pablomurad.com/pablo/itwhisper](https://git.pablomurad.com/pablo/itwhisper) + delete this page, or make it yours. +</script> +</head> +<body> +<div class="wrp"> + <header class="bar"> + <a class="home" href="#" id="homeLink"></a> + <nav id="nav"></nav> + </header> + <main id="view"></main> +</div> + +<script> +(() => { +"use strict"; + +function parse(src){ + const roots=[], stack=[]; + for(const line of src.split(/\r?\n/)){ + if(!line.trim()) continue; + const depth=line.match(/^\s*/)[0].replace(/\t/g,' ').length; + const node={text:line.trim(), children:[]}; + while(stack.length && stack[stack.length-1].depth>=depth) stack.pop(); + (stack.length ? stack[stack.length-1].node.children : roots).push(node); + stack.push({depth, node}); + } + const nodes={}; let n=0; + const slug=t=>t.normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')||'page'; + const pageId=t=>{ const b=slug(t); let i=b, k=2; while(i in nodes) i=b+'-'+(k++); return i; }; + const place=node=>{ + const id=node.children.length ? pageId(node.text) : 'l'+(n++).toString(36); + nodes[id]={id, text:node.text, children:[]}; + nodes[id].children=node.children.map(place); + return id; + }; + const top=roots.map(place); + return { home:top[0], nav:top.slice(1), nodes }; +} + +const garden=parse(document.getElementById('garden').textContent); +const siteName=document.title; +const homeLink=document.getElementById('homeLink'), nav=document.getElementById('nav'), view=document.getElementById('view'); +const N=id=>garden.nodes[id]; + +const titleMap={}, parentMap={}; +for(const id in garden.nodes){ + const key=N(id).text.toLowerCase(); + if(!(key in titleMap)) titleMap[key]=id; + for(const c of N(id).children) parentMap[c]=id; +} + +const noCode=text=>text.replace(/`[^`]+`/g,''); +function refsOf(text){ const out=[]; const re=/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g; let m; + while((m=re.exec(noCode(text)))){ const id=titleMap[m[1].trim().toLowerCase()]; if(id) out.push(id); } return out; } + +const esc=s=>s.replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); +function wl(target,label){ const id=titleMap[target.trim().toLowerCase()]; + if(id) return `<a class="wl" href="#${id}">${label}</a>`; + console.warn('itwhisper: no page called "'+target.trim()+'"'); + return `<span class="wl missing">${label}</span>`; } +function inline(s){ + s=s.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g,(m,t,l)=>wl(t,l)); + s=s.replace(/\[\[([^\]]+)\]\]/g,(m,t)=>wl(t,t)); + s=s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(m,l,u)=>`<a class="ext" href="${u}"${/^https?:/.test(u)?' target="_blank" rel="noopener"':''}>${l}</a>`); + return s.replace(/\*([^*]+)\*/g,'<em>$1</em>'); } +const md=text=>text.split(/(`[^`]+`)/).map((part,i)=> i%2 ? `<code>${esc(part.slice(1,-1))}</code>` : inline(esc(part))).join(''); + +const curId=()=>{ const h=decodeURIComponent(location.hash.slice(1)); return N(h) ? h : garden.home; }; + +function renderRow(c){ + const node=N(c), isPage=node.children.length>0; + const row=document.createElement('div'); row.className='node'; + const bul=document.createElement(isPage?'a':'span'); bul.className='bul'; + const tx=document.createElement('span'); tx.className='tx'; + if(isPage){ + bul.href='#'+c; bul.textContent='›'; + const a=document.createElement('a'); a.className='door'; a.href='#'+c; a.textContent=node.text; tx.appendChild(a); + } else { + bul.textContent='·'; + tx.innerHTML=md(node.text); + } + row.append(bul, tx); + return row; +} + +function render(){ + const id=curId(), node=N(id); + view.innerHTML=''; + const h=document.createElement('h1'); h.className='pg'; h.innerHTML=md(node.text); view.appendChild(h); + node.children.forEach(c=>view.appendChild(renderRow(c))); + + const ownerOf=nid=>N(nid).children.length ? nid : (parentMap[nid]||nid); + const back=Object.keys(garden.nodes).filter(nid=>ownerOf(nid)!==id && refsOf(N(nid).text).includes(id)); + if(!back.length) return; + const b=document.createElement('div'); b.className='back'; b.innerHTML='<h2>whispered from</h2>'; + const ul=document.createElement('ul'); + back.forEach(nid=>{ + const owner=ownerOf(nid); + const li=document.createElement('li'); + li.innerHTML=`<a href="#${owner}">${esc(N(owner).text)}</a> <span class="ctx">· ${md(N(nid).text)}</span>`; + ul.appendChild(li); + }); + b.appendChild(ul); view.appendChild(b); +} + +homeLink.textContent=siteName; +homeLink.addEventListener('click',e=>{ e.preventDefault(); location.hash=garden.home; }); +garden.nav.forEach(id=>{ const a=document.createElement('a'); a.href='#'+id; a.textContent=N(id).text; nav.appendChild(a); }); +window.addEventListener('hashchange',()=>{ render(); scrollTo(0,0); }); +render(); +})(); +</script> +</body> +</html>