@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.
Files changed (57) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +239 -0
  3. package/bin/taproot-site.js +5 -0
  4. package/package.json +40 -0
  5. package/src/api.js +1507 -0
  6. package/src/appearance-contract.js +311 -0
  7. package/src/atomic-file.js +136 -0
  8. package/src/bounded-file.js +59 -0
  9. package/src/capabilities.js +29 -0
  10. package/src/cli.js +872 -0
  11. package/src/config.js +412 -0
  12. package/src/constants.js +173 -0
  13. package/src/content/components.js +870 -0
  14. package/src/content/free-form-section-registry.json +524 -0
  15. package/src/content/free-form-sections.js +582 -0
  16. package/src/content/index.js +29 -0
  17. package/src/content/inline-facts.js +97 -0
  18. package/src/content/markdown.js +1625 -0
  19. package/src/content/tiptap-prosemirror.ts +1266 -0
  20. package/src/content/validate-document.js +1079 -0
  21. package/src/content/vocabulary.js +397 -0
  22. package/src/credentials.js +544 -0
  23. package/src/errors.js +133 -0
  24. package/src/footer-contract.js +687 -0
  25. package/src/footer-draft-hash.js +203 -0
  26. package/src/footer-workspace.js +136 -0
  27. package/src/image-metadata.js +169 -0
  28. package/src/index.js +16 -0
  29. package/src/json-guard.js +111 -0
  30. package/src/output.js +157 -0
  31. package/src/presentation-reference.js +600 -0
  32. package/src/reference-help.js +927 -0
  33. package/src/session.js +316 -0
  34. package/src/settings-catalog.js +187 -0
  35. package/src/settings.js +157 -0
  36. package/src/theme-validation.js +344 -0
  37. package/src/transport.js +777 -0
  38. package/src/verbs/approve.js +178 -0
  39. package/src/verbs/deploy.js +327 -0
  40. package/src/verbs/env.js +89 -0
  41. package/src/verbs/footer-push.js +115 -0
  42. package/src/verbs/index.js +125 -0
  43. package/src/verbs/login.js +529 -0
  44. package/src/verbs/logout.js +39 -0
  45. package/src/verbs/media-upload.js +319 -0
  46. package/src/verbs/nav-push.js +333 -0
  47. package/src/verbs/pages-push.js +990 -0
  48. package/src/verbs/preview-page.js +107 -0
  49. package/src/verbs/preview-revoke.js +56 -0
  50. package/src/verbs/pull.js +727 -0
  51. package/src/verbs/sites.js +57 -0
  52. package/src/verbs/status.js +94 -0
  53. package/src/verbs/theme-push.js +197 -0
  54. package/src/verbs/use.js +129 -0
  55. package/src/verbs/validate.js +477 -0
  56. package/src/verbs/whoami.js +108 -0
  57. 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
+ }