@tabnas/markdown 0.6.2 → 0.7.2
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/dist/block.d.ts +257 -0
- package/dist/block.js +213 -68
- package/dist/block.js.map +1 -1
- package/dist/engine-block.d.ts +69 -0
- package/dist/engine-block.js +99 -0
- package/dist/engine-block.js.map +1 -0
- package/dist/engine-inline.d.ts +25 -0
- package/dist/engine-inline.js +237 -0
- package/dist/engine-inline.js.map +1 -0
- package/dist/inline.d.ts +250 -0
- package/dist/inline.js +395 -215
- package/dist/inline.js.map +1 -1
- package/dist/markdown.d.ts +2 -3
- package/dist/markdown.js +91 -63
- package/dist/markdown.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/block.ts +224 -75
- package/src/engine-block.ts +151 -0
- package/src/engine-inline.ts +284 -0
- package/src/inline.ts +422 -218
- package/src/markdown.ts +95 -65
|
@@ -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
|
+
}
|