@taprootio/site-authoring 0.10.8 → 0.11.0
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 +2 -2
- package/examples/riverbend-wellness/manifest.fixture.json +1 -1
- package/package.json +1 -1
- package/src/cli.js +2 -22
- package/src/constants.js +1 -1
- package/src/content/free-form-section-registry.json +7 -4
- package/src/content/tiptap-prosemirror.ts +10 -22
- package/src/content/validate-document.js +36 -22
- package/src/content/vocabulary.js +13 -61
- package/src/reference-help.js +11 -46
- package/src/verbs/approve.js +2 -46
- package/src/verbs/index.js +0 -1
- package/src/verbs/pages-push.js +6 -102
- package/src/verbs/pull.js +30 -31
- package/src/workspace.js +6 -46
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-
|
|
554
|
-
result, and leaves the source unchanged. Excluded metadata
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
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
|
-
+ "
|
|
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}${
|
|
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,5 @@
|
|
|
1
1
|
export const CLI_NAME = "@taprootio/site-authoring";
|
|
2
|
-
export const CLI_VERSION = "0.
|
|
2
|
+
export const CLI_VERSION = "0.11.0";
|
|
3
3
|
export const CLI_BINARY_NAME = "taproot-site";
|
|
4
4
|
export const RESULT_SCHEMA_VERSION = 1;
|
|
5
5
|
export const CONFIG_FILE_NAME = "taproot-site.json";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version":
|
|
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
|
-
"
|
|
492
|
-
"label": "
|
|
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
|
}
|
|
@@ -72,10 +72,13 @@ export interface RenderProseMirrorOptions {
|
|
|
72
72
|
nodeRenderers?: {
|
|
73
73
|
componentBlock?: (node: ProseMirrorNode) => string | undefined;
|
|
74
74
|
taprootImage?: (node: ProseMirrorNode) => string | undefined;
|
|
75
|
-
|
|
75
|
+
/** Emits an adopted fragment's canonical bytes; without it the node renders nothing. */
|
|
76
|
+
integrationFragment?: (node: ProseMirrorNode) => string | undefined;
|
|
76
77
|
};
|
|
77
78
|
/** Records visible document-order content after each node is rendered. */
|
|
78
79
|
onRenderedNode?: (node: ProseMirrorNode, html: string) => void;
|
|
80
|
+
/** Maps an authored heading level (1–4) to the level rendered; defaults to the authored level. */
|
|
81
|
+
headingLevel?: (authored: number) => number;
|
|
79
82
|
/** Claims the page's one LCP slot before rendering a section photo. */
|
|
80
83
|
claimSectionBackgroundImage?: () => boolean;
|
|
81
84
|
}
|
|
@@ -128,16 +131,6 @@ function collectText(node: ProseMirrorNode, parts: string[]): void {
|
|
|
128
131
|
return;
|
|
129
132
|
}
|
|
130
133
|
|
|
131
|
-
// rawHtml is an intentional, already-sanitized node in the JSON document
|
|
132
|
-
// contract. It is not the retired outer HTML-body transport. Preserve its
|
|
133
|
-
// reader-visible text when a consumer (for example Recipe JSON-LD) needs a
|
|
134
|
-
// plain-text representation of the document.
|
|
135
|
-
if (node.type === "rawHtml") {
|
|
136
|
-
const html = stringAttr(node.attrs?.html);
|
|
137
|
-
if (html) parts.push(plainTextFromRawHtml(html));
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
134
|
for (const child of node.content ?? []) {
|
|
142
135
|
collectText(child, parts);
|
|
143
136
|
}
|
|
@@ -157,12 +150,6 @@ function isTextBlock(type: string): boolean {
|
|
|
157
150
|
|| type === "tableHeader";
|
|
158
151
|
}
|
|
159
152
|
|
|
160
|
-
function plainTextFromRawHtml(html: string): string {
|
|
161
|
-
return html
|
|
162
|
-
.replace(/<(?:br\s*\/?|\/(?:p|div|li|h[1-6]|blockquote|pre|tr|table|section|article))\s*[^>]*>/giu, " ")
|
|
163
|
-
.replace(/<[^>]*>/gu, "");
|
|
164
|
-
}
|
|
165
|
-
|
|
166
153
|
type RenderPlacement = "root" | "section" | "nested";
|
|
167
154
|
|
|
168
155
|
function renderNodes(
|
|
@@ -292,10 +279,10 @@ function renderNode(
|
|
|
292
279
|
placement === "root" ? "section" : "nested",
|
|
293
280
|
compactFollowing,
|
|
294
281
|
);
|
|
295
|
-
case "
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
282
|
+
case "integrationFragment":
|
|
283
|
+
// Root only: a fragment nested in a section, list or quote is not one
|
|
284
|
+
// adoption wrote, so it renders nothing whatever hook is supplied.
|
|
285
|
+
return placement === "root" ? options.nodeRenderers?.integrationFragment?.(node) ?? "" : "";
|
|
299
286
|
default:
|
|
300
287
|
return renderNodes(node.content ?? [], options, "nested");
|
|
301
288
|
}
|
|
@@ -363,7 +350,8 @@ function wrapMark(html: string, mark: ProseMirrorMark): string {
|
|
|
363
350
|
|
|
364
351
|
function renderHeading(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
|
|
365
352
|
const rawLevel = numberAttr(node.attrs?.level) ?? 1;
|
|
366
|
-
const
|
|
353
|
+
const authored = Math.min(4, Math.max(1, Math.trunc(rawLevel)));
|
|
354
|
+
const level = Math.min(6, Math.max(1, options.headingLevel?.(authored) ?? authored));
|
|
367
355
|
return renderElement(
|
|
368
356
|
`h${level}`,
|
|
369
357
|
withProseMeasure(textBlockAttrs(node.attrs), node.type, options),
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
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,
|
|
342
|
-
*
|
|
343
|
-
* children. A body that fails this is refused by the server with
|
|
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;
|
package/src/reference-help.js
CHANGED
|
@@ -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 =
|
|
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-
|
|
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
|
|
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:
|
|
1319
|
-
|
|
1320
|
-
|
|
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
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
|
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\
|
|
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")
|
package/src/verbs/approve.js
CHANGED
|
@@ -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) {
|
package/src/verbs/index.js
CHANGED
|
@@ -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
|
package/src/verbs/pages-push.js
CHANGED
|
@@ -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
|
|
341
|
-
const outcome = content.validateDocument(document_
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
)
|
|
1041
|
-
}
|
|
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 =
|
|
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
|
|
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
|
/**
|