@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,397 @@
|
|
|
1
|
+
import { sanitizeDiagnostic } from "../errors.js";
|
|
2
|
+
import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The primitives the content module shares: the accepted ProseMirror
|
|
6
|
+
* vocabulary, the stable error codes, and faithful reimplementations of the
|
|
7
|
+
* canonical renderer's own predicates.
|
|
8
|
+
*
|
|
9
|
+
* Everything in this file exists because the server does not validate page
|
|
10
|
+
* bodies. `RichTextBody.IsPresent` checks that a document is a `doc` with at
|
|
11
|
+
* least one meaningful node and nothing else; the renderer
|
|
12
|
+
* (`shared/tiptap-prosemirror.ts`, copied verbatim next door as
|
|
13
|
+
* `tiptap-prosemirror.ts`) then *silently drops* anything it does not
|
|
14
|
+
* recognise — an unknown node renders its children, an unknown mark renders
|
|
15
|
+
* bare text, an unsafe link loses its anchor, an out-of-range heading level
|
|
16
|
+
* is clamped, an invalid CSS override disappears. Every one of those is a
|
|
17
|
+
* page that publishes successfully and renders wrong, so this package rejects
|
|
18
|
+
* them before they are sent.
|
|
19
|
+
*
|
|
20
|
+
* The rule for the reimplementations below: match the renderer exactly,
|
|
21
|
+
* including the parts that look like bugs. A "safer" copy would reject
|
|
22
|
+
* documents the renderer accepts, or accept documents it silently mangles,
|
|
23
|
+
* and either way the CLI would stop being the schema.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// The accepted vocabulary
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
// Every `case` label in the renderer's `renderNode` switch, minus the
|
|
31
|
+
// `default` arm that silently renders an unknown node's children. The
|
|
32
|
+
// renderer-parity test extracts those labels from the copied source and
|
|
33
|
+
// compares them with this list, so a node added upstream turns this package
|
|
34
|
+
// red instead of passing through unvalidated.
|
|
35
|
+
export const NODE_TYPES = Object.freeze([
|
|
36
|
+
"doc",
|
|
37
|
+
"paragraph",
|
|
38
|
+
"text",
|
|
39
|
+
"hardBreak",
|
|
40
|
+
"heading",
|
|
41
|
+
"bulletList",
|
|
42
|
+
"orderedList",
|
|
43
|
+
"listItem",
|
|
44
|
+
"blockquote",
|
|
45
|
+
"codeBlock",
|
|
46
|
+
"horizontalRule",
|
|
47
|
+
"taprootImage",
|
|
48
|
+
"componentBlock",
|
|
49
|
+
"section",
|
|
50
|
+
freeFormSectionRegistry.inlineFacts.nodeType,
|
|
51
|
+
"rawHtml",
|
|
52
|
+
freeFormSectionRegistry.table.nodeTypes.table,
|
|
53
|
+
freeFormSectionRegistry.table.nodeTypes.row,
|
|
54
|
+
freeFormSectionRegistry.table.nodeTypes.header,
|
|
55
|
+
freeFormSectionRegistry.table.nodeTypes.cell,
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// Every `case` label in the renderer's `wrapMark` switch, minus the `default`
|
|
59
|
+
// arm that returns the text unwrapped. Cross-checked by the same parity test.
|
|
60
|
+
export const MARK_TYPES = Object.freeze([
|
|
61
|
+
"bold",
|
|
62
|
+
"italic",
|
|
63
|
+
"underline",
|
|
64
|
+
"strike",
|
|
65
|
+
"code",
|
|
66
|
+
"link",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
export const NODE_TYPE_SET = Object.freeze(new Set(NODE_TYPES));
|
|
70
|
+
export const MARK_TYPE_SET = Object.freeze(new Set(MARK_TYPES));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every code this module emits. They are wire identities an agent branches on,
|
|
74
|
+
* so they are appended to, never reworded or renumbered.
|
|
75
|
+
*/
|
|
76
|
+
export const CONTENT_ERROR_CODES = Object.freeze({
|
|
77
|
+
// Document shape
|
|
78
|
+
docType: "content.doc_type",
|
|
79
|
+
docContent: "content.doc_content",
|
|
80
|
+
emptyDocument: "content.empty_document",
|
|
81
|
+
depthLimit: "content.depth_limit",
|
|
82
|
+
errorLimit: "content.error_limit",
|
|
83
|
+
// Nodes
|
|
84
|
+
nodeInvalid: "content.node_invalid",
|
|
85
|
+
nodeKey: "content.node_key",
|
|
86
|
+
unknownNode: "content.unknown_node",
|
|
87
|
+
childNotAllowed: "content.child_not_allowed",
|
|
88
|
+
childRequired: "content.child_required",
|
|
89
|
+
textInvalid: "content.text_invalid",
|
|
90
|
+
// Marks
|
|
91
|
+
marksInvalid: "content.marks_invalid",
|
|
92
|
+
markKey: "content.mark_key",
|
|
93
|
+
markMisplaced: "content.mark_misplaced",
|
|
94
|
+
unknownMark: "content.unknown_mark",
|
|
95
|
+
linkHref: "content.link_href",
|
|
96
|
+
// Attributes
|
|
97
|
+
attrsInvalid: "content.attrs_invalid",
|
|
98
|
+
attrUnknown: "content.attr_unknown",
|
|
99
|
+
attrInvalid: "content.attr_invalid",
|
|
100
|
+
headingLevel: "content.heading_level",
|
|
101
|
+
// Images
|
|
102
|
+
imageKeys: "content.image_keys",
|
|
103
|
+
imageTransient: "content.image_transient",
|
|
104
|
+
// Components
|
|
105
|
+
componentUnknown: "content.component_unknown",
|
|
106
|
+
componentData: "content.component_data",
|
|
107
|
+
sectionContextUnknown: "content.section_context_unknown",
|
|
108
|
+
// Raw HTML
|
|
109
|
+
rawHtmlForbidden: "content.raw_html_forbidden",
|
|
110
|
+
// Tables
|
|
111
|
+
tableHeader: "content.table_header",
|
|
112
|
+
tableShape: "content.table_shape",
|
|
113
|
+
tableRagged: "content.table_ragged",
|
|
114
|
+
tableBounds: "content.table_bounds",
|
|
115
|
+
tableCellContent: "content.table_cell_content",
|
|
116
|
+
tableSpan: "content.table_span",
|
|
117
|
+
// Markdown conversion
|
|
118
|
+
markdownInput: "content.markdown_input",
|
|
119
|
+
markdownControl: "content.markdown_control",
|
|
120
|
+
markdownFrontMatter: "content.markdown_front_matter",
|
|
121
|
+
markdownEmpty: "content.markdown_empty",
|
|
122
|
+
headingDepth: "content.heading_depth",
|
|
123
|
+
markdownSetext: "content.markdown_setext",
|
|
124
|
+
markdownTable: "content.markdown_table",
|
|
125
|
+
markdownTableAlignment: "content.markdown_table_alignment",
|
|
126
|
+
markdownFootnote: "content.markdown_footnote",
|
|
127
|
+
markdownHtml: "content.markdown_html",
|
|
128
|
+
markdownAutolink: "content.markdown_autolink",
|
|
129
|
+
markdownEntity: "content.markdown_entity",
|
|
130
|
+
markdownReferenceLink: "content.markdown_reference_link",
|
|
131
|
+
markdownIndentedCode: "content.markdown_indented_code",
|
|
132
|
+
markdownUnclosedFence: "content.markdown_unclosed_fence",
|
|
133
|
+
markdownCodeLanguage: "content.markdown_code_language",
|
|
134
|
+
markdownSectionHeader: "content.markdown_section_header",
|
|
135
|
+
markdownSectionUnclosed: "content.markdown_section_unclosed",
|
|
136
|
+
markdownSectionNested: "content.markdown_section_nested",
|
|
137
|
+
markdownInlineFacts: "content.markdown_inline_facts",
|
|
138
|
+
markdownListMarker: "content.markdown_list_marker",
|
|
139
|
+
markdownListStart: "content.markdown_list_start",
|
|
140
|
+
markdownListItem: "content.markdown_list_item",
|
|
141
|
+
markdownImage: "content.markdown_image",
|
|
142
|
+
markdownLink: "content.markdown_link",
|
|
143
|
+
markdownNesting: "content.markdown_nesting",
|
|
144
|
+
markdownResolveImage: "content.markdown_resolve_image",
|
|
145
|
+
markdownInvariant: "content.markdown_invariant",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export const CONTENT_LIMITS = Object.freeze({
|
|
149
|
+
// A document deeper than this is either machine-generated nonsense or a
|
|
150
|
+
// cycle a caller hand-built; either way the traversal stops rather than
|
|
151
|
+
// exhausting the stack.
|
|
152
|
+
documentDepth: 64,
|
|
153
|
+
// Enough to describe a genuinely broken document without letting a hostile
|
|
154
|
+
// one turn one call into megabytes of diagnostics.
|
|
155
|
+
documentErrors: 200,
|
|
156
|
+
identifierScalars: 64,
|
|
157
|
+
markdownBytes: 1024 * 1024,
|
|
158
|
+
markdownNesting: 16,
|
|
159
|
+
// Character steps the inline scanner may take across one whole conversion.
|
|
160
|
+
// Finding a closing delimiter is a linear scan, and an *unclosed* one scans
|
|
161
|
+
// to the end of the block — so a document of nothing but openers costs
|
|
162
|
+
// O(n^2) and `markdownBytes` alone would allow minutes of uninterruptible
|
|
163
|
+
// CPU. Real prose closes its delimiters within a few characters and spends
|
|
164
|
+
// a tiny fraction of this: a 37 KB document of well-formed bold, italic,
|
|
165
|
+
// code, and link runs measures in the low tens of thousands of steps.
|
|
166
|
+
inlineScanBudget: 5_000_000,
|
|
167
|
+
imageReferenceLength: 1024,
|
|
168
|
+
componentDataBytes: 256 * 1024,
|
|
169
|
+
tableCaptionScalars: freeFormSectionRegistry.table.attrs.caption.maxScalars,
|
|
170
|
+
tableDataRows: freeFormSectionRegistry.table.rows.maxData,
|
|
171
|
+
tableColumns: freeFormSectionRegistry.table.columns.max,
|
|
172
|
+
tableCellTextScalars: freeFormSectionRegistry.table.cells.maxTextScalars,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Renderer-mirrored constants and predicates
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/** `TIPTAP_SITE_DEFAULT` — the "no explicit choice" sentinel. */
|
|
180
|
+
export const TIPTAP_SITE_DEFAULT = "site-default";
|
|
181
|
+
|
|
182
|
+
/** `TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS` — anything else normalises to 60 (silent). */
|
|
183
|
+
export const IMAGE_MAX_HEIGHT_OPTIONS = Object.freeze([45, 60, 75, 90]);
|
|
184
|
+
|
|
185
|
+
/** The canonical `TiptapImagePresentationPlacement` union. */
|
|
186
|
+
export const IMAGE_PRESENTATION_PLACEMENTS = Object.freeze([
|
|
187
|
+
"left",
|
|
188
|
+
"left-text-right",
|
|
189
|
+
"center",
|
|
190
|
+
"right",
|
|
191
|
+
"right-text-left",
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
/** `ArticleImageAlign` from the editor's image extension. */
|
|
195
|
+
export const IMAGE_ALIGNMENTS = Object.freeze(["start", "center", "end"]);
|
|
196
|
+
|
|
197
|
+
/** `ArticleImagePlacement` from the editor's image extension. */
|
|
198
|
+
export const IMAGE_PLACEMENTS = Object.freeze(["inline", "float-start", "float-end"]);
|
|
199
|
+
|
|
200
|
+
/** The four values the renderer's `textBlockAttrs` emits a `text-align` for. */
|
|
201
|
+
export const TEXT_ALIGNMENTS = Object.freeze(["left", "center", "right", "justify"]);
|
|
202
|
+
|
|
203
|
+
export function isPlainObject(value) {
|
|
204
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Mirrors the renderer's `isSafeUrl`, including the behavior a from-scratch
|
|
209
|
+
* implementation would get wrong:
|
|
210
|
+
*
|
|
211
|
+
* A value `new URL()` cannot parse is treated as a relative reference and
|
|
212
|
+
* accepted — the renderer's deliberate escape hatch for `foo/bar`. Protocol-
|
|
213
|
+
* relative references, backslashes, and ASCII controls are rejected before
|
|
214
|
+
* that fallback so a native URL cannot acquire an ambiguous authority or
|
|
215
|
+
* browser-dependent interpretation.
|
|
216
|
+
*
|
|
217
|
+
* A link the renderer refuses is not dropped loudly: `wrapMark` returns the
|
|
218
|
+
* bare text and the anchor vanishes, so the CLI must refuse it here.
|
|
219
|
+
*/
|
|
220
|
+
export function isSafeUrl(url) {
|
|
221
|
+
if (/[\u0000-\u001f\u007f]/u.test(url) || url.includes("\\")) return false;
|
|
222
|
+
const cleaned = url.trim();
|
|
223
|
+
if (!cleaned) return false;
|
|
224
|
+
if (cleaned.startsWith("//")) return false;
|
|
225
|
+
if (cleaned.startsWith("/") || cleaned.startsWith("./")) return true;
|
|
226
|
+
if (cleaned.startsWith("#") || cleaned.startsWith("?")) return true;
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const parsed = new URL(cleaned);
|
|
230
|
+
return ["http:", "https:", "mailto:", "tel:"].includes(parsed.protocol);
|
|
231
|
+
} catch {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The renderer's CSS grammar, character for character. These values are
|
|
237
|
+
// emitted into inline styles on published pages, which is why the grammar is
|
|
238
|
+
// this narrow — and why a value outside it is dropped rather than escaped.
|
|
239
|
+
const CSS_LENGTH_UNITS = "px|em|rem|ch|ex|vw|vh|svh|lvh|dvh|svw|lvw|dvw|vmin|vmax|%";
|
|
240
|
+
const CSS_LENGTH_RE = new RegExp(`^\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})$`, "i");
|
|
241
|
+
const CSS_VAR_RE = /^var\(--[a-z0-9-]+\)$/i;
|
|
242
|
+
const CSS_CALC_TERM = `(?:\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})|\\d+(?:\\.\\d+)?|var\\(--[a-z0-9-]+\\))`;
|
|
243
|
+
const CSS_CALC_RE = new RegExp(
|
|
244
|
+
`^calc\\(\\s*${CSS_CALC_TERM}(?:\\s*[+\\-*/]\\s*${CSS_CALC_TERM})*\\s*\\)$`,
|
|
245
|
+
"i",
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Mirrors the renderer's `normalizeCssLengthOverride`: returns the trimmed
|
|
250
|
+
* value, or null when the value is invalid *or* is the site-default sentinel.
|
|
251
|
+
* Callers that need to tell those two apart check the sentinel themselves.
|
|
252
|
+
*/
|
|
253
|
+
export function normalizeCssLengthOverride(value, options) {
|
|
254
|
+
if (typeof value !== "string") return null;
|
|
255
|
+
const trimmed = value.trim();
|
|
256
|
+
if (!trimmed || trimmed === TIPTAP_SITE_DEFAULT) return null;
|
|
257
|
+
if (trimmed === "0") return trimmed;
|
|
258
|
+
if (options?.allowNone && trimmed.toLowerCase() === "none") return "none";
|
|
259
|
+
if (CSS_LENGTH_RE.test(trimmed)) return trimmed;
|
|
260
|
+
if (CSS_VAR_RE.test(trimmed)) return trimmed;
|
|
261
|
+
if (CSS_CALC_RE.test(trimmed)) return trimmed;
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The lowercase canonical UUID the API round-trips. Deliberately looser than
|
|
267
|
+
* `config.js`'s site-id pattern on the version nibble: image ids are server
|
|
268
|
+
* minted and the platform is free to move from v4 to v7 without this package
|
|
269
|
+
* refusing every image on the site.
|
|
270
|
+
*/
|
|
271
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
272
|
+
|
|
273
|
+
export function isUuid(value) {
|
|
274
|
+
return typeof value === "string" && UUID_RE.test(value);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Server-parity: RichTextBody.IsPresent
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
const NAMED_ENTITIES = Object.freeze({
|
|
282
|
+
amp: "&",
|
|
283
|
+
apos: "'",
|
|
284
|
+
gt: ">",
|
|
285
|
+
lt: "<",
|
|
286
|
+
nbsp: " ",
|
|
287
|
+
quot: "\"",
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A bounded stand-in for `WebUtility.HtmlDecode`, used only to answer "does
|
|
292
|
+
* this raw HTML contain visible text?". The named set covers the entities that
|
|
293
|
+
* can hide a `<` or a space; an unknown named entity stays literal, which
|
|
294
|
+
* makes the answer *more* likely to be "yes, there is text" — the safe
|
|
295
|
+
* direction, because a false "empty" would refuse a page the server accepts.
|
|
296
|
+
*/
|
|
297
|
+
function decodeHtmlEntities(html) {
|
|
298
|
+
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) => {
|
|
299
|
+
if (body[0] === "#") {
|
|
300
|
+
const codePoint = body[1] === "x" || body[1] === "X"
|
|
301
|
+
? Number.parseInt(body.slice(2), 16)
|
|
302
|
+
: Number.parseInt(body.slice(1), 10);
|
|
303
|
+
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return match;
|
|
304
|
+
try {
|
|
305
|
+
return String.fromCodePoint(codePoint);
|
|
306
|
+
} catch {
|
|
307
|
+
return match;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return NAMED_ENTITIES[body] ?? match;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Mirrors `RichTextBody.HasMeaningfulHtml`: text outside tags, after decoding. */
|
|
315
|
+
function hasMeaningfulHtml(html) {
|
|
316
|
+
if (typeof html !== "string" || html.trim() === "") return false;
|
|
317
|
+
const decoded = decodeHtmlEntities(html).replaceAll("\u00a0", " ");
|
|
318
|
+
let insideTag = false;
|
|
319
|
+
for (const character of decoded) {
|
|
320
|
+
if (character === "<") {
|
|
321
|
+
insideTag = true;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (character === ">") {
|
|
325
|
+
insideTag = false;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!insideTag && character.trim() !== "") return true;
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Mirrors `RichTextBody.HasMeaningfulContent` in
|
|
335
|
+
* `api/src/Taproot.Domain/Entities/PageModel/RichTextBody.cs`. Text counts when
|
|
336
|
+
* it is non-whitespace, raw HTML when it has visible text, and images and
|
|
337
|
+
* component blocks always count; every other node counts only through its
|
|
338
|
+
* children. A body that fails this is refused by the server with "Body is
|
|
339
|
+
* required", which is why this package names it locally instead.
|
|
340
|
+
*/
|
|
341
|
+
function hasMeaningfulContent(node, depth = 0) {
|
|
342
|
+
if (!isPlainObject(node) || depth > CONTENT_LIMITS.documentDepth) return false;
|
|
343
|
+
if (node.type === "text") {
|
|
344
|
+
return typeof node.text === "string" && node.text.trim() !== "";
|
|
345
|
+
}
|
|
346
|
+
if (node.type === "rawHtml") {
|
|
347
|
+
return isPlainObject(node.attrs) && hasMeaningfulHtml(node.attrs.html);
|
|
348
|
+
}
|
|
349
|
+
if (
|
|
350
|
+
node.type === "taprootImage"
|
|
351
|
+
|| node.type === "componentBlock"
|
|
352
|
+
|| node.type === freeFormSectionRegistry.inlineFacts.nodeType
|
|
353
|
+
) return true;
|
|
354
|
+
if (!Array.isArray(node.content)) return false;
|
|
355
|
+
return node.content.some((child) => hasMeaningfulContent(child, depth + 1));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Mirrors `RichTextBody.IsPresent` for a whole document. */
|
|
359
|
+
export function documentIsPresent(document) {
|
|
360
|
+
if (!isPlainObject(document) || document.type !== "doc" || !Array.isArray(document.content)) return false;
|
|
361
|
+
return document.content.some((node) => hasMeaningfulContent(node, 1));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// Diagnostics
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Bounds an author-supplied identifier before it reaches a message or a path.
|
|
370
|
+
* Node types, attribute keys, and component types all come from the document
|
|
371
|
+
* under validation, and the result of this module is printed to a terminal and
|
|
372
|
+
* written to `GITHUB_OUTPUT` — so the same de-fanging `errors.js` applies to
|
|
373
|
+
* API diagnostics applies here, with a much tighter length bound.
|
|
374
|
+
*/
|
|
375
|
+
export function identifier(value) {
|
|
376
|
+
let source = "";
|
|
377
|
+
try {
|
|
378
|
+
// `validateDocument` promises never to throw, and a caller-built document
|
|
379
|
+
// can carry an object whose own `toString` throws.
|
|
380
|
+
source = typeof value === "string" ? value : String(value);
|
|
381
|
+
} catch {
|
|
382
|
+
source = "";
|
|
383
|
+
}
|
|
384
|
+
const clean = sanitizeDiagnostic(source, "");
|
|
385
|
+
const bounded = [...clean].slice(0, CONTENT_LIMITS.identifierScalars).join("");
|
|
386
|
+
return bounded || "(unnamed)";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** JSON-pointer escaping (RFC 6901) for a single path segment. */
|
|
390
|
+
export function pointerSegment(value) {
|
|
391
|
+
return identifier(value).replaceAll("~", "~0").replaceAll("/", "~1");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** One validation finding. Frozen so a caller cannot mutate a shared result. */
|
|
395
|
+
export function contentError(path, code, message) {
|
|
396
|
+
return Object.freeze({ path, code, message });
|
|
397
|
+
}
|