@taprootio/site-authoring 0.19.0 → 0.21.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 (43) hide show
  1. package/README.md +132 -28
  2. package/examples/riverbend-wellness/nav.json +3 -6
  3. package/examples/riverbend-wellness/pages/visit.md +1 -1
  4. package/package.json +2 -2
  5. package/src/api.js +118 -31
  6. package/src/changelog.js +48 -0
  7. package/src/changelog.json +71 -0
  8. package/src/cli-release.js +9 -1
  9. package/src/cli.js +128 -15
  10. package/src/constants.js +19 -0
  11. package/src/content/components.js +8 -5
  12. package/src/errors.js +3 -0
  13. package/src/footer-contract.js +5 -3
  14. package/src/form-references.js +103 -0
  15. package/src/forms-workspace.js +24 -0
  16. package/src/index.js +1 -0
  17. package/src/output.js +9 -0
  18. package/src/presentation-reference.js +41 -6
  19. package/src/problems.js +6 -1
  20. package/src/redirects-contract.js +50 -140
  21. package/src/reference-help.js +83 -36
  22. package/src/reserved-paths.js +4 -10
  23. package/src/setting-reference.js +438 -0
  24. package/src/site-check-findings.js +99 -0
  25. package/src/site-check-rules.json +22 -0
  26. package/src/site-checks.js +337 -0
  27. package/src/staging-check.js +48 -9
  28. package/src/staging-served.js +320 -0
  29. package/src/transport.js +13 -0
  30. package/src/typed-page-reference.js +128 -4
  31. package/src/typed-pages.js +1 -1
  32. package/src/verbs/changelog.js +62 -0
  33. package/src/verbs/deploy.js +38 -10
  34. package/src/verbs/forms-push.js +22 -1
  35. package/src/verbs/index.js +3 -0
  36. package/src/verbs/nav-push.js +49 -1
  37. package/src/verbs/pages-push.js +45 -1
  38. package/src/verbs/plan.js +4 -2
  39. package/src/verbs/pull.js +17 -3
  40. package/src/verbs/redirects-check.js +4 -1
  41. package/src/verbs/staging-review.js +1 -1
  42. package/src/verbs/status.js +35 -28
  43. package/src/verbs/validate.js +21 -0
@@ -44,6 +44,12 @@ import {
44
44
  REQUIRED_THEME_PROPERTIES,
45
45
  validateAndLintThemePair,
46
46
  } from "./theme-validation.js";
