wiki-plugin-pod 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ReadMe.md ADDED
@@ -0,0 +1,122 @@
1
+ # Federated Wiki - Pod Plugin
2
+
3
+ Plugin type: `pod`. Gathers configurable **pods of related wiki sites**
4
+ and adds them to your neighborhood. An augmentation of the
5
+ [Present](https://github.com/wardcunningham/wiki-plugin-present) plugin: instead
6
+ of only listing sister sites, the item text is a list of keywords choosing
7
+ *which* pods to gather.
8
+
9
+ ## Commands (one per line in the item text)
10
+
11
+ The item text follows the [fedwiki DSL convention](../../.claude/skills/fedwiki-dsl/SKILL.md):
12
+ each line is a **command** — UPPERCASE is canonical, input case is forgiven, and
13
+ a single trailing colon (YAML-style) is optional.
14
+
15
+ | Command | Gathers | Source |
16
+ |---|---|---|
17
+ | `POD` | `PARENT` + `SISTERS` together (shorthand) | server |
18
+ | `SISTERS` | sibling sites sharing the parent domain | server |
19
+ | `PARENT` | the parent-domain site itself | server |
20
+ | `CHILDREN` | direct sub-domains of this site | server |
21
+ | `DESCENDANTS` | all sub-domains, any depth | server |
22
+ | `FARM` | every wiki in the farm | server |
23
+ | `NEIGHBOURHOOD` | the sites currently in your neighborhood | client |
24
+ | `SNAPSHOT` | freeze the current neighborhood (v0.1 == NEIGHBOURHOOD) | client |
25
+
26
+ Example item text:
27
+
28
+ ```
29
+ POD
30
+ ```
31
+
32
+ ## Actions
33
+
34
+ Actions are not site-kinds — they change what the panel *does* with the pod
35
+ it gathered. Add one on its own line alongside the kind commands.
36
+
37
+ | Action | Effect |
38
+ |---|---|
39
+ | `ROSTER` | draw the gather compactly, **mirroring a normal roster item** — a left-aligned "<commands> Rosters" title over flowing flags, instead of the wide table |
40
+ | `TWIN` | show only the members that hold a page with **this page's slug** |
41
+ | `WATCH` | show only the members whose copy of this page is *actually a fork* of it — i.e. who is watching (tracking) your page |
42
+ | `TITLE yes\|no` | whether the `ROSTER` title shows (default `yes`). `TITLE no` hides it, keeping even padding around the flags |
43
+
44
+ `ROSTER`, `TWIN` and `WATCH` all render as a compact **roster of flags** — the
45
+ members' `img.remote` flags flow inline in the grey box, each linking to that
46
+ member's copy of the page, the (sub)domain shown on hover. `ROSTER` shows the
47
+ whole gather; `TWIN`/`WATCH` filter it:
48
+
49
+ - **`TWIN`** is **existence-only**, exactly like the wiki-client *Twins* strip: a
50
+ slug match in the member's sitemap. Cheap — read straight from the neighbourhood.
51
+ - **`WATCH`** is **lineage-aware**: it fetches each twin's page JSON and keeps only
52
+ those whose journal carries a `fork` event. So `WATCH ⊆ TWIN` — a page that
53
+ merely shares the name but was authored independently is a twin, not a fork.
54
+
55
+ ### Saving a roster page
56
+
57
+ The `ROSTER` view and the wide table both carry a subtle **❄ icon** in the corner
58
+ that saves the gathered pod as a **roster ghost page** titled after the
59
+ commands (e.g. `PARENT SISTERS Rosters`), one roster item per kind. (There is no
60
+ separate `FREEZE` command — the icon replaces it.)
61
+
62
+ Example — watch who among your sisters is tracking the page you're on:
63
+
64
+ ```
65
+ SISTERS
66
+ WATCH
67
+ ```
68
+
69
+ Example — a compact roster of the whole pod, no title:
70
+
71
+ ```
72
+ POD
73
+ ROSTER
74
+ TITLE no
75
+ ```
76
+
77
+ ## Build
78
+
79
+ ```
80
+ npm install
81
+ npm run build # esbuild: src/client/pod.js -> client/pod.js
82
+ ```
83
+
84
+ ## How it differs from Present
85
+
86
+ Present computes peers from the farm *root* (`argv.data`), which misfires in a
87
+ farm laid out as `{farm}/{sub}.{domain}/`. Pod derives the origin from the
88
+ *requesting* site (`argv.status`) and resolves each pod relative to it.
89
+
90
+ ## License
91
+
92
+ MIT
93
+
94
+ ## The interface it declares
95
+
96
+ The plugin ships no server of its own for this — it declares a specification and
97
+ a module of plain functions, and the Farm Plugin mounts them:
98
+
99
+ GET /system/api/pod/roll.json?kinds=children,sisters
100
+
101
+ The answer depends on the site it is asked at, so `origin` and `farmRoot` are
102
+ declared as context in the specification and supplied by the farm. The plugin's
103
+ own route `/plugin/pod/roll` is unchanged and still answers identically.
104
+
105
+ ## One vocabulary
106
+
107
+ `src/pod/commands.js` is the single table of what a pod item's text may
108
+ say. The client imports it to parse; the API declaration names it so the farm
109
+ reads the same one. Each command declares which kind it is, because a command
110
+ language is a superset of an interface:
111
+
112
+ | Kind | Commands | Reaches the farm |
113
+ |---|---|---|
114
+ | parameter | `SISTERS` `PARENT` `CHILDREN` `DESCENDANTS` `FARM` | yes — a value of `kinds` |
115
+ | macro | `POD` | via `PARENT` + `SISTERS` |
116
+ | presentation | `ROSTER` `TITLE` | no — a drawing choice |
117
+ | client-data | `NEIGHBOURHOOD` `SNAPSHOT` `TWIN` `WATCH` | no — only a browser holds it |
118
+ | alias | `NEIGHBORHOOD` | resolves to `NEIGHBOURHOOD` |
119
+
120
+ The `kinds` parameter's allowed values are derived from that table rather than
121
+ written a second time. `npm test` checks the parser against it; the farm checks
122
+ it against the specification.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * pod — the handlers named by the api declaration in package.json.
3
+ *
4
+ * Plain functions taking and returning plain values. Nothing here sees a
5
+ * request or a response, imports nothing from express, and reads nothing from
6
+ * the environment.
7
+ *
8
+ * Unlike a search, this plugin's answer depends on WHERE it was asked: the same
9
+ * question put to two sites of one farm has two different right answers. So the
10
+ * site is an input, declared as context in the specification and supplied by the
11
+ * farm alongside the query parameters — never reached for. A handler that cannot
12
+ * be told where it is being asked should fail loudly rather than guess.
13
+ */
14
+
15
+ import { roll as gather } from '../src/pod/roll.js'
16
+
17
+ export async function roll({ kinds = 'sisters', origin, farmRoot } = {}) {
18
+ if (!origin || !farmRoot) {
19
+ const err = new Error('roll needs the site it is asked at — origin and farmRoot are declared context')
20
+ err.status = 500
21
+ throw err
22
+ }
23
+ return gather({ kinds, origin, farmRoot })
24
+ }
@@ -0,0 +1,95 @@
1
+ {
2
+ "openapi": "3.1.0",
3
+ "info": {
4
+ "title": "Pod",
5
+ "version": "0.8.0",
6
+ "description": "Report which sites of this farm stand as pod to the site being asked \u2014 children, sisters, parent, descendants \u2014 each with a count of the pages it holds. Kinship is read from the name, so the answer depends on where the question is put."
7
+ },
8
+ "paths": {
9
+ "/roll.json": {
10
+ "get": {
11
+ "operationId": "roll",
12
+ "summary": "Gather pods of related sites",
13
+ "description": "Answers for the site the request arrives at, not for the farm as a whole: asked at a child site, sisters are that child's siblings. Unknown kinds are dropped rather than refused. Read-only. Every pod is a neighbourhood, so the site you asked at is never among its own results \u2014 `farm` gives the other sites of the farm, not a census including yourself. For a plain list of everything that exists, including the origin, read x-fedwiki-farm.sites in the merged document.",
14
+ "x-fedwiki-read-only": true,
15
+ "x-fedwiki-context": [
16
+ "origin",
17
+ "farmRoot"
18
+ ],
19
+ "parameters": [
20
+ {
21
+ "name": "kinds",
22
+ "in": "query",
23
+ "required": false,
24
+ "description": "Which pods to gather, comma separated. Defaults to sisters.",
25
+ "style": "form",
26
+ "explode": false,
27
+ "schema": {
28
+ "type": "array",
29
+ "default": [
30
+ "sisters"
31
+ ],
32
+ "items": {
33
+ "type": "string"
34
+ }
35
+ },
36
+ "x-fedwiki-from-commands": "kinds"
37
+ }
38
+ ],
39
+ "responses": {
40
+ "200": {
41
+ "description": "One group per requested kind, each sorted by site name.",
42
+ "content": {
43
+ "application/json": {
44
+ "schema": {
45
+ "$ref": "#/components/schemas/Roll"
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ },
54
+ "components": {
55
+ "schemas": {
56
+ "Roll": {
57
+ "type": "object",
58
+ "description": "The gather, as seen from one site.",
59
+ "properties": {
60
+ "origin": {
61
+ "type": "string",
62
+ "description": "The site the question was asked at."
63
+ },
64
+ "parentDomain": {
65
+ "type": "string",
66
+ "description": "The domain this site hangs from, empty at a farm root."
67
+ },
68
+ "groups": {
69
+ "type": "object",
70
+ "description": "One key per requested and recognised kind.",
71
+ "additionalProperties": {
72
+ "type": "array",
73
+ "items": {
74
+ "$ref": "#/components/schemas/Member"
75
+ }
76
+ }
77
+ }
78
+ }
79
+ },
80
+ "Member": {
81
+ "type": "object",
82
+ "properties": {
83
+ "site": {
84
+ "type": "string",
85
+ "description": "The site's domain name."
86
+ },
87
+ "pages": {
88
+ "type": "integer",
89
+ "description": "How many pages it holds."
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
package/client/pod.js ADDED
@@ -0,0 +1,10 @@
1
+ (()=>{var h={SISTERS:{kind:"parameter",parameter:"kinds",value:"sisters",label:"Sisters",description:"sibling sites sharing the parent domain"},PARENT:{kind:"parameter",parameter:"kinds",value:"parent",label:"Parent",description:"the parent-domain site itself"},CHILDREN:{kind:"parameter",parameter:"kinds",value:"children",label:"Children",description:"direct sub-domains of this site"},DESCENDANTS:{kind:"parameter",parameter:"kinds",value:"descendants",label:"Descendants",description:"all sub-domains, any depth"},FARM:{kind:"parameter",parameter:"kinds",value:"farm",label:"Farm",description:"every wiki in the farm"},POD:{kind:"macro",expands:["PARENT","SISTERS"],description:"PARENT and SISTERS together (shorthand)"},NEIGHBOURHOOD:{kind:"client-data",value:"neighbourhood",label:"Neighbourhood",description:"the sites currently in your neighborhood",why:"reads the neighbourhood the browser has already assembled, which no server can see"},NEIGHBORHOOD:{kind:"alias",alias:"NEIGHBOURHOOD",description:"American spelling"},SNAPSHOT:{kind:"client-data",value:"snapshot",label:"Snapshot",description:"freeze the current neighborhood",why:"same browser-only source as NEIGHBOURHOOD"},TWIN:{kind:"client-data",description:"show only members holding a page with this page's slug",why:"matches against each neighbour's own sitemap, fetched from the browser"},WATCH:{kind:"client-data",description:"stricter TWIN \u2014 only members whose copy is a fork of this page",why:"reads each candidate's page journal, one fetch per neighbour, from the browser"},ROSTER:{kind:"presentation",description:"draw the gather compactly, mirroring a normal roster item"},TITLE:{kind:"presentation",argument:"yes|no",default:!0,description:"whether the ROSTER title shows"}},J=["no","false","off","0","hide","none"],Q=t=>{let e=h[t];return e&&e.kind==="alias"?h[e.alias]:e},j=t=>{let e=h[t];return e&&e.kind==="alias"?e.alias:t},E=t=>String(t||"").trim().split(/\s+/)[0].replace(/:$/,"").toUpperCase(),C=t=>Object.values(h).filter(e=>e.kind==="parameter"&&e.parameter===t&&e.value).map(e=>e.value),D=()=>Object.values(h).filter(t=>t.kind==="client-data"&&t.value).map(t=>t.value),H=()=>{let t={};for(let e of Object.values(h))e.value&&e.label&&(t[e.value]=e.label);return t},M=(t,{fallback:e=["sisters"]}={})=>{let s=[],r=d=>{let p=Q(d);if(p){if(p.kind==="macro")return p.expands.forEach(r);p.value&&(s.includes(p.value)||s.push(p.value))}};for(let d of String(t||"").split(`
2
+ `))d.trim()&&r(E(d));return s.length?s:[...e]},L=(t,e)=>String(t||"").split(`
3
+ `).some(s=>j(E(s))===e),P=t=>{for(let e of String(t||"").split(`
4
+ `)){let s=e.trim().split(/\s+/);if(j(E(e))==="TITLE")return!J.includes((s[1]||"").toLowerCase().replace(/:$/,""))}return h.TITLE.default};var R=C("kinds"),it=D(),B=H(),W=t=>(t||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/\*(.+?)\*/g,"<i>$1</i>"),X=t=>{let e=Math.floor(t/1e3),s=1.9,r="a minute ago";return e>s*60&&(r=`${Math.round(e/60)} minutes ago`),e>s*3600&&(r=`${Math.round(e/3600)} hours ago`),e>s*86400&&(r=`${Math.round(e/86400)} days ago`),e>s*604800&&(r=`${Math.round(e/604800)} weeks ago`),e>s*2592e3&&(r=`${Math.round(e/2592e3)} months ago`),e>s*31536e3&&(r=`${Math.round(e/31536e3)} years ago`),r},U=t=>{if(!t)return"";let e=t.map(s=>s.date).filter(s=>typeof s=="number");return e.length?X(Date.now()-Math.max(...e)):""},F=()=>[80,"80","",null].includes(location.port)?"":`:${location.port}`,O=L,Z=t=>t.endsWith("."+location.hostname)?t.slice(0,-(location.hostname.length+1)):t.split(".")[0],tt=(t,e,s)=>{let r=F(),d=Z(t),p=`<img class=remote title="${t}${r}" src="//${t}${r}/favicon.png" data-site="${t}${r}" data-slug=welcome-visitors>`;return`<tr><td align=right>${d} ${p}<td data-site="${t}${r}">${e} pages ${U(s)}`},et=(t,e)=>`<p class=caption><b>${B[t]}</b></p><table width=100% class=pod-${t}>${e.join(`
5
+ `)}</table>`,nt=(t,e)=>{let s=F(),r=M(e.text),d=O(e.text,"WATCH"),p=O(e.text,"TWIN"),m=d||p,f=O(e.text,"ROSTER"),K=P(e.text),k=`${r.map(o=>o.toUpperCase()).join(" ")} Rosters`,x=(t.closest(".page").attr("id")||"").split("_rev")[0],y={},N=o=>{let l=wiki.neighborhood[o]?.sitemap;return Array.isArray(l)&&l.some(g=>g.slug===x)},w={},G=(o,l)=>{o in w||!N(o)||(w[o]=void 0,wiki.site(o).get(`${x}.json`,(g,b)=>{w[o]=!g&&Array.isArray(b?.journal)&&b.journal.some(S=>S?.type==="fork"),l()}))},V=o=>d?w[o]===!0:N(o);if(t.closest(".page").hasClass("remote")){t.html(`<div class=pod style="background-color:#eee;padding:15px"><center>${W(e.text)}<p class=caption>Pod is only available when viewed on a page's home wiki.</p></div>`);return}let _=m||f?"":`<center>${W(e.text)}`,z=f?"":"<p class=caption>gathering\u2026</p>";t.html(`<div class=pod style="position:relative;background-color:#eee;padding:15px">${_}${z}<div class=groups>${f?"<i>gathering\u2026</i>":""}</div></div>`);let A=o=>{let l={};for(let n of r)if(R.includes(n)){let a=o[n]||[];y[n]=a.map(c=>c.site),l[n]=a.map(c=>c.site+s);for(let c of l[n])wiki.neighborhoodObject.registerNeighbor(c)}else{let a=Object.keys(wiki.neighborhood);y[n]=a,l[n]=a}let g=()=>{let n=new Set,a=[];for(let c of r)for(let i of l[c])d&&G(i,T),!(n.has(i)||!V(i))&&(n.add(i),a.push(`<img class="remote" src="${wiki.site(i).flag()}" title="${i}" data-site="${i}" data-slug="${x}">`));return a},b=()=>{let n=new Set,a=[];for(let c of r)for(let i of l[c])n.has(i)||(n.add(i),a.push(`<img class="remote" src="${wiki.site(i).flag()}" title="${i}" data-site="${i}" data-slug="welcome-visitors">`));return a.join(" ")||"<i>none</i>"},S=()=>{let n=[];for(let a of r){let c=l[a].map(u=>{let q=s&&u.endsWith(s)?u.slice(0,-s.length):u,v=wiki.neighborhood[u]?.sitemap;return tt(q,v?v.length:0,v)}),i=R.includes(a)?"<tr><td><i>none</i>":"<tr><td><i>empty</i>";n.push(et(a,c.length?c:[i]))}return n.join(`
6
+ `)},T=()=>{if(m){let n=g();t.find(".groups").html(n.join(" ")),t.find(".caption").first().text(n.length?"":d?"no pod watchers yet":"no pod twins yet");return}if(f){let n=K?`${k} <br> `:"";t.find(".groups").html(n+b());return}t.find(".groups").html(S()),t.find(".caption").first().text("just updated")};T();let Y=()=>{let n=()=>Math.floor(Math.random()*0xffffffffffff).toString(16).padStart(12,"0"),a=[{type:"markdown",id:n(),text:`# ${k}`}];for(let i of r){let u=y[i]||[];a.push({type:"roster",id:n(),text:`${B[i]} Wikis
7
+
8
+ ${u.join(`
9
+ `)||"(none)"}`})}let c=wiki.newPage({title:k,story:a});wiki.showResult(c,{$page:t.parents(".page")})};if(f||!m){let n=$('<span class=pod-freeze-icon title="Display Rosters \u2014 save as a roster page" style="position:absolute;top:6px;right:9px;cursor:pointer;font-size:14px;opacity:.55">\u2744</span>');n.on("click",Y),t.find(".pod").append(n)}$("body").on("new-neighbor-done",(n,a)=>{if(m)return T();let c=t.find(`td[data-site="${a}"]`);if(!c.length)return;let i=wiki.neighborhood[a]?.sitemap;i&&c.text(`${i.length} pages ${U(i)}`)})},I=r.filter(o=>R.includes(o));if(I.length===0){A({});return}fetch(`/plugin/pod/roll?kinds=${encodeURIComponent(I.join(","))}`).then(o=>{if(!o.ok){let l=new Error(`HTTP ${o.status}`);throw l.status=o.status,l}return o.json()}).then(o=>A(o.groups||{})).catch(o=>{let l=o&&o.status===404?"pod server not loaded \u2014 the host wiki may need a restart or a newer wiki-server / Node":"server error";t.find(".caption").first().text(l)})},st=(t,e)=>{t.dblclick(()=>wiki.textEditor(t,e))};typeof window<"u"&&(window.plugins=window.plugins||{},window.plugins.pod={emit:nt,bind:st});})();
10
+ //# sourceMappingURL=pod.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/pod/commands.js", "../src/client/pod.js"],
4
+ "sourcesContent": ["// commands.js \u2014 the plugin's vocabulary, in one place.\n//\n// This module is the single source for what a pod item's text may say. The\n// client imports it to parse item text; the plugin's API declaration names it so\n// the farm reads the identical table. There is no second copy to drift, which is\n// the same move that put the plugin's thinking in a module and left only the\n// serving in its server.\n//\n// The item text convention is fedwiki-wide: UPPERCASE first word is a command,\n// lowercase is data, and a single trailing colon is optional. See the Fedwiki\n// DSL Skill.\n//\n// A DSL is a superset of an API, never a mirror of one \u2014 so every command\n// declares which KIND it is, and only some kinds cross to HTTP:\n//\n// parameter supplies an argument to the call \u2192 crosses\n// macro shorthand expanding to other commands \u2192 expands, then crosses\n// client-data the server cannot supply it at all \u2192 never crosses\n// presentation changes only how it is drawn \u2192 never crosses\n// alias another spelling of a command \u2192 resolves first\n//\n// Adding a command here is the whole change: the parser, the API's allowed\n// values, the rendered reference page and the plugin's own documentation all\n// read this table.\n\nexport const VOCABULARY_VERSION = 1\n\n/** The story item type these commands are written into. An agent needs this to author one. */\nexport const ITEM_TYPE = 'pod'\n\n/** What the item does when no command is recognised. */\nexport const FALLBACK = ['sisters']\n\nexport const COMMANDS = {\n // ---- parameter: these become values of the `kinds` parameter -------------\n SISTERS: {\n kind: 'parameter',\n parameter: 'kinds',\n value: 'sisters',\n label: 'Sisters',\n description: 'sibling sites sharing the parent domain',\n },\n PARENT: {\n kind: 'parameter',\n parameter: 'kinds',\n value: 'parent',\n label: 'Parent',\n description: 'the parent-domain site itself',\n },\n CHILDREN: {\n kind: 'parameter',\n parameter: 'kinds',\n value: 'children',\n label: 'Children',\n description: 'direct sub-domains of this site',\n },\n DESCENDANTS: {\n kind: 'parameter',\n parameter: 'kinds',\n value: 'descendants',\n label: 'Descendants',\n description: 'all sub-domains, any depth',\n },\n FARM: {\n kind: 'parameter',\n parameter: 'kinds',\n value: 'farm',\n label: 'Farm',\n description: 'every wiki in the farm',\n },\n\n // ---- macro ---------------------------------------------------------------\n POD: {\n kind: 'macro',\n expands: ['PARENT', 'SISTERS'],\n description: 'PARENT and SISTERS together (shorthand)',\n },\n\n // ---- client-data: gathered in the browser, invisible to any server -------\n NEIGHBOURHOOD: {\n kind: 'client-data',\n value: 'neighbourhood',\n label: 'Neighbourhood',\n description: 'the sites currently in your neighborhood',\n why: 'reads the neighbourhood the browser has already assembled, which no server can see',\n },\n NEIGHBORHOOD: { kind: 'alias', alias: 'NEIGHBOURHOOD', description: 'American spelling' },\n SNAPSHOT: {\n kind: 'client-data',\n value: 'snapshot',\n label: 'Snapshot',\n description: 'freeze the current neighborhood',\n why: 'same browser-only source as NEIGHBOURHOOD',\n },\n TWIN: {\n kind: 'client-data',\n description: \"show only members holding a page with this page's slug\",\n why: \"matches against each neighbour's own sitemap, fetched from the browser\",\n },\n WATCH: {\n kind: 'client-data',\n description: 'stricter TWIN \u2014 only members whose copy is a fork of this page',\n why: \"reads each candidate's page journal, one fetch per neighbour, from the browser\",\n },\n\n // ---- presentation: never leaves the client -------------------------------\n ROSTER: {\n kind: 'presentation',\n description: 'draw the gather compactly, mirroring a normal roster item',\n },\n TITLE: {\n kind: 'presentation',\n argument: 'yes|no',\n default: true,\n description: 'whether the ROSTER title shows',\n },\n}\n\n/** Values a trailing TITLE argument may take to mean \"no\". */\nconst FALSEY = ['no', 'false', 'off', '0', 'hide', 'none']\n\n/** Follow an alias to the command it spells. */\nexport const resolve = name => {\n const entry = COMMANDS[name]\n return entry && entry.kind === 'alias' ? COMMANDS[entry.alias] : entry\n}\n\n/** The canonical name a command resolves to, for reporting. */\nexport const canonical = name => {\n const entry = COMMANDS[name]\n return entry && entry.kind === 'alias' ? entry.alias : name\n}\n\n/**\n * The keyword a line begins with, normalised. UPPERCASE is canonical but input\n * case is forgiven, and one trailing colon is optional \u2014 never required.\n */\nexport const keywordOf = line =>\n String(line || '')\n .trim()\n .split(/\\s+/)[0]\n .replace(/:$/, '')\n .toUpperCase()\n\n/** Every value a parameter may take, in declaration order. The API's enum. */\nexport const valuesFor = parameter =>\n Object.values(COMMANDS)\n .filter(c => c.kind === 'parameter' && c.parameter === parameter && c.value)\n .map(c => c.value)\n\n/** Kinds gathered in the browser rather than asked of a server. */\nexport const clientValues = () =>\n Object.values(COMMANDS)\n .filter(c => c.kind === 'client-data' && c.value)\n .map(c => c.value)\n\n/** Display names, derived so a label is written once beside its command. */\nexport const labels = () => {\n const out = {}\n for (const c of Object.values(COMMANDS)) if (c.value && c.label) out[c.value] = c.label\n return out\n}\n\n/**\n * Item text to an ordered, de-duplicated list of gathered kinds.\n *\n * Macros expand in place, aliases resolve, unrecognised lines are ignored as\n * data rather than treated as an error. Default when nothing is recognised:\n * sisters.\n */\nexport const parseKinds = (text, { fallback = ['sisters'] } = {}) => {\n const kinds = []\n const take = name => {\n const entry = resolve(name)\n if (!entry) return\n if (entry.kind === 'macro') return entry.expands.forEach(take)\n if (!entry.value) return\n if (!kinds.includes(entry.value)) kinds.push(entry.value)\n }\n for (const line of String(text || '').split('\\n')) {\n if (!line.trim()) continue\n take(keywordOf(line))\n }\n return kinds.length ? kinds : [...fallback]\n}\n\n/** Whether a bare command appears on a line of its own. */\nexport const hasCommand = (text, name) =>\n String(text || '')\n .split('\\n')\n .some(line => canonical(keywordOf(line)) === name)\n\n/** TITLE yes|no \u2014 shown unless explicitly turned off. */\nexport const parseTitle = text => {\n for (const raw of String(text || '').split('\\n')) {\n const parts = raw.trim().split(/\\s+/)\n if (canonical(keywordOf(raw)) !== 'TITLE') continue\n return !FALSEY.includes((parts[1] || '').toLowerCase().replace(/:$/, ''))\n }\n return COMMANDS.TITLE.default\n}\n", "// wiki-plugin-pod \u2014 gather configurable pods of related wiki sites.\n//\n// Augments the Present plugin: instead of only listing sister sites, the item\n// text is a list of keywords choosing WHICH pods of sites to gather and\n// show. Each gathered site is registered as a neighbor so its pages join your\n// search and lineage. Reads the in-browser neighbourhood for the client-side\n// pods, and a small server route for the disk-derived ones.\n//\n// Recognised keywords (one per line, in item.text):\n// sisters sibling sites sharing the parent domain (server)\n// parent the parent-domain site itself (server)\n// children direct sub-domains of this site (server)\n// descendants all sub-domains, any depth (server)\n// farm every wiki in the farm (server)\n// neighbourhood the sites currently in your neighborhood (client)\n// snapshot freeze the current neighborhood (client, v0.1 == neighbourhood)\n//\n// Actions (not site-kinds):\n// ROSTER draw the gathered pod compactly, mirroring a normal roster\n// item \u2014 a left-aligned \"<commands> Rosters\" title over flowing\n// `img.remote` flags in the grey box, instead of the wide\n// tables. A \u2744 corner icon saves it as a roster ghost page.\n// TITLE yes|no whether the ROSTER title shows (default yes). TITLE no hides\n// it, leaving even padding around the flags.\n// TWIN show a roster of the pod members that hold a page with\n// THIS page's slug \u2014 existence-only, exactly like the\n// wiki-client Twins strip: a slug match in the neighbour's\n// sitemap, no journal or lineage check.\n// WATCH stricter TWIN: show only the members whose copy is actually a\n// fork of this page \u2014 its journal carries a `fork` event \u2014 i.e.\n// who is watching (tracking) your page. Fetches each candidate.\n//\n// TWIN and WATCH render like a roster item (flowing flags, the (sub)domain in the\n// hover tooltip); each flag links to that member's copy of the page. ROSTER is\n// the same compact style for the whole (unfiltered) gather. The wider table (no\n// display command) carries a small \u2744 icon that saves the roster page too, so\n// there is no separate FREEZE command.\n\n// The vocabulary is not defined here. It lives in ../pod/commands.js, which\n// the plugin's API declaration also names \u2014 so what an author may type and what\n// the mounted operation accepts come from one table and cannot drift apart.\nimport { valuesFor, clientValues, labels, parseKinds, hasCommand, parseTitle } from '../pod/commands.js'\n\nconst SERVER_KINDS = valuesFor('kinds')\nconst CLIENT_KINDS = clientValues()\nconst LABEL = labels()\n\nconst expand = text =>\n (text || '')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\\*(.+?)\\*/g, '<i>$1</i>')\n\nconst elapsed = ms => {\n const s = Math.floor(ms / 1000)\n const f = 1.9\n let out = 'a minute ago'\n if (s > f * 60) out = `${Math.round(s / 60)} minutes ago`\n if (s > f * 3600) out = `${Math.round(s / 3600)} hours ago`\n if (s > f * 86400) out = `${Math.round(s / 86400)} days ago`\n if (s > f * 604800) out = `${Math.round(s / 604800)} weeks ago`\n if (s > f * 2592000) out = `${Math.round(s / 2592000)} months ago`\n if (s > f * 31536000) out = `${Math.round(s / 31536000)} years ago`\n return out\n}\n\nconst freshness = sitemap => {\n if (!sitemap) return ''\n const dates = sitemap.map(p => p.date).filter(d => typeof d === 'number')\n return dates.length ? elapsed(Date.now() - Math.max(...dates)) : ''\n}\n\nconst portSuffix = () => ([80, '80', '', null].includes(location.port) ? '' : `:${location.port}`)\n\n// parseKinds, hasCommand and parseTitle come from the shared vocabulary above.\n// ROSTER, TWIN and WATCH are bare keywords on their own line, so they never\n// pollute the gathered kinds: ROSTER draws the whole gather compactly (with a\n// button to save it as a roster page); TWIN and WATCH switch the panel to a\n// roster of the pod members that hold this page (TWIN by slug, WATCH by an\n// actual fork event in the copy's journal).\nconst hasAction = hasCommand\n\n// Display name for a site row. Descendants of the viewing site keep their\n// whole relative name (david.pod.peoplepowered.money seen from\n// peoplepowered.money \u2192 \"david.pod\"); everything else shows its first label.\nconst shortName = site =>\n site.endsWith('.' + location.hostname)\n ? site.slice(0, -(location.hostname.length + 1))\n : site.split('.')[0]\n\nconst rowHtml = (site, pages, sitemap) => {\n const suffix = portSuffix()\n const short = shortName(site)\n const img =\n `<img class=remote title=\"${site}${suffix}\" src=\"//${site}${suffix}/favicon.png\" ` +\n `data-site=\"${site}${suffix}\" data-slug=welcome-visitors>`\n return (\n `<tr><td align=right>${short} ${img}` +\n `<td data-site=\"${site}${suffix}\">${pages} pages ${freshness(sitemap)}`\n )\n}\n\nconst groupHtml = (kind, rows) =>\n `<p class=caption><b>${LABEL[kind]}</b></p>` +\n `<table width=100% class=pod-${kind}>${rows.join('\\n')}</table>`\n\nexport const emit = (div, item) => {\n const suffix = portSuffix()\n const kinds = parseKinds(item.text)\n const watch = hasAction(item.text, 'WATCH')\n const twin = hasAction(item.text, 'TWIN')\n const roster = watch || twin // both render as a (filtered) roster of member flags\n const rosterCmd = hasAction(item.text, 'ROSTER') // compact display of the whole gather\n const showTitle = parseTitle(item.text) // TITLE yes/no \u2014 show the ROSTER title\n // Title for the ROSTER caption and the saved roster page \u2014 the commands\n // present, e.g. \"SISTERS Rosters\" / \"PARENT SISTERS Rosters\".\n const rostersTitle = `${kinds.map(k => k.toUpperCase()).join(' ')} Rosters`\n // slug of the page hosting this item \u2014 what TWIN/WATCH match against, the way\n // Twins matches the viewing page's slug across the neighbourhood. The page\n // div's id IS the slug (possibly with a _rev\u2026 suffix on a historical view);\n // .data('key') is the lineup key, NOT the slug, so don't use it here.\n const slug = (div.closest('.page').attr('id') || '').split('_rev')[0]\n // gathered pod, captured during render so saveRosters() can reuse it:\n // kind -> ordered list of full domain names\n const gathered = {}\n\n // TWIN test \u2014 existence-only: does this neighbour's already-loaded sitemap hold\n // a page with our slug? (Twins' inner test \u2014 no journal, no lineage.)\n const hasTwin = key => {\n const sm = wiki.neighborhood[key]?.sitemap\n return Array.isArray(sm) && sm.some(p => p.slug === slug)\n }\n\n // WATCH test \u2014 is this member watching us: their copy of this page carries a\n // `fork` event in its journal (fedwiki's term for a copy taken from ours).\n // Needs the page JSON, so results are fetched lazily and cached (true/false\n // once resolved, `in` even while the request is in flight). We only fetch where\n // the sitemap already shows the slug, so it costs one request per actual twin.\n const watchStatus = {}\n const ensureWatchChecked = (key, onDone) => {\n if (key in watchStatus || !hasTwin(key)) return\n watchStatus[key] = undefined // in-flight marker\n wiki.site(key).get(`${slug}.json`, (err, page) => {\n watchStatus[key] =\n !err && Array.isArray(page?.journal) && page.journal.some(a => a?.type === 'fork')\n onDone()\n })\n }\n const matches = key => (watch ? watchStatus[key] === true : hasTwin(key))\n\n if (div.closest('.page').hasClass('remote')) {\n div.html(\n `<div class=pod style=\"background-color:#eee;padding:15px\"><center>${expand(item.text)}` +\n `<p class=caption>Pod is only available when viewed on a page's home wiki.</p></div>`,\n )\n return\n }\n\n // The flag-display modes (ROSTER / TWIN / WATCH) carry their own caption, so\n // skip echoing the raw command text; the table keeps its echo. ROSTER also\n // drops the centred `.caption` line entirely \u2014 it renders its own left-aligned\n // title inside `.groups`, mirroring a roster item, so the box padding stays\n // even (important when TITLE is off).\n const echo = roster || rosterCmd ? '' : `<center>${expand(item.text)}`\n const status = rosterCmd ? '' : `<p class=caption>gathering\u2026</p>`\n div.html(\n `<div class=pod style=\"position:relative;background-color:#eee;padding:15px\">${echo}` +\n `${status}<div class=groups>${rosterCmd ? '<i>gathering\u2026</i>' : ''}</div></div>`,\n )\n\n const render = serverGroups => {\n // Candidate pod members per kind, as neighbourhood lookup keys (with the\n // port suffix). Server kinds are registered once here so their sitemaps load;\n // TWIN/WATCH later filter these candidates down to the matching members.\n const candidates = {}\n for (const kind of kinds) {\n if (SERVER_KINDS.includes(kind)) {\n const roll = serverGroups[kind] || []\n gathered[kind] = roll.map(r => r.site)\n candidates[kind] = roll.map(r => r.site + suffix)\n for (const key of candidates[kind]) wiki.neighborhoodObject.registerNeighbor(key)\n } else {\n // neighbourhood / snapshot \u2014 read the live in-browser neighbourhood\n const sites = Object.keys(wiki.neighborhood)\n gathered[kind] = sites\n candidates[kind] = sites\n }\n }\n\n // TWIN / WATCH render like a roster item: the matching members' flags flowing\n // inline, deduplicated across kinds, each linking to that member's copy of\n // THIS page. No titles are shown (every copy is the same page) \u2014 the\n // (sub)domain lives in the hover tooltip. Sitemaps (and, for WATCH, the\n // fetched page journals) arrive asynchronously, so this is re-run as they\n // resolve, which is when matches reveal themselves.\n const buildRoster = () => {\n const seen = new Set()\n const flags = []\n for (const kind of kinds) {\n for (const key of candidates[kind]) {\n if (watch) ensureWatchChecked(key, paint) // lazily confirm the fork event\n if (seen.has(key) || !matches(key)) continue\n seen.add(key)\n flags.push(\n `<img class=\"remote\" src=\"${wiki.site(key).flag()}\" ` +\n `title=\"${key}\" data-site=\"${key}\" data-slug=\"${slug}\">`,\n )\n }\n }\n return flags\n }\n\n // ROSTER \u2014 the whole gather as one compact roster: a single \"<commands>\n // Rosters\" caption (the panel's own .caption) over all members' flags. The\n // markup mirrors the wiki-plugin-roster item exactly: space-separated\n // `img.remote` flags in the panel's grey box \u2014 no extra wrapper \u2014 each\n // linking to that member's welcome page. The \u2744 icon saves it as a page.\n const buildRosterDisplay = () => {\n const seen = new Set()\n const flags = []\n for (const kind of kinds) {\n for (const key of candidates[kind]) {\n if (seen.has(key)) continue\n seen.add(key)\n flags.push(\n `<img class=\"remote\" src=\"${wiki.site(key).flag()}\" ` +\n `title=\"${key}\" data-site=\"${key}\" data-slug=\"welcome-visitors\">`,\n )\n }\n }\n return flags.join(' ') || '<i>none</i>'\n }\n\n // Non-roster: the full pod tables (favicon + name + page count + freshness).\n const buildGroups = () => {\n const html = []\n for (const kind of kinds) {\n const rows = candidates[kind].map(key => {\n const bare = suffix && key.endsWith(suffix) ? key.slice(0, -suffix.length) : key\n const sm = wiki.neighborhood[key]?.sitemap\n return rowHtml(bare, sm ? sm.length : 0, sm)\n })\n const empty = SERVER_KINDS.includes(kind) ? '<tr><td><i>none</i>' : '<tr><td><i>empty</i>'\n html.push(groupHtml(kind, rows.length ? rows : [empty]))\n }\n return html.join('\\n')\n }\n\n const paint = () => {\n if (roster) {\n // TWIN / WATCH \u2014 flat, filtered flags\n const flags = buildRoster()\n div.find('.groups').html(flags.join(' '))\n div.find('.caption').first().text(\n flags.length ? '' : watch ? 'no pod watchers yet' : 'no pod twins yet',\n )\n return\n }\n if (rosterCmd) {\n // Mirror a roster item: an optional left-aligned title, then a <br>, then\n // the flags \u2014 all in the panel's grey box. TITLE off \u2192 flags only, even\n // padding all round.\n const title = showTitle ? `${rostersTitle} <br> ` : ''\n div.find('.groups').html(title + buildRosterDisplay())\n return\n }\n div.find('.groups').html(buildGroups())\n div.find('.caption').first().text('just updated')\n }\n paint()\n\n // Save the gathered pod as a roster ghost page titled after the commands\n // (e.g. \"SISTERS Rosters\"), one roster item per kind. No FREEZE command any\n // more: it's reached from the \u2744 icon tucked into the corner of the ROSTER\n // and wide-table views.\n const saveRosters = () => {\n const hexId = () =>\n Math.floor(Math.random() * 0xffffffffffff).toString(16).padStart(12, '0')\n const story = [{ type: 'markdown', id: hexId(), text: `# ${rostersTitle}` }]\n for (const kind of kinds) {\n const domains = gathered[kind] || []\n story.push({\n type: 'roster',\n id: hexId(),\n text: `${LABEL[kind]} Wikis\\n\\n${domains.join('\\n') || '(none)'}`,\n })\n }\n const page = wiki.newPage({ title: rostersTitle, story })\n wiki.showResult(page, { $page: div.parents('.page') })\n }\n\n // Freeze affordance \u2014 a subtle \u2744 icon in the panel corner, on the ROSTER and\n // the wide-table views (TWIN / WATCH are monitoring views, so they get none).\n if (rosterCmd || !roster) {\n const icon = $(\n '<span class=pod-freeze-icon title=\"Display Rosters \u2014 save as a roster page\" ' +\n 'style=\"position:absolute;top:6px;right:9px;cursor:pointer;font-size:14px;opacity:.55\">\u2744</span>',\n )\n icon.on('click', saveRosters)\n div.find('.pod').append(icon)\n }\n\n // As each neighbour's sitemap loads: in TWIN/WATCH mode repaint, since a match\n // may have just appeared (a member whose sitemap now shows our slug \u2014 and for\n // WATCH that also kicks off the journal fetch); otherwise just backfill that\n // member's page-count / freshness cell.\n $('body').on('new-neighbor-done', (e, site) => {\n if (roster) return paint()\n const cell = div.find(`td[data-site=\"${site}\"]`)\n if (!cell.length) return\n const sm = wiki.neighborhood[site]?.sitemap\n if (sm) cell.text(`${sm.length} pages ${freshness(sm)}`)\n })\n }\n\n const serverKinds = kinds.filter(k => SERVER_KINDS.includes(k))\n if (serverKinds.length === 0) {\n render({})\n return\n }\n\n fetch(`/plugin/pod/roll?kinds=${encodeURIComponent(serverKinds.join(','))}`)\n .then(res => {\n if (!res.ok) {\n const err = new Error(`HTTP ${res.status}`)\n err.status = res.status\n throw err\n }\n return res.json()\n })\n .then(data => render(data.groups || {}))\n .catch(err => {\n // A 404 means the plugin's server component never registered its route \u2014\n // almost always an old wiki-server / Node combination that couldn't load\n // server/server.js (see this plugin's server notes). Say so, rather than a\n // generic error, so the operator knows where to look.\n const msg =\n err && err.status === 404\n ? 'pod server not loaded \u2014 the host wiki may need a restart or a newer wiki-server / Node'\n : 'server error'\n div.find('.caption').first().text(msg)\n })\n}\n\nexport const bind = (div, item) => {\n div.dblclick(() => wiki.textEditor(div, item))\n}\n\nif (typeof window !== 'undefined') {\n window.plugins = window.plugins || {}\n window.plugins.pod = { emit, bind }\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["COMMANDS", "FALSEY", "resolve", "name", "entry", "canonical", "keywordOf", "line", "valuesFor", "parameter", "c", "clientValues", "labels", "out", "parseKinds", "text", "fallback", "kinds", "take", "hasCommand", "parseTitle", "raw", "parts", "SERVER_KINDS", "valuesFor", "CLIENT_KINDS", "clientValues", "LABEL", "labels", "expand", "text", "elapsed", "ms", "s", "f", "out", "freshness", "sitemap", "dates", "p", "d", "portSuffix", "hasAction", "hasCommand", "shortName", "site", "rowHtml", "pages", "suffix", "short", "img", "groupHtml", "kind", "rows", "emit", "div", "item", "kinds", "parseKinds", "watch", "twin", "roster", "rosterCmd", "showTitle", "parseTitle", "rostersTitle", "k", "slug", "gathered", "hasTwin", "key", "sm", "watchStatus", "ensureWatchChecked", "onDone", "err", "page", "a", "matches", "echo", "status", "render", "serverGroups", "candidates", "roll", "r", "sites", "buildRoster", "seen", "flags", "paint", "buildRosterDisplay", "buildGroups", "html", "bare", "empty", "title", "saveRosters", "hexId", "story", "domains", "icon", "e", "cell", "serverKinds", "res", "data", "msg", "bind"]
7
+ }
package/factory.json ADDED
@@ -0,0 +1 @@
1
+ {"name": "Pod", "title": "Gather pods of related wiki sites — sisters, parent, children, farm, neighbourhood", "category": "other"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "wiki-plugin-pod",
3
+ "version": "1.0.0",
4
+ "description": "Federated Wiki - Pod Plugin — gather configurable pods of related wiki sites (sisters, parent, children, farm, neighbourhood) into your neighborhood",
5
+ "type": "module",
6
+ "main": "client/pod.js",
7
+ "files": [
8
+ "api",
9
+ "client",
10
+ "pages",
11
+ "server",
12
+ "src",
13
+ "factory.json"
14
+ ],
15
+ "scripts": {
16
+ "build": "node scripts/build.js",
17
+ "test": "node test/vocabulary.mjs"
18
+ },
19
+ "keywords": [
20
+ "pod",
21
+ "wiki",
22
+ "federated wiki",
23
+ "plugin",
24
+ "neighborhood",
25
+ "sisters"
26
+ ],
27
+ "author": "David Bovill",
28
+ "license": "MIT",
29
+ "devDependencies": {
30
+ "esbuild": "^0.25.0"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-pod.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-pod/issues"
38
+ },
39
+ "homepage": "https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-pod#readme",
40
+ "fedwiki": {
41
+ "api": {
42
+ "version": 1,
43
+ "spec": "api/openapi.json",
44
+ "handlers": "api/handlers.js",
45
+ "vocabulary": "src/pod/commands.js"
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,157 @@
1
+ {
2
+ "title": "About Pod Plugin",
3
+ "story": [
4
+ {
5
+ "type": "markdown",
6
+ "id": "be5122a8320d633c",
7
+ "text": "This plugin, type: **pod**, gathers pods of related wiki sites and adds them to your neighborhood. It augments the Present plugin: instead of only listing sister sites, you choose *which* pods to gather."
8
+ },
9
+ {
10
+ "type": "pod",
11
+ "id": "01b9d66d009aa2c7",
12
+ "text": "POD"
13
+ },
14
+ {
15
+ "type": "markdown",
16
+ "id": "625ddc4e896c6e27",
17
+ "text": "Write one **command** per line (UPPERCASE, an optional trailing colon \u2014 see the [[Fedwiki DSL Skill]] convention):\n\n- `POD` \u2014 `PARENT` and `SISTERS` together (shorthand)\n- `SISTERS` \u2014 sibling sites sharing the parent domain\n- `PARENT` \u2014 the parent-domain site itself\n- `CHILDREN` \u2014 direct sub-domains of this site\n- `DESCENDANTS` \u2014 all sub-domains, any depth\n- `FARM` \u2014 every wiki in the farm\n- `NEIGHBOURHOOD` \u2014 the sites currently in your neighborhood\n- `SNAPSHOT` \u2014 the current neighborhood *(same as NEIGHBOURHOOD)*\n\nAnd the **actions** (not site-kinds):\n\n- `ROSTER` \u2014 draw the gather compactly, mirroring a normal roster item\n- `TWIN` \u2014 only members that hold a page with **this page's slug**\n- `WATCH` \u2014 only members whose copy is *actually a fork* of it\n- `TITLE yes|no` \u2014 whether the `ROSTER` title shows (default yes)"
18
+ },
19
+ {
20
+ "type": "markdown",
21
+ "id": "7c1a4e0b9f2d5a86",
22
+ "text": "### ROSTER, TWIN and WATCH \u2014 compact rosters of flags\n\nAll three render like a **roster item**: the members' flags flow inline in the grey box, each linking to that member's copy of the page, the (sub)domain on hover. `ROSTER` shows the whole gather (mirroring a normal roster, with an optional left-aligned title toggled by `TITLE yes|no`); `TWIN`/`WATCH` filter it:\n\n- **`TWIN`** is **existence-only** \u2014 a slug match in the member's sitemap, like the wiki-client *Twins* strip.\n- **`WATCH`** is **lineage-aware** \u2014 it fetches each twin's page JSON and keeps only those whose journal carries a `fork` event (`WATCH \u2286 TWIN`): who is watching your page.\n\nThe `ROSTER` and table views carry a subtle **\u2744 icon** that saves the gather as a roster ghost page titled after the commands (e.g. `PARENT SISTERS Rosters`). No separate `FREEZE` command.\n\n```\nSISTERS\nWATCH\n```"
23
+ },
24
+ {
25
+ "type": "markdown",
26
+ "id": "cedff82dadcf82fd",
27
+ "text": "Each gathered site is registered as a neighbor, so its pages join your search and lineage. The page still reads normally without the plugin."
28
+ },
29
+ {
30
+ "type": "markdown",
31
+ "id": "c41e9b70a5d8362f",
32
+ "text": "# Which Commands Cross\n\nA command language is a superset of an interface, never a mirror of one. Of the thirteen words below, five become a value of the `kinds` parameter and one is shorthand for two of those; the rest never leave the browser \u2014 `ROSTER` and `TITLE` because they are drawing choices the farm has no opinion about, and `NEIGHBOURHOOD`, `SNAPSHOT`, `TWIN` and `WATCH` because they read what only a browser holds. That last group is not a gap to be closed: no server could answer them however this plugin were written.\n\nThe table saying which is which lives in one module. The parser imports it and the plugin's interface declaration names it, so what an author may type and what the mounted operation accepts are one list read twice. See [[Pod API]] for the rendered version, and [[About Farm Plugin]] for how a declaration becomes a route."
33
+ },
34
+ {
35
+ "type": "markdown",
36
+ "id": "47fd6339cb39a685",
37
+ "text": "## Repositories\n\n- **GitHub**: [Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-pod](https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-pod)\n- **npm**: [wiki-plugin-pod](https://www.npmjs.com/package/wiki-plugin-pod) \u2014 `npm install wiki-plugin-pod`"
38
+ },
39
+ {
40
+ "type": "markdown",
41
+ "id": "19d5c8a03e6b7412",
42
+ "text": "# See\n\n[[Pod API]] \u00b7 [[Farm API]] \u00b7 [[About Farm Plugin]] \u00b7 [[Fedwiki DSL Skill]]"
43
+ }
44
+ ],
45
+ "journal": [
46
+ {
47
+ "type": "create",
48
+ "item": {
49
+ "title": "About Pod Plugin",
50
+ "story": [
51
+ {
52
+ "type": "markdown",
53
+ "id": "be5122a8320d633c",
54
+ "text": "This plugin, type: **pod**, gathers pods of related wiki sites and adds them to your neighborhood. It augments the Present plugin: instead of only listing sister sites, you choose *which* pods to gather."
55
+ },
56
+ {
57
+ "type": "pod",
58
+ "id": "01b9d66d009aa2c7",
59
+ "text": "POD"
60
+ },
61
+ {
62
+ "type": "markdown",
63
+ "id": "625ddc4e896c6e27",
64
+ "text": "Write one **command** per line (UPPERCASE, an optional trailing colon \u2014 see the [[Fedwiki DSL Skill]] convention):\n\n- `POD` \u2014 `PARENT` and `SISTERS` together (shorthand)\n- `SISTERS` \u2014 sibling sites sharing the parent domain\n- `PARENT` \u2014 the parent-domain site itself\n- `CHILDREN` \u2014 direct sub-domains of this site\n- `DESCENDANTS` \u2014 all sub-domains, any depth\n- `FARM` \u2014 every wiki in the farm\n- `NEIGHBOURHOOD` \u2014 the sites currently in your neighborhood\n- `SNAPSHOT` \u2014 freeze the current neighborhood *(same as NEIGHBOURHOOD)*"
65
+ },
66
+ {
67
+ "type": "markdown",
68
+ "id": "cedff82dadcf82fd",
69
+ "text": "Each gathered site is registered as a neighbor, so its pages join your search and lineage. The page still reads normally without the plugin."
70
+ },
71
+ {
72
+ "type": "markdown",
73
+ "id": "47fd6339cb39a685",
74
+ "text": "## Repositories\n\n- **GitHub**: [Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-pod](https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-pod)\n- **npm**: [wiki-plugin-pod](https://www.npmjs.com/package/wiki-plugin-pod) \u2014 `npm install wiki-plugin-pod`"
75
+ }
76
+ ]
77
+ },
78
+ "date": 1782310000000,
79
+ "provenance": "Authored by an AI agent \u2014 Claude Code (claude-opus-4-8) following the fedwiki-create-plugin skill.",
80
+ "plugin": "pod"
81
+ },
82
+ {
83
+ "type": "edit",
84
+ "id": "625ddc4e896c6e27",
85
+ "item": {
86
+ "type": "markdown",
87
+ "id": "625ddc4e896c6e27",
88
+ "text": "Write one **command** per line (UPPERCASE, an optional trailing colon \u2014 see the [[Fedwiki DSL Skill]] convention):\n\n- `POD` \u2014 `PARENT` and `SISTERS` together (shorthand)\n- `SISTERS` \u2014 sibling sites sharing the parent domain\n- `PARENT` \u2014 the parent-domain site itself\n- `CHILDREN` \u2014 direct sub-domains of this site\n- `DESCENDANTS` \u2014 all sub-domains, any depth\n- `FARM` \u2014 every wiki in the farm\n- `NEIGHBOURHOOD` \u2014 the sites currently in your neighborhood\n- `SNAPSHOT` \u2014 freeze the current neighborhood *(same as NEIGHBOURHOOD)*\n\nAnd one **action** (not a site-kind):\n\n- `FREEZE` \u2014 show a button that saves the gathered pod as a **roster ghost page** (one roster item per kind), so an ephemeral panel becomes a forkable page."
89
+ },
90
+ "date": 1782642651716
91
+ },
92
+ {
93
+ "type": "edit",
94
+ "id": "625ddc4e896c6e27",
95
+ "item": {
96
+ "type": "markdown",
97
+ "id": "625ddc4e896c6e27",
98
+ "text": "Write one **command** per line (UPPERCASE, an optional trailing colon \u2014 see the [[Fedwiki DSL Skill]] convention):\n\n- `POD` \u2014 `PARENT` and `SISTERS` together (shorthand)\n- `SISTERS` \u2014 sibling sites sharing the parent domain\n- `PARENT` \u2014 the parent-domain site itself\n- `CHILDREN` \u2014 direct sub-domains of this site\n- `DESCENDANTS` \u2014 all sub-domains, any depth\n- `FARM` \u2014 every wiki in the farm\n- `NEIGHBOURHOOD` \u2014 the sites currently in your neighborhood\n- `SNAPSHOT` \u2014 freeze the current neighborhood *(same as NEIGHBOURHOOD)*\n\nAnd two **actions** (not site-kinds):\n\n- `FREEZE` \u2014 show a button that saves the gathered pod as a **roster ghost page** (one roster item per kind), so an ephemeral panel becomes a forkable page.\n- `FORK` \u2014 filter the gathered pod down to the members whose sitemap already holds **this page's slug** \u2014 who in your pod has *forked* the page you are viewing."
99
+ },
100
+ "date": 1784200000000
101
+ },
102
+ {
103
+ "type": "add",
104
+ "id": "7c1a4e0b9f2d5a86",
105
+ "after": "625ddc4e896c6e27",
106
+ "item": {
107
+ "type": "markdown",
108
+ "id": "7c1a4e0b9f2d5a86",
109
+ "text": "### FORK \u2014 a fork roster\n\n`FORK` is an **existence-only** check, exactly like the wiki-client *Twins* strip: a slug match in a pod member's sitemap, with no journal or lineage inspection. A group with no match is hidden entirely, so the panel only grows an element when a pod member has actually forked the page \u2014 a quiet way to watch who in your pod is twinning your work. Each fork row's flag links straight to that member's copy.\n\n```\nSISTERS\nFORK\n```"
110
+ },
111
+ "date": 1784200000000,
112
+ "provenance": "Authored by an AI agent \u2014 Claude Code (claude-opus-4-8) adding the FORK action."
113
+ },
114
+ {
115
+ "type": "edit",
116
+ "id": "625ddc4e896c6e27",
117
+ "item": {
118
+ "type": "markdown",
119
+ "id": "625ddc4e896c6e27",
120
+ "text": "Write one **command** per line (UPPERCASE, an optional trailing colon \u2014 see the [[Fedwiki DSL Skill]] convention):\n\n- `POD` \u2014 `PARENT` and `SISTERS` together (shorthand)\n- `SISTERS` \u2014 sibling sites sharing the parent domain\n- `PARENT` \u2014 the parent-domain site itself\n- `CHILDREN` \u2014 direct sub-domains of this site\n- `DESCENDANTS` \u2014 all sub-domains, any depth\n- `FARM` \u2014 every wiki in the farm\n- `NEIGHBOURHOOD` \u2014 the sites currently in your neighborhood\n- `SNAPSHOT` \u2014 freeze the current neighborhood *(same as NEIGHBOURHOOD)*\n\nAnd the **actions** (not site-kinds):\n\n- `FREEZE` \u2014 show a button that saves the gathered pod as a **roster ghost page** (one roster item per kind), so an ephemeral panel becomes a forkable page.\n- `TWIN` \u2014 show a roster of the pod members that hold a page with **this page's slug**.\n- `FORK` \u2014 show a roster of the pod members whose copy of this page is *actually a fork* of it."
121
+ },
122
+ "date": 1784300000000
123
+ },
124
+ {
125
+ "type": "edit",
126
+ "id": "7c1a4e0b9f2d5a86",
127
+ "item": {
128
+ "type": "markdown",
129
+ "id": "7c1a4e0b9f2d5a86",
130
+ "text": "### TWIN and FORK \u2014 a roster of who else holds this page\n\nBoth turn the panel into a compact **roster of flags** (like a roster item): the matching members' flags flow inline, each linking to that member's copy of the page, the (sub)domain shown on hover. They differ only in strictness:\n\n- **`TWIN`** is **existence-only**, exactly like the wiki-client *Twins* strip \u2014 a slug match in the member's sitemap, read straight from the neighbourhood.\n- **`FORK`** is **lineage-aware** \u2014 it fetches each twin's page JSON and keeps only those whose journal carries a `fork` event. So a page that merely shares the name is a *twin*, not a *fork* (`FORK \u2286 TWIN`).\n\n```\nSISTERS\nFORK\n```"
131
+ },
132
+ "date": 1784300000000,
133
+ "provenance": "Authored by an AI agent \u2014 Claude Code (claude-opus-4-8) splitting TWIN (existence) from FORK (journal fork event) and switching to a roster-of-flags display."
134
+ },
135
+ {
136
+ "type": "edit",
137
+ "id": "625ddc4e896c6e27",
138
+ "item": {
139
+ "type": "markdown",
140
+ "id": "625ddc4e896c6e27",
141
+ "text": "Write one **command** per line (UPPERCASE, an optional trailing colon \u2014 see the [[Fedwiki DSL Skill]] convention):\n\n- `POD` \u2014 `PARENT` and `SISTERS` together (shorthand)\n- `SISTERS` \u2014 sibling sites sharing the parent domain\n- `PARENT` \u2014 the parent-domain site itself\n- `CHILDREN` \u2014 direct sub-domains of this site\n- `DESCENDANTS` \u2014 all sub-domains, any depth\n- `FARM` \u2014 every wiki in the farm\n- `NEIGHBOURHOOD` \u2014 the sites currently in your neighborhood\n- `SNAPSHOT` \u2014 the current neighborhood *(same as NEIGHBOURHOOD)*\n\nAnd the **actions** (not site-kinds):\n\n- `ROSTER` \u2014 draw the gather compactly, mirroring a normal roster item\n- `TWIN` \u2014 only members that hold a page with **this page's slug**\n- `WATCH` \u2014 only members whose copy is *actually a fork* of it\n- `TITLE yes|no` \u2014 whether the `ROSTER` title shows (default yes)"
142
+ },
143
+ "date": 1784400000000
144
+ },
145
+ {
146
+ "type": "edit",
147
+ "id": "7c1a4e0b9f2d5a86",
148
+ "item": {
149
+ "type": "markdown",
150
+ "id": "7c1a4e0b9f2d5a86",
151
+ "text": "### ROSTER, TWIN and WATCH \u2014 compact rosters of flags\n\nAll three render like a **roster item**: the members' flags flow inline in the grey box, each linking to that member's copy of the page, the (sub)domain on hover. `ROSTER` shows the whole gather (mirroring a normal roster, with an optional left-aligned title toggled by `TITLE yes|no`); `TWIN`/`WATCH` filter it:\n\n- **`TWIN`** is **existence-only** \u2014 a slug match in the member's sitemap, like the wiki-client *Twins* strip.\n- **`WATCH`** is **lineage-aware** \u2014 it fetches each twin's page JSON and keeps only those whose journal carries a `fork` event (`WATCH \u2286 TWIN`): who is watching your page.\n\nThe `ROSTER` and table views carry a subtle **\u2744 icon** that saves the gather as a roster ghost page titled after the commands (e.g. `PARENT SISTERS Rosters`). No separate `FREEZE` command.\n\n```\nSISTERS\nWATCH\n```"
152
+ },
153
+ "date": 1784400000000
154
+ }
155
+ ],
156
+ "plugin": "pod"
157
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,47 @@
1
+ // pod plugin — server-side component.
2
+ //
3
+ // A thin caller now. Everything it used to know how to do lives in
4
+ // ../src/pod/roll.js as plain functions; this file only turns a request into
5
+ // arguments and a result into JSON. The route it serves is unchanged, so the
6
+ // shipped client keeps calling the address it always called — the migration
7
+ // adds a second way in without disturbing the first.
8
+ //
9
+ // Started on the `running-serv` event with { argv, app }.
10
+ //
11
+ // NOTE: authored as CommonJS on purpose. wiki-server loads a plugin's
12
+ // server/server.js with require() (older releases) or import() (newer); CJS
13
+ // is the only format that works under BOTH, on every Node version. An ESM
14
+ // server.js throws ERR_REQUIRE_ESM on the require() loader (Node < 22.12),
15
+ // and the wiki swallows that error, so the plugin's routes silently vanish.
16
+ // The sibling server/package.json ({"type":"commonjs"}) makes Node treat this
17
+ // file as CJS even though the plugin's root package.json is "type":"module",
18
+ // so the rest of the plugin (src/, tests, build) can stay ESM.
19
+ //
20
+ // The shared module is ESM, and is reached by dynamic import() rather than
21
+ // require(). Dynamic import works from CommonJS on every Node that runs the
22
+ // wiki, while require() of an ES module only works from Node 22.12 — so this is
23
+ // the one form that needs no version to be true.
24
+
25
+ const path = require('node:path')
26
+
27
+ const startServer = ({ argv, app }) => {
28
+ // argv.status = {farmRoot}/{thisDomain}/status
29
+ const farmRoot = path.dirname(path.dirname(argv.status))
30
+ const origin = path.basename(path.dirname(argv.status)) // e.g. demoscene.localhost
31
+
32
+ // Imported once, awaited per request. Kept as the promise so a slow or failed
33
+ // load cannot delay the route being registered.
34
+ const thinking = import('../src/pod/roll.js')
35
+
36
+ app.get('/plugin/pod/roll', async (req, res) => {
37
+ try {
38
+ const { roll } = await thinking
39
+ res.json(await roll({ kinds: req.query.kinds || 'sisters', origin, farmRoot }))
40
+ } catch (e) {
41
+ console.log('pod plugin: roll failed —', e?.stack || e)
42
+ res.status(500).json({ error: e.message })
43
+ }
44
+ })
45
+ }
46
+
47
+ module.exports = { startServer }
@@ -0,0 +1,353 @@
1
+ // wiki-plugin-pod — gather configurable pods of related wiki sites.
2
+ //
3
+ // Augments the Present plugin: instead of only listing sister sites, the item
4
+ // text is a list of keywords choosing WHICH pods of sites to gather and
5
+ // show. Each gathered site is registered as a neighbor so its pages join your
6
+ // search and lineage. Reads the in-browser neighbourhood for the client-side
7
+ // pods, and a small server route for the disk-derived ones.
8
+ //
9
+ // Recognised keywords (one per line, in item.text):
10
+ // sisters sibling sites sharing the parent domain (server)
11
+ // parent the parent-domain site itself (server)
12
+ // children direct sub-domains of this site (server)
13
+ // descendants all sub-domains, any depth (server)
14
+ // farm every wiki in the farm (server)
15
+ // neighbourhood the sites currently in your neighborhood (client)
16
+ // snapshot freeze the current neighborhood (client, v0.1 == neighbourhood)
17
+ //
18
+ // Actions (not site-kinds):
19
+ // ROSTER draw the gathered pod compactly, mirroring a normal roster
20
+ // item — a left-aligned "<commands> Rosters" title over flowing
21
+ // `img.remote` flags in the grey box, instead of the wide
22
+ // tables. A ❄ corner icon saves it as a roster ghost page.
23
+ // TITLE yes|no whether the ROSTER title shows (default yes). TITLE no hides
24
+ // it, leaving even padding around the flags.
25
+ // TWIN show a roster of the pod members that hold a page with
26
+ // THIS page's slug — existence-only, exactly like the
27
+ // wiki-client Twins strip: a slug match in the neighbour's
28
+ // sitemap, no journal or lineage check.
29
+ // WATCH stricter TWIN: show only the members whose copy is actually a
30
+ // fork of this page — its journal carries a `fork` event — i.e.
31
+ // who is watching (tracking) your page. Fetches each candidate.
32
+ //
33
+ // TWIN and WATCH render like a roster item (flowing flags, the (sub)domain in the
34
+ // hover tooltip); each flag links to that member's copy of the page. ROSTER is
35
+ // the same compact style for the whole (unfiltered) gather. The wider table (no
36
+ // display command) carries a small ❄ icon that saves the roster page too, so
37
+ // there is no separate FREEZE command.
38
+
39
+ // The vocabulary is not defined here. It lives in ../pod/commands.js, which
40
+ // the plugin's API declaration also names — so what an author may type and what
41
+ // the mounted operation accepts come from one table and cannot drift apart.
42
+ import { valuesFor, clientValues, labels, parseKinds, hasCommand, parseTitle } from '../pod/commands.js'
43
+
44
+ const SERVER_KINDS = valuesFor('kinds')
45
+ const CLIENT_KINDS = clientValues()
46
+ const LABEL = labels()
47
+
48
+ const expand = text =>
49
+ (text || '')
50
+ .replace(/&/g, '&amp;')
51
+ .replace(/</g, '&lt;')
52
+ .replace(/>/g, '&gt;')
53
+ .replace(/\*(.+?)\*/g, '<i>$1</i>')
54
+
55
+ const elapsed = ms => {
56
+ const s = Math.floor(ms / 1000)
57
+ const f = 1.9
58
+ let out = 'a minute ago'
59
+ if (s > f * 60) out = `${Math.round(s / 60)} minutes ago`
60
+ if (s > f * 3600) out = `${Math.round(s / 3600)} hours ago`
61
+ if (s > f * 86400) out = `${Math.round(s / 86400)} days ago`
62
+ if (s > f * 604800) out = `${Math.round(s / 604800)} weeks ago`
63
+ if (s > f * 2592000) out = `${Math.round(s / 2592000)} months ago`
64
+ if (s > f * 31536000) out = `${Math.round(s / 31536000)} years ago`
65
+ return out
66
+ }
67
+
68
+ const freshness = sitemap => {
69
+ if (!sitemap) return ''
70
+ const dates = sitemap.map(p => p.date).filter(d => typeof d === 'number')
71
+ return dates.length ? elapsed(Date.now() - Math.max(...dates)) : ''
72
+ }
73
+
74
+ const portSuffix = () => ([80, '80', '', null].includes(location.port) ? '' : `:${location.port}`)
75
+
76
+ // parseKinds, hasCommand and parseTitle come from the shared vocabulary above.
77
+ // ROSTER, TWIN and WATCH are bare keywords on their own line, so they never
78
+ // pollute the gathered kinds: ROSTER draws the whole gather compactly (with a
79
+ // button to save it as a roster page); TWIN and WATCH switch the panel to a
80
+ // roster of the pod members that hold this page (TWIN by slug, WATCH by an
81
+ // actual fork event in the copy's journal).
82
+ const hasAction = hasCommand
83
+
84
+ // Display name for a site row. Descendants of the viewing site keep their
85
+ // whole relative name (david.pod.peoplepowered.money seen from
86
+ // peoplepowered.money → "david.pod"); everything else shows its first label.
87
+ const shortName = site =>
88
+ site.endsWith('.' + location.hostname)
89
+ ? site.slice(0, -(location.hostname.length + 1))
90
+ : site.split('.')[0]
91
+
92
+ const rowHtml = (site, pages, sitemap) => {
93
+ const suffix = portSuffix()
94
+ const short = shortName(site)
95
+ const img =
96
+ `<img class=remote title="${site}${suffix}" src="//${site}${suffix}/favicon.png" ` +
97
+ `data-site="${site}${suffix}" data-slug=welcome-visitors>`
98
+ return (
99
+ `<tr><td align=right>${short} ${img}` +
100
+ `<td data-site="${site}${suffix}">${pages} pages ${freshness(sitemap)}`
101
+ )
102
+ }
103
+
104
+ const groupHtml = (kind, rows) =>
105
+ `<p class=caption><b>${LABEL[kind]}</b></p>` +
106
+ `<table width=100% class=pod-${kind}>${rows.join('\n')}</table>`
107
+
108
+ export const emit = (div, item) => {
109
+ const suffix = portSuffix()
110
+ const kinds = parseKinds(item.text)
111
+ const watch = hasAction(item.text, 'WATCH')
112
+ const twin = hasAction(item.text, 'TWIN')
113
+ const roster = watch || twin // both render as a (filtered) roster of member flags
114
+ const rosterCmd = hasAction(item.text, 'ROSTER') // compact display of the whole gather
115
+ const showTitle = parseTitle(item.text) // TITLE yes/no — show the ROSTER title
116
+ // Title for the ROSTER caption and the saved roster page — the commands
117
+ // present, e.g. "SISTERS Rosters" / "PARENT SISTERS Rosters".
118
+ const rostersTitle = `${kinds.map(k => k.toUpperCase()).join(' ')} Rosters`
119
+ // slug of the page hosting this item — what TWIN/WATCH match against, the way
120
+ // Twins matches the viewing page's slug across the neighbourhood. The page
121
+ // div's id IS the slug (possibly with a _rev… suffix on a historical view);
122
+ // .data('key') is the lineup key, NOT the slug, so don't use it here.
123
+ const slug = (div.closest('.page').attr('id') || '').split('_rev')[0]
124
+ // gathered pod, captured during render so saveRosters() can reuse it:
125
+ // kind -> ordered list of full domain names
126
+ const gathered = {}
127
+
128
+ // TWIN test — existence-only: does this neighbour's already-loaded sitemap hold
129
+ // a page with our slug? (Twins' inner test — no journal, no lineage.)
130
+ const hasTwin = key => {
131
+ const sm = wiki.neighborhood[key]?.sitemap
132
+ return Array.isArray(sm) && sm.some(p => p.slug === slug)
133
+ }
134
+
135
+ // WATCH test — is this member watching us: their copy of this page carries a
136
+ // `fork` event in its journal (fedwiki's term for a copy taken from ours).
137
+ // Needs the page JSON, so results are fetched lazily and cached (true/false
138
+ // once resolved, `in` even while the request is in flight). We only fetch where
139
+ // the sitemap already shows the slug, so it costs one request per actual twin.
140
+ const watchStatus = {}
141
+ const ensureWatchChecked = (key, onDone) => {
142
+ if (key in watchStatus || !hasTwin(key)) return
143
+ watchStatus[key] = undefined // in-flight marker
144
+ wiki.site(key).get(`${slug}.json`, (err, page) => {
145
+ watchStatus[key] =
146
+ !err && Array.isArray(page?.journal) && page.journal.some(a => a?.type === 'fork')
147
+ onDone()
148
+ })
149
+ }
150
+ const matches = key => (watch ? watchStatus[key] === true : hasTwin(key))
151
+
152
+ if (div.closest('.page').hasClass('remote')) {
153
+ div.html(
154
+ `<div class=pod style="background-color:#eee;padding:15px"><center>${expand(item.text)}` +
155
+ `<p class=caption>Pod is only available when viewed on a page's home wiki.</p></div>`,
156
+ )
157
+ return
158
+ }
159
+
160
+ // The flag-display modes (ROSTER / TWIN / WATCH) carry their own caption, so
161
+ // skip echoing the raw command text; the table keeps its echo. ROSTER also
162
+ // drops the centred `.caption` line entirely — it renders its own left-aligned
163
+ // title inside `.groups`, mirroring a roster item, so the box padding stays
164
+ // even (important when TITLE is off).
165
+ const echo = roster || rosterCmd ? '' : `<center>${expand(item.text)}`
166
+ const status = rosterCmd ? '' : `<p class=caption>gathering…</p>`
167
+ div.html(
168
+ `<div class=pod style="position:relative;background-color:#eee;padding:15px">${echo}` +
169
+ `${status}<div class=groups>${rosterCmd ? '<i>gathering…</i>' : ''}</div></div>`,
170
+ )
171
+
172
+ const render = serverGroups => {
173
+ // Candidate pod members per kind, as neighbourhood lookup keys (with the
174
+ // port suffix). Server kinds are registered once here so their sitemaps load;
175
+ // TWIN/WATCH later filter these candidates down to the matching members.
176
+ const candidates = {}
177
+ for (const kind of kinds) {
178
+ if (SERVER_KINDS.includes(kind)) {
179
+ const roll = serverGroups[kind] || []
180
+ gathered[kind] = roll.map(r => r.site)
181
+ candidates[kind] = roll.map(r => r.site + suffix)
182
+ for (const key of candidates[kind]) wiki.neighborhoodObject.registerNeighbor(key)
183
+ } else {
184
+ // neighbourhood / snapshot — read the live in-browser neighbourhood
185
+ const sites = Object.keys(wiki.neighborhood)
186
+ gathered[kind] = sites
187
+ candidates[kind] = sites
188
+ }
189
+ }
190
+
191
+ // TWIN / WATCH render like a roster item: the matching members' flags flowing
192
+ // inline, deduplicated across kinds, each linking to that member's copy of
193
+ // THIS page. No titles are shown (every copy is the same page) — the
194
+ // (sub)domain lives in the hover tooltip. Sitemaps (and, for WATCH, the
195
+ // fetched page journals) arrive asynchronously, so this is re-run as they
196
+ // resolve, which is when matches reveal themselves.
197
+ const buildRoster = () => {
198
+ const seen = new Set()
199
+ const flags = []
200
+ for (const kind of kinds) {
201
+ for (const key of candidates[kind]) {
202
+ if (watch) ensureWatchChecked(key, paint) // lazily confirm the fork event
203
+ if (seen.has(key) || !matches(key)) continue
204
+ seen.add(key)
205
+ flags.push(
206
+ `<img class="remote" src="${wiki.site(key).flag()}" ` +
207
+ `title="${key}" data-site="${key}" data-slug="${slug}">`,
208
+ )
209
+ }
210
+ }
211
+ return flags
212
+ }
213
+
214
+ // ROSTER — the whole gather as one compact roster: a single "<commands>
215
+ // Rosters" caption (the panel's own .caption) over all members' flags. The
216
+ // markup mirrors the wiki-plugin-roster item exactly: space-separated
217
+ // `img.remote` flags in the panel's grey box — no extra wrapper — each
218
+ // linking to that member's welcome page. The ❄ icon saves it as a page.
219
+ const buildRosterDisplay = () => {
220
+ const seen = new Set()
221
+ const flags = []
222
+ for (const kind of kinds) {
223
+ for (const key of candidates[kind]) {
224
+ if (seen.has(key)) continue
225
+ seen.add(key)
226
+ flags.push(
227
+ `<img class="remote" src="${wiki.site(key).flag()}" ` +
228
+ `title="${key}" data-site="${key}" data-slug="welcome-visitors">`,
229
+ )
230
+ }
231
+ }
232
+ return flags.join(' ') || '<i>none</i>'
233
+ }
234
+
235
+ // Non-roster: the full pod tables (favicon + name + page count + freshness).
236
+ const buildGroups = () => {
237
+ const html = []
238
+ for (const kind of kinds) {
239
+ const rows = candidates[kind].map(key => {
240
+ const bare = suffix && key.endsWith(suffix) ? key.slice(0, -suffix.length) : key
241
+ const sm = wiki.neighborhood[key]?.sitemap
242
+ return rowHtml(bare, sm ? sm.length : 0, sm)
243
+ })
244
+ const empty = SERVER_KINDS.includes(kind) ? '<tr><td><i>none</i>' : '<tr><td><i>empty</i>'
245
+ html.push(groupHtml(kind, rows.length ? rows : [empty]))
246
+ }
247
+ return html.join('\n')
248
+ }
249
+
250
+ const paint = () => {
251
+ if (roster) {
252
+ // TWIN / WATCH — flat, filtered flags
253
+ const flags = buildRoster()
254
+ div.find('.groups').html(flags.join(' '))
255
+ div.find('.caption').first().text(
256
+ flags.length ? '' : watch ? 'no pod watchers yet' : 'no pod twins yet',
257
+ )
258
+ return
259
+ }
260
+ if (rosterCmd) {
261
+ // Mirror a roster item: an optional left-aligned title, then a <br>, then
262
+ // the flags — all in the panel's grey box. TITLE off → flags only, even
263
+ // padding all round.
264
+ const title = showTitle ? `${rostersTitle} <br> ` : ''
265
+ div.find('.groups').html(title + buildRosterDisplay())
266
+ return
267
+ }
268
+ div.find('.groups').html(buildGroups())
269
+ div.find('.caption').first().text('just updated')
270
+ }
271
+ paint()
272
+
273
+ // Save the gathered pod as a roster ghost page titled after the commands
274
+ // (e.g. "SISTERS Rosters"), one roster item per kind. No FREEZE command any
275
+ // more: it's reached from the ❄ icon tucked into the corner of the ROSTER
276
+ // and wide-table views.
277
+ const saveRosters = () => {
278
+ const hexId = () =>
279
+ Math.floor(Math.random() * 0xffffffffffff).toString(16).padStart(12, '0')
280
+ const story = [{ type: 'markdown', id: hexId(), text: `# ${rostersTitle}` }]
281
+ for (const kind of kinds) {
282
+ const domains = gathered[kind] || []
283
+ story.push({
284
+ type: 'roster',
285
+ id: hexId(),
286
+ text: `${LABEL[kind]} Wikis\n\n${domains.join('\n') || '(none)'}`,
287
+ })
288
+ }
289
+ const page = wiki.newPage({ title: rostersTitle, story })
290
+ wiki.showResult(page, { $page: div.parents('.page') })
291
+ }
292
+
293
+ // Freeze affordance — a subtle ❄ icon in the panel corner, on the ROSTER and
294
+ // the wide-table views (TWIN / WATCH are monitoring views, so they get none).
295
+ if (rosterCmd || !roster) {
296
+ const icon = $(
297
+ '<span class=pod-freeze-icon title="Display Rosters — save as a roster page" ' +
298
+ 'style="position:absolute;top:6px;right:9px;cursor:pointer;font-size:14px;opacity:.55">❄</span>',
299
+ )
300
+ icon.on('click', saveRosters)
301
+ div.find('.pod').append(icon)
302
+ }
303
+
304
+ // As each neighbour's sitemap loads: in TWIN/WATCH mode repaint, since a match
305
+ // may have just appeared (a member whose sitemap now shows our slug — and for
306
+ // WATCH that also kicks off the journal fetch); otherwise just backfill that
307
+ // member's page-count / freshness cell.
308
+ $('body').on('new-neighbor-done', (e, site) => {
309
+ if (roster) return paint()
310
+ const cell = div.find(`td[data-site="${site}"]`)
311
+ if (!cell.length) return
312
+ const sm = wiki.neighborhood[site]?.sitemap
313
+ if (sm) cell.text(`${sm.length} pages ${freshness(sm)}`)
314
+ })
315
+ }
316
+
317
+ const serverKinds = kinds.filter(k => SERVER_KINDS.includes(k))
318
+ if (serverKinds.length === 0) {
319
+ render({})
320
+ return
321
+ }
322
+
323
+ fetch(`/plugin/pod/roll?kinds=${encodeURIComponent(serverKinds.join(','))}`)
324
+ .then(res => {
325
+ if (!res.ok) {
326
+ const err = new Error(`HTTP ${res.status}`)
327
+ err.status = res.status
328
+ throw err
329
+ }
330
+ return res.json()
331
+ })
332
+ .then(data => render(data.groups || {}))
333
+ .catch(err => {
334
+ // A 404 means the plugin's server component never registered its route —
335
+ // almost always an old wiki-server / Node combination that couldn't load
336
+ // server/server.js (see this plugin's server notes). Say so, rather than a
337
+ // generic error, so the operator knows where to look.
338
+ const msg =
339
+ err && err.status === 404
340
+ ? 'pod server not loaded — the host wiki may need a restart or a newer wiki-server / Node'
341
+ : 'server error'
342
+ div.find('.caption').first().text(msg)
343
+ })
344
+ }
345
+
346
+ export const bind = (div, item) => {
347
+ div.dblclick(() => wiki.textEditor(div, item))
348
+ }
349
+
350
+ if (typeof window !== 'undefined') {
351
+ window.plugins = window.plugins || {}
352
+ window.plugins.pod = { emit, bind }
353
+ }
@@ -0,0 +1,201 @@
1
+ // commands.js — the plugin's vocabulary, in one place.
2
+ //
3
+ // This module is the single source for what a pod item's text may say. The
4
+ // client imports it to parse item text; the plugin's API declaration names it so
5
+ // the farm reads the identical table. There is no second copy to drift, which is
6
+ // the same move that put the plugin's thinking in a module and left only the
7
+ // serving in its server.
8
+ //
9
+ // The item text convention is fedwiki-wide: UPPERCASE first word is a command,
10
+ // lowercase is data, and a single trailing colon is optional. See the Fedwiki
11
+ // DSL Skill.
12
+ //
13
+ // A DSL is a superset of an API, never a mirror of one — so every command
14
+ // declares which KIND it is, and only some kinds cross to HTTP:
15
+ //
16
+ // parameter supplies an argument to the call → crosses
17
+ // macro shorthand expanding to other commands → expands, then crosses
18
+ // client-data the server cannot supply it at all → never crosses
19
+ // presentation changes only how it is drawn → never crosses
20
+ // alias another spelling of a command → resolves first
21
+ //
22
+ // Adding a command here is the whole change: the parser, the API's allowed
23
+ // values, the rendered reference page and the plugin's own documentation all
24
+ // read this table.
25
+
26
+ export const VOCABULARY_VERSION = 1
27
+
28
+ /** The story item type these commands are written into. An agent needs this to author one. */
29
+ export const ITEM_TYPE = 'pod'
30
+
31
+ /** What the item does when no command is recognised. */
32
+ export const FALLBACK = ['sisters']
33
+
34
+ export const COMMANDS = {
35
+ // ---- parameter: these become values of the `kinds` parameter -------------
36
+ SISTERS: {
37
+ kind: 'parameter',
38
+ parameter: 'kinds',
39
+ value: 'sisters',
40
+ label: 'Sisters',
41
+ description: 'sibling sites sharing the parent domain',
42
+ },
43
+ PARENT: {
44
+ kind: 'parameter',
45
+ parameter: 'kinds',
46
+ value: 'parent',
47
+ label: 'Parent',
48
+ description: 'the parent-domain site itself',
49
+ },
50
+ CHILDREN: {
51
+ kind: 'parameter',
52
+ parameter: 'kinds',
53
+ value: 'children',
54
+ label: 'Children',
55
+ description: 'direct sub-domains of this site',
56
+ },
57
+ DESCENDANTS: {
58
+ kind: 'parameter',
59
+ parameter: 'kinds',
60
+ value: 'descendants',
61
+ label: 'Descendants',
62
+ description: 'all sub-domains, any depth',
63
+ },
64
+ FARM: {
65
+ kind: 'parameter',
66
+ parameter: 'kinds',
67
+ value: 'farm',
68
+ label: 'Farm',
69
+ description: 'every wiki in the farm',
70
+ },
71
+
72
+ // ---- macro ---------------------------------------------------------------
73
+ POD: {
74
+ kind: 'macro',
75
+ expands: ['PARENT', 'SISTERS'],
76
+ description: 'PARENT and SISTERS together (shorthand)',
77
+ },
78
+
79
+ // ---- client-data: gathered in the browser, invisible to any server -------
80
+ NEIGHBOURHOOD: {
81
+ kind: 'client-data',
82
+ value: 'neighbourhood',
83
+ label: 'Neighbourhood',
84
+ description: 'the sites currently in your neighborhood',
85
+ why: 'reads the neighbourhood the browser has already assembled, which no server can see',
86
+ },
87
+ NEIGHBORHOOD: { kind: 'alias', alias: 'NEIGHBOURHOOD', description: 'American spelling' },
88
+ SNAPSHOT: {
89
+ kind: 'client-data',
90
+ value: 'snapshot',
91
+ label: 'Snapshot',
92
+ description: 'freeze the current neighborhood',
93
+ why: 'same browser-only source as NEIGHBOURHOOD',
94
+ },
95
+ TWIN: {
96
+ kind: 'client-data',
97
+ description: "show only members holding a page with this page's slug",
98
+ why: "matches against each neighbour's own sitemap, fetched from the browser",
99
+ },
100
+ WATCH: {
101
+ kind: 'client-data',
102
+ description: 'stricter TWIN — only members whose copy is a fork of this page',
103
+ why: "reads each candidate's page journal, one fetch per neighbour, from the browser",
104
+ },
105
+
106
+ // ---- presentation: never leaves the client -------------------------------
107
+ ROSTER: {
108
+ kind: 'presentation',
109
+ description: 'draw the gather compactly, mirroring a normal roster item',
110
+ },
111
+ TITLE: {
112
+ kind: 'presentation',
113
+ argument: 'yes|no',
114
+ default: true,
115
+ description: 'whether the ROSTER title shows',
116
+ },
117
+ }
118
+
119
+ /** Values a trailing TITLE argument may take to mean "no". */
120
+ const FALSEY = ['no', 'false', 'off', '0', 'hide', 'none']
121
+
122
+ /** Follow an alias to the command it spells. */
123
+ export const resolve = name => {
124
+ const entry = COMMANDS[name]
125
+ return entry && entry.kind === 'alias' ? COMMANDS[entry.alias] : entry
126
+ }
127
+
128
+ /** The canonical name a command resolves to, for reporting. */
129
+ export const canonical = name => {
130
+ const entry = COMMANDS[name]
131
+ return entry && entry.kind === 'alias' ? entry.alias : name
132
+ }
133
+
134
+ /**
135
+ * The keyword a line begins with, normalised. UPPERCASE is canonical but input
136
+ * case is forgiven, and one trailing colon is optional — never required.
137
+ */
138
+ export const keywordOf = line =>
139
+ String(line || '')
140
+ .trim()
141
+ .split(/\s+/)[0]
142
+ .replace(/:$/, '')
143
+ .toUpperCase()
144
+
145
+ /** Every value a parameter may take, in declaration order. The API's enum. */
146
+ export const valuesFor = parameter =>
147
+ Object.values(COMMANDS)
148
+ .filter(c => c.kind === 'parameter' && c.parameter === parameter && c.value)
149
+ .map(c => c.value)
150
+
151
+ /** Kinds gathered in the browser rather than asked of a server. */
152
+ export const clientValues = () =>
153
+ Object.values(COMMANDS)
154
+ .filter(c => c.kind === 'client-data' && c.value)
155
+ .map(c => c.value)
156
+
157
+ /** Display names, derived so a label is written once beside its command. */
158
+ export const labels = () => {
159
+ const out = {}
160
+ for (const c of Object.values(COMMANDS)) if (c.value && c.label) out[c.value] = c.label
161
+ return out
162
+ }
163
+
164
+ /**
165
+ * Item text to an ordered, de-duplicated list of gathered kinds.
166
+ *
167
+ * Macros expand in place, aliases resolve, unrecognised lines are ignored as
168
+ * data rather than treated as an error. Default when nothing is recognised:
169
+ * sisters.
170
+ */
171
+ export const parseKinds = (text, { fallback = ['sisters'] } = {}) => {
172
+ const kinds = []
173
+ const take = name => {
174
+ const entry = resolve(name)
175
+ if (!entry) return
176
+ if (entry.kind === 'macro') return entry.expands.forEach(take)
177
+ if (!entry.value) return
178
+ if (!kinds.includes(entry.value)) kinds.push(entry.value)
179
+ }
180
+ for (const line of String(text || '').split('\n')) {
181
+ if (!line.trim()) continue
182
+ take(keywordOf(line))
183
+ }
184
+ return kinds.length ? kinds : [...fallback]
185
+ }
186
+
187
+ /** Whether a bare command appears on a line of its own. */
188
+ export const hasCommand = (text, name) =>
189
+ String(text || '')
190
+ .split('\n')
191
+ .some(line => canonical(keywordOf(line)) === name)
192
+
193
+ /** TITLE yes|no — shown unless explicitly turned off. */
194
+ export const parseTitle = text => {
195
+ for (const raw of String(text || '').split('\n')) {
196
+ const parts = raw.trim().split(/\s+/)
197
+ if (canonical(keywordOf(raw)) !== 'TITLE') continue
198
+ return !FALSEY.includes((parts[1] || '').toLowerCase().replace(/:$/, ''))
199
+ }
200
+ return COMMANDS.TITLE.default
201
+ }
@@ -0,0 +1,81 @@
1
+ // roll.js — what the pod plugin knows how to do, as plain functions.
2
+ //
3
+ // No request, no response, no argv, nothing from a web framework. Everything it
4
+ // needs arrives as ordinary values, so the same function answers the plugin's
5
+ // own route and the farm's mounted operation without either knowing about the
6
+ // other. Lifted out of server/server.js unchanged; that file is now a thin
7
+ // caller of this one.
8
+ //
9
+ // Node-only (it reads the farm directory), and deliberately NOT imported by the
10
+ // client — the client shares only the vocabulary in ./commands.js.
11
+
12
+ import fs from 'node:fs/promises'
13
+ import path from 'node:path'
14
+
15
+ /** Every pod this module can gather, as predicates over farm directory names. */
16
+ export const selectorsFor = ({ origin, parentDomain }) => {
17
+ const domainOf = name => name.split('.').slice(1).join('.')
18
+ return {
19
+ sisters: name => name !== origin && domainOf(name) === parentDomain,
20
+ parent: name => parentDomain !== '' && name === parentDomain,
21
+ children: name => domainOf(name) === origin,
22
+ descendants: name => name !== origin && name.endsWith('.' + origin),
23
+ farm: name => name !== origin,
24
+ }
25
+ }
26
+
27
+ /** A directory is a wiki when it carries a readable sitemap. */
28
+ const isWiki = (farmRoot, name) =>
29
+ fs
30
+ .access(path.join(farmRoot, name, 'status', 'sitemap.json'), fs.constants.R_OK)
31
+ .then(() => true)
32
+ .catch(() => false)
33
+
34
+ const pageCount = (farmRoot, name) =>
35
+ fs
36
+ .readdir(path.join(farmRoot, name, 'pages'))
37
+ .then(entries => entries.length)
38
+ .catch(() => 0)
39
+
40
+ /** The parent domain of a site name — '' for a farm root such as `localhost`. */
41
+ export const parentOf = origin => origin.split('.').slice(1).join('.')
42
+
43
+ /**
44
+ * Gather the requested pods of the site being asked.
45
+ *
46
+ * `origin` and `farmRoot` are the context: the answer genuinely depends on WHERE
47
+ * the question was asked, which is why they are arguments rather than something
48
+ * this module reaches for. Unknown kinds are dropped rather than refused, and
49
+ * the default is sisters — the same forgiveness the item text shows.
50
+ */
51
+ export const roll = async ({ kinds, origin, farmRoot }) => {
52
+ const parentDomain = parentOf(origin)
53
+ const selectors = selectorsFor({ origin, parentDomain })
54
+
55
+ const wanted = (Array.isArray(kinds) ? kinds : String(kinds || 'sisters').split(','))
56
+ .map(k => String(k).trim())
57
+ .filter(k => selectors[k])
58
+
59
+ let entries
60
+ try {
61
+ entries = await fs.readdir(farmRoot, { withFileTypes: true })
62
+ } catch {
63
+ return { origin, parentDomain, groups: {} }
64
+ }
65
+
66
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
67
+ const flags = await Promise.all(dirs.map(name => isWiki(farmRoot, name)))
68
+ const wikis = dirs.filter((_, i) => flags[i])
69
+
70
+ const groups = {}
71
+ for (const kind of wanted) {
72
+ const names = wikis.filter(selectors[kind])
73
+ const group = await Promise.all(
74
+ names.map(async name => ({ site: name, pages: await pageCount(farmRoot, name) })),
75
+ )
76
+ group.sort((a, b) => a.site.localeCompare(b.site))
77
+ groups[kind] = group
78
+ }
79
+
80
+ return { origin, parentDomain, groups }
81
+ }