1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
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>
|