@peaceroad/markdown-it-renderer-fence 0.6.1 → 0.8.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.
Files changed (34) hide show
  1. package/README.md +183 -430
  2. package/docs/README.md +37 -0
  3. package/docs/code-highlighting-design.md +317 -0
  4. package/docs/custom-highlight-styling-guide.md +204 -0
  5. package/package.json +22 -13
  6. package/src/custom-highlight/option-validator.js +19 -10
  7. package/src/custom-highlight/scope-name.js +23 -0
  8. package/src/custom-highlight/{shiki-keyword-rules.js → shiki-role-rules.js} +229 -44
  9. package/src/custom-highlight/{shiki-keyword.js → shiki-role.js} +64 -56
  10. package/src/custom-highlight/shiki-theme-role.js +176 -0
  11. package/src/entry/markup-highlight.js +4 -0
  12. package/src/fence/line-notes.js +199 -0
  13. package/src/fence/render-api-provider.js +62 -62
  14. package/src/fence/render-api-renderer.js +26 -11
  15. package/src/fence/render-api-runtime.js +5 -13
  16. package/src/fence/render-api.js +24 -12
  17. package/src/fence/render-markup.js +24 -16
  18. package/src/fence/render-shared.js +232 -20
  19. package/theme/_shared/rf-core.css +164 -0
  20. package/theme/rf-basic/README.md +91 -0
  21. package/theme/rf-basic/api/highlightjs.css +71 -0
  22. package/theme/rf-basic/api/shiki.css +67 -0
  23. package/theme/rf-basic/base.css +3 -0
  24. package/theme/rf-basic/index.css +7 -0
  25. package/theme/rf-basic/index.js +155 -0
  26. package/theme/rf-basic/markup/highlightjs.css +77 -0
  27. package/theme/rf-basic/markup/shiki.css +11 -0
  28. package/theme/rf-monochrome/README.md +71 -0
  29. package/theme/rf-monochrome/base.css +3 -0
  30. package/theme/rf-monochrome/index.css +5 -0
  31. package/theme/rf-monochrome/index.js +72 -0
  32. package/theme/rf-monochrome/markup/highlightjs.css +134 -0
  33. package/theme/rf-monochrome/markup/shiki.css +27 -0
  34. package/THIRD_PARTY_NOTICES.md +0 -56
