code.pablomurad.com

itwhisper

One-file template for a small garden of linked pages, with backlinks and no build step

Add the itwhisper template
author
Pablo Murad
date
commit
6c8644b7a1b7c6f15efec0903bbb3a62e8414b89
changes
3 files +255 −0
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>