pipeup 0.7.0 → 0.8.0-beta.1

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Comments and feedback right on any HTML page: documents, slide decks and whole sites.
4
4
 
5
- > **Alpha (0.7.0).** The API and the stored comment format may change before 1.0; pin an exact version.
5
+ > **Alpha (0.8.0-beta.1).** The API and the stored comment format may change before 1.0; pin an exact version.
6
6
  > Website and Try pages: https://pipeup-ai.github.io/pipeup/ · Source: https://github.com/pipeup-ai/pipeup
7
7
 
8
8
  Pipeup: signed comment operations, threads with one-level replies, anchors that survive page changes,
@@ -21,7 +21,7 @@ As a classic script (also works from `file://`), it mounts itself on pages that
21
21
  ```html
22
22
  <html data-pipeup-doc="<id>:<key>">
23
23
  ...
24
- <script src="https://cdn.jsdelivr.net/npm/pipeup@0.7.0/dist/pipeup.min.js" integrity="sha384-…" crossorigin="anonymous" defer></script>
24
+ <script src="https://cdn.jsdelivr.net/npm/pipeup@0.8.0-beta.1/dist/pipeup.min.js" integrity="sha384-…" crossorigin="anonymous" defer></script>
25
25
  ```
26
26
 
27
27
  Each release's notes on GitHub give the `integrity` value
