export const WORDS_DOCUMENT_VERSION = 1; export type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code'; export type WordsBlockType = 'paragraph' | 'heading' | 'quote' | 'code' | 'list' | 'table'; export type WordsListKind = 'ordered' | 'unordered' | 'check'; export type WordsHeadingLevel = 1 | 2 | 3; export type WordsTextAlign = 'left' | 'center' | 'right' | 'justify'; // Table types live in the table extension since F2.3 (extension system // migration). Re-exported here so existing consumers that import table // types from `engine/document` keep compiling. New code SHOULD import // from `extensions/table` directly. import type { WordsTableCellVerticalAlign, WordsTableCellTone, WordsTableBlock, WordsTableRow, WordsTableCell, WordsTableOptions, WordsTableCellOptions } from '../extensions/table/types'; export type { WordsTableCellVerticalAlign, WordsTableCellTone, WordsTableBlock, WordsTableRow, WordsTableCell, WordsTableOptions, WordsTableCellOptions }; export interface WordsDocument { readonly version: typeof WORDS_DOCUMENT_VERSION; readonly children: readonly WordsBlock[]; } export type WordsBlock = | WordsParagraphBlock | WordsHeadingBlock | WordsQuoteBlock | WordsCodeBlock | WordsListBlock | WordsTableBlock; 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 WordsCodeBlock { readonly type: 'code'; readonly children: readonly WordsInline[]; readonly language?: string; } 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[]; } // Table interface declarations live in `extensions/table/types.ts` since // F2.3. The interfaces (WordsTableBlock, WordsTableRow, WordsTableCell, // WordsTableOptions, WordsTableCellOptions) are re-exported from this // module via the `export type { ... } from '../extensions/table/types'` // statement near the top of the file. 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 const WORDS_TABLE_CELL_VERTICAL_ALIGNS = [ 'top', 'middle', 'bottom' ] as const satisfies readonly WordsTableCellVerticalAlign[]; export const WORDS_TABLE_CELL_TONES = [ 'default', 'muted', 'accent' ] as const satisfies readonly WordsTableCellTone[]; 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 createCodeBlock( children: readonly WordsInline[] = [createText('')], language?: string ): WordsCodeBlock { const normalizedLanguage = normalizeWordsCodeLanguage(language); return { type: 'code', children, ...(normalizedLanguage ? { language: normalizedLanguage } : {}) }; } 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 createTable( children: readonly WordsTableRow[] = [createTableRow()], options: WordsTableOptions = {} ): WordsTableBlock { return { type: 'table', ...(options.striped ? { striped: true } : {}), ...(options.compact ? { compact: true } : {}), children }; } export function createTableRow( children: readonly WordsTableCell[] = [createTableCell()] ): WordsTableRow { return { type: 'table-row', children }; } export function createTableCell( children: readonly WordsInline[] = [createText('')], options: WordsTableCellOptions = {} ): WordsTableCell { return { type: 'table-cell', ...(options.header ? { header: true } : {}), ...(options.textAlign && options.textAlign !== 'left' ? { textAlign: options.textAlign } : {}), ...(options.verticalAlign && options.verticalAlign !== 'top' ? { verticalAlign: options.verticalAlign } : {}), ...(options.tone && options.tone !== 'default' ? { tone: options.tone } : {}), 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 isWordsTableCellVerticalAlign( value: unknown ): value is WordsTableCellVerticalAlign { return ( typeof value === 'string' && (WORDS_TABLE_CELL_VERTICAL_ALIGNS as readonly string[]).includes(value) ); } export function isWordsTableCellTone(value: unknown): value is WordsTableCellTone { return typeof value === 'string' && (WORDS_TABLE_CELL_TONES as readonly string[]).includes(value); } export function normalizeWordsCodeLanguage(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const language = value.trim().toLowerCase(); return /^[a-z0-9][a-z0-9+_.-]{0,31}$/.test(language) ? language : undefined; } 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'); } if (block.type === 'table') { return block.children .map((row) => row.children.map((cell) => cell.children.map(getInlineText).join('')).join('\t') ) .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(''); }