@taprootio/site-authoring 0.8.2 → 0.8.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const CLI_NAME = "@taprootio/site-authoring";
2
- export const CLI_VERSION = "0.8.2";
2
+ export const CLI_VERSION = "0.8.3";
3
3
  export const CLI_BINARY_NAME = "taproot-site";
4
4
  export const RESULT_SCHEMA_VERSION = 1;
5
5
  export const CONFIG_FILE_NAME = "taproot-site.json";
@@ -68,6 +68,16 @@ export interface RenderProseMirrorOptions {
68
68
  /** The page shell emits this default once so rich-text formats inherit it. */
69
69
  maxHeightVh?: TiptapImageMaxHeightVh | number | null;
70
70
  };
71
+ /** Generator-only render hooks for structured nodes that need page context. */
72
+ nodeRenderers?: {
73
+ componentBlock?: (node: ProseMirrorNode) => string | undefined;
74
+ taprootImage?: (node: ProseMirrorNode) => string | undefined;
75
+ rawHtml?: (html: string, node: ProseMirrorNode) => string | undefined;
76
+ };
77
+ /** Records visible document-order content after each node is rendered. */
78
+ onRenderedNode?: (node: ProseMirrorNode, html: string) => void;
79
+ /** Claims the page's one LCP slot before rendering a section photo. */
80
+ claimSectionBackgroundImage?: () => boolean;
71
81
  }
72
82
 
