wiki-plugin-pod 1.0.0 → 1.2.2

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.
@@ -2,13 +2,14 @@
2
2
  //
3
3
  // This module is the single source for what a pod item's text may say. The
4
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.
5
+ // the farm reads the identical table. There is no second copy to drift.
8
6
  //
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.
7
+ // The PARSER is not here, and neither is an import of it. The FARM loads this
8
+ // module server-side to read the table, so it must be data and nothing else —
9
+ // an import of @fortyfoxes/wiki-dsl here would need that package installed on
10
+ // every farm, and a vocabulary that fails to load is dropped from the farm's
11
+ // merged document entirely. The parser is bound in ./vocabulary.js, which only
12
+ // the browser and the tests load.
12
13
  //
13
14
  // A DSL is a superset of an API, never a mirror of one — so every command
14
15
  // declares which KIND it is, and only some kinds cross to HTTP:
@@ -33,11 +34,16 @@ export const FALLBACK = ['sisters']
33
34
 
34
35
  export const COMMANDS = {
35
36
  // ---- parameter: these become values of the `kinds` parameter -------------
37
+ //
38
+ // `heading` is what the drawn group is called. `{site}` is the site the item
39
+ // is on, because that is what a pod is relative to and the one thing a reader
40
+ // landing cold cannot work out.
36
41
  SISTERS: {
37
42
  kind: 'parameter',
38
43
  parameter: 'kinds',
39
44
  value: 'sisters',
40
45
  label: 'Sisters',
46
+ heading: 'Sisters of {site}',
41
47
  description: 'sibling sites sharing the parent domain',
42
48
  },
43
49
  PARENT: {
@@ -45,6 +51,7 @@ export const COMMANDS = {
45
51
  parameter: 'kinds',
46
52
  value: 'parent',
47
53
  label: 'Parent',
54
+ heading: 'Parent of {site}',
48
55
  description: 'the parent-domain site itself',
49
56
  },
50
57
  CHILDREN: {
@@ -52,6 +59,7 @@ export const COMMANDS = {
52
59
  parameter: 'kinds',
53
60
  value: 'children',
54
61
  label: 'Children',
62
+ heading: 'Children of {site}',
55
63
  description: 'direct sub-domains of this site',
56
64
  },
57
65
  DESCENDANTS: {
@@ -59,6 +67,7 @@ export const COMMANDS = {
59
67
  parameter: 'kinds',
60
68
  value: 'descendants',
61
69
  label: 'Descendants',
70
+ heading: 'Descendants of {site}',
62
71
  description: 'all sub-domains, any depth',
63
72
  },
64
73
  FARM: {
@@ -66,6 +75,7 @@ export const COMMANDS = {
66
75
  parameter: 'kinds',
67
76
  value: 'farm',
68
77
  label: 'Farm',
78
+ heading: 'The rest of the farm',
69
79
  description: 'every wiki in the farm',
70
80
  },
71
81
 
@@ -81,6 +91,7 @@ export const COMMANDS = {
81
91
  kind: 'client-data',
82
92
  value: 'neighbourhood',
83
93
  label: 'Neighbourhood',
94
+ heading: 'Your neighbourhood',
84
95
  description: 'the sites currently in your neighborhood',
85
96
  why: 'reads the neighbourhood the browser has already assembled, which no server can see',
86
97
  },
@@ -89,113 +100,49 @@ export const COMMANDS = {
89
100
  kind: 'client-data',
90
101
  value: 'snapshot',
91
102
  label: 'Snapshot',
103
+ heading: 'Your neighbourhood',
92
104
  description: 'freeze the current neighborhood',
93
105
  why: 'same browser-only source as NEIGHBOURHOOD',
94
106
  },
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
107
 
106
108
  // ---- presentation: never leaves the client -------------------------------
107
- ROSTER: {
109
+ //
110
+ // FORCE is a behaviour switch rather than a drawing choice, but `presentation`
111
+ // is the closest of the kinds the farm's vocabulary check accepts, and the
112
+ // thing that matters is true of it: it never crosses to a server.
113
+ FORCE: {
108
114
  kind: 'presentation',
109
- description: 'draw the gather compactly, mirroring a normal roster item',
115
+ argument: 'yes|no',
116
+ default: false,
117
+ description: 'register the gathered sites as neighbours when the page loads',
110
118
  },
111
119
  TITLE: {
112
120
  kind: 'presentation',
113
121
  argument: 'yes|no',
114
122
  default: true,
115
- description: 'whether the ROSTER title shows',
123
+ description: 'whether each group shows its heading',
116
124
  },
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
125
 
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
126
+ // ---- retired: kept so pages that say them still parse ---------------------
127
+ //
128
+ // A word removed from this table is a word the parser reports as a mistake, so
129
+ // retiring one by deleting it would put a false "not a pod command" warning on
130
+ // every page that still says it. These stay, as presentation (the kind the
131
+ // farm's check allows for a word that never crosses), carrying what became of
132
+ // them.
133
+ ROSTER: {
134
+ kind: 'presentation',
135
+ retired: 'the default — every pod is drawn as a roster of flags',
136
+ description: 'no longer needed: rosters are how a pod is drawn',
137
+ },
138
+ TWIN: {
139
+ kind: 'presentation',
140
+ retired: 'wiki-plugin-twin',
141
+ description: 'moved: which pod members hold a page with this page’s slug',
142
+ },
143
+ WATCH: {
144
+ kind: 'presentation',
145
+ retired: 'wiki-plugin-twin',
146
+ description: 'moved: which pod members have forked this page',
147
+ },
201
148
  }
@@ -0,0 +1,39 @@
1
+ // kinship.js — who stands as pod to whom, as arithmetic on names.
2
+ //
3
+ // Kinship needs no disk: given the names a farm holds, every pod is a predicate
4
+ // over strings. So selection lives here, imported by both readers — the browser,
5
+ // which asks the farm only what sites exist, and the API handler, which answers
6
+ // server-side for an agent that cannot run our client. That is why the plugin's
7
+ // server shrank to one directory read and no opinions.
8
+
9
+ /** The domain a site hangs from — '' for a farm root such as `localhost`. */
10
+ export const parentOf = origin => origin.split('.').slice(1).join('.')
11
+
12
+ /** Every pod, as predicates over site names, relative to the site asking. */
13
+ export const selectorsFor = origin => {
14
+ const parentDomain = parentOf(origin)
15
+ const domainOf = name => name.split('.').slice(1).join('.')
16
+ return {
17
+ sisters: name => name !== origin && domainOf(name) === parentDomain,
18
+ parent: name => parentDomain !== '' && name === parentDomain,
19
+ children: name => domainOf(name) === origin,
20
+ descendants: name => name !== origin && name.endsWith('.' + origin),
21
+ farm: name => name !== origin,
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Sort site names into the requested pods — names in, names out. Unknown kinds
27
+ * are dropped rather than refused, the same forgiveness the item text shows.
28
+ * Every pod is a neighbourhood, so the asker is never among its own results.
29
+ */
30
+ export const gather = (sites, { origin, kinds }) => {
31
+ const selectors = selectorsFor(origin)
32
+ const wanted = (Array.isArray(kinds) ? kinds : String(kinds || 'sisters').split(','))
33
+ .map(k => String(k).trim())
34
+ .filter(k => selectors[k])
35
+
36
+ const groups = {}
37
+ for (const kind of wanted) groups[kind] = sites.filter(selectors[kind]).sort((a, b) => a.localeCompare(b))
38
+ return groups
39
+ }
package/src/pod/roll.js CHANGED
@@ -1,81 +1,50 @@
1
- // roll.js — what the pod plugin knows how to do, as plain functions.
1
+ // roll.js — the one thing kinship cannot work out for itself: what is here.
2
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.
3
+ // Node-only, never imported by the client. The selectors, the sorting and the
4
+ // page counts have all left: selection moved to ../pod/kinship.js, and the
5
+ // counts went away because the client never read them — it takes them from each
6
+ // neighbour's own sitemap, so the farm was reading a directory per site on
7
+ // every render for nothing.
8
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.
9
+ // A directory is a wiki when it holds pages — the same test wiki-plugin-farm
10
+ // and wiki-plugin-hitchhiker use. This plugin used to test for
11
+ // `status/sitemap.json`, which is written on first view rather than on
12
+ // creation, so a farm's newest sites were invisible to it alone.
11
13
 
12
14
  import fs from 'node:fs/promises'
13
15
  import path from 'node:path'
14
16
 
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])
17
+ import { gather, parentOf } from './kinship.js'
58
18
 
19
+ /** The sites of this farm — every directory holding pages. */
20
+ export const sitesOf = async farmRoot => {
59
21
  let entries
60
22
  try {
61
23
  entries = await fs.readdir(farmRoot, { withFileTypes: true })
62
24
  } catch {
63
- return { origin, parentDomain, groups: {} }
25
+ return []
64
26
  }
65
-
66
27
  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])
28
+ const holdsPages = await Promise.all(
29
+ dirs.map(name =>
30
+ fs
31
+ .access(path.join(farmRoot, name, 'pages'), fs.constants.R_OK)
32
+ .then(() => true)
33
+ .catch(() => false),
34
+ ),
35
+ )
36
+ return dirs.filter((_, i) => holdsPages[i]).sort()
37
+ }
69
38
 
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
- }
39
+ /**
40
+ * Gather the requested pods of the site being asked. `origin` and `farmRoot` are
41
+ * context: the answer depends on WHERE the question was put, which is why they
42
+ * are arguments rather than something this module reaches for.
43
+ */
44
+ export const roll = async ({ kinds, origin, farmRoot }) => ({
45
+ origin,
46
+ parentDomain: parentOf(origin),
47
+ groups: gather(await sitesOf(farmRoot), { origin, kinds }),
48
+ })
79
49
 
80
- return { origin, parentDomain, groups }
81
- }
50
+ export { parentOf }
@@ -0,0 +1,20 @@
1
+ // vocabulary.js — the table, read.
2
+ //
3
+ // ./commands.js is data with no imports, because the farm loads it server-side
4
+ // and a vocabulary that cannot be imported is dropped from the farm's document.
5
+ // Everything that READS that table lives here instead: @fortyfoxes/wiki-dsl,
6
+ // bundled into the client at build time and shared with every other plugin whose
7
+ // item text is a small language.
8
+
9
+ import { dsl } from '@fortyfoxes/wiki-dsl'
10
+
11
+ import { COMMANDS, FALLBACK } from './commands.js'
12
+
13
+ export const { parseKinds, hasCommand, argumentOf, problems, valuesFor, fieldByValue, canonical, resolve, suggest } =
14
+ dsl(COMMANDS, { fallback: FALLBACK })
15
+
16
+ /** What a retired command became, or undefined for a live one. */
17
+ export const retirementOf = word => COMMANDS[word]?.retired
18
+
19
+ export { COMMANDS, FALLBACK }
20
+ export { VOCABULARY_VERSION, ITEM_TYPE } from './commands.js'
@@ -1,3 +0,0 @@
1
- {
2
- "type": "commonjs"
3
- }
package/server/server.js DELETED
@@ -1,47 +0,0 @@
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 }