@peaceroad/markdown-it-renderer-fence 0.6.0 → 0.7.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 CHANGED
@@ -115,11 +115,12 @@ Note:
115
115
 
116
116
  - `samp` rendering for `samp`, `shell`, `console` languages.
117
117
  - line number wrapping via `start` (`line-number-start` long form) / `data-pre-start`.
118
- - line number skip/reset controls via `line-number-skip` / `line-number-reset`.
118
+ - line number skip/set controls via `line-number-skip` / `line-number-set`.
119
119
  - emphasized lines via `em-lines` / `emphasize-lines`.
120
120
  - optional line-end spacer via `lineEndSpanThreshold`.
121
121
  - optional pre-wrap support via `wrap` / `pre-wrap`.
122
122
  - comment line markers via `comment-mark`.
123
+ - sidecar line notes via immediate `line-notes` fence (`notes` alias).
123
124
 
124
125
  ### Fence Attribute Examples
125
126
 
@@ -157,7 +158,7 @@ console.log(a)
157
158
  Advanced line number control:
158
159
 
159
160
  ~~~md
160
- ```txt {start="25" line-number-skip="5" line-number-reset="6:136"}
161
+ ```txt {start="25" line-number-skip="5" line-number-set="6:136"}
161
162
  line1
162
163
  line2
163
164
  line3
@@ -168,7 +169,7 @@ line6
168
169
  ~~~
169
170
 
170
171
  ```html
171
- <pre><code class="language-txt" data-pre-start="25" data-pre-line-number-skip="5" data-pre-line-number-reset="6:136" style="counter-set:pre-line-number 25;">
172
+ <pre><code class="language-txt" data-pre-start="25" data-pre-line-number-skip="5" data-pre-line-number-set="6:136" style="counter-set:pre-line-number 25;">
172
173
  <span class="pre-line">line1</span>
173
174
  <span class="pre-line">line2</span>
174
175
  <span class="pre-line">line3</span>
@@ -229,38 +230,76 @@ echo 1
229
230
  </samp></pre>
230
231
  ```
231
232
 
233
+ Line notes:
234
+
235
+ ~~~md
236
+ ```js {start="5"}
237
+ const a = 1
238
+ console.log(a)
239
+ ```
240
+ ```line-notes
241
+ 1: setup {width="7em"}
242
+ 2:result {width="10em"}
243
+ ```
244
+ ~~~
245
+
246
+ ```html
247
+ <div class="pre-wrapper-line-notes" data-pre-line-notes-layout="anchor"><pre><code class="language-js" data-pre-start="5" data-pre-line-notes="true" style="counter-set:pre-line-number 5;"><span class="pre-line pre-line-has-end-note" data-pre-line-note-from="1" data-pre-line-note-to="1"><span class="pre-line-content" style="anchor-name:--pre-line-note-1;" aria-describedby="pre-line-note-1-1-1">const a = 1</span></span>
248
+ <span class="pre-line pre-line-has-end-note" data-pre-line-note-from="2" data-pre-line-note-to="2"><span class="pre-line-content" style="anchor-name:--pre-line-note-2;" aria-describedby="pre-line-note-1-1-2">console.log(a)</span></span>
249
+ </code></pre><div class="pre-line-note-layer"><div id="pre-line-note-1-1-1" class="pre-line-note" role="note" data-pre-line-note-from="1" data-pre-line-note-to="1" data-pre-line-note-label="1" style="position-anchor:--pre-line-note-1; --line-note-width:7em;">setup</div><div id="pre-line-note-1-1-2" class="pre-line-note" role="note" data-pre-line-note-from="2" data-pre-line-note-to="2" data-pre-line-note-label="2" style="position-anchor:--pre-line-note-2; --line-note-width:10em;">result</div></div></div>
250
+ ```
251
+
232
252
  ### Markup Notes
233
253
 
234
254
  - `useHighlightPre: true` keeps highlighter-provided `<pre><code>` when present.
235
255
  - In that passthrough path, line-splitting features are intentionally disabled:
236
256
  - line numbers
237
257
  - `line-number-skip`
238
- - `line-number-reset`
258
+ - `line-number-set`
239
259
  - `em-lines`
240
260
  - line-end spacer
241
261
  - `comment-mark`
262
+ - `line-notes`
242
263
  - `samp` conversion
243
264
 
244
- Official line-number CSS contract:
265
+ Reference line-number CSS contract:
245
266
 
246
- ```css
247
- .pre-line {
248
- counter-increment: pre-line-number;
249
- }
267
+ Minimum counter contract:
250
268
 
251
- .pre-line::before {
269
+ ```css
270
+ pre :is(code, samp)[data-pre-start] .pre-line::before {
252
271
  content: counter(pre-line-number);
253
272
  }
254
273
 
