@bongos/core 1.19.1062 → 1.19.1064

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.
Files changed (56) hide show
  1. package/.bongos-core.json +123 -48
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +8 -0
  6. package/clients/bongos-client/index.cjs +8 -0
  7. package/clients/bongos-client/index.d.ts +12 -0
  8. package/clients/bongos-client/index.mjs +8 -0
  9. package/docs/api/openapi.json +301 -3
  10. package/docs/api-reference.md +7 -3
  11. package/docs/copy-inventory.md +44 -7
  12. package/docs/copy-registry.json +393 -26
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +36 -4
  16. package/docs/page-readings.json +34 -1
  17. package/modules/copy-desk/page-status.js +63 -0
  18. package/modules/copy-desk/pages.js +1 -0
  19. package/modules/copy-desk/routes/copy-desk.js +30 -0
  20. package/modules/hall-ui/public/tweak-editor-lib.js +137 -0
  21. package/modules/hall-ui/public/tweak-editor.css +437 -0
  22. package/modules/hall-ui/public/tweak-editor.html +111 -0
  23. package/modules/hall-ui/public/tweak-editor.js +447 -0
  24. package/modules/hall-ui/public/tweak-editor.states.json +99 -0
  25. package/modules/hall-ui/records/tweak-editor.md +32 -0
  26. package/modules/lifecycle/db-grade.js +16 -0
  27. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  28. package/modules/lifecycle/page-tweak-hold.js +74 -0
  29. package/modules/lifecycle/page-tweak-reads.js +33 -1
  30. package/modules/lifecycle/routes/tasks.js +4 -2
  31. package/modules/lifecycle/routes/visuals.js +82 -0
  32. package/modules/lifecycle/task-visual-db.js +40 -1
  33. package/modules/lifecycle/task-visuals.js +24 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +12 -0
  37. package/scripts/gds/copy-apply.js +277 -1
  38. package/scripts/gds/page-reader.js +2 -0
  39. package/scripts/gds/ship-finish.js +27 -2
  40. package/scripts/gds/ship-flow.js +17 -1
  41. package/scripts/gds/ship-land.js +30 -7
  42. package/scripts/gds/ship-merge.js +43 -15
  43. package/scripts/gds/strand-watch.js +41 -1
  44. package/scripts/gds/task.js +23 -5
  45. package/scripts/gds/tweak-renders.js +200 -0
  46. package/scripts/hall-preview/server.js +21 -0
  47. package/src/bongos/serve-internal.js +5 -1
  48. package/src/module-api.js +1 -1
  49. package/tests/copy_desk_page_editor.mjs +181 -0
  50. package/tests/hall_audit.mjs +5 -0
  51. package/tests/hall_page_gate_map.mjs +3 -0
  52. package/tests/hall_tweak_editor.mjs +432 -0
  53. package/tests/page_tweak_hold.mjs +265 -0
  54. package/tests/publish_status_branch.mjs +23 -0
  55. package/tests/task_visual_slots.mjs +161 -0
  56. package/tests/tweak_batch_apply.mjs +265 -0
@@ -10,10 +10,10 @@
10
10
  "An id changes only when the host prefix, the data-page value or the file name changes; that is a rename, and records keyed on the old id move with it."
11
11
  ],
12
12
  "counts": {
13
- "pages": 37,
13
+ "pages": 38,
14
14
  "surfaces": 3,
15
- "with_states": 30,
16
- "states": 149
15
+ "with_states": 31,
16
+ "states": 155
17
17
  },
18
18
  "surfaces": [
19
19
  {
@@ -22,7 +22,7 @@
22
22
  "at": "modules/hall-ui/public",
23
23
  "answers_at": "host prefix builders.",
24
24
  "origin": "https://builders.{apex}",
25
- "pages": 30
25
+ "pages": 31
26
26
  },
27
27
  {
28
28
  "id": "landing",
@@ -932,6 +932,38 @@
932
932
  "out"
933
933
  ]
934
934
  },
