pipeup 0.7.0 → 0.8.0-beta.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.
- package/README.md +22 -13
- package/cli/init.mjs +581 -0
- package/cli/pipeup.mjs +70 -1
- package/cli/read.mjs +299 -0
- package/dist/addons.json +54 -0
- package/dist/pipeup.agents.js +1 -1
- package/dist/pipeup.core.js +1 -1
- package/dist/pipeup.esm.js +1 -1
- package/dist/pipeup.min.js +1 -1
- package/dist/types/core.d.ts +4 -2
- package/dist/types/document.d.ts +2 -2
- package/dist/types/export/agent.d.ts +1 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/ui/addons.d.ts +24 -1
- package/dist/types/ui/app.d.ts +2 -3
- package/dist/types/ui/composer.d.ts +9 -3
- package/dist/types/ui/context.d.ts +10 -4
- package/dist/types/ui/draft-view.d.ts +2 -3
- package/dist/types/ui/host.d.ts +1 -1
- package/dist/types/ui/icons.d.ts +8 -5
- package/dist/types/ui/layout.d.ts +15 -1
- package/dist/types/ui/legible.d.ts +1 -1
- package/dist/types/ui/reactions.d.ts +2 -0
- package/dist/types/ui/select.d.ts +1 -1
- package/dist/types/ui/shortcut.d.ts +6 -0
- package/dist/types/ui/styles.d.ts +1 -1
- package/dist/types/ui/thread-view.d.ts +17 -10
- package/package.json +2 -1
- package/skills/pipeup-add/SKILL.md +4 -4
- package/skills/pipeup-apply/SKILL.md +2 -2
- package/skills/pipeup-integrate/SKILL.md +4 -4
- package/skills/pipeup-send-to-git-setup/SKILL.md +2 -2
- package/skills/pipeup-summarise/SKILL.md +3 -3
- package/dist/types/crypto/portable.d.ts +0 -15
- package/dist/types/ui/identity.d.ts +0 -27
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.
|
|
5
|
+
> **Alpha (0.8.0-beta.2).** 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.
|
|
24
|
+
<script src="https://cdn.jsdelivr.net/npm/pipeup@0.8.0-beta.2/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
|
|
@@ -61,25 +61,34 @@ Examples in the source repository, one per kind of page, each following the agen
|
|
|
61
61
|
- [examples/review-site.html](https://github.com/pipeup-ai/pipeup/blob/main/libs/ts/pipeup/examples/review-site.html) — a site: clear blocks with stable ids for comment mode and pins; navigation ignored.
|
|
62
62
|
- [examples/review-deck.html](https://github.com/pipeup-ai/pipeup/blob/main/libs/ts/pipeup/examples/review-deck.html) — a deck: one marked element per slide; slide controls ignored so they keep working, and one line so Pipeup can go to a comment's slide.
|
|
63
63
|
|
|
64
|
-
Comments show
|
|
65
|
-
|
|
66
|
-
counts the open threads
|
|
64
|
+
Comments show whether comment mode is on or off: highlights, bubbles and the column can be read and opened, and
|
|
65
|
+
outside comment mode the rest of the page works as normal. **Hide comments** in the control's menu puts them out
|
|
66
|
+
of sight on that page (remembered in the browser); the control still counts the open threads, and comment mode,
|
|
67
|
+
All comments or a comment being written show them again while they last.
|
|
67
68
|
|
|
68
|
-
The comment control is
|
|
69
|
-
a comment bubble.
|
|
69
|
+
The comment control is a small square of frosted glass tucked into the bottom-right corner: the comment icon, or
|
|
70
|
+
the number of open threads inside a comment bubble. Pointed at, it reaches left to show its shortcut; clicking (or tapping) it opens its menu of
|
|
70
71
|
one-line rows, each explained by a tooltip on hover or focus. From the top: who you are (avatar and name, with
|
|
71
|
-
Add name, edited in place in that row), Copy as Markdown
|
|
72
|
-
**All comments** is a panel on the right that moves the page over to make room: every thread in page order
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
Add name, edited in place in that row), Copy comments (as Markdown for AI, or as text), Theme, All comments, a Hide comments switch, and nearest the button **Start commenting** with its shortcut and a switch, which leaves the menu open so you see it move. The menu closes on Esc, a click outside, or 3 seconds after the pointer leaves it.
|
|
73
|
+
**All comments** is a panel on the right that moves the page over to make room: every thread in page order as a
|
|
74
|
+
card, threads whose content is gone under "No longer on the page", and two icon toggles in its header, **@** (mentions
|
|
75
|
+
of me) and **✓** (show resolved). While it is open the comment control is its footer (who you are, Comment, and ⋯ for the
|
|
76
|
+
menu), comment mode leaves it open, and a new comment is written in it, in page order. Choosing a card scrolls to its
|
|
77
|
+
place and opens it in the panel; clicking a comment on the page opens it there too. The panel stays open until you
|
|
78
|
+
close it with its × or Esc; a click on the page never closes it.
|
|
79
|
+
|
|
80
|
+
Every thread is the same compact card, beside the page, from a bubble or in the panel: avatar, name and how long ago,
|
|
81
|
+
the words, and its actions (React, ✓ Resolve, and ⋯ for Copy thread, and Edit and Delete on your own). A deleted
|
|
82
|
+
comment goes away; deleting a thread's first comment removes the thread. The comment and
|
|
83
|
+
reply boxes have Cancel and Comment (Reply) buttons; Enter also sends. Elements the page
|
|
76
84
|
fixes to the window don't move over with the page.
|
|
77
85
|
|
|
78
86
|
Comment mode (press **Shift+Alt+C**, or **⇧⌥C** on a Mac, or switch on Start commenting in the control's menu) lets reviewers comment on blocks and drop pins
|
|
79
87
|
(Option-click, Alt-click, or the naming bar's Pin) on any page. While it is on, clicks and submits on the page do
|
|
80
88
|
nothing, and selecting text offers a comment icon, clicking a highlight opens its thread, and
|
|
81
89
|
clicking a block opens its comment box at once (a bar names the block; Around it and Inside it move the box to the
|
|
82
|
-
block around it or inside it, keeping what you typed, and Pin makes it a pin at the block's centre).
|
|
90
|
+
block around it or inside it, keeping what you typed, and Pin makes it a pin at the block's centre). Hold **Space**
|
|
91
|
+
to use the page as normal for a moment; letting go brings comment mode back. Esc steps back: the box and its block together, then comment mode (or an open
|
|
83
92
|
comment, then comment mode). It is a quiet page, not a
|
|
84
93
|
sandbox:
|
|
85
94
|
|
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, "&").replace(/"/g, """);
|
|
30
|
+
const unescapeAttr = (v) =>
|
|
31
|
+
v
|
|
32
|
+
.replace(/"/g, '"')
|
|
33
|
+
.replace(/'/g, "'")
|
|
34
|
+
.replace(/&/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(/ /g, " ")
|
|
204
|
+
.replace(/&/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
|
+
}
|