wiki-plugin-pod 1.0.0 → 1.2.3

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/src/client/pod.js CHANGED
@@ -1,326 +1,172 @@
1
1
  // wiki-plugin-pod — gather configurable pods of related wiki sites.
2
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.
3
+ // The item text chooses WHICH pods of sites to gather — sisters, parent,
4
+ // children, descendants, the farm, the neighbourhood — and draws them as a
5
+ // roster of flags. Say FORCE and the gathered sites also join your neighbourhood
6
+ // on load, so their pages join your search and lineage; without it the roster's
7
+ // own » does that when you ask, which is how any other roster behaves.
8
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)
9
+ // The commands an author may write are NOT listed here. They live in
10
+ // ../pod/commands.js, which the plugin's API declaration also names, so what an
11
+ // author may type and what the mounted operation accepts come from one table.
12
+ // Restating that table in a comment is how the two drift apart; read the table.
17
13
  //
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.
14
+ // Where each pod comes from: this plugin ships NO server. It declares a
15
+ // specification and a module of plain functions (api/, src/pod/), and the Farm
16
+ // Plugin mounts them — so the disk-derived pods are read from that mount,
17
+ // /system/api/pod/roll.json, the same address an agent uses. NEIGHBOURHOOD and
18
+ // SNAPSHOT never leave the browser at all, being the neighbourhood the client
19
+ // has already assembled.
32
20
  //
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'
21
+ // How it draws: it doesn't. A gather IS a roster — a heading over the members'
22
+ // flags — and wiki-plugin-roster already draws one, on every wiki there is. So
23
+ // pod builds the roster text and hands it to that plugin. The text it builds is
24
+ // also exactly what the ❄ icon saves as a page, so the drawn thing and the saved
25
+ // thing are one string rather than two code paths that must agree.
26
+ import { valuesFor, fieldByValue, parseKinds, problems, hasCommand, argumentOf, retirementOf } from '../pod/vocabulary.js'
43
27
 
44
28
  const SERVER_KINDS = valuesFor('kinds')
45
- const CLIENT_KINDS = clientValues()
46
- const LABEL = labels()
29
+ const HEADING = fieldByValue('heading')
47
30
 
48
- const expand = text =>
31
+ const escapeHtml = text =>
49
32
  (text || '')
50
33
  .replace(/&/g, '&amp;')
51
34
  .replace(/</g, '&lt;')
52
35
  .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
36
 
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)) : ''
37
+ // A mistyped command (CHIKDREN for CHILDREN) is parsed as data, so the item
38
+ // falls back to sisters and draws a pod nobody asked for. Say so above the
39
+ // gather, since nothing else on the panel would show a trace of it.
40
+ const problemsHtml = found =>
41
+ found.length
42
+ ? `<p class=pod-problem style="color:#a00;font-size:80%;text-align:center;margin:0 0 6px">` +
43
+ found
44
+ .map(p =>
45
+ p.suggestion
46
+ ? `${escapeHtml(p.word)} — did you mean <b>${p.suggestion}</b>?`
47
+ : `${escapeHtml(p.word)} — not a pod command`,
48
+ )
49
+ .join('<br>') +
50
+ `</p>`
51
+ : ''
52
+
53
+ // A command that has moved says where it went, rather than silently doing
54
+ // nothing on a page that still asks for it.
55
+ const retiredHtml = text => {
56
+ const moved = ['TWIN', 'WATCH', 'ROSTER']
57
+ .filter(word => hasCommand(text, word))
58
+ .map(word => [word, retirementOf(word)])
59
+ return moved.length
60
+ ? `<p class=pod-retired style="color:#666;font-size:80%;text-align:center;margin:0 0 6px">` +
61
+ moved.map(([word, became]) => `${word} — now ${escapeHtml(became)}`).join('<br>') +
62
+ `</p>`
63
+ : ''
72
64
  }
73
65
 
74
66
  const portSuffix = () => ([80, '80', '', null].includes(location.port) ? '' : `:${location.port}`)
75
67
 
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
68
  export const emit = (div, item) => {
109
69
  const suffix = portSuffix()
110
70
  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))
71
+ const showHeadings = argumentOf(item.text, 'TITLE')
72
+ const force = argumentOf(item.text, 'FORCE')
73
+ const notes = problemsHtml(problems(item.text)) + retiredHtml(item.text)
151
74
 
152
75
  if (div.closest('.page').hasClass('remote')) {
153
76
  div.html(
154
- `<div class=pod style="background-color:#eee;padding:15px"><center>${expand(item.text)}` +
77
+ `<div class=pod style="background-color:#eee;padding:15px"><center>` +
155
78
  `<p class=caption>Pod is only available when viewed on a page's home wiki.</p></div>`,
156
79
  )
157
80
  return
158
81
  }
