@taprootio/site-authoring 0.14.1 → 0.15.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.
@@ -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,
@@ -71,7 +80,7 @@ import {
71
80
 
72
81
  export { getAppearanceReference, getFooterReference, getThemeReference };
73
82
 
74
- export const REFERENCE_VERSION = 33;
83
+ export const REFERENCE_VERSION = 34;
75
84
 
76
85
  // The date-limit examples `help forms` prints; tests run them through the form validator.
77
86
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -408,7 +417,7 @@ export const REFERENCE_TOPICS = Object.freeze([
408
417
  Object.freeze({
409
418
  name: "media",
410
419
  usage: `${CLI_BINARY_NAME} help media`,
411
- summary: "Describe media selection, naming, and component-ready output.",
420
+ summary: "Describe media selection, naming, and where the uploaded media is recorded.",
412
421
  }),
413
422
  Object.freeze({
414
423
  name: "preview",
@@ -445,6 +454,11 @@ export const REFERENCE_TOPICS = Object.freeze([
445
454
  usage: `${CLI_BINARY_NAME} help import`,
446
455
  summary: "Import an existing WordPress site from its export: mapping rules, order of work, and what to report.",
447
456
  }),
457
+ Object.freeze({
458
+ name: "fonts",
459
+ usage: `${CLI_BINARY_NAME} help fonts [category]`,
460
+ summary: "List the font families a published site can load, for the theme's font fields.",
461
+ }),
448
462
  Object.freeze({
449
463
  name: "fixture",
450
464
  usage: `${CLI_BINARY_NAME} help fixture`,
@@ -462,15 +476,20 @@ const WORKFLOW_REFERENCES = Object.freeze({
462
476
  + "has said you may; the export names every media URL but carries no image bytes, so downloading originals from "
463
477
  + "the old site also needs that permission. Work in a fresh workspace: 'use' the site, then 'pull'.",
464
478
  "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 "
479
+ + "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
480
  + "--staging, review it; 10 redirects check and delivery check --staging; 11 deploy --production; 12 delivery "
467
481
  + "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
482
  "Classify. Published post: article (the default); recipe when it has an Ingredients list and a Steps list "
469
483
  + "(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 "
484
+ + "page at its path, children under their parent's path. Never guess a place review: see Place reviews "
485
+ + "below. Not imported, and always "
472
486
  + "listed: drafts, pending, private, scheduled and password-protected items; custom post types; comments; the WordPress front "
473
487
  + "page (Taproot seeds its own home page: ask the owner what belongs there).",
488
+ "Place reviews. A post with a rating and a place becomes a place review when the place can be found: run "
489
+ + "'places search <name and city>', and if one prediction is plainly the same place, 'places select "
490
+ + "<googlePlaceId> <sessionToken>' gives the placeId for its front matter (rating: will-return, might-return or "
491
+ + "wont-return, mapped from the post's own scale). When nothing matches, import it as an article and list it. A travel "
492
+ + "or food article, or an album, about one place can carry place: <placeId> in its front matter.",
474
493
  "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
494
  + "page's is its post name under its parents. Keep the original publication date in displayDate (the date "
476
495
  + "part of wp:post_date). Categories beyond the first, and all tags, have no Taproot equivalent (tags are "
@@ -488,13 +507,14 @@ const WORKFLOW_REFERENCES = Object.freeze({
488
507
  + "album when they are the post), links to content you did not import (keep the absolute old link), and images "
489
508
  + "on other hosts. Every item goes in the account with its page and what happened to it.",
490
509
  "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>').",
510
+ + "the -300x200 thumbnail WordPress also stores), then run 'media upload' once for the whole media/ directory (at most 500 "
511
+ + "files a run). It records every file in .taproot-site-media.json and reports counts and a bounded item list; "
512
+ + "a run that stopped partway is safe to repeat, because identical files deduplicate.",
513
+ "Check before sending. 'validate' (no credential, no request) checks every page source, media reference and "
514
+ + "setting in the workspace at once and lists every problem in error.problems with its file, code and field. "
515
+ + "'pages push --dry-run' then checks the pages against the live site — paths taken, templates, revisions, "
516
+ + "videos — and sends nothing. 'plan' checks the whole workspace against the site and orders what is left "
517
+ + "(media, pages, footer, theme, navigation); 'apply --plan <planHash>' runs exactly that plan.",
498
518
  "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
519
  + "manifest; a custom link is an EXTERNAL_URL item; a category or tag item has no target, so list it).",
500
520
  "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 +588,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
568
588
  `Pull the baseline: ${CLI_BINARY_NAME} pull writes settings/taproot-styles.json with both schemes' complete effective `
569
589
  + "themes (the stored theme resolved over the same defaults every consumer renders) beside brand.json, "
570
590
  + "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. "
591
+ + "a theme from memory or from an example. semanticMappings holds only authored pins, listed in explicitMappingTokens "
592
+ + "at the root; a context's pins need no marker. "
572
593
  + "site-publishing-preferences.json also carries timeZone, the site's IANA zone (UTC until the owner sets it), "
573
594
  + "which decides what \"today\" means for relative form date bounds; it is read-only here, so ask the owner to set "
574
595
  + "it in the editor under Site settings.",
@@ -579,10 +600,16 @@ const WORKFLOW_REFERENCES = Object.freeze({
579
600
  "Prepare media locally: PNG, JPEG, GIF and WebP raster files only, addressed relative to the workspace root. Make "
580
601
  + "the variants yourself with any image tool (a transparent logo, a @2x copy, a square favicon); there is no "
581
602
  + "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).",
603
+ + "(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.",
604
+ `Validate offline: ${CLI_BINARY_NAME} validate checks the whole workspace at once — the complete pair, the `
605
+ + "appearance files, the footer, every page, navigation and redirects — against the contracts the site enforces, "
606
+ + "lists every problem in error.problems, and warns when a semanticMappings pin repeats the default on a token "
607
+ + "your roles would have moved (the pin would keep the role from rendering).",
608
+ `Plan a combined change: when pages, the footer, the theme and navigation all changed, ${CLI_BINARY_NAME} plan `
609
+ + "checks them against the live site and lists the steps in the order they must run, each ready, blocked or "
610
+ + `nothing to do; ${CLI_BINARY_NAME} apply --plan <planHash> runs them through the ordinary push verbs and refuses `
611
+ + "a plan the workspace or the site has moved past. After a failed step, plan again: finished steps read as "
612
+ + "nothing to do.",
586
613
  `Push: ${CLI_BINARY_NAME} theme push --dry-run first: it reads the site and lists the JSON paths at which each `
587
614
  + "settings file differs from it, and says whether the baseline pull recorded is still current. Then "
588
615
  + `${CLI_BINARY_NAME} theme push saves both themes, the appearance scalars and the ten footer scheme colours in `
@@ -829,22 +856,23 @@ const WORKFLOW_REFERENCES = Object.freeze({
829
856
  }),
830
857
  media: Object.freeze({
831
858
  title: "Media upload contract",
832
- summary: "Upload workspace-root-relative raster files and videos and receive component-ready media objects and video ids.",
859
+ summary: "Upload workspace-root-relative raster files and videos; images are recorded in .taproot-site-media.json and videos report their ids.",
833
860
  usage: `${CLI_BINARY_NAME} media upload [path...]`,
834
861
  details: Object.freeze([
835
862
  "Paths are relative to the configured workspace root, not the shell's current directory.",
836
863
  "PNG, JPEG, GIF, and WebP are accepted; retina names such as logo@2x.png and logo@3x.png are supported.",
837
864
  "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
865
  "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 }.",
866
+ "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.",
867
+ "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
868
  "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
869
  "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.",
870
+ "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
871
  ]),
845
872
  example: Object.freeze({
846
873
  command: `${CLI_BINARY_NAME} media upload media/logo@2x.png`,
847
- componentMedia: Object.freeze({
874
+ markdownImage: "media/logo@2x.png",
875
+ pmJsonImageRecord: Object.freeze({
848
876
  imageId: "<image-uuid>",
849
877
  src: "<preferred-delivery-url>",
850
878
  urls: Object.freeze([Object.freeze({ minWidth: 640, url: "<responsive-delivery-url>" })]),
@@ -872,6 +900,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
872
900
  + "(staging.host_unavailable, staging.authority_denied, staging.surface_refused, or staging.handoff_unavailable) "
873
901
  + "and a recovery step; run staging review to mint another (it works on every surface). Never verify a theme "
874
902
  + "on production instead.",
903
+ "A preview is served on the site's staging address: its own address under Taproot's user-content domain "
904
+ + "(ontaproot.io in production), or its customer staging domain once that is active. The address has to be "
905
+ + "live, so a site that has never deployed to staging, or whose staging host is changing, answers "
906
+ + "preview.staging_unavailable; run 'deploy --staging' once, or wait for the change to finish.",
875
907
  "A page path resolves through .taproot-site-manifest.json; a canonical page UUID works directly.",
876
908
  "The homepage's manifest path is empty; address it as '/', which resolves to that empty root path.",
877
909
  "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 +921,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
889
921
  summary:
890
922
  `${FIXTURE_MANIFEST_FILE_NAME} binds a directory laid out like a pulled workspace to deterministic identities, `
891
923
  + "so validate can prove it with no credential, no network, and no write.",
892
- usage: `${CLI_BINARY_NAME} validate <fixture-directory>`,
924
+ usage: `${CLI_BINARY_NAME} validate [<fixture-directory>]`,
893
925
  details: Object.freeze([
894
926
  `Required root fields: ${FIXTURE_REQUIRED_ROOT_FIELDS.join(", ")}.`,
895
927
  `Optional root fields: ${FIXTURE_OPTIONAL_ROOT_FIELDS.join(", ")}. Appearance and footer metadata, when present, `
@@ -928,6 +960,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
928
960
  }.`,
929
961
  `The fixture block carries ${FIXTURE_METADATA_FIELDS.join(", ")}. Every image a page references by imageId must `
930
962
  + "be listed in fixture.imageIds, and every absolute delivery URL a page uses must sit on a declared origin. "
963
+ + "A Markdown media path resolves through the fixture's own .taproot-site-media.json, as in a workspace; "
964
+ + "without one, no media path resolves. "
931
965
  + "videoIds is optional: list the ready videos a page may place with a video component, and validate refuses a "
932
966
  + "video component whose videoId is not listed. A live push makes the same check against the site's video library.",
933
967
  `Each deliveryOrigins entry is an origin-only HTTPS URL (no path, query, fragment, or credentials) on `
@@ -1011,11 +1045,15 @@ export function listComponentTypeReferences() {
1011
1045
  return COMPONENT_TYPES.map(componentSummary);
1012
1046
  }
1013
1047
 
1048
+ const EXAMPLE_MEDIA_PATH = "media/photo.webp";
1049
+
1014
1050
  export function getComponentReference(type) {
1015
1051
  const definition = getComponentDefinition(type);
1016
1052
  if (!definition) return undefined;
1017
1053
  const example = definition.example;
1018
- const markdownExample = `\`\`\`component:${type}\n${JSON.stringify(example, null, 2)}\n\`\`\``;
1054
+ // Markdown names each image by its media path; pages push resolves it (TR01187).
1055
+ const markdownData = mapComponentImages(type, example, (value) => (value === null ? null : EXAMPLE_MEDIA_PATH));
1056
+ const markdownExample = `\`\`\`component:${type}\n${JSON.stringify(markdownData, null, 2)}\n\`\`\``;
1019
1057
  return {
1020
1058
  type,
1021
1059
  displayName: definition.displayName,
@@ -1150,12 +1188,14 @@ function backgroundReference() {
1150
1188
  }),
1151
1189
  scrimOpacityByValue: Object.freeze({ ...BACKGROUND_DEFINITION.fields.scrimStrength.opacityByValue }),
1152
1190
  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.",
1191
+ "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
1192
  compactBehavior:
1155
1193
  "At the shared compact breakpoint, portraitImage and portraitFocus replace the landscape image and focus when supplied.",
1156
1194
  example: BACKGROUND_EXAMPLE,
1157
1195
  markdownExample: `:::section ${
1158
- JSON.stringify({ background: BACKGROUND_EXAMPLE })
1196
+ JSON.stringify({
1197
+ background: { ...BACKGROUND_EXAMPLE, image: "media/class.webp", portraitImage: "media/class-portrait.webp" },
1198
+ })
1159
1199
  }\n## Practice with us\n\nFind your next class.\n:::`,
1160
1200
  });
1161
1201
  }
@@ -1185,11 +1225,11 @@ function decorationReference() {
1185
1225
  additionalProperties: false,
1186
1226
  fields: sectionAttributeReference().find((attr) => attr.name === "decoration").fields,
1187
1227
  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.",
1228
+ "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
1229
  tintTokens: Object.freeze({ ...DECORATION_DEFINITION.fields.tint.tokenByValue }),
1190
1230
  example: DECORATION_EXAMPLE,
1191
1231
  markdownExample: `:::section ${
1192
- JSON.stringify({ decoration: DECORATION_EXAMPLE })
1232
+ JSON.stringify({ decoration: { ...DECORATION_EXAMPLE, image: "media/starburst.webp" } })
1193
1233
  }\n## Rooted in warmth\n\nMove with confidence.\n:::`,
1194
1234
  maskGuidance:
1195
1235
  "Use a transparent PNG or WebP whose alpha channel contains only the mark. The active section context supplies the tint in both schemes.",
@@ -1556,6 +1596,25 @@ const FREE_FORM_REFERENCE = Object.freeze({
1556
1596
  root: Object.freeze({ type: "doc", content: "array of supported block nodes" }),
1557
1597
  nodes: NODE_TYPES,
1558
1598
  marks: MARK_TYPES,
1599
+ // TR01190: what a link mark's rel and target publish as.
1600
+ links: Object.freeze({
1601
+ markdown: '[Shop](https://shop.example){rel="sponsored nofollow" target="_blank"}',
1602
+ rel: LINK_REL_TOKENS,
1603
+ target: LINK_TARGETS,
1604
+ default:
1605
+ "A link with no rel is followed, whether it goes to this site or another. Choose nofollow, sponsored "
1606
+ + "(paid or affiliate), or ugc (written by someone else) for a link that should not pass endorsement; the "
1607
+ + "choice is per link, internal links included.",
1608
+ markdownRule:
1609
+ 'Write {rel="…" target="…"} directly after the link\'s closing parenthesis: either attribute alone or both, '
1610
+ + 'each double-quoted once. rel is space-separated; target="_blank" opens a new tab.',
1611
+ pmJson:
1612
+ 'In .pm.json, a link mark carries attrs.rel (a space-separated string, or null) and attrs.target ("_blank" '
1613
+ + 'opens a new tab and publishes with noopener noreferrer added, "_self" or null keeps the same tab).',
1614
+ refusals:
1615
+ `Any other rel token, opener included, is refused (${CONTENT_ERROR_CODES.linkRel}); so is another target `
1616
+ + `(${CONTENT_ERROR_CODES.linkTarget}). The editor's old default, "noopener noreferrer nofollow", reads as no choice.`,
1617
+ }),
1559
1618
  componentNode: Object.freeze({
1560
1619
  type: "componentBlock",
1561
1620
  attrs: Object.freeze({
@@ -1665,7 +1724,8 @@ function schemaConstraints(schema) {
1665
1724
 
1666
1725
  function formatSchemaLine(name, schema, required, indent) {
1667
1726
  const constraints = schemaConstraints(schema);
1668
- return `${indent}${name.padEnd(Math.max(1, 28 - indent.length))} ${schemaLabel(schema)}; ${
1727
+ const label = schema.markdownType ? "media path | null in Markdown; object | null in .pm.json" : schemaLabel(schema);
1728
+ return `${indent}${name.padEnd(Math.max(1, 28 - indent.length))} ${label}; ${
1669
1729
  required ? "required" : "optional"
1670
1730
  }${constraints.length > 0 ? `; ${constraints.join("; ")}` : ""}`;
1671
1731
  }
@@ -1675,7 +1735,7 @@ function nestedSchemaLines(schema, path, indent = " ") {
1675
1735
  const valuePath = schema.type === "array" ? `${path}[]` : path;
1676
1736
  if (!valueSchema?.properties) return [];
1677
1737
 
1678
- const lines = [];
1738
+ const lines = valueSchema.markdownType ? [`${indent}${valuePath} in a .pm.json document:`] : [];
1679
1739
  for (const property of valueSchema.properties) {
1680
1740
  const propertyPath = `${valuePath}.${property.name}`;
1681
1741
  lines.push(formatSchemaLine(propertyPath, property.schema, property.required, indent));
@@ -1719,9 +1779,11 @@ function formatSectionAttr(attr) {
1719
1779
 
1720
1780
  function formatRegistryField(field, indent = " ") {
1721
1781
  if (field.type === "processed-image") {
1722
- return `${indent}${field.name.padEnd(Math.max(1, 20 - indent.length))} processed-image${
1782
+ return `${indent}${field.name.padEnd(Math.max(1, 20 - indent.length))} media path${
1783
+ field.nullable ? " | null" : ""
1784
+ } in Markdown; processed-image${
1723
1785
  field.nullable ? " | null" : ""
1724
- }; ${field.required ? "required" : `default ${JSON.stringify(field.default)}`}; required keys ${
1786
+ } in .pm.json; ${field.required ? "required" : `default ${JSON.stringify(field.default)}`}; .pm.json required keys ${
1725
1787
  field.requiredKeys.join(", ")
1726
1788
  }; optional keys ${
1727
1789
  field.optionalKeys.join(", ")
@@ -1761,6 +1823,8 @@ function formatDesignRecipe(recipe) {
1761
1823
 
1762
1824
  export function formatReferenceResult(result) {
1763
1825
  switch (result.topic) {
1826
+ case "fonts":
1827
+ return formatFontCatalog(result.fonts);
1764
1828
  case "topics":
1765
1829
  return `Usage: ${CLI_BINARY_NAME} help <topic> [name] [--json]\n\nReference topics:\n${
1766
1830
  result.topics.map((topic) => ` ${topic.usage.padEnd(48)} ${topic.summary}`).join("\n")
@@ -1808,7 +1872,9 @@ export function formatReferenceResult(result) {
1808
1872
  page.document.nodes.join(", ")
1809
1873
  }\nSupported marks: ${
1810
1874
  page.document.marks.join(", ")
1811
- }\n\nTop-level section container:\n${page.sections.example}\n\nSection attributes:\n${
1875
+ }\n\nLinks:\n markdown ${page.document.links.markdown}\n rel ${
1876
+ page.document.links.rel.join(", ")
1877
+ }\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
1878
  page.document.sectionNode.attrs.map(formatSectionAttr).join("\n")
1813
1879
  }\n\nSection photo background (closed object):\n${
1814
1880
  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,