@taprootio/site-authoring 0.14.1 → 0.16.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 (49) hide show
  1. package/README.md +181 -16
  2. package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
  3. package/examples/riverbend-wellness/pages/index.md +1 -17
  4. package/package.json +2 -2
  5. package/src/api.js +123 -1
  6. package/src/authors-contract.js +170 -0
  7. package/src/cli.js +355 -30
  8. package/src/constants.js +9 -0
  9. package/src/content/components.js +64 -6
  10. package/src/content/free-form-sections.js +21 -4
  11. package/src/content/link-warnings.js +109 -0
  12. package/src/content/markdown.js +161 -26
  13. package/src/content/tiptap-prosemirror.ts +66 -8
  14. package/src/content/validate-document.js +30 -6
  15. package/src/content/vocabulary.js +26 -0
  16. package/src/errors.js +22 -0
  17. package/src/espalier-shared.js +11 -0
  18. package/src/fixture-init.js +55 -3
  19. package/src/font-catalog.js +87 -0
  20. package/src/footer-contract.js +1 -1
  21. package/src/output.js +19 -1
  22. package/src/page-metadata.js +92 -0
  23. package/src/presentation-reference.js +21 -7
  24. package/src/problems.js +116 -0
  25. package/src/reference-help.js +157 -41
  26. package/src/session.js +13 -0
  27. package/src/staging-check.js +62 -0
  28. package/src/theme-proposals.js +393 -0
  29. package/src/theme-validation.js +31 -10
  30. package/src/typed-page-reference.js +97 -21
  31. package/src/typed-pages.js +196 -13
  32. package/src/verbs/approve.js +63 -14
  33. package/src/verbs/authors.js +167 -0
  34. package/src/verbs/deploy.js +5 -28
  35. package/src/verbs/index.js +22 -0
  36. package/src/verbs/media-upload.js +127 -30
  37. package/src/verbs/nav-push.js +7 -0
  38. package/src/verbs/pages-meta-set.js +303 -0
  39. package/src/verbs/pages-push.js +814 -224
  40. package/src/verbs/places.js +61 -0
  41. package/src/verbs/plan.js +595 -0
  42. package/src/verbs/pull.js +255 -20
  43. package/src/verbs/redirects-check.js +4 -10
  44. package/src/verbs/theme-push.js +68 -36
  45. package/src/verbs/validate.js +309 -217
  46. package/src/video-prepare.js +48 -12
  47. package/src/workspace-check.js +199 -0
  48. package/src/workspace-ledger.js +173 -0
  49. package/src/workspace.js +105 -10
