@vectojs/markdown 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ import { type ContentProjection, type ContentProjectionHint, GlyphRasterAtlas, type GlyphRasterAtlasStats, IRenderer } from '@vectojs/core';
2
+ import { UIComponent } from '@vectojs/ui';
3
+ import { type MarkdownTheme } from './theme';
4
+ /**
5
+ * A single self-rendering entity for fenced code blocks.
6
+ *
7
+ * Replaces the old N×M child-entity explosion (Container → Stack → Text per
8
+ * segment per line) with a flat leaf that draws its own background + text.
9
+ */
10
+ export declare class CodeBlock extends UIComponent {
11
+ private lines;
12
+ private grid;
13
+ /** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
14
+ private rawLines;
15
+ private cellWidth;
16
+ private source;
17
+ /** Bumped by {@link buildLines} and {@link setSelectable}; read by `Scene`. */
18
+ private contentEpoch;
19
+ private lang;
20
+ private theme;
21
+ /**
22
+ * Assigned in the constructor rather than as a field initializer: both come
23
+ * from `theme`, and a field initializer runs before the constructor body has
24
+ * a `theme` to read.
25
+ */
26
+ private lineH;
27
+ private pad;
28
+ private codeFont;
29
+ selectable: boolean;
30
+ /**
31
+ * @param theme Any subset of {@link MarkdownTheme}; missing keys fall back to
32
+ * `DEFAULT_THEME` in `./theme`. Accepting a partial theme keeps callers that were
33
+ * written against an earlier, smaller `MarkdownTheme` working — this class
34
+ * is public API, and a hand-built theme literal would otherwise start
35
+ * throwing `lineHeight must be a positive finite number` the moment a new
36
+ * size key was added.
37
+ */
38
+ constructor(code: string, lang: string, maxWidth: number, theme: MarkdownTheme, selectable?: boolean);
39
+ /** Re-parse code content (e.g. for live editing). */
40
+ setCode(code: string, lang?: string): this;
41
+ /** Enable or disable browser-native selection for this code block. */
42
+ setSelectable(selectable: boolean): this;
43
+ getContentEpoch(): number;
44
+ /**
45
+ * Change the block's box width.
46
+ *
47
+ * Deliberately does **not** rebuild the grid or the highlight, because code does
48
+ * not reflow: lines are placed on a fixed monospace grid at `col × cellWidth` and
49
+ * a long line overflows rather than wrapping, so `height` is a function of line
50
+ * *count* alone. The width only sizes the rounded background. Anything that would
51
+ * change the glyph geometry — the source, the language, the font — goes through
52
+ * {@link setCode} and invalidates the grid there.
53
+ *
54
+ * @returns `this` for chaining.
55
+ */
56
+ setWidth(width: number): this;
57
+ getContentProjection(hint?: ContentProjectionHint): ContentProjection | null;
58
+ /**
59
+ * Re-highlight the code, reusing the highlight of any unchanged line prefix.
60
+ *
61
+ * Streaming appends to the END of a block, so all but the last line or two are
62
+ * byte-identical to the previous call — yet this used to re-highlight every
63
+ * line on every chunk, making a streamed block O(N) per append and O(N^2)
64
+ * overall. Reusing the stable prefix makes an append proportional to what
65
+ * actually changed.
66
+ *
67
+ * The last previously-seen line is deliberately NOT reused: a chunk usually
68
+ * lands mid-line, so that line's text (and therefore its tokenization) changes.
69
+ */
70
+ private buildLines;
71
+ private ensureGrid;
72
+ /** Code blocks are decorative — not interactive. */
73
+ isPointInside(): boolean;
74
+ render(r: IRenderer): void;
75
+ }
76
+ /**
77
+ * Instrumentation for the code-block glyph atlas in use, or `null` before first
78
+ * use.
79
+ *
80
+ * Exposed so an app or benchmark can confirm the atlas is actually active and
81
+ * reusing slots. Watch `resets`: a steadily climbing count means the glyph set is
82
+ * unbounded for the atlas size, so every reset re-rasterizes everything and the
83
+ * atlas is doing net harm rather than saving work.
84
+ *
85
+ * Reports the *most recently used* atlas, which after a zoom is the one now being
86
+ * blitted — see {@link codeAtlas}.
87
+ */
88
+ export declare function codeAtlasStats(): GlyphRasterAtlasStats | null;
89
+ /**
90
+ * The code-block atlas most recently blitted from, or `null` before first use.
91
+ *
92
+ * For instrumentation that must map a traced `drawImage` back to the glyph it
93
+ * painted — a blit carries only a source rect, so `slotAt()` is the only way to
94
+ * recover the cluster and its metrics. Used by `e2e/text-projection.e2e.ts` to
95
+ * keep the code-grid positioning assertions working on the blit path.
96
+ *
97
+ * "Most recently used" rather than "the one" because atlases are pooled per DPR:
98
+ * a caller resolving a traced blit wants the atlas that produced it, which is the
99
+ * one the last render selected. Compare its {@link GlyphRasterAtlas.pixelRatio}
100
+ * against {@link IRenderer.pixelRatio} to assert the blit is 1:1.
101
+ */
102
+ export declare function codeAtlas(): GlyphRasterAtlas | null;
@@ -0,0 +1,33 @@
1
+ import { Entity, IRenderer } from '@vectojs/core';
2
+ /**
3
+ * Leaf entities the Markdown renderer composes: the `<hr>` rule, the blockquote
4
+ * accent bar, and a bare container for nested layouts.
5
+ *
6
+ * These live outside `Markdown.ts` because `MathBlock` extends
7
+ * `MarkdownContainer`. With the base class declared in `Markdown.ts`, a math
8
+ * module importing it back forms a cycle that resolves the binding to
9
+ * `undefined` when the `extends` clause is evaluated, throwing `TypeError:
10
+ * Class extends value undefined is not a constructor or null` on import.
11
+ * Verified by reintroducing it deliberately. 22 of 27 test files enter through
12
+ * `../src/Markdown`, which is the order that trips it. See
13
+ * `forge/decisions/file-decomposition-2026-08.md`.
14
+ */
15
+ /** A thin horizontal line (for `<hr>`). */
16
+ export declare class HorizontalRule extends Entity {
17
+ color: string;
18
+ constructor(w: number, color: string);
19
+ isPointInside(): boolean;
20
+ render(r: IRenderer): void;
21
+ }
22
+ /** A vertical accent bar for blockquotes. */
23
+ export declare class QuoteBorder extends Entity {
24
+ color: string;
25
+ constructor(height: number, color: string, width?: number);
26
+ isPointInside(): boolean;
27
+ render(r: IRenderer): void;
28
+ }
29
+ /** A simple concrete container entity for nested layouts. */
30
+ export declare class MarkdownContainer extends Entity {
31
+ isPointInside(_globalX: number, _globalY: number): boolean;
32
+ render(_r: any): void;
33
+ }
@@ -0,0 +1,126 @@
1
+ import type { InlineObjectBox, InlineObjectSurface } from '@vectojs/core';
2
+ import type { Token, Tokens } from 'marked';
3
+ /**
4
+ * Image predicates over a `marked` token tree, plus the raster store for images
5
+ * that render *inline* rather than as their own block.
6
+ *
7
+ * Still a leaf — it imports no other module of this package, so nothing here has
8
+ * an edge back into `Markdown.ts`. The predicates are pure; the raster store below
9
+ * is not, and is here rather than in `markdown-inline.ts` so that everything
10
+ * deciding how an image reaches the screen lives in one file. The predicates are
11
+ * kept together because they decide, between them, whether a paragraph renders as
12
+ * one `RichText` or as a `Stack` of runs and images — a disagreement among them
13
+ * silently drops a picture. See
14
+ * `forge/decisions/file-decomposition-2026-08.md`.
15
+ */
16
+ /**
17
+ * Whether a paragraph renders as a `Stack` of runs and images rather than one
18
+ * `RichText`.
19
+ *
20
+ * The same test the `paragraph` render arm uses, so the reconciler and the
21
+ * renderer cannot disagree about which shape a token produces.
22
+ *
23
+ * The search is over **descendants, not direct children**. `marked` nests an
24
+ * image as deeply as the source does — `[![a](u)](dest)` is
25
+ * `paragraph > link > image` and `- item ![a](u)` is
26
+ * `list_item > text > [text, image]` — so a direct-children test failed every
27
+ * nested form, sent the run to `inlineRunRichText`, which has no image support,
28
+ * and dropped the image with no warning. Recursing costs one walk of an inline
29
+ * run and removes the whole class rather than the two shapes that were reported.
30
+ */
31
+ export declare function paragraphHasImage(token: Tokens.Paragraph): boolean;
32
+ /**
33
+ * Whether any token in this inline run, at any depth, is an image.
34
+ *
35
+ * The one place the question is answered, so the predicate above, the list-item
36
+ * tier check and the flattening the render arms do cannot drift apart.
37
+ */
38
+ export declare function containsImage(tokens: Token[] | undefined): boolean;
39
+ /**
40
+ * An inline run with nested images lifted to the top level, in source order.
41
+ *
42
+ * The paragraph arm splits a run into one `Stack` child per image plus one per
43
+ * maximal run of non-image tokens, which requires every image to be a direct
44
+ * member of the array it iterates. An image inside a link or an emphasis is not,
45
+ * so the run is flattened first.
46
+ *
47
+ * A wrapper is replaced by its children rather than dropped, so the text inside a
48
+ * link that also holds an image survives. Only wrappers **containing** an image
49
+ * are opened: a plain link keeps its own token, and therefore keeps the styling
50
+ * and click handling `renderInlineToRichText` gives it.
51
+ */
52
+ /**
53
+ * Every image in an inline run, at any depth, in source order.
54
+ *
55
+ * Pairs with `stripImages`: together they partition a run into the prose a
56
+ * `RichText` can render and the images it cannot.
57
+ */
58
+ export declare function imagesOf(tokens: Token[] | undefined): Tokens.Image[];
59
+ /**
60
+ * The same token with every nested image removed, prose intact.
61
+ *
62
+ * A wrapper that held only an image is dropped; one that also held text keeps the
63
+ * text. Used for a list item's lead run, which must show its marker and its prose
64
+ * while its images render as blocks beneath.
65
+ */
66
+ export declare function stripImages<T extends Token>(token: T): T;
67
+ export declare function liftNestedImages(tokens: Token[]): Token[];
68
+ /** Index of the last `image` token in an inline run, or -1 if there is none. */
69
+ export declare function lastIndexOfImage(tokens: Token[]): number;
70
+ /**
71
+ * How many `Stack` children the paragraph render arm builds for an inline run.
72
+ *
73
+ * One child per image, plus one per *maximal run* of consecutive non-image
74
+ * tokens — the arm merges those into a single `RichText` via `flushText`, so this
75
+ * is not `tokens.length`. Kept in lockstep with that arm; it is what
76
+ * `updateImageParagraph` checks to confirm the entity it was handed is the one
77
+ * built for the old tokens.
78
+ */
79
+ /**
80
+ * A decoding raster for one inline image, keyed by URL.
81
+ *
82
+ * Separate from the paragraph path's `Image` entity, which owns its own bitmap and
83
+ * resizes itself in `onLoad`. An inline image cannot do that: it is an
84
+ * `InlineObject`, and the box it occupies is fixed when the span is collected, so
85
+ * the natural size has to be readable from here before the next layout rather than
86
+ * applied to an entity afterwards.
87
+ */
88
+ export interface InlineImageRaster {
89
+ /** `undefined` when this environment has no `Image` (SSR, plain unit tests). */
90
+ bitmap?: HTMLImageElement;
91
+ decoded: boolean;
92
+ /** Natural size, known only once decoded. */
93
+ naturalWidth?: number;
94
+ naturalHeight?: number;
95
+ /** Set when the decode failed, so a broken URL is not retried every frame. */
96
+ failed?: boolean;
97
+ }
98
+ /** Subscribe `notify` to inline-image decodes. Idempotent per closure. */
99
+ export declare function subscribeInlineImageRaster(notify: () => void): void;
100
+ /**
101
+ * Unsubscribe `notify`.
102
+ *
103
+ * Must be called on teardown: the set is module-level and lives as long as the
104
+ * page, so a retained closure retains the whole entity tree that created it.
105
+ */
106
+ export declare function unsubscribeInlineImageRaster(notify: () => void): void;
107
+ /**
108
+ * Ensure the raster for `src` is decoding, and return it.
109
+ *
110
+ * Synchronous and idempotent: the span collector calls it while measuring and the
111
+ * paint path calls it on every visible frame, and only the first call starts a
112
+ * decode. Exported because the span collector needs the natural size to size its
113
+ * box, which is the whole reason this store reports one.
114
+ */
115
+ export declare function ensureInlineImageRaster(src: string): InlineImageRaster;
116
+ /**
117
+ * Paint one inline image into the box the layout engine reserved for it.
118
+ *
119
+ * Draws nothing until the raster decodes — one frame of empty box, then a repaint
120
+ * through {@link inlineImageRasterWaiters}. Mirrors `paintInlineMath`; a
121
+ * placeholder slab would flash a grey rectangle mid-sentence on every first paint.
122
+ */
123
+ export declare function paintInlineImage(src: string, surface: InlineObjectSurface, box: InlineObjectBox): void;
124
+ /** Drop every cached raster. Tests only — a decode is process-wide state. */
125
+ export declare function clearInlineImageRasters(): void;
126
+ export declare function expectedImageParagraphChildren(tokens: Token[]): number;
@@ -0,0 +1,58 @@
1
+ import { type StyledSpan, type TextStyle } from '@vectojs/core';
2
+ import { RichText } from '@vectojs/ui';
3
+ import type { Token } from 'marked';
4
+ import type { MarkdownTheme } from './theme';
5
+ /**
6
+ * Inline tokens to `RichText` spans: the entity decoder, the span collector and
7
+ * its switch, the unclosed-delimiter scanner for optimistic streaming, and the
8
+ * `RichText` factory.
9
+ *
10
+ * Depends one way on `./markdown-math` (inline formulas become object spans) and
11
+ * takes the theme as a type only, so nothing here imports the component. See
12
+ * `forge/decisions/file-decomposition-2026-08.md`.
13
+ */
14
+ /** Decode basic HTML entities that `marked` emits in token text. */
15
+ export declare function decodeEntities(text: string): string;
16
+ /**
17
+ * Recursively walk the inline token tree, accumulating {@link StyledSpan}s
18
+ * with inherited style overrides (bold, italic, etc.).
19
+ */
20
+ export declare function collectSpans(tokens: Token[], inherited: TextStyle, theme: Required<MarkdownTheme>, out: StyledSpan[],
21
+ /**
22
+ * The size the enclosing block is drawn at, when it is not the theme body size.
23
+ *
24
+ * Only inline math uses it: `ex` is font-relative, so a formula's reserved box
25
+ * has to be resolved against the size of the run it sits in. A heading carries
26
+ * its size in its `font` string rather than in any span style, so it cannot be
27
+ * recovered from `inherited`.
28
+ */
29
+ blockFontSize?: number): void;
30
+ /**
31
+ * One trailing inline construct that has opened but not closed yet.
32
+ *
33
+ * `at` is the index in the scanned text of the construct's first syntax
34
+ * character, so the caller can split there: everything before it keeps whatever
35
+ * `marked` already decided, everything after it is the construct's content.
36
+ */
37
+ export interface UnclosedInline {
38
+ kind: 'strong' | 'em' | 'codespan' | 'link';
39
+ /** Index of the opening marker's first character. */
40
+ at: number;
41
+ /** Index just past the opening marker, where the content starts. */
42
+ contentAt: number;
43
+ }
44
+ /**
45
+ * Find the last unclosed inline construct in one trailing text run.
46
+ *
47
+ * Only ever called with the text of the FINAL inline token of the document's
48
+ * final paragraph. That is the only place an unclosed construct can be: a
49
+ * construct that closed is already its own `strong`/`em`/`codespan`/`link`
50
+ * token, so whatever syntax characters survive into a plain text token are
51
+ * exactly the ones `marked` could not pair up.
52
+ *
53
+ * Returns `null` when nothing plausible is open, which is the common case and
54
+ * must stay cheap — this runs once per streamed chunk.
55
+ */
56
+ export declare function findUnclosedInline(text: string): UnclosedInline | null;
57
+ /** Parse inline markdown tokens and produce a {@link RichText} entity. */
58
+ export declare function renderInlineToRichText(tokens: Token[] | undefined, fallbackText: string, font: string, color: string, maxWidth: number, theme: Required<MarkdownTheme>, selectable: boolean, onLinkClick?: (url: string) => void): RichText;
@@ -0,0 +1,169 @@
1
+ import { type InlineObjectBox, type InlineObjectSurface, type DevtoolsDescriptor } from '@vectojs/core';
2
+ import type { Token, Tokens } from 'marked';
3
+ import { MarkdownContainer } from './markdown-entities';
4
+ /**
5
+ * Begin (or join) loading the math engine, resolving once formulas typeset
6
+ * synchronously.
7
+ *
8
+ * Idempotent and safe to call from anywhere: the promise is cached, so N callers
9
+ * and N documents share one module load. Rejection is swallowed deliberately —
10
+ * a failed load must degrade to TeX source in a CodeBlock, not reject a caller's
11
+ * `await close()` or leave an unhandled rejection on the page. `mathConverter`
12
+ * simply stays null and every formula keeps rendering as source.
13
+ */
14
+ export declare function preloadMathJax(): Promise<void>;
15
+ /** Whether formulas can be typeset without waiting. Exposed for tests. */
16
+ export declare function isMathJaxReady(): boolean;
17
+ /** Convert an `ex` measurement to px at a given font size. */
18
+ export declare function exToPx(ex: number, fontSize: number): number;
19
+ /**
20
+ * The px size out of a CSS font shorthand (`'bold 28px Inter, sans-serif'` → 28).
21
+ *
22
+ * `undefined` when there is no `px` size to read, so the caller can fall back to
23
+ * the theme rather than silently substituting a wrong number. `@vectojs/ui` has an
24
+ * equivalent `fontSizePx` in `measure.ts`, but it is not re-exported from that
25
+ * package's barrel and it returns a hardcoded 16 on failure, which would hide a
26
+ * malformed font behind a plausible-looking box.
27
+ *
28
+ * Deliberately not a regex. The obvious `/(\d+(?:\.\d+)?)px/` is polynomial: the
29
+ * digit run can backtrack from every start position when no `px` follows, so a
30
+ * font string of many digits costs O(n^2) — CodeQL flagged exactly that here
31
+ * (`js/polynomial-redos`, high), and `font` comes from caller-supplied theme
32
+ * input. Anchoring on `px` first and walking back over the digits is linear.
33
+ */
34
+ export declare function fontSizeFromFont(font: string): number | undefined;
35
+ /**
36
+ * A converted formula: its SVG data URI and the intrinsic box scraped off it.
37
+ *
38
+ * The box is in **`ex` units**, not px, because `ex` is font-relative and one
39
+ * cached conversion is reused across runs of different sizes (inline math in a
40
+ * heading versus body prose). Callers resolve to px with {@link exToPx} at the
41
+ * size of the run the formula actually sits in.
42
+ */
43
+ interface MathRender {
44
+ uri: string;
45
+ /** Intrinsic width in `ex`. */
46
+ widthEx: number;
47
+ /** Intrinsic height in `ex`, ascent + descent. */
48
+ heightEx: number;
49
+ /**
50
+ * How far the box descends below the text baseline, in `ex`, as a positive
51
+ * number.
52
+ *
53
+ * MathJax emits this as `style="vertical-align:-N ex"` on the root `<svg>`.
54
+ * Measured on 8 formulas spanning subscripts, superscripts, fractions, big
55
+ * operators and radicals, it equals the viewBox-derived depth exactly, so it
56
+ * is read straight off the attribute rather than computed from the viewBox.
57
+ */
58
+ depthEx: number;
59
+ }
60
+ /**
61
+ * Subscribe `notify` to inline-formula raster decodes.
62
+ *
63
+ * A function rather than exporting the `Set`, so the collection stays private to
64
+ * this module once the math cluster moves out of `Markdown.ts`. Idempotent per
65
+ * closure, since `Set` de-duplicates.
66
+ */
67
+ export declare function subscribeInlineMathRaster(notify: () => void): void;
68
+ /**
69
+ * Unsubscribe `notify`.
70
+ *
71
+ * Must be called on teardown: the set is module-level and lives as long as the
72
+ * page, so a retained closure retains the whole entity tree that created it.
73
+ */
74
+ export declare function unsubscribeInlineMathRaster(notify: () => void): void;
75
+ /**
76
+ * Paint one inline formula into the box the layout engine reserved for it.
77
+ *
78
+ * Draws nothing until the raster has decoded — one frame of blank box, then a
79
+ * repaint via {@link inlineMathRasterWaiters}. Drawing a placeholder slab instead
80
+ * would flash a grey rectangle mid-sentence on every first paint.
81
+ */
82
+ export declare function paintInlineMath(uri: string, surface: InlineObjectSurface, box: InlineObjectBox): void;
83
+ export declare const MATH_LANGS: Set<string>;
84
+ /**
85
+ * Whether a token subtree contains an `inlineMath` token.
86
+ *
87
+ * Recursive because inline math nests: inside `strong`/`em`, a link's children, a
88
+ * list item's tokens, a blockquote, or a table cell. Used only to decide whether
89
+ * to start the lazy MathJax load, so a false negative delays typesetting rather
90
+ * than corrupting output — but a missed nesting site means a formula in, say, a
91
+ * table cell never typesets at all.
92
+ */
93
+ export declare function containsInlineMath(token: Token): boolean;
94
+ /**
95
+ * Whether a fenced-code token's source actually contains its closing fence.
96
+ *
97
+ * `marked` lexes an unterminated fence as a COMPLETE `code` token as soon as the
98
+ * info string is read, so a formula streamed a few characters at a time arrives
99
+ * as a long run of whole tokens, nearly all of them syntactically invalid TeX.
100
+ * The token carries no "closed" flag (probed against marked 18.0.7: the keys are
101
+ * exactly `type`, `raw`, `lang`, `text` whether or not the fence is closed), so
102
+ * `raw` is the only signal. Per CommonMark a closing fence is a line of at least
103
+ * as many of the SAME fence character as the opening, indented at most three
104
+ * spaces, followed by nothing but whitespace.
105
+ */
106
+ export declare function isFenceClosed(raw: string): boolean;
107
+ /**
108
+ * Whether this `code` token renders as a typeset formula rather than a CodeBlock.
109
+ *
110
+ * Single source of truth for that decision, because three places have to agree
111
+ * on it: the render arm, the top-level in-place update path, and the blockquote
112
+ * tail path. If the two update paths disagreed with the renderer they would call
113
+ * `setCode` on an entity that is not a CodeBlock, or leave a CodeBlock on screen
114
+ * where a formula belongs.
115
+ *
116
+ * An empty closed fence is deliberately NOT math: it renders as the empty
117
+ * CodeBlock any other empty fence would, rather than as a zero-width image.
118
+ */
119
+ export declare function rendersAsMath(token: Tokens.Code): boolean;
120
+ /**
121
+ * A cached formula render, or null when one is not available *yet*.
122
+ *
123
+ * Null deliberately means two things at once — "MathJax is not loaded" and "the
124
+ * conversion failed" — because the caller's response to both is identical: show
125
+ * the TeX source in a CodeBlock. Keeping them one signal is what let the render
126
+ * arm stay unchanged when loading became lazy. A cache hit is answered even
127
+ * before MathJax loads, so a formula already converted once (the common case on
128
+ * a re-render, and for the closed fence whose `raw` grows by a trailing newline)
129
+ * never waits on the module.
130
+ *
131
+ * The cache lookup being ahead of the `mathConverter` check is intentional but
132
+ * currently unobservable: `mathConverter` only ever goes null -> set, so nothing
133
+ * can be in the cache while it is still null. Swapping the two lines changes no
134
+ * behaviour today (confirmed by mutation: no test fails). It is written this way
135
+ * so the cache stays authoritative if the converter ever becomes resettable.
136
+ */
137
+ export declare function renderMathToSVGDataURI(formula: string, displayMode: boolean, color: string): MathRender | null;
138
+ /**
139
+ * One display formula: a `$$..$$` block or a closed ```` ```math ```` fence.
140
+ *
141
+ * A named class rather than a bare {@link MarkdownContainer} because the formula
142
+ * needs a stable handle, and after the switch to an inline object it has none:
143
+ * the typeset raster lives in a `paint` closure captured by the span, so removing
144
+ * the `Image` entity left nothing exposing either the source or the SVG bytes.
145
+ * Devtools, tests, and anything auditing what a formula actually rendered all
146
+ * want that. `markstream-vue` reaches the same conclusion from the DOM side and
147
+ * publishes `data-markstream-mode` on its math node for the same reason.
148
+ *
149
+ * Deliberately carries no typeset-vs-source flag. A formula MathJax has not
150
+ * converted yet renders as a bare {@link CodeBlock} of its TeX, which this class
151
+ * does not wrap — wrapping it would put a container between `content` and a
152
+ * `CodeBlock` that the streamed `setCode` path locates by type. So a flag would
153
+ * have exactly one reachable value, which is the dead-API trap that cost CTX-0208
154
+ * a debugging pass. Add it together with wrapping the fallback, or not at all.
155
+ */
156
+ export declare class MathBlock extends MarkdownContainer {
157
+ /**
158
+ * The TeX source, exactly as written between the delimiters.
159
+ *
160
+ * Also the projected text and the accessible name, so this is the one string a
161
+ * reader can find, select, and copy.
162
+ */
163
+ readonly formula: string;
164
+ /** The `data:image/svg+xml` URI of the typeset glyphs. */
165
+ readonly svgUri: string;
166
+ constructor(formula: string, svgUri: string);
167
+ getDevtoolsDescriptor(): DevtoolsDescriptor;
168
+ }
169
+ export {};
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Color, typography and spacing tokens for `@vectojs/markdown`.
3
+ *
4
+ * Split out of `Markdown.ts` so `CodeBlock` and the inline renderer can take a
5
+ * theme without importing the whole component, which would make the module
6
+ * graph cyclic. See `forge/decisions/file-decomposition-2026-08.md`.
7
+ */
8
+ /**
9
+ * Color, typography and spacing theme for Markdown rendering.
10
+ *
11
+ * The shape is deliberately **flat**, not a nested token tree. Every key is
12
+ * optional and merged over {@link DEFAULT_THEME} by a single spread, so adding a
13
+ * key is backward compatible and a caller may override exactly one value
14
+ * without restating a group. A nested `{ colors: {...}, spacing: {...} }` shape
15
+ * would need a deep merge and would break every existing caller.
16
+ *
17
+ * Sizes and spacing are **numbers in px**, not CSS strings, because the values
18
+ * are consumed by canvas layout arithmetic rather than by a stylesheet.
19
+ */
20
+ export interface MarkdownTheme {
21
+ /** Body text color. */
22
+ textColor?: string;
23
+ /** Heading text color. */
24
+ headingColor?: string;
25
+ /** Code text color (inline + block). */
26
+ codeColor?: string;
27
+ /** Code block background color. */
28
+ codeBgColor?: string;
29
+ /** Blockquote border/accent color. */
30
+ quoteBorderColor?: string;
31
+ /**
32
+ * Blockquote text color. Defaults to {@link MarkdownTheme.textColor} so
33
+ * blockquote body text matches surrounding prose unless overridden.
34
+ */
35
+ quoteTextColor?: string;
36
+ /** Horizontal-rule color. */
37
+ hrColor?: string;
38
+ /** Table background color. */
39
+ tableBgColor?: string;
40
+ /** Table header background color. */
41
+ tableHeaderBgColor?: string;
42
+ /** Link text color. */
43
+ linkColor?: string;
44
+ /**
45
+ * Color for TeX source shown verbatim when a formula could not be typeset.
46
+ * Deliberately distinct from body text so an untypeset formula is visible as
47
+ * a failure rather than reading as prose.
48
+ */
49
+ mathFallbackColor?: string;
50
+ /** Code-block keyword color. */
51
+ syntaxKeywordColor?: string;
52
+ /** Code-block string-literal color. */
53
+ syntaxStringColor?: string;
54
+ /** Code-block comment color. */
55
+ syntaxCommentColor?: string;
56
+ /** Code-block numeric-literal color. */
57
+ syntaxNumberColor?: string;
58
+ /** Body font. */
59
+ bodyFont?: string;
60
+ /** Monospace font for code. */
61
+ codeFont?: string;
62
+ /** Base font size in px. */
63
+ fontSize?: number;
64
+ /**
65
+ * Font sizes in px for heading depths 1-6. A shorter array is padded by
66
+ * repeating its last entry; depths past the end clamp to the last entry.
67
+ */
68
+ headingSizes?: readonly number[];
69
+ /** Code-block font size in px. */
70
+ codeFontSize?: number;
71
+ /**
72
+ * Table cell font size in px.
73
+ *
74
+ * Left `undefined` by default and **derived** as `fontSize - 2` (clamped to
75
+ * at least 1) rather than defaulted to a literal, because that is how it was
76
+ * hardcoded before it became a key: a caller who raises only `fontSize` must
77
+ * keep getting a proportionally larger table, which a fixed default would
78
+ * silently break.
79
+ */
80
+ tableFontSize?: number;
81
+ /** Code-block line height in px. */
82
+ codeLineHeight?: number;
83
+ /**
84
+ * Line height in px for body text drawn through the plain-`Text` fallback
85
+ * path (an unrecognised block token that still carries `text`).
86
+ */
87
+ bodyLineHeight?: number;
88
+ /** Vertical gap in px between top-level blocks. */
89
+ blockGap?: number;
90
+ /** Inner padding in px of a code block. */
91
+ codePadding?: number;
92
+ /** Corner radius in px of a code block. */
93
+ codeRadius?: number;
94
+ /** Vertical gap in px between list items. */
95
+ listGap?: number;
96
+ /** Vertical gap in px between blocks inside one multi-block list item. */
97
+ listItemGap?: number;
98
+ /** Left indent in px of a blockquote's contents. */
99
+ quoteIndent?: number;
100
+ /** Width in px of a blockquote's accent border. */
101
+ quoteBorderWidth?: number;
102
+ /** Vertical gap in px between blocks inside a blockquote. */
103
+ quoteInnerGap?: number;
104
+ /** Corner radius in px of an image. */
105
+ imageRadius?: number;
106
+ /**
107
+ * Height of an image that renders *inline*, as a multiple of the run's font
108
+ * size.
109
+ *
110
+ * Applies only where an image shares a line with text — a heading or a table
111
+ * cell. An image that is its own block (the ordinary `![alt](url)` paragraph)
112
+ * still renders at its natural size capped to the available width, and ignores
113
+ * this.
114
+ *
115
+ * A cap rather than the natural size, because an inline object's box is fixed
116
+ * when the span is collected while the natural size is known only after the
117
+ * decode, and because a 512px logo written into an `h1` would otherwise tower
118
+ * over its own heading. The width follows the natural aspect ratio, so a wide
119
+ * badge stays wide. `1.15` keeps a square icon a little shorter than the line it
120
+ * sits on, which is where a cap-height glyph puts its own ink.
121
+ */
122
+ inlineImageScale?: number;
123
+ }
124
+ /**
125
+ * Resolved defaults. Every key of {@link MarkdownTheme} has an entry here, so a
126
+ * resolved theme is `Required<MarkdownTheme>` and no consumer needs a fallback.
127
+ *
128
+ * Two entries are placeholders that {@link resolveTheme} overwrites when the
129
+ * caller did not set them: `quoteTextColor` (follows `textColor`) and
130
+ * `tableFontSize` (derived from `fontSize`). They still carry a literal so this
131
+ * object satisfies `Required<MarkdownTheme>`; read {@link resolveTheme} for the
132
+ * value a caller actually gets.
133
+ */
134
+ export declare const DEFAULT_THEME: Required<MarkdownTheme>;
135
+ /**
136
+ * Merge a caller's partial theme over {@link DEFAULT_THEME}.
137
+ *
138
+ * `tableFontSize` is **derived** from the resolved `fontSize` when the caller
139
+ * did not set it explicitly, so raising only `fontSize` still scales tables.
140
+ * A plain spread cannot express that: `DEFAULT_THEME` has to satisfy
141
+ * `Required<MarkdownTheme>`, so its literal would always win over the
142
+ * derivation.
143
+ */
144
+ export declare function resolveTheme(theme?: MarkdownTheme): Required<MarkdownTheme>;
145
+ /**
146
+ * Font size in px for a 1-based heading depth, clamping past the end of
147
+ * `headingSizes` and tolerating a short or empty array.
148
+ *
149
+ * Extracted so the heading renderer and any caller inspecting the scale agree;
150
+ * an inline `sizes[Math.min(depth - 1, 5)]` silently yields `undefined` for a
151
+ * theme that supplied fewer than six sizes.
152
+ */
153
+ export declare function headingSize(theme: Required<MarkdownTheme>, depth: number): number;