@taprootio/site-authoring 0.1.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/LICENSE +15 -0
- package/README.md +239 -0
- package/bin/taproot-site.js +5 -0
- package/package.json +40 -0
- package/src/api.js +1507 -0
- package/src/appearance-contract.js +311 -0
- package/src/atomic-file.js +136 -0
- package/src/bounded-file.js +59 -0
- package/src/capabilities.js +29 -0
- package/src/cli.js +872 -0
- package/src/config.js +412 -0
- package/src/constants.js +173 -0
- package/src/content/components.js +870 -0
- package/src/content/free-form-section-registry.json +524 -0
- package/src/content/free-form-sections.js +582 -0
- package/src/content/index.js +29 -0
- package/src/content/inline-facts.js +97 -0
- package/src/content/markdown.js +1625 -0
- package/src/content/tiptap-prosemirror.ts +1266 -0
- package/src/content/validate-document.js +1079 -0
- package/src/content/vocabulary.js +397 -0
- package/src/credentials.js +544 -0
- package/src/errors.js +133 -0
- package/src/footer-contract.js +687 -0
- package/src/footer-draft-hash.js +203 -0
- package/src/footer-workspace.js +136 -0
- package/src/image-metadata.js +169 -0
- package/src/index.js +16 -0
- package/src/json-guard.js +111 -0
- package/src/output.js +157 -0
- package/src/presentation-reference.js +600 -0
- package/src/reference-help.js +927 -0
- package/src/session.js +316 -0
- package/src/settings-catalog.js +187 -0
- package/src/settings.js +157 -0
- package/src/theme-validation.js +344 -0
- package/src/transport.js +777 -0
- package/src/verbs/approve.js +178 -0
- package/src/verbs/deploy.js +327 -0
- package/src/verbs/env.js +89 -0
- package/src/verbs/footer-push.js +115 -0
- package/src/verbs/index.js +125 -0
- package/src/verbs/login.js +529 -0
- package/src/verbs/logout.js +39 -0
- package/src/verbs/media-upload.js +319 -0
- package/src/verbs/nav-push.js +333 -0
- package/src/verbs/pages-push.js +990 -0
- package/src/verbs/preview-page.js +107 -0
- package/src/verbs/preview-revoke.js +56 -0
- package/src/verbs/pull.js +727 -0
- package/src/verbs/sites.js +57 -0
- package/src/verbs/status.js +94 -0
- package/src/verbs/theme-push.js +197 -0
- package/src/verbs/use.js +129 -0
- package/src/verbs/validate.js +477 -0
- package/src/verbs/whoami.js +108 -0
- package/src/workspace.js +1085 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
import { validateComponentBlock } from "./components.js";
|
|
2
|
+
import {
|
|
3
|
+
FREE_FORM_SECTION_REGISTRY,
|
|
4
|
+
normalizeFreeFormSectionBackground,
|
|
5
|
+
normalizeFreeFormSectionDecoration,
|
|
6
|
+
} from "./free-form-sections.js";
|
|
7
|
+
import { normalizeInlineFactsItems } from "./inline-facts.js";
|
|
8
|
+
import {
|
|
9
|
+
CONTENT_ERROR_CODES as CODES,
|
|
10
|
+
CONTENT_LIMITS,
|
|
11
|
+
contentError,
|
|
12
|
+
documentIsPresent,
|
|
13
|
+
identifier,
|
|
14
|
+
IMAGE_ALIGNMENTS,
|
|
15
|
+
IMAGE_MAX_HEIGHT_OPTIONS,
|
|
16
|
+
IMAGE_PLACEMENTS,
|
|
17
|
+
IMAGE_PRESENTATION_PLACEMENTS,
|
|
18
|
+
isPlainObject,
|
|
19
|
+
isSafeUrl,
|
|
20
|
+
isUuid,
|
|
21
|
+
MARK_TYPE_SET,
|
|
22
|
+
NODE_TYPE_SET,
|
|
23
|
+
normalizeCssLengthOverride,
|
|
24
|
+
pointerSegment,
|
|
25
|
+
TEXT_ALIGNMENTS,
|
|
26
|
+
TIPTAP_SITE_DEFAULT,
|
|
27
|
+
} from "./vocabulary.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The ProseMirror document validator.
|
|
31
|
+
*
|
|
32
|
+
* Every rule here answers one question: *would the canonical renderer, or the
|
|
33
|
+
* editor's own schema, quietly discard this?* The renderer's `default` switch
|
|
34
|
+
* arms drop unknown nodes and marks, `renderHeading` clamps a level of 9 to 4,
|
|
35
|
+
* `wrapMark` drops an unsafe link, `textBlockAttrs` drops an unparseable
|
|
36
|
+
* font size, and `PageImageDeliveryRewriter` leaves an image blank when the
|
|
37
|
+
* `src`/`urls` keys are absent. None of that fails a push. All of it fails a
|
|
38
|
+
* page, after publication, silently — so it fails here instead.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Attribute rules
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
// A spec's `check` returns null when the value is acceptable, or the tail of
|
|
46
|
+
// the sentence "'<attr>' must be …" when it is not.
|
|
47
|
+
const spec = (check, options = {}) => Object.freeze({ check, ...options });
|
|
48
|
+
|
|
49
|
+
const isAbsent = (value) => value === undefined || value === null;
|
|
50
|
+
|
|
51
|
+
/** Optional string attributes accept null: it is every extension's default. */
|
|
52
|
+
const optionalString = () => spec((value) => (isAbsent(value) || typeof value === "string" ? null : "a string"));
|
|
53
|
+
|
|
54
|
+
const optionalEnum = (values, extra = []) =>
|
|
55
|
+
spec((value) => {
|
|
56
|
+
if (isAbsent(value)) return null;
|
|
57
|
+
if (typeof value === "string" && (values.includes(value) || extra.includes(value))) return null;
|
|
58
|
+
return `one of ${[...values, ...extra].map((entry) => `"${entry}"`).join(", ")}`;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const optionalPositiveInteger = () =>
|
|
62
|
+
spec((value) => {
|
|
63
|
+
if (isAbsent(value)) return null;
|
|
64
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? null : "a positive integer";
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A per-block or per-image CSS override. The renderer runs every one of these
|
|
69
|
+
* through `normalizeCssLengthOverride` and drops whatever fails, so the
|
|
70
|
+
* validator accepts exactly what that function accepts, plus the explicit
|
|
71
|
+
* `"site-default"` sentinel it maps to "no override". An empty string is *not*
|
|
72
|
+
* a sentinel — it is a silent drop — and is refused.
|
|
73
|
+
*/
|
|
74
|
+
const optionalCssLength = (options) =>
|
|
75
|
+
spec((value) => {
|
|
76
|
+
if (isAbsent(value) || value === TIPTAP_SITE_DEFAULT) return null;
|
|
77
|
+
if (typeof value === "string" && normalizeCssLengthOverride(value, options) !== null) return null;
|
|
78
|
+
return "a CSS length, a var()/calc() expression"
|
|
79
|
+
+ (options?.allowNone ? ", \"none\"" : "")
|
|
80
|
+
+ ", or \"site-default\"";
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** `textAlign` — the four values `textBlockAttrs` emits a `text-align` for. */
|
|
84
|
+
const TEXT_ALIGN_SPEC = optionalEnum(TEXT_ALIGNMENTS);
|
|
85
|
+
|
|
86
|
+
/** `fontSize` — the advanced text setting, validated as published inline CSS. */
|
|
87
|
+
const FONT_SIZE_SPEC = spec((value) => {
|
|
88
|
+
if (isAbsent(value)) return null;
|
|
89
|
+
if (typeof value === "string" && normalizeCssLengthOverride(value) !== null) return null;
|
|
90
|
+
return "a CSS length, a var() reference, or a calc() expression";
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const TEXT_BLOCK_ATTRS = Object.freeze({
|
|
94
|
+
textAlign: TEXT_ALIGN_SPEC,
|
|
95
|
+
fontSize: FONT_SIZE_SPEC,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const RESPONSIVE_URL_KEYS = Object.freeze(["minWidth", "url", "type"]);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `urls` — the responsive delivery hints. The renderer's
|
|
102
|
+
* `validResponsiveImageUrls` skips any entry without a truthy `minWidth` and
|
|
103
|
+
* `url`, so a malformed entry disappears from the published `<esp-image>`
|
|
104
|
+
* without a trace. `type` is optional and is emitted when present.
|
|
105
|
+
*/
|
|
106
|
+
const URLS_SPEC = spec((value) => {
|
|
107
|
+
if (!Array.isArray(value)) return "an array of { minWidth, url } objects";
|
|
108
|
+
for (const entry of value) {
|
|
109
|
+
if (!isPlainObject(entry)) return "an array of { minWidth, url } objects";
|
|
110
|
+
if (Object.keys(entry).some((key) => !RESPONSIVE_URL_KEYS.includes(key))) {
|
|
111
|
+
return "an array of objects with only minWidth, url, and type keys";
|
|
112
|
+
}
|
|
113
|
+
if (typeof entry.minWidth !== "number" || !Number.isFinite(entry.minWidth) || entry.minWidth <= 0) {
|
|
114
|
+
return "an array whose entries each carry a positive numeric minWidth";
|
|
115
|
+
}
|
|
116
|
+
if (typeof entry.url !== "string" || entry.url === "") {
|
|
117
|
+
return "an array whose entries each carry a non-empty url string";
|
|
118
|
+
}
|
|
119
|
+
if (entry.type !== undefined && typeof entry.type !== "string") {
|
|
120
|
+
return "an array whose entries carry a string type when present";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}, {
|
|
125
|
+
required: true,
|
|
126
|
+
missingCode: CODES.imageKeys,
|
|
127
|
+
missingMessage: "'urls' must be present, even as []: PageImageDeliveryRewriter only replaces delivery URLs on "
|
|
128
|
+
+ "keys that already exist, so an image node without a 'urls' key renders with no responsive sources.",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const CROP_KEYS = Object.freeze(["x", "y", "width", "height"]);
|
|
132
|
+
|
|
133
|
+
/** `crop` — a `CropRegion` in source fractions, serialised into `data-crop`. */
|
|
134
|
+
const CROP_SPEC = spec((value) => {
|
|
135
|
+
if (isAbsent(value)) return null;
|
|
136
|
+
if (!isPlainObject(value)) return "a { x, y, width, height } object of 0-1 fractions";
|
|
137
|
+
const keys = Object.keys(value);
|
|
138
|
+
if (keys.length !== CROP_KEYS.length || CROP_KEYS.some((key) => !keys.includes(key))) {
|
|
139
|
+
return "an object with exactly the keys x, y, width, and height";
|
|
140
|
+
}
|
|
141
|
+
for (const key of CROP_KEYS) {
|
|
142
|
+
const entry = value[key];
|
|
143
|
+
if (typeof entry !== "number" || !Number.isFinite(entry) || entry < 0 || entry > 1) {
|
|
144
|
+
return "a { x, y, width, height } object of 0-1 fractions";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `taprootImage`. The attribute set is the editor extension's
|
|
152
|
+
* (`extensions/taproot-image.ts`) minus the four the editor strips on save
|
|
153
|
+
* (`TRANSIENT_TAPROOT_IMAGE_ATTRS` in `taproot-tiptap.ts`: `localImage`,
|
|
154
|
+
* `resolvedMaxHeightVh`, `tempId`, `uploading`). Those four are
|
|
155
|
+
* editor-session state — an upload in flight, a blob URL, a placeholder id, a
|
|
156
|
+
* resolved display value — and serialising them publishes a document that
|
|
157
|
+
* references a browser session that no longer exists.
|
|
158
|
+
*
|
|
159
|
+
* `presentationPlacement` is also `rendered: false` in the extension but is
|
|
160
|
+
* kept here, because the editor keeps it too: `renderHTML` skips it while the
|
|
161
|
+
* renderer's `resolvePlacement` reads it, so it is stored state, not session
|
|
162
|
+
* state.
|
|
163
|
+
*/
|
|
164
|
+
const IMAGE_ATTRS = Object.freeze({
|
|
165
|
+
imageId: spec(
|
|
166
|
+
(value) => (isUuid(value) ? null : "a canonical lowercase UUID"),
|
|
167
|
+
{ required: true },
|
|
168
|
+
),
|
|
169
|
+
src: spec(
|
|
170
|
+
(value) => (typeof value === "string" ? null : "a string (\"\" is correct for authored content)"),
|
|
171
|
+
{
|
|
172
|
+
required: true,
|
|
173
|
+
missingCode: CODES.imageKeys,
|
|
174
|
+
missingMessage: "'src' must be present, even as \"\": PageImageDeliveryRewriter only replaces delivery URLs "
|
|
175
|
+
+ "on keys that already exist, so an image node without a 'src' key renders blank.",
|
|
176
|
+
},
|
|
177
|
+
),
|
|
178
|
+
urls: URLS_SPEC,
|
|
179
|
+
alt: optionalString(),
|
|
180
|
+
width: optionalPositiveInteger(),
|
|
181
|
+
height: optionalPositiveInteger(),
|
|
182
|
+
crop: CROP_SPEC,
|
|
183
|
+
maxHeightVh: spec((value) => {
|
|
184
|
+
if (isAbsent(value) || value === TIPTAP_SITE_DEFAULT) return null;
|
|
185
|
+
return IMAGE_MAX_HEIGHT_OPTIONS.includes(value)
|
|
186
|
+
? null
|
|
187
|
+
: `one of ${IMAGE_MAX_HEIGHT_OPTIONS.join(", ")} or "site-default"`;
|
|
188
|
+
}),
|
|
189
|
+
maxHeight: optionalCssLength({ allowNone: true }),
|
|
190
|
+
borderWidth: optionalCssLength(),
|
|
191
|
+
presentationPlacement: optionalEnum(IMAGE_PRESENTATION_PLACEMENTS, [TIPTAP_SITE_DEFAULT]),
|
|
192
|
+
imageAlign: optionalEnum(IMAGE_ALIGNMENTS),
|
|
193
|
+
imagePlacement: optionalEnum(IMAGE_PLACEMENTS),
|
|
194
|
+
// TextAlign is configured for taprootImage as well as paragraph and heading.
|
|
195
|
+
textAlign: TEXT_ALIGN_SPEC,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/** The four the editor strips on save — never valid on the wire. */
|
|
199
|
+
const IMAGE_TRANSIENT_ATTRS = Object.freeze(["uploading", "localImage", "tempId", "resolvedMaxHeightVh"]);
|
|
200
|
+
|
|
201
|
+
const COMPONENT_ATTRS = Object.freeze({
|
|
202
|
+
// Both values are checked by `validateComponentBlock`, which owns the
|
|
203
|
+
// registry lookup and the data-shape tables; these specs only establish that
|
|
204
|
+
// the attributes are present at all.
|
|
205
|
+
componentType: spec(() => null, {
|
|
206
|
+
required: true,
|
|
207
|
+
missingCode: CODES.componentUnknown,
|
|
208
|
+
missingMessage: "'componentType' is required and must name one of the eight registry components.",
|
|
209
|
+
}),
|
|
210
|
+
componentData: spec(() => null, {
|
|
211
|
+
required: true,
|
|
212
|
+
missingCode: CODES.componentData,
|
|
213
|
+
missingMessage: "'componentData' is required and must be a JSON string.",
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const SECTION_CONTEXT = FREE_FORM_SECTION_REGISTRY.section.attrs.context;
|
|
218
|
+
const SECTION_CONTEXT_PATTERN = new RegExp(SECTION_CONTEXT.pattern, "u");
|
|
219
|
+
const SECTION_ATTRS = Object.freeze({
|
|
220
|
+
context: spec((value) => {
|
|
221
|
+
if (isAbsent(value)) return null;
|
|
222
|
+
return typeof value === "string"
|
|
223
|
+
&& [...value].length <= SECTION_CONTEXT.maximumLength
|
|
224
|
+
&& SECTION_CONTEXT_PATTERN.test(value)
|
|
225
|
+
? null
|
|
226
|
+
: `an identifier matching ${SECTION_CONTEXT.pattern} with at most ${SECTION_CONTEXT.maximumLength} characters`;
|
|
227
|
+
}),
|
|
228
|
+
contentPadding: optionalEnum(FREE_FORM_SECTION_REGISTRY.section.attrs.contentPadding.values),
|
|
229
|
+
surface: optionalEnum(FREE_FORM_SECTION_REGISTRY.section.attrs.surface.values),
|
|
230
|
+
// Nested field diagnostics are owned by
|
|
231
|
+
// `normalizeFreeFormSectionBackground` below.
|
|
232
|
+
background: spec(() => null),
|
|
233
|
+
// Nested field diagnostics are owned by
|
|
234
|
+
// `normalizeFreeFormSectionDecoration` below, so this outer spec deliberately
|
|
235
|
+
// does not collapse them into one error at `/attrs/decoration`.
|
|
236
|
+
decoration: spec(() => null),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const TABLE_CONTRACT = FREE_FORM_SECTION_REGISTRY.table;
|
|
240
|
+
const TABLE_NODE_TYPES = TABLE_CONTRACT.nodeTypes;
|
|
241
|
+
const TABLE_CAPTION = TABLE_CONTRACT.attrs.caption;
|
|
242
|
+
const INLINE_FACTS_CONTRACT = FREE_FORM_SECTION_REGISTRY.inlineFacts;
|
|
243
|
+
|
|
244
|
+
function scalarLength(value, stopAfter = Number.POSITIVE_INFINITY) {
|
|
245
|
+
let length = 0;
|
|
246
|
+
for (const _character of value) {
|
|
247
|
+
length += 1;
|
|
248
|
+
if (length > stopAfter) break;
|
|
249
|
+
}
|
|
250
|
+
return length;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const TABLE_ATTRS = Object.freeze({
|
|
254
|
+
caption: spec((value) => {
|
|
255
|
+
if (isAbsent(value)) return null;
|
|
256
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
257
|
+
return "non-empty plain text when present";
|
|
258
|
+
}
|
|
259
|
+
return scalarLength(value, TABLE_CAPTION.maxScalars) <= TABLE_CAPTION.maxScalars
|
|
260
|
+
? null
|
|
261
|
+
: `at most ${TABLE_CAPTION.maxScalars} Unicode scalar values`;
|
|
262
|
+
}, { code: CODES.tableBounds }),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Node rules
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
const INLINE = Object.freeze(["text", "hardBreak"]);
|
|
270
|
+
const BLOCKS = Object.freeze([
|
|
271
|
+
"paragraph",
|
|
272
|
+
"heading",
|
|
273
|
+
"bulletList",
|
|
274
|
+
"orderedList",
|
|
275
|
+
"blockquote",
|
|
276
|
+
"codeBlock",
|
|
277
|
+
"horizontalRule",
|
|
278
|
+
"taprootImage",
|
|
279
|
+
"componentBlock",
|
|
280
|
+
"rawHtml",
|
|
281
|
+
]);
|
|
282
|
+
const SECTION_BLOCKS = Object.freeze([
|
|
283
|
+
...BLOCKS,
|
|
284
|
+
TABLE_NODE_TYPES.table,
|
|
285
|
+
INLINE_FACTS_CONTRACT.nodeType,
|
|
286
|
+
]);
|
|
287
|
+
const ROOT_BLOCKS = Object.freeze([...SECTION_BLOCKS, FREE_FORM_SECTION_REGISTRY.section.nodeType]);
|
|
288
|
+
const NONE = Object.freeze([]);
|
|
289
|
+
|
|
290
|
+
const rule = (options) => Object.freeze({ attrs: Object.freeze({}), children: NONE, minChildren: 0, ...options });
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The content model, taken from the editor's schema rather than from what the
|
|
294
|
+
* renderer happens to tolerate. The renderer will happily emit `<p><ul>…</ul>`
|
|
295
|
+
* — the browser then hoists the list out of the paragraph — and ProseMirror
|
|
296
|
+
* itself silently repairs a document that violates a node's content
|
|
297
|
+
* expression, which is how a pushed body comes back from a pull with content
|
|
298
|
+
* missing.
|
|
299
|
+
*
|
|
300
|
+
* **Which nodes may carry marks.** In prosemirror-model a mark lands on a node
|
|
301
|
+
* when the node's *parent* type allows that mark: `AddMarkStep.apply` marks
|
|
302
|
+
* every `node.isAtom` in the range where `parent.type.allowsMarkType(...)`, and
|
|
303
|
+
* `ParseContext.insertNode` applies the active mark set under the same test.
|
|
304
|
+
* `markSet` is `null` — every mark allowed — for a type with inline content and
|
|
305
|
+
* no explicit `marks` spec, which here means exactly `paragraph` and `heading`.
|
|
306
|
+
* So the nodes that can arrive carrying marks are the inline atoms that live in
|
|
307
|
+
* those two: `text` and `hardBreak`. Every other node in this vocabulary is a
|
|
308
|
+
* block whose parents (`doc`, `listItem`, `blockquote`) have `markSet == []`
|
|
309
|
+
* and so can never mark a child — `horizontalRule`, `taprootImage`, and
|
|
310
|
+
* `componentBlock` included, since all three are `group: "block"` atoms — and
|
|
311
|
+
* `codeBlock` declares `marks: ""`, which is why its text is refused marks
|
|
312
|
+
* below. If a future extension makes one of them inline, this list changes with
|
|
313
|
+
* it; the criterion, not the list, is the rule.
|
|
314
|
+
*/
|
|
315
|
+
const NODE_RULES = Object.freeze({
|
|
316
|
+
doc: rule({ children: ROOT_BLOCKS }),
|
|
317
|
+
paragraph: rule({ attrs: TEXT_BLOCK_ATTRS, children: INLINE }),
|
|
318
|
+
heading: rule({
|
|
319
|
+
attrs: Object.freeze({
|
|
320
|
+
// 1-4 exactly: `renderHeading` clamps anything else into range, so a
|
|
321
|
+
// level of 5 publishes as an `<h4>` and a level of 0 as an `<h1>`.
|
|
322
|
+
level: spec(
|
|
323
|
+
(value) => (typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 4
|
|
324
|
+
? null
|
|
325
|
+
: "an integer from 1 to 4"),
|
|
326
|
+
{ required: true, code: CODES.headingLevel, missingCode: CODES.headingLevel },
|
|
327
|
+
),
|
|
328
|
+
...TEXT_BLOCK_ATTRS,
|
|
329
|
+
}),
|
|
330
|
+
children: INLINE,
|
|
331
|
+
}),
|
|
332
|
+
text: rule({ marks: true }),
|
|
333
|
+
// A hard break carries marks for the most ordinary reason there is: select
|
|
334
|
+
// two lines of a paragraph, press Bold, and `AddMarkStep` marks every atom
|
|
335
|
+
// in the range — the `<br>` between them included. Pasting
|
|
336
|
+
// `<strong>a<br>b</strong>` does the same through the DOM parser. The
|
|
337
|
+
// renderer emits `<br>` and ignores the mark, which is a visual no-op rather
|
|
338
|
+
// than a silent mangle (a `<br>` inside `<strong>` looks like a `<br>`), so
|
|
339
|
+
// refusing the shape would hard-block an ordinary pull/push for nothing.
|
|
340
|
+
hardBreak: rule({ marks: true }),
|
|
341
|
+
bulletList: rule({ children: Object.freeze(["listItem"]), minChildren: 1 }),
|
|
342
|
+
orderedList: rule({
|
|
343
|
+
// Tiptap's OrderedList declares `start` (default 1) and `type` (default
|
|
344
|
+
// null), and prosemirror-model's `Node.toJSON` emits a node's attrs
|
|
345
|
+
// whenever its type declares any — so *every* stored ordered list carries
|
|
346
|
+
// `{ "start": 1, "type": null }` and refusing the pair would refuse an
|
|
347
|
+
// ordinary pull/push round trip. Only the defaults are accepted: the
|
|
348
|
+
// renderer publishes a bare `<ol>` and reads neither attribute, so any
|
|
349
|
+
// other value is silently discarded at publish time.
|
|
350
|
+
attrs: Object.freeze({
|
|
351
|
+
start: spec((value) => (isAbsent(value) || value === 1
|
|
352
|
+
? null
|
|
353
|
+
: "1, null, or absent: the renderer publishes a bare <ol>, so a list starting anywhere else "
|
|
354
|
+
+ "would silently publish starting at 1")
|
|
355
|
+
),
|
|
356
|
+
type: spec((value) => (isAbsent(value)
|
|
357
|
+
? null
|
|
358
|
+
: "null or absent: the renderer publishes a bare <ol> and carries no list-style type, so any "
|
|
359
|
+
+ "other value would silently publish as a decimal list")
|
|
360
|
+
),
|
|
361
|
+
}),
|
|
362
|
+
children: Object.freeze(["listItem"]),
|
|
363
|
+
minChildren: 1,
|
|
364
|
+
}),
|
|
365
|
+
// ListItem's content expression is `paragraph block*`.
|
|
366
|
+
listItem: rule({ children: BLOCKS, minChildren: 1, firstChild: "paragraph" }),
|
|
367
|
+
blockquote: rule({ children: BLOCKS, minChildren: 1 }),
|
|
368
|
+
codeBlock: rule({
|
|
369
|
+
attrs: Object.freeze({
|
|
370
|
+
language: spec((value) => {
|
|
371
|
+
if (isAbsent(value)) return null;
|
|
372
|
+
return typeof value === "string" && value !== ""
|
|
373
|
+
? null
|
|
374
|
+
: "a non-empty string (omit it, or use null, for no language)";
|
|
375
|
+
}),
|
|
376
|
+
}),
|
|
377
|
+
// `renderCodeBlock` concatenates `child.text` and ignores everything else,
|
|
378
|
+
// so a nested node inside a code block vanishes and marks are stripped.
|
|
379
|
+
children: Object.freeze(["text"]),
|
|
380
|
+
childMarks: false,
|
|
381
|
+
}),
|
|
382
|
+
horizontalRule: rule({}),
|
|
383
|
+
taprootImage: rule({ attrs: IMAGE_ATTRS, rejectedAttrs: IMAGE_TRANSIENT_ATTRS, imageDelivery: true }),
|
|
384
|
+
componentBlock: rule({ attrs: COMPONENT_ATTRS, component: true }),
|
|
385
|
+
section: rule({
|
|
386
|
+
attrs: SECTION_ATTRS,
|
|
387
|
+
children: SECTION_BLOCKS,
|
|
388
|
+
minChildren: FREE_FORM_SECTION_REGISTRY.section.minimumChildren,
|
|
389
|
+
sectionBackground: true,
|
|
390
|
+
sectionDecoration: true,
|
|
391
|
+
}),
|
|
392
|
+
[INLINE_FACTS_CONTRACT.nodeType]: rule({
|
|
393
|
+
attrs: Object.freeze({
|
|
394
|
+
items: spec(() => null, {
|
|
395
|
+
required: true,
|
|
396
|
+
missingMessage: "'items' is required on an 'inlineFacts' node and must contain 1 through 6 facts.",
|
|
397
|
+
}),
|
|
398
|
+
}),
|
|
399
|
+
inlineFacts: true,
|
|
400
|
+
}),
|
|
401
|
+
rawHtml: rule({
|
|
402
|
+
attrs: Object.freeze({
|
|
403
|
+
html: spec((value) => (typeof value === "string" ? null : "a string"), { required: true }),
|
|
404
|
+
}),
|
|
405
|
+
rawHtml: true,
|
|
406
|
+
}),
|
|
407
|
+
[TABLE_NODE_TYPES.table]: rule({
|
|
408
|
+
attrs: TABLE_ATTRS,
|
|
409
|
+
children: Object.freeze([TABLE_NODE_TYPES.row]),
|
|
410
|
+
table: true,
|
|
411
|
+
}),
|
|
412
|
+
[TABLE_NODE_TYPES.row]: rule({
|
|
413
|
+
children: Object.freeze([TABLE_NODE_TYPES.header, TABLE_NODE_TYPES.cell]),
|
|
414
|
+
tableRow: true,
|
|
415
|
+
}),
|
|
416
|
+
[TABLE_NODE_TYPES.header]: rule({
|
|
417
|
+
children: Object.freeze(["paragraph"]),
|
|
418
|
+
tableCell: "header",
|
|
419
|
+
rejectedTableSpans: TABLE_CONTRACT.cells.spanAttrs,
|
|
420
|
+
}),
|
|
421
|
+
[TABLE_NODE_TYPES.cell]: rule({
|
|
422
|
+
children: Object.freeze(["paragraph"]),
|
|
423
|
+
tableCell: "data",
|
|
424
|
+
rejectedTableSpans: TABLE_CONTRACT.cells.spanAttrs,
|
|
425
|
+
}),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Mark rules
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
const MARK_RULES = Object.freeze({
|
|
433
|
+
bold: Object.freeze({}),
|
|
434
|
+
italic: Object.freeze({}),
|
|
435
|
+
underline: Object.freeze({}),
|
|
436
|
+
strike: Object.freeze({}),
|
|
437
|
+
code: Object.freeze({}),
|
|
438
|
+
link: Object.freeze({
|
|
439
|
+
href: spec(
|
|
440
|
+
(value) => {
|
|
441
|
+
if (typeof value !== "string" || value === "") return "a URL";
|
|
442
|
+
// `wrapMark` drops the whole anchor when this fails, keeping the text:
|
|
443
|
+
// the link disappears from the published page without an error.
|
|
444
|
+
return isSafeUrl(value)
|
|
445
|
+
? null
|
|
446
|
+
: "a safe HTTP(S), mailto, tel, non-protocol-relative root, fragment, query, or relative URL without backslashes or ASCII controls";
|
|
447
|
+
},
|
|
448
|
+
{ required: true, code: CODES.linkHref, missingCode: CODES.linkHref },
|
|
449
|
+
),
|
|
450
|
+
// `@tiptap/extension-link` declares these four alongside `href`, and
|
|
451
|
+
// `Mark.toJSON` emits every declared attribute — so every link the editor
|
|
452
|
+
// has ever written carries all five, and refusing any of them would refuse
|
|
453
|
+
// an ordinary pull/push round trip. The renderer ignores them and writes
|
|
454
|
+
// its own `rel`.
|
|
455
|
+
target: optionalString(),
|
|
456
|
+
rel: optionalString(),
|
|
457
|
+
class: optionalString(),
|
|
458
|
+
title: optionalString(),
|
|
459
|
+
}),
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// Traversal
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
function report(state, path, code, message) {
|
|
467
|
+
if (state.errors.length >= CONTENT_LIMITS.documentErrors) {
|
|
468
|
+
state.truncated = true;
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
state.errors.push(contentError(path, code, message));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The complete set of keys prosemirror-model serialises, read off the source:
|
|
476
|
+
* `Node.toJSON` writes `type`, then `attrs` when the node's type declares any,
|
|
477
|
+
* `content` when the node has children, and `marks` when it carries any;
|
|
478
|
+
* `TextNode.toJSON` adds `text`; `Mark.toJSON` writes `type` and `attrs`.
|
|
479
|
+
* There is no fifth node key and no third mark key, which is what makes
|
|
480
|
+
* refusing everything else safe for round-tripping.
|
|
481
|
+
*/
|
|
482
|
+
const SERIALIZED_NODE_KEYS = Object.freeze(["type", "attrs", "content", "marks", "text"]);
|
|
483
|
+
const SERIALIZED_MARK_KEYS = Object.freeze(["type", "attrs"]);
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Refuses a key the renderer will never read.
|
|
487
|
+
*
|
|
488
|
+
* This is the quietest failure in the whole format: `{"type": "paragraph",
|
|
489
|
+
* "text": "..."}` — an agent putting text where content belongs — and
|
|
490
|
+
* `"contents"` for `"content"` are *structurally valid* ProseMirror. The
|
|
491
|
+
* document keeps its presence (other blocks carry real content), the server
|
|
492
|
+
* stores it, and the renderer walks `node.content`, finds nothing, and
|
|
493
|
+
* publishes an empty paragraph. The words are simply gone.
|
|
494
|
+
*
|
|
495
|
+
* `attrs` and `marks` stay allowed as keys wherever they might appear, because
|
|
496
|
+
* `checkAttrs` and `checkMarks` already diagnose their *contents* far more
|
|
497
|
+
* precisely than "unexpected key" ever could.
|
|
498
|
+
*/
|
|
499
|
+
function checkNodeKeys(node, path, nodeRule, isText, state) {
|
|
500
|
+
for (const key of Object.keys(node)) {
|
|
501
|
+
const known = SERIALIZED_NODE_KEYS.includes(key)
|
|
502
|
+
&& (key !== "text" || isText)
|
|
503
|
+
&& (key !== "content" || nodeRule.children.length > 0);
|
|
504
|
+
if (known) continue;
|
|
505
|
+
report(
|
|
506
|
+
state,
|
|
507
|
+
`${path}/${pointerSegment(key)}`,
|
|
508
|
+
CODES.nodeKey,
|
|
509
|
+
`'${identifier(key)}' is not part of a '${identifier(node.type)}' node. ProseMirror serialises only type, `
|
|
510
|
+
+ "attrs, content, marks, and — on a text node — text; anything else is content the renderer never reads "
|
|
511
|
+
+ "and the page publishes without.",
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function checkMarkKeys(mark, markPath, state) {
|
|
517
|
+
for (const key of Object.keys(mark)) {
|
|
518
|
+
if (SERIALIZED_MARK_KEYS.includes(key)) continue;
|
|
519
|
+
report(
|
|
520
|
+
state,
|
|
521
|
+
`${markPath}/${pointerSegment(key)}`,
|
|
522
|
+
CODES.markKey,
|
|
523
|
+
`'${identifier(key)}' is not part of a '${identifier(mark.type)}' mark. A serialised mark carries only type `
|
|
524
|
+
+ "and attrs; a value anywhere else is dropped.",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function checkAttrs(node, path, nodeRule, state) {
|
|
530
|
+
const attrsPath = `${path}/attrs`;
|
|
531
|
+
const attrs = node.attrs;
|
|
532
|
+
const present = isPlainObject(attrs);
|
|
533
|
+
|
|
534
|
+
if (!isAbsent(attrs) && !present) {
|
|
535
|
+
report(state, attrsPath, CODES.attrsInvalid, "'attrs' must be an object when present.");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (present) {
|
|
540
|
+
for (const key of Object.keys(attrs)) {
|
|
541
|
+
if (nodeRule.rejectedTableSpans?.includes(key)) {
|
|
542
|
+
report(
|
|
543
|
+
state,
|
|
544
|
+
`${attrsPath}/${pointerSegment(key)}`,
|
|
545
|
+
CODES.tableSpan,
|
|
546
|
+
`Table cell attribute '${
|
|
547
|
+
identifier(key)
|
|
548
|
+
}' is unsupported. Remove colspan, rowspan, and colwidth; every row must use the same explicit columns.`,
|
|
549
|
+
);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (nodeRule.rejectedAttrs?.includes(key)) {
|
|
553
|
+
report(
|
|
554
|
+
state,
|
|
555
|
+
`${attrsPath}/${pointerSegment(key)}`,
|
|
556
|
+
CODES.imageTransient,
|
|
557
|
+
`'${identifier(key)}' is editor-session state the extension marks rendered: false. It must never be `
|
|
558
|
+
+ "serialised into a published document.",
|
|
559
|
+
);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
// Through `Object.hasOwn`, never a bare index: an attribute named
|
|
563
|
+
// `constructor` or `toString` would otherwise resolve to something off
|
|
564
|
+
// `Object.prototype`, and calling `.check` on it would throw out of a
|
|
565
|
+
// function documented never to throw.
|
|
566
|
+
const attrSpec = Object.hasOwn(nodeRule.attrs, key) ? nodeRule.attrs[key] : undefined;
|
|
567
|
+
if (!attrSpec) {
|
|
568
|
+
report(
|
|
569
|
+
state,
|
|
570
|
+
`${attrsPath}/${pointerSegment(key)}`,
|
|
571
|
+
CODES.attrUnknown,
|
|
572
|
+
`'${identifier(key)}' is not an attribute of a '${node.type}' node.`,
|
|
573
|
+
);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const detail = attrSpec.check(attrs[key]);
|
|
577
|
+
if (detail) {
|
|
578
|
+
report(
|
|
579
|
+
state,
|
|
580
|
+
`${attrsPath}/${pointerSegment(key)}`,
|
|
581
|
+
attrSpec.code ?? CODES.attrInvalid,
|
|
582
|
+
`'${key}' must be ${detail}.`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
for (const [key, attrSpec] of Object.entries(nodeRule.attrs)) {
|
|
589
|
+
if (!attrSpec.required || (present && Object.hasOwn(attrs, key))) continue;
|
|
590
|
+
report(
|
|
591
|
+
state,
|
|
592
|
+
`${attrsPath}/${key}`,
|
|
593
|
+
attrSpec.missingCode ?? attrSpec.code ?? CODES.attrInvalid,
|
|
594
|
+
attrSpec.missingMessage ?? `'${key}' is required on a '${node.type}' node.`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// A crop can only ride an image whose `src` is the signed crop URL the
|
|
599
|
+
// editor stored. `PageImageDeliveryRewriter` sends any node carrying `crop`
|
|
600
|
+
// down its persisted-crop branch, which looks for a crop token inside
|
|
601
|
+
// `src`/`urls`; authored content sends those keys empty, so no token is
|
|
602
|
+
// found, `NormalizePersistedCropUrls` prunes an already-empty array, and the
|
|
603
|
+
// node is published with no delivery URL at all — a blank image.
|
|
604
|
+
if (nodeRule.imageDelivery && present && !isAbsent(attrs.crop) && attrs.src === "") {
|
|
605
|
+
report(
|
|
606
|
+
state,
|
|
607
|
+
`${attrsPath}/crop`,
|
|
608
|
+
CODES.imageKeys,
|
|
609
|
+
"'crop' can only travel on an image whose 'src' is the signed crop URL the editor stored. Authored content "
|
|
610
|
+
+ "must omit it: with an empty 'src' the server finds no crop token, writes no delivery URL, and the image "
|
|
611
|
+
+ "publishes blank.",
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (nodeRule.sectionDecoration && present && Object.hasOwn(attrs, "decoration")) {
|
|
616
|
+
const result = normalizeFreeFormSectionDecoration(attrs.decoration, `${attrsPath}/decoration`);
|
|
617
|
+
for (const error of result.errors) report(state, error.path, error.code, error.message);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (nodeRule.sectionBackground && present && Object.hasOwn(attrs, "background")) {
|
|
621
|
+
const result = normalizeFreeFormSectionBackground(attrs.background, `${attrsPath}/background`);
|
|
622
|
+
for (const error of result.errors) report(state, error.path, error.code, error.message);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (nodeRule.inlineFacts && present && Object.hasOwn(attrs, "items")) {
|
|
626
|
+
const result = normalizeInlineFactsItems(attrs.items, `${attrsPath}/items`);
|
|
627
|
+
for (const error of result.errors) report(state, error.path, error.code, error.message);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// The registry lookup and the eight data tables live in components.js. A
|
|
631
|
+
// missing attribute is already reported above, so the data check runs only
|
|
632
|
+
// once a `componentType` exists to validate against; an absent
|
|
633
|
+
// `componentData` stands in as "{}" so the type is still checked without a
|
|
634
|
+
// second report of the same missing attribute.
|
|
635
|
+
if (nodeRule.component && present && Object.hasOwn(attrs, "componentType")) {
|
|
636
|
+
const data = Object.hasOwn(attrs, "componentData") ? attrs.componentData : "{}";
|
|
637
|
+
for (const error of validateComponentBlock(attrs.componentType, data, attrsPath)) {
|
|
638
|
+
report(state, error.path, error.code, error.message);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function checkMarks(node, path, nodeRule, marksAllowed, state) {
|
|
644
|
+
const marks = node.marks;
|
|
645
|
+
if (!nodeRule.marks && Object.hasOwn(node, "marks")) {
|
|
646
|
+
report(
|
|
647
|
+
state,
|
|
648
|
+
`${path}/marks`,
|
|
649
|
+
CODES.markMisplaced,
|
|
650
|
+
`Marks ride text and hard breaks; ProseMirror never puts one on a '${node.type}' node, because a block `
|
|
651
|
+
+ "node's parent allows no marks.",
|
|
652
|
+
);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (isAbsent(marks)) return;
|
|
656
|
+
if (!Array.isArray(marks)) {
|
|
657
|
+
report(state, `${path}/marks`, CODES.marksInvalid, "'marks' must be an array when present.");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (marks.length === 0) return;
|
|
661
|
+
if (!marksAllowed) {
|
|
662
|
+
report(
|
|
663
|
+
state,
|
|
664
|
+
`${path}/marks`,
|
|
665
|
+
CODES.markMisplaced,
|
|
666
|
+
"Text inside a code block cannot carry marks; the renderer concatenates the raw text and drops them.",
|
|
667
|
+
);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const seen = new Set();
|
|
672
|
+
marks.forEach((mark, index) => {
|
|
673
|
+
const markPath = `${path}/marks/${index}`;
|
|
674
|
+
if (!isPlainObject(mark) || typeof mark.type !== "string") {
|
|
675
|
+
report(state, markPath, CODES.marksInvalid, "A mark must be an object with a string 'type'.");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (!MARK_TYPE_SET.has(mark.type)) {
|
|
679
|
+
report(
|
|
680
|
+
state,
|
|
681
|
+
markPath,
|
|
682
|
+
CODES.unknownMark,
|
|
683
|
+
`Unknown mark '${identifier(mark.type)}'. The renderer drops it and publishes the text unmarked.`,
|
|
684
|
+
);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (seen.has(mark.type)) {
|
|
688
|
+
report(state, markPath, CODES.marksInvalid, `A text node carries more than one '${mark.type}' mark.`);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
seen.add(mark.type);
|
|
692
|
+
checkMarkKeys(mark, markPath, state);
|
|
693
|
+
checkMarkAttrs(mark, markPath, state);
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function checkMarkAttrs(mark, markPath, state) {
|
|
698
|
+
const rules = MARK_RULES[mark.type];
|
|
699
|
+
const attrsPath = `${markPath}/attrs`;
|
|
700
|
+
const attrs = mark.attrs;
|
|
701
|
+
const present = isPlainObject(attrs);
|
|
702
|
+
if (!isAbsent(attrs) && !present) {
|
|
703
|
+
report(state, attrsPath, CODES.attrsInvalid, "'attrs' must be an object when present.");
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (present) {
|
|
707
|
+
for (const key of Object.keys(attrs)) {
|
|
708
|
+
// `Object.hasOwn` for the same reason as in `checkAttrs`: a mark
|
|
709
|
+
// attribute named `constructor` must report, not throw.
|
|
710
|
+
const attrSpec = Object.hasOwn(rules, key) ? rules[key] : undefined;
|
|
711
|
+
if (!attrSpec) {
|
|
712
|
+
report(
|
|
713
|
+
state,
|
|
714
|
+
`${attrsPath}/${pointerSegment(key)}`,
|
|
715
|
+
CODES.attrUnknown,
|
|
716
|
+
`'${identifier(key)}' is not an attribute of a '${mark.type}' mark.`,
|
|
717
|
+
);
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
const detail = attrSpec.check(attrs[key]);
|
|
721
|
+
if (detail) {
|
|
722
|
+
report(
|
|
723
|
+
state,
|
|
724
|
+
`${attrsPath}/${pointerSegment(key)}`,
|
|
725
|
+
attrSpec.code ?? CODES.attrInvalid,
|
|
726
|
+
`'${key}' must be ${detail}.`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
for (const [key, attrSpec] of Object.entries(rules)) {
|
|
732
|
+
if (!attrSpec.required || (present && Object.hasOwn(attrs, key))) continue;
|
|
733
|
+
report(
|
|
734
|
+
state,
|
|
735
|
+
`${attrsPath}/${key}`,
|
|
736
|
+
attrSpec.missingCode ?? attrSpec.code ?? CODES.attrInvalid,
|
|
737
|
+
attrSpec.missingMessage ?? `'${key}' is required on a '${mark.type}' mark.`,
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function textDescendantFacts(node, stopAfter) {
|
|
743
|
+
let scalars = 0;
|
|
744
|
+
let hasNonWhitespace = false;
|
|
745
|
+
const stack = [{ node, depth: 0 }];
|
|
746
|
+
while (stack.length > 0 && scalars <= stopAfter) {
|
|
747
|
+
const entry = stack.pop();
|
|
748
|
+
const current = entry.node;
|
|
749
|
+
if (!isPlainObject(current) || entry.depth > CONTENT_LIMITS.documentDepth) continue;
|
|
750
|
+
if (current.type === "text" && typeof current.text === "string") {
|
|
751
|
+
if (current.text.trim() !== "") hasNonWhitespace = true;
|
|
752
|
+
scalars += scalarLength(current.text, stopAfter - scalars);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (Array.isArray(current.content)) {
|
|
756
|
+
for (let index = current.content.length - 1; index >= 0; index -= 1) {
|
|
757
|
+
stack.push({ node: current.content[index], depth: entry.depth + 1 });
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return { scalars, hasNonWhitespace };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function checkTable(node, path, state) {
|
|
765
|
+
const rows = node.content;
|
|
766
|
+
if (!Array.isArray(rows)) {
|
|
767
|
+
report(
|
|
768
|
+
state,
|
|
769
|
+
`${path}/content`,
|
|
770
|
+
CODES.tableHeader,
|
|
771
|
+
"A table must begin with exactly one tableRow whose cells are all tableHeader nodes.",
|
|
772
|
+
);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const dataRows = Math.max(0, rows.length - TABLE_CONTRACT.rows.header);
|
|
777
|
+
if (rows.length === 0) {
|
|
778
|
+
report(
|
|
779
|
+
state,
|
|
780
|
+
`${path}/content`,
|
|
781
|
+
CODES.tableHeader,
|
|
782
|
+
"A table is missing its header row.",
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
if (dataRows < TABLE_CONTRACT.rows.minData || dataRows > TABLE_CONTRACT.rows.maxData) {
|
|
786
|
+
report(
|
|
787
|
+
state,
|
|
788
|
+
`${path}/content`,
|
|
789
|
+
CODES.tableBounds,
|
|
790
|
+
`A table must contain exactly one header row followed by ${TABLE_CONTRACT.rows.minData} through ${TABLE_CONTRACT.rows.maxData} data rows.`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
let headerColumns;
|
|
795
|
+
rows.forEach((row, rowIndex) => {
|
|
796
|
+
const rowPath = `${path}/content/${rowIndex}`;
|
|
797
|
+
if (!isPlainObject(row) || row.type !== TABLE_NODE_TYPES.row) {
|
|
798
|
+
report(
|
|
799
|
+
state,
|
|
800
|
+
rowPath,
|
|
801
|
+
rowIndex === 0 ? CODES.tableHeader : CODES.tableShape,
|
|
802
|
+
rowIndex === 0
|
|
803
|
+
? "The first table child must be the one header tableRow."
|
|
804
|
+
: "Every table child after the header must be a data tableRow.",
|
|
805
|
+
);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (!Array.isArray(row.content)) {
|
|
809
|
+
report(
|
|
810
|
+
state,
|
|
811
|
+
`${rowPath}/content`,
|
|
812
|
+
rowIndex === 0 ? CODES.tableHeader : CODES.tableShape,
|
|
813
|
+
"A tableRow must carry a content array of explicit cells.",
|
|
814
|
+
);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const columns = row.content.length;
|
|
819
|
+
if (columns < TABLE_CONTRACT.columns.min || columns > TABLE_CONTRACT.columns.max) {
|
|
820
|
+
report(
|
|
821
|
+
state,
|
|
822
|
+
`${rowPath}/content`,
|
|
823
|
+
CODES.tableBounds,
|
|
824
|
+
`Every table row must contain ${TABLE_CONTRACT.columns.min} through ${TABLE_CONTRACT.columns.max} cells.`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
if (rowIndex === 0) {
|
|
828
|
+
headerColumns = columns;
|
|
829
|
+
if (row.content.some((cell) => !isPlainObject(cell) || cell.type !== TABLE_NODE_TYPES.header)) {
|
|
830
|
+
report(
|
|
831
|
+
state,
|
|
832
|
+
`${rowPath}/content`,
|
|
833
|
+
CODES.tableHeader,
|
|
834
|
+
`The first row must contain only '${TABLE_NODE_TYPES.header}' nodes.`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (row.content.some((cell) => !isPlainObject(cell) || cell.type !== TABLE_NODE_TYPES.cell)) {
|
|
841
|
+
report(
|
|
842
|
+
state,
|
|
843
|
+
`${rowPath}/content`,
|
|
844
|
+
CODES.tableShape,
|
|
845
|
+
`Data rows must contain only '${TABLE_NODE_TYPES.cell}' nodes; a table has exactly one header row.`,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
if (headerColumns !== undefined && columns !== headerColumns) {
|
|
849
|
+
report(
|
|
850
|
+
state,
|
|
851
|
+
`${rowPath}/content`,
|
|
852
|
+
CODES.tableRagged,
|
|
853
|
+
`This row has ${columns} cells; every data row must match the header's ${headerColumns} columns.`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function checkTableCell(node, path, nodeRule, state) {
|
|
860
|
+
const content = node.content;
|
|
861
|
+
if (
|
|
862
|
+
!Array.isArray(content)
|
|
863
|
+
|| content.length !== TABLE_CONTRACT.cells.paragraphs
|
|
864
|
+
|| !isPlainObject(content[0])
|
|
865
|
+
|| content[0].type !== "paragraph"
|
|
866
|
+
) {
|
|
867
|
+
report(
|
|
868
|
+
state,
|
|
869
|
+
`${path}/content`,
|
|
870
|
+
CODES.tableCellContent,
|
|
871
|
+
`A '${node.type}' must contain exactly one paragraph and no other block content.`,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const paragraph = Array.isArray(content) && isPlainObject(content[0]) && content[0].type === "paragraph"
|
|
876
|
+
? content[0]
|
|
877
|
+
: undefined;
|
|
878
|
+
if (
|
|
879
|
+
paragraph
|
|
880
|
+
&& !TABLE_CONTRACT.cells.paragraphAttrs
|
|
881
|
+
&& !isAbsent(paragraph.attrs)
|
|
882
|
+
&& (!isPlainObject(paragraph.attrs) || Object.keys(paragraph.attrs).length > 0)
|
|
883
|
+
) {
|
|
884
|
+
report(
|
|
885
|
+
state,
|
|
886
|
+
`${path}/content/0/attrs`,
|
|
887
|
+
CODES.tableCellContent,
|
|
888
|
+
"A table cell paragraph has no appearance attributes; remove its attrs object.",
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const facts = textDescendantFacts(node, TABLE_CONTRACT.cells.maxTextScalars);
|
|
893
|
+
if (facts.scalars > TABLE_CONTRACT.cells.maxTextScalars) {
|
|
894
|
+
report(
|
|
895
|
+
state,
|
|
896
|
+
`${path}/content`,
|
|
897
|
+
CODES.tableBounds,
|
|
898
|
+
`A table cell may contain at most ${TABLE_CONTRACT.cells.maxTextScalars} Unicode scalar values of text.`,
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
if (nodeRule.tableCell === "header" && TABLE_CONTRACT.cells.headerNonWhitespace && !facts.hasNonWhitespace) {
|
|
902
|
+
report(
|
|
903
|
+
state,
|
|
904
|
+
`${path}/content`,
|
|
905
|
+
CODES.tableHeader,
|
|
906
|
+
"Every header cell must contain non-whitespace text.",
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function checkChildren(node, path, nodeRule, state, depth) {
|
|
912
|
+
// A leaf takes no children at all, and `checkNodeKeys` has already refused a
|
|
913
|
+
// stray `content` on one — reporting it again per child would say the same
|
|
914
|
+
// thing several times over.
|
|
915
|
+
if (nodeRule.children.length === 0) return;
|
|
916
|
+
|
|
917
|
+
const content = node.content;
|
|
918
|
+
if (isAbsent(content)) {
|
|
919
|
+
if (nodeRule.minChildren > 0) {
|
|
920
|
+
report(
|
|
921
|
+
state,
|
|
922
|
+
`${path}/content`,
|
|
923
|
+
CODES.childRequired,
|
|
924
|
+
`A '${node.type}' node must contain at least ${nodeRule.minChildren} child node.`,
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (!Array.isArray(content)) {
|
|
930
|
+
report(state, `${path}/content`, CODES.nodeInvalid, "'content' must be an array when present.");
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (content.length < nodeRule.minChildren) {
|
|
934
|
+
report(
|
|
935
|
+
state,
|
|
936
|
+
`${path}/content`,
|
|
937
|
+
CODES.childRequired,
|
|
938
|
+
`A '${node.type}' node must contain at least ${nodeRule.minChildren} child node.`,
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
content.forEach((child, index) => {
|
|
942
|
+
validateNode(child, `${path}/content/${index}`, nodeRule, index, state, depth + 1);
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function validateNode(node, path, parentRule, index, state, depth) {
|
|
947
|
+
if (state.errors.length >= CONTENT_LIMITS.documentErrors) {
|
|
948
|
+
state.truncated = true;
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (depth > CONTENT_LIMITS.documentDepth) {
|
|
952
|
+
report(state, path, CODES.depthLimit, `Documents may not nest deeper than ${CONTENT_LIMITS.documentDepth} levels.`);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (!isPlainObject(node) || typeof node.type !== "string") {
|
|
956
|
+
report(state, path, CODES.nodeInvalid, "A node must be an object with a string 'type'.");
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (!NODE_TYPE_SET.has(node.type)) {
|
|
960
|
+
report(
|
|
961
|
+
state,
|
|
962
|
+
path,
|
|
963
|
+
CODES.unknownNode,
|
|
964
|
+
`Unknown node '${identifier(node.type)}'. The renderer drops it and publishes only its children.`,
|
|
965
|
+
);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (!parentRule.children.includes(node.type)) {
|
|
969
|
+
report(
|
|
970
|
+
state,
|
|
971
|
+
path,
|
|
972
|
+
CODES.childNotAllowed,
|
|
973
|
+
`A '${node.type}' node is not valid inside this node.`,
|
|
974
|
+
);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (parentRule.firstChild && index === 0 && node.type !== parentRule.firstChild) {
|
|
978
|
+
report(
|
|
979
|
+
state,
|
|
980
|
+
path,
|
|
981
|
+
CODES.childNotAllowed,
|
|
982
|
+
`A list item must begin with a '${parentRule.firstChild}' node.`,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const nodeRule = NODE_RULES[node.type];
|
|
987
|
+
if (nodeRule.rawHtml && !state.allowRawHtml) {
|
|
988
|
+
report(
|
|
989
|
+
state,
|
|
990
|
+
path,
|
|
991
|
+
CODES.rawHtmlForbidden,
|
|
992
|
+
"A 'rawHtml' node is published verbatim and unsanitised. Pass allowRawHtml to accept one deliberately.",
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
if (node.type === "text") {
|
|
996
|
+
if (typeof node.text !== "string") {
|
|
997
|
+
report(state, path, CODES.textInvalid, "A text node must carry a string 'text'.");
|
|
998
|
+
} else if (node.text === "") {
|
|
999
|
+
report(state, path, CODES.textInvalid, "A text node cannot be empty; ProseMirror cannot represent one.");
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
checkNodeKeys(node, path, nodeRule, node.type === "text", state);
|
|
1004
|
+
checkAttrs(node, path, nodeRule, state);
|
|
1005
|
+
checkMarks(node, path, nodeRule, parentRule.childMarks !== false, state);
|
|
1006
|
+
if (nodeRule.table) checkTable(node, path, state);
|
|
1007
|
+
if (nodeRule.tableCell) checkTableCell(node, path, nodeRule, state);
|
|
1008
|
+
checkChildren(node, path, nodeRule, state, depth);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Validates a ProseMirror page body against the vocabulary the published-site
|
|
1013
|
+
* renderer actually understands.
|
|
1014
|
+
*
|
|
1015
|
+
* Returns `{ errors }`; an empty `errors` array means the document is safe to
|
|
1016
|
+
* send. `path` is a JSON pointer into the document (`""` is the document
|
|
1017
|
+
* itself, `/content/3/content/0` its fourth block's first child, and a
|
|
1018
|
+
* component finding continues *through* the `componentData` JSON string into
|
|
1019
|
+
* the parsed data). `code` is one of `CONTENT_ERROR_CODES`.
|
|
1020
|
+
*
|
|
1021
|
+
* `allowRawHtml` opts a caller into `rawHtml` nodes, which are published
|
|
1022
|
+
* unsanitised. It is off by default precisely because an agent should not be
|
|
1023
|
+
* able to emit one by accident.
|
|
1024
|
+
*/
|
|
1025
|
+
export function validateDocument(document, options = {}) {
|
|
1026
|
+
const state = {
|
|
1027
|
+
errors: [],
|
|
1028
|
+
truncated: false,
|
|
1029
|
+
allowRawHtml: options?.allowRawHtml === true,
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
if (!isPlainObject(document) || document.type !== "doc") {
|
|
1033
|
+
report(state, "", CODES.docType, "A page body must be an object with type \"doc\".");
|
|
1034
|
+
return finish(state);
|
|
1035
|
+
}
|
|
1036
|
+
if (!Array.isArray(document.content)) {
|
|
1037
|
+
report(state, "/content", CODES.docContent, "A page body must carry a 'content' array.");
|
|
1038
|
+
return finish(state);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
checkNodeKeys(document, "", NODE_RULES.doc, false, state);
|
|
1042
|
+
checkAttrs(document, "", NODE_RULES.doc, state);
|
|
1043
|
+
checkMarks(document, "", NODE_RULES.doc, true, state);
|
|
1044
|
+
document.content.forEach((node, index) => {
|
|
1045
|
+
validateNode(node, `/content/${index}`, NODE_RULES.doc, index, state, 1);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
// The server's own gate, named locally. `RichTextBody.IsPresent` refuses a
|
|
1049
|
+
// document with no text, no image, and no component block with a bare "Body
|
|
1050
|
+
// is required" that says nothing about which page or why.
|
|
1051
|
+
if (!documentIsPresent(document)) {
|
|
1052
|
+
report(
|
|
1053
|
+
state,
|
|
1054
|
+
"",
|
|
1055
|
+
CODES.emptyDocument,
|
|
1056
|
+
"A page body must contain text, an image, a component block, or inline facts; the server refuses an empty document.",
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return finish(state);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function finish(state) {
|
|
1064
|
+
if (state.truncated) {
|
|
1065
|
+
state.errors.push(contentError(
|
|
1066
|
+
"",
|
|
1067
|
+
CODES.errorLimit,
|
|
1068
|
+
`Validation stopped after ${CONTENT_LIMITS.documentErrors} findings; fix these and run it again.`,
|
|
1069
|
+
));
|
|
1070
|
+
}
|
|
1071
|
+
return Object.freeze({ errors: Object.freeze(state.errors) });
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Exposed so the renderer-parity test can assert the rules table covers
|
|
1076
|
+
* exactly the vocabulary the copied renderer switches on — a node added
|
|
1077
|
+
* upstream must fail here rather than pass through unvalidated.
|
|
1078
|
+
*/
|
|
1079
|
+
export { NODE_RULES };
|