47
+ import {
48
+ describeSettingField,
49
+ formatSettingsReference,
50
+ PAGE_BACKGROUND_IMAGE_RENDERING,
51
+ PAGE_BACKGROUND_VERSUS_CANVAS,
52
+ } from "./setting-reference.js";
47
53
  import {
48
54
  SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
49
55
  SETTINGS_TYPE_TAPROOT_STYLES,
@@ -273,14 +279,23 @@ const THEME_GROUPS = Object.freeze([
273
279
  }),
274
280
  Object.freeze({
275
281
  name: "surface-assets",
276
- summary: "Preserve pulled asset fields; use appearance image ids for site-owned canvas art.",
282
+ summary:
283
+ "Preserve pulled asset fields; use appearance image ids for the logos, favicon and canvas image. The page "
284
+ + "background image is a theme field behind the page content, unlike the canvas image, which paints only the "
285
+ + "side margins (help settings has both).",
277
286
  fields: Object.freeze([
278
287
  themeField("stylesheets", "Agent-authored site themes must keep this exact array empty.", { exact: [] }),
279
288
  themeField(
280
289
  "pageBackgroundImage",
281
- "CSS background-image; the complete Taproot workspace includes an empty value.",
290
+ "CSS background-image (the app's Page style > Background image). " + PAGE_BACKGROUND_IMAGE_RENDERING
291
+ + " Today it is a CSS url(...) of a delivered image, the src that .taproot-site-media.json records; it tiles "
292
+ + "at the image's natural pixel size from the top-left. The complete Taproot workspace includes an empty value.",
293
+ ),
294
+ themeField(
295
+ "pageBackgroundImageOpacity",
296
+ "How strongly the page background image shows, from 0 (invisible) to 1 (fully opaque).",
297
+ { minimum: 0, maximum: 1 },
282
298
  ),
283
- themeField("pageBackgroundImageOpacity", "Page background opacity.", { minimum: 0, maximum: 1 }),
284
299
  themeField("boxBackgroundImage", "Optional inherited box-surface background image."),
285
300
  themeField("boxBackgroundImageOpacity", "Optional box background opacity.", { minimum: 0, maximum: 1 }),
286
301
  themeField("vellumOpacity", "Optional modal-vellum opacity.", { minimum: 0, maximum: 1 }),
@@ -418,6 +433,9 @@ export function getThemeReference() {
418
433
  };
419
434
  }
420
435
 
436
+ // "linkHoverColor" reads "link hover color", so a new footer color token needs no second table.
437
+ const footerColorLabel = (name) => name.replace(/([A-Z])/gu, " $1").toLowerCase();
438
+
421
439
  function appearanceColorReference(definition) {
422
440
  return definition.type === "appearance-color"
423
441
  ? {
@@ -432,6 +450,7 @@ export function getAppearanceReference() {
432
450
  const scalarFields = APPEARANCE_SCALAR_FIELDS.map((field) => ({
433
451
  file: APPEARANCE_FILES[field.settingsType],
434
452
  path: "settings." + field.name,
453
+ ...describeSettingField(field.settingsType, field.name),
435
454
  type: field.type,
436
455
  ...(field.values ? { values: field.values } : {}),
437
456
  ...(field.minimum !== undefined ? { minimum: field.minimum, maximum: field.maximum } : {}),
@@ -447,27 +466,36 @@ export function getAppearanceReference() {
447
466
  {
448
467
  file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES],
449
468
  path: "settings.lightTheme",
469
+ ...describeSettingField(SETTINGS_TYPE_TAPROOT_STYLES, "lightTheme"),
450
470
  type: "complete-espalier-theme",
451
471
  },
452
472
  {
453
473
  file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES],
454
474
  path: "settings.darkTheme",
475
+ ...describeSettingField(SETTINGS_TYPE_TAPROOT_STYLES, "darkTheme"),
455
476
  type: "complete-espalier-theme",
456
477
  },
457
478
  ...scalarFields,
458
479
  ...APPEARANCE_FOOTER_COLOR_FIELDS.map((field) => ({
459
480
  file: field.file,
460
481
  path: field.path,
482
+ appLabel: "Footer",
483
+ description: `The footer's ${footerColorLabel(field.name)} in the ${field.scheme} scheme. footer push owns the rest of the footer.`,
484
+ renders: "The footer, once footerSettings.enabled is true (default false).",
461
485
  type: field.type,
462
486
  ...appearanceColorReference(field),
463
487
  default: field.default,
464
488
  })),
465
489
  ],
466
490
  readOnlyProjections: APPEARANCE_READ_ONLY_FIELDS,
491
+ pageBackgroundVersusCanvas: PAGE_BACKGROUND_VERSUS_CANVAS,
492
+ fieldMap: CLI_BINARY_NAME + " help settings maps every pulled field, including the read-only ones, to its app control.",
467
493
  imageReferences:
468
494
  "Use site-owned image ids retained from pull or returned by media upload. URLs are server projections, never authored inputs.",
469
495
  logoContract:
470
496
  "lightLogoId and darkLogoId are the current scheme-specific logo fields; there is no separate compact-logo setting. "
497
+ + "Each takes one image id: upload the highest-resolution file you have and assign it. An @2x or @3x suffix is only "
498
+ + "part of a filename, nothing pairs it with another file, and the platform generates the sizes. "
471
499
  + "Prefer transparent PNG or WebP artwork with genuine alpha so the header background shows through, including "
472
500
  + "inside letter counters. Avoid baked-in background rectangles unless the brand intentionally uses a badge. "
473
501
  + "Keep padding tight and verify the delivered image preserves transparency. Logo style and coloration are "
@@ -776,11 +804,17 @@ export function formatPresentationReference(reference) {
776
804
  }
777
805
  if (reference.referenceKind === "appearance") {
778
806
  return reference.title + "\nUsage: " + reference.usage + "\n\nWritable fields:\n"
779
- + reference.fields.map((field) => " " + field.file + " :: " + field.path + " — " + fieldConstraint(field)).join(
780
- "\n",
781
- )
807
+ + reference.fields.map((field) =>
808
+ " " + field.file + " :: " + field.path + " — " + fieldConstraint(field)
809
+ + "\n app: " + field.appLabel + ". " + field.description
810
+ + (field.renders === undefined ? "" : "\n renders: " + field.renders)
811
+ ).join("\n")
782
812
  + "\n\nRead-only server projections:\n"
783
813
  + reference.readOnlyProjections.map((field) => " " + field.file + " :: " + field.path).join("\n")
814
+ + "\n\nPage background image versus canvas image:\n - " + reference.pageBackgroundVersusCanvas.pageBackground
815
+ + "\n - " + reference.pageBackgroundVersusCanvas.canvas
816
+ + "\n - " + reference.pageBackgroundVersusCanvas.shared
817
+ + "\n\nField map: " + reference.fieldMap
784
818
  + "\n\nImages: " + reference.imageReferences + "\nLogos: " + reference.logoContract
785
819
  + "\nLogo contrast: " + reference.logoContrastContract
786
820
  + "\nFavicon: " + reference.faviconContract
@@ -794,6 +828,7 @@ export function formatPresentationReference(reference) {
794
828
  + "\nRetry: " + reference.retry
795
829
  + "\nRecovery: " + reference.recovery + "\n";
796
830
  }
831
+ if (reference.referenceKind === "settings") return formatSettingsReference(reference);
797
832
  if (reference.referenceKind === "footer") {
798
833
  const messages = reference.messages.map((message) =>
799
834
  message.name + " (unlisted fields rejected)\n"
package/src/problems.js CHANGED
@@ -39,7 +39,12 @@ function withContext(problem, context, error) {
39
39
  export function problemsOf(error, context = {}) {
40
40
  const own = Array.isArray(error.problems) && error.problems.length > 0
41
41
  ? error.problems
42
- : [{ code: error.code, ...(error.field === undefined ? {} : { field: error.field }), message: error.message }];
42
+ : [{
43
+ code: error.code,
44
+ ...(error.field === undefined ? {} : { field: error.field }),
45
+ ...(error.rule === undefined ? {} : { rule: error.rule }),
46
+ message: error.message,
47
+ }];
43
48
  return own.map((problem) => withContext(problem, context, error));
44
49
  }
45
50
 
@@ -1,5 +1,20 @@
1
1
  import { SiteAuthoringError } from "./errors.js";
2
- import { RUNTIME_MIRROR_PATH_REASON, isRuntimeMirrorPath } from "./reserved-paths.js";
2
+ import { RUNTIME_MIRROR_PATH_REASON } from "./reserved-paths.js";
3
+ import {
4
+ isRequestSpelling,
5
+ normalizeRedirectPath,
6
+ redirectSourceRule,
7
+ resolvesAway,
8
+ RULE_REDIRECT_SOURCE_GENERATED_FILE,
9
+ RULE_REDIRECT_SOURCE_INDEX_HTML,
10
+ RULE_REDIRECT_SOURCE_INTEGRATION_DATA,
11
+ RULE_REDIRECT_SOURCE_INVALID,
12
+ RULE_REDIRECT_SOURCE_NOT_STORABLE,
13
+ RULE_REDIRECT_SOURCE_RUNTIME_MIRROR,
14
+ RULE_REDIRECT_SOURCE_SHARE_LINK,
15
+ } from "./site-checks.js";
16
+
17
+ export { normalizeRedirectPath };
3
18
 
4
19
  /**
5
20
  * The redirect map's workspace contract (TR00702).
@@ -64,64 +79,14 @@ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/u;
64
79
  const ENTRY_KEYS = new Set(["path", "kind", "target", "status", "origin"]);
65
80
  const SHA256 = /^[0-9a-f]{64}$/u;
66
81
 
67
- // An empty interior segment, or a dot or slash hidden behind percent-encoding.
68
- // '//' is also where a protocol-relative URL hides, which is why the leading
69
- // case is covered by this rule rather than by a separate prefix check.
70
- const RESOLVABLE_SPELLING = /\/\/|%2e|%2f/iu;
71
-
72
- // A spelling no browser would send as a pathname: anything outside printable
73
- // ASCII, one of the characters a URL parser escapes on sight, or a '%' that is
74
- // not an escape.
75
- // No `i` flag: under Unicode case folding it would let U+212A (KELVIN SIGN)
76
- // and U+017F (LONG S) through `[^!-~]`, which the site refuses.
77
- const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
78
-
79
- /**
80
- * Whether a path is already written the way a browser sends a pathname.
81
- *
82
- * The published-site edge keys on `url.pathname`, which the URL parser has
83
- * already percent-encoded, and its `normalizePathname` decodes nothing — while
84
- * every rule here compares the string as written. So '/old%20page -> /old page'
85
- * is two different strings to loop detection and one path to the edge, which
86
- * matches '/old%20page', resolves the target with `new URL` back to
87
- * '/old%20page', and serves a permanent self-loop. A source written with a
88
- * literal space or a non-ASCII character is the same mismatch the other way,
89
- * stored under a key no request pathname ever matches.
90
- *
91
- * Refused rather than encoded here. URL escaping is not reproducibly canonical
92
- * between the API's .NET and the edge's WHATWG parser, so a spelling this pass
93
- * encoded would still not be the one the Worker computes and the layers would
94
- * go on comparing different strings. Write what a browser sends and every layer
95
- * compares it unchanged — and the byte bounds then measure that same spelling.
96
- */
97
- function isRequestSpelling(value) {
98
- return !UNSENDABLE_SPELLING.test(value);
99
- }
100
-
101
- /**
102
- * Whether a path names one thing to this pass and another wherever it is
103
- * resolved.
104
- *
105
- * Every rule here compares the string as written, and nothing downstream does:
106
- * the generator resolves a source with path.resolve, so '/x/../visit' writes
107
- * over the page rendered at '/visit', and the published-site edge resolves a
108
- * target with `new URL`, so '/a -> /x/../a' is the self-loop that loop
109
- * detection just passed. Percent-encoded dots and slashes are the same trick
110
- * spelled so a segment scan cannot see it, so they are refused wherever they
111
- * appear rather than decoded and re-examined.
112
- */
113
- function resolvesAway(value) {
114
- if (RESOLVABLE_SPELLING.test(value)) return true;
115
- return value.split("/").some((segment) => segment === "." || segment === "..");
116
- }
117
82
 
118
83
  /** Whether the value is a redirect-map revision as the server mints one. */
119
84
  export function isRedirectMapRevision(value) {
120
85
  return typeof value === "string" && SHA256.test(value);
121
86
  }
122
87
 
123
- function entryError(code, message, field) {
124
- return new SiteAuthoringError(code, `${field}: ${message}`, { field });
88
+ function entryError(code, message, field, rule) {
89
+ return new SiteAuthoringError(code, `${field}: ${message}`, { field, rule });
125
90
  }
126
91
 
127
92
  function isPlainObject(value) {
@@ -133,93 +98,32 @@ function utf8Length(value) {
133
98
  }
134
99
 
135
100
  /**
136
- * Normalizes a source path exactly as the published-site Worker's
137
- * `normalizePathname` does: require the leading slash, strip trailing slashes,
138
- * change nothing else. A `.html` suffix survives, which is the whole point —
139
- * `/faqs.html` is a real legacy URL and has to be representable. A spelling
140
- * some other layer would resolve away is refused rather than repaired (see
141
- * `resolvesAway`), and so is one no browser would send as a pathname (see
142
- * `isRequestSpelling`, which subsumes the control-character refusal).
101
+ * Why a source rule refuses a path, in the words the CLI shows. The rule ids are
102
+ * the site checks' (shared/site-check-rules.json); the decisions themselves are
103
+ * made by `redirectSourceRule` in the shared source, which the API's C#
104
+ * implementation also passes the same corpus for.
143
105
  */
144
- export function normalizeRedirectPath(value) {
145
- if (typeof value !== "string") return undefined;
146
- const candidate = value.trim();
147
- if (
148
- candidate === ""
149
- || /[?#\\]/u.test(candidate)
150
- || !isRequestSpelling(candidate)
151
- || resolvesAway(candidate)
152
- ) {
153
- return undefined;
154
- }
155
- const withSlash = candidate.startsWith("/") ? candidate : `/${candidate}`;
156
- const trimmed = withSlash.replace(/\/+$/u, "");
157
- return trimmed === "" ? "/" : trimmed;
158
- }
159
-
160
- // The root segment the published output reserves for integration data; the release
161
- // inventory leaves everything under it out.
162
- const RESERVED_INTEGRATION_DATA_SEGMENT = "integrations";
163
-
164
- // Root-level files the site generates. A redirect is a marker file inside the
165
- // published site (TR00968), so a source that names one has nowhere to live.
166
- const GENERATED_ROOT_FILES = Object.freeze(["404.html", "sitemap.xml", "robots.txt", "favicon.ico"]);
167
- // The longest decoded path segment a marker file name may have; the API rule and the
168
- // generator use the same 200 bytes.
169
- const MAX_MARKER_SEGMENT_BYTES = 200;
170
- const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
171
-
172
- function isWindowsDeviceSegment(segment) {
173
- const stem = segment.split(".", 1)[0].toLowerCase();
174
- return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
175
- }
106
+ const SOURCE_RULE_REASONS = Object.freeze({
107
+ [RULE_REDIRECT_SOURCE_INDEX_HTML]: () =>
108
+ "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
109
+ + "a redirect source, and the page or home file there is generated.",
110
+ [RULE_REDIRECT_SOURCE_INTEGRATION_DATA]: (first) =>
111
+ `'/${first}' is reserved for integration data, so nothing under it can be a redirect source.`,
112
+ [RULE_REDIRECT_SOURCE_SHARE_LINK]: () =>
113
+ "'/s/' is reserved for share links, so nothing under it can be a redirect source.",
114
+ [RULE_REDIRECT_SOURCE_RUNTIME_MIRROR]: () => RUNTIME_MIRROR_PATH_REASON,
115
+ [RULE_REDIRECT_SOURCE_GENERATED_FILE]: (first) =>
116
+ `'/${first}' is a file the site generates, so neither it nor anything under it can be a redirect source.`,
117
+ [RULE_REDIRECT_SOURCE_NOT_STORABLE]: () =>
118
+ "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
119
+ + "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).",
120
+ });
176
121
 
177
- /**
178
- * Why a normalized source path cannot be a redirect, or undefined when it can.
179
- * A mirror of `SiteRedirectMapContract.SourceRefusalReason` on the server (the
180
- * API is the authority; both run the same test vectors): `…/index.html` is
181
- * answered by the platform for every page, the generator writes the files
182
- * named above, and the marker is stored under the decoded spelling, so a path
183
- * that cannot be stored once decoded (an escaped percent sign, a dot-prefixed
184
- * segment, a reserved device name) is refused. The site root is left to the
185
- * occupancy rule.
186
- */
187
- export function redirectSourceRefusalReason(path) {
188
- if (path === "/") return undefined;
189
- // Every rule reads the decoded spelling: it is the name the marker is stored
190
- // under, and the name the generator's own files use (`/%69ndex.html` is
191
- // `/index.html`).
192
- let decoded;
193
- try {
194
- decoded = path.replace(/^\/+|\/+$/gu, "").split("/").map(decodeURIComponent).join("/");
195
- } catch {
196
- return "it holds a percent escape that does not decode.";
197
- }
198
- const segments = decoded.split("/");
199
- const first = segments[0].toLowerCase();
200
- if (segments.some((segment) => segment.toLowerCase() === "index.html")) {
201
- return "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
202
- + "a redirect source, and the page or home file there is generated.";
203
- }
204
- if (first === RESERVED_INTEGRATION_DATA_SEGMENT) {
205
- return `'/${segments[0]}' is reserved for integration data, so nothing under it can be a redirect source.`;
206
- }
207
- if (isRuntimeMirrorPath(decoded)) return RUNTIME_MIRROR_PATH_REASON;
208
- if (GENERATED_ROOT_FILES.includes(first)) {
209
- return `'/${segments[0]}' is a file the site generates, so neither it nor anything under it can be a redirect source.`;
210
- }
211
- if (
212
- decoded.includes("..")
213
- || decoded !== decoded.normalize("NFC")
214
- || segments.some((segment) =>
215
- segment === "" || segment.startsWith(".") || /[%\\?#\u0000-\u001F\u007F-\u009F]/u.test(segment)
216
- || isWindowsDeviceSegment(segment) || new TextEncoder().encode(segment).length > MAX_MARKER_SEGMENT_BYTES
217
- )
218
- ) {
219
- return "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
220
- + "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).";
221
- }
222
- return undefined;
122
+ /** The refusal for a normalized source: its site-check rule id and the reason, or undefined when it can be a redirect. */
123
+ export function redirectSourceRefusal(path) {
124
+ const refusal = redirectSourceRule(path);
125
+ if (refusal === undefined) return undefined;
126
+ return { ruleId: refusal.ruleId, reason: SOURCE_RULE_REASONS[refusal.ruleId](refusal.detail) };
223
127
  }
224
128
 
225
129
  function looksAbsolute(value) {
@@ -281,6 +185,7 @@ function validateEntry(value, index, seen) {
281
185
  + "or '..' segment, no empty segment, and no percent-encoded dot or slash. Write the percent-encoded "
282
186
  + "spelling a browser sends (/caf%C3%A9, /old%20page), never the raw one.",
283
187
  `${field}.path`,
188
+ RULE_REDIRECT_SOURCE_INVALID,
284
189
  );
285
190
  }
286
191
  // The site root is not refused here. A home page rename records an entry at
@@ -303,9 +208,14 @@ function validateEntry(value, index, seen) {
303
208
  `${field}.path`,
304
209
  );
305
210
  }
306
- const sourceRefusal = redirectSourceRefusalReason(path);
211
+ const sourceRefusal = redirectSourceRefusal(path);
307
212
  if (sourceRefusal !== undefined) {
308
- throw entryError("redirects.path_unpublishable", `'${path}': ${sourceRefusal}`, `${field}.path`);
213
+ throw entryError(
214
+ "redirects.path_unpublishable",
215
+ `'${path}': ${sourceRefusal.reason}`,
216
+ `${field}.path`,
217
+ sourceRefusal.ruleId,
218
+ );
309
219
  }
310
220
  // The marker is stored under the decoded spelling, so `/a%41` and `/aA` are one
311
221
  // file, and the second would silently replace the first.