@@ -0,0 +1,199 @@
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 lineNoteHeaderReg = /^\s*([1-9]\d*)(?:\s*-\s*([1-9]\d*))?\s*[::]\s*(.*)$/
9
+ const lineNoteContinuationReg = /^(?: {2,}|\t)/
10
+ const lineNoteAttrsReg = /^(.*?)(?:\s+\{([^{}]+)\})\s*$/
11
+ const lineNoteAttrsOnlyReg = /^\{([^{}]+)\}\s*$/
12
+ const lineNoteWidthReg = /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem|ch|vw|svw|lvw|dvw|%)$/
13
+
14
+ const canContainLineNotes = (state) => {
15
+ const src = state && state.src
16
+ return typeof src === 'string' && src.indexOf('notes') !== -1
17
+ }
18
+
19
+ const isInfoWhitespaceCode = (code) => {
20
+ return code === 32 || code === 9 || code === 10 || code === 13 || code === 12
21
+ }
22
+
23
+ const isInfoNameEnd = (str, pos) => {
24
+ if (pos >= str.length) return true
25
+ const code = str.charCodeAt(pos)
26
+ return code === 123 || isInfoWhitespaceCode(code)
27
+ }
28
+
29
+ const infoNameStartsWith = (info, name) => {
30
+ const str = String(info ?? '')
31
+ let pos = 0
32
+ while (pos < str.length && isInfoWhitespaceCode(str.charCodeAt(pos))) pos++
33
+ return str.startsWith(name, pos) && isInfoNameEnd(str, pos + name.length)
34
+ }
35
+
36
+ const isLineNoteFenceInfo = (info) => {
37
+ return infoNameStartsWith(info, 'line-notes') || infoNameStartsWith(info, 'notes')
38
+ }
39
+
40
+ const stripContinuationIndent = (line) => {
41
+ if (!line) return ''
42
+ if (line[0] === '\t') return line.slice(1)
43
+ if (line.startsWith(' ')) return line.slice(2)
44
+ return line
45
+ }
46
+
47
+ const parseValidatedLineNoteWidth = (attrText) => {
48
+ if (!attrText || attrText.indexOf('width') === -1) return ''
49
+ const attrs = getInfoAttr(attrText)
50
+ if (!attrs.length) return ''
51
+ for (let i = 0; i < attrs.length; i++) {
52
+ const name = attrs[i][0]
53
+ const value = String(attrs[i][1] || '').trim()
54
+ if (name !== 'width') continue
55
+ if (lineNoteWidthReg.test(value)) return value
56
+ }
57
+ return ''
58
+ }
59
+
60
+ const parseLineNoteHeaderText = (text) => {
61
+ const str = String(text ?? '')
62
+ if (str.indexOf('{') === -1 || str.indexOf('}') === -1) {
63
+ return { text: str, width: '' }
64
+ }
65
+ const match = str.match(lineNoteAttrsReg)
66
+ if (!match) return { text: str, width: '' }
67
+ return {
68
+ text: match[1].trimEnd(),
69
+ width: parseValidatedLineNoteWidth(match[2]),
70
+ }
71
+ }
72
+
73
+ const parseLineNoteAttrsOnlyText = (text) => {
74
+ const str = String(text ?? '').trim()
75
+ if (str[0] !== '{' || str[str.length - 1] !== '}') return null
76
+ const match = str.match(lineNoteAttrsOnlyReg)
77
+ if (!match) return null
78
+ const width = parseValidatedLineNoteWidth(match[1])
79
+ return width ? { width } : null
80
+ }
81
+
82
+ const parseLineNotesContent = (content) => {
83
+ const raw = String(content ?? '')
84
+ if (!raw || (raw.indexOf(':') === -1 && raw.indexOf(':') === -1)) return []
85
+ const lines = raw.split('\n')
86
+ const notes = []
87
+ const seenStarts = new Set()
88
+ let current = null
89
+
90
+ const pushCurrent = () => {
91
+ if (!current) return true
92
+ if (!current.lines.length) return false
93
+ notes.push({
94
+ from: current.from,
95
+ to: current.to,
96
+ text: current.lines.join('\n'),
97
+ lineCount: current.lines.length,
98
+ width: current.width || '',
99
+ })
100
+ seenStarts.add(current.from)
101
+ current = null
102
+ return true
103
+ }
104
+
105
+ for (let i = 0; i < lines.length; i++) {
106
+ const line = lines[i]
107
+ const header = line.match(lineNoteHeaderReg)
108
+ if (header) {
109
+ if (!pushCurrent()) return null
110
+ let from = Number(header[1])
111
+ let to = header[2] ? Number(header[2]) : from
112
+ if (from > to) {
113
+ const swap = from
114
+ from = to
115
+ to = swap
116
+ }
117
+ if (seenStarts.has(from)) return null
118
+ current = {
119
+ from,
120
+ to,
121
+ lines: [],
122
+ width: '',
123
+ }
124
+ const parsed = parseLineNoteHeaderText(header[3])
125
+ current.width = parsed.width
126
+ if (parsed.text) current.lines.push(parsed.text)
127
+ continue
128
+ }
129
+
130
+ if (current && lineNoteContinuationReg.test(line)) {
131
+ const stripped = stripContinuationIndent(line)
132
+ const attrsOnly = parseLineNoteAttrsOnlyText(stripped)
133
+ if (attrsOnly) {
134
+ if (attrsOnly.width) current.width = attrsOnly.width
135
+ continue
136
+ }
137
+ current.lines.push(stripped)
138
+ continue
139
+ }
140
+
141
+ if (!line.trim()) {
142
+ continue
143
+ }
144
+
145
+ return null
146
+ }
147
+
148
+ if (!pushCurrent()) return null
149
+ return notes
150
+ }
151
+
152
+ const getTokenLineNotes = (token) => {
153
+ if (!token || !token.meta || typeof token.meta !== 'object') return null
154
+ const notes = token.meta[lineNotesMetaKey]
155
+ return Array.isArray(notes) && notes.length ? notes : null
156
+ }
157
+
158
+ const setTokenLineNotes = (token, notes) => {
159
+ if (!token || !notes || !notes.length) return
160
+ if (!token.meta || typeof token.meta !== 'object') token.meta = {}
161
+ token.meta[lineNotesMetaKey] = notes
162
+ }
163
+
164
+ const extendTokenMapWithImmediateFollower = (token, next) => {
165
+ if (!token || !Array.isArray(token.map) || token.map.length !== 2) return
166
+ if (!next || !Array.isArray(next.map) || next.map.length !== 2) return
167
+ const start = token.map[0]
168
+ const end = next.map[1]
169
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return
170
+ if (end > token.map[1]) token.map = [start, end]
171
+ }
172
+
173
+ const installLineNotesCoreRule = (md) => {
174
+ if (!md || md[lineNotesInstalledKey]) return
175
+ md[lineNotesInstalledKey] = true
176
+ md.core.ruler.after('block', lineNotesRuleName, (state) => {
177
+ if (!canContainLineNotes(state)) return
178
+ const tokens = state && state.tokens
179
+ if (!Array.isArray(tokens) || tokens.length < 2) return
180
+
181
+ for (let i = 0; i < tokens.length - 1; i++) {
182
+ const token = tokens[i]
183
+ const next = tokens[i + 1]
184
+ if (!token || token.type !== 'fence' || !next || next.type !== 'fence') continue
185
+ if (!isLineNoteFenceInfo(next.info)) continue
186
+ if (isLineNoteFenceInfo(token.info)) continue
187
+ const notes = parseLineNotesContent(next.content)
188
+ if (!notes || !notes.length) continue
189
+ setTokenLineNotes(token, notes)
190
+ extendTokenMapWithImmediateFollower(token, next)
191
+ tokens.splice(i + 1, 1)
192
+ }
193
+ })
194
+ }
195
+
196
+ export {
197
+ getTokenLineNotes,
198
+ installLineNotesCoreRule,
199
+ }
@@ -1,9 +1,9 @@
1
1
  import {
2
- classifyShikiScopeKeyword,
2
+ classifyShikiScopeRole,
3
3
  getShikiRawScopeName,
4
- normalizeShikiKeywordLangAliasMap,
5
- resolveShikiKeywordLangForFence,
6
- } from '../custom-highlight/shiki-keyword.js'
4
+ normalizeShikiRoleLangAliasMap,
5
+ resolveShikiRoleLangForFence,
6
+ } from '../custom-highlight/shiki-role.js'
7
7
  import {
8
8
  escapeHtmlAttr,
9
9
  getCustomHighlightPayloadMap,
@@ -15,8 +15,12 @@ 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,
23
+ getCommentLineRanges,
20
24
  normalizeEmphasisRanges,
21
25
  } from './render-shared.js'
