@taprootio/site-authoring 0.10.9 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -550,8 +550,8 @@ The destination must be new, outside the source workspace, and its parent must e
550
550
  `taproot-site --config path/to/taproot-site.json validate --init ../authoring-fixture`
551
551
  to select another source configuration. Initialization needs no credential or
552
552
  network. It exports editable free-form pages as ProseMirror with navigation,
553
- redirects, settings, and version-6 appearance/footer metadata, validates the
554
- result, and leaves the source unchanged. Excluded metadata/read-only pages are
553
+ redirects, settings, and version-7 appearance/footer metadata, validates the
554
+ result, and leaves the source unchanged. Excluded metadata-only pages are
555
555
  counted; unresolved references to them fail validation.
556
556
 
557
557
  Exported UUIDs and HTTP(S) origins are deterministic fixture values under
@@ -1,5 +1,5 @@
1
1
  {
2
- "manifestVersion": 6,
2
+ "manifestVersion": 7,
3
3
  "siteId": "a0000000-0000-4000-8000-000000000001",
4
4
  "pulledAt": "2026-09-04T00:00:00.000Z",
5
5
  "pages": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.10.9",
3
+ "version": "0.11.1",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/cli.js CHANGED
@@ -222,7 +222,6 @@ const VERBS = Object.freeze([
222
222
  tokens: ["pages", "push"],
223
223
  summary: "Create and update pages from the local workspace.",
224
224
  positionals: "pagePaths",
225
- allowRawHtml: true,
226
225
  note:
227
226
  "Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
228
227
  + "The homepage is recorded with an empty path, so address it as '/'. "
@@ -234,13 +233,7 @@ const VERBS = Object.freeze([
234
233
  + "documents of pages it is not sending: an unrelated page left on an obsolete contract is reported by the "
235
234
  + "whole-workspace push, not used to block this one. The result states the selection and how many sources were "
236
235
  + "discovered and validated. "
237
- + "The system 404 projection written by pull is read-only: an unchanged whole-workspace push verifies and skips it, "
238
- + "while a changed, missing, or replacement source is refused before any page mutation, and naming it in a "
239
- + "selection is refused outright. "
240
- + "See 'taproot-site help page free-form' for the stable manifest and error contract. "
241
- + "--allow-raw-html permits rawHtml nodes, which render verbatim and"
242
- + " unsanitized on the published site. Off by default on purpose; leave"
243
- + " it off unless the content is trusted hand-written markup.",
236
+ + "See 'taproot-site help page free-form' for the stable manifest and error contract.",
244
237
  },
245
238
  {
246
239
  name: VERB_NAV_PUSH,
@@ -573,9 +566,6 @@ function verbHelp(verb) {
573
566
  --production Promote the completed staging deployment to production.
574
567
  --allow-failed-preview Explicitly override the matching candidate's failed preview.`
575
568
  : "";
576
- const rawHtmlOption = verb.allowRawHtml
577
- ? "\n --allow-raw-html Permit rawHtml nodes. They render verbatim and unsanitized."
578
- : "";
579
569
  const jsonOption = verb.json
580
570
  ? "\n --json Emit the stable JSON contract (operational output is always JSON)."
581
571
  : "";
@@ -649,7 +639,7 @@ function verbHelp(verb) {
649
639
  ${verb.summary}
650
640
  ${boundary}
651
641
 
652
- ${options}${targetOption}${deliveryTargetOption}${rawHtmlOption}${jsonOption}${dryRunOption}${nameOption}${note}
642
+ ${options}${targetOption}${deliveryTargetOption}${jsonOption}${dryRunOption}${nameOption}${note}
653
643
  `;
654
644
  }
655
645
 
@@ -923,7 +913,6 @@ function parseArguments(arguments_) {
923
913
  let propagationWaitSeconds;
924
914
  let browser;
925
915
  let allowFailedPreview = false;
926
- let allowRawHtml = false;
927
916
  let dryRun = false;
928
917
  let json = false;
929
918
  let keyName;
@@ -983,13 +972,6 @@ function parseArguments(arguments_) {
983
972
  allowFailedPreview = true;
984
973
  continue;
985
974
  }
986
- if (verb.allowRawHtml && argument === "--allow-raw-html") {
987
- if (allowRawHtml) {
988
- throw usageError("cli.duplicate_option", "--allow-raw-html may be supplied only once.");
989
- }
990
- allowRawHtml = true;
991
- continue;
992
- }
993
975
  if (verb.dryRun && argument === "--dry-run") {
994
976
  if (dryRun) throw usageError("cli.duplicate_option", "--dry-run may be supplied only once.");
995
977
  dryRun = true;
@@ -1124,7 +1106,6 @@ function parseArguments(arguments_) {
1124
1106
  ...(propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds }),
1125
1107
  ...(browser === undefined ? {} : { browser }),
1126
1108
  init,
1127
- allowRawHtml,
1128
1109
  dryRun,
1129
1110
  keyName,
1130
1111
  positionals: verb.positionals
@@ -1202,7 +1183,6 @@ export async function runCli({
1202
1183
  allowFailedPreview: parsed.allowFailedPreview,
1203
1184
  init: parsed.init,
1204
1185
  quiet: parsed.quiet,
1205
- allowRawHtml: parsed.allowRawHtml,
1206
1186
  dryRun: parsed.dryRun,
1207
1187
  keyName: parsed.keyName,
1208
1188
  capabilities: parsed.capabilities,
package/src/constants.js CHANGED
@@ -1,5 +1,7 @@
1
+ import packageMetadata from "../package.json" with { type: "json" };
2
+
1
3
  export const CLI_NAME = "@taprootio/site-authoring";
2
- export const CLI_VERSION = "0.10.9";
4
+ export const CLI_VERSION = packageMetadata.version;
3
5
  export const CLI_BINARY_NAME = "taproot-site";
4
6
  export const RESULT_SCHEMA_VERSION = 1;
5
7
  export const CONFIG_FILE_NAME = "taproot-site.json";
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 6,
2
+ "version": 7,
3
3
  "section": {
4
4
  "nodeType": "section",
5
5
  "label": "Section",
@@ -488,8 +488,8 @@
488
488
  "measure": "wide",
489
489
  "placement": "flow"
490
490
  },
491
- "rawHtml": {
492
- "label": "Raw HTML",
491
+ "integrationFragment": {
492
+ "label": "Integration content",
493
493
  "measure": "wide",
494
494
  "placement": "flow"
495
495
  },
@@ -593,5 +593,8 @@
593
593
  "section": true,
594
594
  "measure": "wide"
595
595
  }
596
- }
596
+ },
597
+ "retiredNodeTypes": [
598
+ "rawHtml"
599
+ ]
597
600
  }
@@ -10,6 +10,17 @@
10
10
  import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
11
11
 
12
12
  export const TIPTAP_SITE_DEFAULT = "site-default";
13
+ export const DEFAULT_TIPTAP_IMAGE_MAX_IMAGES_PER_ROW = 2;
14
+ export const TIPTAP_IMAGE_MAX_IMAGES_PER_ROW_OPTIONS = [1, 2, 3] as const;
15
+ export const TIPTAP_IMAGE_NARROW_CONTENT_REM = 40;
16
+
17
+ export function normalizeTiptapImageMaxImagesPerRow(value: unknown): number {
18
+ const count = Number(value);
19
+ return TIPTAP_IMAGE_MAX_IMAGES_PER_ROW_OPTIONS.some(option => option === count)
20
+ ? count
21
+ : DEFAULT_TIPTAP_IMAGE_MAX_IMAGES_PER_ROW;
22
+ }
23
+
13
24
  export const DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH = 60;
14
25
  export const TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS = [45, 60, 75, 90] as const;
15
26
  const SECTION_CONTEXT_PATTERN = new RegExp(
@@ -64,6 +75,7 @@ export interface RenderProseMirrorOptions {
64
75
  /** Normalize free-form root flow into full-bleed Espalier sections. */
65
76
  freeFormSections?: boolean;
66
77
  imageDefaults?: {
78
+ maxImagesPerRow?: number | null;
67
79
  placement?: TiptapImagePresentationPlacement | string | null;
68
80
  /** The page shell emits this default once so rich-text formats inherit it. */
69
81
  maxHeightVh?: TiptapImageMaxHeightVh | number | null;
@@ -71,8 +83,12 @@ export interface RenderProseMirrorOptions {
71
83
  /** Generator-only render hooks for structured nodes that need page context. */
72
84
  nodeRenderers?: {
73
85
  componentBlock?: (node: ProseMirrorNode) => string | undefined;
74
- taprootImage?: (node: ProseMirrorNode) => string | undefined;
75
- rawHtml?: (html: string, node: ProseMirrorNode) => string | undefined;
86
+ taprootImage?: (
87
+ node: ProseMirrorNode,
88
+ context: { groupColumns?: number; fullWidth: boolean },
89
+ ) => string | undefined;
90
+ /** Emits an adopted fragment's canonical bytes; without it the node renders nothing. */
91
+ integrationFragment?: (node: ProseMirrorNode) => string | undefined;
76
92
  };
77
93
  /** Records visible document-order content after each node is rendered. */
78
94
  onRenderedNode?: (node: ProseMirrorNode, html: string) => void;
@@ -130,16 +146,6 @@ function collectText(node: ProseMirrorNode, parts: string[]): void {
130
146
  return;
131
147
  }
132
148
 
133
- // rawHtml is an intentional, already-sanitized node in the JSON document
134
- // contract. It is not the retired outer HTML-body transport. Preserve its
135
- // reader-visible text when a consumer (for example Recipe JSON-LD) needs a
136
- // plain-text representation of the document.
137
- if (node.type === "rawHtml") {
138
- const html = stringAttr(node.attrs?.html);
139
- if (html) parts.push(plainTextFromRawHtml(html));
140
- return;
141
- }
142
-
143
149
  for (const child of node.content ?? []) {
144
150
  collectText(child, parts);
145
151
  }
@@ -159,21 +165,27 @@ function isTextBlock(type: string): boolean {
159
165
  || type === "tableHeader";
160
166
  }
161
167
 
162
- function plainTextFromRawHtml(html: string): string {
163
- return html
164
- .replace(/<(?:br\s*\/?|\/(?:p|div|li|h[1-6]|blockquote|pre|tr|table|section|article))\s*[^>]*>/giu, " ")
165
- .replace(/<[^>]*>/gu, "");
166
- }
167
-
168
168
  type RenderPlacement = "root" | "section" | "nested";
169
+ type RenderOptionsWithLayout = RenderProseMirrorOptions & { fullWidthImages?: boolean };
169
170
 
170
171
  function renderNodes(
171
172
  nodes: readonly ProseMirrorNode[],
172
- options: RenderProseMirrorOptions,
173
+ options: RenderOptionsWithLayout,
173
174
  placement: RenderPlacement,
174
175
  ): string {
175
- return nodes.map((node) => {
176
- const html = renderNode(node, options, placement);
176
+ let imageRunEnd = 0;
177
+ let groupColumns: number | undefined;
178
+ return nodes.map((node, index) => {
179
+ const isImage = node.type === "taprootImage";
180
+ if (isImage && index >= imageRunEnd) {
181
+ imageRunEnd = index + 1;
182
+ while (imageRunEnd < nodes.length && nodes[imageRunEnd].type === "taprootImage") imageRunEnd++;
183
+ const count = imageRunEnd - index;
184
+ groupColumns = count > 1
185
+ ? Math.min(count, normalizeTiptapImageMaxImagesPerRow(options.imageDefaults?.maxImagesPerRow))
186
+ : undefined;
187
+ }
188
+ const html = renderNode(node, options, placement, false, isImage ? groupColumns : undefined);
177
189
  options.onRenderedNode?.(node, html);
178
190
  return html;
179
191
  }).join("");
@@ -232,9 +244,10 @@ function isRootBandComponent(node: ProseMirrorNode): boolean {
232
244
 
233
245
  function renderNode(
234
246
  node: ProseMirrorNode,
235
- options: RenderProseMirrorOptions,
247
+ options: RenderOptionsWithLayout,
236
248
  placement: RenderPlacement,
237
249
  compactFollowing = false,
250
+ groupColumns?: number,
238
251
  ): string {
239
252
  switch (node.type) {
240
253
  case "doc":
@@ -278,7 +291,8 @@ function renderNode(
278
291
  case "table":
279
292
  return placement === "root" || placement === "section" ? renderTable(node, options) : "";
280
293
  case "taprootImage":
281
- return options.nodeRenderers?.taprootImage?.(node) ?? renderTaprootImage(node, options);
294
+ return options.nodeRenderers?.taprootImage?.(node, { groupColumns, fullWidth: options.fullWidthImages === true })
295
+ ?? renderTaprootImage(node, options);
282
296
  case "inlineFacts":
283
297
  return renderInlineFacts(node, options, placement);
284
298
  case "componentBlock":
@@ -294,10 +308,10 @@ function renderNode(
294
308
  placement === "root" ? "section" : "nested",
295
309
  compactFollowing,
296
310
  );
297
- case "rawHtml": {
298
- const html = stringAttr(node.attrs?.html) ?? "";
299
- return options.nodeRenderers?.rawHtml?.(html, node) ?? html;
300
- }
311
+ case "integrationFragment":
312
+ // Root only: a fragment nested in a section, list or quote is not one
313
+ // adoption wrote, so it renders nothing whatever hook is supplied.
314
+ return placement === "root" ? options.nodeRenderers?.integrationFragment?.(node) ?? "" : "";
301
315
  default:
302
316
  return renderNodes(node.content ?? [], options, "nested");
303
317
  }
@@ -752,7 +766,7 @@ function renderSection(
752
766
  const content = renderElement(
753
767
  "div",
754
768
  { "data-section-content": true },
755
- renderNodes(nodes, options, contentPlacement),
769
+ renderNodes(nodes, { ...options, fullWidthImages: contentPadding === "none" }, contentPlacement),
756
770
  );
757
771
  const surfaced = surface === "none"
758
772
  ? content
@@ -1196,6 +1210,7 @@ function resolveImagePresentation(
1196
1210
  if (maxHeightOverride) styleParts.push(`--taproot-article-image-max-height: ${maxHeightOverride}`);
1197
1211
  if (borderWidthOverride) styleParts.push(`--esp-image-border: ${imageBorderValue(borderWidthOverride)}`);
1198
1212
  return {
1213
+ "data-tiptap-image": normalizeTiptapImageMaxImagesPerRow(defaults?.maxImagesPerRow),
1199
1214
  // The page shell owns the inherited site default so HTML and structured
1200
1215
  // rich text share it. Serialize only an explicit legacy choice; this
1201
1216
  // includes 60vh when it intentionally overrides a taller site default.
@@ -22,6 +22,7 @@ import {
22
22
  NODE_TYPE_SET,
23
23
  normalizeCssLengthOverride,
24
24
  pointerSegment,
25
+ RETIRED_NODE_TYPE_SET,
25
26
  TEXT_ALIGNMENTS,
26
27
  TIPTAP_SITE_DEFAULT,
27
28
  } from "./vocabulary.js";
@@ -280,14 +281,19 @@ const BLOCKS = Object.freeze([
280
281
  "taprootImage",
281
282
  "componentBlock",
282
283
  "integrationPlacement",
283
- "rawHtml",
284
284
  ]);
285
285
  const SECTION_BLOCKS = Object.freeze([
286
286
  ...BLOCKS,
287
287
  TABLE_NODE_TYPES.table,
288
288
  INLINE_FACTS_CONTRACT.nodeType,
289
289
  ]);
290
- const ROOT_BLOCKS = Object.freeze([...SECTION_BLOCKS, FREE_FORM_SECTION_REGISTRY.section.nodeType]);
290
+ // integrationFragment is root-only: adoption writes one per source fragment
291
+ // as a direct child of the doc, and nothing nests it anywhere else.
292
+ const ROOT_BLOCKS = Object.freeze([
293
+ ...SECTION_BLOCKS,
294
+ FREE_FORM_SECTION_REGISTRY.section.nodeType,
295
+ "integrationFragment",
296
+ ]);
291
297
  const NONE = Object.freeze([]);
292
298
 
293
299
  const rule = (options) => Object.freeze({ attrs: Object.freeze({}), children: NONE, minChildren: 0, ...options });
@@ -401,6 +407,24 @@ const NODE_RULES = Object.freeze({
401
407
  ? null : "a positive server-assigned configuration revision"),
402
408
  }),
403
409
  }),
410
+ // Integration adoption's frozen fragment. Authors keep or delete one, never
411
+ // create or alter it; the API holds every pushed fragment to a stored copy,
412
+ // so this checks only that the shape is one adoption could have written.
413
+ integrationFragment: rule({
414
+ attrs: Object.freeze({
415
+ html: spec((value) => (typeof value === "string" && value !== "" ? null : "a non-empty string"), {
416
+ required: true,
417
+ }),
418
+ // Strictly ascending ordinal order, which also makes the ids distinct:
419
+ // adoption writes them sorted, and the API's PageBodyNodePolicy refuses
420
+ // any other order. Lowercase UUIDs are ASCII, so `<` is ordinal here.
421
+ imageIds: spec((value) => (Array.isArray(value)
422
+ && value.every(isUuid)
423
+ && value.every((id, index) => index === 0 || value[index - 1] < id)
424
+ ? null
425
+ : "an array of distinct lowercase image UUIDs in ascending order"), { required: true }),
426
+ }),
427
+ }),
404
428
  section: rule({
405
429
  attrs: SECTION_ATTRS,
406
430
  children: SECTION_BLOCKS,
@@ -417,12 +441,6 @@ const NODE_RULES = Object.freeze({
417
441
  }),
418
442
  inlineFacts: true,
419
443
  }),
420
- rawHtml: rule({
421
- attrs: Object.freeze({
422
- html: spec((value) => (typeof value === "string" ? null : "a string"), { required: true }),
423
- }),
424
- rawHtml: true,
425
- }),
426
444
  [TABLE_NODE_TYPES.table]: rule({
427
445
  attrs: TABLE_ATTRS,
428
446
  children: Object.freeze([TABLE_NODE_TYPES.row]),
@@ -975,6 +993,15 @@ function validateNode(node, path, parentRule, index, state, depth) {
975
993
  report(state, path, CODES.nodeInvalid, "A node must be an object with a string 'type'.");
976
994
  return;
977
995
  }
996
+ if (RETIRED_NODE_TYPE_SET.has(node.type)) {
997
+ report(
998
+ state,
999
+ path,
1000
+ CODES.rawHtmlForbidden,
1001
+ `'${identifier(node.type)}' is a retired node type. Rewrite its content as ordinary nodes.`,
1002
+ );
1003
+ return;
1004
+ }
978
1005
  if (!NODE_TYPE_SET.has(node.type)) {
979
1006
  report(
980
1007
  state,
@@ -1003,14 +1030,6 @@ function validateNode(node, path, parentRule, index, state, depth) {
1003
1030
  }
1004
1031
 
1005
1032
  const nodeRule = NODE_RULES[node.type];
1006
- if (nodeRule.rawHtml && !state.allowRawHtml) {
1007
- report(
1008
- state,
1009
- path,
1010
- CODES.rawHtmlForbidden,
1011
- "A 'rawHtml' node is published verbatim and unsanitised. Pass allowRawHtml to accept one deliberately.",
1012
- );
1013
- }
1014
1033
  if (node.type === "text") {
1015
1034
  if (typeof node.text !== "string") {
1016
1035
  report(state, path, CODES.textInvalid, "A text node must carry a string 'text'.");
@@ -1036,16 +1055,11 @@ function validateNode(node, path, parentRule, index, state, depth) {
1036
1055
  * itself, `/content/3/content/0` its fourth block's first child, and a
1037
1056
  * component finding continues *through* the `componentData` JSON string into
1038
1057
  * the parsed data). `code` is one of `CONTENT_ERROR_CODES`.
1039
- *
1040
- * `allowRawHtml` opts a caller into `rawHtml` nodes, which are published
1041
- * unsanitised. It is off by default precisely because an agent should not be
1042
- * able to emit one by accident.
1043
1058
  */
1044
- export function validateDocument(document, options = {}) {
1059
+ export function validateDocument(document) {
1045
1060
  const state = {
1046
1061
  errors: [],
1047
1062
  truncated: false,
1048
- allowRawHtml: options?.allowRawHtml === true,
1049
1063
  };
1050
1064
 
1051
1065
  if (!isPlainObject(document) || document.type !== "doc") {
@@ -50,9 +50,9 @@ export const NODE_TYPES = Object.freeze([
50
50
  "taprootImage",
51
51
  "componentBlock",
52
52
  "integrationPlacement",
53
+ "integrationFragment",
53
54
  "section",
54
55
  freeFormSectionRegistry.inlineFacts.nodeType,
55
- "rawHtml",
56
56
  freeFormSectionRegistry.table.nodeTypes.table,
57
57
  freeFormSectionRegistry.table.nodeTypes.row,
58
58
  freeFormSectionRegistry.table.nodeTypes.header,
@@ -73,6 +73,12 @@ export const MARK_TYPES = Object.freeze([
73
73
  export const NODE_TYPE_SET = Object.freeze(new Set(NODE_TYPES));
74
74
  export const MARK_TYPE_SET = Object.freeze(new Set(MARK_TYPES));
75
75
 
76
+ // Node names the contract has retired. Each is refused by name rather than
77
+ // reported as unknown, so an old document gets a pointed answer instead of a
78
+ // generic one, and the parity test keeps every name out of NODE_TYPES.
79
+ export const RETIRED_NODE_TYPES = Object.freeze([...freeFormSectionRegistry.retiredNodeTypes]);
80
+ export const RETIRED_NODE_TYPE_SET = Object.freeze(new Set(RETIRED_NODE_TYPES));
81
+
76
82
  /**
77
83
  * Every code this module emits. They are wire identities an agent branches on,
78
84
  * so they are appended to, never reworded or renumbered.
@@ -109,7 +115,7 @@ export const CONTENT_ERROR_CODES = Object.freeze({
109
115
  componentUnknown: "content.component_unknown",
110
116
  componentData: "content.component_data",
111
117
  sectionContextUnknown: "content.section_context_unknown",
112
- // Raw HTML
118
+ // Retired rawHtml node
113
119
  rawHtmlForbidden: "content.raw_html_forbidden",
114
120
  // Tables
115
121
  tableHeader: "content.table_header",
@@ -283,78 +289,24 @@ export function isUuid(value) {
283
289
  // Server-parity: RichTextBody.IsPresent
284
290
  // ---------------------------------------------------------------------------
285
291
 
286
- const NAMED_ENTITIES = Object.freeze({
287
- amp: "&",
288
- apos: "'",
289
- gt: ">",
290
- lt: "<",
291
- nbsp: " ",
292
- quot: "\"",
293
- });
294
-
295
- /**
296
- * A bounded stand-in for `WebUtility.HtmlDecode`, used only to answer "does
297
- * this raw HTML contain visible text?". The named set covers the entities that
298
- * can hide a `<` or a space; an unknown named entity stays literal, which
299
- * makes the answer *more* likely to be "yes, there is text" — the safe
300
- * direction, because a false "empty" would refuse a page the server accepts.
301
- */
302
- function decodeHtmlEntities(html) {
303
- return html.replace(/&(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (match, body) => {
304
- if (body[0] === "#") {
305
- const codePoint = body[1] === "x" || body[1] === "X"
306
- ? Number.parseInt(body.slice(2), 16)
307
- : Number.parseInt(body.slice(1), 10);
308
- if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return match;
309
- try {
310
- return String.fromCodePoint(codePoint);
311
- } catch {
312
- return match;
313
- }
314
- }
315
- return NAMED_ENTITIES[body] ?? match;
316
- });
317
- }
318
-
319
- /** Mirrors `RichTextBody.HasMeaningfulHtml`: text outside tags, after decoding. */
320
- function hasMeaningfulHtml(html) {
321
- if (typeof html !== "string" || html.trim() === "") return false;
322
- const decoded = decodeHtmlEntities(html).replaceAll("\u00a0", " ");
323
- let insideTag = false;
324
- for (const character of decoded) {
325
- if (character === "<") {
326
- insideTag = true;
327
- continue;
328
- }
329
- if (character === ">") {
330
- insideTag = false;
331
- continue;
332
- }
333
- if (!insideTag && character.trim() !== "") return true;
334
- }
335
- return false;
336
- }
337
-
338
292
  /**
339
293
  * Mirrors `RichTextBody.HasMeaningfulContent` in
340
294
  * `api/src/Taproot.Domain/Entities/PageModel/RichTextBody.cs`. Text counts when
341
- * it is non-whitespace, raw HTML when it has visible text, and images and
342
- * component blocks always count; every other node counts only through its
343
- * children. A body that fails this is refused by the server with "Body is
344
- * required", which is why this package names it locally instead.
295
+ * it is non-whitespace; images, component blocks, integration placements and
296
+ * adopted integration fragments always count; every other node counts only
297
+ * through its children. A body that fails this is refused by the server with
298
+ * "Body is required", which is why this package names it locally instead.
345
299
  */
346
300
  function hasMeaningfulContent(node, depth = 0) {
347
301
  if (!isPlainObject(node) || depth > CONTENT_LIMITS.documentDepth) return false;
348
302
  if (node.type === "text") {
349
303
  return typeof node.text === "string" && node.text.trim() !== "";
350
304
  }
351
- if (node.type === "rawHtml") {
352
- return isPlainObject(node.attrs) && hasMeaningfulHtml(node.attrs.html);
353
- }
354
305
  if (
355
306
  node.type === "taprootImage"
356
307
  || node.type === "componentBlock"
357
308
  || node.type === "integrationPlacement"
309
+ || node.type === "integrationFragment"
358
310
  || node.type === freeFormSectionRegistry.inlineFacts.nodeType
359
311
  ) return true;
360
312
  if (!Array.isArray(node.content)) return false;
@@ -39,10 +39,8 @@ import {
39
39
  INTERNAL_PAGE_BASELINE_DIRECTORY,
40
40
  MANIFEST_VERSION,
41
41
  NAVIGATION_FILE_NAME,
42
- PAGE_READ_ONLY_REASON_SYSTEM_404,
43
42
  PAGE_SOURCE_EXTENSIONS,
44
43
  PAGE_WORKSPACE_MODE_EDITABLE,
45
- PAGE_WORKSPACE_MODE_READ_ONLY,
46
44
  PAGES_DIRECTORY,
47
45
  pageSourceFormat,
48
46
  SETTINGS_DIRECTORY,
@@ -51,7 +49,7 @@ import {
51
49
 
52
50
  export { getAppearanceReference, getFooterReference, getThemeReference };
53
51
 
54
- export const REFERENCE_VERSION = 26;
52
+ export const REFERENCE_VERSION = 27;
55
53
  export const PAGE_TYPES = Object.freeze(["free-form"]);
56
54
 
57
55
  function deepFreeze(value) {
@@ -716,9 +714,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
716
714
  + "with no duplicates. Fixtures are copied and shipped, so a real delivery host in one would be a live "
717
715
  + "reference in every copy.",
718
716
  "validate reads the fixture and writes nothing to it. Copy the directory somewhere writable before editing it.",
719
- "validate --init <new-directory> exports the current pulled workspace as a validated version-6 fixture with "
717
+ "validate --init <new-directory> exports the current pulled workspace as a validated version-7 fixture with "
720
718
  + "appearance and footer metadata. Run from the workspace or its configured project; --config may select the source.",
721
- "Initialization keeps editable free-form pages as ProseMirror sources and reports excluded metadata/read-only pages. "
719
+ "Initialization keeps editable free-form pages as ProseMirror sources and reports excluded metadata-only pages. "
722
720
  + "References to excluded pages must be resolved before the fixture can validate. It never copies credentials, "
723
721
  + "internal reconciliation state, or deployment receipts. UUIDs and HTTP(S) origins are replaced with deterministic "
724
722
  + "fixture identities and example.test origins; URL credentials, queries and fragments are removed. Authored prose is retained.",
@@ -1315,26 +1313,9 @@ const FREE_FORM_REFERENCE = Object.freeze({
1315
1313
  "Read from .taproot-site-manifest.json at the workspace root; the document file contains only the ProseMirror root.",
1316
1314
  }),
1317
1315
  ]),
1318
- systemPages: Object.freeze([
1319
- Object.freeze({
1320
- path: SYSTEM_PAGE_NOT_FOUND_PATH,
1321
- mode: PAGE_WORKSPACE_MODE_READ_ONLY,
1322
- reason: PAGE_READ_ONLY_REASON_SYSTEM_404,
1323
- projection:
1324
- "pull writes the exact stored ProseMirror body and records its SHA-256 in .taproot-site-manifest.json",
1325
- unchangedPush: "A whole-workspace pages push verifies the hash and skips this file without updating the page.",
1326
- approval:
1327
- "An unscoped approve excludes this projection even when the live system page carries a draft; naming it explicitly is refused.",
1328
- modifiedError: "pages.read_only_modified",
1329
- missingError: "pages.read_only_missing",
1330
- replacementError: "pages.system_page_read_only",
1331
- scopedPushError: "pages.page_read_only",
1332
- scopedApprovalError: "approve.page_read_only",
1333
- guidance:
1334
- "Do not delete, replace, or edit this file. Run pull to restore it; author the system 404 through an owner-controlled surface.",
1335
- }),
1336
- ]),
1337
- systemHome: "The pulled home page remains an ordinary editable page.",
1316
+ systemPages:
1317
+ `Home and ${SYSTEM_PAGE_NOT_FOUND_PATH} are ordinary editable pages. Taproot seeds both, so they are update-only `
1318
+ + "and their paths are immutable.",
1338
1319
  }),
1339
1320
  document: Object.freeze({
1340
1321
  root: Object.freeze({ type: "doc", content: "array of supported block nodes" }),
@@ -1369,19 +1350,9 @@ const FREE_FORM_REFERENCE = Object.freeze({
1369
1350
  }),
1370
1351
  tableNode: TABLE_REFERENCE,
1371
1352
  inlineFactsNode: INLINE_FACTS_REFERENCE,
1372
- rawHtml: Object.freeze({
1373
- default: "rejected",
1374
- trackedProseMirror: Object.freeze({
1375
- format: ".pm.json",
1376
- optIn: `${CLI_BINARY_NAME} pages push --allow-raw-html`,
1377
- behavior: "The flag permits explicit rawHtml nodes only in a tracked ProseMirror document.",
1378
- }),
1379
- markdown: Object.freeze({
1380
- format: ".md",
1381
- supported: false,
1382
- behavior: "Inline HTML remains unsupported and is rejected even when --allow-raw-html is present.",
1383
- }),
1384
- warning: "rawHtml renders verbatim and unsanitized; use it only for trusted hand-written markup.",
1353
+ integrationFragmentNode: Object.freeze({
1354
+ type: "integrationFragment",
1355
+ note: "Adopted integration content: preserved or deleted, never authored.",
1385
1356
  }),
1386
1357
  }),
1387
1358
  sections: Object.freeze({
@@ -1592,13 +1563,7 @@ export function formatReferenceResult(result) {
1592
1563
  page.workspace.formats.map((format) => ` ${format.extension.padEnd(9)} ${format.purpose}`).join("\n")
1593
1564
  }\n\nOne source per page:\n rule ${sourceRule.rule}\n manifest fields ${
1594
1565
  sourceRule.manifestFields.join(", ")
1595
- }\n pull ${sourceRule.pull}\n internal state ${sourceRule.internalState}\n format change ${sourceRule.formatChange} (${sourceRule.formatChangeError})\n renamed source ${sourceRule.renamedSource} (${sourceRule.renamedSourceError})\n conflicts ${sourceRule.conflict} (${sourceRule.conflictError})\n conflict detail ${sourceRule.conflictDetail}\n push conflicts ${sourceRule.pushConflict} (${sourceRule.pushConflictError})\n revision ${sourceRule.revisionSource}\n recovery ${sourceRule.conflictRecovery}\n push selection ${sourceRule.pushSelection}\n\nSystem page projections:\n${
1596
- page.workspace.systemPages.map((systemPage) =>
1597
- ` ${systemPage.path.padEnd(9)} ${systemPage.mode}; ${systemPage.projection}.\n${
1598
- " ".repeat(12)
1599
- }${systemPage.unchangedPush} ${systemPage.approval}\n${" ".repeat(12)}${systemPage.guidance}`
1600
- ).join("\n")
1601
- }\n home editable; ${page.workspace.systemHome}\n\nMarkdown front matter:\n${
1566
+ }\n pull ${sourceRule.pull}\n internal state ${sourceRule.internalState}\n format change ${sourceRule.formatChange} (${sourceRule.formatChangeError})\n renamed source ${sourceRule.renamedSource} (${sourceRule.renamedSourceError})\n conflicts ${sourceRule.conflict} (${sourceRule.conflictError})\n conflict detail ${sourceRule.conflictDetail}\n push conflicts ${sourceRule.pushConflict} (${sourceRule.pushConflictError})\n revision ${sourceRule.revisionSource}\n recovery ${sourceRule.conflictRecovery}\n push selection ${sourceRule.pushSelection}\n\nSystem pages: ${page.workspace.systemPages}\n\nMarkdown front matter:\n${
1602
1567
  markdownFormat.metadata.map(formatMetadata).join("\n")
1603
1568
  }\n\nDocument root: { "type": "doc", "content": [...] }\nSupported nodes: ${
1604
1569
  page.document.nodes.join(", ")
@@ -1633,7 +1598,7 @@ export function formatReferenceResult(result) {
1633
1598
  page.inlineFacts.attrs.items.itemFieldOrder.join(", ")
1634
1599
  } order\n value ${page.inlineFacts.valuePolicy}\n label ${page.inlineFacts.labelPolicy}\n url ${page.inlineFacts.urlPolicy}\n Markdown fenced ${page.inlineFacts.markdown.fence} block whose body is a JSON array\n\nInline-facts Markdown example:\n${page.inlineFacts.markdown.example}\n\nDirect ProseMirror inlineFacts example:\n${
1635
1600
  JSON.stringify(page.inlineFacts.examples.proseMirror, null, 2)
1636
- }\n\nRaw HTML: ${page.document.rawHtml.default} by default. For a tracked ${page.document.rawHtml.trackedProseMirror.format} document only: ${page.document.rawHtml.trackedProseMirror.behavior} Opt in with ${page.document.rawHtml.trackedProseMirror.optIn}. For ${page.document.rawHtml.markdown.format}, ${page.document.rawHtml.markdown.behavior} ${page.document.rawHtml.warning}\n\nComponents:\n${
1601
+ }\n\n${page.document.integrationFragmentNode.type}: ${page.document.integrationFragmentNode.note}\n\nComponents:\n${
1637
1602
  page.components.map((component) => ` ${component.type.padEnd(16)} ${component.summary}`).join("\n")
1638
1603
  }\n\nWorkflow:\n${
1639
1604
  page.workflow.map((step) => ` ${step.command}: ${step.result}`).join("\n")
@@ -125,6 +125,7 @@ export const SETTINGS_GROUPS = Object.freeze([
125
125
  // the same as the site behaves.
126
126
  { name: "hideLightboxComments", wireType: "boolean" },
127
127
  { name: "tiptapImageMaxHeightVh", wireType: "int" },
128
+ { name: "tiptapImageMaxImagesPerRow", wireType: "int" },
128
129
  { name: "tiptapImageBorderWidth", wireType: "int" },
129
130
  { name: "albumBorderWidth", wireType: "int" },
130
131
  { name: "albumMaxImagesPerRow", wireType: "int" },
@@ -1,20 +1,8 @@
1
- import {
2
- listSitePages,
3
- PAGE_STATUS_DELETED,
4
- publishDrafts,
5
- TEMPLATE_TYPE_FREE_FORM,
6
- withRefusalGuidance,
7
- } from "../api.js";
1
+ import { listSitePages, PAGE_STATUS_DELETED, publishDrafts, withRefusalGuidance } from "../api.js";
8
2
  import { VERB_APPROVE } from "../constants.js";
9
3
  import { SiteAuthoringError } from "../errors.js";
10
4
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
11
- import {
12
- normalizePagePath,
13
- readManifest,
14
- readOnlySystem404Projections,
15
- SYSTEM_PAGE_NOT_FOUND_PATH,
16
- writeManifest,
17
- } from "../workspace.js";
5
+ import { normalizePagePath, readManifest, writeManifest } from "../workspace.js";
18
6
 
19
7
  /**
20
8
  * `approve` — publish the site's drafts.
@@ -46,7 +34,6 @@ export async function approve(invocation) {
46
34
  const manifestByPageId = new Map(
47
35
  manifest.pages.filter((entry) => typeof entry?.pageId === "string").map((entry) => [entry.pageId, entry]),
48
36
  );
49
- const readOnlyProjections = readOnlySystem404Projections(manifest);
50
37
 
51
38
  // Positional arguments narrow the selection to those page paths; `cli.js`
52
39
  // hands them over as `pagePaths`, and a programmatic caller can supply the
@@ -84,41 +71,10 @@ export async function approve(invocation) {
84
71
  { field: "pages" },
85
72
  );
86
73
  }
87
- const pagesById = new Map(pages.map((summary) => [summary.pageId, summary]));
88
- for (const projection of readOnlyProjections) {
89
- const summary = pagesById.get(projection.pageId);
90
- if (
91
- summary?.status === PAGE_STATUS_DELETED
92
- || normalizePagePath(summary?.path)?.toLowerCase() !== SYSTEM_PAGE_NOT_FOUND_PATH
93
- || summary?.templateType !== TEMPLATE_TYPE_FREE_FORM
94
- ) {
95
- const index = manifest.pages.indexOf(projection);
96
- throw new SiteAuthoringError(
97
- "workspace.manifest_invalid",
98
- `The read-only page projection at pages[${index}] does not identify the live free-form system 404. `
99
- + "Nothing was approved; run 'taproot-site pull' again.",
100
- { field: `pages[${index}].pageId` },
101
- );
102
- }
103
- }
104
- const readOnlyPageIds = new Set(readOnlyProjections.map((entry) => entry.pageId));
105
- if (requestedPaths !== undefined) {
106
- const requestedReadOnly = readOnlyProjections.find((entry) =>
107
- requestedPaths.has(normalizePagePath(entry.path) ?? entry.path));
108
- if (requestedReadOnly !== undefined) {
109
- const pagePath = normalizePagePath(requestedReadOnly.path) ?? requestedReadOnly.path;
110
- throw new SiteAuthoringError(
111
- "approve.page_read_only",
112
- `Page path '${pagePath}' is the pulled read-only system 404 projection and cannot be approved.`,
113
- { field: pagePath },
114
- );
115
- }
116
- }
117
74
  const candidates = pages.filter((summary) =>
118
75
  summary.status !== PAGE_STATUS_DELETED
119
76
  && summary.hasDraft
120
77
  && manifestByPageId.has(summary.pageId)
121
- && !readOnlyPageIds.has(summary.pageId)
122
78
  && (requestedPaths === undefined || requestedPaths.has(normalizePagePath(summary.path) ?? summary.path)));
123
79
 
124
80
  if (requestedPaths !== undefined) {
@@ -63,7 +63,6 @@ import { whoami } from "./whoami.js";
63
63
  * configPath, // an explicit --config path, or undefined
64
64
  * deployTarget, // "staging" | "production" for `deploy`, else undefined
65
65
  * quiet, // --quiet; suppresses progress, never the JSON result
66
- * allowRawHtml, // --allow-raw-html; `pages push` only, false everywhere else
67
66
  * keyName, // --name; `login` only, undefined everywhere else
68
67
  * onProgress, // human progress sink (stderr, or a no-op when quiet)
69
68
  * fetch, // injectable fetch, for tests
@@ -32,7 +32,6 @@ import {
32
32
  readManifest,
33
33
  readMediaManifest,
34
34
  readObservedPageRevision,
35
- readOnlySystem404Projections,
36
35
  readWorkspaceFile,
37
36
  readWorkspaceJson,
38
37
  requireManifestSourceRegistry,
@@ -81,15 +80,6 @@ import {
81
80
  * - Creates are POST and updates are PATCH with a *whole* template, not a patch
82
81
  * mask. Neither is replayed by the transport, so each is issued once and an
83
82
  * ambiguous outcome surfaces as ambiguous.
84
- *
85
- * `rawHtml` renders verbatim and unsanitized. It is rejected unless the caller
86
- * explicitly asks for it via `allowRawHtml`, which is exactly the thing an
87
- * agent must not be able to emit by accident.
88
- *
89
- * Pull's system 404 is the deliberate exception to "every file is authored."
90
- * Its exact bytes remain inspectable in the workspace but carry a manifest
91
- * hash and read-only marker. An unchanged whole-workspace push skips it;
92
- * changing, deleting, or replacing it is a refusal before any remote write.
93
83
  */
94
84
 
95
85
  const MAXIMUM_REPORTED = 200;
@@ -337,8 +327,8 @@ function requireContentFunctions(content) {
337
327
  return content;
338
328
  }
339
329
 
340
- function assertValid(content, document_, file, allowRawHtml) {
341
- const outcome = content.validateDocument(document_, { allowRawHtml });
330
+ function assertValid(content, document_, file) {
331
+ const outcome = content.validateDocument(document_);
342
332
  const errors = outcome === null || typeof outcome !== "object" ? undefined : outcome.errors;
343
333
  if (!Array.isArray(errors)) {
344
334
  throw new SiteAuthoringError(
@@ -579,11 +569,10 @@ export async function validateWorkspacePageDocument({
579
569
  file,
580
570
  document: document_,
581
571
  content: injectedContent,
582
- allowRawHtml = false,
583
572
  getSharedThemeContexts,
584
573
  }) {
585
574
  const content = requireContentFunctions(await loadContentModule(injectedContent));
586
- assertValid(content, document_, file, allowRawHtml);
575
+ assertValid(content, document_, file);
587
576
  if (hasNamedFreeFormSectionContext(document_)) {
588
577
  const contexts = typeof getSharedThemeContexts === "function"
589
578
  ? await getSharedThemeContexts()
@@ -607,7 +596,6 @@ export async function pagesPush(invocation) {
607
596
  // exchange said the platform is paused. It changes nothing else: the write
608
597
  // still runs and its refusal still classifies as platform_paused (TR00692).
609
598
  warnIfExternalWritesPaused(session, VERB_PAGES_PUSH);
610
- const allowRawHtml = invocation.allowRawHtml === true;
611
599
  // Both manifests are bound to the site before anything is planned or sent:
612
600
  // every id in them is site-scoped, and a workspace pulled from another site
613
601
  // reads as entirely valid until phase two is already writing.
@@ -630,72 +618,19 @@ export async function pagesPush(invocation) {
630
618
  // Manifest integrity is never scoped: a registry that contradicts itself
631
619
  // describes some other workspace, and a selection cannot make that safe.
632
620
  requireManifestSourceRegistry(pageSourceRegistry(manifest));
633
- const readOnlyProjections = readOnlySystem404Projections(manifest);
634
621
  const targeted = requestedPaths !== undefined;
635
- const readOnlyByFile = new Map(readOnlyProjections.map((entry) => [entry.file, entry]));
636
- const readOnlyByPath = new Map(readOnlyProjections.map((entry) => [normalizePagePath(entry.path), entry]));
637
-
638
- // Naming the projection in a selection is refused by name before anything is
639
- // read. It is the one page this workspace may never send, and a selection is
640
- // an explicit request rather than something to quietly drop.
641
- if (targeted) {
642
- const requestedReadOnly = readOnlyProjections.find((entry) =>
643
- requestedPaths.has(normalizePagePath(entry.path) ?? entry.path));
644
- if (requestedReadOnly !== undefined) {
645
- const pagePath = normalizePagePath(requestedReadOnly.path) ?? requestedReadOnly.path;
646
- throw new SiteAuthoringError(
647
- "pages.page_read_only",
648
- `Page path '${pagePath}' is the pulled read-only system 404 projection and cannot be pushed.`,
649
- { field: pagePath },
650
- );
651
- }
652
- }
653
622
 
654
623
  // The shared list, so the set `pull` refuses to write over is exactly the set
655
624
  // this walks: a file one counted and the other ignored would be a page pushed
656
625
  // from a workspace nothing proved the ownership of.
657
626
  const files = await walkWorkspaceFiles(config.workspaceDir, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS);
658
- const editableFiles = files.filter((file) => !readOnlyByFile.has(file));
659
- if (editableFiles.length === 0 && readOnlyProjections.length === 0) {
627
+ if (files.length === 0) {
660
628
  throw new SiteAuthoringError(
661
629
  "pages.none_found",
662
630
  `No Markdown or ProseMirror page files were found under '${PAGES_DIRECTORY}/' in the workspace.`,
663
631
  { field: PAGES_DIRECTORY },
664
632
  );
665
633
  }
666
- // The projection's own integrity is a whole-workspace check. A targeted push
667
- // has already refused to name it and cannot reach it any other way, so an
668
- // edited projection is something `pages push` with no path reports — not
669
- // something that blocks a different page from being sent.
670
- const skippedReadOnly = [];
671
- if (!targeted) {
672
- for (const entry of readOnlyProjections) {
673
- if (!files.includes(entry.file)) {
674
- throw documentError(
675
- "pages.read_only_missing",
676
- `The pulled read-only system 404 projection '${entry.file}' is missing or was replaced. Run pull again; `
677
- + "author the system 404 through an owner-controlled surface.",
678
- entry.file,
679
- );
680
- }
681
- const bytes = await readWorkspaceFile(config.workspaceDir, entry.file, WORKSPACE_LIMITS.documentBytes);
682
- if (workspaceContentHash(bytes) !== entry.workspaceContentHash) {
683
- throw documentError(
684
- "pages.read_only_modified",
685
- `The pulled read-only system 404 projection '${entry.file}' changed after pull. No page was pushed. `
686
- + "Restore it or pull again; author the system 404 through an owner-controlled surface.",
687
- entry.file,
688
- );
689
- }
690
- skippedReadOnly.push({
691
- file: entry.file,
692
- path: normalizePagePath(entry.path),
693
- pageId: entry.pageId,
694
- reason: entry.readOnlyReason,
695
- });
696
- onProgress(`Verified read-only system 404 projection '${entry.file}'; it will not be pushed.`);
697
- }
698
- }
699
634
  let sharedThemeContexts;
700
635
  const manifestByFile = new Map(
701
636
  manifest.pages.filter((entry) => typeof entry?.file === "string").map((entry) => [entry.file, entry]),
@@ -727,25 +662,6 @@ export async function pagesPush(invocation) {
727
662
  const live = livePages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
728
663
  const liveById = new Map(live.map((summary) => [summary.pageId, summary]));
729
664
  const liveByPath = new Map(live.map((summary) => [normalizePagePath(summary.path) ?? summary.path, summary]));
730
- // The projection's live identity is the other half of the whole-workspace
731
- // read-only check above, and is scoped with it for the same reason.
732
- if (!targeted) {
733
- for (const projection of readOnlyProjections) {
734
- const summary = liveById.get(projection.pageId);
735
- if (
736
- normalizePagePath(summary?.path)?.toLowerCase() !== SYSTEM_PAGE_NOT_FOUND_PATH
737
- || summary?.templateType !== TEMPLATE_TYPE_FREE_FORM
738
- ) {
739
- const index = manifest.pages.indexOf(projection);
740
- throw documentError(
741
- "workspace.manifest_invalid",
742
- `The read-only page projection at pages[${index}] does not identify the live free-form system 404. `
743
- + "No page was pushed; run 'taproot-site pull' again.",
744
- `pages[${index}].pageId`,
745
- );
746
- }
747
- }
748
- }
749
665
 
750
666
  // Resolution: which file is each page path's one authoritative source.
751
667
  // This reads metadata only — a manifest entry, or a front-matter block —
@@ -754,7 +670,7 @@ export async function pagesPush(invocation) {
754
670
  const sources = [];
755
671
  const claimants = [];
756
672
  const unresolved = [];
757
- for (const file of editableFiles) {
673
+ for (const file of files) {
758
674
  const manifestEntry = manifestByFile.get(file);
759
675
  try {
760
676
  const source = await readWorkspacePageSource({ workspaceDir: config.workspaceDir, file, manifestEntry });
@@ -844,14 +760,6 @@ export async function pagesPush(invocation) {
844
760
  const manifestEntry = manifestByFile.get(file);
845
761
  requireCompletePageMetadata(source);
846
762
  const document_ = await convertWorkspacePageSource({ source, mediaManifest, content });
847
- if (readOnlyByPath.has(pagePath)) {
848
- throw documentError(
849
- "pages.system_page_read_only",
850
- `'${file}' attempts to replace the read-only system 404 projection. No page was pushed. `
851
- + "Run pull again; author the system 404 through an owner-controlled surface.",
852
- file,
853
- );
854
- }
855
763
 
856
764
  const target = manifestEntry?.pageId !== undefined
857
765
  ? liveById.get(manifestEntry.pageId)
@@ -930,7 +838,6 @@ export async function pagesPush(invocation) {
930
838
  file,
931
839
  document: document_,
932
840
  content,
933
- allowRawHtml,
934
841
  getSharedThemeContexts: async () => {
935
842
  sharedThemeContexts ??= await readSharedThemeContexts(config.workspaceDir, siteId);
936
843
  return sharedThemeContexts;
@@ -1044,18 +951,15 @@ export async function pagesPush(invocation) {
1044
951
  const reported = boundedList(applied, MAXIMUM_REPORTED);
1045
952
  const reportedUnresolved = boundedList(unresolved, MAXIMUM_REPORTED);
1046
953
  return successResult(VERB_PAGES_PUSH, siteId, {
1047
- allowRawHtml,
1048
954
  pages: {
1049
955
  total: planned.length,
1050
956
  created: applied.filter((entry) => entry.action === "created").length,
1051
957
  updated: applied.filter((entry) => entry.action === "updated").length,
1052
- skippedReadOnly: skippedReadOnly.length,
1053
- readOnlyItems: skippedReadOnly,
1054
958
  // What this run actually looked at, so automation can tell a targeted
1055
959
  // push from a whole-workspace one without inferring it from counts.
1056
960
  selection: targeted ? "targeted" : "workspace",
1057
961
  ...(targeted ? { selectedPaths: [...requestedPaths].sort().map((pagePath) => pagePath || "/") } : {}),
1058
- discovered: editableFiles.length,
962
+ discovered: files.length,
1059
963
  validated: planned.length,
1060
964
  ...(unresolved.length > 0
1061
965
  ? {
package/src/verbs/pull.js CHANGED
@@ -46,12 +46,10 @@ import {
46
46
  NAVIGATION_FILE_NAME,
47
47
  normalizePageBodyRevision,
48
48
  normalizePagePath,
49
- PAGE_READ_ONLY_REASON_SYSTEM_404,
50
49
  PAGE_SOURCE_EXTENSIONS,
51
50
  PAGE_SOURCE_FORMAT_MARKDOWN,
52
51
  PAGE_WORKSPACE_MODE_EDITABLE,
53
52
  PAGE_WORKSPACE_MODE_METADATA_ONLY,
54
- PAGE_WORKSPACE_MODE_READ_ONLY,
55
53
  PAGES_DIRECTORY,
56
54
  pageSourceFormat,
57
55
  pageSourceRegistry,
@@ -59,7 +57,6 @@ import {
59
57
  readWorkspaceFile,
60
58
  readWorkspaceJson,
61
59
  SETTINGS_DIRECTORY,
62
- SYSTEM_PAGE_NOT_FOUND_PATH,
63
60
  walkWorkspaceFiles,
64
61
  WORKSPACE_LIMITS,
65
62
  workspaceContentHash,
@@ -118,10 +115,6 @@ const APPEARANCE_SETTINGS_TYPES = Object.freeze([
118
115
  SETTINGS_TYPE_SITE_HEADER,
119
116
  ]);
120
117
 
121
- function isSystemNotFound(pagePath) {
122
- return normalizePagePath(pagePath)?.toLowerCase() === SYSTEM_PAGE_NOT_FOUND_PATH;
123
- }
124
-
125
118
  /**
126
119
  * The refusal for a remote body this workspace cannot represent.
127
120
  *
@@ -342,11 +335,6 @@ function assignPageFiles(pages, tracked) {
342
335
  async function resolveTrackedSources(workspaceDir, pages, registry, onProgress) {
343
336
  const tracked = new Map();
344
337
  for (const summary of pages) {
345
- // The system 404 is never authored locally: pull owns its bytes and
346
- // records their hash, and every other verb refuses to send them back.
347
- // Tracking it would offer to keep local edits to a file whose whole
348
- // contract is that local edits are a refusal.
349
- if (isSystemNotFound(summary.path)) continue;
350
338
  const entry = registry.get(summary.pageId);
351
339
  if (entry === undefined) continue;
352
340
  // One file cannot be two pages' source. Keeping either entry would hash
@@ -412,10 +400,31 @@ async function readSourceRegistry(workspaceDir, siteId) {
412
400
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || parsed.siteId !== siteId) {
413
401
  return new Map();
414
402
  }
415
- const registry = pageSourceRegistry(parsed);
403
+ const registry = pageSourceRegistry(withoutRetiredReadOnlyProjections(parsed));
416
404
  return parsed.manifestVersion === MANIFEST_VERSION ? registry : withoutSupersededRemoteHashes(registry);
417
405
  }
418
406
 
407
+ /**
408
+ * Drops every page entry a version-6 manifest recorded as the retired
409
+ * `workspaceMode: "read-only"` system-404 projection.
410
+ *
411
+ * That entry carries a file but no baseline, and push skipped it, so its bytes
412
+ * are the site's old `rawHtml` body rather than anything the author wrote.
413
+ * Tracking it would keep those bytes as local work: once the owner converts the
414
+ * 404 on the site, this pull would neither refresh nor conflict on it, and the
415
+ * next whole-workspace push would refuse with `content.raw_html_forbidden` on
416
+ * a page the author never touched. Forgetting the entry sends the page through
417
+ * the ordinary fresh pull instead, which overwrites the file with the site's
418
+ * body and records a baseline.
419
+ */
420
+ function withoutRetiredReadOnlyProjections(manifest) {
421
+ if (!Array.isArray(manifest.pages)) return manifest;
422
+ return {
423
+ ...manifest,
424
+ pages: manifest.pages.filter((entry) => entry?.workspaceMode !== "read-only"),
425
+ };
426
+ }
427
+
419
428
  /**
420
429
  * Forgets `baseline.remoteHash` on every entry of a registry read out of a
421
430
  * manifest that is not this version's.
@@ -1031,23 +1040,14 @@ export async function pull(invocation) {
1031
1040
  } else {
1032
1041
  entry.file = pageFiles.get(summary.pageId);
1033
1042
  const source = Buffer.from(`${JSON.stringify(body, undefined, 2)}\n`, "utf8");
1034
- if (isSystemNotFound(summary.path)) {
1035
- entry.workspaceMode = PAGE_WORKSPACE_MODE_READ_ONLY;
1036
- entry.readOnlyReason = PAGE_READ_ONLY_REASON_SYSTEM_404;
1037
- entry.workspaceContentHash = workspaceContentHash(source);
1038
- onProgress(
1039
- `Page '${summary.path}' is the system 404; writing an integrity-checked read-only projection.`,
1040
- );
1041
- } else {
1042
- entry.workspaceMode = PAGE_WORKSPACE_MODE_EDITABLE;
1043
- entry.sourceFormat = pageSourceFormat(entry.file);
1044
- const revision = normalizePageBodyRevision(page.bodyRevision);
1045
- entry.baseline = {
1046
- remoteHash: canonicalDocumentHash(body),
1047
- sourceHash: workspaceContentHash(source),
1048
- ...(revision === undefined ? {} : { revision }),
1049
- };
1050
- }
1043
+ entry.workspaceMode = PAGE_WORKSPACE_MODE_EDITABLE;
1044
+ entry.sourceFormat = pageSourceFormat(entry.file);
1045
+ const revision = normalizePageBodyRevision(page.bodyRevision);
1046
+ entry.baseline = {
1047
+ remoteHash: canonicalDocumentHash(body),
1048
+ sourceHash: workspaceContentHash(source),
1049
+ ...(revision === undefined ? {} : { revision }),
1050
+ };
1051
1051
  await writeWorkspaceFile(config.workspaceDir, entry.file, source);
1052
1052
  bodies += 1;
1053
1053
  const baselineFile = internalPageBaselineFile(summary.pageId);
@@ -1132,7 +1132,6 @@ export async function pull(invocation) {
1132
1132
  // Markdown page to survive the pull can assert on it.
1133
1133
  tracked: tracked.size,
1134
1134
  revisionsRecordedWithoutBodyComparison,
1135
- readOnly: manifestPages.filter((entry) => entry.workspaceMode === PAGE_WORKSPACE_MODE_READ_ONLY).length,
1136
1135
  truncated,
1137
1136
  items: reported.items,
1138
1137
  ...(reported.truncated ? { itemsTruncated: true } : {}),
package/src/workspace.js CHANGED
@@ -128,6 +128,10 @@ export function internalPageObservedRevisionFile(pageId) {
128
128
  : undefined;
129
129
  }
130
130
 
131
+ // Version 7 tracks the system 404 as an ordinary editable page with a
132
+ // baseline, like home; version 6 wrote it as a hash-checked read-only
133
+ // projection that push and approve skipped.
134
+ //
131
135
  // Version 6 records the site's own `baseline.revision` for every tracked page:
132
136
  // the opaque revision of the stored authoring state the site reported when this
133
137
  // workspace last agreed with it. Version 5 could only compare one pull's body
@@ -139,7 +143,7 @@ export function internalPageObservedRevisionFile(pageId) {
139
143
  // classified pulled files but recorded no registry. The package is in-repo and
140
144
  // its manifests are workspace state, so an older one is re-pulled rather than
141
145
  // migrated.
142
- export const MANIFEST_VERSION = 6;
146
+ export const MANIFEST_VERSION = 7;
143
147
 
144
148
  /**
145
149
  * The shape of a page body revision as the site reports it: a scheme name and
@@ -240,10 +244,7 @@ function normalizeObservedDifferences(value) {
240
244
 
241
245
  export const PAGE_WORKSPACE_MODE_EDITABLE = "editable";
242
246
  export const PAGE_WORKSPACE_MODE_METADATA_ONLY = "metadata-only";
243
- export const PAGE_WORKSPACE_MODE_READ_ONLY = "read-only";
244
- export const PAGE_READ_ONLY_REASON_SYSTEM_404 = "system-404";
245
247
  export const SYSTEM_PAGE_NOT_FOUND_PATH = "404";
246
- export const SYSTEM_PAGE_NOT_FOUND_FILE = `${PAGES_DIRECTORY}/404.pm.json`;
247
248
  export const WORKSPACE_CONTENT_HASH = /^sha256:[0-9a-f]{64}$/u;
248
249
  // Bumped when the media manifest gained its `siteId` binding: a manifest
249
250
  // without one cannot be proved to belong to the site being written to, and the
@@ -273,7 +274,7 @@ export const WORKSPACE_LIMITS = Object.freeze({
273
274
  pagePathBytes: 512,
274
275
  });
275
276
 
276
- /** Hashes the exact bounded bytes pull wrote for a read-only projection. */
277
+ /** Hashes the exact bounded bytes of a workspace source. */
277
278
  export function workspaceContentHash(bytes) {
278
279
  return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
279
280
  }
@@ -489,47 +490,6 @@ export function requireManifestSourceRegistry(registry) {
489
490
  return registry;
490
491
  }
491
492
 
492
- /**
493
- * Returns only the complete read-only projection shape that `pull` writes.
494
- *
495
- * A manifest is editable input, so a caller must not treat an arbitrary
496
- * `workspaceMode: read-only` as permission to omit a page from push or
497
- * approval. The live page identity is checked separately after listing the
498
- * site; this function proves only the deterministic on-disk half.
499
- */
500
- export function readOnlySystem404Projections(manifest) {
501
- const projections = [];
502
- for (const [index, entry] of manifest.pages.entries()) {
503
- if (entry?.workspaceMode !== PAGE_WORKSPACE_MODE_READ_ONLY) continue;
504
- const pagePath = normalizePagePath(entry.path);
505
- if (
506
- pagePath?.toLowerCase() !== SYSTEM_PAGE_NOT_FOUND_PATH
507
- || entry.file !== SYSTEM_PAGE_NOT_FOUND_FILE
508
- || typeof entry.pageId !== "string"
509
- || entry.pageId.trim() === ""
510
- || entry.readOnlyReason !== PAGE_READ_ONLY_REASON_SYSTEM_404
511
- || typeof entry.workspaceContentHash !== "string"
512
- || !WORKSPACE_CONTENT_HASH.test(entry.workspaceContentHash)
513
- ) {
514
- throw new SiteAuthoringError(
515
- "workspace.manifest_invalid",
516
- `The read-only page projection at pages[${index}] is not the complete system-404 shape written by pull. `
517
- + "Run 'taproot-site pull' again.",
518
- { field: `pages[${index}].workspaceMode` },
519
- );
520
- }
521
- projections.push(entry);
522
- }
523
- if (projections.length > 1) {
524
- throw new SiteAuthoringError(
525
- "workspace.manifest_invalid",
526
- "The manifest records more than one read-only system 404 projection. Run 'taproot-site pull' again.",
527
- { field: "pages" },
528
- );
529
- }
530
- return projections;
531
- }
532
-
533
493
  const WINDOWS_DEVICE_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
534
494
 
535
495
  /**