export const WORDS_DOCUMENT_VERSION = 1; export type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code'; export type WordsBlockType = 'paragraph' | 'heading' | 'quote' | 'list'; export type WordsListKind = 'ordered' | 'unordered' | 'check'; export type WordsHeadingLevel = 1 | 2 | 3; export type WordsTextAlign = 'left' | 'center' | 'right' | 'justify'; export interface WordsDocument { readonly version: typeof WORDS_DOCUMENT_VERSION; readonly children: readonly WordsBlock[]; } export type WordsBlock = WordsParagraphBlock | WordsHeadingBlock | WordsQuoteBlock | WordsListBlock; export interface WordsParagraphBlock { readonly type: 'paragraph'; readonly children: readonly WordsInline[]; readonly textAlign?: WordsTextAlign; } export interface WordsHeadingBlock { readonly type: 'heading'; readonly level: WordsHeadingLevel; readonly children: readonly WordsInline[]; readonly textAlign?: WordsTextAlign; } export interface WordsQuoteBlock { readonly type: 'quote'; readonly children: readonly WordsInline[]; readonly textAlign?: WordsTextAlign; } export interface WordsListBlock { readonly type: 'list'; readonly kind: WordsListKind; readonly children: readonly WordsListItem[]; } export interface WordsListItem { readonly type: 'list-item'; readonly checked?: boolean; readonly indent?: number; readonly children: readonly WordsInline[]; } export type WordsInline = WordsText | WordsLink; export interface WordsText { readonly type: 'text'; readonly text: string; readonly marks?: readonly WordsMark[]; } export interface WordsLink { readonly type: 'link'; readonly href: string; readonly title?: string; readonly children: readonly WordsInline[]; } export const WORDS_MARKS = [ 'bold', 'italic', 'underline', 'strike', 'code' ] as const satisfies readonly WordsMark[]; export const WORDS_LIST_KINDS = [ 'ordered', 'unordered', 'check' ] as const satisfies readonly WordsListKind[]; export const WORDS_HEADING_LEVELS = [1, 2, 3] as const satisfies readonly WordsHeadingLevel[]; export const WORDS_TEXT_ALIGNS = ['left', 'center', 'right', 'justify'] as const satisfies readonly WordsTextAlign[]; export function createEmptyWordsDocument(): WordsDocument { return { version: WORDS_DOCUMENT_VERSION, children: [createParagraph()] }; } export function createParagraph( children: readonly WordsInline[] = [createText('')], textAlign?: WordsTextAlign ): WordsParagraphBlock { return { type: 'paragraph', children, ...(textAlign ? { textAlign } : {}) }; } export function createHeading( level: WordsHeadingLevel, children: readonly WordsInline[] = [createText('')], textAlign?: WordsTextAlign ): WordsHeadingBlock { return { type: 'heading', level, children, ...(textAlign ? { textAlign } : {}) }; } export function createQuote( children: readonly WordsInline[] = [createText('')], textAlign?: WordsTextAlign ): WordsQuoteBlock { return { type: 'quote', children, ...(textAlign ? { textAlign } : {}) }; } export function createList( kind: WordsListKind, children: readonly WordsListItem[] = [createListItem()] ): WordsListBlock { return { type: 'list', kind, children }; } export function createListItem( children: readonly WordsInline[] = [createText('')], checked = false, indent?: number ): WordsListItem { return { type: 'list-item', ...(checked ? { checked } : {}), ...(indent ? { indent } : {}), children }; } export function createText(text: string, marks: readonly WordsMark[] = []): WordsText { const normalizedMarks = normalizeMarks(marks); return { type: 'text', text, ...(normalizedMarks.length ? { marks: normalizedMarks } : {}) }; } export function createLink( href: string, children: readonly WordsInline[], title?: string ): WordsLink { return { type: 'link', href, ...(title ? { title } : {}), children }; } export function normalizeMarks(marks: readonly WordsMark[] | undefined): readonly WordsMark[] { if (!marks?.length) return []; const order = new Map(WORDS_MARKS.map((mark, index) => [mark, index])); return Array.from(new Set(marks)).sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)); } export function sameMarks( a: readonly WordsMark[] | undefined, b: readonly WordsMark[] | undefined ): boolean { const left = normalizeMarks(a); const right = normalizeMarks(b); if (left.length !== right.length) return false; return left.every((mark, index) => mark === right[index]); } export function isWordsMark(value: unknown): value is WordsMark { return typeof value === 'string' && (WORDS_MARKS as readonly string[]).includes(value); } export function isWordsHeadingLevel(value: unknown): value is WordsHeadingLevel { return typeof value === 'number' && (WORDS_HEADING_LEVELS as readonly number[]).includes(value); } export function isWordsListKind(value: unknown): value is WordsListKind { return typeof value === 'string' && (WORDS_LIST_KINDS as readonly string[]).includes(value); } export function isWordsTextAlign(value: unknown): value is WordsTextAlign { return typeof value === 'string' && (WORDS_TEXT_ALIGNS as readonly string[]).includes(value); } export function isTextInline(inline: WordsInline): inline is WordsText { return inline.type === 'text'; } export function isLinkInline(inline: WordsInline): inline is WordsLink { return inline.type === 'link'; } export function getBlockText(block: WordsBlock): string { if (block.type === 'list') { return block.children.map((item) => item.children.map(getInlineText).join('')).join('\n'); } return block.children.map(getInlineText).join(''); } export function getInlineText(inline: WordsInline): string { return getInlineTextSafe(inline, new WeakSet()); } function getInlineTextSafe(inline: WordsInline, seen: WeakSet): string { if (seen.has(inline)) return ''; seen.add(inline); if (inline.type === 'text') return inline.text; return inline.children.map((child) => getInlineTextSafe(child, seen)).join(''); }