@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,344 @@
1
+ import {
2
+ encodeTheme,
3
+ LIGHTNESS_KEYS,
4
+ SEMANTIC_COLOR_NAMES,
5
+ validateThemePair,
6
+ } from "@taprootio/espalier/shared/theme";
7
+
8
+ import { hasControlCharacter, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
9
+
10
+ export const MAXIMUM_THEME_WARNINGS = 32;
11
+ export const MAXIMUM_THEME_WARNING_SCALARS = 512;
12
+ export const MAXIMUM_THEME_OPEN_MAP_ENTRIES = 128;
13
+ export const MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS = 64;
14
+
15
+ // Kept in the same order as DefaultSiteTheme.RequiredProperties. This is the
16
+ // storage doctrine rather than every optional Espalier property: a site theme
17
+ // may add optional surfaces, but it may not omit one of the groups whose
18
+ // absence makes the app, preview, and generator resolve differently.
19
+ export const REQUIRED_THEME_PROPERTIES = Object.freeze([
20
+ "seedColor",
21
+ "angles",
22
+ "chroma",
23
+ "lightness",
24
+ "semanticHues",
25
+ "semanticMappings",
26
+ "variantChroma",
27
+ "intents",
28
+ "tones",
29
+ "anchors",
30
+ "roles",
31
+ "contexts",
32
+ "dataRamps",
33
+ "dataPalette",
34
+ "borderRadius",
35
+ "rootFontSize",
36
+ "typeRatio",
37
+ "spaceRatio",
38
+ "viewportMin",
39
+ "viewportMax",
40
+ "stylesheets",
41
+ "pageBackgroundImage",
42
+ "pageBackgroundImageOpacity",
43
+ "fontBody",
44
+ "fontHeadings",
45
+ "fontBrand",
46
+ "fontMonospace",
47
+ "fontWeightBody",
48
+ "fontWeightHeadings",
49
+ "fontWeightBrand",
50
+ "fontWeightMonospace",
51
+ ]);
52
+
53
+ const REQUIRED_ANGLE_LEAVES = Object.freeze([
54
+ "analogous",
55
+ "complementary",
56
+ "splitComplementary",
57
+ "triadic",
58
+ ]);
59
+ const REQUIRED_SEMANTIC_HUE_LEAVES = Object.freeze(["danger", "success", "warning", "info"]);
60
+ const REQUIRED_DATA_PALETTE_LEAVES = Object.freeze([
61
+ "series1",
62
+ "series2",
63
+ "series3",
64
+ "series4",
65
+ "series5",
66
+ "series6",
67
+ "series7",
68
+ "series8",
69
+ ]);
70
+
71
+ // Fixed-shape descendants are part of completeness too. Espalier validation
72
+ // accepts these objects sparsely because it also validates page-level deltas;
73
+ // a stored agent-authored site theme has the stricter frozen-value contract.
74
+ export const REQUIRED_THEME_PATHS = Object.freeze([
75
+ ...REQUIRED_ANGLE_LEAVES.map((leaf) => `angles.${leaf}`),
76
+ ...SEMANTIC_COLOR_NAMES.flatMap((color) => [`chroma.${color}.min`, `chroma.${color}.max`]),
77
+ ...LIGHTNESS_KEYS.map((leaf) => `lightness.${leaf}`),
78
+ ...REQUIRED_SEMANTIC_HUE_LEAVES.map((leaf) => `semanticHues.${leaf}`),
79
+ ...REQUIRED_DATA_PALETTE_LEAVES.map((leaf) => `dataPalette.${leaf}`),
80
+ ]);
81
+
82
+ export const HEADER_BRAND_COLOR_TOKENS = Object.freeze(
83
+ new Set([
84
+ "--esp-color-headings",
85
+ "--esp-color-link",
86
+ "--esp-color-primary",
87
+ "--esp-color-text",
88
+ ]),
89
+ );
90
+
91
+ export const FOOTER_COLOR_TOKENS = Object.freeze({
92
+ backgroundColor: new Set([
93
+ "--esp-color-background",
94
+ "--esp-color-layer-1",
95
+ "--esp-color-layer-2",
96
+ "--esp-color-layer-3",
97
+ "--esp-color-layer-4",
98
+ ]),
99
+ textColor: new Set(["--esp-color-text", "--esp-color-headings"]),
100
+ headingColor: new Set(["--esp-color-headings", "--esp-color-text"]),
101
+ linkColor: new Set(["--esp-color-link", "--esp-color-headings"]),
102
+ linkHoverColor: new Set(["--esp-color-link-hover", "--esp-color-link"]),
103
+ });
104
+
105
+ export const APPEARANCE_COLOR_CONSTRAINT = Object.freeze({
106
+ maximumLength: 128,
107
+ customForms: Object.freeze([
108
+ Object.freeze({ syntax: "#rgb | #rgba | #rrggbb | #rrggbbaa" }),
109
+ Object.freeze({
110
+ syntax: "oklch(lightness chroma hue)",
111
+ lightness: Object.freeze({ minimum: 0, maximum: 1 }),
112
+ chroma: Object.freeze({ minimum: 0, maximum: 0.25 }),
113
+ hue: Object.freeze({ minimum: 0, maximum: 360 }),
114
+ }),
115
+ ]),
116
+ });
117
+
118
+ const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
119
+ const OKLCH_COLOR =
120
+ /^oklch\( *(?<lightness>(?:0|[1-9]\d*)(?:\.\d+)?) +(?<chroma>(?:0|[1-9]\d*)(?:\.\d+)?) +(?<hue>(?:0|[1-9]\d*)(?:\.\d+)?) *\)$/iu;
121
+
122
+ function isPlainObject(value) {
123
+ return value !== null && typeof value === "object" && !Array.isArray(value);
124
+ }
125
+
126
+ function fail(code, message, field) {
127
+ throw new SiteAuthoringError(code, message, { field });
128
+ }
129
+
130
+ function hasThemePath(theme, path) {
131
+ let current = theme;
132
+ for (const segment of path.split(".")) {
133
+ if (!isPlainObject(current) || !Object.hasOwn(current, segment)) return false;
134
+ current = current[segment];
135
+ }
136
+ return true;
137
+ }
138
+
139
+ function requireBoundedOpenMaps(theme, scheme) {
140
+ for (const property of ["anchors", "contexts", "dataRamps"]) {
141
+ const collection = theme[property];
142
+ if (!isPlainObject(collection)) continue;
143
+ if (Object.keys(collection).length > MAXIMUM_THEME_OPEN_MAP_ENTRIES) {
144
+ fail(
145
+ "theme.collection_too_large",
146
+ `${scheme}Theme.${property} may contain at most ${MAXIMUM_THEME_OPEN_MAP_ENTRIES} entries.`,
147
+ `${scheme}Theme.${property}`,
148
+ );
149
+ }
150
+ if (
151
+ property === "anchors"
152
+ && (
153
+ Object.keys(collection).some((key) => [...key].length > MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS)
154
+ || Object.values(collection).some((anchor) =>
155
+ isPlainObject(anchor)
156
+ && Object.keys(anchor).some((key) =>
157
+ key !== "color" && [...key].length > MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS
158
+ )
159
+ )
160
+ )
161
+ ) {
162
+ fail(
163
+ "theme.collection_key_too_long",
164
+ `${scheme}Theme.anchors names and slot names may contain at most ${MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS} characters.`,
165
+ `${scheme}Theme.anchors`,
166
+ );
167
+ }
168
+ if (
169
+ property === "anchors"
170
+ && Object.values(collection).some((anchor) =>
171
+ isPlainObject(anchor)
172
+ && Object.keys(anchor).filter((key) => key !== "color").length
173
+ > MAXIMUM_THEME_OPEN_MAP_ENTRIES
174
+ )
175
+ ) {
176
+ fail(
177
+ "theme.collection_too_large",
178
+ `${scheme}Theme.anchors entries may define at most ${MAXIMUM_THEME_OPEN_MAP_ENTRIES} slots besides color.`,
179
+ `${scheme}Theme.anchors`,
180
+ );
181
+ }
182
+ }
183
+ }
184
+
185
+ function requireBoundedToneMap(tones, field) {
186
+ if (!isPlainObject(tones)) {
187
+ fail("theme.document_invalid", `${field} must be a JSON object.`, field);
188
+ }
189
+ if (Object.keys(tones).length > MAXIMUM_THEME_OPEN_MAP_ENTRIES) {
190
+ fail(
191
+ "theme.collection_too_large",
192
+ `${field} may contain at most ${MAXIMUM_THEME_OPEN_MAP_ENTRIES} entries.`,
193
+ field,
194
+ );
195
+ }
196
+ if (Object.keys(tones).some((key) => [...key].length > MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS)) {
197
+ fail(
198
+ "theme.collection_key_too_long",
199
+ `${field} names may contain at most ${MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS} characters.`,
200
+ field,
201
+ );
202
+ }
203
+ }
204
+
205
+ function requireBoundedToneMaps(theme, scheme) {
206
+ requireBoundedToneMap(theme.tones, `${scheme}Theme.tones`);
207
+ if (!isPlainObject(theme.contexts)) return;
208
+ for (const [contextName, context] of Object.entries(theme.contexts)) {
209
+ if (isPlainObject(context) && Object.hasOwn(context, "tones")) {
210
+ requireBoundedToneMap(
211
+ context.tones,
212
+ `${scheme}Theme.contexts.${contextName}.tones`,
213
+ );
214
+ }
215
+ }
216
+ }
217
+
218
+ function requireCompleteTheme(theme, scheme) {
219
+ if (!isPlainObject(theme)) {
220
+ fail("theme.document_invalid", `${scheme}Theme must be a JSON object.`, `${scheme}Theme`);
221
+ }
222
+ for (const property of REQUIRED_THEME_PROPERTIES) {
223
+ if (!Object.hasOwn(theme, property)) {
224
+ fail(
225
+ "theme.incomplete",
226
+ `${scheme}Theme is missing '${property}'. Start from the complete theme written by 'taproot-site pull'.`,
227
+ `${scheme}Theme.${property}`,
228
+ );
229
+ }
230
+ }
231
+ for (const property of ["semanticMappings", "variantChroma", "tones"]) {
232
+ if (!isPlainObject(theme[property])) {
233
+ fail(
234
+ "theme.document_invalid",
235
+ `${scheme}Theme.${property} must be a JSON object.`,
236
+ `${scheme}Theme.${property}`,
237
+ );
238
+ }
239
+ }
240
+ requireBoundedOpenMaps(theme, scheme);
241
+ requireBoundedToneMaps(theme, scheme);
242
+ for (const path of REQUIRED_THEME_PATHS) {
243
+ if (!hasThemePath(theme, path)) {
244
+ fail(
245
+ "theme.incomplete",
246
+ `${scheme}Theme is missing '${path}'. Start from the complete theme written by 'taproot-site pull'.`,
247
+ `${scheme}Theme.${path}`,
248
+ );
249
+ }
250
+ }
251
+ }
252
+
253
+ function refuseAgentStylesheets(theme, scheme) {
254
+ // Espalier keeps this array in its complete theme document, but an external
255
+ // stylesheet is mutable code outside the frozen authoring-preview inventory.
256
+ // Agent themes use roles, contexts, anchors, and the font-family fields; they
257
+ // must not add a second styling channel that the preview CSP cannot reproduce.
258
+ if (
259
+ Array.isArray(theme.stylesheets)
260
+ && theme.stylesheets.length > 0
261
+ ) {
262
+ fail(
263
+ "theme.stylesheets_unsupported",
264
+ `${scheme}Theme.stylesheets must remain empty. Set it to [] in settings/taproot-styles.json; this will remove it from the published site when the theme is deployed. Use Espalier roles, contexts, anchors, and font fields instead of external CSS.`,
265
+ `${scheme}Theme.stylesheets`,
266
+ );
267
+ }
268
+ }
269
+
270
+ export function validateAndEncodeThemePair(lightTheme, darkTheme) {
271
+ requireCompleteTheme(lightTheme, "light");
272
+ requireCompleteTheme(darkTheme, "dark");
273
+
274
+ let light;
275
+ let dark;
276
+ try {
277
+ light = encodeTheme(lightTheme);
278
+ dark = encodeTheme(darkTheme);
279
+ } catch (error) {
280
+ throw new SiteAuthoringError(
281
+ "theme.encoding_failed",
282
+ "The theme contains text Espalier cannot encode. Use the same theme vocabulary emitted by Taproot pull.",
283
+ { cause: error },
284
+ );
285
+ }
286
+
287
+ const result = validateThemePair(light, dark);
288
+ if (!result.valid) {
289
+ const first = result.errors[0] ?? "The theme pair is invalid.";
290
+ const field = /^\s*(?:light|dark):\s*Every entry in ([A-Za-z0-9_.-]+)/u.exec(first)?.[1]
291
+ ?? /^\s*(?:light|dark):\s*([A-Za-z0-9_.-]+)/u.exec(first)?.[1]
292
+ ?? (/^\s*contexts\b/u.test(first) ? "contexts" : undefined);
293
+ throw new SiteAuthoringError(
294
+ "theme.validation_failed",
295
+ `Espalier rejected the theme: ${first}`,
296
+ { field },
297
+ );
298
+ }
299
+ refuseAgentStylesheets(lightTheme, "light");
300
+ refuseAgentStylesheets(darkTheme, "dark");
301
+ const warnings = result.warnings
302
+ .slice(0, MAXIMUM_THEME_WARNINGS)
303
+ .map((warning) => [...sanitizeDiagnostic(warning, "Theme validation warning.")]
304
+ .slice(0, MAXIMUM_THEME_WARNING_SCALARS)
305
+ .join(""));
306
+ return {
307
+ light,
308
+ dark,
309
+ warnings,
310
+ warningCount: result.warnings.length,
311
+ warningsTruncated: result.warnings.length > warnings.length,
312
+ };
313
+ }
314
+
315
+ function supportedNumber(value, minimum, maximum) {
316
+ const parsed = Number(value);
317
+ return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum;
318
+ }
319
+
320
+ export function isSupportedAppearanceColor(value, allowedTokens) {
321
+ if (
322
+ typeof value !== "string"
323
+ || value.length > APPEARANCE_COLOR_CONSTRAINT.maximumLength
324
+ || hasControlCharacter(value)
325
+ ) return false;
326
+ if (value === "" || allowedTokens.has(value) || HEX_COLOR.test(value)) return true;
327
+ const match = OKLCH_COLOR.exec(value);
328
+ const oklch = APPEARANCE_COLOR_CONSTRAINT.customForms[1];
329
+ return match !== null
330
+ && supportedNumber(match.groups?.lightness, oklch.lightness.minimum, oklch.lightness.maximum)
331
+ && supportedNumber(match.groups?.chroma, oklch.chroma.minimum, oklch.chroma.maximum)
332
+ && supportedNumber(match.groups?.hue, oklch.hue.minimum, oklch.hue.maximum);
333
+ }
334
+
335
+ export function requireAppearanceColor(value, allowedTokens, field) {
336
+ if (!isSupportedAppearanceColor(value, allowedTokens)) {
337
+ fail(
338
+ "theme.color_invalid",
339
+ `${field} must be empty, an allowed Espalier semantic token, a hex color, or a bounded oklch color.`,
340
+ field,
341
+ );
342
+ }
343
+ return value;
344
+ }