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.
svelte-kit-vice/src/uix/soma/components/words/engine/document.ts

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('');
}

Powered by TurnKey Linux.