@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
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
 
3
- import { CLI_BINARY_NAME } from "./constants.js";
3
+ import { CLI_BINARY_NAME, LIMITS } from "./constants.js";
4
4
  import {
5
5
  canonicalizeComponentData,
6
6
  COMPONENT_TYPES,
@@ -64,9 +64,11 @@ import {
64
64
  REDIRECTS_FILE_NAME,
65
65
  } from "./redirects-contract.js";
66
66
  import { SETTINGS_GROUPS } from "./settings-catalog.js";
67
+ import { getSettingsReference } from "./setting-reference.js";
67
68
  import {
68
69
  formatTypedPageReference,
69
70
  getTypedPageReference,
71
+ LEGAL_PLATFORM_LINK_GUIDANCE,
70
72
  MARKDOWN_PAGE_TYPES,
71
73
  TYPED_PAGE_TYPES,
72
74
  } from "./typed-page-reference.js";
@@ -84,9 +86,9 @@ import {
84
86
  SYSTEM_PAGE_NOT_FOUND_PATH,
85
87
  } from "./workspace.js";
86
88
 
87
- export { getAppearanceReference, getFooterReference, getThemeReference };
89
+ export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
88
90
 
89
- export const REFERENCE_VERSION = 36;
91
+ export const REFERENCE_VERSION = 38;
90
92
 
91
93
  // The date-limit examples `help forms` prints; tests run them through the form validator.
92
94
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -387,7 +389,7 @@ export function getDesignBlueprint(type) {
387
389
  return DESIGN_BLUEPRINTS.find((blueprint) => blueprint.type === type);
388
390
  }
389
391
  export const REFERENCE_TOPICS = Object.freeze([
390
- Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types: free-form, article, recipe, album, place-review." }),
392
+ Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List page types: free-form, article, recipe, album, place-review, generated, and the read-only legal page." }),
391
393
  Object.freeze({
392
394
  name: "page",
393
395
  usage: `${CLI_BINARY_NAME} help page <page-type>`,
@@ -450,6 +452,11 @@ export const REFERENCE_TOPICS = Object.freeze([
450
452
  usage: `${CLI_BINARY_NAME} help appearance`,
451
453
  summary: "Map site presentation decisions to theme-push workspace fields.",
452
454
  }),
455
+ Object.freeze({
456
+ name: "settings",
457
+ usage: `${CLI_BINARY_NAME} help settings`,
458
+ summary: "Map every pulled site setting to its app control, say what it does and where it renders, and which verb writes it.",
459
+ }),
453
460
  Object.freeze({
454
461
  name: "footer",
455
462
  usage: `${CLI_BINARY_NAME} help footer`,
@@ -501,7 +508,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
501
508
  + "part of wp:post_date). Categories beyond the first, and all tags, have no Taproot equivalent (tags are "
502
509
  + "generated per page type): list them. The featured image becomes coverImage on typed pages, and the same image must also appear in the page's own content (put it at the top of the body), or the site refuses the cover; a free-form page "
503
510
  + "has none, so list it.",
504
- "Content. Convert the HTML to the Markdown subset. Keep headings from h2 to h4 (the title is the h1; deeper levels are refused, so make h5 and h6 an h4). Decode HTML entities (&amp;, &#8217;) in titles, excerpts, menu labels and term names; WordPress stores them encoded. An image "
511
+ "Content. Convert the HTML to the Markdown subset. Keep body headings from h2 to h4 (deeper levels are refused, so make h5 and h6 an h4). The h1 depends on the template: an article or place review prints its title as the h1, so its body starts at h2 (a recipe and an album print their titles too; their fixed level-2 headings are set out below); a free-form page prints no title, so open its Markdown with a '# ' line holding the page title (the first level-1 heading becomes the h1; see help page free-form). Decode HTML entities (&amp;, &#8217;) in titles, excerpts, menu labels and term names; WordPress stores them encoded. An image "
505
512
  + "must stand alone in its own block, so split paragraphs around images and lift images out of links; a link "
506
513
  + "to the full-size file is dropped, so list it. Tables need a header row and 2 to 12 columns, else write "
507
514
  + "a list and report it. Remove Gutenberg block comments and the more-tag marker. In a recipe, times and servings "
@@ -604,7 +611,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
604
611
  + "contexts and intents paired across schemes. Set appearance scalars (default scheme, header, logos, favicon) "
605
612
  + "in the appearance files: help appearance.",
606
613
  "Prepare media locally: PNG, JPEG, GIF and WebP raster files only, addressed relative to the workspace root. Make "
607
- + "the variants yourself with any image tool (a transparent logo, a @2x copy, a square favicon); there is no "
614
+ + "the variants yourself with any image tool (a transparent logo at the highest resolution you have, a square favicon); there is no "
608
615
  + "recolor, crop or SVG upload command. Upload each with media upload, then assign lightLogoId and darkLogoId "
609
616
  + "(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
617
  `Validate offline: ${CLI_BINARY_NAME} validate checks the whole workspace at once — the complete pair, the `
@@ -623,10 +630,12 @@ const WORKFLOW_REFERENCES = Object.freeze({
623
630
  + "(theme.concurrent_modification) and nothing is written; keep copies of the edited files, pull, re-apply, "
624
631
  + "and push again. A lost response is safe to retry — the site answers applied=false when it already holds "
625
632
  + "the change set. footer push owns footer prose, links and imagery.",
626
- `Review on staging: ${CLI_BINARY_NAME} deploy --staging stages the presentation and returns a single-use review handoff `
627
- + "in stagingPreview.url (on a managed Docs site too). Open it once, then use the site's theme toggle to check "
628
- + "both schemes: text, headings, links and hover, actions and focus, the header logos, the favicon. When no "
629
- + "handoff could be minted, stagingPreview.reason says why and staging review mints another. A page draft is "
633
+ `Review on staging: ${CLI_BINARY_NAME} deploy --staging stages the presentation; it reports no review URL by default `
634
+ + `because the URL is a live credential, so run ${CLI_BINARY_NAME} staging review (or add --handoff to deploy) for `
635
+ + "a single-use handoff in stagingPreview.url (on a managed Docs site too). Open it once, then use the site's "
636
+ + "theme toggle to check both schemes: text, headings, links and hover, actions and focus, the header logos, the "
637
+ + "favicon. When a requested handoff could not be minted, stagingPreview.reason says why and staging review mints "
638
+ + "another (add --path <page> to open it on the page under review). A page draft is "
630
639
  + "not needed to review a presentation change; preview page renders one persisted draft and is a different tool.",
631
640
  `Promote only after review: ${CLI_BINARY_NAME} deploy --production promotes the completed staging deployment. `
632
641
  + "Never use production to discover what staging should have shown.",
@@ -650,7 +659,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
650
659
  summary: "nav push replaces the complete tree in nav.json; item IDs are author-minted canonical lowercase UUIDs.",
651
660
  usage: `${CLI_BINARY_NAME} nav push`,
652
661
  details: Object.freeze([
653
- "nav.json is { siteId, navItems }, not a bare array.",
662
+ "nav.json is { siteId, navItems }, not a bare array. Defaults are left out: an item has no resourceId or "
663
+ + "externalUrl unless its kind uses one, and no children when it has none. pull and nav push both write that "
664
+ + "form (nav push rewrites the file after it saves), and either form is accepted on the way in, so a pull after "
665
+ + "a push shows no change you did not make.",
654
666
  "kind is NAV_ITEM_KIND_PAGE, NAV_ITEM_KIND_EXTERNAL_URL, or NAV_ITEM_KIND_GROUP_HEADER.",
655
667
  "PAGE uses resourceId from .taproot-site-manifest.json, never pageId or path.",
656
668
  "EXTERNAL_URL uses externalUrl; GROUP_HEADER has no target; every kind may carry children.",
@@ -664,7 +676,6 @@ const WORKFLOW_REFERENCES = Object.freeze({
664
676
  kind: "NAV_ITEM_KIND_PAGE",
665
677
  title: "Classes",
666
678
  resourceId: "<resourceId-from-manifest>",
667
- children: Object.freeze([]),
668
679
  })]),
669
680
  }),
670
681
  }),
@@ -688,9 +699,12 @@ const WORKFLOW_REFERENCES = Object.freeze({
688
699
  + "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; "
689
700
  + "large JSON lists are truncated explicitly with failed entries first (redirects.itemsTruncated). Progress "
690
701
  + "output reports every entry on stderr; --quiet hides it.",
691
- "stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
692
- + "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
693
- + "five minutes and remains subject to current site/host/credential permission.",
702
+ "redirects check and deploy --staging report no review URL unless you pass --handoff; the check "
703
+ + "authenticates with its own handoff either way. With --handoff, stagingPreview.url is a fresh, single-use "
704
+ + "two-minute handoff, minted after checks. It is emitted only in final JSON, excluded from progress and "
705
+ + "GITHUB_OUTPUT; keep it private. Without it, stagingPreview.reason is staging.handoff_not_requested and "
706
+ + "'staging review' mints one. The staging cookie lasts five minutes and remains subject to current "
707
+ + "site/host/credential permission.",
694
708
  `${REDIRECTS_FILE_NAME} is { siteId, revision, entries }, not a bare array. 'redirects pull' writes it and `
695
709
  + "records the revision in .taproot-site-manifest.json; 'pull' writes it too.",
696
710
  "An entry is { path, kind, target, status }. kind is 'redirect' (the default) or 'gone'; a gone entry "
@@ -738,9 +752,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
738
752
  + "purpose: a real migration list needs judgement about which old URLs still deserve a destination and "
739
753
  + "which are simply gone.",
740
754
  "Entries take effect on the next deploy, staging first. Each is a file inside that release, so it goes live "
741
- + "exactly when the release does: staging with the staged release, production when it is promoted. There is "
742
- + "no separate propagation delay; an entry a check does not see belongs to a release that is not being "
743
- + "served yet.",
755
+ + "exactly when the release does: staging with the staged release, production when it is promoted. The edge "
756
+ + `can keep serving the previous release for up to ${LIMITS.stagingPropagationCeilingSeconds} seconds after a deployment completes, so an entry a `
757
+ + "check does not see may belong to a release the edge has not switched to yet; deploy --staging waits for "
758
+ + "staging to serve the new release and reports stagingServed.",
744
759
  "A source cannot name a file the site generates: '/index.html' or any page's own 'index.html', '/404.html', "
745
760
  + "'/sitemap.xml', '/robots.txt', '/favicon.ico', or a path under the sitemap or robots names. Nothing under "
746
761
  + "'/taproot/<version>/' (for example '/taproot/5.0.62/manifest.json') can be a source either: the platform "
@@ -775,7 +790,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
775
790
  + "version of each form this workspace last saw. 'forms validate' needs only taproot-site.json.",
776
791
  `The file is { key, name, sink, retention_days, notify_owner, definition }. key must equal the file's name `
777
792
  + `and is lowercase letters, digits, and single hyphens, at most ${FORM_KEY_MAXIMUM_LENGTH} characters. name is `
778
- + `1 to ${FORM_LIMITS.nameCharacters} characters. sink is one of ${FORM_SINKS.join(", ")} and defaults to `
793
+ + `1 to ${FORM_LIMITS.nameCharacters} characters and unique on the site, ignoring case: two files, or a file and another form on the site, with the same name `
794
+ + "are refused (forms.name_duplicate, forms.name_taken) before anything is written. "
795
+ + `sink is one of ${FORM_SINKS.join(", ")} and defaults to `
779
796
  + "'none' (store submissions only); 'newsletter' requests double-opt-in confirmation for the contact address. "
780
797
  + "A newsletter sink requires a required email contact field and at least one consent checkbox; every consent checkbox must be checked. "
781
798
  + `retention_days is ${FORM_RETENTION_DAYS.minimum} to ${FORM_RETENTION_DAYS.maximum} (a site's plan may cap it lower): `
@@ -788,8 +805,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
788
805
  + "Delivery also requires Taproot's rollout and email provider readiness; opt-in enables neither, and earlier submissions are not backfilled.",
789
806
  `definition is { fields, contact_field?, submit_label?, after_submit? } (contact_field only with several email fields). Each field is { id, type, label, hint?, ...constraints }; the closed `
790
807
  + `field types are ${FIELD_TYPES.join(", ")}. hint is optional helper text of up to ${MAX_HINT_LENGTH} `
791
- + "characters, drawn under the field on the published form; it is the only author-defined text printed there, so a required "
792
- + "field is not marked 'Required' and a visitor learns it from the error on submit. "
808
+ + "characters, drawn under the field on the published form. The published form marks a required field itself: its label "
809
+ + "ends with ' *' and a '* Required' legend sits above the fields, so do not write 'required' into a label or hint; the "
810
+ + "stored label stays exactly as written. "
793
811
  + `At most ${MAX_FIELDS} fields and ${MAX_SUBMISSION_BYTES} bytes `
794
812
  + "per submission. There are no author-supplied regular expressions: use 'format' from its closed list. "
795
813
  + "The exact constraints per type are in shared/field-schema.json, which 'forms validate' enforces by "
@@ -822,7 +840,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
822
840
  + "a deploy whose release does not publish that page is refused, naming the form and the page. The path in "
823
841
  + "each release is the one the page has in that release, so the published form never asks the site where "
824
842
  + "to go. The page must be one you created, the home page included: a generated page (folder, tag or "
825
- + "archive index), the 404 page, a legal page, a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
843
+ + "archive index), the 404 page, a legal page (help page legal), a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
826
844
  + "the 'form' component takes only formKey.",
827
845
  "A form's contact address is the value of one email field, stored with each submission. That is what lets the "
828
846
  + "owner find and delete every row for a visitor who asks. With exactly one email field, that field is the "
@@ -846,8 +864,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
846
864
  "Submissions are never pulled or pushed. They are visitors' personal data: read, mark, export and delete "
847
865
  + "them on the site's Forms page.",
848
866
  "To show a form, place the free-form component 'form' on a page (a component:form block) with formKey set "
849
- + "to the form's key ('help component form' lists its properties). Push the form first and deploy: the release "
850
- + "freezes the form it was built with.",
867
+ + "to the form's key ('help component form' lists its properties). Push the form first: 'pages push' turns the "
868
+ + "key into the form's id for the site and refuses a key the site does not have or has archived, and 'pages pull' "
869
+ + "turns the id back into the key. Then deploy: the release freezes the form it was built with.",
851
870
  "The delegation.content capability carries site.forms.manage, which every forms verb needs: it covers "
852
871
  + "definitions, settings and status. Visitors' submissions are in no capability: reading them "
853
872
  + "(site.forms.submissions.read) and finding, marking or deleting them (site.forms.submissions.manage) belong "
@@ -878,9 +897,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
878
897
  usage: `${CLI_BINARY_NAME} media upload [path...]`,
879
898
  details: Object.freeze([
880
899
  "Paths are relative to the configured workspace root, not the shell's current directory.",
881
- "PNG, JPEG, GIF, and WebP are accepted; retina names such as logo@2x.png and logo@3x.png are supported.",
900
+ "PNG, JPEG, GIF, and WebP are accepted. Each file uploads as its own image: a name such as logo@2x.png is only a filename, nothing pairs it with logo.png, and the platform generates the display sizes from the one file you assign (see help appearance for logos).",
882
901
  "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.",
883
- "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.",
902
+ "Prepare variants locally with any image tool before uploading: a transparent logo at the highest resolution you have (never a 1x and a 2x pair: assign one file and the platform generates the sizes; keep it under the 32 MiB upload limit), 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.",
884
903
  "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.",
885
904
  "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.",
886
905
  "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.",
@@ -888,8 +907,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
888
907
  "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.",
889
908
  ]),
890
909
  example: Object.freeze({
891
- command: `${CLI_BINARY_NAME} media upload media/logo@2x.png`,
892
- markdownImage: "media/logo@2x.png",
910
+ command: `${CLI_BINARY_NAME} media upload media/logo.png`,
911
+ markdownImage: "media/logo.png",
893
912
  pmJsonImageRecord: Object.freeze({
894
913
  imageId: "<image-uuid>",
895
914
  src: "<preferred-delivery-url>",
@@ -910,11 +929,15 @@ const WORKFLOW_REFERENCES = Object.freeze({
910
929
  + "selected page's content until a newer matching preview is ready, the content changes, or the operator "
911
930
  + "explicitly supplies --allow-failed-preview. Pending retries do not clear a known failure; previews "
912
931
  + "remain optional and old snapshots without content identity do not block deployment.",
913
- "deploy --staging and redirects check return a separate fresh single-use staging handoff in "
914
- + "stagingPreview.url, after authenticating headless redirect checks. Handoffs never go to GITHUB_OUTPUT.",
915
- "On a managed Docs site deploy --staging stages settings only and still returns stagingPreview.url: open it "
916
- + "once, then use the site's theme toggle to review both schemes; no page draft is needed to preview a "
917
- + "presentation change. When no handoff can be minted the result carries stagingPreview.reason "
932
+ "deploy --staging and redirects check report no review URL by default, because it is a live single-use "
933
+ + "credential and their JSON is often saved to logs (stagingPreview.reason staging.handoff_not_requested). Run "
934
+ + "staging review for one, or pass --handoff to get a fresh single-use handoff in stagingPreview.url, minted "
935
+ + "after the headless redirect checks. staging review --path <page> (and --review-path <page> beside --handoff) "
936
+ + "opens the review on that page instead of the home page. Handoffs never go to GITHUB_OUTPUT.",
937
+ "On a managed Docs site deploy --staging stages settings only: run staging review (or deploy --staging "
938
+ + "--handoff) and open the URL once, then use the site's theme toggle to review both schemes; no page draft is "
939
+ + "needed to preview a presentation change. When a requested handoff can't be minted the result carries "
940
+ + "stagingPreview.reason "
918
941
  + "(staging.host_unavailable, staging.authority_denied, staging.surface_refused, or staging.handoff_unavailable) "
919
942
  + "and a recovery step; run staging review to mint another (it works on every surface). Never verify a theme "
920
943
  + "on production instead.",
@@ -1511,7 +1534,8 @@ const BACKGROUND_REFERENCE = backgroundReference();
1511
1534
  const FREE_FORM_REFERENCE = Object.freeze({
1512
1535
  type: "free-form",
1513
1536
  displayName: "Free-form page",
1514
- summary: "A page whose body is authored as validated ProseMirror, directly or through the supported Markdown subset.",
1537
+ summary:
1538
+ "A page whose body is authored as validated ProseMirror, directly or through the supported Markdown subset. It renders no automatic heading: open the body with a '# ' title line.",
1515
1539
  workspace: Object.freeze({
1516
1540
  directory: "pages/",
1517
1541
  sourceRule: Object.freeze({
@@ -1671,6 +1695,27 @@ const FREE_FORM_REFERENCE = Object.freeze({
1671
1695
  `Any other rel token, opener included, is refused (${CONTENT_ERROR_CODES.linkRel}); so is another target `
1672
1696
  + `(${CONTENT_ERROR_CODES.linkTarget}). The editor's old default, "noopener noreferrer nofollow", reads as no choice.`,
1673
1697
  }),
1698
+ // TR01220: a free-form page prints no title, so its first level-1 heading is the h1.
1699
+ headings: Object.freeze({
1700
+ rule:
1701
+ "A free-form page renders no automatic heading: its title is not printed on the page. "
1702
+ + "The first level-1 heading in the body becomes the page's h1, so open the Markdown with '# Page title'. A "
1703
+ + "structured page (article, recipe, album, place review) prints its title as the h1, so its body starts at '## '.",
1704
+ claimedFirst:
1705
+ "A hero-section with a title claims the h1, and so does an image-banner with overlay text that opens the page; "
1706
+ + "a '# ' heading after either is then not a second h1. A titled hero-section renders its own h1 wherever it sits, so a '# ' line before one gives the page two h1s: use one or the other. Without a hero or banner, write the '# ' heading yourself.",
1707
+ laterHeadings:
1708
+ "Any later '# ' heading renders as an h2, like the other top-level sections, never as a second h1, "
1709
+ + "and the other headings are renumbered so none skips a level.",
1710
+ depth: `The Markdown subset accepts levels 1 to 4; a deeper heading is refused (${CONTENT_ERROR_CODES.headingDepth}).`,
1711
+ example: "# Visit the studio\n\nWe are open every day.\n\n## Hours\n\nMonday to Friday, 6 AM to 7:30 PM.",
1712
+ }),
1713
+ // TR01224: the default for a policy written as a free-form page.
1714
+ legalAndPrivacyPages: Object.freeze({
1715
+ ...LEGAL_PLATFORM_LINK_GUIDANCE,
1716
+ choosing:
1717
+ `A policy that needs a version and an effective date is a legal page, which the CLI cannot author: see '${CLI_BINARY_NAME} help page legal'.`,
1718
+ }),
1674
1719
  componentNode: Object.freeze({
1675
1720
  type: "componentBlock",
1676
1721
  attrs: Object.freeze({
@@ -1886,7 +1931,7 @@ export function formatReferenceResult(result) {
1886
1931
  result.topics.map((topic) => ` ${topic.usage.padEnd(48)} ${topic.summary}`).join("\n")
1887
1932
  }\n\nAdd --json for stable machine-readable output.\n`;
1888
1933
  case "page-types":
1889
- return `Authorable page types:\n${
1934
+ return `Page types:\n${
1890
1935
  result.pageTypes.map((page) =>
1891
1936
  ` ${page.type.padEnd(16)} ${page.summary}\n${" ".repeat(19)}Help: ${page.helpCommand}`
1892
1937
  ).join("\n")
@@ -1930,7 +1975,9 @@ export function formatReferenceResult(result) {
1930
1975
  page.document.marks.join(", ")
1931
1976
  }\n\nLinks:\n markdown ${page.document.links.markdown}\n rel ${
1932
1977
  page.document.links.rel.join(", ")
1933
- }\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${
1978
+ }\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\nHeadings and the h1:\n rule ${page.document.headings.rule}\n banner and hero ${page.document.headings.claimedFirst}\n later headings ${page.document.headings.laterHeadings}\n depth ${page.document.headings.depth}\n example\n${
1979
+ page.document.headings.example.split("\n").map((line) => ` ${line}`).join("\n")
1980
+ }\n\nLegal and privacy pages:\n linking Taproot ${page.document.legalAndPrivacyPages.rule}\n example ${page.document.legalAndPrivacyPages.example}\n versioned policies ${page.document.legalAndPrivacyPages.choosing}\n\nTop-level section container:\n${page.sections.example}\n\nSection attributes:\n${
1934
1981
  page.document.sectionNode.attrs.map(formatSectionAttr).join("\n")
1935
1982
  }\n\nSection photo background (closed object):\n${
1936
1983
  page.sections.background.fields.map((field) => formatRegistryField(field)).join("\n")
@@ -5,17 +5,11 @@
5
5
  * `/taproot/<semver>/...` (TR01144), and Cloudflare answers a matching request
6
6
  * before the Worker runs, so a page or redirect authored there could never be
7
7
  * reached. This is the one place the package owns that prefix; page push and
8
- * the redirect map both ask here. The API is the authority and mirrors the rule
9
- * in `PublishedOutputPaths.IsRuntimeMirrorPath`; both are pinned by the same
10
- * test vectors.
8
+ * the redirect map both ask here. The API is the authority
9
+ * (`PublishedOutputPaths.IsRuntimeMirrorPath`); both pass the site-check corpus.
11
10
  */
12
- const RUNTIME_MIRROR_PATH =
13
- /^taproot\/[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\/|$)/iu;
14
-
15
- /** Whether a site-relative path (no leading slash) is `taproot/<semver>` or under it. */
16
- export function isRuntimeMirrorPath(path) {
17
- return RUNTIME_MIRROR_PATH.test(path);
18
- }
11
+ // The rule itself lives in the shared site-check source, which the redirect source rules also read.
12
+ export { isRuntimeMirrorPath } from "./site-checks.js";
19
13
 
20
14
  export const RUNTIME_MIRROR_PATH_REASON =
21
15
  "'/taproot/<version>/' is reserved for the published-site runtime, which the platform answers before any page "