73
83
  export function createEmptyProseMirrorDocument(): ProseMirrorDocument {
@@ -97,11 +107,62 @@ export function renderRichTextBodyToHtml(
97
107
  body: unknown,
98
108
  options: RenderProseMirrorOptions = {},
99
109
  ): string {
100
- if (typeof body === "string") return body;
101
110
  if (!isProseMirrorDocument(body)) return "";
102
111
  return renderProseMirrorDocumentToHtml(body, options);
103
112
  }
104
113
 
114
+ /**
115
+ * Extracts the reader-visible text from a ProseMirror document for metadata
116
+ * that only accepts plain text, such as Schema.org recipe instructions.
117
+ */
118
+ export function extractRichTextBodyText(body: unknown): string {
119
+ if (!isProseMirrorDocument(body)) return "";
120
+ const parts: string[] = [];
121
+ collectText(body, parts);
122
+ return parts.join("").replace(/\s+/gu, " ").trim();
123
+ }
124
+
125
+ function collectText(node: ProseMirrorNode, parts: string[]): void {
126
+ if (node.type === "text") {
127
+ if (node.text) parts.push(node.text);
128
+ return;
129
+ }
130
+
131
+ // rawHtml is an intentional, already-sanitized node in the JSON document
132
+ // contract. It is not the retired outer HTML-body transport. Preserve its
133
+ // reader-visible text when a consumer (for example Recipe JSON-LD) needs a
134
+ // plain-text representation of the document.
135
+ if (node.type === "rawHtml") {
136
+ const html = stringAttr(node.attrs?.html);
137
+ if (html) parts.push(plainTextFromRawHtml(html));
138
+ return;
139
+ }
140
+
141
+ for (const child of node.content ?? []) {
142
+ collectText(child, parts);
143
+ }
144
+
145
+ if (node.type === "hardBreak" || isTextBlock(node.type)) {
146
+ parts.push(" ");
147
+ }
148
+ }
149
+
150
+ function isTextBlock(type: string): boolean {
151
+ return type === "paragraph"
152
+ || type === "heading"
153
+ || type === "listItem"
154
+ || type === "blockquote"
155
+ || type === "codeBlock"
156
+ || type === "tableCell"
157
+ || type === "tableHeader";
158
+ }
159
+
160
+ function plainTextFromRawHtml(html: string): string {
161
+ return html
162
+ .replace(/<(?:br\s*\/?|\/(?:p|div|li|h[1-6]|blockquote|pre|tr|table|section|article))\s*[^>]*>/giu, " ")
163
+ .replace(/<[^>]*>/gu, "");
164
+ }
165
+
105
166
  type RenderPlacement = "root" | "section" | "nested";
106
167
 
107
168
  function renderNodes(
@@ -109,7 +170,11 @@ function renderNodes(
109
170
  options: RenderProseMirrorOptions,
110
171
  placement: RenderPlacement,
111
172
  ): string {
112
- return nodes.map((node) => renderNode(node, options, placement)).join("");
173
+ return nodes.map((node) => {
174
+ const html = renderNode(node, options, placement);
175
+ options.onRenderedNode?.(node, html);
176
+ return html;
177
+ }).join("");
113
178
  }
114
179
 
115
180
  function renderFreeFormRootNodes(
@@ -193,11 +258,11 @@ function renderNode(
193
258
  case "table":
194
259
  return placement === "root" || placement === "section" ? renderTable(node, options) : "";
195
260
  case "taprootImage":
196
- return renderTaprootImage(node, options);
261
+ return options.nodeRenderers?.taprootImage?.(node) ?? renderTaprootImage(node, options);
197
262
  case "inlineFacts":
198
263
  return renderInlineFacts(node, options, placement);
199
264
  case "componentBlock":
200
- return renderComponentBlock(node);
265
+ return options.nodeRenderers?.componentBlock?.(node) ?? renderComponentBlock(node);
201
266
  case "integrationPlacement":
202
267
  return renderIntegrationPlacement(node, options);
203
268
  case "section":
@@ -208,8 +273,10 @@ function renderNode(
208
273
  options,
209
274
  placement === "root" ? "section" : "nested",
210
275
  );
211
- case "rawHtml":
212
- return stringAttr(node.attrs?.html) ?? "";
276
+ case "rawHtml": {
277
+ const html = stringAttr(node.attrs?.html) ?? "";
278
+ return options.nodeRenderers?.rawHtml?.(html, node) ?? html;
279
+ }
213
280
  default:
214
281
  return renderNodes(node.content ?? [], options, "nested");
215
282
  }
@@ -650,6 +717,9 @@ function renderSection(
650
717
  if (decorationStyle) sectionStyleParts.push(...decorationStyle);
651
718
  }
652
719
  const background = kind === "explicit" ? normalizeSectionBackground(attrs.background) : undefined;
720
+ const backgroundPicture = background
721
+ ? renderSectionBackgroundPicture(background, options.claimSectionBackgroundImage?.() === true)
722
+ : "";
653
723
  if (background) {
654
724
  sectionStyleParts.push(
655
725
  `--esp-section-background: color-mix(in oklab, var(--esp-color-background) ${background.scrimOpacityPercent}%, transparent)`,
@@ -695,7 +765,7 @@ function renderSection(
695
765
  style:
696
766
  `--taproot-section-focus: ${background.focus}; --taproot-section-portrait-focus: ${background.portraitFocus}`,
697
767
  },
698
- `${renderSectionBackgroundPicture(background)}${section}`,
768
+ `${backgroundPicture}${section}`,
699
769
  );
700
770
  }
701
771
 
@@ -845,7 +915,7 @@ function normalizeSectionFocus(
845
915
  return `${x * 100}% ${y * 100}%`;
846
916
  }
847
917
 
848
- function renderSectionBackgroundPicture(background: NormalizedSectionBackground): string {
918
+ function renderSectionBackgroundPicture(background: NormalizedSectionBackground, hero: boolean): string {
849
919
  const portraitSource = background.portraitImage
850
920
  ? renderVoidElement("source", {
851
921
  media: "(max-width: 48rem)",
@@ -862,7 +932,8 @@ function renderSectionBackgroundPicture(background: NormalizedSectionBackground)
862
932
  height: background.image.height,
863
933
  alt: "",
864
934
  decoding: "async",
865
- loading: "lazy",
935
+ loading: hero ? undefined : "lazy",
936
+ fetchpriority: hero ? "high" : undefined,
866
937
  });
867
938
  return renderElement(
868
939
  "picture",
@@ -1032,19 +1103,50 @@ function renderComponentBlock(node: ProseMirrorNode): string {
1032
1103
  );
1033
1104
  }
1034
1105
 
1035
- function renderTaprootImage(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
1106
+ export interface ResolvedTiptapImage {
1107
+ src: string;
1108
+ urls: Array<{ minWidth: number; url: string; type?: string }>;
1109
+ width: number;
1110
+ height: number;
1111
+ alt: string;
1112
+ imageId: string;
1113
+ preserveStoredSrc: boolean;
1114
+ extraAttributes: Record<string, string | number | undefined>;
1115
+ }
1116
+
1117
+ export function resolveTiptapImage(
1118
+ node: ProseMirrorNode,
1119
+ imageDefaults: RenderProseMirrorOptions["imageDefaults"],
1120
+ ): ResolvedTiptapImage {
1036
1121
  const attrs = node.attrs ?? {};
1037
- const resolved = resolveImagePresentation(attrs, options.imageDefaults);
1122
+ const crop = jsonAttr(attrs.crop);
1123
+ const resolved = resolveImagePresentation(attrs, imageDefaults);
1124
+ return {
1125
+ src: stringAttr(attrs.src) ?? "",
1126
+ urls: validResponsiveImageUrls(attrs.urls),
1127
+ width: numberAttr(attrs.width) ?? 0,
1128
+ height: numberAttr(attrs.height) ?? 0,
1129
+ alt: stringAttr(attrs.alt) ?? "",
1130
+ imageId: stringAttr(attrs.imageId) ?? "",
1131
+ preserveStoredSrc: Boolean(crop && crop !== "null"),
1132
+ extraAttributes: {
1133
+ ...(crop ? { "data-crop": crop } : {}),
1134
+ ...resolved,
1135
+ },
1136
+ };
1137
+ }
1138
+
1139
+ function renderTaprootImage(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
1140
+ const image = resolveTiptapImage(node, options.imageDefaults);
1038
1141
  const imageAttrs: Record<string, string | number | boolean | null | undefined> = {
1039
- "low-res": stringAttr(attrs.src),
1040
- "original-width": numberAttr(attrs.width),
1041
- "original-height": numberAttr(attrs.height),
1042
- "data-image-id": stringAttr(attrs.imageId),
1043
- "data-crop": jsonAttr(attrs.crop),
1044
- caption: stringAttr(attrs.alt),
1045
- ...resolved,
1142
+ "low-res": image.src,
1143
+ "original-width": image.width,
1144
+ "original-height": image.height,
1145
+ "data-image-id": image.imageId,
1146
+ caption: image.alt,
1147
+ ...image.extraAttributes,
1046
1148
  };
1047
- const optionsHtml = validResponsiveImageUrls(attrs.urls)
1149
+ const optionsHtml = image.urls
1048
1150
  .map((url) =>
1049
1151
  renderVoidElement("esp-image-option", {
1050
1152
  width: url.minWidth,