@@ -0,0 +1,116 @@
1
+ import { SiteAuthoringError } from "./errors.js";
2
+
3
+ /**
4
+ * Collecting problems instead of stopping at the first (TR01002, TR00823).
5
+ *
6
+ * A check that runs over a whole workspace reports every refusal it finds, so
7
+ * an importer fixing hundreds of converted pages learns them all in one run.
8
+ * Only `SiteAuthoringError`s are collected: anything else is a defect, and
9
+ * hiding it inside a list would turn a crash into a refusal.
10
+ */
11
+
12
+ /** The parts of a workspace a check reports a problem against. */
13
+ export const CHECK_AREA = Object.freeze({
14
+ media: "media",
15
+ pages: "pages",
16
+ presentation: "presentation",
17
+ footer: "footer",
18
+ navigation: "navigation",
19
+ redirects: "redirects",
20
+ forms: "forms",
21
+ });
22
+
23
+ // Kept off the JSON so a problem serializes as plain data, but remembered so a
24
+ // single refusal can be rethrown exactly as it was raised.
25
+ const SOURCE = Symbol("source error");
26
+
27
+ function withContext(problem, context, error) {
28
+ const located = {
29
+ ...(context.area === undefined ? {} : { area: context.area }),
30
+ ...(context.file === undefined || problem.file !== undefined ? {} : { file: context.file }),
31
+ ...problem,
32
+ };
33
+ if (located.field === located.file) delete located.field;
34
+ Object.defineProperty(located, SOURCE, { value: error });
35
+ return located;
36
+ }
37
+
38
+ /** The problems one refusal stands for: its own list when it carries one, otherwise itself. */
39
+ export function problemsOf(error, context = {}) {
40
+ const own = Array.isArray(error.problems) && error.problems.length > 0
41
+ ? error.problems
42
+ : [{ code: error.code, ...(error.field === undefined ? {} : { field: error.field }), message: error.message }];
43
+ return own.map((problem) => withContext(problem, context, error));
44
+ }
45
+
46
+ /**
47
+ * Runs `action`, adding its refusal to `problems` instead of throwing. Returns
48
+ * the action's value, or undefined when it refused.
49
+ */
50
+ export async function collectProblems(problems, context, action) {
51
+ try {
52
+ return await action();
53
+ } catch (error) {
54
+ if (!(error instanceof SiteAuthoringError)) throw error;
55
+ problems.push(...problemsOf(error, context));
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * One refusal for a list of problems. When they all came from one refusal it
62
+ * is rethrown as raised, alternatives and differences included, so a check
63
+ * that finds one problem fails exactly as it did before problems were
64
+ * collected. Otherwise the code, message and field are the first refusal's and
65
+ * `error.problems` lists every problem.
66
+ */
67
+ export function problemsError(problems, summary) {
68
+ const [first] = problems;
69
+ if (problems.every((problem) => problem[SOURCE] !== undefined && problem[SOURCE] === first[SOURCE])) {
70
+ return first[SOURCE];
71
+ }
72
+ return new SiteAuthoringError(
73
+ first[SOURCE]?.code ?? first.code,
74
+ `${summary}: ${problems.length} problems. First: ${first[SOURCE]?.message ?? first.message}`,
75
+ { field: first[SOURCE]?.field ?? first.field ?? first.file },
76
+ ).withProblems(problems);
77
+ }
78
+
79
+ // A whole-workspace check can find thousands of problems. A JSON result keeps
80
+ // as many as fit well inside its 64 KiB bound; stderr lists them all.
81
+ const PROBLEM_BUDGET_BYTES = 40 * 1024;
82
+
83
+ /** The leading items whose JSON fits `budget` bytes. */
84
+ export function withinByteBudget(items, budget) {
85
+ const kept = [];
86
+ let bytes = 0;
87
+ for (const item of items) {
88
+ bytes += Buffer.byteLength(JSON.stringify(item), "utf8") + 1;
89
+ if (bytes > budget) break;
90
+ kept.push(item);
91
+ }
92
+ return kept;
93
+ }
94
+
95
+ /** The leading problems that fit the result's byte budget, or a smaller one. */
96
+ export function problemsWithinBudget(problems, budget = PROBLEM_BUDGET_BYTES) {
97
+ return withinByteBudget(problems, Math.min(budget, PROBLEM_BUDGET_BYTES));
98
+ }
99
+
100
+ /**
101
+ * Under `apply`, a step refuses to send what its plan did not list: an input
102
+ * edited, or a site state changed, while an earlier step ran was never reviewed.
103
+ */
104
+ export function planStale(subject, field) {
105
+ return new SiteAuthoringError(
106
+ "apply.plan_stale",
107
+ `${subject} changed after the plan was made, so this step stopped before sending it. Run 'taproot-site plan' again and `
108
+ + "apply the new planHash.",
109
+ { field },
110
+ );
111
+ }
112
+
113
+ /** Throws when `problems` is not empty. */
114
+ export function refuseProblems(problems, summary) {
115
+ if (problems.length > 0) throw problemsError(problems, summary);
116
+ }
@@ -6,9 +6,18 @@ import {
6
6
  COMPONENT_TYPES,
7
7
  getComponentDefinition,
8
8
  getComponentPropertyReference,
9
+ mapComponentImages,
9
10
  } from "./content/components.js";
10
11
  import { FREE_FORM_SECTION_REGISTRY } from "./content/free-form-sections.js";
11
- import { CONTENT_ERROR_CODES, CONTENT_LIMITS, MARK_TYPES, NODE_TYPES } from "./content/vocabulary.js";
12
+ import { formatFontCatalog } from "./font-catalog.js";
13
+ import {
14
+ CONTENT_ERROR_CODES,
15
+ CONTENT_LIMITS,
16
+ LINK_REL_TOKENS,
17
+ LINK_TARGETS,
18
+ MARK_TYPES,
19
+ NODE_TYPES,
20
+ } from "./content/vocabulary.js";
12
21
  import {
13
22
  FIXTURE_CONTRACT_VERSION,
14
23
  FIXTURE_DELIVERY_ORIGIN_DOMAIN,
@@ -55,7 +64,12 @@ import {
55
64
  REDIRECTS_FILE_NAME,
56
65
  } from "./redirects-contract.js";
57
66
  import { SETTINGS_GROUPS } from "./settings-catalog.js";
58
- import { formatTypedPageReference, getTypedPageReference, TYPED_PAGE_TYPES } from "./typed-page-reference.js";
67
+ import {
68
+ formatTypedPageReference,
69
+ getTypedPageReference,
70
+ MARKDOWN_PAGE_TYPES,
71
+ TYPED_PAGE_TYPES,
72
+ } from "./typed-page-reference.js";
59
73
  import { PAGE_TEMPLATES } from "./typed-pages.js";
60
74
  import {
61
75
  INTERNAL_PAGE_BASELINE_DIRECTORY,
@@ -63,6 +77,7 @@ import {
63
77
  NAVIGATION_FILE_NAME,
64
78
  PAGE_SOURCE_EXTENSIONS,
65
79
  PAGE_WORKSPACE_MODE_EDITABLE,
80
+ PAGE_WORKSPACE_MODE_METADATA_ONLY,
66
81
  PAGES_DIRECTORY,
67
82
  pageSourceFormat,
68
83
  SETTINGS_DIRECTORY,
@@ -71,7 +86,7 @@ import {
71
86
 
72
87
  export { getAppearanceReference, getFooterReference, getThemeReference };
73
88
 
74
- export const REFERENCE_VERSION = 33;
89
+ export const REFERENCE_VERSION = 35;
75
90
 
76
91
  // The date-limit examples `help forms` prints; tests run them through the form validator.
77
92
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -408,7 +423,7 @@ export const REFERENCE_TOPICS = Object.freeze([
408
423
  Object.freeze({
409
424
  name: "media",
410
425
  usage: `${CLI_BINARY_NAME} help media`,
411
- summary: "Describe media selection, naming, and component-ready output.",
426
+ summary: "Describe media selection, naming, and where the uploaded media is recorded.",
412
427
  }),
413
428
  Object.freeze({
414
429
  name: "preview",
@@ -445,6 +460,11 @@ export const REFERENCE_TOPICS = Object.freeze([
445
460
  usage: `${CLI_BINARY_NAME} help import`,
446
461
  summary: "Import an existing WordPress site from its export: mapping rules, order of work, and what to report.",
447
462
  }),
463
+ Object.freeze({
464
+ name: "fonts",
465
+ usage: `${CLI_BINARY_NAME} help fonts [category]`,
466
+ summary: "List the font families a published site can load, for the theme's font fields.",
467
+ }),
448
468
  Object.freeze({
449
469
  name: "fixture",
450
470
  usage: `${CLI_BINARY_NAME} help fixture`,
@@ -462,15 +482,20 @@ const WORKFLOW_REFERENCES = Object.freeze({
462
482
  + "has said you may; the export names every media URL but carries no image bytes, so downloading originals from "
463
483
  + "the old site also needs that permission. Work in a fresh workspace: 'use' the site, then 'pull'.",
464
484
  "Order of work: 1 parse the export; 2 classify every item; 3 stage media; 4 write page sources; 5 check every "
465
- + "source; 6 pages push; 7 nav.json and nav push; 8 redirects.json and redirects push; 9 approve, deploy "
485
+ + "source with validate and pages push --dry-run; 6 pages push; 7 nav.json and nav push; 8 redirects.json and redirects push; 9 approve, deploy "
466
486
  + "--staging, review it; 10 redirects check and delivery check --staging; 11 deploy --production; 12 delivery "
467
487
  + "check --production --url <the origin 'sites' reports as primaryDomain>. Do not promote until the account of unmapped content has been read by the owner.",
468
488
  "Classify. Published post: article (the default); recipe when it has an Ingredients list and a Steps list "
469
489
  + "(help page recipe); album when it is a gallery with little text (help page album). Published page: a free-form "
470
- + "page at its path, children under their parent's path. Never guess a place review: it needs a Taproot "
471
- + "placeId this CLI cannot look up, so import the post as an article and list it. Not imported, and always "
490
+ + "page at its path, children under their parent's path. Never guess a place review: see Place reviews "
491
+ + "below. Not imported, and always "
472
492
  + "listed: drafts, pending, private, scheduled and password-protected items; custom post types; comments; the WordPress front "
473
493
  + "page (Taproot seeds its own home page: ask the owner what belongs there).",
494
+ "Place reviews. A post with a rating and a place becomes a place review when the place can be found: run "
495
+ + "'places search <name and city>', and if one prediction is plainly the same place, 'places select "
496
+ + "<googlePlaceId> <sessionToken>' gives the placeId for its front matter (rating: will-return, might-return or "
497
+ + "wont-return, mapped from the post's own scale). When nothing matches, import it as an article and list it. A travel "
498
+ + "or food article, or an album, about one place can carry place: <placeId> in its front matter.",
474
499
  "Paths and dates. A post's path is '<first category slug, ignoring the default Uncategorized>/<post name>' ('journal/' with no category; a post or page whose slug has no ASCII letters gets a name like post-<id>, listed), a "
475
500
  + "page's is its post name under its parents. Keep the original publication date in displayDate (the date "
476
501
  + "part of wp:post_date). Categories beyond the first, and all tags, have no Taproot equivalent (tags are "
@@ -488,13 +513,14 @@ const WORKFLOW_REFERENCES = Object.freeze({
488
513
  + "album when they are the post), links to content you did not import (keep the absolute old link), and images "
489
514
  + "on other hosts. Every item goes in the account with its page and what happened to it.",
490
515
  "Media. Save each original in the workspace's media/ directory (PNG, JPEG, GIF or WebP up to 32 MiB each; list SVG, AVIF, HEIC and oversized files, which cannot be uploaded; use the original, not "
491
- + "the -300x200 thumbnail WordPress also stores), then upload in batches of at most 10 files (conservative: the limit depends on how long the delivery URLs are, and was measured only with short stand-in URLs): 'media upload' "
492
- + "prints every file's delivery URLs into a result capped at 64 KiB, and a longer batch uploads everything, "
493
- + "writes .taproot-site-media.json, then exits 1 with output.too_large. If that happens the files are already "
494
- + "uploaded; run the same paths again (they deduplicate) in smaller batches.",
495
- "Check before sending. 'pages push' validates every source before it sends anything but stops at the first "
496
- + "problem. On a large import, validate the sources yourself first so you see every problem at once: run the "
497
- + "package's validators over each file, or push one page at a time ('pages push <path>').",
516
+ + "the -300x200 thumbnail WordPress also stores), then run 'media upload' once for the whole media/ directory (at most 500 "
517
+ + "files a run). It records every file in .taproot-site-media.json and reports counts and a bounded item list; "
518
+ + "a run that stopped partway is safe to repeat, because identical files deduplicate.",
519
+ "Check before sending. 'validate' (no credential, no request) checks every page source, media reference and "
520
+ + "setting in the workspace at once and lists every problem in error.problems with its file, code and field. "
521
+ + "'pages push --dry-run' then checks the pages against the live site — paths taken, templates, revisions, "
522
+ + "videos — and sends nothing. 'plan' checks the whole workspace against the site and orders what is left "
523
+ + "(media, pages, footer, theme, navigation); 'apply --plan <planHash>' runs exactly that plan.",
498
524
  "Navigation. A site can have several menus (header, footer): ask which is primary and list the rest. Rebuild it as nav.json after 'pages push', at most three levels deep with canonical UUID ids, 1000 items and 200-character titles (help nav); a label-only dropdown heading (a WordPress '#' link) is a GROUP_HEADER, and a child of an item you cannot map is listed, not promoted (page items carry the resourceId from the "
499
525
  + "manifest; a custom link is an EXTERNAL_URL item; a category or tag item has no target, so list it).",
500
526
  "Redirects. At most 2000 entries; never redirect to a page you did not push. Every imported page's old path (from its <link>) becomes a 301 to its new path, unless the two "
@@ -568,7 +594,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
568
594
  `Pull the baseline: ${CLI_BINARY_NAME} pull writes settings/taproot-styles.json with both schemes' complete effective `
569
595
  + "themes (the stored theme resolved over the same defaults every consumer renders) beside brand.json, "
570
596
  + "site-header.json and site-publishing-preferences.json. Keep that pull as the baseline you edit; never build "
571
- + "a theme from memory or from an example. semanticMappings holds only authored pins, listed in explicitMappingTokens. "
597
+ + "a theme from memory or from an example. semanticMappings holds only authored pins, listed in explicitMappingTokens "
598
+ + "at the root; a context's pins need no marker. "
572
599
  + "site-publishing-preferences.json also carries timeZone, the site's IANA zone (UTC until the owner sets it), "
573
600
  + "which decides what \"today\" means for relative form date bounds; it is read-only here, so ask the owner to set "
574
601
  + "it in the editor under Site settings.",
@@ -579,10 +606,16 @@ const WORKFLOW_REFERENCES = Object.freeze({
579
606
  "Prepare media locally: PNG, JPEG, GIF and WebP raster files only, addressed relative to the workspace root. Make "
580
607
  + "the variants yourself with any image tool (a transparent logo, a @2x copy, a square favicon); there is no "
581
608
  + "recolor, crop or SVG upload command. Upload each with media upload, then assign lightLogoId and darkLogoId "
582
- + "(and brand.faviconId) from the returned image ids so each scheme carries the artwork that reads on its header.",
583
- `Validate offline: ${CLI_BINARY_NAME} validate checks the complete pair, the appearance files and the footer against `
584
- + "the contracts the site enforces, and warns when a semanticMappings pin repeats the default on a token your "
585
- + "roles would have moved (the pin would keep the role from rendering).",
609
+ + "(and brand.faviconId) from the image ids .taproot-site-media.json records for those files, so each scheme carries the artwork that reads on its header.",
610
+ `Validate offline: ${CLI_BINARY_NAME} validate checks the whole workspace at once — the complete pair, the `
611
+ + "appearance files, the footer, every page, navigation and redirects — against the contracts the site enforces, "
612
+ + "lists every problem in error.problems, and warns when a semanticMappings pin repeats the default on a token "
613
+ + "your roles would have moved (the pin would keep the role from rendering).",
614
+ `Plan a combined change: when pages, the footer, the theme and navigation all changed, ${CLI_BINARY_NAME} plan `
615
+ + "checks them against the live site and lists the steps in the order they must run, each ready, blocked or "
616
+ + `nothing to do; ${CLI_BINARY_NAME} apply --plan <planHash> runs them through the ordinary push verbs and refuses `
617
+ + "a plan the workspace or the site has moved past. After a failed step, plan again: finished steps read as "
618
+ + "nothing to do.",
586
619
  `Push: ${CLI_BINARY_NAME} theme push --dry-run first: it reads the site and lists the JSON paths at which each `
587
620
  + "settings file differs from it, and says whether the baseline pull recorded is still current. Then "
588
621
  + `${CLI_BINARY_NAME} theme push saves both themes, the appearance scalars and the ten footer scheme colours in `
@@ -645,10 +678,16 @@ const WORKFLOW_REFERENCES = Object.freeze({
645
678
  "Run 'redirects check' after staging, or read deploy --staging's automatic check. The Node CLI consumes "
646
679
  + "a separate staging handoff and checks the current map at the real edge, using an in-memory cookie and "
647
680
  + "manual redirects. It reports each path, HTTP status and Location without following targets.",
681
+ "The result carries routeCheck (resolved or unresolved) and redirects: { revision, revisionUnchanged, "
682
+ + "verified, total, checked, matched, failed, items: [{ path, status, location, expectedStatus, matches, "
683
+ + "error? }], itemsTruncated? }, at the top level of both redirects check and deploy --staging. An entry "
684
+ + "whose request failed carries error: \"request_failed\". A check that cannot finish at all reports "
685
+ + "verified: false, an empty items list and redirects.error { code, message }.",
648
686
  "verified is true only if every entry matched and the map revision stayed unchanged during the check. "
649
687
  + "The check proves the current map at staging, not a historical deployment. A mismatch means the staged "
650
688
  + "release does not carry the entry (yet); re-run redirects check after the deployment completes, before promoting. Requests are bounded to 90 seconds, eight at a time; "
651
- + "large JSON lists are truncated explicitly with failed entries first. Human output reports every entry.",
689
+ + "large JSON lists are truncated explicitly with failed entries first (redirects.itemsTruncated). Progress "
690
+ + "output reports every entry on stderr; --quiet hides it.",
652
691
  "stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
653
692
  + "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
654
693
  + "five minutes and remains subject to current site/host/credential permission.",
@@ -829,22 +868,23 @@ const WORKFLOW_REFERENCES = Object.freeze({
829
868
  }),
830
869
  media: Object.freeze({
831
870
  title: "Media upload contract",
832
- summary: "Upload workspace-root-relative raster files and videos and receive component-ready media objects and video ids.",
871
+ summary: "Upload workspace-root-relative raster files and videos; images are recorded in .taproot-site-media.json and videos report their ids.",
833
872
  usage: `${CLI_BINARY_NAME} media upload [path...]`,
834
873
  details: Object.freeze([
835
874
  "Paths are relative to the configured workspace root, not the shell's current directory.",
836
875
  "PNG, JPEG, GIF, and WebP are accepted; retina names such as logo@2x.png and logo@3x.png are supported.",
837
876
  "For header logos prefer genuine transparent PNG/WebP, check contrast in both themes, and include a simplified square favicon. See help appearance for branding guidance.",
838
877
  "Prepare variants locally with any image tool before uploading: a transparent logo, its @2x copy, a scheme-specific dark logo when one asset does not read on both headers, and a square favicon. The CLI uploads what you give it; it has no recolor, crop, or SVG upload command.",
839
- "Assign both schemes after uploading: lightLogoId and darkLogoId in settings/taproot-styles.json and brand.faviconId in settings/brand.json take the returned image ids; theme push writes them. See help walkthrough for the full design pass.",
840
- "Each result item includes media: { imageId, src, urls, width, height, alt }.",
878
+ "Assign both schemes after uploading: lightLogoId and darkLogoId in settings/taproot-styles.json and brand.faviconId in settings/brand.json take the image ids .taproot-site-media.json records for those files; theme push writes them. See help walkthrough for the full design pass.",
879
+ "Each result item reports file, imageId, deduplicated, width, height and processingState; the delivery URLs (src, urls) are in .taproot-site-media.json, not the result, so an upload of any size reports within its bound. media.total and videos.total count every file, and media.itemsTruncated or videos.itemsTruncated says that list stopped short.",
841
880
  "MP4, MOV, and WebM files are uploaded as videos (up to 250 MB each; the site's licence sets the cap, and the container is read from the file's bytes, not its name). The CLI reads each file with mediabunny. A file that is H.264 and AAC but not already an MP4 with its index first is rewritten as one without re-encoding; anything else is sent as it is and judged by the server. When the server refuses a file, its one-line message is printed as media.video_refused. A video is ready to play as soon as the upload is confirmed. The CLI sets no poster, because Node has no video decoder: the Videos page picks one automatically for a video uploaded there, and lets the owner choose another frame, so set a CLI-uploaded video's poster there, with its title and caption. The command reports videos.items: { file, videoId, title, caption, fileName, contentType, byteLength, deduplicated, durationMilliseconds, component }, where component.markdown is the component:video fence and component.block the componentBlock node that place it. Needs the site's licence to include video.",
842
881
  "Place an uploaded video on a page with a video component: a component:video fence in Markdown or a componentBlock of type video, with the returned videoId and optional maxHeight and borderWidth; the poster, caption and shape are the video's own. See help component video. pages push refuses a video that is not in the site's library; validate checks it against fixture.videoIds.",
843
- "The same src/urls delivery fields are saved in .taproot-site-media.json for page and component authoring.",
882
+ "In Markdown, name an uploaded image by its path (such as media/photo.webp) wherever an image goes: body images, coverImage, album images, component image fields and section images. pages push resolves each path through .taproot-site-media.json, which also keeps the src/urls delivery fields a .pm.json image record carries.",
844
883
  ]),
845
884
  example: Object.freeze({
846
885
  command: `${CLI_BINARY_NAME} media upload media/logo@2x.png`,
847
- componentMedia: Object.freeze({
886
+ markdownImage: "media/logo@2x.png",
887
+ pmJsonImageRecord: Object.freeze({
848
888
  imageId: "<image-uuid>",
849
889
  src: "<preferred-delivery-url>",
850
890
  urls: Object.freeze([Object.freeze({ minWidth: 640, url: "<responsive-delivery-url>" })]),
@@ -872,6 +912,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
872
912
  + "(staging.host_unavailable, staging.authority_denied, staging.surface_refused, or staging.handoff_unavailable) "
873
913
  + "and a recovery step; run staging review to mint another (it works on every surface). Never verify a theme "
874
914
  + "on production instead.",
915
+ "A preview is served on the site's staging address: its own address under Taproot's user-content domain "
916
+ + "(ontaproot.io in production), or its customer staging domain once that is active. The address has to be "
917
+ + "live, so a site that has never deployed to staging, or whose staging host is changing, answers "
918
+ + "preview.staging_unavailable; run 'deploy --staging' once, or wait for the change to finish.",
875
919
  "A page path resolves through .taproot-site-manifest.json; a canonical page UUID works directly.",
876
920
  "The homepage's manifest path is empty; address it as '/', which resolves to that empty root path.",
877
921
  "Preview before approving: approve consumes the draft, so an approved page has no draft left to render and answers preview.no_draft. Review it on staging after a deploy instead.",
@@ -889,7 +933,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
889
933
  summary:
890
934
  `${FIXTURE_MANIFEST_FILE_NAME} binds a directory laid out like a pulled workspace to deterministic identities, `
891
935
  + "so validate can prove it with no credential, no network, and no write.",
892
- usage: `${CLI_BINARY_NAME} validate <fixture-directory>`,
936
+ usage: `${CLI_BINARY_NAME} validate [<fixture-directory>]`,
893
937
  details: Object.freeze([
894
938
  `Required root fields: ${FIXTURE_REQUIRED_ROOT_FIELDS.join(", ")}.`,
895
939
  `Optional root fields: ${FIXTURE_OPTIONAL_ROOT_FIELDS.join(", ")}. Appearance and footer metadata, when present, `
@@ -905,7 +949,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
905
949
  + `sourceFormat, and workspaceMode ${PAGE_WORKSPACE_MODE_EDITABLE}. Album images and the cover image must be `
906
950
  + "listed in fixture.imageIds. "
907
951
  + "status, hasDraft, isGenerated, and description are recorded by pull and not read: the page key set "
908
- + "stays open so a fixture from a newer pull is not refused. The homepage's path is the empty string.",
952
+ + "stays open so a fixture from a newer pull is not refused. The homepage's path is the empty string. "
953
+ + `An entry with workspaceMode ${PAGE_WORKSPACE_MODE_METADATA_ONLY} names a page the fixture has no source for, `
954
+ + "such as a generated section page navigation links to: pageId, resourceId and path, and no file.",
909
955
  `file lives under '${PAGES_DIRECTORY}/' and ends in ${PAGE_SOURCE_EXTENSIONS.join(" or ")}; sourceFormat must be `
910
956
  + `the format that extension implies (${
911
957
  PAGE_SOURCE_EXTENSIONS.map((extension) => `${extension} is '${pageSourceFormat(`page${extension}`)}'`).join(
@@ -928,6 +974,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
928
974
  }.`,
929
975
  `The fixture block carries ${FIXTURE_METADATA_FIELDS.join(", ")}. Every image a page references by imageId must `
930
976
  + "be listed in fixture.imageIds, and every absolute delivery URL a page uses must sit on a declared origin. "
977
+ + "A Markdown media path resolves through the fixture's own .taproot-site-media.json, as in a workspace; "
978
+ + "without one, no media path resolves. "
931
979
  + "videoIds is optional: list the ready videos a page may place with a video component, and validate refuses a "
932
980
  + "video component whose videoId is not listed. A live push makes the same check against the site's video library.",
933
981
  `Each deliveryOrigins entry is an origin-only HTTPS URL (no path, query, fragment, or credentials) on `
@@ -937,8 +985,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
937
985
  "validate reads the fixture and writes nothing to it. Copy the directory somewhere writable before editing it.",
938
986
  "validate --init <new-directory> exports the current pulled workspace as a validated version-7 fixture with "
939
987
  + "appearance and footer metadata. Run from the workspace or its configured project; --config may select the source.",
940
- "Initialization keeps editable pages of every template as workspace documents and reports excluded metadata-only pages. "
941
- + "References to excluded pages must be resolved before the fixture can validate. It never copies credentials, "
988
+ "Initialization keeps editable pages of every template as workspace documents. A metadata-only page that "
989
+ + "navigation, the footer or a page links to is kept as a metadata-only entry (initialized.linkedPages); other "
990
+ + "metadata-only pages are left out (initialized.skippedPages). It never copies credentials, "
942
991
  + "internal reconciliation state, or deployment receipts. UUIDs and HTTP(S) origins are replaced with deterministic "
943
992
  + "fixture identities and example.test origins; URL credentials, queries and fragments are removed. Authored prose is retained.",
944
993
  "The destination must be outside the source workspace and must not exist; its parent must already exist without symlinks. Exports are bounded to 64 MiB. "
@@ -1011,11 +1060,15 @@ export function listComponentTypeReferences() {
1011
1060
  return COMPONENT_TYPES.map(componentSummary);
1012
1061
  }
1013
1062
 
1063
+ const EXAMPLE_MEDIA_PATH = "media/photo.webp";
1064
+
1014
1065
  export function getComponentReference(type) {
1015
1066
  const definition = getComponentDefinition(type);
1016
1067
  if (!definition) return undefined;
1017
1068
  const example = definition.example;
1018
- const markdownExample = `\`\`\`component:${type}\n${JSON.stringify(example, null, 2)}\n\`\`\``;
1069
+ // Markdown names each image by its media path; pages push resolves it (TR01187).
1070
+ const markdownData = mapComponentImages(type, example, (value) => (value === null ? null : EXAMPLE_MEDIA_PATH));
1071
+ const markdownExample = `\`\`\`component:${type}\n${JSON.stringify(markdownData, null, 2)}\n\`\`\``;
1019
1072
  return {
1020
1073
  type,
1021
1074
  displayName: definition.displayName,
@@ -1150,12 +1203,14 @@ function backgroundReference() {
1150
1203
  }),
1151
1204
  scrimOpacityByValue: Object.freeze({ ...BACKGROUND_DEFINITION.fields.scrimStrength.opacityByValue }),
1152
1205
  deliveryUrlPolicy:
1153
- "Use complete site-owned processed-image results for image and portraitImage. src and every urls[].url must be HTTPS or non-protocol-relative root-relative delivery URLs; credentials, whitespace, controls, and backslashes are rejected.",
1206
+ "In Markdown, give image and portraitImage as media paths recorded by media upload (such as \"media/photo.webp\"); pages push stores the image records. In a .pm.json document they are complete site-owned processed-image records: src and every urls[].url must be HTTPS or non-protocol-relative root-relative delivery URLs; credentials, whitespace, controls, and backslashes are rejected.",
1154
1207
  compactBehavior:
1155
1208
  "At the shared compact breakpoint, portraitImage and portraitFocus replace the landscape image and focus when supplied.",
1156
1209
  example: BACKGROUND_EXAMPLE,
1157
1210
  markdownExample: `:::section ${
1158
- JSON.stringify({ background: BACKGROUND_EXAMPLE })
1211
+ JSON.stringify({
1212
+ background: { ...BACKGROUND_EXAMPLE, image: "media/class.webp", portraitImage: "media/class-portrait.webp" },
1213
+ })
1159
1214
  }\n## Practice with us\n\nFind your next class.\n:::`,
1160
1215
  });
1161
1216
  }
@@ -1185,11 +1240,11 @@ function decorationReference() {
1185
1240
  additionalProperties: false,
1186
1241
  fields: sectionAttributeReference().find((attr) => attr.name === "decoration").fields,
1187
1242
  deliveryUrlPolicy:
1188
- "Use the complete site-owned processed-image result from media upload. src and every urls[].url must be HTTPS or non-protocol-relative root-relative delivery URLs; credentials, whitespace, controls, and backslashes are rejected.",
1243
+ "In Markdown, give image as a media path recorded by media upload (such as \"media/starburst.webp\"); pages push stores the image record. In a .pm.json document it is the complete site-owned processed-image record: src and every urls[].url must be HTTPS or non-protocol-relative root-relative delivery URLs; credentials, whitespace, controls, and backslashes are rejected.",
1189
1244
  tintTokens: Object.freeze({ ...DECORATION_DEFINITION.fields.tint.tokenByValue }),
1190
1245
  example: DECORATION_EXAMPLE,
1191
1246
  markdownExample: `:::section ${
1192
- JSON.stringify({ decoration: DECORATION_EXAMPLE })
1247
+ JSON.stringify({ decoration: { ...DECORATION_EXAMPLE, image: "media/starburst.webp" } })
1193
1248
  }\n## Rooted in warmth\n\nMove with confidence.\n:::`,
1194
1249
  maskGuidance:
1195
1250
  "Use a transparent PNG or WebP whose alpha channel contains only the mark. The active section context supplies the tint in both schemes.",
@@ -1478,6 +1533,31 @@ const FREE_FORM_REFERENCE = Object.freeze({
1478
1533
  + "refuses before changing anything under pages/ and preserves the site's version as internal state. A "
1479
1534
  + "locally edited .pm.json is kept rather than overwritten, and only collides when the site changed too.",
1480
1535
  conflictError: "pages.pull_conflict",
1536
+ metadata:
1537
+ "A .pm.json page's title, path and description live in the manifest, not the file. Change the title or "
1538
+ + "description with pages meta set <path> --title/--description; a Markdown page keeps them in its front "
1539
+ + "matter. pull keeps such an edit until pages push sends it, and a site edit to the same page is a "
1540
+ + "pages.pull_conflict. A description over 160 characters is warned about (search results cut it off); "
1541
+ + "over 1000 is refused.",
1542
+ authorship:
1543
+ "Pages are authorless by default: a page created without an author publishes with no byline, and pages push "
1544
+ + "lists the pages it created that way in pages.authorless. Name an author with author: in the front matter "
1545
+ + "(every template) or, for a .pm.json page, pages meta set <path> --author. An author is a site author "
1546
+ + "without a Taproot account, named by handle (jane-doe), or a site member who can create pages, named by "
1547
+ + "email address. authors list shows who can be named and rewrites authors.json; authors add <handle> "
1548
+ + "--name <name> [--email <address>] creates a site author, whose address stays private. A site author's "
1549
+ + "name appears on the byline and in structured data, with no profile link or follow button. authors add "
1550
+ + "again with the same handle and name succeeds (existing: true); another name for a taken handle is "
1551
+ + "authors.handle_taken. Only the CLI assigns authors, and only to a page that has none. authors.json is as old "
1552
+ + "as the last pull or authors list, so offline commands (validate, pages meta set) only warn "
1553
+ + "(pages.author_unverified) about a name it lacks; pages push and plan read the site's current list once on a "
1554
+ + "miss and refuse only a name the site lacks. The CLI only fills a page that has no author: a different one "
1555
+ + "already on the page is pages.author_conflict, which names the way out (pages meta set <path> --author "
1556
+ + "<the site's author> for a .pm.json page, the author: line for Markdown, or pages meta set <path> --author "
1557
+ + "\"\" to drop an author that was never sent), naming the author a page already has changes nothing, and a "
1558
+ + "name that is unknown or belongs to a member who cannot create pages is pages.author_unknown either way. "
1559
+ + "Text that is neither a handle nor an address is pages.author_invalid.",
1560
+ authorshipErrors: Object.freeze(["pages.author_invalid", "pages.author_unknown", "pages.author_conflict"]),
1481
1561
  conflictDetail:
1482
1562
  "A reported conflict names the JSON paths at which the site's document differs from the copy this workspace "
1483
1563
  + "last reconciled with, in error.differences, which carries three states. Absent: the two documents were "
@@ -1529,13 +1609,23 @@ const FREE_FORM_REFERENCE = Object.freeze({
1529
1609
  inheritedForTracked: true,
1530
1610
  defaultForNew: "",
1531
1611
  }),
1612
+ Object.freeze({
1613
+ name: "author",
1614
+ type: "string",
1615
+ requiredForNew: false,
1616
+ inheritedForTracked: true,
1617
+ defaultForNew: "",
1618
+ description:
1619
+ "A site author's handle (jane-doe) or a member's email address. Omit it for an authorless page; it "
1620
+ + "only fills a page that has none, and never replaces one.",
1621
+ }),
1532
1622
  Object.freeze({
1533
1623
  name: "template",
1534
1624
  type: "string",
1535
1625
  requiredForNew: false,
1536
1626
  inheritedForTracked: false,
1537
1627
  defaultForNew: "free-form",
1538
- description: `Other templates: ${TYPED_PAGE_TYPES.join(", ")}; see '${CLI_BINARY_NAME} help pages'.`,
1628
+ description: `Other templates: ${MARKDOWN_PAGE_TYPES.join(", ")}; see '${CLI_BINARY_NAME} help pages'.`,
1539
1629
  }),
1540
1630
  ]),
1541
1631
  }),
@@ -1556,6 +1646,25 @@ const FREE_FORM_REFERENCE = Object.freeze({
1556
1646
  root: Object.freeze({ type: "doc", content: "array of supported block nodes" }),
1557
1647
  nodes: NODE_TYPES,
1558
1648
  marks: MARK_TYPES,
1649
+ // TR01190: what a link mark's rel and target publish as.
1650
+ links: Object.freeze({
1651
+ markdown: '[Shop](https://shop.example){rel="sponsored nofollow" target="_blank"}',
1652
+ rel: LINK_REL_TOKENS,
1653
+ target: LINK_TARGETS,
1654
+ default:
1655
+ "A link with no rel is followed, whether it goes to this site or another. Choose nofollow, sponsored "
1656
+ + "(paid or affiliate), or ugc (written by someone else) for a link that should not pass endorsement; the "
1657
+ + "choice is per link, internal links included.",
1658
+ markdownRule:
1659
+ 'Write {rel="…" target="…"} directly after the link\'s closing parenthesis: either attribute alone or both, '
1660
+ + 'each double-quoted once. rel is space-separated; target="_blank" opens a new tab.',
1661
+ pmJson:
1662
+ 'In .pm.json, a link mark carries attrs.rel (a space-separated string, or null) and attrs.target ("_blank" '
1663
+ + 'opens a new tab and publishes with noopener noreferrer added, "_self" or null keeps the same tab).',
1664
+ refusals:
1665
+ `Any other rel token, opener included, is refused (${CONTENT_ERROR_CODES.linkRel}); so is another target `
1666
+ + `(${CONTENT_ERROR_CODES.linkTarget}). The editor's old default, "noopener noreferrer nofollow", reads as no choice.`,
1667
+ }),
1559
1668
  componentNode: Object.freeze({
1560
1669
  type: "componentBlock",
1561
1670
  attrs: Object.freeze({
@@ -1665,7 +1774,8 @@ function schemaConstraints(schema) {
1665
1774
 
1666
1775
  function formatSchemaLine(name, schema, required, indent) {
1667
1776
  const constraints = schemaConstraints(schema);
1668
- return `${indent}${name.padEnd(Math.max(1, 28 - indent.length))} ${schemaLabel(schema)}; ${
1777
+ const label = schema.markdownType ? "media path | null in Markdown; object | null in .pm.json" : schemaLabel(schema);
1778
+ return `${indent}${name.padEnd(Math.max(1, 28 - indent.length))} ${label}; ${
1669
1779
  required ? "required" : "optional"
1670
1780
  }${constraints.length > 0 ? `; ${constraints.join("; ")}` : ""}`;
1671
1781
  }
@@ -1675,7 +1785,7 @@ function nestedSchemaLines(schema, path, indent = " ") {
1675
1785
  const valuePath = schema.type === "array" ? `${path}[]` : path;
1676
1786
  if (!valueSchema?.properties) return [];
1677
1787
 
1678
- const lines = [];
1788
+ const lines = valueSchema.markdownType ? [`${indent}${valuePath} in a .pm.json document:`] : [];
1679
1789
  for (const property of valueSchema.properties) {
1680
1790
  const propertyPath = `${valuePath}.${property.name}`;
1681
1791
  lines.push(formatSchemaLine(propertyPath, property.schema, property.required, indent));
@@ -1719,9 +1829,11 @@ function formatSectionAttr(attr) {
1719
1829
 
1720
1830
  function formatRegistryField(field, indent = " ") {
1721
1831
  if (field.type === "processed-image") {
1722
- return `${indent}${field.name.padEnd(Math.max(1, 20 - indent.length))} processed-image${
1832
+ return `${indent}${field.name.padEnd(Math.max(1, 20 - indent.length))} media path${
1833
+ field.nullable ? " | null" : ""
1834
+ } in Markdown; processed-image${
1723
1835
  field.nullable ? " | null" : ""
1724
- }; ${field.required ? "required" : `default ${JSON.stringify(field.default)}`}; required keys ${
1836
+ } in .pm.json; ${field.required ? "required" : `default ${JSON.stringify(field.default)}`}; .pm.json required keys ${
1725
1837
  field.requiredKeys.join(", ")
1726
1838
  }; optional keys ${
1727
1839
  field.optionalKeys.join(", ")
@@ -1761,6 +1873,8 @@ function formatDesignRecipe(recipe) {
1761
1873
 
1762
1874
  export function formatReferenceResult(result) {
1763
1875
  switch (result.topic) {
1876
+ case "fonts":
1877
+ return formatFontCatalog(result.fonts);
1764
1878
  case "topics":
1765
1879
  return `Usage: ${CLI_BINARY_NAME} help <topic> [name] [--json]\n\nReference topics:\n${
1766
1880
  result.topics.map((topic) => ` ${topic.usage.padEnd(48)} ${topic.summary}`).join("\n")
@@ -1802,13 +1916,15 @@ export function formatReferenceResult(result) {
1802
1916
  page.workspace.formats.map((format) => ` ${format.extension.padEnd(9)} ${format.purpose}`).join("\n")
1803
1917
  }\n\nOne source per page:\n rule ${sourceRule.rule}\n manifest fields ${
1804
1918
  sourceRule.manifestFields.join(", ")
1805
- }\n pull ${sourceRule.pull}\n internal state ${sourceRule.internalState}\n format change ${sourceRule.formatChange} (${sourceRule.formatChangeError})\n renamed source ${sourceRule.renamedSource} (${sourceRule.renamedSourceError})\n conflicts ${sourceRule.conflict} (${sourceRule.conflictError})\n conflict detail ${sourceRule.conflictDetail}\n push conflicts ${sourceRule.pushConflict} (${sourceRule.pushConflictError})\n revision ${sourceRule.revisionSource}\n recovery ${sourceRule.conflictRecovery}\n push selection ${sourceRule.pushSelection}\n\nSystem pages: ${page.workspace.systemPages}\n\nMarkdown front matter:\n${
1919
+ }\n pull ${sourceRule.pull}\n internal state ${sourceRule.internalState}\n format change ${sourceRule.formatChange} (${sourceRule.formatChangeError})\n renamed source ${sourceRule.renamedSource} (${sourceRule.renamedSourceError})\n conflicts ${sourceRule.conflict} (${sourceRule.conflictError})\n conflict detail ${sourceRule.conflictDetail}\n push conflicts ${sourceRule.pushConflict} (${sourceRule.pushConflictError})\n revision ${sourceRule.revisionSource}\n recovery ${sourceRule.conflictRecovery}\n push selection ${sourceRule.pushSelection}\n authors ${sourceRule.authorship} (${sourceRule.authorshipErrors.join(", ")})\n\nSystem pages: ${page.workspace.systemPages}\n\nMarkdown front matter:\n${
1806
1920
  markdownFormat.metadata.map(formatMetadata).join("\n")
1807
1921
  }\n\nDocument root: { "type": "doc", "content": [...] }\nSupported nodes: ${
1808
1922
  page.document.nodes.join(", ")
1809
1923
  }\nSupported marks: ${
1810
1924
  page.document.marks.join(", ")
1811
- }\n\nTop-level section container:\n${page.sections.example}\n\nSection attributes:\n${
1925
+ }\n\nLinks:\n markdown ${page.document.links.markdown}\n rel ${
1926
+ page.document.links.rel.join(", ")
1927
+ }\n default ${page.document.links.default}\n in Markdown ${page.document.links.markdownRule}\n in .pm.json ${page.document.links.pmJson}\n refusals ${page.document.links.refusals}\n\nTop-level section container:\n${page.sections.example}\n\nSection attributes:\n${
1812
1928
  page.document.sectionNode.attrs.map(formatSectionAttr).join("\n")
1813
1929
  }\n\nSection photo background (closed object):\n${
1814
1930
  page.sections.background.fields.map((field) => formatRegistryField(field)).join("\n")
package/src/session.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  updateCredentialMetadata,
22
22
  } from "./credentials.js";
23
23
  import { SiteAuthoringError } from "./errors.js";
24
+ import { withinByteBudget } from "./problems.js";
24
25
  import { exchangeSiteAuthoringToken, withRefusalGuidance } from "./api.js";
25
26
  import { ApiError, SiteApiClient } from "./transport.js";
26
27
  import { readStoredApiBaseUrl } from "./settings.js";
@@ -125,6 +126,9 @@ function siteMissingError() {
125
126
  }
126
127
 
127
128
  async function loadConfig(invocation) {
129
+ // apply hands its steps the configuration it planned with, so a
130
+ // taproot-site.json edited mid-apply is not read by a later step.
131
+ if (invocation.resolvedConfig !== undefined) return invocation.resolvedConfig;
128
132
  return await loadSiteConfig({
129
133
  cwd: invocation.cwd ?? process.cwd(),
130
134
  configPath: invocation.configPath,
@@ -552,6 +556,15 @@ export function boundedList(values, maximum) {
552
556
  : { items, count: values.length, truncated: true };
553
557
  }
554
558
 
559
+ /**
560
+ * The leading values whose JSON fits `budget` bytes, for a list whose entries
561
+ * vary in length: a count bound alone can still pass the 64 KiB result bound.
562
+ */
563
+ export function boundedByBytes(values, budget) {
564
+ const items = withinByteBudget(values, budget);
565
+ return items.length === values.length ? { items } : { items, truncated: true };
566
+ }
567
+
555
568
  export function successResult(verb, siteId, payload = {}) {
556
569
  return {
557
570
  schemaVersion: RESULT_SCHEMA_VERSION,