22
26
  import {
@@ -31,7 +35,6 @@ import {
31
35
  } from './render-api-constants.js'
32
36
 
33
37
  const highlightNameUnsafeReg = /[^A-Za-z0-9_-]+/g
34
- const hyphenMultiReg = /-+/g
35
38
 
36
39
  const defaultCustomHighlightOpt = {
37
40
  provider: 'shiki',
@@ -44,9 +47,9 @@ const defaultCustomHighlightOpt = {
44
47
  scopePrefix: 'hl',
45
48
  includeScopeStyles: true,
46
49
  shikiScopeMode: 'auto',
47
- shikiKeywordClassifier: null,
48
- shikiKeywordLangResolver: null,
49
- shikiKeywordLangAliases: null,
50
+ shikiRoleClassifier: null,
51
+ shikiRoleLangResolver: null,
52
+ shikiRoleLangAliases: null,
50
53
  }
51
54
 
52
55
  const normalizeCustomHighlightProvider = (provider) => {
@@ -58,7 +61,7 @@ const normalizeCustomHighlightProvider = (provider) => {
58
61
  const normalizeShikiScopeMode = (mode) => {
59
62
  const key = String(mode || '').trim().toLowerCase()
60
63
  if (!key) return null
61
- if (key === 'keyword') return 'keyword'
64
+ if (key === 'role') return 'role'
62
65
  if (key === 'semantic') return 'semantic'
63
66
  if (key === 'color') return 'color'
64
67
  if (key === 'auto') return 'auto'
@@ -133,7 +136,7 @@ const buildShikiInternalLangAliasMap = (highlighter) => {
133
136
  } catch (e) {}
134
137
  rawMap[rawName] = canonical
135
138
  }
136
- return normalizeShikiKeywordLangAliasMap(rawMap)
139
+ return normalizeShikiRoleLangAliasMap(rawMap)
137
140
  }
138
141
 
139
142
  const normalizeCustomHighlightOpt = (opt = {}) => {
@@ -160,11 +163,11 @@ const normalizeCustomHighlightOpt = (opt = {}) => {
160
163
  } else {
161
164
  next.theme = normalizedTheme.singleTheme || null
162
165
  }
163
- if (typeof next.shikiKeywordClassifier !== 'function') next.shikiKeywordClassifier = null
164
- if (typeof next.shikiKeywordLangResolver !== 'function') next.shikiKeywordLangResolver = null
165
- next.shikiKeywordLangAliases = normalizeShikiKeywordLangAliasMap(next.shikiKeywordLangAliases)
166
+ if (typeof next.shikiRoleClassifier !== 'function') next.shikiRoleClassifier = null
167
+ if (typeof next.shikiRoleLangResolver !== 'function') next.shikiRoleLangResolver = null
168
+ next.shikiRoleLangAliases = normalizeShikiRoleLangAliasMap(next.shikiRoleLangAliases)
166
169
  next._shikiInternalLangAliasMap =
167
- (next.provider === 'shiki' && next.shikiScopeMode === 'keyword')
170
+ (next.provider === 'shiki' && next.shikiScopeMode === 'role')
168
171
  ? buildShikiInternalLangAliasMap(next.highlighter)
169
172
  : null
170
173
  const fallbackOn = Array.isArray(next.fallbackOn) ? next.fallbackOn : fallbackOnDefault
@@ -183,16 +186,6 @@ const shouldApplyApiFallbackForReason = (chOpt, reason) => {
183
186
  return chOpt._fallbackOnSet.has(reason)
184
187
  }
185
188
 
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
189
  const uniqueHighlightName = (base, usedMap) => {
197
190
  if (!usedMap.has(base)) {
198
191
  usedMap.set(base, 1)
@@ -280,19 +273,20 @@ const sameScopeStyles = (a, b) => {
280
273
  return true
281
274
  }
282
275
 
283
- const getLogicalLinesAndOffsets = (text) => {
284
- const rawLines = text.split('\n')
285
- const logicalLineCount = rawLines.length > 0 && rawLines[rawLines.length - 1] === '' ? rawLines.length - 1 : rawLines.length
286
- const lines = new Array(logicalLineCount)
287
- const offsets = new Array(logicalLineCount)
288
- let cursor = 0
289
- for (let i = 0; i < logicalLineCount; i++) {
290
- const line = rawLines[i]
291
- lines[i] = line
292
- offsets[i] = [cursor, cursor + line.length]
293
- cursor += line.length + 1
276
+ const getLogicalLineOffsets = (text) => {
277
+ const str = String(text ?? '')
278
+ const offsets = []
279
+ if (!str) return offsets
280
+ let lineStart = 0
281
+ for (let i = 0; i < str.length; i++) {
282
+ const code = str.charCodeAt(i)
283
+ if (code !== 10 && code !== 13) continue
284
+ offsets.push([lineStart, i])
285
+ if (code === 13 && i + 1 < str.length && str.charCodeAt(i + 1) === 10) i++
286
+ lineStart = i + 1
294
287
  }
295
- return { lines, offsets }
288
+ if (lineStart < str.length) offsets.push([lineStart, str.length])
289
+ return offsets
296
290
  }
297
291
 
298
292
  const getShikiTokenStyle = (token) => {
@@ -467,21 +461,21 @@ const hasShikiOffsets = (tokenLines) => {
467
461
  return false
468
462
  }
469
463
 
470
- const getShikiScopeName = (tok, lang, style, opt, preResolvedKeywordLang = '') => {
464
+ const getShikiScopeName = (tok, lang, style, opt, preResolvedRoleLang = '') => {
471
465
  const scopeMode = (opt && opt.shikiScopeMode) || 'auto'
472
466
  const rawScope = getShikiRawScopeName(tok)
473
- let customKeywordName = null
474
- const isKeywordScopeMode = scopeMode === 'keyword'
475
- if (isKeywordScopeMode && opt && typeof opt.shikiKeywordClassifier === 'function') {
467
+ let customRoleName = null
468
+ const isRoleScopeMode = scopeMode === 'role'
469
+ if (isRoleScopeMode && opt && typeof opt.shikiRoleClassifier === 'function') {
476
470
  try {
477
- const customName = opt.shikiKeywordClassifier(rawScope, tok, { lang, style, scopeMode })
478
- if (customName != null && customName !== '') customKeywordName = String(customName)
471
+ const customName = opt.shikiRoleClassifier(rawScope, tok, { lang, style, scopeMode })
472
+ if (customName != null && customName !== '') customRoleName = String(customName)
479
473
  } catch (e) {}
480
474
  }
481
- if (isKeywordScopeMode) {
482
- if (customKeywordName) return 'shiki-' + customKeywordName
483
- const bucket = classifyShikiScopeKeyword(rawScope, tok, lang, opt, preResolvedKeywordLang)
484
- return 'shiki-' + bucket
475
+ if (isRoleScopeMode) {
476
+ if (customRoleName) return 'shiki-role-' + customRoleName
477
+ const bucket = classifyShikiScopeRole(rawScope, tok, lang, opt, preResolvedRoleLang)
478
+ return 'shiki-role-' + bucket
485
479
  }
486
480
  if (scopeMode === 'semantic') {
487
481
  if (rawScope) return 'shiki-' + rawScope
@@ -523,11 +517,11 @@ const createRangesFromShikiTokens = (lang, tokenLines, opt) => {
523
517
  scopeMode === 'auto' ||
524
518
  scopeMode === 'color' ||
525
519
  scopeMode === 'semantic' ||
526
- (scopeMode === 'keyword' && !!(opt && typeof opt.shikiKeywordClassifier === 'function'))
520
+ (scopeMode === 'role' && !!(opt && typeof opt.shikiRoleClassifier === 'function'))
527
521
  const needTokenStyle = includeScopeStyles || needStyleForScopeName
528
- let preResolvedKeywordLang = ''
529
- if (scopeMode === 'keyword' && (!opt || typeof opt.shikiKeywordLangResolver !== 'function')) {
530
- preResolvedKeywordLang = resolveShikiKeywordLangForFence(lang, opt)
522
+ let preResolvedRoleLang = ''
523
+ if (scopeMode === 'role' && (!opt || typeof opt.shikiRoleLangResolver !== 'function')) {
524
+ preResolvedRoleLang = resolveShikiRoleLangForFence(lang, opt)
531
525
  }
532
526
  let cursor = 0
533
527
  for (let i = 0; i < tokenLines.length; i++) {
@@ -542,7 +536,7 @@ const createRangesFromShikiTokens = (lang, tokenLines, opt) => {
542
536
  const start = hasOffset ? tok.offset : cursor
543
537
  const end = start + content.length
544
538
  const style = needTokenStyle ? getShikiTokenStyle(tok) : null
545
- const scope = getShikiScopeName(tok, lang, style, opt, preResolvedKeywordLang)
539
+ const scope = getShikiScopeName(tok, lang, style, opt, preResolvedRoleLang)
546
540
  entries.push({ scope, start, end, style })
547
541
  cursor = end
548
542
  }
@@ -559,6 +553,7 @@ const normalizeCustomProviderRanges = (result) => {
559
553
  const entries = []
560
554
  for (const range of source.ranges) {
561
555
  let scope
556
+ let scopeIndex = null
562
557
  let start
563
558
  let end
564
559
  let style
@@ -575,10 +570,11 @@ const normalizeCustomProviderRanges = (result) => {
575
570
  } else {
576
571
  continue
577
572
  }
578
- if (typeof scope === 'number' && scopes && scopes[scope] != null) scope = scopes[scope]
573
+ if (Number.isSafeInteger(scope)) scopeIndex = scope
574
+ if (scopeIndex != null && scopes && scopes[scopeIndex] != null) scope = scopes[scopeIndex]
579
575
  if (scope == null) continue
580
576
  if (!style && scopeStyles) {
581
- if (typeof scope === 'number' && Array.isArray(scopeStyles)) style = scopeStyles[scope]
577
+ if (scopeIndex != null && Array.isArray(scopeStyles)) style = scopeStyles[scopeIndex]
582
578
  else style = scopeStyles[String(scope)]
583
579
  }
584
580
  entries.push({ scope: String(scope), start, end, style: normalizeScopeStyle(style) })
@@ -617,7 +613,12 @@ const buildApiPayload = (entries, text, lang, engine, scopePrefix, includeScopeS
617
613
  }
618
614
  scopeKeyToIndex.set(key, scopeIndex)
619
615
  }
620
- ranges.push([scopeIndex, start, end])
616
+ const previousRange = ranges.length ? ranges[ranges.length - 1] : null
617
+ if (previousRange && previousRange[0] === scopeIndex && previousRange[2] === start) {
618
+ previousRange[2] = end
619
+ } else {
620
+ ranges.push([scopeIndex, start, end])
621
+ }
621
622
  }
622
623
  const payload = {
623
624
  v: customHighlightPayloadSchemaVersion,
@@ -637,8 +638,8 @@ const pushLineFeatureRanges = (entries, content, emphasizeLines, setEmphasizeLin
637
638
  const needsEmphasis = !!(setEmphasizeLines && emphasizeLines && emphasizeLines.length > 0)
638
639
  const needsCommentScan = !!(commentMarkValue && content.indexOf(commentMarkValue) !== -1)
639
640
  if (!needsEmphasis && !needsCommentScan) return
640
- const { lines, offsets } = getLogicalLinesAndOffsets(content)
641
- const maxLine = lines.length
641
+ const offsets = needsEmphasis ? getLogicalLineOffsets(content) : null
642
+ const maxLine = offsets ? offsets.length : 0
642
643
  if (needsEmphasis) {
643
644
  const normalized = normalizeEmphasisRanges(emphasizeLines, maxLine)
644
645
  for (const [s, e] of normalized) {
@@ -648,11 +649,11 @@ const pushLineFeatureRanges = (entries, content, emphasizeLines, setEmphasizeLin
648
649
  }
649
650
  }
650
651
  if (needsCommentScan) {
651
- for (let i = 0; i < lines.length; i++) {
652
- if (lines[i].trimStart().startsWith(commentMarkValue)) {
653
- const [start, end] = offsets[i]
654
- if (end > start) entries.push({ scope: commentLineClass, start, end })
655
- }
652
+ const ranges = getCommentLineRanges(content, commentMarkValue)
653
+ if (!ranges) return
654
+ for (let i = 0; i < ranges.length; i++) {
655
+ const [start, end] = ranges[i]
656
+ entries.push({ scope: commentLineClass, start, end })
656
657
  }
657
658
  }
658
659
  }
@@ -805,6 +806,5 @@ export {
805
806
  normalizeCustomHighlightOpt,
806
807
  renderCustomHighlightPayloadScript,
807
808
  renderCustomHighlightScopeStyleTag,
808
- sanitizeHighlightName,
809
809
  shouldApplyApiFallbackForReason,
810
810
  }
@@ -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', 'setEmphasizeLines', 'lineEndSpanThreshold', 'comment-mark', 'line-notes'])
32
+
29
33
  let fallbackCustomHighlightSeq = 0
30
34
 
31
35
  const nextCustomHighlightId = (env, prefix) => {
@@ -45,23 +49,29 @@ 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, lineNumberSetValue, wrapEnabled, preWrapValue, commentMarkValue, includePayload, timings) => {
49
- const isSamp = opt._sampReg.test(lang)
52
+ const renderFenceApiOrPlain = (token, lang, isSamp, md, opt, slf, env, startNumber, emphasizeLines, lineNumberSkipValue, lineNumberSetValue, wrapEnabled, preWrapValue, commentMarkValue, lineNotes, lineNoteIdPrefix, includePayload, timings) => {
50
53
  const tag = isSamp ? 'samp' : 'code'
51
54
  let content = md.utils.escapeHtml(token.content)
52
55
  const lineStrategy = opt.customHighlight.lineFeatureStrategy
53
56
  const needLineNumber = lineStrategy === 'hybrid' && opt.setLineNumber && startNumber >= 0
54
57
  const needEndSpan = lineStrategy === 'hybrid' && opt.lineEndSpanThreshold > 0
58
+ const needAdvancedLineNumberPlan = needLineNumber && (lineNumberSkipValue !== undefined || lineNumberSetValue !== undefined)
59
+ const needLineNotes = lineStrategy !== 'disable' && !!(lineNotes && lineNotes.length)
60
+ const logicalLineCount = (needAdvancedLineNumberPlan || needLineNotes)
61
+ ? getLogicalLineCount(token.content)
62
+ : -1
55
63
  let lineNumberPlan = null
56
- if (needLineNumber && (lineNumberSkipValue !== undefined || lineNumberSetValue !== undefined)) {
57
- const logicalLineCount = getLogicalLineCount(token.content)
64
+ if (needAdvancedLineNumberPlan) {
58
65
  lineNumberPlan = resolveAdvancedLineNumberPlan(lineNumberSkipValue, lineNumberSetValue, logicalLineCount, logicalLineCount)
59
66
  }
60
- if (needLineNumber || needEndSpan) {
67
+ const lineNotePlan = needLineNotes
68
+ ? resolveLineNotesPlan(lineNotes, logicalLineCount, logicalLineCount, lineNoteIdPrefix)
69
+ : null
70
+ if (needLineNumber || needEndSpan || lineNotePlan) {
61
71
  const splitStartedAt = timings ? getNowMs() : 0
62
72
  const nlIndex = content.indexOf('\n')
63
73
  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)
74
+ content = splitFenceBlockToLines(content, [], needLineNumber, false, needEndSpan, opt.lineEndSpanThreshold, opt.lineEndSpanClass, br, undefined, commentLineClass, lineNumberPlan, lineNotePlan)
65
75
  if (timings) addTimingMs(timings, 'lineSplitMs', getNowMs() - splitStartedAt)
66
76
  }
67
77
 
@@ -88,7 +98,8 @@ const renderFenceApiOrPlain = (token, lang, md, opt, slf, env, startNumber, emph
88
98
 
89
99
  if (preAttrs.length) orderTokenAttrs({ attrs: preAttrs }, opt)
90
100
  const preAttrsText = preAttrs.length ? slf.renderAttrs({ attrs: preAttrs }) : ''
91
- const html = `<pre${preAttrsText}><${tag}${slf.renderAttrs(token)}>${content}</${tag}></pre>\n`
101
+ const preHtml = `<pre${preAttrsText}><${tag}${slf.renderAttrs(token)}>${content}</${tag}></pre>`
102
+ const html = wrapFencePreWithLineNotes(preHtml, lineNotePlan)
92
103
  return inlinePayloadScript ? html + inlinePayloadScript : html
93
104
  }
94
105
 
@@ -105,6 +116,9 @@ const getFenceHtml = (context, md, opt, slf, env) => {
105
116
  const wrapEnabled = context.wrapEnabled
106
117
  const preWrapValue = context.preWrapValue
107
118
  const commentMarkValue = context.commentMarkValue
119
+ const lineNotes = context.lineNotes
120
+ const lineNoteIdPrefix = context.lineNoteIdPrefix
121
+ const isSamp = context.isSamp
108
122
 
109
123
  const apiDecisionBase = {
110
124
  renderer: 'api',
@@ -112,16 +126,17 @@ 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-set', 'lineEndSpanThreshold']
129
+ ? apiDisableLineFeatureList
116
130
  : [],
117
131
  }
118
132
  try {
119
- const html = renderFenceApiOrPlain(token, lang, md, opt, slf, env, startNumber, emphasizeLines, lineNumberSkipValue, lineNumberSetValue, wrapEnabled, preWrapValue, commentMarkValue, true, timings)
133
+ const html = renderFenceApiOrPlain(token, lang, isSamp, 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
123
137
  } catch (err) {
124
- if (opt.customHighlight.fallback === 'plain' && shouldApplyApiFallbackForReason(opt.customHighlight, 'provider-error')) {
138
+ if (!shouldApplyApiFallbackForReason(opt.customHighlight, 'provider-error')) throw err
139
+ if (opt.customHighlight.fallback === 'plain') {
125
140
  const fallbackDecision = {
126
141
  renderer: 'api',
127
142
  includePayload: false,
@@ -130,7 +145,7 @@ const getFenceHtml = (context, md, opt, slf, env) => {
130
145
  reason: 'provider-error',
131
146
  lineFeatureStrategy: opt.customHighlight.lineFeatureStrategy,
132
147
  }
133
- const html = renderFenceApiOrPlain(token, lang, md, opt, slf, env, startNumber, emphasizeLines, lineNumberSkipValue, lineNumberSetValue, wrapEnabled, preWrapValue, commentMarkValue, false, timings)
148
+ const html = renderFenceApiOrPlain(token, lang, isSamp, md, opt, slf, env, startNumber, emphasizeLines, lineNumberSkipValue, lineNumberSetValue, wrapEnabled, preWrapValue, commentMarkValue, lineNotes, lineNoteIdPrefix, false, timings)
134
149
  if (timingEnabled) fallbackDecision.timings = finalizeFenceTimings(timings, fenceStartedAt)
135
150
  emitFenceDecision(opt, fallbackDecision)
136
151
  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,
@@ -37,6 +40,9 @@ const shouldRuntimeFallback = (reason, opt = {}) => {
37
40
  return shouldApplyApiFallbackForReason(chOpt, reason)
38
41
  }
39
42
 
43
+ const customHighlightEnvInitInstalledKey = '__rendererFenceCustomHighlightEnvInitInstalled'
44
+ const customHighlightEnvInitOptionKey = '__rendererFenceCustomHighlightEnvInitOption'
45
+
40
46
  const mditRendererFenceCustomHighlight = (md, option) => {
41
47
  const opt = {
42
48
  ...createCommonFenceOptionDefaults(md),
@@ -66,25 +72,31 @@ const mditRendererFenceCustomHighlight = (md, option) => {
66
72
  opt.customHighlight._hasShikiHighlighter = !!(opt.customHighlight.highlighter && typeof opt.customHighlight.highlighter.codeToTokens === 'function')
67
73
  const tokenOptionBase = {}
68
74
  if (opt.customHighlight._singleTheme) tokenOptionBase.theme = opt.customHighlight._singleTheme
69
- if (opt.customHighlight.shikiScopeMode === 'semantic' || opt.customHighlight.shikiScopeMode === 'keyword') {
75
+ if (opt.customHighlight.shikiScopeMode === 'semantic' || opt.customHighlight.shikiScopeMode === 'role') {
70
76
  tokenOptionBase.includeExplanation = 'scopeName'
71
77
  }
72
78
  opt.customHighlight._shikiTokenOptionBase = Object.keys(tokenOptionBase).length ? tokenOptionBase : null
73
79
  }
74
80
 
75
81
  finalizeCommonFenceOption(opt)
82
+ installLineNotesCoreRule(md)
76
83
 
77
- md.core.ruler.before('block', customHighlightEnvInitRuleName, (state) => {
78
- const env = state && state.env
79
- if (!env || typeof env !== 'object') return
80
- if (opt.customHighlight.transport === 'env') {
81
- env[customHighlightDataEnvKey] = {}
82
- env[customHighlightSeqEnvKey] = 0
83
- return
84
- }
85
- delete env[customHighlightDataEnvKey]
86
- delete env[customHighlightSeqEnvKey]
87
- })
84
+ md[customHighlightEnvInitOptionKey] = opt.customHighlight
85
+ if (!md[customHighlightEnvInitInstalledKey]) {
86
+ md[customHighlightEnvInitInstalledKey] = true
87
+ md.core.ruler.before('block', customHighlightEnvInitRuleName, (state) => {
88
+ const env = state && state.env
89
+ if (!env || typeof env !== 'object') return
90
+ const chOpt = md[customHighlightEnvInitOptionKey]
91
+ if (chOpt && chOpt.transport === 'env') {
92
+ env[customHighlightDataEnvKey] = {}
93
+ env[customHighlightSeqEnvKey] = 0
94
+ return
95
+ }
96
+ delete env[customHighlightDataEnvKey]
97
+ delete env[customHighlightSeqEnvKey]
98
+ })
99
+ }
88
100
 
89
101
  md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
90
102
  const context = prepareFenceRenderContext(tokens, idx, opt)