package/cli/init.mjs ADDED
@@ -0,0 +1,581 @@
1
+ // pipeup init: adds Pipeup to a page in one step (spec §15e, design docs/design/cli-init.md).
2
+ // The page is edited as text: every change is an insertion or an attribute value replaced at a known place, so every
3
+ // other byte stays as it was. Nothing is fetched: Pipeup's hash comes from the package's own file, the add-ons' from
4
+ // dist/addons.json, which the add-ons build writes.
5
+ import { createHash } from "node:crypto";
6
+ import { readFile, writeFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { createInterface } from "node:readline";
10
+
11
+ const PKG = new URL("..", import.meta.url);
12
+ const CDN = "https://cdn.jsdelivr.net/npm";
13
+ const KEYFILE = () => process.env.PIPEUP_AGENT_FILE || join(homedir(), ".pipeup", "agent.json");
14
+ // A start tag, with quoted attribute values that may hold ">".
15
+ const TAG = String.raw`(?:[^>"']|"[^"]*"|'[^']*')*`;
16
+ const HTML_TAG = new RegExp(`<html\\b${TAG}>`, "i");
17
+ const SCRIPT = new RegExp(`<script\\b(${TAG})>([\\s\\S]*?)</script\\s*>`, "gi");
18
+ const SIGNATURE = /var Pipeup=\(\(\)=>/;
19
+
20
+ export class InitError extends Error {
21
+ constructor(message, code = 2) {
22
+ super(message);
23
+ this.code = code;
24
+ }
25
+ }
26
+
27
+ const version = async () => JSON.parse(await readFile(new URL("package.json", PKG), "utf8")).version;
28
+ const sri = (bytes) => `sha384-${createHash("sha384").update(bytes).digest("base64")}`;
29
+ const escapeAttr = (v) => v.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
30
+ const unescapeAttr = (v) =>
31
+ v
32
+ .replace(/&quot;/g, '"')
33
+ .replace(/&#39;/g, "'")
34
+ .replace(/&amp;/g, "&");
35
+
36
+ /** 1.2.3-beta.4 compared with another: negative when a is older. */
37
+ function compare(a, b) {
38
+ const parse = (v) => {
39
+ const [core, pre] = v.split("-", 2);
40
+ return { n: core.split(".").map(Number), pre: pre ?? null };
41
+ };
42
+ const x = parse(a);
43
+ const y = parse(b);
44
+ for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return (x.n[i] ?? 0) - (y.n[i] ?? 0);
45
+ if (x.pre === y.pre) return 0;
46
+ if (x.pre === null) return 1;
47
+ if (y.pre === null) return -1;
48
+ return x.pre.localeCompare(y.pre, "en", { numeric: true });
49
+ }
50
+
51
+ /** An attribute's value in a start tag, or null. */
52
+ function attrOf(tag, name) {
53
+ const m = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
54
+ return m ? unescapeAttr(m[2] ?? m[3] ?? m[4] ?? "") : null;
55
+ }
56
+
57
+ /** Where an attribute's value sits in a start tag (start and end of the value, inside any quotes), or null. */
58
+ function attrSpan(tag, name) {
59
+ const m = new RegExp(`(\\s${name}\\s*=\\s*)("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
60
+ if (!m) return null;
61
+ const quoted = m[2][0] === '"' || m[2][0] === "'";
62
+ const start = m.index + m[1].length + (quoted ? 1 : 0);
63
+ return { start, end: start + m[2].length - (quoted ? 2 : 0) };
64
+ }
65
+
66
+ /** Where to add an attribute to a start tag: just after its last attribute, before any space and its ">" or "/>". */
67
+ const attrEnd = (tag) => tag.length - (/\s*\/?>$/.exec(tag)?.[0].length ?? 1);
68
+
69
+ /** What the page already has: its <html> tag, Pipeup's script, add-ons. */
70
+ function survey(html) {
71
+ const h = HTML_TAG.exec(html);
72
+ if (!h) throw new InitError("the page has no <html> element; add one around the page and run this again");
73
+ const found = { html: { at: h.index, tag: h[0] }, core: null, addons: new Map(), lastScriptEnd: -1 };
74
+ for (const m of html.matchAll(SCRIPT)) {
75
+ const src = attrOf(`<script${m[1]}>`, "src");
76
+ if (src) {
77
+ const addon = /@pipeup\/([a-z][a-z0-9-]*)(?:@[^/]+)?\/dist\//.exec(src);
78
+ const core =
79
+ /(?:^|\/)pipeup(?:@([^/]+))?\/dist\/pipeup\.min\.js/.exec(src) ??
80
+ /(?:^|\/)pipeup\.min\.js$/.exec(src);
81
+ if (core && !found.core)
82
+ found.core = {
83
+ at: m.index,
84
+ tag: `<script${m[1]}>`,
85
+ end: m.index + m[0].length,
86
+ version: core[1] ?? null,
87
+ inline: false,
88
+ };
89
+ else if (addon) found.addons.set(addon[1], m.index + m[0].length);
90
+ } else if (!found.core && SIGNATURE.test(m[2])) {
91
+ const v = /\bVERSION:?\s*(?:=\s*)?"(\d+\.\d+\.\d+(?:-[\w.]+)?)"/.exec(m[2]);
92
+ found.core = {
93
+ at: m.index,
94
+ tag: `<script${m[1]}>`,
95
+ end: m.index + m[0].length,
96
+ version: v?.[1] ?? null,
97
+ inline: true,
98
+ };
99
+ }
100
+ }
101
+ return found;
102
+ }
103
+
104
+ /** Where a new script line goes: before </body>, else </html>, else the end; with the indent of the line above. */
105
+ function scriptSpot(html) {
106
+ const close = /<\/body\s*>/i.exec(html) ?? /<\/html\s*>/i.exec(html);
107
+ const at = close ? close.index : html.length;
108
+ const lineStart = html.lastIndexOf("\n", at - 1) + 1;
109
+ const before = html.slice(lineStart, at);
110
+ if (/^\s*$/.test(before)) {
111
+ // </body> starts its line: the new line goes above it, indented like the content above.
112
+ const prev = html.slice(0, lineStart).trimEnd();
113
+ const prevLine = prev.slice(prev.lastIndexOf("\n") + 1);
114
+ const indent = /^\s*/.exec(prevLine)?.[0] ?? "";
115
+ return { at: lineStart, wrap: (line) => `${indent}${line}\n` };
116
+ }
117
+ return { at, wrap: (line) => `\n${line}\n` };
118
+ }
119
+
120
+ async function addonTable() {
121
+ try {
122
+ return JSON.parse(await readFile(new URL("dist/addons.json", PKG), "utf8"));
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /** Reads answers a line at a time, from a terminal or a pipe; null once the input ends. */
129
+ function answers(input) {
130
+ const rl = createInterface({ input, terminal: false });
131
+ const lines = [];
132
+ const waiting = [];
133
+ let ended = false;
134
+ rl.on("line", (l) => (waiting.length ? waiting.shift()(l) : lines.push(l)));
135
+ rl.on("close", () => {
136
+ ended = true;
137
+ for (const w of waiting.splice(0)) w(null);
138
+ });
139
+ return {
140
+ next: () =>
141
+ lines.length
142
+ ? Promise.resolve(lines.shift())
143
+ : ended
144
+ ? Promise.resolve(null)
145
+ : new Promise((r) => waiting.push(r)),
146
+ close: () => rl.close(),
147
+ };
148
+ }
149
+
150
+ /** Lowercase words joined by dashes: at most five words, as a block's stable name. */
151
+ export function kebab(text) {
152
+ return text
153
+ .toLowerCase()
154
+ .replace(/['\u2019]/g, "")
155
+ .normalize("NFKD")
156
+ .replace(/[̀-ͯ]/g, "")
157
+ .replace(/[^a-z0-9]+/g, " ")
158
+ .trim()
159
+ .split(/\s+/)
160
+ .filter(Boolean)
161
+ .slice(0, 5)
162
+ .join("-")
163
+ .slice(0, 48)
164
+ .replace(/-+$/, "");
165
+ }
166
+
167
+ const BLOCKS = new Set([
168
+ "h1",
169
+ "h2",
170
+ "h3",
171
+ "h4",
172
+ "h5",
173
+ "h6",
174
+ "section",
175
+ "article",
176
+ "figure",
177
+ "table",
178
+ "pre",
179
+ "blockquote",
180
+ ]);
181
+ const MEDIA = new Set(["img", "svg", "canvas"]);
182
+ const VOID = new Set([
183
+ "area",
184
+ "base",
185
+ "br",
186
+ "col",
187
+ "embed",
188
+ "hr",
189
+ "img",
190
+ "input",
191
+ "link",
192
+ "meta",
193
+ "param",
194
+ "source",
195
+ "track",
196
+ "wbr",
197
+ ]);
198
+ const RAW = new Set(["script", "style", "template", "textarea", "title"]);
199
+ const HIDDEN = new Set(["nav", "header", "footer"]);
200
+ const textOf = (s) =>
201
+ s
202
+ .replace(/<[^>]*>/g, " ")
203
+ .replace(/&nbsp;/g, " ")
204
+ .replace(/&amp;/g, "&")
205
+ .replace(/\s+/g, " ")
206
+ .trim();
207
+
208
+ /** The parts of a page worth a stable name, with a proposed name and what each was taken to be. */
209
+ export function candidates(html) {
210
+ const out = [];
211
+ const used = new Set([...html.matchAll(/\sdata-pipeup-id\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]));
212
+ const open = [];
213
+ let slides = [...html.matchAll(/\sdata-pipeup-slide\s*=\s*["'](\d+)["']/gi)].reduce(
214
+ (n, m) => Math.max(n, Number(m[1])),
215
+ 0,
216
+ );
217
+ const re = new RegExp(`<!--[\\s\\S]*?-->|<(/?)([a-zA-Z][\\w-]*)(${TAG})>`, "g");
218
+ for (let m; (m = re.exec(html));) {
219
+ if (!m[2]) continue;
220
+ const name = m[2].toLowerCase();
221
+ if (m[1]) {
222
+ const i = open.map((o) => o.name).lastIndexOf(name);
223
+ if (i >= 0) open.length = i;
224
+ continue;
225
+ }
226
+ const tag = m[0];
227
+ const selfClosing = tag.endsWith("/>") || VOID.has(name);
228
+ if (RAW.has(name)) {
229
+ const end = html.toLowerCase().indexOf(`</${name}`, re.lastIndex);
230
+ re.lastIndex = end < 0 ? html.length : end;
231
+ continue;
232
+ }
233
+ const hidden =
234
+ HIDDEN.has(name) ||
235
+ /\sdata-pipeup-ignore\b/i.test(tag) ||
236
+ /\saria-hidden\s*=\s*["']true["']/i.test(tag);
237
+ const within = open.some((o) => o.hidden);
238
+ if (!selfClosing && name !== "svg") open.push({ name, hidden });
239
+ else if (name === "svg" && !selfClosing) {
240
+ // An svg's insides are drawing, not blocks: skip to its end.
241
+ const end = html.toLowerCase().indexOf("</svg", re.lastIndex);
242
+ re.lastIndex = end < 0 ? html.length : end;
243
+ }
244
+ if (within || hidden || /\sdata-pipeup-(id|label|slide)\s*=/i.test(tag)) continue;
245
+ const at = m.index;
246
+ const cls = attrOf(tag, "class") ?? "";
247
+ const named = attrOf(tag, "aria-label") ?? attrOf(tag, "alt") ?? attrOf(tag, "title");
248
+ if (/(^|\s)slide(\s|$)/i.test(cls)) {
249
+ slides += 1;
250
+ out.push({ at, tag, attr: "data-pipeup-slide", value: String(slides), took: `slide ${slides}` });
251
+ continue;
252
+ }
253
+ if (MEDIA.has(name)) {
254
+ const src = attrOf(tag, "src") ?? "";
255
+ const file = src.split(/[?#]/)[0].split("/").pop() ?? "";
256
+ const label = (
257
+ named ??
258
+ attrOf(tag, "id") ??
259
+ file.replace(/\.[a-z0-9]+$/i, "").replace(/[-_]+/g, " ")
260
+ ).trim();
261
+ const kind =
262
+ name === "img" ? "an image" : name === "svg" ? "a drawing (svg)" : "a canvas (often a chart)";
263
+ out.push({
264
+ at,
265
+ tag,
266
+ attr: "data-pipeup-label",
267
+ value: label || (name === "img" ? "image" : "chart"),
268
+ took: `${kind}${file ? ` (${file})` : ""}`,
269
+ });
270
+ continue;
271
+ }
272
+ if (!BLOCKS.has(name)) continue;
273
+ // Its words: for a section or article its first heading, else its own text.
274
+ const rest = html.slice(re.lastIndex, re.lastIndex + 4000);
275
+ const close = rest.toLowerCase().indexOf(`</${name}`);
276
+ const inner = close < 0 ? rest : rest.slice(0, close);
277
+ const heading = /<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]\s*>/i.exec(inner);
278
+ // A section with a heading is named through its heading: offering both would name one part twice.
279
+ if ((name === "section" || name === "article") && heading) continue;
280
+ const words = textOf(name === "section" || name === "article" ? (heading?.[1] ?? inner) : inner);
281
+ let base = kebab(attrOf(tag, "id") ?? named ?? words) || name;
282
+ let proposal = base;
283
+ for (let n = 2; used.has(proposal); n++) proposal = `${base}-${n}`;
284
+ used.add(proposal);
285
+ const kinds = {
286
+ section: "a section",
287
+ article: "an article",
288
+ figure: "a figure",
289
+ table: "a table",
290
+ pre: "a code block",
291
+ blockquote: "a quotation",
292
+ };
293
+ const kind = kinds[name] ?? "a heading";
294
+ const quote = words ? ` "${words.slice(0, 60)}${words.length > 60 ? "…" : ""}"` : "";
295
+ out.push({ at, tag, attr: "data-pipeup-id", value: proposal, took: `${kind}${quote}` });
296
+ }
297
+ return out;
298
+ }
299
+
300
+ /** Applies edits (each { at, del, text }) from the end of the file back, so earlier offsets stay right. */
301
+ function apply(html, edits) {
302
+ let out = html;
303
+ for (const e of [...edits].sort((a, b) => b.at - a.at || b.order - a.order))
304
+ out = out.slice(0, e.at) + e.text + out.slice(e.at + (e.del ?? 0));
305
+ return out;
306
+ }
307
+
308
+ /** Sets (or appends to) an attribute on the <html> start tag: returns the edit, or null when nothing changes. */
309
+ function htmlAttr(found, name, value, { append = false } = {}) {
310
+ const { at, tag } = found.html;
311
+ const span = attrSpan(tag, name);
312
+ if (!span) return { at: at + attrEnd(tag), text: ` ${name}="${escapeAttr(value)}"` };
313
+ if (!append) return null;
314
+ const current = unescapeAttr(tag.slice(span.start, span.end));
315
+ const items = current.split(/[\s,]+/).filter(Boolean);
316
+ const add = value.split(/[\s,]+/).filter((v) => v && !items.includes(v));
317
+ if (!add.length) return null;
318
+ const next = [...items, ...add].join(current.includes(",") ? "," : " ");
319
+ return { at: at + span.start, del: span.end - span.start, text: escapeAttr(next) };
320
+ }
321
+
322
+ /**
323
+ * Plans and makes the changes. o: { page, yes, dryRun, update, offline, marks, addons: string[], agent, values,
324
+ * input, log }. Resolves to { changed, marked }.
325
+ */
326
+ export async function init(o) {
327
+ const log = o.log ?? console.log;
328
+ const V = await version();
329
+ let html;
330
+ try {
331
+ html = await readFile(o.page, "utf8");
332
+ } catch (e) {
333
+ throw new InitError(`can't read ${o.page}: ${e.message}`, 1);
334
+ }
335
+ const found = survey(html);
336
+ const edits = [];
337
+ const says = [];
338
+ const notes = [];
339
+ let order = 0;
340
+ const edit = (e, say) => {
341
+ if (!e) return;
342
+ edits.push({ ...e, order: order++ });
343
+ if (say) says.push(say);
344
+ };
345
+ const io = o.input ? answers(o.input) : null;
346
+ const ask = async (question) => {
347
+ if (!io) return null;
348
+ process.stdout.write(question);
349
+ const line = await io.next();
350
+ // A piped answer isn't echoed: end the prompt's line so the next starts on its own.
351
+ if (line === null || !o.input.isTTY) process.stdout.write("\n");
352
+ return line;
353
+ };
354
+ try {
355
+ const wanted = [...new Set(o.addons ?? [])];
356
+ if (o.agent && !wanted.includes("agents")) wanted.push("agents");
357
+ if (o.offline && wanted.length)
358
+ throw new InitError(
359
+ "add-ons can't be put inside the page yet (their code isn't in this package); leave out --offline to add them by address",
360
+ );
361
+ const table = wanted.length ? await addonTable() : null;
362
+ if (wanted.length && !table)
363
+ throw new InitError(
364
+ "this install of pipeup doesn't carry the add-ons' hashes (dist/addons.json); install it from npm and run this again",
365
+ 1,
366
+ );
367
+
368
+ // Pipeup itself.
369
+ const spot = scriptSpot(html);
370
+ const coreFile = await readFile(new URL("dist/pipeup.min.js", PKG));
371
+ let addonAt = found.core ? found.core.end : spot.at;
372
+ const addonWrap = found.core
373
+ ? (line) => {
374
+ const lineStart = html.lastIndexOf("\n", found.core.at - 1) + 1;
375
+ const indent = /^\s*/.exec(html.slice(lineStart, found.core.at))?.[0] ?? "";
376
+ return `\n${indent}${line}`;
377
+ }
378
+ : spot.wrap;
379
+ if (!found.core) {
380
+ if (o.offline)
381
+ edit(
382
+ { at: spot.at, text: spot.wrap(`<script>${coreFile.toString("utf8").trim()}</script>`) },
383
+ `Put Pipeup ${V} inside the page (${(coreFile.length / 1024).toFixed(0)} KB), so it works with no network.`,
384
+ );
385
+ else
386
+ edit(
387
+ {
388
+ at: spot.at,
389
+ text: spot.wrap(
390
+ `<script src="${CDN}/pipeup@${V}/dist/pipeup.min.js" integrity="${sri(coreFile)}" crossorigin="anonymous"></script>`,
391
+ ),
392
+ },
393
+ `Add Pipeup ${V} from the CDN, pinned to that version and its integrity hash.`,
394
+ );
395
+ } else if (found.core.version === null) {
396
+ notes.push(
397
+ found.core.inline
398
+ ? "The page carries Pipeup inside it; its version couldn't be read, so it is left as it is."
399
+ : "The page loads Pipeup from a file beside it, without a version in its address, so it is left as it is.",
400
+ );
401
+ } else {
402
+ const c = compare(found.core.version, V);
403
+ if (c === 0) notes.push(`The page already uses Pipeup ${V}.`);
404
+ else if (c > 0)
405
+ notes.push(
406
+ `The page uses Pipeup ${found.core.version}, newer than this command's ${V}; it is left as it is.`,
407
+ );
408
+ else if (!o.update)
409
+ notes.push(`The page uses Pipeup ${found.core.version}. Run this with --update to move it to ${V}.`);
410
+ else if (found.core.inline)
411
+ notes.push(
412
+ `The page carries Pipeup ${found.core.version} inside it; replace it by hand, or remove it and run this again.`,
413
+ );
414
+ else {
415
+ const tag = found.core.tag;
416
+ const src = attrSpan(tag, "src");
417
+ const value = tag.slice(src.start, src.end).replace(found.core.version, V);
418
+ edit(
419
+ { at: found.core.at + src.start, del: src.end - src.start, text: value },
420
+ `Move the page from Pipeup ${found.core.version} to ${V}. See https://github.com/pipeup-ai/pipeup/blob/main/CHANGELOG.md for what changed.`,
421
+ );
422
+ const integrity = attrSpan(tag, "integrity");
423
+ if (integrity)
424
+ edit({
425
+ at: found.core.at + integrity.start,
426
+ del: integrity.end - integrity.start,
427
+ text: sri(coreFile),
428
+ });
429
+ else
430
+ edit({
431
+ at: found.core.at + attrEnd(tag),
432
+ text: ` integrity="${sri(coreFile)}" crossorigin="anonymous"`,
433
+ });
434
+ }
435
+ }
436
+
437
+ // The document identity: added once, never changed.
438
+ if (attrOf(found.html.tag, "data-pipeup-doc") === null) {
439
+ const { newDocumentAttribute } = await import(new URL("dist/pipeup.core.js", PKG).href);
440
+ edit(
441
+ htmlAttr(found, "data-pipeup-doc", newDocumentAttribute()),
442
+ "Give the page a document identity (data-pipeup-doc), so its comments are recognised wherever the file goes.",
443
+ );
444
+ } else notes.push("The page keeps its document identity.");
445
+
446
+ // The agent, then the add-ons.
447
+ if (o.agent) {
448
+ let key = null;
449
+ try {
450
+ key = JSON.parse(await readFile(KEYFILE(), "utf8")).publicKey;
451
+ } catch {
452
+ // no agent yet
453
+ }
454
+ if (!key)
455
+ throw new InitError("this computer has no agent yet; run `pipeup agent` first, then this again");
456
+ edit(
457
+ htmlAttr(found, "data-pipeup-agents", key, { append: true }),
458
+ `List this computer's agent on the page (data-pipeup-agents).`,
459
+ );
460
+ }
461
+ const attrs = new Set();
462
+ for (const id of wanted) {
463
+ const a = table[id];
464
+ if (!a)
465
+ throw new InitError(
466
+ `there is no add-on called "${id}"; the add-ons are: ${Object.keys(table).join(", ")}`,
467
+ );
468
+ if (found.addons.has(id)) {
469
+ notes.push(`The page already has the ${id} add-on.`);
470
+ continue;
471
+ }
472
+ if (a.asks && !attrs.has(a.asks.attr) && attrOf(found.html.tag, a.asks.attr) === null) {
473
+ let value = o.values?.[a.asks.flag];
474
+ if (typeof value !== "string" && !o.yes)
475
+ value = (await ask(`The ${id} add-on needs ${a.asks.what}: `))?.trim();
476
+ if (typeof value !== "string" || !value)
477
+ throw new InitError(`the ${id} add-on needs ${a.asks.what}; pass it with --${a.asks.flag} <value>`);
478
+ if (!new RegExp(a.asks.pattern).test(value)) throw new InitError(`"${value}" isn't ${a.asks.what}`);
479
+ attrs.add(a.asks.attr);
480
+ edit(htmlAttr(found, a.asks.attr, value), `Set ${a.asks.attr}="${value}" for the ${id} add-on.`);
481
+ }
482
+ edit(
483
+ {
484
+ at: addonAt,
485
+ text: addonWrap(
486
+ `<script src="${CDN}/@pipeup/${id}@${V}/dist/${id}.min.js" integrity="${a.integrity}" crossorigin="anonymous"></script>`,
487
+ ),
488
+ },
489
+ `Add the ${id} add-on, pinned like Pipeup. What it sends: ${a.says}`,
490
+ );
491
+ }
492
+ const adding = wanted.filter((id) => table?.[id] && !found.addons.has(id));
493
+ if (adding.length) {
494
+ // A page without the list runs every add-on it loads: starting one lists those it already has too.
495
+ const list =
496
+ attrOf(found.html.tag, "data-pipeup-addons") === null ? [...found.addons.keys(), ...adding] : adding;
497
+ edit(
498
+ htmlAttr(found, "data-pipeup-addons", list.join(","), { append: true }),
499
+ `Allow ${adding.join(", ")} in the page's list of add-ons (data-pipeup-addons).`,
500
+ );
501
+ }
502
+
503
+ // Say it, ask, write.
504
+ for (const n of notes) log(n);
505
+ let changed = false;
506
+ if (says.length) {
507
+ log(`${o.dryRun ? "This would change" : "This changes"} ${o.page}:`);
508
+ for (const s of says) log(` - ${s}`);
509
+ if (o.dryRun) return { changed: false, marked: 0 };
510
+ let go = o.yes;
511
+ if (!go) {
512
+ const reply = await ask("Go ahead? [y/N] ");
513
+ go = /^y(es)?$/i.test(reply?.trim() ?? "");
514
+ }
515
+ if (!go) {
516
+ log("Nothing changed. Run it again with --yes to go ahead without being asked.");
517
+ return { changed: false, marked: 0 };
518
+ }
519
+ html = apply(html, edits);
520
+ try {
521
+ await writeFile(o.page, html);
522
+ } catch (e) {
523
+ throw new InitError(`can't write ${o.page}: ${e.message}`, 1);
524
+ }
525
+ changed = true;
526
+ log(`Changed ${o.page}.`);
527
+ } else if (o.dryRun) {
528
+ log("Nothing to change.");
529
+ return { changed: false, marked: 0 };
530
+ } else if (!o.marks) log("Nothing to change.");
531
+
532
+ // Marks: offered unless --yes; --marks goes straight here.
533
+ let marked = 0;
534
+ if ((o.marks || !o.yes) && io) {
535
+ const parts = candidates(html);
536
+ if (!parts.length) log("Every part of the page that needs a stable name has one.");
537
+ else {
538
+ log(
539
+ `\nStable names keep comments attached when the page changes. ${parts.length} part${parts.length === 1 ? "" : "s"} could have one.\nFor each: Enter accepts the name, type another name to use it, s skips, q stops.`,
540
+ );
541
+ const chosen = [];
542
+ for (const p of parts) {
543
+ const reply = await ask(
544
+ ` ${p.took} → ${p.attr === "data-pipeup-slide" ? `slide ${p.value}` : p.value}: `,
545
+ );
546
+ if (reply === null || /^q$/i.test(reply.trim())) break;
547
+ const r = reply.trim();
548
+ if (/^s$/i.test(r)) continue;
549
+ let value = r || p.value;
550
+ if (p.attr === "data-pipeup-id") value = kebab(value) || p.value;
551
+ if (p.attr === "data-pipeup-slide" && !/^\d+$/.test(value)) value = p.value;
552
+ chosen.push({
553
+ at: p.at + attrEnd(p.tag),
554
+ text: ` ${p.attr}="${escapeAttr(value)}"`,
555
+ order: order++,
556
+ });
557
+ }
558
+ if (chosen.length) {
559
+ html = apply(html, chosen);
560
+ try {
561
+ await writeFile(o.page, html);
562
+ } catch (e) {
563
+ throw new InitError(`can't write ${o.page}: ${e.message}`, 1);
564
+ }
565
+ marked = chosen.length;
566
+ log(
567
+ `Marked ${marked} part${marked === 1 ? "" : "s"}. Run \`pipeup check ${o.page}\` to see what else the page could do better.`,
568
+ );
569
+ } else log("No parts marked.");
570
+ }
571
+ } else if (o.marks && !io)
572
+ log("Marking needs answers: run it in a terminal, or pipe one answer per line.");
573
+ else if (changed && o.yes)
574
+ log(
575
+ `Stable names keep comments attached when the page changes: run \`pipeup init ${o.page} --marks\` to add them.`,
576
+ );
577
+ return { changed, marked };
578
+ } finally {
579
+ io?.close();
580
+ }
581
+ }
package/cli/pipeup.mjs CHANGED
@@ -5,6 +5,8 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { basename, dirname, join, resolve } from "node:path";
7
7
  import { check, table } from "./check.mjs";
8
+ import { init, InitError } from "./init.mjs";
9
+ import { read, ReadError } from "./read.mjs";
8
10
  import { comments, watch } from "./link.mjs";
9
11
  import * as skills from "./skills.mjs";
10
12
 
@@ -14,7 +16,23 @@ const core = () => import("../dist/pipeup.core.js");
14
16
  const KEYFILE = process.env.PIPEUP_AGENT_FILE || join(homedir(), ".pipeup", "agent.json");
15
17
  const AI = "AI assistant";
16
18
 
17
- const USAGE = `pipeup skills install|remove|list [--project] [--agent claude|codex|all] [--yes] [--replace] [--dry-run]
19
+ const USAGE = `pipeup init <page.html> [--yes] [--dry-run] [--update] [--offline] [--addon <id>]… [--agent] [--marks]
20
+ Adds Pipeup to the page: the script pinned to this version and its integrity hash, and a document identity if the
21
+ page has none (never a new one for a page that has one). It says what it will change and asks first; every other
22
+ byte stays as it was. --update moves a page to this version, --offline puts Pipeup inside the page, --addon adds an
23
+ add-on (saying what it sends; one that needs something asks, or takes it as --inbox <url> or --github-repo owner/repo),
24
+ --agent lists this computer's agent and adds the agents add-on. Then it offers stable names for the page's blocks,
25
+ charts and slides, one at a time (--marks goes straight there; --yes skips it). Exit codes: 0 done, 1 a file
26
+ failed, 2 the command needs something first.
27
+
28
+ pipeup read <page.html> [--from <file or folder>]… [--deep] [--all] [--json] [--no-share] [--out <file>]
29
+ Prints the page's comments without opening it, as Markdown (like Copy as Markdown) or, with --json, as data: from
30
+ feedback files and folders of them (--deep looks in folders inside), the page's shared copy (with @pipeup/share
31
+ installed; --no-share reads nothing from the network) and its replies file. Every comment is checked as the page
32
+ checks it; what was read, skipped or left out is said on standard error. --out writes one merged feedback file.
33
+ Exit codes: 0 done, 1 a source couldn't be read, 2 the command needs something first.
34
+
35
+ pipeup skills install|remove|list [--project] [--agent claude|codex|all] [--yes] [--replace] [--dry-run]
18
36
  Puts Pipeup's agent skills where Claude Code (.claude/skills) and Codex (.agents/skills) look: for you, in every
19
37
  project, or with --project for this folder only. It says what it will write, asks first (--yes to go ahead without
20
38
  asking), and never replaces a skill you changed unless you say --replace.
@@ -183,6 +201,57 @@ else if (command === "skills") {
183
201
  } catch (e) {
184
202
  fail(e.message);
185
203
  }
204
+ } else if (command === "init") {
205
+ const page = args._[0];
206
+ if (!page) fail(`missing the page\n\n${USAGE}`);
207
+ // --addon may be given more than once: read every one from the raw arguments.
208
+ const addons = [];
209
+ for (let i = 0; i < rest.length; i++)
210
+ if (rest[i] === "--addon" && rest[i + 1] && !rest[i + 1].startsWith("--")) addons.push(rest[++i]);
211
+ const values = {};
212
+ for (const flag of ["inbox", "github-repo"]) if (typeof args[flag] === "string") values[flag] = args[flag];
213
+ const yes = args.yes === true;
214
+ const marks = args.marks === true;
215
+ try {
216
+ await init({
217
+ page,
218
+ yes,
219
+ dryRun: args["dry-run"] === true,
220
+ update: args.update === true,
221
+ offline: args.offline === true,
222
+ marks,
223
+ agent: args.agent === true,
224
+ addons,
225
+ values,
226
+ input: !yes || marks ? process.stdin : undefined,
227
+ });
228
+ } catch (e) {
229
+ console.error(`pipeup: ${e.message}`);
230
+ process.exit(e instanceof InitError ? e.code : 1);
231
+ }
232
+ } else if (command === "read") {
233
+ const page = args._[0];
234
+ if (!page) fail(`missing the page\n\n${USAGE}`);
235
+ const from = [];
236
+ for (let i = 0; i < rest.length; i++)
237
+ if (rest[i] === "--from" && rest[i + 1] && !rest[i + 1].startsWith("--")) from.push(rest[++i]);
238
+ try {
239
+ const { text, failed } = await read({
240
+ page,
241
+ from,
242
+ deep: args.deep === true,
243
+ all: args.all === true,
244
+ json: args.json === true,
245
+ share: args["no-share"] !== true,
246
+ out: typeof args.out === "string" ? args.out : undefined,
247
+ local: process.env.PIPEUP_SHARE_LOCAL === "1",
248
+ });
249
+ console.log(text);
250
+ process.exit(failed ? 1 : 0);
251
+ } catch (e) {
252
+ console.error(`pipeup: ${e.message}`);
253
+ process.exit(e instanceof ReadError ? e.code : 1);
254
+ }
186
255
  } else if (command === "check") {
187
256
  const page = args._[0];
188
257
  if (!page) fail(`missing the page\n\n${USAGE}`);