UNPKG

@ryhrm-gz/xincodo-lib

Version:

Utilities for working with Xincodo body documents.

1,522 lines (1,521 loc) 48.7 kB
import * as v from "valibot"; //#region src/constants.ts const BODY_VERSION = 1; const TEXT_COLORS = [ "default", "gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red" ]; const BODY_BLOCK_TYPES = [ "text", "heading_1", "heading_2", "heading_3", "heading_4", "bulleted_list", "numbered_list", "toggle_list", "callout", "quote", "table", "divider", "page_link", "gallery", "image" ]; const HEADING_LEVELS = [ 1, 2, 3, 4 ]; //#endregion //#region src/builders.ts function createBody(content) { return { version: 1, content }; } function text(value, options = {}) { return { type: "text", text: value, ...options }; } function lineBreak() { return { type: "line_break" }; } function richText(...content) { return normalizeRichText(content); } function paragraph(...contentAndOptions) { const { content, options } = extractRichTextOptions(contentAndOptions); return { type: "text", ...options, richText: normalizeRichText(content) }; } function heading(level, ...contentAndOptions) { const { content, options } = extractRichTextOptions(contentAndOptions); const richText = normalizeRichText(content); switch (level) { case 1: return { type: "heading_1", ...options, richText }; case 2: return { type: "heading_2", ...options, richText }; case 3: return { type: "heading_3", ...options, richText }; case 4: return { type: "heading_4", ...options, richText }; } } function listItem(...contentAndOptions) { const { content, options } = extractRichTextOptions(contentAndOptions); return { ...options, richText: normalizeRichText(content) }; } function toggleListItem(...contentAndOptions) { const { content, options } = extractRichTextOptions(contentAndOptions); return { ...options, richText: normalizeRichText(content) }; } function bulletedList(items, options = {}) { return { type: "bulleted_list", ...options, items }; } function numberedList(items, options = {}) { return { type: "numbered_list", ...options, items }; } function toggleList(items, options = {}) { return { type: "toggle_list", ...options, items }; } function callout(...contentAndOptions) { const { content, options } = extractRichTextOptions(contentAndOptions); return { type: "callout", ...options, richText: normalizeRichText(content) }; } function quote(...contentAndOptions) { const { content, options } = extractRichTextOptions(contentAndOptions); return { type: "quote", ...options, richText: normalizeRichText(content) }; } function tableCell(...content) { return { richText: normalizeRichText(content) }; } function tableRow(cells) { return { cells }; } function table(rows, options = {}) { return { type: "table", ...options, rows }; } function divider(options = {}) { return { type: "divider", ...options }; } function pageLink(page, options = {}) { return { type: "page_link", ...options, page }; } function galleryImage(source, options = {}) { return { ...options, source }; } function gallery(images, options = {}) { return { type: "gallery", ...options, images }; } function image(source, options = {}) { return { type: "image", ...options, source }; } function normalizeRichText(content) { return content.map((item) => typeof item === "string" ? text(item) : item); } function extractRichTextOptions(contentAndOptions) { const content = [...contentAndOptions]; const last = content.at(-1); if (isBuilderOptions(last)) { content.pop(); return { content, options: last }; } return { content, options: void 0 }; } function isBuilderOptions(value) { return typeof value === "object" && value !== null && !("type" in value); } //#endregion //#region src/traversal.ts function walkBody(input, visitor) { if (isBlockArray(input)) { walkBlocks(input, visitor, void 0, [{ key: "content" }], 0); return; } if (isBody$1(input)) { walkBlocks(input.content, visitor, input, [{ key: "content" }], 0); return; } walkBlock(input, visitor, void 0, [], void 0, 0); } function findBlock(input, predicate) { let found; walkBody(input, { block(block, context) { if (found) return false; if (predicate(block, context)) { found = block; return false; } } }); return found; } function filterBlocks(input, predicate) { const blocks = []; walkBody(input, { block(block, context) { if (predicate(block, context)) blocks.push(block); } }); return blocks; } function mapBody(input, mapper) { if (isBlockArray(input)) return mapBlocks(input, mapper, void 0, [{ key: "content" }], 0); if (isBody$1(input)) return { ...input, content: mapBlocks(input.content, mapper, input, [{ key: "content" }], 0) }; return mapBlock(input, mapper, void 0, [], void 0, 0); } function walkBlocks(blocks, visitor, parent, path, depth) { blocks.forEach((block, index) => { walkBlock(block, visitor, parent, withIndex(path, index), index, depth); }); } function walkBlock(block, visitor, parent, path, index, depth) { if (!(visitor.block?.(block, { path, parent, index, depth }) !== false)) return; switch (block.type) { case "text": walkRichText(block.richText, visitor, block, "richText", [...path, { key: "richText" }], depth); walkChildBlocks(block.children, visitor, block, path, depth); break; case "heading_1": case "heading_2": case "heading_3": case "heading_4": walkRichText(block.richText, visitor, block, "richText", [...path, { key: "richText" }], depth); break; case "bulleted_list": case "numbered_list": case "toggle_list": block.items.forEach((item, itemIndex) => { const itemPath = appendPath(path, { key: "items", index: itemIndex }); if (!(visitor.listItem?.(item, { path: itemPath, parent: block, index: itemIndex, depth }) !== false)) return; walkRichText(item.richText, visitor, item, "richText", appendPath(itemPath, { key: "richText" }), depth); walkChildBlocks(item.children, visitor, item, itemPath, depth); }); break; case "callout": case "quote": walkRichText(block.richText, visitor, block, "richText", [...path, { key: "richText" }], depth); walkChildBlocks(block.children, visitor, block, path, depth); break; case "table": block.rows.forEach((row, rowIndex) => { row.cells.forEach((cell, cellIndex) => { const cellPath = appendPath(appendPath(path, { key: "rows", index: rowIndex }), { key: "cells", index: cellIndex }); if (visitor.tableCell?.(cell, { path: cellPath, parent: block, rowIndex, cellIndex, depth }) !== false) walkRichText(cell.richText, visitor, cell, "richText", appendPath(cellPath, { key: "richText" }), depth); }); }); break; case "divider": break; case "page_link": if (block.title) walkRichText(block.title, visitor, block, "title", [...path, { key: "title" }], depth); break; case "gallery": if (block.caption) walkRichText(block.caption, visitor, block, "caption", [...path, { key: "caption" }], depth); block.images.forEach((image, imageIndex) => { const imagePath = appendPath(path, { key: "images", index: imageIndex }); if (visitor.galleryImage?.(image, { path: imagePath, parent: block, index: imageIndex, depth }) !== false && image.caption) walkRichText(image.caption, visitor, image, "caption", appendPath(imagePath, { key: "caption" }), depth); }); break; case "image": if (block.caption) walkRichText(block.caption, visitor, block, "caption", [...path, { key: "caption" }], depth); break; } } function walkChildBlocks(children, visitor, parent, path, depth) { if (!children) return; walkBlocks(children, visitor, parent, [...path, { key: "children" }], depth + 1); } function walkRichText(richText, visitor, parent, field, path, depth) { const context = { path, parent, field, depth }; if (!(visitor.richText?.(richText, context) !== false)) return; richText.forEach((inline, index) => { visitor.inline?.(inline, { ...context, path: withIndex(path, index), index }); }); } function mapBlocks(blocks, mapper, parent, path, depth) { return blocks.map((block, index) => mapBlock(block, mapper, parent, withIndex(path, index), index, depth)); } function mapBlock(block, mapper, parent, path, index, depth) { return mapper(mapBlockChildren(block, mapper, path, depth), { path, parent, index, depth }); } function mapBlockChildren(block, mapper, path, depth) { switch (block.type) { case "text": return block.children ? { ...block, children: mapBlocks(block.children, mapper, block, [...path, { key: "children" }], depth + 1) } : block; case "bulleted_list": case "numbered_list": return { ...block, items: block.items.map((item, itemIndex) => mapListItem(item, mapper, [...path, { key: "items", index: itemIndex }], depth)) }; case "toggle_list": return { ...block, items: block.items.map((item, itemIndex) => mapToggleListItem(item, mapper, [...path, { key: "items", index: itemIndex }], depth)) }; case "callout": case "quote": return block.children ? { ...block, children: mapBlocks(block.children, mapper, block, [...path, { key: "children" }], depth + 1) } : block; case "heading_1": case "heading_2": case "heading_3": case "heading_4": case "table": case "divider": case "page_link": case "gallery": case "image": return block; } } function mapListItem(item, mapper, path, depth) { return item.children ? { ...item, children: mapBlocks(item.children, mapper, item, [...path, { key: "children" }], depth + 1) } : item; } function mapToggleListItem(item, mapper, path, depth) { return item.children ? { ...item, children: mapBlocks(item.children, mapper, item, [...path, { key: "children" }], depth + 1) } : item; } function withIndex(path, index) { const last = path.at(-1); if (!last || last.index !== void 0) return [...path, { key: "content", index }]; return [...path.slice(0, -1), { ...last, index }]; } function appendPath(path, segment) { return [...path, segment]; } function isBlockArray(input) { return Array.isArray(input); } function isBody$1(input) { return "version" in input && "content" in input; } //#endregion //#region src/editing.ts var BodyEditError = class extends Error { constructor(message) { super(message); this.name = "BodyEditError"; } }; function findBlockPathById(input, id) { let foundPath; walkBody(input, { block(block, context) { if (block.id === id) { foundPath = [...context.path]; return false; } } }); return foundPath; } function getBlockAtPath(input, path) { let foundBlock; walkBody(input, { block(block, context) { if (isSamePath(context.path, path)) { foundBlock = block; return false; } } }); return foundBlock; } function updateBlockAtPath(body, path, updater) { const location = resolveBlockLocation(path); return updateBlockCollection(body, location.collectionPath, (blocks) => replaceAtIndex(blocks, location.index, (block) => updater(block))); } function replaceBlockAtPath(body, path, block) { return updateBlockAtPath(body, path, () => block); } function removeBlockAtPath(body, path) { const location = resolveBlockLocation(path); return updateBlockCollection(body, location.collectionPath, (blocks) => { assertIndex(blocks, location.index, "block"); return blocks.filter((_, index) => index !== location.index); }); } function insertBlockAtRoot(body, block, options = {}) { const index = options.index ?? body.content.length; assertInsertIndex(body.content, index, "block"); return { ...body, content: [ ...body.content.slice(0, index), block, ...body.content.slice(index) ] }; } function insertBlockAtPath(body, path, block, options = {}) { const location = resolveBlockLocation(path); const position = options.position ?? "after"; return updateBlockCollection(body, location.collectionPath, (blocks) => { assertIndex(blocks, location.index, "block"); const insertIndex = position === "before" ? location.index : location.index + 1; return [ ...blocks.slice(0, insertIndex), block, ...blocks.slice(insertIndex) ]; }); } function moveBlock(body, fromPath, toPath, options = {}) { if (isSamePath(fromPath, toPath)) return body; if (isDescendantPath(toPath, fromPath)) throw new BodyEditError("Cannot move a block relative to its own descendant."); const block = getBlockAtPath(body, fromPath); if (!block) throw new BodyEditError(`No block found at ${formatPath$1(fromPath)}.`); const adjustedToPath = adjustBlockPathAfterRemoval(toPath, fromPath); if (!adjustedToPath) return body; return insertBlockAtPath(removeBlockAtPath(body, fromPath), adjustedToPath, block, options); } function updateRichTextAtPath(body, path, updater) { const fieldPath = normalizeRichTextPath(path); if (fieldPath.length === 0) throw new BodyEditError("Rich text path cannot be empty."); const [firstSegment, ...remainingSegments] = fieldPath; if (firstSegment?.key !== "content" || firstSegment.index === void 0) throw new BodyEditError(`Invalid rich text path ${formatPath$1(path)}.`); return { ...body, content: replaceAtIndex(body.content, firstSegment.index, (block) => updateBlockRichTextAtPath(block, remainingSegments, updater)) }; } function resolveBlockLocation(path) { const lastSegment = path.at(-1); if (!lastSegment || lastSegment.key !== "content" && lastSegment.key !== "children") throw new BodyEditError(`Path ${formatPath$1(path)} does not point to a block.`); if (lastSegment.index === void 0) throw new BodyEditError(`Path ${formatPath$1(path)} does not include a block index.`); return { collectionPath: [...path.slice(0, -1), { key: lastSegment.key }], index: lastSegment.index }; } function updateBlockCollection(body, collectionPath, updater) { const [firstSegment, ...remainingSegments] = collectionPath; if (collectionPath.length === 1 && firstSegment?.key === "content" && firstSegment.index === void 0) return { ...body, content: updater([...body.content]) }; if (firstSegment?.key !== "content" || firstSegment.index === void 0) throw new BodyEditError(`Invalid block collection path ${formatPath$1(collectionPath)}.`); return { ...body, content: replaceAtIndex(body.content, firstSegment.index, (block) => updateBlockCollectionInsideBlock(block, remainingSegments, updater)) }; } function updateBlockCollectionInsideBlock(block, path, updater) { const [segment, ...remainingSegments] = path; if (!segment) throw new BodyEditError("Block collection path ended before reaching a collection."); switch (segment.key) { case "children": return updateChildrenCollection(block, segment, remainingSegments, updater); case "items": return updateListItemCollection(block, segment, remainingSegments, updater); default: throw new BodyEditError(`Cannot reach a block collection through "${segment.key}".`); } } function updateChildrenCollection(block, segment, remainingSegments, updater) { if (!hasChildren(block)) throw new BodyEditError(`${block.type} blocks do not contain child blocks.`); if (segment.index === void 0) { if (remainingSegments.length > 0) throw new BodyEditError("Child block collection path is missing a block index."); return { ...block, children: updater([...block.children ?? []]) }; } return { ...block, children: replaceAtIndex(block.children ?? [], segment.index, (childBlock) => updateBlockCollectionInsideBlock(childBlock, remainingSegments, updater)) }; } function updateListItemCollection(block, segment, remainingSegments, updater) { if (!isListBlock(block)) throw new BodyEditError(`${block.type} blocks do not contain list items.`); if (segment.index === void 0) throw new BodyEditError("List item path is missing an item index."); switch (block.type) { case "bulleted_list": case "numbered_list": return { ...block, items: replaceAtIndex(block.items, segment.index, (item) => updateBlockCollectionInsideListItem(item, remainingSegments, updater)) }; case "toggle_list": return { ...block, items: replaceAtIndex(block.items, segment.index, (item) => updateBlockCollectionInsideListItem(item, remainingSegments, updater)) }; } } function updateBlockCollectionInsideListItem(item, path, updater) { const [segment, ...remainingSegments] = path; if (segment?.key !== "children") throw new BodyEditError("List item block collection paths must continue through children."); if (segment.index === void 0) { if (remainingSegments.length > 0) throw new BodyEditError("List item child collection path is missing a block index."); return { ...item, children: updater([...item.children ?? []]) }; } return { ...item, children: replaceAtIndex(item.children ?? [], segment.index, (block) => updateBlockCollectionInsideBlock(block, remainingSegments, updater)) }; } function updateBlockRichTextAtPath(block, path, updater) { const [segment, ...remainingSegments] = path; if (!segment) throw new BodyEditError("Rich text path ended before reaching a field."); if (remainingSegments.length === 0 && isRichTextField(segment.key)) return updateBlockRichTextField(block, segment.key, updater); switch (segment.key) { case "children": if (!hasChildren(block) || segment.index === void 0) throw new BodyEditError(`Invalid children path for ${block.type}.`); return { ...block, children: replaceAtIndex(block.children ?? [], segment.index, (childBlock) => updateBlockRichTextAtPath(childBlock, remainingSegments, updater)) }; case "items": return updateListBlockItemRichTextAtPath(block, segment, remainingSegments, updater); case "rows": return updateTableRichTextAtPath(block, segment, remainingSegments, updater); case "images": return updateGalleryBlockImageRichTextAtPath(block, segment, remainingSegments, updater); default: throw new BodyEditError(`Cannot reach rich text through "${segment.key}".`); } } function updateBlockRichTextField(block, field, updater) { switch (field) { case "richText": if (!hasRichText(block)) throw new BodyEditError(`${block.type} blocks do not contain richText.`); return { ...block, richText: updater([...block.richText]) }; case "caption": if (!hasCaption(block)) throw new BodyEditError(`${block.type} blocks do not contain a caption.`); return { ...block, caption: updater([...block.caption ?? []]) }; case "title": if (block.type !== "page_link") throw new BodyEditError(`${block.type} blocks do not contain a title.`); return { ...block, title: updater([...block.title ?? []]) }; } } function updateListBlockItemRichTextAtPath(block, segment, remainingSegments, updater) { if (!isListBlock(block) || segment.index === void 0) throw new BodyEditError(`Invalid list item path for ${block.type}.`); switch (block.type) { case "bulleted_list": case "numbered_list": return { ...block, items: replaceAtIndex(block.items, segment.index, (item) => updateListItemRichTextAtPath(item, remainingSegments, updater)) }; case "toggle_list": return { ...block, items: replaceAtIndex(block.items, segment.index, (item) => updateListItemRichTextAtPath(item, remainingSegments, updater)) }; } } function updateListItemRichTextAtPath(item, path, updater) { const [segment, ...remainingSegments] = path; if (!segment) throw new BodyEditError("List item rich text path ended before reaching a field."); if (segment.key === "richText" && remainingSegments.length === 0) return { ...item, richText: updater([...item.richText]) }; if (segment.key !== "children" || segment.index === void 0) throw new BodyEditError("List item rich text paths must continue through richText or children."); return { ...item, children: replaceAtIndex(item.children ?? [], segment.index, (block) => updateBlockRichTextAtPath(block, remainingSegments, updater)) }; } function updateTableRichTextAtPath(block, rowSegment, remainingSegments, updater) { if (block.type !== "table" || rowSegment.index === void 0) throw new BodyEditError(`Invalid table row path for ${block.type}.`); return { ...block, rows: replaceAtIndex(block.rows, rowSegment.index, (row) => updateTableRowRichTextAtPath(row, remainingSegments, updater)) }; } function updateTableRowRichTextAtPath(row, path, updater) { const [cellSegment, richTextSegment] = path; if (path.length !== 2 || cellSegment?.key !== "cells" || cellSegment.index === void 0 || richTextSegment?.key !== "richText") throw new BodyEditError("Table rich text paths must continue through cells and richText."); return { ...row, cells: replaceAtIndex(row.cells, cellSegment.index, (cell) => updateTableCellRichText(cell, updater)) }; } function updateTableCellRichText(cell, updater) { return { ...cell, richText: updater([...cell.richText]) }; } function updateGalleryBlockImageRichTextAtPath(block, imageSegment, remainingSegments, updater) { if (block.type !== "gallery" || imageSegment.index === void 0) throw new BodyEditError(`Invalid gallery image path for ${block.type}.`); return { ...block, images: replaceAtIndex(block.images, imageSegment.index, (image) => updateGalleryImageCaptionAtPath(image, remainingSegments, updater)) }; } function updateGalleryImageCaptionAtPath(image, path, updater) { const [captionSegment] = path; if (path.length !== 1 || captionSegment?.key !== "caption") throw new BodyEditError("Gallery image rich text paths must end at caption."); return { ...image, caption: updater([...image.caption ?? []]) }; } function adjustBlockPathAfterRemoval(targetPath, removedPath) { const targetLocation = resolveBlockLocation(targetPath); const removedLocation = resolveBlockLocation(removedPath); if (!isSamePath(targetLocation.collectionPath, removedLocation.collectionPath)) return targetPath; if (targetLocation.index === removedLocation.index) return; if (targetLocation.index < removedLocation.index) return targetPath; const lastSegment = targetPath.at(-1); if (!lastSegment) return targetPath; return [...targetPath.slice(0, -1), { ...lastSegment, index: targetLocation.index - 1 }]; } function normalizeRichTextPath(path) { const lastSegment = path.at(-1); if (!lastSegment || !isRichTextField(lastSegment.key)) throw new BodyEditError(`Path ${formatPath$1(path)} does not point to rich text.`); return [...path.slice(0, -1), { key: lastSegment.key }]; } function replaceAtIndex(items, index, updater) { assertIndex(items, index, "item"); return items.map((item, itemIndex) => itemIndex === index ? updater(item) : item); } function assertIndex(items, index, label) { if (!Number.isInteger(index) || index < 0 || index >= items.length) throw new BodyEditError(`No ${label} exists at index ${index}.`); } function assertInsertIndex(items, index, label) { if (!Number.isInteger(index) || index < 0 || index > items.length) throw new BodyEditError(`Cannot insert ${label} at index ${index}.`); } function isSamePath(left, right) { return left.length === right.length && left.every((segment, index) => { const rightSegment = right[index]; return segment.key === rightSegment?.key && segment.index === rightSegment.index; }); } function isDescendantPath(path, ancestorPath) { return path.length > ancestorPath.length && isSamePath(path.slice(0, ancestorPath.length), ancestorPath); } function formatPath$1(path) { if (path.length === 0) return "<root>"; return path.map((segment) => segment.index === void 0 ? segment.key : `${segment.key}[${segment.index}]`).join("."); } function hasChildren(block) { return block.type === "text" || block.type === "callout" || block.type === "quote"; } function isListBlock(block) { return block.type === "bulleted_list" || block.type === "numbered_list" || block.type === "toggle_list"; } function hasRichText(block) { return block.type === "text" || block.type === "heading_1" || block.type === "heading_2" || block.type === "heading_3" || block.type === "heading_4" || block.type === "callout" || block.type === "quote"; } function hasCaption(block) { return block.type === "gallery" || block.type === "image"; } function isRichTextField(key) { return key === "richText" || key === "caption" || key === "title"; } //#endregion //#region src/utils.ts function toPlainText(input) { if (isPlainTextArray(input)) return isRichTextArray(input) ? richTextToPlainText(input) : blocksToPlainText(input); if (isBody(input)) return blocksToPlainText(input.content); if (isRichText(input)) return richTextToPlainText([input]); return blockToPlainText(input); } function richTextToPlainText(richText) { return richText.map((inline) => { switch (inline.type) { case "text": return inline.text; case "line_break": return "\n"; } }).join(""); } function blocksToPlainText(blocks) { return joinLines(blocks.map(blockToPlainText)); } function blockToPlainText(block) { switch (block.type) { case "text": case "callout": case "quote": return joinLines([richTextToPlainText(block.richText), blocksToPlainText(block.children ?? [])]); case "heading_1": case "heading_2": case "heading_3": case "heading_4": return richTextToPlainText(block.richText); case "bulleted_list": return joinLines(block.items.map((item) => prefixFirstLine("- ", listItemToPlainText(item)))); case "numbered_list": { const start = block.start ?? 1; return joinLines(block.items.map((item, index) => prefixFirstLine(`${start + index}. `, listItemToPlainText(item)))); } case "toggle_list": return joinLines(block.items.map((item) => prefixFirstLine("- ", listItemToPlainText(item)))); case "table": return block.rows.map((row) => row.cells.map((cell) => richTextToPlainText(cell.richText)).join(" ")).join("\n"); case "divider": return ""; case "page_link": return block.title ? richTextToPlainText(block.title) : pageReferenceToPlainText(block.page); case "gallery": return joinLines([block.caption ? richTextToPlainText(block.caption) : "", ...block.images.map(galleryImageToPlainText)]); case "image": return block.caption ? richTextToPlainText(block.caption) : block.alt ?? ""; } } function listItemToPlainText(item) { return joinLines([richTextToPlainText(item.richText), blocksToPlainText(item.children ?? [])]); } function galleryImageToPlainText(image) { return image.caption ? richTextToPlainText(image.caption) : image.alt ?? ""; } function pageReferenceToPlainText(page) { switch (page.type) { case "page_id": return page.pageId; case "url": return page.url; } } function prefixFirstLine(prefix, value) { if (value.length === 0) return prefix.trimEnd(); return `${prefix}${value}`; } function joinLines(values) { return values.filter((value) => value.length > 0).join("\n"); } function isBody(input) { return "version" in input && "content" in input; } function isRichText(input) { return "type" in input && (input.type === "line_break" || input.type === "text" && "text" in input); } function isPlainTextArray(input) { return Array.isArray(input); } function isRichTextArray(input) { return input.every(isRichText); } //#endregion //#region src/lint.ts const defaultOptions = { requireImageAlt: true, validateUrls: true }; function lintBody(input, options = {}) { const resolvedOptions = { ...defaultOptions, ...options }; const issues = []; const seenIds = /* @__PURE__ */ new Map(); let previousHeadingLevel; function addIssue(issue) { issues.push({ ...issue, path: [...issue.path] }); } function checkId(id, path) { if (!id) return; const firstPath = seenIds.get(id); if (firstPath) { addIssue({ severity: "error", code: "duplicate_id", message: `Duplicate id "${id}". First seen at ${formatPath(firstPath)}.`, path }); return; } seenIds.set(id, [...path]); } walkBody(input, { block(block, context) { checkId(block.id, context.path); if (resolvedOptions.maxDepth !== void 0 && context.depth > resolvedOptions.maxDepth) addIssue({ severity: "warning", code: "max_depth_exceeded", message: `Block depth ${context.depth} exceeds maxDepth ${resolvedOptions.maxDepth}.`, path: context.path }); switch (block.type) { case "text": checkRichTextContent(block.richText, context.path, "info", addIssue); break; case "heading_1": case "heading_2": case "heading_3": case "heading_4": { const level = headingLevel$1(block.type); checkRichTextContent(block.richText, context.path, "warning", addIssue); if (previousHeadingLevel !== void 0 && level - previousHeadingLevel > 1) addIssue({ severity: "warning", code: "heading_level_jump", message: `Heading level jumps from ${previousHeadingLevel} to ${level}.`, path: context.path }); previousHeadingLevel = level; break; } case "bulleted_list": case "toggle_list": if (block.items.length === 0) addIssue({ severity: "info", code: "empty_list", message: `${block.type} has no items.`, path: context.path }); break; case "numbered_list": if (block.items.length === 0) addIssue({ severity: "info", code: "empty_list", message: "numbered_list has no items.", path: context.path }); if (block.start !== void 0 && (!Number.isInteger(block.start) || block.start < 1)) addIssue({ severity: "error", code: "invalid_numbered_list_start", message: "numbered_list.start must be a positive integer.", path: context.path }); break; case "callout": checkRichTextContent(block.richText, context.path, "info", addIssue); if (block.icon?.type === "emoji" && block.icon.emoji.trim().length === 0) addIssue({ severity: "warning", code: "empty_rich_text", message: "Callout emoji icon is empty.", path: context.path }); if (block.icon?.type === "image") checkImageSource(block.icon.source, context.path, resolvedOptions, addIssue); break; case "quote": checkRichTextContent(block.richText, context.path, "info", addIssue); break; case "table": checkTable(block.rows.map((row) => row.cells.length), context.path, addIssue); break; case "divider": break; case "page_link": if (block.page.type === "page_id" && block.page.pageId.trim().length === 0) addIssue({ severity: "error", code: "empty_page_reference", message: "page_link.page.pageId is empty.", path: context.path }); if (block.page.type === "url") checkHref(block.page.url, context.path, resolvedOptions, addIssue, "page_link.page.url"); break; case "gallery": if (block.images.length === 0) addIssue({ severity: "warning", code: "empty_gallery", message: "gallery has no images.", path: context.path }); break; case "image": checkImageSource(block.source, context.path, resolvedOptions, addIssue); checkImageAlt(block.alt, context.path, resolvedOptions, addIssue); break; } }, listItem(item, context) { if (isBlankRichText(item.richText) && !item.children?.length) addIssue({ severity: "info", code: "empty_rich_text", message: "List item has no text or child blocks.", path: context.path }); }, galleryImage(image, context) { checkId(image.id, context.path); checkImageSource(image.source, context.path, resolvedOptions, addIssue); checkImageAlt(image.alt, context.path, resolvedOptions, addIssue); }, inline(inline, context) { if (inline.type !== "text") return; if (inline.text.length === 0) addIssue({ severity: "info", code: "empty_rich_text", message: "Text inline is empty.", path: context.path }); if (inline.link) checkHref(inline.link.href, context.path, resolvedOptions, addIssue, "text link href"); } }); return createReport(issues); } function createReport(issues) { const errors = issues.filter((issue) => issue.severity === "error"); const warnings = issues.filter((issue) => issue.severity === "warning"); const infos = issues.filter((issue) => issue.severity === "info"); return { valid: errors.length === 0, issues, errors, warnings, infos }; } function checkRichTextContent(richText, path, severity, addIssue) { if (!isBlankRichText(richText)) return; addIssue({ severity, code: "empty_rich_text", message: "Rich text has no visible text.", path }); } function checkTable(cellCounts, path, addIssue) { if (cellCounts.length === 0) { addIssue({ severity: "warning", code: "empty_table", message: "table has no rows.", path }); return; } const expectedCellCount = cellCounts[0] ?? 0; if (expectedCellCount === 0) addIssue({ severity: "warning", code: "empty_table", message: "table rows have no cells.", path }); if (cellCounts.some((cellCount) => cellCount !== expectedCellCount)) addIssue({ severity: "error", code: "inconsistent_table_columns", message: "table rows must have the same number of cells.", path }); } function checkImageSource(source, path, options, addIssue) { if (!options.validateUrls) return; if (source.type === "external") { if (!isAbsoluteHttpUrl(source.url)) addIssue({ severity: "error", code: "invalid_url", message: "External image source must be an absolute http(s) URL.", path }); return; } if (!isHref(source.url)) addIssue({ severity: "error", code: "invalid_url", message: "File image source URL is invalid.", path }); } function checkImageAlt(alt, path, options, addIssue) { if (!options.requireImageAlt || alt?.trim()) return; addIssue({ severity: "warning", code: "missing_image_alt", message: "Image alt text is missing.", path }); } function checkHref(href, path, options, addIssue, label) { if (!options.validateUrls || isHref(href)) return; addIssue({ severity: "error", code: "invalid_url", message: `${label} is invalid.`, path }); } function isBlankRichText(richText) { return richTextToPlainText(richText).trim().length === 0; } function headingLevel$1(type) { switch (type) { case "heading_1": return 1; case "heading_2": return 2; case "heading_3": return 3; case "heading_4": return 4; } } function isHref(value) { const trimmed = value.trim(); if (trimmed.length === 0 || /\s/.test(trimmed)) return false; if (trimmed.startsWith("/") || trimmed.startsWith("#")) return true; try { const url = new URL(trimmed); return [ "http:", "https:", "mailto:", "tel:" ].includes(url.protocol); } catch { return false; } } function isAbsoluteHttpUrl(value) { const trimmed = value.trim(); if (trimmed.length === 0 || /\s/.test(trimmed)) return false; try { const url = new URL(trimmed); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } function formatPath(path) { if (path.length === 0) return "$"; return `$${path.map((segment) => segment.index === void 0 ? `.${segment.key}` : `.${segment.key}[${segment.index}]`).join("")}`; } //#endregion //#region src/schemas.ts const textColorSchema = v.picklist(TEXT_COLORS); const textAnnotationsSchema = v.object({ color: v.optional(textColorSchema), backgroundColor: v.optional(textColorSchema), bold: v.optional(v.boolean()), italic: v.optional(v.boolean()), underline: v.optional(v.boolean()), strikethrough: v.optional(v.boolean()) }); const textLinkSchema = v.object({ href: v.string(), title: v.optional(v.string()) }); const bodyRichTextSchema = v.union([v.object({ type: v.literal("text"), text: v.string(), annotations: v.optional(textAnnotationsSchema), link: v.optional(textLinkSchema) }), v.object({ type: v.literal("line_break") })]); const imageSourceSchema = v.union([v.object({ type: v.literal("external"), url: v.string() }), v.object({ type: v.literal("file"), url: v.string(), expiryTime: v.optional(v.string()) })]); const calloutIconSchema = v.union([v.object({ type: v.literal("emoji"), emoji: v.string() }), v.object({ type: v.literal("image"), source: imageSourceSchema })]); const pageReferenceSchema = v.union([v.object({ type: v.literal("page_id"), pageId: v.string() }), v.object({ type: v.literal("url"), url: v.string() })]); const tableCellSchema = v.object({ richText: v.array(bodyRichTextSchema) }); const tableRowSchema = v.object({ cells: v.array(tableCellSchema) }); const galleryImageSchema = v.object({ id: v.optional(v.string()), source: imageSourceSchema, caption: v.optional(v.array(bodyRichTextSchema)), alt: v.optional(v.string()) }); const listItemSchema = v.lazy(() => v.object({ richText: v.array(bodyRichTextSchema), children: v.optional(v.array(bodyBlockSchema)) })); const toggleListItemSchema = v.lazy(() => v.object({ richText: v.array(bodyRichTextSchema), children: v.optional(v.array(bodyBlockSchema)), expanded: v.optional(v.boolean()) })); const bodyBlockSchema = v.lazy(() => v.union([ v.object({ type: v.literal("text"), id: v.optional(v.string()), richText: v.array(bodyRichTextSchema), children: v.optional(v.array(bodyBlockSchema)) }), v.object({ type: v.literal("heading_1"), id: v.optional(v.string()), richText: v.array(bodyRichTextSchema) }), v.object({ type: v.literal("heading_2"), id: v.optional(v.string()), richText: v.array(bodyRichTextSchema) }), v.object({ type: v.literal("heading_3"), id: v.optional(v.string()), richText: v.array(bodyRichTextSchema) }), v.object({ type: v.literal("heading_4"), id: v.optional(v.string()), richText: v.array(bodyRichTextSchema) }), v.object({ type: v.literal("bulleted_list"), id: v.optional(v.string()), items: v.array(listItemSchema) }), v.object({ type: v.literal("numbered_list"), id: v.optional(v.string()), start: v.optional(v.number()), items: v.array(listItemSchema) }), v.object({ type: v.literal("toggle_list"), id: v.optional(v.string()), items: v.array(toggleListItemSchema) }), v.object({ type: v.literal("callout"), id: v.optional(v.string()), richText: v.array(bodyRichTextSchema), children: v.optional(v.array(bodyBlockSchema)), icon: v.optional(calloutIconSchema), color: v.optional(textColorSchema), backgroundColor: v.optional(textColorSchema) }), v.object({ type: v.literal("quote"), id: v.optional(v.string()), richText: v.array(bodyRichTextSchema), children: v.optional(v.array(bodyBlockSchema)) }), v.object({ type: v.literal("table"), id: v.optional(v.string()), hasColumnHeader: v.optional(v.boolean()), hasRowHeader: v.optional(v.boolean()), rows: v.array(tableRowSchema) }), v.object({ type: v.literal("divider"), id: v.optional(v.string()) }), v.object({ type: v.literal("page_link"), id: v.optional(v.string()), page: pageReferenceSchema, title: v.optional(v.array(bodyRichTextSchema)) }), v.object({ type: v.literal("gallery"), id: v.optional(v.string()), images: v.array(galleryImageSchema), caption: v.optional(v.array(bodyRichTextSchema)) }), v.object({ type: v.literal("image"), id: v.optional(v.string()), source: imageSourceSchema, caption: v.optional(v.array(bodyRichTextSchema)), alt: v.optional(v.string()) }) ])); const bodySchema = v.object({ version: v.literal(1), content: v.array(bodyBlockSchema) }); function parseBody(input) { return v.safeParse(bodySchema, input); } //#endregion //#region src/migration.ts const MIN_SUPPORTED_BODY_VERSION = 1; const migrationSteps = []; var BodyMigrationError = class extends Error { issue; constructor(issue) { super(issue.message); this.name = "BodyMigrationError"; this.issue = issue; } }; function safeMigrateBody(input) { const currentParseResult = parseBody(input); if (currentParseResult.success) return { success: true, output: currentParseResult.output, migrated: false, fromVersion: 1, toVersion: 1 }; const fromVersion = readBodyVersion(input); if (!isSupportedVersionValue(fromVersion)) return createMigrationFailure({ code: "invalid_body", message: "Body data must include an integer version.", version: fromVersion, parseIssues: currentParseResult.issues }); if (fromVersion < MIN_SUPPORTED_BODY_VERSION || fromVersion > 1) return createMigrationFailure({ code: "unsupported_version", message: `Body version ${fromVersion} cannot be migrated to version 1.`, version: fromVersion }); let candidate = input; let currentVersion = fromVersion; let migrated = false; while (currentVersion < 1) { const step = migrationSteps.find((migration) => migration.fromVersion === currentVersion); if (!step) return createMigrationFailure({ code: "unsupported_version", message: `No migration is registered from body version ${currentVersion}.`, version: currentVersion }); candidate = step.migrate(candidate); currentVersion = step.toVersion; migrated = true; } const migratedParseResult = parseBody(candidate); if (!migratedParseResult.success) return createMigrationFailure({ code: "invalid_body", message: `Migrated body data does not match version 1.`, version: fromVersion, parseIssues: migratedParseResult.issues }); return { success: true, output: migratedParseResult.output, migrated, fromVersion, toVersion: 1 }; } function migrateBody(input) { const result = safeMigrateBody(input); if (!result.success) throw new BodyMigrationError(result.issue); return result.output; } function createMigrationFailure(issue) { return { success: false, issue }; } function readBodyVersion(input) { if (!isRecord(input)) return; return input.version; } function isSupportedVersionValue(value) { return typeof value === "number" && Number.isInteger(value); } function isRecord(value) { return typeof value === "object" && value !== null; } //#endregion //#region src/rendering.ts const DEFAULT_EXCERPT_MAX_LENGTH = 160; const DEFAULT_READING_WORDS_PER_MINUTE = 200; const DEFAULT_READING_CHARACTERS_PER_MINUTE = 500; const DEFAULT_MINIMUM_READING_MINUTES = 1; const DEFAULT_HEADING_FALLBACK_PREFIX = "heading"; const DEFAULT_HEADING_DUPLICATE_SEPARATOR = "-"; const HEADING_SLUG_DISALLOWED_CHARACTERS = /[^\p{Letter}\p{Number}-]+/gu; function extractHeadings(input) { const headings = []; walkBody(input, { block(block, context) { if (!isHeadingBlock(block)) return; headings.push({ id: block.id, level: headingLevel(block), text: richTextToPlainText(block.richText), block, path: [...context.path] }); } }); return headings; } function extractHeadingAnchors(input, options = {}) { const usedSlugs = /* @__PURE__ */ new Set(); const baseSlugCounts = /* @__PURE__ */ new Map(); const preserveExistingIds = options.preserveExistingIds ?? true; const fallbackPrefix = slugifyHeading(options.fallbackPrefix ?? DEFAULT_HEADING_FALLBACK_PREFIX) || DEFAULT_HEADING_FALLBACK_PREFIX; const duplicateSeparator = options.duplicateSeparator ?? DEFAULT_HEADING_DUPLICATE_SEPARATOR; return extractHeadings(input).map((heading, index) => { const preferredSlug = preserveExistingIds && heading.id ? heading.id.trim() : options.slugify?.(heading) ?? slugifyHeading(heading.text); const fallbackSlug = `${fallbackPrefix}${duplicateSeparator}${index + 1}`; const slug = createUniqueSlug(preferredSlug || fallbackSlug, usedSlugs, baseSlugCounts, duplicateSeparator); return { ...heading, anchorId: slug, href: `#${encodeURIComponent(slug)}`, slug }; }); } function slugifyHeading(value) { return value.normalize("NFKC").trim().toLowerCase().replace(/[\s_]+/g, "-").replace(HEADING_SLUG_DISALLOWED_CHARACTERS, "").replace(/-+/g, "-").replace(/^-|-$/g, ""); } function createExcerpt(input, options = {}) { const maxLength = options.maxLength ?? DEFAULT_EXCERPT_MAX_LENGTH; if (maxLength <= 0) return ""; const omission = options.omission ?? "..."; const text = normalizeWhitespace(toPlainText(input)); if (text.length <= maxLength) return text; if (omission.length >= maxLength) return omission.slice(0, maxLength); const contentLength = maxLength - omission.length; let excerpt = text.slice(0, contentLength).trimEnd(); if (options.preserveWords) { const lastWhitespaceIndex = excerpt.search(/\s+\S*$/); if (lastWhitespaceIndex > 0) excerpt = excerpt.slice(0, lastWhitespaceIndex).trimEnd(); } return `${excerpt}${omission}`; } function estimateReadingTime(input, options = {}) { const text = normalizeWhitespace(toPlainText(input)); const characters = text.replace(/\s/g, "").length; const words = countWords(text); const cjkCharacters = countCjkCharacters(text); const wordsPerMinute = options.wordsPerMinute ?? DEFAULT_READING_WORDS_PER_MINUTE; const charactersPerMinute = options.charactersPerMinute ?? DEFAULT_READING_CHARACTERS_PER_MINUTE; const minimumMinutes = options.minimumMinutes ?? DEFAULT_MINIMUM_READING_MINUTES; const estimatedMinutes = words / wordsPerMinute + cjkCharacters / charactersPerMinute || characters / charactersPerMinute; return { minutes: Math.max(minimumMinutes, Math.ceil(estimatedMinutes)), words, characters }; } function collectImageSources(input) { const images = []; walkBody(input, { block(block, context) { switch (block.type) { case "callout": if (block.icon?.type === "image") images.push({ kind: "callout_icon", source: block.icon.source, block, path: [...context.path] }); break; case "image": images.push({ kind: "image", source: block.source, alt: block.alt, caption: block.caption, block, path: [...context.path] }); break; } }, galleryImage(image, context) { images.push({ kind: "gallery_image", source: image.source, alt: image.alt, caption: image.caption, imageId: image.id, block: context.parent, path: [...context.path] }); } }); return images; } function collectLinks(input) { const links = []; walkBody(input, { block(block, context) { if (block.type !== "page_link") return; links.push({ kind: "page_link", page: block.page, text: block.title ? richTextToPlainText(block.title) : pageReferenceToText(block.page), block, path: [...context.path] }); }, inline(inline, context) { if (inline.type !== "text" || !inline.link) return; links.push({ kind: "text_link", href: inline.link.href, title: inline.link.title, text: inline.text, link: inline.link, path: [...context.path] }); } }); return links; } function isHeadingBlock(block) { return block.type === "heading_1" || block.type === "heading_2" || block.type === "heading_3" || block.type === "heading_4"; } function headingLevel(block) { switch (block.type) { case "heading_1": return 1; case "heading_2": return 2; case "heading_3": return 3; case "heading_4": return 4; } } function normalizeWhitespace(value) { return value.replace(/\s+/g, " ").trim(); } function createUniqueSlug(baseSlug, usedSlugs, baseSlugCounts, duplicateSeparator) { let count = (baseSlugCounts.get(baseSlug) ?? 0) + 1; let slug = count === 1 ? baseSlug : `${baseSlug}${duplicateSeparator}${count}`; while (usedSlugs.has(slug)) { count += 1; slug = `${baseSlug}${duplicateSeparator}${count}`; } baseSlugCounts.set(baseSlug, count); usedSlugs.add(slug); return slug; } function countWords(value) { return value.match(/[A-Za-z0-9]+(?:['-][A-Za-z0-9]+)*/g)?.length ?? 0; } function countCjkCharacters(value) { return value.match(/[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/gu)?.length ?? 0; } function pageReferenceToText(page) { switch (page.type) { case "page_id": return page.pageId; case "url": return page.url; } } //#endregion export { BODY_BLOCK_TYPES, BODY_VERSION, BodyEditError, BodyMigrationError, HEADING_LEVELS, TEXT_COLORS, bodyBlockSchema, bodyRichTextSchema, bodySchema, bulletedList, callout, calloutIconSchema, collectImageSources, collectLinks, createBody, createExcerpt, divider, estimateReadingTime, extractHeadingAnchors, extractHeadings, filterBlocks, findBlock, findBlockPathById, gallery, galleryImage, galleryImageSchema, getBlockAtPath, heading, image, imageSourceSchema, insertBlockAtPath, insertBlockAtRoot, lineBreak, lintBody, listItem, listItemSchema, mapBody, migrateBody, moveBlock, numberedList, pageLink, pageReferenceSchema, paragraph, parseBody, quote, removeBlockAtPath, replaceBlockAtPath, richText, richTextToPlainText, safeMigrateBody, slugifyHeading, table, tableCell, tableCellSchema, tableRow, tableRowSchema, text, textAnnotationsSchema, textColorSchema, textLinkSchema, toPlainText, toggleList, toggleListItem, toggleListItemSchema, updateBlockAtPath, updateRichTextAtPath, walkBody };