@vectojs/markdown 0.14.0 → 0.16.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.
- package/README.md +79 -17
- package/dist/Markdown.d.ts +61 -154
- package/dist/MarkdownWorkerSource.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1706 -1353
- package/dist/index.mjs +1519 -1175
- package/dist/markdown-code.d.ts +102 -0
- package/dist/markdown-entities.d.ts +33 -0
- package/dist/markdown-footnote.d.ts +133 -0
- package/dist/markdown-image.d.ts +126 -0
- package/dist/markdown-inline.d.ts +58 -0
- package/dist/markdown-math.d.ts +169 -0
- package/dist/theme.d.ts +179 -0
- package/package.json +8 -7
|
@@ -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,133 @@
|
|
|
1
|
+
import type { TokenizerAndRendererExtension } from 'marked';
|
|
2
|
+
/**
|
|
3
|
+
* GFM footnotes: the two `marked` tokenizer extensions, their token types, and
|
|
4
|
+
* the marker text a reference renders as.
|
|
5
|
+
*
|
|
6
|
+
* This module is the **single** registration source for both `marked.use` call
|
|
7
|
+
* sites — `Markdown.ts` and `MarkdownWorker.ts`. They must agree exactly: a
|
|
8
|
+
* worker lexing with a different extension set than the main thread produces
|
|
9
|
+
* tokens the renderer has no arm for, which is why `MarkdownWorker.ts` already
|
|
10
|
+
* carries a comment demanding lockstep for the math tokenizers. Sharing one
|
|
11
|
+
* array makes the divergence impossible rather than merely discouraged, and it
|
|
12
|
+
* costs nothing in the worker bundle: `scripts/build-worker.js` runs esbuild
|
|
13
|
+
* with `bundle: true`, so this import is inlined into the emitted worker source.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately holds **no** entity, theme or `@vectojs/*` import. The whole
|
|
16
|
+
* module is inlined into the worker string, so a dependency on the render layer
|
|
17
|
+
* would drag the UI packages into a worker that only lexes text.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* A footnote reference — `[^1]` or `[^note]` — as it appears mid-sentence.
|
|
21
|
+
*
|
|
22
|
+
* Carries `label`, not `text`. That is load-bearing in two places: `Markdown`'s
|
|
23
|
+
* `producesEntity` falls back to `'text' in token` for an unknown type and
|
|
24
|
+
* `renderToken`'s `default:` arm renders `.text` as a plain block, so a token
|
|
25
|
+
* spelling its payload `text` would silently route through the fallback and
|
|
26
|
+
* reproduce the very defect this replaces. A distinct field name makes an
|
|
27
|
+
* unhandled footnote token render nothing instead of rendering wrongly.
|
|
28
|
+
*/
|
|
29
|
+
export interface FootnoteRefToken {
|
|
30
|
+
type: 'footnoteRef';
|
|
31
|
+
raw: string;
|
|
32
|
+
/** The label exactly as written between `[^` and `]`, e.g. `1` or `note`. */
|
|
33
|
+
label: string;
|
|
34
|
+
}
|
|
35
|
+
/** A footnote definition line — `[^1]: The note.` — as its own block. */
|
|
36
|
+
export interface FootnoteDefToken {
|
|
37
|
+
type: 'footnoteDef';
|
|
38
|
+
raw: string;
|
|
39
|
+
/** The label exactly as written, matching a {@link FootnoteRefToken}'s. */
|
|
40
|
+
label: string;
|
|
41
|
+
/** The note body, source text with inline markup left unparsed. */
|
|
42
|
+
body: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The two extensions, in the order they are registered.
|
|
46
|
+
*
|
|
47
|
+
* ## Neither supplies `start()`, and the block one must not
|
|
48
|
+
*
|
|
49
|
+
* `start()` is not the harmless optimisation it looks like. `Lexer.blockTokens`
|
|
50
|
+
* clips the text handed to the paragraph tokenizer whenever any extension's
|
|
51
|
+
* `startBlock` hook reports a position, and sets a flag that merges the *next*
|
|
52
|
+
* paragraph into the clipped one — so a `[^` anywhere ahead retroactively
|
|
53
|
+
* re-groups paragraphs already emitted.
|
|
54
|
+
*
|
|
55
|
+
* Measured against marked 18.0.7 on `incrementalLex.ts`'s own probe string,
|
|
56
|
+
* `'Term\n: definition-ish\n| partial | table |\n| --- |\n\nAfter.\n'` followed
|
|
57
|
+
* by `'\n[^1]: n\n'`:
|
|
58
|
+
*
|
|
59
|
+
* - with a block `start()`: `[paragraph, space, paragraph, space, footnoteDef]`
|
|
60
|
+
* — four content tokens collapse to three and the `Term` paragraph is **lost**
|
|
61
|
+
* - without it: `[paragraph, paragraph, space, paragraph, space, footnoteDef]`,
|
|
62
|
+
* identical to the no-extension baseline
|
|
63
|
+
*
|
|
64
|
+
* It also breaks the invariant every incremental offset is derived from — that
|
|
65
|
+
* the tokens' `raw` strings tile their source. With a block `start()`,
|
|
66
|
+
* `'A[^1] B[^2].\n\n[^1]: One.\n[^2]: Two.\n'` yields a paragraph whose `raw` is
|
|
67
|
+
* `'A\n[^1] B[^2].'`: a newline the source does not contain. A 2x2 matrix over
|
|
68
|
+
* (block `start()`, inline `start()`) attributes both symptoms to the block one
|
|
69
|
+
* alone.
|
|
70
|
+
*
|
|
71
|
+
* The inline `start()` is simply not load-bearing — with and without it the
|
|
72
|
+
* inline token sequence is identical — so it is omitted for the smaller surface.
|
|
73
|
+
*
|
|
74
|
+
* Omitting the block `start()` costs one spec-adjacent behaviour, and it is the
|
|
75
|
+
* behaviour that is already correct: a definition on the line directly after a
|
|
76
|
+
* paragraph line, with no blank line between, is absorbed into that paragraph.
|
|
77
|
+
* CommonMark says the same of link reference definitions (they cannot interrupt
|
|
78
|
+
* a paragraph) and GFM says it of footnote definitions.
|
|
79
|
+
*
|
|
80
|
+
* ## Why an extension is enough
|
|
81
|
+
*
|
|
82
|
+
* A `marked.use` extension **runs before** the built-in tokenizers, not after:
|
|
83
|
+
* `Lexer.blockTokens` and `Lexer.inlineTokens` both begin with
|
|
84
|
+
* `this.options.extensions?.<level>?.some(...)`, and `Marked.use` inserts with
|
|
85
|
+
* `unshift`. So `footnoteRef` claims `[^1]` ahead of the built-in `link` rule
|
|
86
|
+
* and `footnoteDef` claims the definition line ahead of the `def` rule. Both are
|
|
87
|
+
* necessary. Without them, marked 18.0.7 splits on whether the note body
|
|
88
|
+
* contains a space, because a link destination cannot:
|
|
89
|
+
*
|
|
90
|
+
* - `[^1]: The note.` → `[paragraph, space, paragraph]`, the definition showing
|
|
91
|
+
* as a stray body paragraph and `Here[^1] is text.` printing its raw syntax
|
|
92
|
+
* - `[^1]: Note.` → `[paragraph, space, def]`, where the reference becomes a
|
|
93
|
+
* real inline `link` with `href: 'Note.'` — a **clickable link to a garbage
|
|
94
|
+
* URL** — and the definition line vanishes from the output entirely
|
|
95
|
+
*
|
|
96
|
+
* A test written only against the first case passes while the second still ships.
|
|
97
|
+
*
|
|
98
|
+
* Claiming the definition before the `def` rule has a second effect worth
|
|
99
|
+
* naming: `tokens.links` stays empty, so a footnoted document no longer trips
|
|
100
|
+
* the permanent `'link-definition'` degrade in `incrementalLex.ts`.
|
|
101
|
+
*
|
|
102
|
+
* ## `renderer` is required but unreachable
|
|
103
|
+
*
|
|
104
|
+
* `marked.use` demands one for a custom token, and `@vectojs/markdown` never
|
|
105
|
+
* calls `marked.parse` — it renders from the token tree. Returning `raw`
|
|
106
|
+
* matches what the math extensions do, so an HTML round-trip is lossless rather
|
|
107
|
+
* than silently dropping the note.
|
|
108
|
+
*/
|
|
109
|
+
export declare const FOOTNOTE_EXTENSIONS: TokenizerAndRendererExtension[];
|
|
110
|
+
/**
|
|
111
|
+
* The visible marker for a label: `1` → `[1]`.
|
|
112
|
+
*
|
|
113
|
+
* Brackets rather than a raised superscript because {@link TextStyle} has no
|
|
114
|
+
* baseline shift, and `InlineObjectSurface` exposes only `drawImage` — so a
|
|
115
|
+
* genuinely raised marker would mean rasterizing text, at a cost far past what a
|
|
116
|
+
* reference marker is worth. Size alone carries the signal instead
|
|
117
|
+
* (`theme.footnoteMarkerScale`).
|
|
118
|
+
*
|
|
119
|
+
* Unicode superscript digits (`¹`) were the obvious alternative and are wrong
|
|
120
|
+
* here: they exist only for digits, so `[^note]` could not use them, and a
|
|
121
|
+
* document mixing numeric and named labels would render two different marker
|
|
122
|
+
* styles. Font coverage for the full set is also uneven.
|
|
123
|
+
*
|
|
124
|
+
* The `^` is dropped — `[^1]` is source syntax, `[1]` is the conventional
|
|
125
|
+
* printed marker — and the label is printed **as written** rather than
|
|
126
|
+
* renumbered to GFM's 1, 2, 3 by order of first reference. Renumbering needs
|
|
127
|
+
* document-wide state, which is exactly the non-local dependency that makes
|
|
128
|
+
* incremental lexing unsound: a reference arriving late would renumber markers
|
|
129
|
+
* already on screen. incremark reaches the same conclusion from the other
|
|
130
|
+
* direction, patching micromark to stop checking whether a definition exists so
|
|
131
|
+
* a reference can parse before its definition arrives.
|
|
132
|
+
*/
|
|
133
|
+
export declare function footnoteMarker(label: string): string;
|
|
@@ -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 — `[](dest)` is
|
|
25
|
+
* `paragraph > link > image` and `- item ` 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 {};
|