255
- .pre-line.pre-line-no-number {
256
- counter-increment: none;
274
+ pre :is(code, samp)[data-pre-start] .pre-line::after {
275
+ content: "";
276
+ counter-increment: pre-line-number;
257
277
  }
258
278
 
259
- .pre-line.pre-line-no-number::before {
279
+ pre :is(code, samp)[data-pre-start] .pre-line.pre-line-no-number::before {
260
280
  content: "";
261
281
  }
282
+
283
+ pre :is(code, samp)[data-pre-start] .pre-line.pre-line-no-number::after {
284
+ counter-increment: none;
285
+ }
262
286
  ```
263
287
 
288
+ Recommended display CSS:
289
+
290
+ - See [`example/line-number.css`](./example/line-number.css) for the full reference stylesheet.
291
+ - See [`example/line-number-sample.html`](./example/line-number-sample.html) for Markdown / Preview / HTML side-by-side examples.
292
+ - See [`example/line-notes.css`](./example/line-notes.css) for the companion line-note stylesheet.
293
+ - See [`example/line-notes-sample.html`](./example/line-notes-sample.html) for sidecar line-note examples.
294
+
295
+ Note:
296
+
297
+ - renderer-fence emits `counter-set` as the displayed line number value itself
298
+ - the reference CSS increments the counter in `span.pre-line::after`, so `start="1"` maps directly to `counter-set:pre-line-number 1;`
299
+ - `line-number-set="6:136"` similarly emits `counter-set:pre-line-number 136;` on that line wrapper
300
+ - the minimum contract above is the required part; `example/line-number.css` adds gutter width, divider line, and whitespace handling
301
+ - if a consumer uses a different counter strategy, its CSS must be aligned with this HTML contract
302
+
264
303
  ### Markup Options
265
304
 
266
305
  - `attrsOrder` (default: `['class', 'id', 'data-*', 'style']`): output attribute order (`data-*` wildcard supported).
@@ -287,13 +326,34 @@ Line-number attr syntax note:
287
326
 
288
327
  - `line-number-start` is the long form of `start`; rendered output still uses `data-pre-start`.
289
328
  - `line-number-skip` supports single values (`2`), ranges (`4-6`), and open-ended forms (`3-`).
290
- - `line-number-reset` uses `line:number` pairs (for example `6:136,14:220`).
291
- - `line-number-skip` / `line-number-reset` are applied only when source and highlighted logical line counts match; otherwise renderer falls back to plain sequential numbering.
329
+ - `line-number-set` uses `line:number` pairs (for example `6:136,14:220`).
330
+ - `line-number-skip` and `line-number-set` must not target the same source line. If they overlap, the skipped line stays blank and the conflicting `line-number-set` entry is ignored.
331
+ - `line-number-skip` / `line-number-set` are applied only when source and highlighted logical line counts match; otherwise renderer falls back to plain sequential numbering.
332
+
333
+ Line-note syntax note:
334
+
335
+ - `line-notes` is the canonical sidecar fence name; `notes` is a short alias.
336
+ - Sidecar notes are folded only when they immediately follow a code/samp fence.
337
+ - Immediate sidecar folding is fail-closed: if any non-empty line is malformed, a note entry is incomplete, or the same note start line appears twice, the `line-notes` fence stays as a normal literal fence block.
338
+ - Syntax supports `N: text`, `N-M: text`, `N:text`, and `N-M:text`.
339
+ - Per-note attrs can be appended in markdown-it-attrs style on the same line for single-line notes, for example `5: cache lookup key {width="8rem"}`.
340
+ - For multiline notes, prefer a trailing attrs-only continuation line, for example ` {width="11rem"}`.
341
+ - Continuation lines start with indentation (` ` or tab) and append multiline note text to the previous note.
342
+ - Output keeps note text outside `<code>/<samp>` in a sibling note layer for safer copy semantics.
343
+ - Line-note blocks use the external wrapper contract `pre-wrapper-line-notes`.
344
+ - `data-pre-line-notes-layout="anchor"` is added only for simple non-overlapping note layouts. The current check is intentionally lightweight: it uses the note start line plus the note text's explicit logical line count, so a two-line note starting at line `3` prevents another anchored note from starting at line `4`.
345
+ - This anchor-safety check does not measure CSS wrapping. If a note wraps because of narrow CSS width, consumer CSS should still be prepared to fall back to a below-block list.
346
+ - When anchor positioning is unavailable or the layout is not marked safe, consumer CSS should render notes as a below-block list. The reference stylesheet uses `data-pre-line-note-label` so the fallback still shows which source line each note belongs to.
347
+ - In anchor layouts, the rendered line HTML carries note metadata on `.pre-line`, and the anchor itself is attached to `.pre-line-content` so notes can start from the rendered code end.
348
+ - Accessibility contract: the rendered `.pre-line-content` carries `aria-describedby`, and each note item renders with a stable `id` plus `role="note"`, so assistive tech can associate the note text with its anchor line.
349
+ - When a sidecar fence is folded, the previous fence token `map` is extended to cover the absorbed note-fence lines so editor scroll/jump integrations can still treat the merged output as one source span.
350
+ - `line-notes` are applied only when source and highlighted logical line counts match; otherwise note markup is skipped.
292
351
 
293
352
  Migration note (`0.5.0`):
294
353
 
295
354
  - `comment-line` / `data-pre-comment-line` / `pre-comment-line` were removed
296
355
  - use `comment-mark` / `data-pre-comment-mark` / `pre-line-comment`
356
+ - `line-number-reset` was removed; use `line-number-set`
297
357
 
298
358
  ## Custom Highlight API Mode (Experimental / Advanced)
299
359
 
@@ -519,6 +579,7 @@ Then include that bridge in generated HTML:
519
579
  - `customHighlight.idPrefix` (default: `'hl-'`): generated per-block payload id prefix.
520
580
  - `customHighlight.scopePrefix` (default: `'hl'`): scope name prefix used for highlight registration.
521
581
  - `customHighlight.lineFeatureStrategy` (`'hybrid' | 'disable'`, default: `'hybrid'`): keep or disable line-span features in API rendering.
582
+ - In `NODE_ENV=development`, invalid known enum-like values and unknown `customHighlight` keys emit warn-once diagnostics while keeping normalized fallback behavior.
522
583
 
523
584
  ### API Helper Exports
524
585
 
@@ -532,11 +593,19 @@ Then include that bridge in generated HTML:
532
593
  - `customHighlightPayloadSchemaVersion`
533
594
  - `customHighlightPayloadSupportedVersions`
534
595
 
596
+ ### Runtime Version Policy
597
+
598
+ - `applyCustomHighlights(..., { strictVersion: true })` accepts only `customHighlightPayloadSupportedVersions` (currently `v:1`).
599
+ - `supportedVersion` / `supportedVersions` allow explicit accepted payload versions.
600
+ - If `strictVersion` is `true`, it takes precedence and custom accepted versions are ignored.
601
+
535
602
  ## Docs and Examples
536
603
 
537
604
  - API styling guide: `docs/custom-highlight-styling-guide.md`
538
605
  - default preset CSS sample: `docs/default-highlight-theme.css`
539
606
  - provider matrix page: `example/custom-highlight-provider-matrix.html`
607
+ - line-number sample: `example/line-number-sample.html`
608
+ - line-notes sample: `example/line-notes-sample.html`
540
609
 
541
610
  ## Tests and Benchmarks
542
611
 
@@ -547,8 +616,6 @@ Then include that bridge in generated HTML:
547
616
  - performance baseline: `npm run test:performance`
548
617
  - runtime apply benchmark: `npm run test:performance:runtime`
549
618
 
550
- ## License and Notices
619
+ ## License
551
620
 
552
621
  - Project license: MIT (`LICENSE`)
553
- - Third-party notices: `THIRD_PARTY_NOTICES.md`
554
- - `docs/default-highlight-theme.css` contains mappings derived from highlight.js theme families (BSD-3-Clause; see notices).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peaceroad/markdown-it-renderer-fence",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "In code blocks, the `<samp>` tag is used with the language keywords `samp`, `shell`, and `console`, and provides an option to display line numbers.",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -13,7 +13,6 @@
13
13
  "index.js",
14
14
  "src",
15
15
  "LICENSE",
16
- "THIRD_PARTY_NOTICES.md",
17
16
  "README.md"
18
17
  ],
19
18
  "type": "module",
@@ -42,10 +41,10 @@
42
41
  "repository": "https://github.com/peaceroad/p7d-markdown-it-renderer-fence",
43
42
  "license": "MIT",
44
43
  "devDependencies": {
45
- "@peaceroad/markdown-it-figure-with-p-caption": "^0.16.0",
44
+ "@peaceroad/markdown-it-figure-with-p-caption": "^0.17.0",
46
45
  "highlight.js": "^11.11.1",
47
- "markdown-it": "^14.1.0",
46
+ "markdown-it": "^14.1.1",
48
47
  "markdown-it-attrs": "^4.3.1",
49
- "shiki": "^3.21.0"
48
+ "shiki": "^4.0.2"
50
49
  }
51
50
  }
@@ -42,6 +42,15 @@ const knownCustomHighlightKeys = new Set([
42
42
  'theme',
43
43
  ])
44
44
 
45
+ const isExactValidOptionValue = (value, validSet) => {
46
+ return typeof value === 'string' && validSet.has(value)
47
+ }
48
+
49
+ const isNormalizedValidOptionValue = (value, validSet) => {
50
+ if (typeof value !== 'string') return false
51
+ return validSet.has(value.trim().toLowerCase())
52
+ }
53
+
45
54
  const validateCustomHighlightOptions = (rawOpt, normalizedOpt) => {
46
55
  if (!rawOpt || typeof rawOpt !== 'object') return
47
56
 
@@ -54,31 +63,31 @@ const validateCustomHighlightOptions = (rawOpt, normalizedOpt) => {
54
63
  }
55
64
  }
56
65
 
57
- if (rawOpt.provider != null && !validProviderSet.has(normalizedOpt.provider)) {
66
+ if (rawOpt.provider != null && !isExactValidOptionValue(rawOpt.provider, validProviderSet)) {
58
67
  warnOptionIssueOnce(
59
68
  'provider',
60
69
  `Invalid customHighlight.provider "${String(rawOpt.provider)}". Using "${normalizedOpt.provider}".`,
61
70
  )
62
71
  }
63
- if (rawOpt.fallback != null && !validFallbackSet.has(normalizedOpt.fallback)) {
72
+ if (rawOpt.fallback != null && !isExactValidOptionValue(rawOpt.fallback, validFallbackSet)) {
64
73
  warnOptionIssueOnce(
65
74
  'fallback',
66
75
  `Invalid customHighlight.fallback "${String(rawOpt.fallback)}". Using "${normalizedOpt.fallback}".`,
67
76
  )
68
77
  }
69
- if (rawOpt.transport != null && !validTransportSet.has(normalizedOpt.transport)) {
78
+ if (rawOpt.transport != null && !isExactValidOptionValue(rawOpt.transport, validTransportSet)) {
70
79
  warnOptionIssueOnce(
71
80
  'transport',
72
81
  `Invalid customHighlight.transport "${String(rawOpt.transport)}". Using "${normalizedOpt.transport}".`,
73
82
  )
74
83
  }
75
- if (rawOpt.lineFeatureStrategy != null && !validLineFeatureStrategySet.has(normalizedOpt.lineFeatureStrategy)) {
84
+ if (rawOpt.lineFeatureStrategy != null && !isExactValidOptionValue(rawOpt.lineFeatureStrategy, validLineFeatureStrategySet)) {
76
85
  warnOptionIssueOnce(
77
86
  'line-feature-strategy',
78
87
  `Invalid customHighlight.lineFeatureStrategy "${String(rawOpt.lineFeatureStrategy)}". Using "${normalizedOpt.lineFeatureStrategy}".`,
79
88
  )
80
89
  }
81
- if (rawOpt.shikiScopeMode != null && !validShikiScopeModeSet.has(normalizedOpt.shikiScopeMode)) {
90
+ if (rawOpt.shikiScopeMode != null && !isNormalizedValidOptionValue(rawOpt.shikiScopeMode, validShikiScopeModeSet)) {
82
91
  warnOptionIssueOnce(
83
92
  'shiki-scope-mode',
84
93
  `Invalid customHighlight.shikiScopeMode "${String(rawOpt.shikiScopeMode)}". Using "${normalizedOpt.shikiScopeMode}".`,
@@ -0,0 +1,23 @@
1
+ const highlightNameUnsafeReg = /[^A-Za-z0-9_-]+/g
2
+ const hyphenMultiReg = /-+/g
3
+
4
+ const sanitizeHighlightNamePart = (value, fallback = '') => {
5
+ let safe = String(value == null ? '' : value)
6
+ .replace(highlightNameUnsafeReg, '-')
7
+ .replace(hyphenMultiReg, '-')
8
+ .replace(/^-+|-+$/g, '')
9
+ if (!safe) safe = fallback
10
+ if (/^[0-9]/.test(safe)) safe = 'x-' + safe
11
+ if (safe.startsWith('--')) safe = safe.slice(2) || fallback
12
+ return safe
13
+ }
14
+
15
+ const sanitizeHighlightName = (name, prefix = '') => {
16
+ const safe = sanitizeHighlightNamePart(name, 'scope')
17
+ const prefixBase = sanitizeHighlightNamePart(prefix, '')
18
+ return prefixBase ? `${prefixBase}-${safe}` : safe
19
+ }
20
+
21
+ export {
22
+ sanitizeHighlightName,
23
+ }
@@ -4,6 +4,9 @@ import {
4
4
  finalizeCommonFenceOption,
5
5
  prepareFenceRenderContext,
6
6
  } from '../fence/render-shared.js'
7
+ import {
8
+ installLineNotesCoreRule,
9
+ } from '../fence/line-notes.js'
7
10
  import {
8
11
  renderFenceMarkup,
9
12
  } from '../fence/render-markup.js'
@@ -17,6 +20,7 @@ const mditRendererFenceMarkup = (md, option) => {
17
20
  }
18
21
 
19
22
  finalizeCommonFenceOption(opt)
23
+ installLineNotesCoreRule(md)
20
24
 
21
25
  md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
22
26
  const context = prepareFenceRenderContext(tokens, idx, opt)
@@ -0,0 +1,181 @@
1
+ import {
2
+ getInfoAttr,
3
+ } from '../utils/attr-utils.js'
4
+
5
+ const lineNotesRuleName = 'renderer_fence_line_notes'
6
+ const lineNotesMetaKey = '__rendererFenceLineNotes'
7
+ const lineNotesInstalledKey = '__rendererFenceLineNotesInstalled'
8
+ const fenceInfoNameReg = /^([^{\s]*)/
9
+ const lineNoteHeaderReg = /^\s*([1-9]\d*)(?:\s*-\s*([1-9]\d*))?\s*[::]\s*(.*)$/
10
+ const lineNoteContinuationReg = /^(?: {2,}|\t)/
11
+ const lineNoteFenceNameSet = new Set(['line-notes', 'notes'])
12
+ const lineNoteAttrsReg = /^(.*?)(?:\s+\{([^{}]+)\})\s*$/
13
+ const lineNoteAttrsOnlyReg = /^\{([^{}]+)\}\s*$/
14
+ const lineNoteWidthReg = /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem|ch|vw|svw|lvw|dvw|%)$/
15
+
16
+ const getFenceInfoName = (info) => {
17
+ const match = String(info ?? '').trim().match(fenceInfoNameReg)
18
+ return match ? match[1] : ''
19
+ }
20
+
21
+ const stripContinuationIndent = (line) => {
22
+ if (!line) return ''
23
+ if (line[0] === '\t') return line.slice(1)
24
+ if (line.startsWith(' ')) return line.slice(2)
25
+ return line
26
+ }
27
+
28
+ const parseValidatedLineNoteWidth = (attrText) => {
29
+ if (!attrText || attrText.indexOf('width') === -1) return ''
30
+ const attrs = getInfoAttr(attrText)
31
+ if (!attrs.length) return ''
32
+ for (let i = 0; i < attrs.length; i++) {
33
+ const name = attrs[i][0]
34
+ const value = String(attrs[i][1] || '').trim()
35
+ if (name !== 'width') continue
36
+ if (lineNoteWidthReg.test(value)) return value
37
+ }
38
+ return ''
39
+ }
40
+
41
+ const parseLineNoteHeaderText = (text) => {
42
+ const str = String(text ?? '')
43
+ if (str.indexOf('{') === -1 || str.indexOf('}') === -1) {
44
+ return { text: str, width: '' }
45
+ }
46
+ const match = str.match(lineNoteAttrsReg)
47
+ if (!match) return { text: str, width: '' }
48
+ return {
49
+ text: match[1].trimEnd(),
50
+ width: parseValidatedLineNoteWidth(match[2]),
51
+ }
52
+ }
53
+
54
+ const parseLineNoteAttrsOnlyText = (text) => {
55
+ const str = String(text ?? '').trim()
56
+ if (str[0] !== '{' || str[str.length - 1] !== '}') return null
57
+ const match = str.match(lineNoteAttrsOnlyReg)
58
+ if (!match) return null
59
+ const width = parseValidatedLineNoteWidth(match[1])
60
+ return width ? { width } : null
61
+ }
62
+
63
+ const parseLineNotesContent = (content) => {
64
+ const raw = String(content ?? '')
65
+ if (!raw || (raw.indexOf(':') === -1 && raw.indexOf(':') === -1)) return []
66
+ const lines = raw.split('\n')
67
+ const notes = []
68
+ const seenStarts = new Set()
69
+ let current = null
70
+
71
+ const pushCurrent = () => {
72
+ if (!current) return true
73
+ if (!current.lines.length) return false
74
+ notes.push({
75
+ from: current.from,
76
+ to: current.to,
77
+ text: current.lines.join('\n'),
78
+ lineCount: current.lines.length,
79
+ width: current.width || '',
80
+ })
81
+ seenStarts.add(current.from)
82
+ current = null
83
+ return true
84
+ }
85
+
86
+ for (let i = 0; i < lines.length; i++) {
87
+ const line = lines[i]
88
+ const header = line.match(lineNoteHeaderReg)
89
+ if (header) {
90
+ if (!pushCurrent()) return null
91
+ let from = Number(header[1])
92
+ let to = header[2] ? Number(header[2]) : from
93
+ if (from > to) {
94
+ const swap = from
95
+ from = to
96
+ to = swap
97
+ }
98
+ if (seenStarts.has(from)) return null
99
+ current = {
100
+ from,
101
+ to,
102
+ lines: [],
103
+ width: '',
104
+ }
105
+ const parsed = parseLineNoteHeaderText(header[3])
106
+ current.width = parsed.width
107
+ if (parsed.text) current.lines.push(parsed.text)
108
+ continue
109
+ }
110
+
111
+ if (current && lineNoteContinuationReg.test(line)) {
112
+ const stripped = stripContinuationIndent(line)
113
+ const attrsOnly = parseLineNoteAttrsOnlyText(stripped)
114
+ if (attrsOnly) {
115
+ if (attrsOnly.width) current.width = attrsOnly.width
116
+ continue
117
+ }
118
+ current.lines.push(stripped)
119
+ continue
120
+ }
121
+
122
+ if (!line.trim()) {
123
+ continue
124
+ }
125
+
126
+ return null
127
+ }
128
+
129
+ if (!pushCurrent()) return null
130
+ return notes
131
+ }
132
+
133
+ const getTokenLineNotes = (token) => {
134
+ if (!token || !token.meta || typeof token.meta !== 'object') return null
135
+ const notes = token.meta[lineNotesMetaKey]
136
+ return Array.isArray(notes) && notes.length ? notes : null
137
+ }
138
+
139
+ const setTokenLineNotes = (token, notes) => {
140
+ if (!token || !notes || !notes.length) return
141
+ if (!token.meta || typeof token.meta !== 'object') token.meta = {}
142
+ token.meta[lineNotesMetaKey] = notes
143
+ }
144
+
145
+ const extendTokenMapWithImmediateFollower = (token, next) => {
146
+ if (!token || !Array.isArray(token.map) || token.map.length !== 2) return
147
+ if (!next || !Array.isArray(next.map) || next.map.length !== 2) return
148
+ const start = token.map[0]
149
+ const end = next.map[1]
150
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return
151
+ if (end > token.map[1]) token.map = [start, end]
152
+ }
153
+
154
+ const installLineNotesCoreRule = (md) => {
155
+ if (!md || md[lineNotesInstalledKey]) return
156
+ md[lineNotesInstalledKey] = true
157
+ md.core.ruler.after('block', lineNotesRuleName, (state) => {
158
+ const tokens = state && state.tokens
159
+ if (!Array.isArray(tokens) || tokens.length < 2) return
160
+
161
+ for (let i = 0; i < tokens.length - 1; i++) {
162
+ const token = tokens[i]
163
+ const next = tokens[i + 1]
164
+ if (!token || token.type !== 'fence' || !next || next.type !== 'fence') continue
165
+ const nextName = getFenceInfoName(next.info)
166
+ if (!lineNoteFenceNameSet.has(nextName)) continue
167
+ const currentName = getFenceInfoName(token.info)
168
+ if (lineNoteFenceNameSet.has(currentName)) continue
169
+ const notes = parseLineNotesContent(next.content)
170
+ if (!notes || !notes.length) continue
171
+ setTokenLineNotes(token, notes)
172
+ extendTokenMapWithImmediateFollower(token, next)
173
+ tokens.splice(i + 1, 1)
174
+ }
175
+ })
176
+ }
177
+
178
+ export {
179
+ getTokenLineNotes,
180
+ installLineNotesCoreRule,
181
+ }
@@ -15,6 +15,9 @@ import {
15
15
  import {
16
16
  validateCustomHighlightOptions,
17
17
  } from '../custom-highlight/option-validator.js'
18
+ import {
19
+ sanitizeHighlightName,
20
+ } from '../custom-highlight/scope-name.js'
18
21
  import {
19
22
  commentLineClass,
20
23
  normalizeEmphasisRanges,
@@ -31,7 +34,6 @@ import {
31
34
  } from './render-api-constants.js'
32
35
 
33
36
  const highlightNameUnsafeReg = /[^A-Za-z0-9_-]+/g
34
- const hyphenMultiReg = /-+/g
35
37
 
36
38
  const defaultCustomHighlightOpt = {
37
39
  provider: 'shiki',
@@ -183,16 +185,6 @@ const shouldApplyApiFallbackForReason = (chOpt, reason) => {
183
185
  return chOpt._fallbackOnSet.has(reason)
184
186
  }
185
187
 
186
- const sanitizeHighlightName = (name, prefix = '') => {
187
- const prefixBase = String(prefix == null ? '' : prefix).replace(highlightNameUnsafeReg, '-').replace(hyphenMultiReg, '-').replace(/^-+|-+$/g, '')
188
- const raw = String(name || '')
189
- let safe = raw.replace(highlightNameUnsafeReg, '-').replace(hyphenMultiReg, '-').replace(/^-+|-+$/g, '')
190
- if (!safe) safe = 'scope'
191
- if (/^[0-9]/.test(safe)) safe = 'x-' + safe
192
- if (safe.startsWith('--')) safe = safe.slice(2) || 'scope'
193
- return prefixBase ? `${prefixBase}-${safe}` : safe
194
- }
195
-
196
188
  const uniqueHighlightName = (base, usedMap) => {
197
189
  if (!usedMap.has(base)) {
198
190
  usedMap.set(base, 1)
@@ -805,6 +797,5 @@ export {
805
797
  normalizeCustomHighlightOpt,
806
798
  renderCustomHighlightPayloadScript,
807
799
  renderCustomHighlightScopeStyleTag,
808
- sanitizeHighlightName,
809
800
  shouldApplyApiFallbackForReason,
810
801
  }
@@ -11,7 +11,9 @@ import {
11
11
  orderTokenAttrs,
12
12
  preWrapStyle,
13
13
  resolveAdvancedLineNumberPlan,
14
+ resolveLineNotesPlan,
14
15
  splitFenceBlockToLines,
16
+ wrapFencePreWithLineNotes,
15
17
  } from './render-shared.js'
16
18
  import {
17
19
  createApiPayloadForFence,
@@ -26,6 +28,8 @@ import {
26
28
  renderFenceMarkup,
27
29
  } from './render-markup.js'
28
30
 
31
+ const apiDisableLineFeatureList = Object.freeze(['setLineNumber', 'line-number-skip', 'line-number-set', 'lineEndSpanThreshold', 'line-notes'])
32
+
29
33
  let fallbackCustomHighlightSeq = 0
30
34
 
31
35
  const nextCustomHighlightId = (env, prefix) => {
@@ -45,23 +49,30 @@ const buildApiPreAttrs = (preWrapValue, wrapEnabled, opt) => {
45
49
  return preAttrs
46
50
  }
47
51
 
48
- const renderFenceApiOrPlain = (token, lang, md, opt, slf, env, startNumber, emphasizeLines, lineNumberSkipValue, lineNumberResetValue, wrapEnabled, preWrapValue, commentMarkValue, includePayload, timings) => {
52
+ const renderFenceApiOrPlain = (token, lang, md, opt, slf, env, startNumber, emphasizeLines, lineNumberSkipValue, lineNumberSetValue, wrapEnabled, preWrapValue, commentMarkValue, lineNotes, lineNoteIdPrefix, includePayload, timings) => {
49
53
  const isSamp = opt._sampReg.test(lang)
50
54
  const tag = isSamp ? 'samp' : 'code'
51
55
  let content = md.utils.escapeHtml(token.content)
52
56
  const lineStrategy = opt.customHighlight.lineFeatureStrategy
53
57
  const needLineNumber = lineStrategy === 'hybrid' && opt.setLineNumber && startNumber >= 0
54
58
  const needEndSpan = lineStrategy === 'hybrid' && opt.lineEndSpanThreshold > 0
59
+ const needAdvancedLineNumberPlan = needLineNumber && (lineNumberSkipValue !== undefined || lineNumberSetValue !== undefined)
60
+ const needLineNotes = lineStrategy !== 'disable' && !!(lineNotes && lineNotes.length)
61
+ const logicalLineCount = (needAdvancedLineNumberPlan || needLineNotes)
62
+ ? getLogicalLineCount(token.content)
63
+ : -1
55
64
  let lineNumberPlan = null
56
- if (needLineNumber && (lineNumberSkipValue !== undefined || lineNumberResetValue !== undefined)) {
57
- const logicalLineCount = getLogicalLineCount(token.content)
58
- lineNumberPlan = resolveAdvancedLineNumberPlan(lineNumberSkipValue, lineNumberResetValue, logicalLineCount, logicalLineCount)
65
+ if (needAdvancedLineNumberPlan) {
66
+ lineNumberPlan = resolveAdvancedLineNumberPlan(lineNumberSkipValue, lineNumberSetValue, logicalLineCount, logicalLineCount)
59
67
  }
60
- if (needLineNumber || needEndSpan) {
68
+ const lineNotePlan = needLineNotes
69
+ ? resolveLineNotesPlan(lineNotes, logicalLineCount, logicalLineCount, lineNoteIdPrefix)
70
+ : null
71
+ if (needLineNumber || needEndSpan || lineNotePlan) {
61
72
  const splitStartedAt = timings ? getNowMs() : 0
62
73
  const nlIndex = content.indexOf('\n')
63
74
  const br = nlIndex > 0 && content[nlIndex - 1] === '\r' ? '\r\n' : '\n'
64
- content = splitFenceBlockToLines(content, [], needLineNumber, false, needEndSpan, opt.lineEndSpanThreshold, opt.lineEndSpanClass, br, undefined, commentLineClass, lineNumberPlan)
75
+ content = splitFenceBlockToLines(content, [], needLineNumber, false, needEndSpan, opt.lineEndSpanThreshold, opt.lineEndSpanClass, br, undefined, commentLineClass, lineNumberPlan, lineNotePlan)
65
76
  if (timings) addTimingMs(timings, 'lineSplitMs', getNowMs() - splitStartedAt)
66
77
  }
67
78
 
@@ -88,7 +99,8 @@ const renderFenceApiOrPlain = (token, lang, md, opt, slf, env, startNumber, emph
88
99
 
89
100
  if (preAttrs.length) orderTokenAttrs({ attrs: preAttrs }, opt)
90
101
  const preAttrsText = preAttrs.length ? slf.renderAttrs({ attrs: preAttrs }) : ''
91
- const html = `<pre${preAttrsText}><${tag}${slf.renderAttrs(token)}>${content}</${tag}></pre>\n`
102
+ const preHtml = `<pre${preAttrsText}><${tag}${slf.renderAttrs(token)}>${content}</${tag}></pre>`
103
+ const html = wrapFencePreWithLineNotes(preHtml, lineNotePlan)
92
104
  return inlinePayloadScript ? html + inlinePayloadScript : html
93
105
  }
94
106
 
@@ -101,10 +113,12 @@ const getFenceHtml = (context, md, opt, slf, env) => {
101
113
  const startNumber = context.startNumber
102
114
  const emphasizeLines = context.emphasizeLines
103
115
  const lineNumberSkipValue = context.lineNumberSkipValue
104
- const lineNumberResetValue = context.lineNumberResetValue
116
+ const lineNumberSetValue = context.lineNumberSetValue
105
117
  const wrapEnabled = context.wrapEnabled
106
118
  const preWrapValue = context.preWrapValue
107
119
  const commentMarkValue = context.commentMarkValue
120
+ const lineNotes = context.lineNotes
121
+ const lineNoteIdPrefix = context.lineNoteIdPrefix
108
122
 
109
123
  const apiDecisionBase = {
110
124
  renderer: 'api',
@@ -112,11 +126,11 @@ const getFenceHtml = (context, md, opt, slf, env) => {
112
126
  fallbackUsed: false,
113
127
  lineFeatureStrategy: opt.customHighlight.lineFeatureStrategy,
114
128
  disabledFeatures: opt.customHighlight.lineFeatureStrategy === 'disable'
115
- ? ['setLineNumber', 'line-number-skip', 'line-number-reset', 'lineEndSpanThreshold']
129
+ ? apiDisableLineFeatureList
116
130
  : [],
117
131
  }
118
132
  try {
119
- const html = renderFenceApiOrPlain(token, lang, md, opt, slf, env, startNumber, emphasizeLines, lineNumberSkipValue, lineNumberResetValue, wrapEnabled, preWrapValue, commentMarkValue, true, timings)
133
+ const html = renderFenceApiOrPlain(token, lang, md, opt, slf, env, startNumber, emphasizeLines, lineNumberSkipValue, lineNumberSetValue, wrapEnabled, preWrapValue, commentMarkValue, lineNotes, lineNoteIdPrefix, true, timings)
120
134
  if (timingEnabled) apiDecisionBase.timings = finalizeFenceTimings(timings, fenceStartedAt)
121
135
  emitFenceDecision(opt, apiDecisionBase)
122
136
  return html
@@ -130,7 +144,7 @@ const getFenceHtml = (context, md, opt, slf, env) => {
130
144
  reason: 'provider-error',
131
145
  lineFeatureStrategy: opt.customHighlight.lineFeatureStrategy,
132
146
  }
133
- const html = renderFenceApiOrPlain(token, lang, md, opt, slf, env, startNumber, emphasizeLines, lineNumberSkipValue, lineNumberResetValue, wrapEnabled, preWrapValue, commentMarkValue, false, timings)
147
+ const html = renderFenceApiOrPlain(token, lang, md, opt, slf, env, startNumber, emphasizeLines, lineNumberSkipValue, lineNumberSetValue, wrapEnabled, preWrapValue, commentMarkValue, lineNotes, lineNoteIdPrefix, false, timings)
134
148
  if (timingEnabled) fallbackDecision.timings = finalizeFenceTimings(timings, fenceStartedAt)
135
149
  emitFenceDecision(opt, fallbackDecision)
136
150
  return html
@@ -4,6 +4,9 @@ import {
4
4
  getPayloadMapFromScript,
5
5
  styleToHighlightCss,
6
6
  } from '../custom-highlight/runtime-utils.js'
7
+ import {
8
+ sanitizeHighlightName,
9
+ } from '../custom-highlight/scope-name.js'
7
10
  import {
8
11
  customHighlightAppliedAttr,
9
12
  customHighlightAppliedSelector,
@@ -19,17 +22,6 @@ const globalRuntimeInsertedScopeStyles = new Map()
19
22
  const runtimeInsertedScopeStylesByDoc = new WeakMap()
20
23
  const runtimeApplyStateByRoot = new WeakMap()
21
24
  const validColorSchemeSet = new Set(['light', 'dark', 'auto'])
22
- const highlightNameUnsafeReg = /[^A-Za-z0-9_-]+/g
23
- const hyphenMultiReg = /-+/g
24
-
25
- const sanitizeHighlightName = (name) => {
26
- const raw = String(name || '')
27
- let safe = raw.replace(highlightNameUnsafeReg, '-').replace(hyphenMultiReg, '-').replace(/^-+|-+$/g, '')
28
- if (!safe) safe = 'scope'
29
- if (/^[0-9]/.test(safe)) safe = 'x-' + safe
30
- if (safe.startsWith('--')) safe = safe.slice(2) || 'scope'
31
- return safe
32
- }
33
25
 
34
26
  const getCustomHighlightBlockId = (node) => {
35
27
  if (!node || typeof node.getAttribute !== 'function') return null
@@ -133,10 +125,10 @@ const addMediaQueryChangeListener = (queryList, listener) => {
133
125
  }
134
126
 
135
127
  const getRuntimeSupportedPayloadVersions = (options = {}) => {
136
- const set = new Set()
137
128
  if (options.strictVersion === true) {
138
- for (const v of customHighlightPayloadSupportedVersions) set.add(v)
129
+ return new Set(customHighlightPayloadSupportedVersions)
139
130
  }
131
+ const set = new Set()
140
132
  if (Number.isSafeInteger(options.supportedVersion)) set.add(options.supportedVersion)
141
133
  if (Array.isArray(options.supportedVersions)) {
142
134
  for (const v of options.supportedVersions) {
@@ -7,6 +7,9 @@ import {
7
7
  finalizeCommonFenceOption,
8
8
  prepareFenceRenderContext,
9
9
  } from './render-shared.js'
10
+ import {
11
+ installLineNotesCoreRule,
12
+ } from './line-notes.js'
10
13
  import {
11
14
  customHighlightDataEnvKey,
12
15
  customHighlightEnvInitRuleName,
@@ -73,6 +76,7 @@ const mditRendererFenceCustomHighlight = (md, option) => {
73
76
  }
74
77
 
75
78
  finalizeCommonFenceOption(opt)
79
+ installLineNotesCoreRule(md)
76
80
 
77
81
  md.core.ruler.before('block', customHighlightEnvInitRuleName, (state) => {
78
82
  const env = state && state.env
@@ -14,12 +14,16 @@ import {
14
14
  orderTokenAttrs,
15
15
  preWrapStyle,
16
16
  resolveAdvancedLineNumberPlan,
17
+ resolveLineNotesPlan,
17
18
  splitFenceBlockToLines,
19
+ wrapFencePreWithLineNotes,
18
20
  } from './render-shared.js'
19
21
  import {
20
22
  parsePreCodeWrapper,
21
23
  } from '../utils/pre-code-wrapper-parser.js'
22
24
 
25
+ const markupPassthroughDisabledFeatures = Object.freeze(['setLineNumber', 'line-number-skip', 'line-number-set', 'setEmphasizeLines', 'lineEndSpanThreshold', 'comment-mark', 'line-notes', 'samp'])
26
+
23
27
  const renderFenceMarkup = (context, md, opt, slf) => {
24
28
  const token = context.token
25
29
  const lang = context.lang
@@ -29,10 +33,12 @@ const renderFenceMarkup = (context, md, opt, slf) => {
29
33
  const startNumber = context.startNumber
30
34
  const emphasizeLines = context.emphasizeLines
31
35
  const lineNumberSkipValue = context.lineNumberSkipValue
32
- const lineNumberResetValue = context.lineNumberResetValue
36
+ const lineNumberSetValue = context.lineNumberSetValue
33
37
  const wrapEnabled = context.wrapEnabled
34
38
  const preWrapValue = context.preWrapValue
35
39
  const commentMarkValue = context.commentMarkValue
40
+ const lineNotes = context.lineNotes
41
+ const lineNoteIdPrefix = context.lineNoteIdPrefix
36
42
 
37
43
  const isSamp = opt._sampReg.test(lang)
38
44
  const sourceContent = token.content
@@ -76,7 +82,7 @@ const renderFenceMarkup = (context, md, opt, slf) => {
76
82
  passthrough: true,
77
83
  passthroughReason: 'pre-code-parse-failed',
78
84
  hasHighlightPre: false,
79
- disabledFeatures: ['setLineNumber', 'setEmphasizeLines', 'lineEndSpanThreshold', 'comment-mark', 'samp'],
85
+ disabledFeatures: markupPassthroughDisabledFeatures,
80
86
  }
81
87
  if (timingEnabled) decision.timings = finalizeFenceTimings(timings, fenceStartedAt)
82
88
  emitFenceDecision(opt, decision)
@@ -105,7 +111,8 @@ const renderFenceMarkup = (context, md, opt, slf) => {
105
111
  if (preAttrs.length) orderTokenAttrs({ attrs: preAttrs }, opt)
106
112
  const preAttrsText = preAttrs.length ? slf.renderAttrs({ attrs: preAttrs }) : ''
107
113
 
108
- const needLineNumber = opt.setLineNumber && startNumber >= 0
114
+ const useHighlightPre = opt.useHighlightPre && hasHighlightPre
115
+ const needLineNumber = !useHighlightPre && opt.setLineNumber && startNumber >= 0
109
116
  let sourceLogicalLineCount = -1
110
117
  let highlightedLogicalLineCount = -1
111
118
  const ensureLogicalLineCounts = () => {
@@ -113,18 +120,22 @@ const renderFenceMarkup = (context, md, opt, slf) => {
113
120
  if (highlightedLogicalLineCount === -1) highlightedLogicalLineCount = getLogicalLineCount(content)
114
121
  }
115
122
  let normalizedEmphasis = []
116
- if (opt.setEmphasizeLines && emphasizeLines.length > 0) {
123
+ if (!useHighlightPre && opt.setEmphasizeLines && emphasizeLines.length > 0) {
117
124
  ensureLogicalLineCounts()
118
125
  normalizedEmphasis = normalizeEmphasisRanges(emphasizeLines, highlightedLogicalLineCount)
119
126
  }
120
127
  let lineNumberPlan = null
121
- if (needLineNumber && (lineNumberSkipValue !== undefined || lineNumberResetValue !== undefined)) {
128
+ if (needLineNumber && (lineNumberSkipValue !== undefined || lineNumberSetValue !== undefined)) {
122
129
  ensureLogicalLineCounts()
123
- lineNumberPlan = resolveAdvancedLineNumberPlan(lineNumberSkipValue, lineNumberResetValue, sourceLogicalLineCount, highlightedLogicalLineCount)
130
+ lineNumberPlan = resolveAdvancedLineNumberPlan(lineNumberSkipValue, lineNumberSetValue, sourceLogicalLineCount, highlightedLogicalLineCount)
131
+ }
132
+ let lineNotePlan = null
133
+ if (!useHighlightPre && lineNotes && lineNotes.length) {
134
+ ensureLogicalLineCounts()
135
+ lineNotePlan = resolveLineNotesPlan(lineNotes, sourceLogicalLineCount, highlightedLogicalLineCount, lineNoteIdPrefix)
124
136
  }
125
137
  const needEmphasis = normalizedEmphasis.length > 0
126
138
  const needEndSpan = opt.lineEndSpanThreshold > 0
127
- const useHighlightPre = opt.useHighlightPre && hasHighlightPre
128
139
 
129
140
  if (!useHighlightPre && commentMarkValue && sourceContent.indexOf(commentMarkValue) !== -1) {
130
141
  ensureLogicalLineCounts()
@@ -140,7 +151,7 @@ const renderFenceMarkup = (context, md, opt, slf) => {
140
151
  }
141
152
  }
142
153
 
143
- if (!useHighlightPre && (needLineNumber || needEmphasis || needEndSpan || needComment)) {
154
+ if (!useHighlightPre && (needLineNumber || needEmphasis || needEndSpan || needComment || lineNotePlan)) {
144
155
  const splitStartedAt = timingEnabled ? getNowMs() : 0
145
156
  const nlIndex = content.indexOf('\n')
146
157
  const br = nlIndex > 0 && content[nlIndex - 1] === '\r' ? '\r\n' : '\n'
@@ -156,6 +167,7 @@ const renderFenceMarkup = (context, md, opt, slf) => {
156
167
  commentLines,
157
168
  commentLineClass,
158
169
  lineNumberPlan,
170
+ lineNotePlan,
159
171
  )
160
172
  if (timingEnabled) addTimingMs(timings, 'lineSplitMs', getNowMs() - splitStartedAt)
161
173
  }
@@ -165,11 +177,12 @@ const renderFenceMarkup = (context, md, opt, slf) => {
165
177
  renderer: 'markup',
166
178
  useHighlightPre,
167
179
  hasHighlightPre,
168
- disabledFeatures: useHighlightPre ? ['setLineNumber', 'line-number-skip', 'line-number-reset', 'setEmphasizeLines', 'lineEndSpanThreshold', 'comment-mark', 'samp'] : [],
180
+ disabledFeatures: useHighlightPre ? markupPassthroughDisabledFeatures : [],
169
181
  }
170
182
  if (timingEnabled) decision.timings = finalizeFenceTimings(timings, fenceStartedAt)
171
183
  emitFenceDecision(opt, decision)
172
- return `<pre${preAttrsText}><${tag}${slf.renderAttrs(token)}>${content}</${tag}></pre>\n`
184
+ const preHtml = `<pre${preAttrsText}><${tag}${slf.renderAttrs(token)}>${content}</${tag}></pre>`
185
+ return wrapFencePreWithLineNotes(preHtml, lineNotePlan)
173
186
  }
174
187
 
175
188
  export {
@@ -4,20 +4,36 @@ import {
4
4
  getInfoAttr,
5
5
  getLangFromClassAttr,
6
6
  } from '../utils/attr-utils.js'
7
+ import {
8
+ getTokenLineNotes,
9
+ } from './line-notes.js'
7
10
 
8
11
  const infoReg = /^([^{\s]*)(?:\s*\{(.*)\})?$/
9
12
  const tagReg = /<\/?([A-Za-z][A-Za-z0-9-]*)(?:\s+[^>]*?)?\/?\s*>/g
10
13
  const preLineTag = '<span class="pre-line">'
11
14
  const preLineNoNumberClass = 'pre-line-no-number'
15
+ const preLineHasEndNoteClass = 'pre-line-has-end-note'
16
+ const preLineContentClass = 'pre-line-content'
12
17
  const emphOpenTag = '<span class="pre-lines-emphasis">'
13
18
  const commentLineClass = 'pre-line-comment'
19
+ const preWithLineNotesClass = 'pre-wrapper-line-notes'
20
+ const lineNoteLayerClass = 'pre-line-note-layer'
21
+ const lineNoteClass = 'pre-line-note'
14
22
  const closeTag = '</span>'
15
23
  const closeTagLen = closeTag.length
24
+ const defaultPreLineTags = { open: preLineTag, close: closeTag }
16
25
  const preWrapStyle = 'white-space: pre-wrap; overflow-wrap: anywhere;'
17
26
  const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
18
27
  const nonNegativeIntReg = /^\d+$/
19
28
  const positiveIntReg = /^[1-9]\d*$/
20
29
 
30
+ const getLineNoteIdPrefix = (token, idx) => {
31
+ const tokenMap = token && Array.isArray(token.map) ? token.map : null
32
+ const mapStart = tokenMap && Number.isSafeInteger(tokenMap[0]) ? tokenMap[0] + 1 : 0
33
+ const tokenIndex = Number.isSafeInteger(idx) ? idx + 1 : 0
34
+ return `pre-line-note-${mapStart}-${tokenIndex}`
35
+ }
36
+
21
37
  const getLineVisualLengthIgnoringTags = (line, threshold) => {
22
38
  let len = 0
23
39
  let inTag = false
@@ -89,6 +105,7 @@ const createCommonFenceOptionDefaults = (md) => {
89
105
  const finalizeCommonFenceOption = (opt) => {
90
106
  opt._sampReg = new RegExp('^(?:samp|' + opt.sampLang.split(',').join('|') + ')$')
91
107
  opt._attrOrderIndex = createAttrOrderIndexGetter(opt.attrsOrder || [])
108
+ opt._attrOrderRankCache = new Map()
92
109
  return opt
93
110
  }
94
111
 
@@ -115,6 +132,13 @@ const parsePositiveLineIndex = (val) => {
115
132
  return num
116
133
  }
117
134
 
135
+ const escapeHtmlText = (value) => {
136
+ return String(value ?? '')
137
+ .replace(/&/g, '&amp;')
138
+ .replace(/</g, '&lt;')
139
+ .replace(/>/g, '&gt;')
140
+ }
141
+
118
142
  const getLogicalLineCount = (text) => {
119
143
  const str = String(text ?? '')
120
144
  if (!str) return 0
@@ -190,7 +214,7 @@ const normalizeEmphasisRanges = (emphasizeLines, maxLine) => {
190
214
  return normalized
191
215
  }
192
216
 
193
- const getLineNumberResetEntries = (attrVal) => {
217
+ const getLineNumberSetEntries = (attrVal) => {
194
218
  const str = String(attrVal ?? '')
195
219
  if (!str) return []
196
220
  const result = []
@@ -200,9 +224,9 @@ const getLineNumberResetEntries = (attrVal) => {
200
224
  const colon = part.indexOf(':')
201
225
  if (colon === -1) continue
202
226
  const lineNumber = parsePositiveLineIndex(part.slice(0, colon).trim())
203
- const resetNumber = parseStartNumber(part.slice(colon + 1).trim())
204
- if (lineNumber == null || resetNumber == null) continue
205
- result.push([lineNumber, resetNumber])
227
+ const setNumber = parseStartNumber(part.slice(colon + 1).trim())
228
+ if (lineNumber == null || setNumber == null) continue
229
+ result.push([lineNumber, setNumber])
206
230
  }
207
231
  return result
208
232
  }
@@ -218,59 +242,142 @@ const createSparseLineFlagMap = (ranges, maxLine) => {
218
242
  return flags
219
243
  }
220
244
 
221
- const createSparseLineResetMap = (entries, maxLine) => {
245
+ const createSparseLineSetMap = (entries, maxLine, hidden) => {
222
246
  if (!entries || !entries.length || maxLine <= 0) return null
223
- const resets = []
247
+ const sets = []
224
248
  let hasValue = false
225
249
  for (let i = 0; i < entries.length; i++) {
226
250
  const line = entries[i][0]
227
251
  const value = entries[i][1]
228
252
  if (!Number.isSafeInteger(line) || line <= 0 || line > maxLine) continue
229
- resets[line - 1] = value
253
+ if (hidden && hidden[line - 1]) continue
254
+ sets[line - 1] = value
230
255
  hasValue = true
231
256
  }
232
- return hasValue ? resets : null
257
+ return hasValue ? sets : null
233
258
  }
234
259
 
235
- const buildAdvancedLineNumberPlan = (skipRanges, resetEntries, sourceLineCount, renderedLineCount) => {
260
+ const buildAdvancedLineNumberPlan = (skipRanges, setEntries, lineCount) => {
236
261
  const hasSkip = !!(skipRanges && skipRanges.length)
237
- const hasReset = !!(resetEntries && resetEntries.length)
238
- if (!hasSkip && !hasReset) return null
239
- if (!Number.isSafeInteger(sourceLineCount) || sourceLineCount <= 0) return null
240
- if (sourceLineCount !== renderedLineCount) return null
241
- const hidden = hasSkip ? createSparseLineFlagMap(skipRanges, sourceLineCount) : null
242
- const resets = hasReset ? createSparseLineResetMap(resetEntries, sourceLineCount) : null
243
- if (!hidden && !resets) return null
244
- return { hidden, resets }
262
+ const hasSet = !!(setEntries && setEntries.length)
263
+ if (!hasSkip && !hasSet) return null
264
+ const hidden = hasSkip ? createSparseLineFlagMap(skipRanges, lineCount) : null
265
+ const sets = hasSet ? createSparseLineSetMap(setEntries, lineCount, hidden) : null
266
+ if (!hidden && !sets) return null
267
+ return { hidden, sets }
245
268
  }
246
269
 
247
- const resolveAdvancedLineNumberPlan = (lineNumberSkipValue, lineNumberResetValue, sourceLineCount, renderedLineCount) => {
270
+ const resolveAdvancedLineNumberPlan = (lineNumberSkipValue, lineNumberSetValue, sourceLineCount, renderedLineCount) => {
248
271
  const hasSkipValue = lineNumberSkipValue !== undefined
249
- const hasResetValue = lineNumberResetValue !== undefined
250
- if (!hasSkipValue && !hasResetValue) return null
272
+ const hasSetValue = lineNumberSetValue !== undefined
273
+ if (!hasSkipValue && !hasSetValue) return null
251
274
  if (!Number.isSafeInteger(sourceLineCount) || sourceLineCount <= 0) return null
252
275
  if (sourceLineCount !== renderedLineCount) return null
253
276
  const skipRanges = hasSkipValue ? getLineNumberSkipRanges(lineNumberSkipValue) : null
254
- const resetEntries = hasResetValue ? getLineNumberResetEntries(lineNumberResetValue) : null
255
- return buildAdvancedLineNumberPlan(skipRanges, resetEntries, sourceLineCount, renderedLineCount)
277
+ const setEntries = hasSetValue ? getLineNumberSetEntries(lineNumberSetValue) : null
278
+ return buildAdvancedLineNumberPlan(skipRanges, setEntries, sourceLineCount)
279
+ }
280
+
281
+ const createLineNotePlan = (lineNotes, maxLine, idPrefix) => {
282
+ if (!Array.isArray(lineNotes) || !lineNotes.length || maxLine <= 0) return null
283
+ const anchors = []
284
+ const items = []
285
+ const noteIdPrefix = String(idPrefix || 'pre-line-note')
286
+ let prevFrom = 0
287
+ let prevTo = 0
288
+ let needsSort = false
289
+ for (let i = 0; i < lineNotes.length; i++) {
290
+ const note = lineNotes[i]
291
+ if (!note || !Number.isSafeInteger(note.from) || !Number.isSafeInteger(note.to)) continue
292
+ let from = note.from
293
+ let to = note.to
294
+ if (from > to) {
295
+ const swap = from
296
+ from = to
297
+ to = swap
298
+ }
299
+ if (to < 1 || from > maxLine) continue
300
+ if (from < 1) from = 1
301
+ if (to > maxLine) to = maxLine
302
+ if (anchors[from - 1] !== undefined) return null
303
+ const lineCount = Number.isSafeInteger(note.lineCount) && note.lineCount > 0
304
+ ? note.lineCount
305
+ : Math.max(getLogicalLineCount(note.text), 1)
306
+ const item = {
307
+ from,
308
+ to,
309
+ label: from === to ? String(from) : `${from}-${to}`,
310
+ html: escapeHtmlText(note.text),
311
+ anchorName: `--pre-line-note-${from}`,
312
+ id: `${noteIdPrefix}-${from}`,
313
+ lineCount,
314
+ width: note.width || '',
315
+ }
316
+ anchors[from - 1] = item
317
+ items.push(item)
318
+ if (!needsSort && items.length > 1 && (from < prevFrom || (from === prevFrom && to < prevTo))) {
319
+ needsSort = true
320
+ }
321
+ prevFrom = from
322
+ prevTo = to
323
+ }
324
+ if (!items.length) return null
325
+
326
+ if (needsSort) items.sort((a, b) => a.from - b.from || a.to - b.to)
327
+ let canAnchor = true
328
+ let previousVisualEnd = 0
329
+ let overflowLines = 0
330
+ for (let i = 0; i < items.length; i++) {
331
+ const item = items[i]
332
+ const visualEnd = item.from + item.lineCount - 1
333
+ if (item.from <= previousVisualEnd) canAnchor = false
334
+ if (visualEnd > maxLine) overflowLines = Math.max(overflowLines, visualEnd - maxLine)
335
+ previousVisualEnd = visualEnd
336
+ }
337
+
338
+ return {
339
+ anchors,
340
+ items,
341
+ canAnchor,
342
+ overflowLines,
343
+ }
256
344
  }
257
345
 
258
- const getPreLineOpenTag = (lineNumberPlan, lineIndex) => {
259
- if (!lineNumberPlan) return preLineTag
260
- const hidden = !!(lineNumberPlan.hidden && lineNumberPlan.hidden[lineIndex])
261
- const resetNumber = lineNumberPlan.resets ? lineNumberPlan.resets[lineIndex] : undefined
262
- if (!hidden && resetNumber == null) return preLineTag
346
+ const resolveLineNotesPlan = (lineNotes, sourceLineCount, renderedLineCount, idPrefix) => {
347
+ if (!Array.isArray(lineNotes) || !lineNotes.length) return null
348
+ if (!Number.isSafeInteger(sourceLineCount) || sourceLineCount <= 0) return null
349
+ if (sourceLineCount !== renderedLineCount) return null
350
+ return createLineNotePlan(lineNotes, sourceLineCount, idPrefix)
351
+ }
352
+
353
+ const buildPreLineTags = (hidden, setNumber, lineNote) => {
354
+ if (!hidden && setNumber == null && !lineNote) return defaultPreLineTags
263
355
  let tag = '<span class="pre-line'
264
356
  if (hidden) tag += ' ' + preLineNoNumberClass
357
+ if (lineNote) tag += ' ' + preLineHasEndNoteClass
265
358
  tag += '"'
266
- if (resetNumber != null) tag += ` style="counter-set:pre-line-number ${resetNumber};"`
267
- return tag + '>'
359
+ if (setNumber != null) tag += ` style="counter-set:pre-line-number ${setNumber};"`
360
+ if (lineNote) {
361
+ tag += ` data-pre-line-note-from="${lineNote.from}"`
362
+ tag += ` data-pre-line-note-to="${lineNote.to}"`
363
+ }
364
+ tag += '>'
365
+ if (!lineNote) return { open: tag, close: closeTag }
366
+ const describedByAttr = lineNote.id ? ` aria-describedby="${lineNote.id}"` : ''
367
+ return {
368
+ open: `${tag}<span class="${preLineContentClass}" style="anchor-name:${lineNote.anchorName};"${describedByAttr}>`,
369
+ close: `${closeTag}${closeTag}`,
370
+ }
268
371
  }
269
372
 
270
- const splitFenceBlockToLines = (content, emphasizeLines, needLineNumber, needEmphasis, needEndSpan, threshold, lineEndSpanClass, br, commentLines, commentClass, lineNumberPlan) => {
373
+ const splitFenceBlockToLines = (content, emphasizeLines, needLineNumber, needEmphasis, needEndSpan, threshold, lineEndSpanClass, br, commentLines, commentClass, lineNumberPlan, lineNotePlan) => {
271
374
  const lines = content.split(br)
272
375
  const max = lines.length
273
- const hasDynamicLineNumberPlan = !!lineNumberPlan
376
+ const lineNumberHidden = lineNumberPlan ? lineNumberPlan.hidden : null
377
+ const lineNumberSets = lineNumberPlan ? lineNumberPlan.sets : null
378
+ const lineNoteAnchors = lineNotePlan ? lineNotePlan.anchors : null
379
+ const hasLineNotes = !!lineNoteAnchors
380
+ const needLineWrap = needLineNumber || hasLineNotes
274
381
  let emIdx = 0
275
382
  let emStart = -1
276
383
  let emEnd = -1
@@ -287,6 +394,9 @@ const splitFenceBlockToLines = (content, emphasizeLines, needLineNumber, needEmp
287
394
  let line = lines[n]
288
395
  const notLastLine = n < max - 1
289
396
  const doComment = commentLines && commentLines[n]
397
+ const hidden = !!(lineNumberHidden && lineNumberHidden[n])
398
+ const setNumber = lineNumberSets ? lineNumberSets[n] : undefined
399
+ const lineNote = hasLineNotes ? lineNoteAnchors[n] : null
290
400
  let hasLt = false
291
401
  let hasLtChecked = false
292
402
 
@@ -310,7 +420,7 @@ const splitFenceBlockToLines = (content, emphasizeLines, needLineNumber, needEmp
310
420
  }
311
421
  }
312
422
 
313
- if (needLineNumber && notLastLine) {
423
+ if (needLineWrap && notLastLine) {
314
424
  if (!hasLtChecked) {
315
425
  hasLt = line.indexOf('<') !== -1
316
426
  hasLtChecked = true
@@ -340,8 +450,9 @@ const splitFenceBlockToLines = (content, emphasizeLines, needLineNumber, needEmp
340
450
  line = `<span class="${commentClass}">` + line + closeTag
341
451
  }
342
452
 
343
- if (needLineNumber && notLastLine) {
344
- line = (hasDynamicLineNumberPlan ? getPreLineOpenTag(lineNumberPlan, n) : preLineTag) + line + closeTag
453
+ if (needLineWrap && notLastLine) {
454
+ const lineTags = buildPreLineTags(hidden, setNumber, lineNote)
455
+ line = lineTags.open + line + lineTags.close
345
456
  }
346
457
 
347
458
  if (needEmphasis && emIdx < emphasizeLines.length && emStart === n + 1) {
@@ -360,10 +471,33 @@ const splitFenceBlockToLines = (content, emphasizeLines, needLineNumber, needEmp
360
471
  return lines.join(br)
361
472
  }
362
473
 
474
+ const renderLineNotesLayer = (lineNotePlan) => {
475
+ const items = lineNotePlan && lineNotePlan.items
476
+ if (!items || !items.length) return ''
477
+ let html = `<div class="${lineNoteLayerClass}">`
478
+ for (let i = 0; i < items.length; i++) {
479
+ const item = items[i]
480
+ let style = `position-anchor:${item.anchorName};`
481
+ if (item.width) style += ` --line-note-width:${item.width};`
482
+ html += `<div id="${item.id}" class="${lineNoteClass}" role="note" data-pre-line-note-from="${item.from}" data-pre-line-note-to="${item.to}" data-pre-line-note-label="${item.label}" style="${style}">${item.html}</div>`
483
+ }
484
+ html += '</div>'
485
+ return html
486
+ }
487
+
488
+ const wrapFencePreWithLineNotes = (preHtml, lineNotePlan) => {
489
+ const items = lineNotePlan && lineNotePlan.items
490
+ if (!items || !items.length) return preHtml + '\n'
491
+ let attrs = ` class="${preWithLineNotesClass}"`
492
+ if (lineNotePlan.canAnchor) attrs += ' data-pre-line-notes-layout="anchor"'
493
+ if (lineNotePlan.overflowLines > 0) attrs += ` style="--pre-line-note-overflow-lines:${lineNotePlan.overflowLines};"`
494
+ return `<div${attrs}>${preHtml}${renderLineNotesLayer(lineNotePlan)}</div>\n`
495
+ }
496
+
363
497
  const orderTokenAttrs = (token, opt) => {
364
498
  const attrs = token.attrs
365
499
  if (!attrs || attrs.length < 2) return
366
- const rankCache = new Map()
500
+ const rankCache = opt._attrOrderRankCache || new Map()
367
501
  const getRank = (name) => {
368
502
  let rank = rankCache.get(name)
369
503
  if (rank === undefined) {
@@ -399,10 +533,12 @@ const prepareFenceRenderContext = (tokens, idx, opt) => {
399
533
  let startNumber = -1
400
534
  let emphasizeLines = []
401
535
  let lineNumberSkipValue
402
- let lineNumberResetValue
536
+ let lineNumberSetValue
403
537
  let wrapEnabled = false
404
538
  let preWrapValue
405
539
  let commentMarkValue
540
+ const lineNotes = getTokenLineNotes(token)
541
+ const lineNoteIdPrefix = lineNotes && lineNotes.length ? getLineNoteIdPrefix(token, idx) : ''
406
542
  const attrNormalizeStartedAt = timingEnabled ? getNowMs() : 0
407
543
 
408
544
  if (token.attrs) {
@@ -412,7 +548,7 @@ const prepareFenceRenderContext = (tokens, idx, opt) => {
412
548
  let styleIndex = -1
413
549
  let dataPreCommentIndex = -1
414
550
  let dataPreLineNumberSkipIndex = -1
415
- let dataPreLineNumberResetIndex = -1
551
+ let dataPreLineNumberSetIndex = -1
416
552
  let startValue
417
553
  let emphasisValue
418
554
  let styleValue
@@ -420,7 +556,7 @@ const prepareFenceRenderContext = (tokens, idx, opt) => {
420
556
  let sawStartAttr = false
421
557
  let sawEmphasisAttr = false
422
558
  let sawLineNumberSkipAttr = false
423
- let sawLineNumberResetAttr = false
559
+ let sawLineNumberSetAttr = false
424
560
  const appendOrder = []
425
561
 
426
562
  for (const attr of token.attrs) {
@@ -469,11 +605,13 @@ const prepareFenceRenderContext = (tokens, idx, opt) => {
469
605
  lineNumberSkipValue = val
470
606
  newAttrs.push(attr)
471
607
  break
472
- case 'data-pre-line-number-reset':
473
- dataPreLineNumberResetIndex = newAttrs.length
474
- lineNumberResetValue = val
608
+ case 'data-pre-line-number-set':
609
+ dataPreLineNumberSetIndex = newAttrs.length
610
+ lineNumberSetValue = val
475
611
  newAttrs.push(attr)
476
612
  break
613
+ case 'data-pre-line-number-reset':
614
+ break
477
615
  case 'em-lines':
478
616
  case 'emphasize-lines':
479
617
  if (opt.setEmphasizeLines) emphasizeLines = getEmphasizeLines(val)
@@ -498,13 +636,16 @@ const prepareFenceRenderContext = (tokens, idx, opt) => {
498
636
  sawLineNumberSkipAttr = true
499
637
  }
500
638
  break
639
+ case 'line-number-set':
640
+ case 'pre-line-number-set':
641
+ lineNumberSetValue = val
642
+ if (!sawLineNumberSetAttr) {
643
+ appendOrder.push('line-number-set')
644
+ sawLineNumberSetAttr = true
645
+ }
646
+ break
501
647
  case 'line-number-reset':
502
648
  case 'pre-line-number-reset':
503
- lineNumberResetValue = val
504
- if (!sawLineNumberResetAttr) {
505
- appendOrder.push('line-number-reset')
506
- sawLineNumberResetAttr = true
507
- }
508
649
  break
509
650
  case 'data-pre-wrap':
510
651
  preWrapValue = val
@@ -523,7 +664,7 @@ const prepareFenceRenderContext = (tokens, idx, opt) => {
523
664
  if (emphasisValue !== undefined && dataPreEmphasisIndex >= 0) newAttrs[dataPreEmphasisIndex][1] = emphasisValue
524
665
  if (commentMarkValue !== undefined && dataPreCommentIndex >= 0) newAttrs[dataPreCommentIndex][1] = commentMarkValue
525
666
  if (lineNumberSkipValue !== undefined && dataPreLineNumberSkipIndex >= 0) newAttrs[dataPreLineNumberSkipIndex][1] = lineNumberSkipValue
526
- if (lineNumberResetValue !== undefined && dataPreLineNumberResetIndex >= 0) newAttrs[dataPreLineNumberResetIndex][1] = lineNumberResetValue
667
+ if (lineNumberSetValue !== undefined && dataPreLineNumberSetIndex >= 0) newAttrs[dataPreLineNumberSetIndex][1] = lineNumberSetValue
527
668
 
528
669
  for (const kind of appendOrder) {
529
670
  if (kind === 'start' && dataPreStartIndex === -1 && startValue !== undefined) {
@@ -534,8 +675,8 @@ const prepareFenceRenderContext = (tokens, idx, opt) => {
534
675
  newAttrs.push(['data-pre-comment-mark', commentMarkValue])
535
676
  } else if (kind === 'line-number-skip' && dataPreLineNumberSkipIndex === -1 && lineNumberSkipValue !== undefined) {
536
677
  newAttrs.push(['data-pre-line-number-skip', lineNumberSkipValue])
537
- } else if (kind === 'line-number-reset' && dataPreLineNumberResetIndex === -1 && lineNumberResetValue !== undefined) {
538
- newAttrs.push(['data-pre-line-number-reset', lineNumberResetValue])
678
+ } else if (kind === 'line-number-set' && dataPreLineNumberSetIndex === -1 && lineNumberSetValue !== undefined) {
679
+ newAttrs.push(['data-pre-line-number-set', lineNumberSetValue])
539
680
  }
540
681
  }
541
682
 
@@ -553,6 +694,7 @@ const prepareFenceRenderContext = (tokens, idx, opt) => {
553
694
  }
554
695
 
555
696
  if (timingEnabled) addTimingMs(timings, 'attrNormalizeMs', getNowMs() - attrNormalizeStartedAt)
697
+ if (lineNotes && lineNotes.length) token.attrSet('data-pre-line-notes', 'true')
556
698
 
557
699
  return {
558
700
  token,
@@ -560,10 +702,12 @@ const prepareFenceRenderContext = (tokens, idx, opt) => {
560
702
  startNumber,
561
703
  emphasizeLines,
562
704
  lineNumberSkipValue,
563
- lineNumberResetValue,
705
+ lineNumberSetValue,
564
706
  wrapEnabled,
565
707
  preWrapValue,
566
708
  commentMarkValue,
709
+ lineNotes,
710
+ lineNoteIdPrefix,
567
711
  timingEnabled,
568
712
  timings,
569
713
  fenceStartedAt,
@@ -586,5 +730,7 @@ export {
586
730
  preWrapStyle,
587
731
  prepareFenceRenderContext,
588
732
  resolveAdvancedLineNumberPlan,
733
+ resolveLineNotesPlan,
589
734
  splitFenceBlockToLines,
735
+ wrapFencePreWithLineNotes,
590
736
  }
@@ -1,56 +0,0 @@
1
- # Third-Party Notices
2
-
3
- This repository is licensed under MIT (see `LICENSE`), and also contains documentation assets derived from third-party themes.
4
-
5
- ## highlight.js theme references used in docs
6
-
7
- Files:
8
-
9
- - `docs/default-highlight-theme.css`
10
-
11
- Source references:
12
-
13
- - `highlight.js` style family (GitHub / GitHub Dark)
14
- - Upstream paths (for reference):
15
- - `node_modules/highlight.js/styles/github.css`
16
- - `node_modules/highlight.js/styles/github-dark.css`
17
-
18
- Upstream license:
19
-
20
- - BSD 3-Clause License
21
- - Copyright (c) 2006, Ivan Sagalaev.
22
-
23
- Full upstream license text:
24
-
25
- ```text
26
- BSD 3-Clause License
27
-
28
- Copyright (c) 2006, Ivan Sagalaev.
29
- All rights reserved.
30
-
31
- Redistribution and use in source and binary forms, with or without
32
- modification, are permitted provided that the following conditions are met:
33
-
34
- * Redistributions of source code must retain the above copyright notice, this
35
- list of conditions and the following disclaimer.
36
-
37
- * Redistributions in binary form must reproduce the above copyright notice,
38
- this list of conditions and the following disclaimer in the documentation
39
- and/or other materials provided with the distribution.
40
-
41
- * Neither the name of the copyright holder nor the names of its
42
- contributors may be used to endorse or promote products derived from
43
- this software without specific prior written permission.
44
-
45
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
46
- AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
47
- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
48
- DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
49
- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
50
- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
51
- SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
52
- CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
53
- OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
54
- OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
55
- ```
56
-