You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
390 lines
11 KiB
390 lines
11 KiB
export const WORDS_DOCUMENT_VERSION = 1;
|
|
|
|
/**
|
|
* Inline mark — boolean format flags ('bold', 'italic', ...) plus
|
|
* parametric marks carrying a value via colon-prefixed syntax:
|
|
* - `color:#ff0000` foreground text color (hex with #)
|
|
* - `bgcolor:#ffeeaa` highlight / background color
|
|
*
|
|
* Parametric marks REPLACE same-prefix entries when toggled (you
|
|
* can have at most one of each prefix per inline). The HTML
|
|
* serializer emits them as `style="color:..; background-color:..."`.
|
|
* Boolean marks toggle as before.
|
|
*/
|
|
export type WordsMark =
|
|
| 'bold'
|
|
| 'italic'
|
|
| 'underline'
|
|
| 'strike'
|
|
| 'code'
|
|
| `color:${string}`
|
|
| `bgcolor:${string}`;
|
|
export type WordsBlockType =
|
|
| 'paragraph'
|
|
| 'heading'
|
|
| 'quote'
|
|
| 'code'
|
|
| 'list'
|
|
| 'table'
|
|
| 'image';
|
|
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
|
|
};
|
|
|
|
// Image types live in the image extension (F3). Re-exported here for
|
|
// the same reason as the table types — keep `engine/document` as the
|
|
// historical import surface working while new code uses
|
|
// `extensions/image` directly.
|
|
import type {
|
|
WordsImageAlign,
|
|
WordsImageBlock,
|
|
WordsImageOptions,
|
|
WordsImageStatus
|
|
} from '../extensions/image/types';
|
|
export type {
|
|
WordsImageAlign,
|
|
WordsImageBlock,
|
|
WordsImageOptions,
|
|
WordsImageStatus
|
|
};
|
|
|
|
export interface WordsDocument {
|
|
readonly version: typeof WORDS_DOCUMENT_VERSION;
|
|
readonly children: readonly WordsBlock[];
|
|
}
|
|
|
|
export type WordsBlock =
|
|
| WordsParagraphBlock
|
|
| WordsHeadingBlock
|
|
| WordsQuoteBlock
|
|
| WordsCodeBlock
|
|
| WordsListBlock
|
|
| WordsTableBlock
|
|
| WordsImageBlock;
|
|
|
|
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[];
|
|
// Table cell value-set constants live in the table extension since
|
|
// F2.3b. Re-exported here for backward compat with consumers that
|
|
// import them from `engine/document`.
|
|
export {
|
|
WORDS_TABLE_CELL_VERTICAL_ALIGNS,
|
|
WORDS_TABLE_CELL_TONES
|
|
} from '../extensions/table/factories';
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
// Table factory functions live in the table extension since F2.3b.
|
|
// Re-exported here for backward compat — see `extensions/table/factories.ts`.
|
|
export { createTable, createTableRow, createTableCell } from '../extensions/table/factories';
|
|
|
|
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<string, number>(
|
|
(WORDS_MARKS as readonly string[]).map((mark, index) => [mark, index])
|
|
);
|
|
// Dedupe boolean marks; for parametric marks (color:X, bgcolor:Y)
|
|
// keep only the LAST value per prefix so multiple toggles in a
|
|
// row resolve to the user's final choice.
|
|
const seenPrefix = new Map<string, WordsMark>();
|
|
const booleanSet = new Set<WordsMark>();
|
|
for (const mark of marks) {
|
|
const colon = mark.indexOf(':');
|
|
if (colon > 0) {
|
|
seenPrefix.set(mark.slice(0, colon), mark);
|
|
} else {
|
|
booleanSet.add(mark);
|
|
}
|
|
}
|
|
const dedup = [...booleanSet, ...seenPrefix.values()];
|
|
// Sort: boolean marks first in declaration order, parametric marks
|
|
// after (stable). This keeps existing tests happy.
|
|
return dedup.sort((a, b) => {
|
|
const ai = order.get(a) ?? 1e6;
|
|
const bi = order.get(b) ?? 1e6;
|
|
return ai - bi;
|
|
});
|
|
}
|
|
|
|
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 {
|
|
if (typeof value !== 'string') return false;
|
|
if ((WORDS_MARKS as readonly string[]).includes(value)) return true;
|
|
// Parametric: `color:#hex` or `bgcolor:#hex`. Liberal hex check —
|
|
// anything that's `#` + 3/4/6/8 hex digits.
|
|
const colorMatch = value.match(/^(color|bgcolor):(#[0-9a-fA-F]{3,8})$/);
|
|
return !!colorMatch;
|
|
}
|
|
|
|
/**
|
|
* Read the parametric value carried by a mark, or undefined for
|
|
* boolean marks. Returns the part after the colon.
|
|
*
|
|
* markValue('color:#ff0000') // '#ff0000'
|
|
* markValue('bold') // undefined
|
|
*/
|
|
export function markValue(mark: WordsMark): string | undefined {
|
|
const colon = mark.indexOf(':');
|
|
return colon > 0 ? mark.slice(colon + 1) : undefined;
|
|
}
|
|
|
|
/** Prefix of a parametric mark, or the mark itself for boolean ones. */
|
|
export function markPrefix(mark: WordsMark): string {
|
|
const colon = mark.indexOf(':');
|
|
return colon > 0 ? mark.slice(0, colon) : mark;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Table predicates live in the table extension since F2.3b.
|
|
// Re-exported here for backward compat.
|
|
export {
|
|
isWordsTableCellVerticalAlign,
|
|
isWordsTableCellTone
|
|
} from '../extensions/table/factories';
|
|
|
|
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');
|
|
}
|
|
if (block.type === 'image') {
|
|
// Plain-text representation of an image is its alt text. Used
|
|
// for word count, copy-as-plaintext and accessibility flows.
|
|
// Captions are *not* included to keep the image a single
|
|
// semantic atom in plain-text contexts.
|
|
return block.alt ?? '';
|
|
}
|
|
return block.children.map(getInlineText).join('');
|
|
}
|
|
|
|
export function getInlineText(inline: WordsInline): string {
|
|
return getInlineTextSafe(inline, new WeakSet<object>());
|
|
}
|
|
|
|
function getInlineTextSafe(inline: WordsInline, seen: WeakSet<object>): 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('');
|
|
}
|