@ryhrm-gz/xincodo-lib
Version:
Utilities for working with Xincodo body documents.
1,522 lines (1,521 loc) • 48.7 kB
JavaScript
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 };