935
+ {
936
+ "id": "builders:tweak-editor",
937
+ "surface": "builders",
938
+ "module": "hall-ui",
939
+ "page": "tweak-editor",
940
+ "id_from": "data-page",
941
+ "title": "Tweak a page",
942
+ "document_title": "Tweak a page — {{worldName}}",
943
+ "path": "/tweak-editor",
944
+ "url": "https://builders.{apex}/tweak-editor",
945
+ "files": [
946
+ "modules/hall-ui/public/tweak-editor.html",
947
+ "modules/hall-ui/public/style.css",
948
+ "modules/hall-ui/public/tweak-editor.css",
949
+ "modules/hall-ui/public/shell.js",
950
+ "modules/hall-ui/public/palette.js",
951
+ "modules/hall-ui/public/dom-utils.js",
952
+ "modules/hall-ui/public/tweak-editor-lib.js",
953
+ "modules/hall-ui/public/tweak-editor.js"
954
+ ],
955
+ "states_files": [
956
+ "modules/hall-ui/public/tweak-editor.states.json"
957
+ ],
958
+ "states": [
959
+ "doc",
960
+ "typed",
961
+ "chrome",
962
+ "submitted",
963
+ "picking",
964
+ "out"
965
+ ]
966
+ },
935
967
  {
936
968
  "id": "builders:watch",
937
969
  "surface": "builders",
@@ -5,7 +5,7 @@
5
5
  "adr": "0341 D3",
6
6
  "reread": "node scripts/gds/page-reader.js --stale",
7
7
  "how": ["Generated by node scripts/gds/page-reader.js (BV2.TW04, task 1004315; ADR 0341 D3). Do not hand-edit.","One entry per page of docs/page-inventory.json. Lines are in page order: the default state first, and a line only another state shows sits where that state shows it, with its state named.","placement: placed = a docs/copy-registry.json row renders it (file, line, string_id); shared = shell text every page of the surface carries (top bar, sidebar, footer), with its row when the registry has one; unplaced = the registry cannot place it (a rewrite becomes an engineer task, ADR 0341 D4).","text keeps {…} where the registry row has a hole, or where a value the page read from its API was filled in. A string that is only such data is counted under omitted.data, not listed.","reading_hash is over the non-shared lines (key, section, text); files_hash is over the page's inventory files. --check compares files_hash only and never opens a browser.","Rendered through the hall-preview harness (hall) and the ui-design kit stub (landing, status), at 1280x800 with the clock pinned, so a reading is the fixture world's page."],
8
- "counts": {"pages":37,"lines":3616,"placed":1117,"shared":243,"unplaced":2256,"omitted_data":1969},
8
+ "counts": {"pages":38,"lines":3636,"placed":1131,"shared":247,"unplaced":2258,"omitted_data":2010},
9
9
  "pages": [
10
10
  {
11
11
  "id": "builders:atlas",
@@ -2908,6 +2908,39 @@
2908
2908
  {"key":"L0082","section":"This is your sky.","text":"{…} file{…}","placement":"placed","file":"modules/hall-ui/public/gate.js","line":151,"string_id":"3efa67dc6e07"}
2909
2909
  ]
2910
2910
  },
2911
+ {
2912
+ "id": "builders:tweak-editor",
2913
+ "surface": "builders",
2914
+ "title": "Tweak a page",
2915
+ "states_read": ["doc","typed","chrome","submitted","picking","out"],
2916
+ "files_hash": "8f56ba72c49a8e62",
2917
+ "reading_hash": "4c09a72e05c598fd",
2918
+ "counts": {"lines":20,"placed":14,"shared":4,"unplaced":2},
2919
+ "omitted": {"data":41},
2920
+ "notes": [],
2921
+ "lines": [
2922
+ {"key":"L0001","section":"skip link","text":"Skip to content","placement":"shared","file":"modules/hall-ui/public/shell.js","line":776,"string_id":"87cee78ca134"},
2923
+ {"key":"L0002","section":"top bar","text":"Tweak a page","placement":"shared","file":"modules/hall-ui/public/tweak-editor.html","line":32,"string_id":"72a29648399b","state":"chrome"},
2924
+ {"key":"L0003","section":"top bar","text":"Jump to…","placement":"shared","file":"modules/hall-ui/public/shell.js","line":496,"string_id":"eba610d25523","state":"chrome"},
2925
+ {"key":"L0004","section":"top bar","text":"Ctrl K","placement":"shared","state":"chrome"},
2926
+ {"key":"L0005","section":"page top","text":"← studio","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":52,"string_id":"3ad4e941d265"},
2927
+ {"key":"L0006","section":"page top","text":"{…} of {…}","placement":"placed","file":"modules/hall-ui/public/sky-panel.js","line":120,"string_id":"73c1372b43d9","state":"typed","occurrences":2},
2928
+ {"key":"L0007","section":"page top","text":"open the page ↗","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":56,"string_id":"08f1f614253c"},
2929
+ {"key":"L0008","section":"page top","text":"download .docx","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":57,"string_id":"fcec9cad1c69"},
2930
+ {"key":"L0009","section":"page top","text":"upload .docx","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":58,"string_id":"432def6844b7"},
2931
+ {"key":"L0010","section":"page top","text":"submit page","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":60,"string_id":"9ec19e32f86f"},
2932
+ {"key":"L0011","section":"{…}","text":"Every word on the page, in the order it shows them. Type over anything, or download it, work in Word, and upload it back.","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":67,"string_id":"9904ca3d33e8"},
2933
+ {"key":"L0012","section":"{…}","text":"was: {…}","placement":"unplaced","occurrences":2},
2934
+ {"key":"L0013","section":"{…} project{…}","text":"{…} project{…}","placement":"placed","file":"modules/hall-ui/public/fleet.js","line":39,"string_id":"771bef3d31b5","occurrences":2},
2935
+ {"key":"L0014","section":"{…} project{…}","text":"was: {…}","placement":"unplaced"},
2936
+ {"key":"L0015","section":"{…} is in.","text":"{…} is in.","placement":"placed","file":"modules/hall-ui/public/tweak-editor.js","line":361,"string_id":"55b949873848","state":"submitted"},
2937
+ {"key":"L0016","section":"{…} is in.","text":"It comes back to your studio once it is applied, and nothing ships until you have seen it.","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":80,"string_id":"bef8dbf78170","state":"submitted"},
2938
+ {"key":"L0017","section":"{…} is in.","text":"next up","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":83,"string_id":"73bfe3b1ff54","state":"submitted"},
2939
+ {"key":"L0018","section":"{…} is in.","text":"take next","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":89,"string_id":"d4f99c118825","state":"submitted"},
2940
+ {"key":"L0019","section":"{…} is in.","text":"pick another","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":90,"string_id":"b6ceacaad5e5","state":"submitted"},
2941
+ {"key":"L0020","section":"{…} is in.","text":"back to the studio","placement":"placed","file":"modules/hall-ui/public/tweak-editor.html","line":91,"string_id":"24a2c1d958da","state":"submitted"}
2942
+ ]
2943
+ },
2911
2944
  {
2912
2945
  "id": "builders:watch",
2913
2946
  "surface": "builders",
@@ -511,7 +511,70 @@ function composeNext({ inventory, readings, tasks, asks, builderId, alternatives
511
511
  };
512
512
  }
513
513
 
514
+ // The margin note a line carries in the editor (ADR 0341 D4, TW10): shared
515
+ // shell text changes on every page, and a line the applier cannot place goes
516
+ // to an engineer at submit (pages.goesToEngineer, the submit's own rule).
517
+ const EDITOR_NOTES = Object.freeze({ shared: 'changes this on every page', engineer: 'goes to an engineer' });
518
+
519
+ function editorNote(line) {
520
+ if (pages.goesToEngineer(line)) return 'engineer';
521
+ return line.placement === 'shared' ? 'shared' : null;
522
+ }
523
+
524
+ // GET /copy-desk/pages/:pageId/draft — what the editor (task 1004321 / BV2.TW10)
525
+ // opens on: every line of the page reading in page order, the open round and
526
+ // whether the caller holds it, and the saved draft. A draft saved on another
527
+ // reading is not applied to these lines (its keys may name other lines); it is
528
+ // reported as `draft_on_older_reading` instead.
529
+ //
530
+ // `readingPage` is the page's entry in docs/page-readings.json (null when the
531
+ // page has not been read); `round` is the page's open round as the lifecycle
532
+ // port lists it, or null.
533
+ function composeEditor({ page, readingPage, round, builderId }) {
534
+ const lines = ((readingPage && readingPage.lines) || []).map((l) => ({
535
+ key: l.key, section: l.section, text: l.text, placement: l.placement, note: editorNote(l),
536
+ }));
537
+ const readingHash = readingPage ? readingPage.reading_hash || null : null;
538
+ let draft = null;
539
+ let olderReading = false;
540
+ if (round) {
541
+ const d = pages.parseDraftBlock(round.description);
542
+ if (d.ok) {
543
+ if (d.draft.reading_hash === readingHash) {
544
+ const keys = new Set(lines.map((l) => l.key));
545
+ draft = {
546
+ reading_hash: d.draft.reading_hash,
547
+ saved_at: d.draft.saved_at || null,
548
+ lines: d.draft.lines.filter((l) => keys.has(l.key)).map((l) => ({ key: l.key, after: l.after })),
549
+ };
550
+ } else {
551
+ olderReading = true;
552
+ }
553
+ }
554
+ }
555
+ const heldByYou = !!round && round.holder_id != null && String(round.holder_id) === String(builderId);
556
+ return {
557
+ ok: true,
558
+ page_id: page.id,
559
+ page: { id: page.id, title: page.title || page.id, surface: page.surface, surface_word: surfaceWord(page.surface), path: page.path || null, url: page.url || null },
560
+ reading: { reading_hash: readingHash, read: !!readingPage, lines },
561
+ notes: EDITOR_NOTES,
562
+ round: round ? {
563
+ task_id: String(round.id),
564
+ state: roundState(round),
565
+ status: round.status,
566
+ held_by_you: heldByYou,
567
+ holder: round.holder_id != null ? { id: String(round.holder_id), login: round.holder_login || null } : null,
568
+ claim: { method: 'POST', path: `/copy-desk/pages/${page.id}/claim` },
569
+ } : null,
570
+ draft,
571
+ draft_on_older_reading: olderReading,
572
+ counter: { changed: draft ? draft.lines.length : 0, total: lines.length },
573
+ };
574
+ }
575
+
514
576
  module.exports = {
577
+ composeEditor,
515
578
  TALLY_REASON_PREFIX,
516
579
  NEXT_ALTERNATIVES,
517
580
  NEXT_TIERS,
@@ -448,6 +448,7 @@ module.exports = {
448
448
  sameDraft,
449
449
  saveDraftInto,
450
450
  freezeBatch,
451
+ goesToEngineer,
451
452
  unplacedSourceRef,
452
453
  unplacedTaskTitle,
453
454
  unplacedTaskBody,
@@ -17,6 +17,7 @@
17
17
  // TW08 (task 1004319) added the draft autosave and the submit, and TW09 (task
18
18
  // 1004320) the Word round trip, GET and POST /copy-desk/pages/:pageId/draft.docx:
19
19
  // the upload lands in the round's DRAFT, through the autosave's own port call.
20
+ // TW10 (task 1004321) added the editor's read, GET /copy-desk/pages/:pageId/draft.
20
21
  //
21
22
  // THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
22
23
  //
@@ -972,6 +973,35 @@ module.exports = function buildCopyDeskRouter() {
972
973
  }
973
974
  });
974
975
 
976
+ // -------------------------------------------------------------------------
977
+ // GET /copy-desk/pages/:pageId/draft — what the tweak editor opens on (task
978
+ // 1004321 / BV2.TW10, ADR 0341 D4): every line of the page reading in page
979
+ // order with its margin note, the open round and whether the caller holds
980
+ // it, and the saved draft (only on the current reading). The read the PUT
981
+ // below saves over, and the .docx download's own data as JSON.
982
+ //
983
+ // rank: any signed-in builder, as the download: the words are the page's
984
+ // own and the draft sits on a task every builder can read.
985
+ // -------------------------------------------------------------------------
986
+ router.get('/copy-desk/pages/:pageId/draft', api.requireBuilder, async (req, res) => {
987
+ try {
988
+ const found = pageOrFail(req, res);
989
+ if (!found) return;
990
+ const { page, pageId } = found;
991
+ const lifecycle = pageReadsOrFail(res);
992
+ if (!lifecycle) return;
993
+ const readingPage = readingPageOrFail(res, pageId);
994
+ if (!readingPage) return;
995
+ const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
996
+ const out = pageStatus.composeEditor({ page, readingPage, round, builderId: req.builder.id });
997
+ res.set('Cache-Control', 'no-store');
998
+ res.json(out);
999
+ } catch (err) {
1000
+ log.error({ err }, 'GET /copy-desk/pages/:pageId/draft failed');
1001
+ if (!res.headersSent) res.fail('copy_page_editor_failed', 500);
1002
+ }
1003
+ });
1004
+
975
1005
  // -------------------------------------------------------------------------
976
1006
  // PUT /copy-desk/pages/:pageId/draft — autosave the artist's draft (task
977
1007
  // 1004319 / BV2.TW08, ADR 0341 D4).
@@ -0,0 +1,137 @@
1
+ // tweak-editor-lib.js — the tweak editor's pure half (task 1004321 / BV2.TW10,
2
+ // ADR 0341 D4; docs/design/mocks/tweak-mode/Editor.dc.html).
3
+ //
4
+ // The editor is one paper document holding every word of a page, in the order
5
+ // the page shows them, typed over in place and autosaved. Everything here is a
6
+ // pure function of the editor read (GET /copy-desk/pages/:pageId/draft) and the
7
+ // words in the boxes, so a test imports this file and runs the real rules:
8
+ //
9
+ // sectionsOf(lines) consecutive runs of one section, as the page shows
10
+ // them (a section that recurs gets its heading again —
11
+ // the Word download's own rule, docx.js)
12
+ // noteFor(line, value) the margin note: shared and engineer lines carry
13
+ // theirs, a changed line carries "was: <old words>"
14
+ // draftBody(...) the WHOLE draft the autosave PUTs: every changed,
15
+ // non-empty line as { key, after }
16
+ // counterText(...) "2 of 75 rewritten · saved"
17
+ // liveUrl(url, loc) the page's live address, {apex} resolved from the
18
+ // hall's own host (shell.js APEX_ORIGIN's rule)
19
+ //
20
+ // Node-require-safe (the roadmap-timeline.js pattern): assignment is the only
21
+ // eval-time side effect, and only when a window exists.
22
+ (() => {
23
+ 'use strict';
24
+
25
+ // The ADR's words (D4), and the read's own `notes` wins when it carries them.
26
+ const NOTES = Object.freeze({ shared: 'changes this on every page', engineer: 'goes to an engineer' });
27
+
28
+ function sectionsOf(lines) {
29
+ const out = [];
30
+ for (const l of lines || []) {
31
+ const last = out[out.length - 1];
32
+ if (last && last.name === l.section) last.lines.push(l);
33
+ else out.push({ name: l.section, lines: [l] });
34
+ }
35
+ return out;
36
+ }
37
+
38
+ // What the box holds is compared as typed, so a trailing space the artist is
39
+ // still typing counts as a change; the server tidies before it stores.
40
+ function isChanged(line, value) {
41
+ return typeof value === 'string' && value !== line.text;
42
+ }
43
+
44
+ // The mock's rule: a shared or engineer line always shows its own note, and
45
+ // any other changed line shows the words it replaced.
46
+ function noteFor(line, value, notes) {
47
+ const n = notes || NOTES;
48
+ if (line.note === 'shared') return { kind: 'shared', text: n.shared || NOTES.shared };
49
+ if (line.note === 'engineer') return { kind: 'engineer', text: n.engineer || NOTES.engineer };
50
+ if (isChanged(line, value)) return { kind: 'was', text: `was: ${line.text}` };
51
+ return null;
52
+ }
53
+
54
+ // An empty box is not a rewrite: removing words from a page is a code change
55
+ // (the server's line_empty), so an emptied line is left out of the save and
56
+ // the page keeps its words until the artist types new ones.
57
+ function isEmpty(value) {
58
+ return !String(value == null ? '' : value).trim();
59
+ }
60
+
61
+ function draftBody(readingHash, lines, values) {
62
+ const out = [];
63
+ for (const l of lines || []) {
64
+ const v = values[l.key];
65
+ if (isChanged(l, v) && !isEmpty(v)) out.push({ key: l.key, after: v });
66
+ }
67
+ return { reading_hash: readingHash, lines: out };
68
+ }
69
+
70
+ function changedCount(lines, values) {
71
+ return draftBody('', lines, values).lines.length;
72
+ }
73
+
74
+ const SAVE_WORDS = Object.freeze({ saved: 'saved', saving: 'saving…', failed: 'not saved', idle: 'saved' });
75
+ function counterText(changed, total, save) {
76
+ return `${changed} of ${total} rewritten · ${SAVE_WORDS[save] || SAVE_WORDS.saved}`;
77
+ }
78
+
79
+ // The editor's starting words: the page's own, with the saved draft laid over.
80
+ function initialValues(lines, draft) {
81
+ const values = {};
82
+ for (const l of lines || []) values[l.key] = l.text;
83
+ for (const d of (draft && draft.lines) || []) if (d && Object.prototype.hasOwnProperty.call(values, d.key)) values[d.key] = d.after;
84
+ return values;
85
+ }
86
+
87
+ // The mock shows two rows once a line passes 64 characters; the page grows
88
+ // each box to its words instead, so this is only the first paint's guess.
89
+ function rowsFor(value) {
90
+ return String(value || '').length > 64 ? 2 : 1;
91
+ }
92
+
93
+ // {apex} is the instance's own domain. The hall answers at builders.<apex>
94
+ // (or status.<apex>), so the apex is this host without that prefix — the
95
+ // shell's own APEX_ORIGIN rule. On a plain-http dev host the scheme follows.
96
+ function liveUrl(url, loc) {
97
+ if (!url) return null;
98
+ const host = String(loc.host || '').replace(/^(builders|status)\./, '');
99
+ let out = String(url).replace('{apex}', host);
100
+ if (loc.protocol === 'http:') out = out.replace(/^https:\/\//, 'http://');
101
+ return out;
102
+ }
103
+
104
+ // "uploaded · 3 lines from your doc" (the mock's words).
105
+ function uploadedLabel(merge) {
106
+ const n = merge && Array.isArray(merge.from_file) ? merge.from_file.length : 0;
107
+ return `uploaded · ${n} ${n === 1 ? 'line' : 'lines'} from your doc`;
108
+ }
109
+
110
+ // A page's name as the artist reads it ("Home", "Projects"). The inventory's
111
+ // title is the page's <title>, and on the apex that is a branding template
112
+ // ("{{productName}}: {{tagline}}"), which is not a name. So a templated title
113
+ // names its page by what follows the product ("{{productName}} — projects"
114
+ // is "Projects"), and a surface's front page, path "/", is "Home".
115
+ function displayTitle(page) {
116
+ const t = String((page && page.title) || (page && page.page_id) || '');
117
+ if (!/\{\{/.test(t)) return t;
118
+ if (page.path === '/') return 'Home';
119
+ const rest = t.replace(/^\{\{[^}]*\}\}\s*(?:—|-|:)\s*/, '').replace(/\{\{[^}]*\}\}/g, '').trim();
120
+ if (!rest) return 'Home';
121
+ return rest.charAt(0).toUpperCase() + rest.slice(1);
122
+ }
123
+
124
+ // The page id rides the query string: ?page=landing:index.
125
+ const PAGE_ID_RE = /^[a-z0-9-]+:[a-z0-9-]+$/;
126
+ function pageIdFrom(search) {
127
+ const m = /[?&]page=([^&#]*)/.exec(String(search || ''));
128
+ if (!m) return null;
129
+ let id = '';
130
+ try { id = decodeURIComponent(m[1]); } catch (_) { return null; }
131
+ return PAGE_ID_RE.test(id) ? id : null;
132
+ }
133
+
134
+ const api = { NOTES, sectionsOf, isChanged, noteFor, draftBody, changedCount, counterText, initialValues, rowsFor, liveUrl, uploadedLabel, displayTitle, pageIdFrom };
135
+ if (typeof window !== 'undefined') window.OTBTweakEditor = api;
136
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
137
+ })();