@tabnas/markdown 0.6.1 → 0.7.1

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,284 @@
1
+ /* Copyright (c) 2026 Richard Rodger, MIT License */
2
+
3
+ // The engine-facing inline driver: a second, package-private engine instance
4
+ // whose lexer IS the inline scanner. Each custom matcher is a thin adapter
5
+ // over one `InlineParser` scanner method — the same methods the engine-free
6
+ // path runs — so the anti-drift rule holds: recognition, node building, the
7
+ // delimiter stack and the bracket stack all live once, in `inline.ts`.
8
+ //
9
+ // Two engine features carry the design:
10
+ //
11
+ // * **Context-sensitive matchers.** Every adapter reaches the per-parse
12
+ // state through `lex.ctx.u.inl`. That is what makes the `]` matcher the
13
+ // cursor owner: `parseCloseBracket` consumes a successful link tail
14
+ // directly from the subject, and because that happens at *lex* time, the
15
+ // engine's point advances past the tail and everything after it is lexed
16
+ // fresh. The corpus-blind straddle cases (a backtick inside a successful
17
+ // destination or title, followed by a code span — see test/spec/mixed.tsv)
18
+ // are correct by construction: no token is ever cut across a tail
19
+ // boundary, so no re-lex or resynchronization machinery exists at all.
20
+ //
21
+ // * **Matcher order as precedence.** At `<`, the autolink adapter runs
22
+ // before the raw-HTML adapter, exactly as the hand scanner's dispatch
23
+ // tries them; the text-run adapter and the single-character literal
24
+ // fallback come last. The registered order *is* §6.3's precedence table,
25
+ // visible in the instance's configuration instead of buried in a switch.
26
+ //
27
+ // The one-token effects are applied at lex time on purpose. The engine's
28
+ // rule loop reads lookahead tokens before earlier alts' actions run, so any
29
+ // state a matcher consults — the tree for break trimming, the bracket stack
30
+ // for `]` — must be current when the *lexer* reaches it, not when the
31
+ // parser does. The `inline` rule therefore carries no per-token actions:
32
+ // the tokens are the observable record of the scan (subscribe with
33
+ // `tn.sub({lex})` and watch), and the rule's close action finalizes the
34
+ // block exactly as the engine-free `parse` tail does.
35
+
36
+ import { Tabnas } from '@tabnas/parser'
37
+ import type { Context, MakeLexMatcher } from '@tabnas/parser'
38
+
39
+ import { InlineParser, forEachInlineBlock } from './inline.ts'
40
+ import type { MdNode } from './node.ts'
41
+ import type { ParserOptions, RefMap } from './options.ts'
42
+
43
+ const C_NEWLINE = 10
44
+ const C_BANG = 33
45
+ const C_AMPERSAND = 38
46
+ const C_ASTERISK = 42
47
+ const C_LESSTHAN = 60
48
+ const C_OPEN_BRACKET = 91
49
+ const C_BACKSLASH = 92
50
+ const C_CLOSE_BRACKET = 93
51
+ const C_UNDERSCORE = 95
52
+ const C_BACKTICK = 96
53
+ const C_TILDE = 126
54
+
55
+ /** The per-parse state carried on `ctx.u.inl` — seeded by `parse.prepare`. */
56
+ export type InlineState = {
57
+ parser: InlineParser
58
+ block: MdNode
59
+ }
60
+
61
+ /** The inline token alphabet, in the order the rule's alt lists them. */
62
+ export const INLINE_TOKENS = [
63
+ '#IBK', // line break (soft or hard, decided against the tree)
64
+ '#IES', // backslash escape
65
+ '#ICS', // code span (or an unmatched literal backtick run)
66
+ '#IDL', // emphasis/strikethrough delimiter run
67
+ '#IOB', // [
68
+ '#IBG', // ![
69
+ '#ICB', // ] — including a consumed inline link tail
70
+ '#IAL', // angle autolink
71
+ '#IHT', // raw HTML tag
72
+ '#IEN', // entity reference
73
+ '#ITX', // ordinary text run
74
+ '#ILI', // single literal character nothing else claimed
75
+ ]
76
+
77
+ /**
78
+ * Adapt one `InlineParser` scanner method into a lexer matcher: sync the
79
+ * parser to the engine's point, run the method (which appends nodes and
80
+ * moves `pos`), and emit a token covering exactly what it consumed.
81
+ * `name` may depend on what was consumed (`!` vs `![`).
82
+ */
83
+ function adapt(
84
+ guard: (cc: number, opts: ParserOptions) => boolean,
85
+ run: (p: InlineParser, block: MdNode) => boolean,
86
+ name: (p: InlineParser, start: number) => string,
87
+ opts: ParserOptions,
88
+ ): any {
89
+ return (lex: any) => {
90
+ const st: InlineState | undefined = lex.ctx && lex.ctx.u && lex.ctx.u.inl
91
+ if (undefined === st) return undefined
92
+
93
+ const pnt = lex.pnt
94
+ const src: string = lex.src
95
+ if (pnt.sI >= src.length) return undefined
96
+ if (!guard(src.charCodeAt(pnt.sI), opts)) return undefined
97
+
98
+ const p = st.parser
99
+ const start = pnt.sI
100
+ p.pos = start
101
+ if (!run(p, st.block)) return undefined
102
+
103
+ const consumed = src.slice(start, p.pos)
104
+ const tkn = lex.token(name(p, start), undefined, consumed, pnt)
105
+
106
+ // Row/column bookkeeping is observability data: a matcher that consumed
107
+ // line endings (soft breaks, multiline code spans or titles) moved the
108
+ // scan onto a later row, and later tokens must say so.
109
+ let newlines = 0
110
+ let lastNl = -1
111
+ for (let i = 0; i < consumed.length; i++) {
112
+ if (10 === consumed.charCodeAt(i)) {
113
+ newlines += 1
114
+ lastNl = i
115
+ }
116
+ }
117
+ if (0 < newlines) {
118
+ pnt.rI += newlines
119
+ pnt.cI = consumed.length - lastNl
120
+ } else {
121
+ pnt.cI += consumed.length
122
+ }
123
+ pnt.sI = p.pos
124
+ return tkn
125
+ }
126
+ }
127
+
128
+ /** Build the inline matcher set for one option set. GFM strikethrough is a
129
+ * separate matcher registered only when the option is on — the option
130
+ * reshapes the registered matcher set itself, so the base dialect's lexer
131
+ * simply has no tilde matcher rather than a disabled branch inside one. */
132
+ function inlineMatchers(opts: ParserOptions): Record<string, { order: number; make: MakeLexMatcher }> {
133
+ const fixed = (n: string) => () => n
134
+ const m = (
135
+ order: number,
136
+ guard: (cc: number, o: ParserOptions) => boolean,
137
+ run: (p: InlineParser, block: MdNode) => boolean,
138
+ name: (p: InlineParser, start: number) => string,
139
+ ) => ({ order, make: (() => adapt(guard, run, name, opts)) as MakeLexMatcher })
140
+
141
+ return {
142
+ inlBreak: m(1.00e5, (c) => C_NEWLINE === c, (p, b) => p.parseNewline(b), fixed('#IBK')),
143
+ inlEscape: m(1.01e5, (c) => C_BACKSLASH === c, (p, b) => p.parseBackslash(b), fixed('#IES')),
144
+ inlCode: m(1.02e5, (c) => C_BACKTICK === c, (p, b) => p.parseBackticks(b), fixed('#ICS')),
145
+ inlDelim: m(
146
+ 1.03e5,
147
+ (c) => C_ASTERISK === c || C_UNDERSCORE === c,
148
+ (p, b) => p.handleDelim(p.subject.charCodeAt(p.pos), b),
149
+ fixed('#IDL'),
150
+ ),
151
+ inlOpenBracket: m(1.04e5, (c) => C_OPEN_BRACKET === c, (p, b) => p.parseOpenBracket(b), fixed('#IOB')),
152
+ // `!` introduces an image only before `[`; otherwise it is a literal.
153
+ inlBang: m(
154
+ 1.05e5,
155
+ (c) => C_BANG === c,
156
+ (p, b) => p.parseBang(b),
157
+ (p, start) => (2 === p.pos - start ? '#IBG' : '#ILI'),
158
+ ),
159
+ // The cursor owner: on a successful inline link tail, `parseCloseBracket`
160
+ // has already consumed it when this token is emitted.
161
+ inlCloseBracket: m(1.06e5, (c) => C_CLOSE_BRACKET === c, (p, b) => p.parseCloseBracket(b), fixed('#ICB')),
162
+ // At `<`: autolink first, raw HTML second — matcher order is precedence.
163
+ inlAutolink: m(1.07e5, (c) => C_LESSTHAN === c, (p, b) => p.parseAutolink(b), fixed('#IAL')),
164
+ inlHtmlTag: m(1.08e5, (c) => C_LESSTHAN === c, (p, b) => p.parseHtmlTag(b), fixed('#IHT')),
165
+ inlEntity: m(1.09e5, (c) => C_AMPERSAND === c, (p, b) => p.parseEntity(b), fixed('#IEN')),
166
+ inlText: m(1.10e5, () => true, (p, b) => p.parseString(b), fixed('#ITX')),
167
+ // Nothing claimed the character: one literal char, same as the hand
168
+ // scanner's dispatch fallback.
169
+ inlLiteral: m(1.11e5, () => true, (p, b) => p.parseLiteralChar(b), fixed('#ILI')),
170
+ // GFM strikethrough: `~` runs join the delimiter stack only when the
171
+ // dialect is on (without it, the text matcher swallows tilde runs).
172
+ ...(true === opts.gfm
173
+ ? {
174
+ inlTilde: m(
175
+ 1.035e5,
176
+ (c) => C_TILDE === c,
177
+ (p, b) => p.handleDelim(C_TILDE, b),
178
+ fixed('#IDL'),
179
+ ),
180
+ }
181
+ : {}),
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Build the package-private inline engine instance for one option set. All
187
+ * built-in matchers are disabled; the matcher set above is the complete
188
+ * lexical description of the inline phase.
189
+ */
190
+ export function makeInlineTn(opts: ParserOptions): Tabnas {
191
+ const tn = new Tabnas()
192
+
193
+ tn.options({ rule: { start: 'inline' } })
194
+
195
+ // Human descriptions for the inline alphabet, surfaced in railroad
196
+ // diagram legends (read off the live config by @tabnas/railroad).
197
+ tn.options({
198
+ config: {
199
+ modify: {
200
+ 'inl-tokendesc': (cfg: any) => {
201
+ cfg.tokenDesc = Object.assign(cfg.tokenDesc || {}, {
202
+ '#IBK': 'line break (soft or hard)',
203
+ '#IES': 'backslash escape',
204
+ '#ICS': 'code span',
205
+ '#IDL': 'emphasis/strikethrough delimiter run',
206
+ '#IOB': 'link opener [',
207
+ '#IBG': 'image opener ![',
208
+ '#ICB': 'closer ] (with any consumed inline link tail)',
209
+ '#IAL': 'angle autolink',
210
+ '#IHT': 'raw HTML tag',
211
+ '#IEN': 'entity reference',
212
+ '#ITX': 'text run',
213
+ '#ILI': 'literal character',
214
+ })
215
+ },
216
+ },
217
+ },
218
+ })
219
+ tn.options({
220
+ space: { lex: false },
221
+ line: { lex: false },
222
+ string: { lex: false },
223
+ comment: { lex: false },
224
+ number: { lex: false },
225
+ value: { lex: false },
226
+ text: { lex: false },
227
+ lex: { match: inlineMatchers(opts) },
228
+ parse: {
229
+ prepare: {
230
+ 'inl-reset': (_tn: Tabnas, ctx: Context, meta?: any) => {
231
+ ;(ctx.u as any).inl =
232
+ meta && meta.md ? { parser: meta.md.inl, block: meta.md.block } : undefined
233
+ },
234
+ },
235
+ },
236
+ })
237
+
238
+ // One OR-position over the whole alphabet: any inline token loops the
239
+ // rule; the empty alts fall through to the close on #ZZ. The names are
240
+ // minted to tins here — the same table the matchers' lex.token calls use.
241
+ const toks = INLINE_TOKENS.map((n) => tn.token(n))
242
+
243
+ tn.rule('inline', (rs) => {
244
+ return rs
245
+ .clear()
246
+ .open([{ s: [toks], r: 'inline' }, {}])
247
+ .close([
248
+ {
249
+ s: '#ZZ',
250
+ a: (_r: any, ctx: Context) => {
251
+ const st: InlineState = (ctx.u as any).inl
252
+ st.parser.finishBlock(st.block)
253
+ },
254
+ },
255
+ {},
256
+ ])
257
+ })
258
+
259
+ return tn
260
+ }
261
+
262
+ /**
263
+ * The engine-path inline phase: the same block walk as `parseInlines`, with
264
+ * each block's scan driven by the engine instance's lexer. An empty subject
265
+ * never reaches the engine (its empty-source path returns before the rules
266
+ * run), and finishes the block directly — a no-op resolve, exactly as the
267
+ * hand scanner's `parse` does.
268
+ */
269
+ export function parseInlinesEngine(
270
+ inlineTn: Tabnas,
271
+ doc: MdNode,
272
+ refmap: RefMap,
273
+ options: ParserOptions,
274
+ ): void {
275
+ const parser = new InlineParser(refmap, options)
276
+
277
+ forEachInlineBlock(doc, options, (block) => {
278
+ if (!parser.beginBlock(block)) {
279
+ parser.finishBlock(block)
280
+ return
281
+ }
282
+ inlineTn.parse(parser.subject, { md: { inl: parser, block } })
283
+ })
284
+ }