159
82
 
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 = []
83
+ div.html(`${notes}<div class=pod-gather><p class=caption>gathering…</p></div>`)
84
+
85
+ // The roster text: a heading, a BLANK LINE, then one domain per line, and a
86
+ // blank line again between groups. That is wiki-plugin-roster's own markup —
87
+ // categories are the lines that are not domains, and a blank line starts a new
88
+ // display line, which is what puts the heading on a line of its own with the
89
+ // flags in a row beneath it. Without the blank line roster runs them together:
90
+ // "Sisters of demo.localhost [flag] [flag]" on one line.
91
+ //
92
+ // This same string draws the item and, saved, becomes an ordinary roster item
93
+ // on a page any wiki can read.
94
+ const rosterText = (groups, origin) =>
95
+ kinds
96
+ .map(kind => {
97
+ const sites = groups[kind] || []
98
+ if (!sites.length) return ''
99
+ const heading = showHeadings ? (HEADING[kind] || kind).replace('{site}', origin) + '\n\n' : ''
100
+ return heading + sites.join('\n')
101
+ })
102
+ .filter(Boolean)
103
+ .join('\n\n')
104
+
105
+ const render = (groups, origin) => {
106
+ // FORCE — register the gathered sites as neighbours, so their pages join
107
+ // your search and lineage. Off by default: a pod is a roster, and a roster
108
+ // does not reach into your neighbourhood unasked. The roster's own » does it
109
+ // on demand instead, and the flags are drawn either way — wiki.site().flag()
110
+ // never consults the neighbourhood.
111
+ if (force) {
200
112
  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
- }
113
+ if (!SERVER_KINDS.includes(kind)) continue
114
+ for (const site of groups[kind] || []) wiki.neighborhoodObject.registerNeighbor(site + suffix)
210
115
  }
211
- return flags
212
116
  }
213
117
 
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) {
118
+ const text = rosterText(groups, origin)
119
+ const target = div.find('.pod-gather')
120
+ target.empty()
121
+ if (!text) return target.html('<p class=caption><i>none</i></p>')
122
+
123
+ wiki.getPlugin('roster', roster => {
124
+ target.empty()
125
+ const rosterItem = { type: 'roster', id: `${item.id}-roster`, text }
126
+ roster.emit(target, rosterItem)
127
+ // bind() too, or the » roster draws beside each row — "add these N sites
128
+ // to neighborhood" — is inert markup. With FORCE off that link is the only
129
+ // way to take the pod into your neighbourhood, so it has to work.
130
+ //
131
+ // Its other binding is a dblclick opening a text editor on this synthetic
132
+ // roster item, which belongs to nothing and would edit a string we
133
+ // generated. Drop that one; pod binds its own dblclick, on the item, to
134
+ // edit the pod text an author actually wrote.
135
+ roster.bind(target, rosterItem)
136
+ target.off('dblclick')
137
+ // Freeze — the same text, saved as a real roster item on a new page.
296
138
  const icon = $(
297
- '<span class=pod-freeze-icon title="Display Rosters — save as a roster page" ' +
139
+ '<span class=pod-freeze-icon title="Freeze — save this gather as a roster page" ' +
298
140
  'style="position:absolute;top:6px;right:9px;cursor:pointer;font-size:14px;opacity:.55">❄</span>',
299
141
  )
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)}`)
142
+ icon.on('click', () => {
143
+ const title = kinds.map(k => (HEADING[k] || k).replace('{site}', origin)).join(', ')
144
+ const page = wiki.newPage({
145
+ title,
146
+ story: [{ type: 'roster', id: Math.floor(Math.random() * 0xffffffffffff).toString(16).padStart(12, '0'), text }],
147
+ })
148
+ wiki.showResult(page, { $page: div.parents('.page') })
149
+ })
150
+ div.css('position', 'relative').append(icon)
314
151
  })
315
152
  }
316
153
 
154
+ // Only the disk-derived pods need asking; NEIGHBOURHOOD and SNAPSHOT are
155
+ // already in the browser.
156
+ const neighbourhood = () => Object.keys(wiki.neighborhood).map(key => (suffix && key.endsWith(suffix) ? key.slice(0, -suffix.length) : key))
157
+ const localGroups = () => {
158
+ const groups = {}
159
+ for (const kind of kinds) if (!SERVER_KINDS.includes(kind)) groups[kind] = neighbourhood()
160
+ return groups
161
+ }
162
+
317
163
  const serverKinds = kinds.filter(k => SERVER_KINDS.includes(k))
318
164
  if (serverKinds.length === 0) {
319
- render({})
165
+ render(localGroups(), location.hostname)
320
166
  return
321
167
  }
322
168
 
323
- fetch(`/plugin/pod/roll?kinds=${encodeURIComponent(serverKinds.join(','))}`)
169
+ fetch(`/system/api/pod/roll.json?kinds=${encodeURIComponent(serverKinds.join(','))}`)
324
170
  .then(res => {
325
171
  if (!res.ok) {
326
172
  const err = new Error(`HTTP ${res.status}`)
@@ -329,15 +175,15 @@ export const emit = (div, item) => {
329
175
  }
330
176
  return res.json()
331
177
  })
332
- .then(data => render(data.groups || {}))
178
+ .then(data => render({ ...(data.groups || {}), ...localGroups() }, data.origin || location.hostname))
333
179
  .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.
180
+ // A 404 means nothing is mounted at that address: this farm has no
181
+ // wiki-plugin-farm, or it has not been restarted since pod was installed.
182
+ // Name it, rather than saying "server error", so the operator knows what
183
+ // to install.
338
184
  const msg =
339
185
  err && err.status === 404
340
- ? 'pod server not loaded — the host wiki may need a restart or a newer wiki-server / Node'
186
+ ? 'pod needs wiki-plugin-farm on this wiki to mount its API — install it and restart'
341
187
  : 'server error'
342
188
  div.find('.caption').first().text(msg)
343
189
  })