@bongos/core 1.19.491 → 1.19.493
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/.bongos-core.json +68 -43
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +151 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +106 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +56 -34
- package/docs/copy-registry.json +237 -39
- package/docs/design/mocks/hall/task/v1.html +7 -11
- package/docs/design/mocks/hall/task/v3.html +7 -11
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/module.json +2 -2
- package/modules/copy-desk/proposals.js +278 -0
- package/modules/copy-desk/routes/copy-desk.js +173 -9
- package/modules/copy-desk/tests/copy_no_cms.mjs +90 -12
- package/modules/copy-desk/tests/copy_proposals.mjs +279 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/copy-desk.css +9 -1
- package/modules/hall-ui/public/copy-desk.html +33 -0
- package/modules/hall-ui/public/copy-desk.js +134 -3
- package/modules/hall-ui/public/copy-desk.states.json +18 -1
- package/modules/hall-ui/public/task.css +71 -1
- package/modules/hall-ui/public/task.js +132 -13
- package/modules/hall-ui/public/task.states.json +1 -1
- package/modules/lifecycle/db-claim-reads.js +40 -0
- package/modules/lifecycle/db.js +4 -0
- package/modules/lifecycle/lifecycle.js +6 -0
- package/modules/lifecycle/routes/tasks.js +12 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/copy-apply.js +338 -0
- package/scripts/gds/copy-inventory.js +128 -30
- package/src/module-api.js +1 -1
- package/tests/hall_collab_world.mjs +24 -4
- package/tests/hall_record_world.mjs +183 -2
- package/tests/task_detail_includes.mjs +108 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
//
|
|
4
|
+
// scripts/gds/copy-apply.js — land a copy proposal as a real diff (R03 of goal
|
|
5
|
+
// 1000074, the artist's loop; task 1003114).
|
|
6
|
+
//
|
|
7
|
+
// WHAT IT IS FOR. R03's done-when is that "an artist with no terminal can propose
|
|
8
|
+
// a copy change that reaches main through a claim". The copy desk gives them the
|
|
9
|
+
// first half: a proposal becomes a GDS task carrying a patch
|
|
10
|
+
// (modules/copy-desk/proposals.js). This command is the second half — the thing
|
|
11
|
+
// whoever claims that task runs. It is the direct analogue of `adapter.export()`
|
|
12
|
+
// in the design half (ADR 0081, modules/ui-design/adapters/*): tool output lands
|
|
13
|
+
// in the repo as a committed diff under a claim, with a path guard, and the repo
|
|
14
|
+
// stays the source of truth. Nothing about the copy path bypasses the ledger,
|
|
15
|
+
// because this command IS the ledger's hands.
|
|
16
|
+
//
|
|
17
|
+
// WHY IT RE-DERIVES INSTEAD OF TRUSTING THE PATCH. The patch names a file and a
|
|
18
|
+
// line, but a line number is stale the moment anything above it changes, and the
|
|
19
|
+
// registry's committed rows deliberately carry no character offsets (see the
|
|
20
|
+
// note in copy-inventory.js's buildRegistry). So this re-runs R01's OWN
|
|
21
|
+
// extractor over the working tree and finds the occurrence there. One set of
|
|
22
|
+
// matching rules, in one file, used by both the inventory and the applier —
|
|
23
|
+
// which is what stops this command from editing a byte the inventory would never
|
|
24
|
+
// have called copy.
|
|
25
|
+
//
|
|
26
|
+
// THE REFUSALS ARE THE FEATURE. A copy change that lands on the wrong characters
|
|
27
|
+
// is worse than one that does not land, so every ambiguity is a named refusal
|
|
28
|
+
// with the reason on one line: the string is gone, it moved and is no longer
|
|
29
|
+
// unique, its literal is escaped so no exact span exists, the placeholder
|
|
30
|
+
// arithmetic does not hold, or the file is outside a registry surface. None of
|
|
31
|
+
// them means the wording was wrong — they mean a person should look.
|
|
32
|
+
//
|
|
33
|
+
// Run: node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
|
|
34
|
+
// node scripts/gds/copy-apply.js --dry-run (say what would change, write nothing)
|
|
35
|
+
// node scripts/gds/copy-apply.js --task 1234 (a named task, when you hold more than one)
|
|
36
|
+
// node scripts/gds/copy-apply.js --patch-file p.json (a local patch — used by the tests)
|
|
37
|
+
//
|
|
38
|
+
// WHY THE BARE FORM DEFAULTS TO YOUR CLAIM rather than taking an id. The task a
|
|
39
|
+
// proposal creates has to print the exact line that lands it, and it cannot know
|
|
40
|
+
// its own id at the moment its description is written. Every workaround for that
|
|
41
|
+
// is worse than the rule it would paper over: this command may only be run under
|
|
42
|
+
// a claim (that is the whole point of the feature), and a claim already names a
|
|
43
|
+
// task. So the bare call reads the claim, and holding no claim is a refusal that
|
|
44
|
+
// says to go and claim the task — the methodology's own rule, enforced by the
|
|
45
|
+
// command instead of restated in its output.
|
|
46
|
+
|
|
47
|
+
const fs = require('node:fs');
|
|
48
|
+
const path = require('node:path');
|
|
49
|
+
|
|
50
|
+
const ci = require('./copy-inventory.js');
|
|
51
|
+
const proposals = require('../../modules/copy-desk/proposals.js');
|
|
52
|
+
const { apiCall, arg, hasFlag, cliExit } = require('./cli-lib.js');
|
|
53
|
+
|
|
54
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
55
|
+
const PLACEHOLDER = proposals.PLACEHOLDER;
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// PURE below this line, down to `main`. The tests drive these directly with
|
|
59
|
+
// string sources — no fs, no network, no task.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
// THE PATH GUARD, and it is the same rule the design adapter enforces: an export
|
|
63
|
+
// may only write inside the surfaces the contract knows about. Here the surface
|
|
64
|
+
// roots come from R01's own SURFACES table, so a patch can only ever touch a
|
|
65
|
+
// directory the inventory actually walks. A patch naming anything else — a
|
|
66
|
+
// traversal, a config file, a migration — is refused before a single byte is
|
|
67
|
+
// read, not after.
|
|
68
|
+
function surfaceRootFor(surfaceId) {
|
|
69
|
+
const surface = ci.SURFACES.find((s) => s.id === surfaceId);
|
|
70
|
+
return surface ? surface.at : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkPatchPath(patch) {
|
|
74
|
+
const root = surfaceRootFor(patch.surface);
|
|
75
|
+
if (!root) return { ok: false, code: 'unknown_surface', detail: { surface: patch.surface, known: ci.SURFACES.map((s) => s.id) } };
|
|
76
|
+
// Compare normalized, repo-relative, forward-slash paths — the same form the
|
|
77
|
+
// registry stores — and require the file to sit under its surface's root.
|
|
78
|
+
const rel = String(patch.file).replace(/\\/g, '/');
|
|
79
|
+
const abs = path.resolve(REPO_ROOT, rel);
|
|
80
|
+
const rootAbs = path.resolve(REPO_ROOT, root);
|
|
81
|
+
if (!abs.startsWith(rootAbs + path.sep)) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
code: 'path_outside_surface',
|
|
85
|
+
detail: { file: rel, surface: patch.surface, must_be_under: root },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, rel, abs };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Find the one occurrence this patch is about, in the tree as it stands now.
|
|
92
|
+
//
|
|
93
|
+
// Matching is on (origin, current text) within the file, NOT on the line: a line
|
|
94
|
+
// number is the first thing to go stale, and the registry id is explicitly
|
|
95
|
+
// designed to survive a string moving. The line is used only to disambiguate
|
|
96
|
+
// when the same sentence appears at several sites in one file — which is the
|
|
97
|
+
// duplicate case R01 already reports, and where guessing would be indefensible.
|
|
98
|
+
function locateOccurrence(source, rel, patch) {
|
|
99
|
+
const rows = (path.extname(rel) === '.html'
|
|
100
|
+
? ci.fromHtml({ source, rel })
|
|
101
|
+
: ci.fromJs({ source, rel }))
|
|
102
|
+
.filter((r) => r.origin === patch.origin && r.text === patch.current);
|
|
103
|
+
|
|
104
|
+
if (!rows.length) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
code: 'target_gone',
|
|
108
|
+
detail: {
|
|
109
|
+
file: rel,
|
|
110
|
+
looked_for: patch.current,
|
|
111
|
+
what_this_means: 'That exact wording is no longer in this file — it has already been edited, or the proposal was written against an older tree. Nothing was changed. Re-read the string on the copy desk and propose again if it still needs work.',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
let row = rows[0];
|
|
116
|
+
if (rows.length > 1) {
|
|
117
|
+
const onLine = rows.filter((r) => r.line === patch.line);
|
|
118
|
+
if (onLine.length !== 1) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
code: 'ambiguous_target',
|
|
122
|
+
detail: {
|
|
123
|
+
file: rel,
|
|
124
|
+
occurrences: rows.length,
|
|
125
|
+
lines: rows.map((r) => r.line),
|
|
126
|
+
what_this_means: 'This sentence appears more than once in this file and the proposal cannot say which one it meant. That is the duplicate case the copy desk reports — decide whether they should all change, and land it as an ordinary edit under this claim.',
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
row = onLine[0];
|
|
131
|
+
}
|
|
132
|
+
if (row.start === undefined) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
code: 'no_exact_span',
|
|
136
|
+
detail: {
|
|
137
|
+
file: rel,
|
|
138
|
+
line: row.line,
|
|
139
|
+
what_this_means: 'This string lives inside an escaped quoted literal, so the inventory cannot point at its exact characters and this command will not guess. Edit it by hand under this claim.',
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, row };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Rebuild the replacement text, re-injecting the source's own interpolations.
|
|
147
|
+
//
|
|
148
|
+
// The registry writes `{…}` where the source has `${expr}`, so the artist edits a
|
|
149
|
+
// readable sentence with holes in it. Landing it means putting the ORIGINAL
|
|
150
|
+
// expressions back, in order — the k-th hole takes the k-th expression. The
|
|
151
|
+
// count was already checked when the proposal was made; it is checked again here
|
|
152
|
+
// against the real source, because the source is what is about to be written.
|
|
153
|
+
function reinjectInterpolations(rawSpan, proposedText) {
|
|
154
|
+
const expressions = String(rawSpan).match(/\$\{[^{}]*\}/g) || [];
|
|
155
|
+
const holes = proposals.placeholderCount(proposedText);
|
|
156
|
+
if (holes !== expressions.length) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
code: 'placeholder_mismatch',
|
|
160
|
+
detail: {
|
|
161
|
+
expected: expressions.length,
|
|
162
|
+
got: holes,
|
|
163
|
+
placeholder: PLACEHOLDER,
|
|
164
|
+
what_this_means: 'The values this sentence drops in at render time do not line up with the proposed wording. Nothing was changed.',
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
let out = '';
|
|
169
|
+
let rest = String(proposedText);
|
|
170
|
+
for (const expr of expressions) {
|
|
171
|
+
const at = rest.indexOf(PLACEHOLDER);
|
|
172
|
+
out += rest.slice(0, at) + expr;
|
|
173
|
+
rest = rest.slice(at + PLACEHOLDER.length);
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, text: out + rest };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The whole edit, as a pure function of the file's current contents and a patch.
|
|
179
|
+
// Returns the new source — or a named refusal. Writes nothing.
|
|
180
|
+
function applyPatchToSource(source, rel, patch) {
|
|
181
|
+
const located = locateOccurrence(source, rel, patch);
|
|
182
|
+
if (!located.ok) return located;
|
|
183
|
+
const { row } = located;
|
|
184
|
+
const rawSpan = source.slice(row.start, row.end);
|
|
185
|
+
const rebuilt = reinjectInterpolations(rawSpan, patch.proposed);
|
|
186
|
+
if (!rebuilt.ok) return rebuilt;
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
line: row.line,
|
|
190
|
+
before: rawSpan,
|
|
191
|
+
after: rebuilt.text,
|
|
192
|
+
source: source.slice(0, row.start) + rebuilt.text + source.slice(row.end),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// The command.
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function refuse(result) {
|
|
201
|
+
const detail = result.detail || {};
|
|
202
|
+
console.error(`copy-apply: REFUSED (${result.code})`);
|
|
203
|
+
if (detail.what_this_means) console.error(` ${detail.what_this_means}`);
|
|
204
|
+
for (const [k, v] of Object.entries(detail)) {
|
|
205
|
+
if (k === 'what_this_means') continue;
|
|
206
|
+
console.error(` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`);
|
|
207
|
+
}
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The task this session is holding. Returns a named refusal rather than an id
|
|
212
|
+
// when there is nothing to act under — "claim it first" is the correct answer,
|
|
213
|
+
// not an inconvenience to route around.
|
|
214
|
+
async function claimedTaskId() {
|
|
215
|
+
const me = await apiCall('GET', '/api/gds/me');
|
|
216
|
+
if (!me.ok) return { ok: false, code: 'no_session', detail: { status: me.status, run: 'node scripts/gds/setup.js' } };
|
|
217
|
+
const claims = me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []);
|
|
218
|
+
if (!claims.length) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
code: 'no_active_claim',
|
|
222
|
+
detail: {
|
|
223
|
+
what_this_means: 'A copy change lands under a claim like every other change. Claim the proposal task first (node scripts/gds/claim.js <id>), then run this again — or name one with --task <id>.',
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (claims.length > 1) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
code: 'several_active_claims',
|
|
231
|
+
detail: {
|
|
232
|
+
held: claims.map((c) => c.task_id),
|
|
233
|
+
what_this_means: 'You hold more than one claim, so this command will not guess which proposal you meant. Name it with --task <id>.',
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, taskId: claims[0].task_id };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function loadPatch({ taskId, patchFile }) {
|
|
241
|
+
if (patchFile) {
|
|
242
|
+
let raw;
|
|
243
|
+
try {
|
|
244
|
+
raw = fs.readFileSync(path.resolve(process.cwd(), patchFile), 'utf8');
|
|
245
|
+
} catch (err) {
|
|
246
|
+
return { ok: false, code: 'patch_file_unreadable', detail: { reason: err.message } };
|
|
247
|
+
}
|
|
248
|
+
// A bare patch file is still parsed through the SAME reader the task path
|
|
249
|
+
// uses, so the local shortcut can never accept a shape the real path rejects.
|
|
250
|
+
return proposals.parseProposalBlock(raw.trim().startsWith('{')
|
|
251
|
+
? ['```' + proposals.FENCE, raw.trim(), '```'].join('\n')
|
|
252
|
+
: raw);
|
|
253
|
+
}
|
|
254
|
+
const res = await apiCall('GET', `/api/gds/tasks/${taskId}`);
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
return { ok: false, code: 'task_unreadable', detail: { task: taskId, status: res.status } };
|
|
257
|
+
}
|
|
258
|
+
const task = (res.data && res.data.task) || {};
|
|
259
|
+
const parsed = proposals.parseProposalBlock(task.description || '');
|
|
260
|
+
if (!parsed.ok) return parsed;
|
|
261
|
+
return { ok: true, patch: parsed.patch, task };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function main() {
|
|
265
|
+
let taskId = arg('--task');
|
|
266
|
+
const patchFile = arg('--patch-file');
|
|
267
|
+
const dryRun = hasFlag('--dry-run');
|
|
268
|
+
|
|
269
|
+
if (!taskId && !patchFile) {
|
|
270
|
+
const held = await claimedTaskId();
|
|
271
|
+
if (!held.ok) return cliExit(refuse(held));
|
|
272
|
+
taskId = held.taskId;
|
|
273
|
+
console.log(`copy-apply: the task you are holding — #${taskId}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const loaded = await loadPatch({ taskId, patchFile });
|
|
277
|
+
if (!loaded.ok) return cliExit(refuse(loaded));
|
|
278
|
+
const { patch } = loaded;
|
|
279
|
+
|
|
280
|
+
const guard = checkPatchPath(patch);
|
|
281
|
+
if (!guard.ok) return cliExit(refuse(guard));
|
|
282
|
+
|
|
283
|
+
let source;
|
|
284
|
+
try {
|
|
285
|
+
source = fs.readFileSync(guard.abs, 'utf8');
|
|
286
|
+
} catch (err) {
|
|
287
|
+
return cliExit(refuse({ code: 'file_unreadable', detail: { file: guard.rel, reason: err.message } }));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const applied = applyPatchToSource(source, guard.rel, patch);
|
|
291
|
+
if (!applied.ok) return cliExit(refuse(applied));
|
|
292
|
+
|
|
293
|
+
console.log(`copy-apply: ${guard.rel}:${applied.line} (${patch.origin})`);
|
|
294
|
+
console.log(` - ${applied.before}`);
|
|
295
|
+
console.log(` + ${applied.after}`);
|
|
296
|
+
|
|
297
|
+
if (dryRun) {
|
|
298
|
+
console.log('copy-apply: --dry-run, nothing written.');
|
|
299
|
+
return cliExit(0);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
fs.writeFileSync(guard.abs, applied.source);
|
|
303
|
+
|
|
304
|
+
// Regenerate the inventory in the same breath. The registry is a committed
|
|
305
|
+
// artifact whose CI check fails on drift, so an applier that edited a surface
|
|
306
|
+
// and left the registry describing the old tree would hand the builder a
|
|
307
|
+
// red build and a puzzle. It also gives us the verification below.
|
|
308
|
+
const { reg } = ci.write();
|
|
309
|
+
|
|
310
|
+
// VERIFY OUR OWN WORK, because a copy change that lands on the wrong characters
|
|
311
|
+
// is the failure this whole command is shaped to avoid — and the registry we
|
|
312
|
+
// just rebuilt is an independent read of the file we just wrote.
|
|
313
|
+
const nowThere = reg.entries.some((e) => e.file === guard.rel && e.text === patch.proposed);
|
|
314
|
+
if (!nowThere) {
|
|
315
|
+
console.error('copy-apply: WROTE THE FILE, but the regenerated registry does not contain the new wording at that path.');
|
|
316
|
+
console.error(' The edit landed somewhere the inventory does not read as copy. Review `git diff` before doing anything else.');
|
|
317
|
+
return cliExit(3);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
console.log(`copy-apply: applied, registry regenerated (${reg.counts.strings} strings).`);
|
|
321
|
+
console.log(' Review with `git diff`, then ship this task the normal way.');
|
|
322
|
+
return cliExit(0);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (require.main === module) {
|
|
326
|
+
main().catch((err) => {
|
|
327
|
+
console.error(`copy-apply: ${err && err.message ? err.message : err}`);
|
|
328
|
+
process.exitCode = 1;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
surfaceRootFor,
|
|
334
|
+
checkPatchPath,
|
|
335
|
+
locateOccurrence,
|
|
336
|
+
reinjectInterpolations,
|
|
337
|
+
applyPatchToSource,
|
|
338
|
+
};
|
|
@@ -132,6 +132,28 @@ function looksLikeCopy(raw) {
|
|
|
132
132
|
return true;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// THE SPAN: where a row's text physically SITS in the file, as [start, end)
|
|
136
|
+
// character offsets. Every extractor below records one alongside the text.
|
|
137
|
+
//
|
|
138
|
+
// It is deliberately NOT part of the committed registry — buildRegistry drops it
|
|
139
|
+
// (see the note there). A committed offset would go stale on the next edit to an
|
|
140
|
+
// unrelated line of the same file, and the registry's whole point is an id that
|
|
141
|
+
// survives movement. The span is a LIVE fact, recomputed from the tree by whoever
|
|
142
|
+
// needs it: today that is scripts/gds/copy-apply.js (R03 of goal 1000074), which
|
|
143
|
+
// re-runs this extractor against the working tree and uses the span to replace
|
|
144
|
+
// exactly the characters a string occupies and nothing else. Deriving it here,
|
|
145
|
+
// beside the regex that captured the text, is what keeps the applier from owning
|
|
146
|
+
// a second, drifting copy of these matching rules.
|
|
147
|
+
//
|
|
148
|
+
// The span covers the TRIMMED text — a row's `text` is whitespace-collapsed and
|
|
149
|
+
// trimmed, so the indentation around a multi-line literal belongs to the file's
|
|
150
|
+
// formatting, not to the copy, and an applier must leave it alone.
|
|
151
|
+
function trimmedSpan(group, groupStart) {
|
|
152
|
+
const lead = group.length - group.replace(/^\s+/, '').length;
|
|
153
|
+
const trail = group.length - group.replace(/\s+$/, '').length;
|
|
154
|
+
return { start: groupStart + lead, end: groupStart + group.length - trail };
|
|
155
|
+
}
|
|
156
|
+
|
|
135
157
|
// Strip <script>/<style> bodies so their contents are not read as page text.
|
|
136
158
|
// Replaced with spaces of equal length so every line number stays truthful.
|
|
137
159
|
function stripEmbedded(html) {
|
|
@@ -151,7 +173,8 @@ function fromHtml({ source, rel }) {
|
|
|
151
173
|
const textRe = />([^<>]+)</g;
|
|
152
174
|
while ((m = textRe.exec(cleaned)) !== null) {
|
|
153
175
|
if (!looksLikeCopy(m[1])) continue;
|
|
154
|
-
|
|
176
|
+
// The captured group opens one character past the '>' that anchored the match.
|
|
177
|
+
out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-text', confidence: 'certain', ...trimmedSpan(m[1], m.index + 1) });
|
|
155
178
|
}
|
|
156
179
|
|
|
157
180
|
// 2. copy-bearing attributes.
|
|
@@ -159,14 +182,19 @@ function fromHtml({ source, rel }) {
|
|
|
159
182
|
re.lastIndex = 0;
|
|
160
183
|
while ((m = re.exec(cleaned)) !== null) {
|
|
161
184
|
if (!looksLikeCopy(m[1])) continue;
|
|
162
|
-
|
|
185
|
+
// Every COPY_ATTR_RE ends with the closing quote, so the group's last
|
|
186
|
+
// character sits exactly one before the match's end. DERIVED, never
|
|
187
|
+
// searched for — indexOf would land on an earlier copy of the same text.
|
|
188
|
+
const attrStart = m.index + m[0].length - 1 - m[1].length;
|
|
189
|
+
out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: `html-attr:${attr}`, confidence: 'certain', ...trimmedSpan(m[1], attrStart) });
|
|
163
190
|
}
|
|
164
191
|
}
|
|
165
192
|
|
|
166
193
|
// 3. <title> is the page's own name — always copy.
|
|
167
194
|
const titleRe = /<title>([^<]{2,200})<\/title>/gi;
|
|
168
195
|
while ((m = titleRe.exec(source)) !== null) {
|
|
169
|
-
|
|
196
|
+
const titleStart = m.index + '<title>'.length;
|
|
197
|
+
out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-title', confidence: 'certain', ...trimmedSpan(m[1], titleStart) });
|
|
170
198
|
}
|
|
171
199
|
return out;
|
|
172
200
|
}
|
|
@@ -198,24 +226,75 @@ function unescapeLiteral(raw) {
|
|
|
198
226
|
return String(raw).replace(/\\(['"`\\])/g, '$1');
|
|
199
227
|
}
|
|
200
228
|
|
|
201
|
-
//
|
|
229
|
+
// Can offsets into this match's BODY be read as offsets into the file?
|
|
230
|
+
//
|
|
231
|
+
// A template literal (group 1) is used raw, so yes. A quoted literal is
|
|
232
|
+
// `unescapeLiteral`d first, and that SHIFTS every character after a backslash —
|
|
233
|
+
// so yes only when the unescape changed nothing. The alternative, an
|
|
234
|
+
// escape-aware index map, would buy spans for a handful of quoted-markup rows
|
|
235
|
+
// and add a second place for the escaping rules to be wrong. A row with no span
|
|
236
|
+
// is refused BY NAME in copy-apply.js instead, which is the honest trade.
|
|
237
|
+
function literalIsUnshifted(m) {
|
|
238
|
+
if (m[1] !== undefined) return true;
|
|
239
|
+
const raw = m[2] ?? m[3];
|
|
240
|
+
return raw === undefined ? false : unescapeLiteral(raw) === raw;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Interpolations are replaced by a fixed-width PLACEHOLDER before matching, so a
|
|
244
|
+
// position in the holed string is NOT a position in the source. This records the
|
|
245
|
+
// literal runs between the holes so one can be converted back to the other —
|
|
246
|
+
// which is what lets a markup row carry a source span (see `trimmedSpan`).
|
|
247
|
+
//
|
|
248
|
+
// The conversion is only ever asked for at a capture BOUNDARY, and every boundary
|
|
249
|
+
// sits on a real character: a text node's group opens just past a '>' and closes
|
|
250
|
+
// just before a '<', and neither can appear inside `{…}`. A boundary that lands
|
|
251
|
+
// where a hole meets the next run resolves to the character after the closing
|
|
252
|
+
// '}' of the interpolation, which is the right place to cut.
|
|
253
|
+
function holeMap(body) {
|
|
254
|
+
const re = /\$\{[^{}]*\}/g;
|
|
255
|
+
const runs = [];
|
|
256
|
+
let withHoles = '';
|
|
257
|
+
let last = 0;
|
|
258
|
+
let m;
|
|
259
|
+
while ((m = re.exec(body)) !== null) {
|
|
260
|
+
const literal = body.slice(last, m.index);
|
|
261
|
+
runs.push({ holed: withHoles.length, raw: last, length: literal.length });
|
|
262
|
+
withHoles += literal + PLACEHOLDER;
|
|
263
|
+
last = m.index + m[0].length;
|
|
264
|
+
}
|
|
265
|
+
const tail = body.slice(last);
|
|
266
|
+
runs.push({ holed: withHoles.length, raw: last, length: tail.length });
|
|
267
|
+
return { withHoles: withHoles + tail, runs };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function holedToRaw(runs, at) {
|
|
271
|
+
for (const run of runs) {
|
|
272
|
+
if (at >= run.holed && at <= run.holed + run.length) return run.raw + (at - run.holed);
|
|
273
|
+
}
|
|
274
|
+
return null; // only reachable for a position strictly inside a placeholder
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// A literal that carries markup: extract what a person reads out of it, each row
|
|
278
|
+
// carrying [start, end) offsets into the RAW body it was found in.
|
|
202
279
|
function copyFromMarkupLiteral(body) {
|
|
203
|
-
const withHoles = body
|
|
280
|
+
const { withHoles, runs } = holeMap(body);
|
|
204
281
|
const out = [];
|
|
282
|
+
const emit = (group, groupStart) => {
|
|
283
|
+
const t = group.replace(/\s+/g, ' ').trim();
|
|
284
|
+
if (t === PLACEHOLDER || !looksLikeCopy(t.replace(/\{…\}/g, 'x'))) return;
|
|
285
|
+
const span = trimmedSpan(group, groupStart);
|
|
286
|
+
const start = holedToRaw(runs, span.start);
|
|
287
|
+
const end = holedToRaw(runs, span.end);
|
|
288
|
+
// A span that cannot be mapped back is reported as text with no span rather
|
|
289
|
+
// than as a guess; the applier refuses such a row by name.
|
|
290
|
+
out.push(start === null || end === null ? { text: t } : { text: t, start, end });
|
|
291
|
+
};
|
|
205
292
|
let m;
|
|
206
293
|
const textRe = />([^<>]+)</g;
|
|
207
|
-
while ((m = textRe.exec(withHoles)) !== null)
|
|
208
|
-
const t = m[1].replace(/\s+/g, ' ').trim();
|
|
209
|
-
if (t === PLACEHOLDER || !looksLikeCopy(t.replace(/\{…\}/g, 'x'))) continue;
|
|
210
|
-
out.push(t);
|
|
211
|
-
}
|
|
294
|
+
while ((m = textRe.exec(withHoles)) !== null) emit(m[1], m.index + 1);
|
|
212
295
|
for (const { re } of COPY_ATTR_RES) {
|
|
213
296
|
re.lastIndex = 0;
|
|
214
|
-
while ((m = re.exec(withHoles)) !== null)
|
|
215
|
-
const t = m[1].replace(/\s+/g, ' ').trim();
|
|
216
|
-
if (!looksLikeCopy(t.replace(/\{…\}/g, 'x'))) continue;
|
|
217
|
-
out.push(t);
|
|
218
|
-
}
|
|
297
|
+
while ((m = re.exec(withHoles)) !== null) emit(m[1], m.index + m[0].length - 1 - m[1].length);
|
|
219
298
|
}
|
|
220
299
|
return out;
|
|
221
300
|
}
|
|
@@ -224,12 +303,12 @@ function fromJs({ source, rel }) {
|
|
|
224
303
|
const out = [];
|
|
225
304
|
const starts = lineIndex(source);
|
|
226
305
|
const seen = new Set(); // (text, line) — one row per string per site
|
|
227
|
-
const push = (text, index, origin) => {
|
|
306
|
+
const push = (text, index, origin, span = null) => {
|
|
228
307
|
const line = lineAt(starts, index);
|
|
229
308
|
const key = `${text}\u0000${line}\u0000${origin}`;
|
|
230
309
|
if (seen.has(key)) return;
|
|
231
310
|
seen.add(key);
|
|
232
|
-
out.push({ text, file: rel, line, origin, confidence: 'certain' });
|
|
311
|
+
out.push({ text, file: rel, line, origin, confidence: 'certain', ...(span || {}) });
|
|
233
312
|
};
|
|
234
313
|
|
|
235
314
|
let m;
|
|
@@ -238,19 +317,31 @@ function fromJs({ source, rel }) {
|
|
|
238
317
|
while ((m = JS_LITERALS.exec(source)) !== null) {
|
|
239
318
|
const body = m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '');
|
|
240
319
|
if (!body || !/<[a-zA-Z/]/.test(body) || !/>/.test(body)) continue;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
while ((m = TEXT_ASSIGN.exec(source)) !== null) {
|
|
251
|
-
const body = (m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '')).replace(/\$\{[^{}]*\}/g, PLACEHOLDER).replace(/\s+/g, ' ').trim();
|
|
252
|
-
if (body && looksLikeCopy(body.replace(/\{…\}/g, 'x'))) push(body, m.index, 'js-text-assign');
|
|
320
|
+
// Each alternative opens with exactly one delimiter, so the body starts one
|
|
321
|
+
// character past the match.
|
|
322
|
+
const mapped = literalIsUnshifted(m);
|
|
323
|
+
for (const f of copyFromMarkupLiteral(body)) {
|
|
324
|
+
const span = mapped && f.start !== undefined
|
|
325
|
+
? { start: m.index + 1 + f.start, end: m.index + 1 + f.end }
|
|
326
|
+
: null;
|
|
327
|
+
push(f.text, m.index, 'js-markup', span);
|
|
328
|
+
}
|
|
253
329
|
}
|
|
330
|
+
// 2. strings shown directly to a person. Both of these normalize the WHOLE
|
|
331
|
+
// literal into the row's text, so the span is the whole body: the delimiter
|
|
332
|
+
// closes the match, so the body ends one character before the match does.
|
|
333
|
+
const wholeLiteral = (re, origin) => {
|
|
334
|
+
re.lastIndex = 0;
|
|
335
|
+
while ((m = re.exec(source)) !== null) {
|
|
336
|
+
const rawBody = m[1] ?? m[2] ?? m[3] ?? '';
|
|
337
|
+
const body = (m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '')).replace(/\$\{[^{}]*\}/g, PLACEHOLDER).replace(/\s+/g, ' ').trim();
|
|
338
|
+
if (!body || !looksLikeCopy(body.replace(/\{…\}/g, 'x'))) continue;
|
|
339
|
+
const bodyStart = m.index + m[0].length - 1 - rawBody.length;
|
|
340
|
+
push(body, m.index, origin, literalIsUnshifted(m) ? trimmedSpan(rawBody, bodyStart) : null);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
wholeLiteral(UI_CALL, 'js-notice');
|
|
344
|
+
wholeLiteral(TEXT_ASSIGN, 'js-text-assign');
|
|
254
345
|
return out;
|
|
255
346
|
}
|
|
256
347
|
|
|
@@ -276,7 +367,14 @@ function buildRegistry() {
|
|
|
276
367
|
? fromHtml({ source, rel })
|
|
277
368
|
: fromJs({ source, rel });
|
|
278
369
|
for (const f of found) {
|
|
279
|
-
|
|
370
|
+
// THE SPAN IS DROPPED HERE, DELIBERATELY. Every extractor records where a
|
|
371
|
+
// string physically sits (see `trimmedSpan`), and none of it is committed:
|
|
372
|
+
// an offset goes stale the moment anything above it in the file changes,
|
|
373
|
+
// so a registry carrying one would be wrong far more often than the id —
|
|
374
|
+
// which is designed to survive exactly that movement — ever is. The span
|
|
375
|
+
// is recomputed from the tree by the applier, at the moment it edits.
|
|
376
|
+
const { start, end, ...row } = f;
|
|
377
|
+
entries.push({ id: stableId(surface.id, f.text), surface: surface.id, ...row });
|
|
280
378
|
count += 1;
|
|
281
379
|
}
|
|
282
380
|
}
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.493'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -158,13 +158,32 @@ test('the filters and the search are the kit\'s bar; the duplicates are paged th
|
|
|
158
158
|
assert.match(read('copy-desk.html'), /src="\/builders\/hall-kit\.js/, 'the page loads the kit');
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
test('the
|
|
161
|
+
test('the three dialogs are the family modal on a native <dialog>, with the number input in a field', () => {
|
|
162
162
|
const html = read('copy-desk.html');
|
|
163
|
-
|
|
163
|
+
// Three since task 1003114 (R03): flag, close, and the proposal composer.
|
|
164
|
+
assert.equal((html.match(/<dialog id="cd-(close-dialog|dialog|propose-dialog)" class="ov-modal cd-dialog">/g) || []).length, 3);
|
|
164
165
|
assert.match(html, /<div class="ov-field ov-field--wide">\s*<label class="ov-field__l" for="cd-close-task">[^<]*<\/label>\s*<input type="number" id="cd-close-task"/, 'the number input sits in the family field, which restates the 30px pill');
|
|
165
166
|
assert.ok(!/<label[^>]*>[^<]*<span class="cd-dialog__hint"/.test(html), 'no hint lives inside a label (the label voice would uppercase it)');
|
|
166
|
-
assert.equal((html.match(/class="ov-form__why"/g) || []).length,
|
|
167
|
+
assert.equal((html.match(/class="ov-form__why"/g) || []).length, 4, 'the four hints are the form\'s own explainer line');
|
|
167
168
|
assert.match(html, /class="btn-accent" id="cd-submit"/, 'the accent door files the flag');
|
|
169
|
+
assert.match(html, /class="btn-accent" id="cd-propose-submit"/, 'and the proposal composer has its own');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('the proposal composer is prefilled, and says what filing it does and does not do', () => {
|
|
173
|
+
const html = read('copy-desk.html');
|
|
174
|
+
const js = read('copy-desk.js');
|
|
175
|
+
// The composer opens with the CURRENT wording in the box — an artist edits a
|
|
176
|
+
// sentence rather than retyping it out of the quote above, which is how a
|
|
177
|
+
// proposal accidentally changes a word nobody meant to change.
|
|
178
|
+
assert.match(js, /\$\('#cd-propose-text'\)\.value = t\.text;/, 'the textarea opens prefilled with the current wording');
|
|
179
|
+
// The placeholder rule is shown only when the sentence has holes, because for
|
|
180
|
+
// most strings it is noise and for a few it is the reason the change refuses.
|
|
181
|
+
assert.match(html, /id="cd-propose-holes" hidden/, 'the placeholder rule starts hidden');
|
|
182
|
+
assert.match(js, /hint\.hidden = n === 0;/, 'and is shown only for a sentence with holes');
|
|
183
|
+
// NO LIVE CMS, said to the person in the dialog rather than only in an ADR.
|
|
184
|
+
assert.match(html, /Nothing changes yet\. This files a task carrying your wording/,
|
|
185
|
+
'the composer tells the artist their words do not reach a reader until a task ships');
|
|
186
|
+
assert.match(js, /copy-desk\/proposals/, 'and it posts to the proposal route');
|
|
168
187
|
});
|
|
169
188
|
|
|
170
189
|
test('the copy desk renders MORE facts per row than any other page, and lost none', () => {
|
|
@@ -205,10 +224,11 @@ test('the harness fixtures exist and carry the shapes the pages read', () => {
|
|
|
205
224
|
|
|
206
225
|
test('the states files walk the new states', () => {
|
|
207
226
|
const cd = JSON.parse(read('copy-desk.states.json'));
|
|
208
|
-
assert.deepEqual(Object.keys(cd.states), ['queue', 'out', 'search', 'composer', 'closer']);
|
|
227
|
+
assert.deepEqual(Object.keys(cd.states), ['queue', 'out', 'search', 'composer', 'closer', 'proposer']);
|
|
209
228
|
assert.deepEqual(cd.states.search.actions[1], ['fill', '#cd-search', 'save']);
|
|
210
229
|
assert.deepEqual(cd.states.composer.actions[1], ['clickIfVisible', '.cd-flag-btn'], 'clickIfVisible: against the kit\'s stub the button does not exist');
|
|
211
230
|
assert.deepEqual(cd.states.closer.actions[1], ['clickIfVisible', '.cd-close-btn']);
|
|
231
|
+
assert.deepEqual(cd.states.proposer.actions[1], ['clickIfVisible', '.cd-propose-btn'], 'task 1003114: the proposal composer is a measured state, not one looked at once');
|
|
212
232
|
const cb = JSON.parse(read('collab.states.json'));
|
|
213
233
|
assert.deepEqual(cb.states.board.expect.visible, ['#cb-for-me']);
|
|
214
234
|
});
|