Skip to content

Instantly share code, notes, and snippets.

@yhatt
Created August 7, 2026 23:49
Show Gist options
  • Select an option

  • Save yhatt/acea9267b5174a7dc3a112dd52966dcf to your computer and use it in GitHub Desktop.

Select an option

Save yhatt/acea9267b5174a7dc3a112dd52966dcf to your computer and use it in GitHub Desktop.
migrate-marp-theme-to-v5 skill for coding agent
#!/usr/bin/env node
/** Add Marp v5 Shiki variables derived from legacy highlight.js theme rules. */
import { readFileSync, writeFileSync } from 'node:fs'
import { extname } from 'node:path'
import { TextDecoder } from 'node:util'
const START_MARKER = '/* migrate-marp-theme-to-v5:start */'
const END_MARKER = '/* migrate-marp-theme-to-v5:end */'
const TOKEN_MAP = new Map([
['attr', 'function'],
['attribute', 'constant'],
['built_in', 'constant'],
['char', 'string-expression'],
['class', 'function'],
['constructor', 'function'],
['literal', 'constant'],
['number', 'constant'],
['operator', 'keyword'],
['property', 'constant'],
['selector-attr', 'function'],
['selector-class', 'function'],
['selector-id', 'function'],
['selector-pseudo', 'function'],
['symbol', 'constant'],
['variable', 'constant'],
['string', 'string-expression'],
['regexp', 'string-expression'],
['comment', 'comment'],
['bullet', 'string'],
['code', 'string'],
['doctag', 'keyword'],
['keyword', 'keyword'],
['meta', 'keyword'],
['template-tag', 'keyword'],
['type', 'function'],
['params', 'parameter'],
['function', 'function'],
['name', 'string-expression'],
['title', 'function'],
['subst', 'string-expression'],
['template-variable', 'constant'],
['punctuation', 'punctuation'],
['tag', 'punctuation'],
['link', 'link'],
['selector-tag', 'string-expression'],
['addition', 'inserted'],
['inserted', 'inserted'],
['deletion', 'deleted'],
['deleted', 'deleted'],
['change', 'changed'],
['changed', 'changed'],
])
// highlight.js uses these broad classes for several TextMate scopes. The
// selected target is the best general default, but context-specific review is
// still necessary.
const REVIEW_MAPPINGS = new Set([
'attr',
'built_in',
'meta',
'params',
'subst',
'symbol',
'template-tag',
'template-variable',
'type',
'variable',
])
class Report {
migrated = 0
warnings = []
notes = []
warn(message) {
if (!this.warnings.includes(message)) this.warnings.push(message)
}
}
const hljsMatches = (value) => [
...value.matchAll(/\.hljs(?:-([A-Za-z0-9_-]+))?/g),
]
function splitTopLevel(value, delimiter) {
const parts = []
let start = 0
let quote = ''
let parens = 0
let brackets = 0
let comment = false
for (let i = 0; i < value.length; i += 1) {
if (comment) {
if (value.startsWith('*/', i)) {
comment = false
i += 1
}
continue
}
if (!quote && value.startsWith('/*', i)) {
comment = true
i += 1
continue
}
const char = value[i]
if (quote) {
if (char === '\\') i += 1
else if (char === quote) quote = ''
} else if (char === '"' || char === "'") quote = char
else if (char === '(') parens += 1
else if (char === ')' && parens) parens -= 1
else if (char === '[') brackets += 1
else if (char === ']' && brackets) brackets -= 1
else if (char === delimiter && parens === 0 && brackets === 0) {
parts.push(value.slice(start, i))
start = i + 1
}
}
parts.push(value.slice(start))
return parts
}
function findMatchingBrace(text, opening) {
let depth = 1
let quote = ''
let comment = false
for (let i = opening + 1; i < text.length; i += 1) {
if (comment) {
if (text.startsWith('*/', i)) {
comment = false
i += 1
}
continue
}
if (!quote && text.startsWith('/*', i)) {
comment = true
i += 1
continue
}
const char = text[i]
if (quote) {
if (char === '\\') i += 1
else if (char === quote) quote = ''
} else if (char === '"' || char === "'") quote = char
else if (char === '{') depth += 1
else if (char === '}') {
depth -= 1
if (depth === 0) return i
}
}
throw new Error(`unclosed CSS block at offset ${opening}`)
}
function topLevelRules(css) {
const rules = []
let boundary = 0
let quote = ''
let comment = false
for (let i = 0; i < css.length; i += 1) {
if (comment) {
if (css.startsWith('*/', i)) {
comment = false
i += 1
}
continue
}
if (!quote && css.startsWith('/*', i)) {
comment = true
i += 1
continue
}
const char = css[i]
if (quote) {
if (char === '\\') i += 1
else if (char === quote) quote = ''
} else if (char === '"' || char === "'") quote = char
else if (char === ';') boundary = i + 1
else if (char === '{') {
const closeBrace = findMatchingBrace(css, i)
const rawPrelude = css.slice(boundary, i)
const separator = Math.max(rawPrelude.lastIndexOf(';'), rawPrelude.lastIndexOf('}'))
const start = boundary + separator + 1
rules.push({
start,
openBrace: i,
closeBrace,
prelude: css.slice(start, i).trim(),
body: css.slice(i + 1, closeBrace),
})
boundary = closeBrace + 1
i = closeBrace
}
}
return rules
}
function declarations(body) {
const parsed = []
for (const chunk of splitTopLevel(body, ';')) {
const pair = splitTopLevel(chunk, ':')
if (pair.length < 2) continue
const property = pair[0].trim().toLowerCase()
const value = pair.slice(1).join(':').trim()
if (/^(?:--)?[\w-]+$/.test(property) && value) parsed.push([property, value])
}
return parsed
}
const splitSelectors = (prelude) =>
splitTopLevel(prelude, ',').map((part) => part.trim()).filter(Boolean)
const cleanPrelude = (prelude) =>
prelude.replace(/^\s*(?:\/\*[\s\S]*?\*\/\s*)+/, '')
function targetScope(selector) {
const match = /\.hljs(?:-([A-Za-z0-9_-]+))?/.exec(selector)
if (!match) return 'section'
let prefix = selector.slice(0, match.index).trimEnd()
const wrapper = /:(?:where|is)\(\s*$/.exec(prefix)
if (wrapper) prefix = prefix.slice(0, wrapper.index).trimEnd()
prefix = prefix.replace(/(?:\s*[>+~]\s*|\s+)$/, '').trim()
return prefix || 'section'
}
function tokenVariable(selector) {
const matches = hljsMatches(selector)
if (matches.length === 0) return null
const token = matches.at(-1)[1]
if (token === undefined) return null
const mapped = TOKEN_MAP.get(token)
return mapped ? `--marp-shiki-token-${mapped}` : ''
}
function makeVariableBlock(candidates) {
const lines = [START_MARKER]
for (const [scope, values] of candidates) {
lines.push(`${scope} {`)
for (const [variable, value] of values) lines.push(` ${variable}: ${value};`)
lines.push('}')
}
lines.push(END_MARKER)
return lines.join('\n')
}
function applyReplacements(text, replacements) {
for (const { start, end, value } of replacements.sort((a, b) => b.start - a.start)) {
text = text.slice(0, start) + value + text.slice(end)
}
return text
}
function migrateCssBlock(css, report, context) {
if (css.includes(START_MARKER) || css.includes(END_MARKER)) {
if (css.includes(START_MARKER) && css.includes(END_MARKER)) {
report.notes.push(`${context}: kept existing editable migration snapshot verbatim`)
return css
}
throw new Error(`${context}: incomplete migration marker pair`)
}
const rules = topLevelRules(css)
const replacements = []
const candidates = new Map()
const candidateSources = new Map()
const existing = new Map()
const baseScopes = new Set()
for (const rule of rules) {
const prelude = cleanPrelude(rule.prelude)
if (prelude.startsWith('@')) {
const migrated = migrateCssBlock(rule.body, report, `${context} / ${prelude}`)
if (migrated !== rule.body) {
replacements.push({ start: rule.openBrace + 1, end: rule.closeBrace, value: migrated })
}
continue
}
const shikiVariables = new Set(
declarations(rule.body)
.map(([property]) => property)
.filter((property) => property.startsWith('--marp-shiki-')),
)
if (shikiVariables.size) {
for (const selector of splitSelectors(prelude)) {
if (!existing.has(selector)) existing.set(selector, new Set())
for (const variable of shikiVariables) existing.get(selector).add(variable)
}
}
}
for (const rule of rules) {
const prelude = cleanPrelude(rule.prelude)
if (prelude.startsWith('@') || !prelude.includes('.hljs')) continue
const parsedDeclarations = declarations(rule.body)
const reversed = [...parsedDeclarations].reverse()
const color = reversed.find(([property]) => property === 'color')?.[1]
const background = reversed.find(([property]) =>
property === 'background' || property === 'background-color')?.[1]
const unsupported = [...new Set(
parsedDeclarations
.map(([property]) => property)
.filter((property) =>
property !== 'color' && property !== 'background' && property !== 'background-color'),
)].sort()
if (unsupported.length) {
report.warn(
`${context}: kept non-color declaration(s) ${unsupported.join(', ')} on ${JSON.stringify(prelude)}`,
)
}
for (const selector of splitSelectors(prelude)) {
const matches = hljsMatches(selector)
if (matches.length === 0) continue
const scope = targetScope(selector)
const isBase = matches.at(-1)[1] === undefined
const variable = isBase ? '--marp-shiki-foreground' : tokenVariable(selector)
if (isBase) baseScopes.add(scope)
if (variable === '') {
report.warn(`${context}: no automatic token mapping for ${JSON.stringify(selector)}`)
continue
}
const token = matches.at(-1)[1]
if (token !== undefined && REVIEW_MAPPINGS.has(token)) {
report.warn(
`${context}: ${JSON.stringify(selector)} uses a context-dependent best-effort mapping to ${variable}; review representative languages`,
)
}
if (!candidates.has(scope)) candidates.set(scope, new Map())
if (!candidateSources.has(scope)) candidateSources.set(scope, new Map())
const values = candidates.get(scope)
const sources = candidateSources.get(scope)
if (color && variable) {
if (values.has(variable) && values.get(variable) !== color) {
report.warn(
`${context}: ${JSON.stringify(sources.get(variable))} (${values.get(variable)}) and ${JSON.stringify(selector)} (${color}) map to ${variable} in ${JSON.stringify(scope)}; kept the later declaration`,
)
}
if (!existing.get(scope)?.has(variable)) {
values.set(variable, color)
sources.set(variable, selector)
}
}
if (background) {
if (isBase) {
const backgroundVariable = '--marp-shiki-background'
if (!existing.get(scope)?.has(backgroundVariable)) {
values.set(backgroundVariable, background)
}
} else {
report.warn(
`${context}: token-level background on ${JSON.stringify(selector)} has no direct Shiki variable`,
)
}
}
}
}
const hasResolvedVariable = (scope, variable) => {
if (candidates.get(scope)?.has(variable) || existing.get(scope)?.has(variable)) return true
return scope !== 'section' && (
candidates.get('section')?.has(variable) || existing.get('section')?.has(variable)
)
}
for (const scope of baseScopes) {
const lineHighlight = '--marp-shiki-line-highlight'
if (existing.get(scope)?.has(lineHighlight)) continue
if (
hasResolvedVariable(scope, '--marp-shiki-foreground')
&& hasResolvedVariable(scope, '--marp-shiki-background')
) {
if (!candidates.has(scope)) candidates.set(scope, new Map())
candidates.get(scope).set(
lineHighlight,
'color-mix(in srgb, var(--marp-shiki-foreground) 20%, var(--marp-shiki-background))',
)
report.warn(
`${context}: inferred ${lineHighlight} for ${JSON.stringify(scope)} from foreground and background; review the proposed contrast`,
)
} else {
report.warn(
`${context}: could not infer ${lineHighlight} for ${JSON.stringify(scope)} because foreground or background is unresolved`,
)
}
}
for (const [scope, values] of [...candidates]) {
if (values.size === 0) candidates.delete(scope)
}
if (candidates.size === 0) return applyReplacements(css, replacements)
report.migrated += [...candidates.values()]
.reduce((count, values) => count + values.size, 0)
const transformed = applyReplacements(css, replacements)
const separator = !transformed || transformed.endsWith('\n\n')
? ''
: transformed.endsWith('\n') ? '\n' : '\n\n'
return transformed + separator + makeVariableBlock(candidates) + '\n'
}
function splitLinesKeepEnds(text) {
return text.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) ?? []
}
function fencedRanges(markdown) {
const ranges = []
let offset = 0
let opening = null
for (const line of splitLinesKeepEnds(markdown)) {
const match = /^[ \t]{0,3}(`{3,}|~{3,})/.exec(line)
if (match) {
const fence = match[1]
if (!opening) opening = { character: fence[0], length: fence.length, start: offset }
else if (fence[0] === opening.character && fence.length >= opening.length) {
ranges.push([opening.start, offset + line.length])
opening = null
}
}
offset += line.length
}
if (opening) ranges.push([opening.start, markdown.length])
return ranges
}
const insideRanges = (position, ranges) =>
ranges.some(([start, end]) => start <= position && position < end)
function markdownCssRegions(markdown, report) {
const regions = []
const fences = fencedRanges(markdown)
for (const match of markdown.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gdi)) {
if (!insideRanges(match.index, fences)) {
regions.push({ start: match.indices[1][0], end: match.indices[1][1], value: '<style>' })
}
}
const containers = []
const frontMatter = /^---[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/d.exec(markdown)
if (frontMatter) {
containers.push({
start: frontMatter.indices[1][0],
end: frontMatter.indices[1][1],
label: 'front matter style directive',
})
}
for (const comment of markdown.matchAll(/<!--[\s\S]*?-->/gd)) {
if (!insideRanges(comment.index, fences)) {
containers.push({
start: comment.index,
end: comment.index + comment[0].length,
label: 'comment style directive',
})
}
}
for (const container of containers) {
const chunk = markdown.slice(container.start, container.end)
for (const match of chunk.matchAll(/^(?<indent>[ \t]*)style\s*:\s*[|>]([+-])?[ \t]*(?:#.*)?\r?\n/gm)) {
const indent = match.groups.indent
const contentStart = container.start + match.index + match[0].length
let cursor = contentStart
let contentEnd = contentStart
for (const line of splitLinesKeepEnds(markdown.slice(contentStart, container.end))) {
const indentation = /^[ \t]*/.exec(line)[0].length
if (line.trim() && indentation <= indent.length) break
cursor += line.length
contentEnd = cursor
}
if (contentEnd <= contentStart) continue
const raw = markdown.slice(contentStart, contentEnd)
const nonblank = splitLinesKeepEnds(raw).filter((line) => line.trim())
const common = nonblank.length
? Math.min(...nonblank.map((line) => /^[ \t]*/.exec(line)[0].length))
: 0
const dedented = splitLinesKeepEnds(raw)
.map((line) => line.trim() ? line.slice(common) : line)
.join('')
const migrated = migrateCssBlock(dedented, report, container.label)
const prefix = ' '.repeat(common)
const indented = splitLinesKeepEnds(migrated)
.map((line) => line.trim() ? prefix + line : line)
.join('')
regions.push({ start: contentStart, end: contentEnd, value: indented })
}
}
return regions
}
function migrateMarkdown(markdown, report) {
const regions = markdownCssRegions(markdown, report)
const replacements = []
for (const region of regions) {
const migrated = ['<style>', 'front matter style directive', 'comment style directive']
.includes(region.value)
? migrateCssBlock(markdown.slice(region.start, region.end), report, region.value)
: region.value
if (migrated !== markdown.slice(region.start, region.end)) {
replacements.push({ start: region.start, end: region.end, value: migrated })
}
}
const output = applyReplacements(markdown, replacements)
const maskReplacements = [
...regions.map(({ start, end }) => ({ start, end, value: ' '.repeat(end - start) })),
...fencedRanges(markdown)
.map(([start, end]) => ({ start, end, value: ' '.repeat(end - start) })),
]
const masked = applyReplacements(markdown, maskReplacements)
if (masked.includes('.hljs')) {
report.warn('Markdown contains .hljs text outside supported style regions; inspect it manually')
}
return output
}
function detectFormat(inputPath, requested) {
if (requested !== 'auto') return requested
const extension = extname(inputPath).toLowerCase()
if (['.md', '.markdown', '.mdown', '.mkd'].includes(extension)) return 'markdown'
if (extension === '.css') return 'css'
throw new Error('cannot detect input format; pass --format css or --format markdown')
}
function usage() {
return `usage: migrate_marp_theme.mjs [--format auto|css|markdown] [--write] [--check] input
Add Marp v5 Shiki variables derived from legacy highlight.js theme rules.
options:
-h, --help show this help message and exit
--format FORMAT auto, css, or markdown (default: auto)
--write edit the input file in place
--check exit 1 if migration would change the file`
}
function parseArgs(argv) {
const args = { format: 'auto', write: false, check: false, input: null, help: false }
for (let i = 0; i < argv.length; i += 1) {
const value = argv[i]
if (value === '-h' || value === '--help') args.help = true
else if (value === '--write') args.write = true
else if (value === '--check') args.check = true
else if (value === '--format' || value.startsWith('--format=')) {
const format = value === '--format' ? argv[++i] : value.slice('--format='.length)
if (!['auto', 'css', 'markdown'].includes(format)) {
throw new Error('--format must be auto, css, or markdown')
}
args.format = format
} else if (value.startsWith('-')) throw new Error(`unknown option: ${value}`)
else if (args.input) throw new Error('accepts exactly one input file')
else args.input = value
}
if (!args.help && !args.input) throw new Error('input file is required')
return args
}
function main() {
let args
try {
args = parseArgs(process.argv.slice(2))
if (args.help) {
console.log(usage())
return 0
}
if (args.write && args.check) throw new Error('--write and --check cannot be combined')
const source = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(args.input))
const format = detectFormat(args.input, args.format)
const report = new Report()
const output = format === 'markdown'
? migrateMarkdown(source, report)
: migrateCssBlock(source, report, args.input)
const changed = output !== source
console.error(`migrated ${report.migrated} variable declaration(s)`)
for (const note of report.notes) console.error(`note: ${note}`)
for (const warning of report.warnings) console.error(`warning: ${warning}`)
if (args.check) return changed ? 1 : 0
if (args.write) {
if (changed) writeFileSync(args.input, output, 'utf8')
return 0
}
process.stdout.write(output)
return 0
} catch (error) {
console.error(`error: ${error.message}`)
return 2
}
}
process.exitCode = main()
name migrate-marp-theme-to-v5
description Migrate Marp theme syntax-highlighting colors from legacy highlight.js `.hljs` and `.hljs-*` CSS selectors to Marp Core v5 `--marp-shiki-*` custom properties, infer a reviewable line-highlight color, and retain every legacy rule for backward compatibility. Use for Marp theme CSS and Marp Markdown that contains HTML style elements or a YAML `style` directive, including scoped or variant-specific highlighting colors.

Migrate Marp Theme to v5

Preserve legacy highlight.js rules and add Marp v5 Shiki color variables beside them. Treat the migration as additive: never delete or rewrite .hljs or .hljs-* rules.

Workflow

  1. Identify whether each input is CSS or Markdown. For Markdown, inspect <style> elements and block-scalar style: | / style: > directives in front matter or HTML directive comments.

  2. Run the bundled converter without --write and capture its proposed output:

    node migrate_marp_theme.mjs INPUT > /tmp/migrated-output

    Pass --format css or --format markdown only when extension-based detection is wrong.

  3. Review stderr diagnostics and diff the proposal against the input. Read token-mapping.md when the script reports ambiguous selectors, unsupported declarations, or competing colors.

  4. Review the proposed marked block. Adjust inferred values and add review-only variables inside the matching scope in that same block. Do not create a separate section or variant override just to hold reviewed migration values. Only migrate color declarations. Keep font weight, font style, text decoration, and token-specific background declarations on the legacy selectors.

  5. Apply only after review. If the proposal needs no edits, run:

    node migrate_marp_theme.mjs --write INPUT

    If the proposal was edited, apply that reviewed proposal to the input instead; do not run --write afterward because it would recreate the unreviewed proposal.

  6. Verify structurally that both generations coexist: .hljs-* rules remain unchanged and the relevant scopes now define --marp-shiki-*. Report that visual verification remains for the user.

Guardrails

  • Keep all original .hljs selectors and declarations byte-for-byte; add a marked variable block instead of replacing them.
  • Preserve every selector scope without special-casing class names. Rules such as section.dark .hljs-keyword, section.light .hljs-keyword, and section.ocean .hljs-keyword must define independent variables on section.dark, section.light, and section.ocean, not globally.
  • Respect CSS source order. If multiple legacy rules map to the same variable and scope, use the last color declaration and report the collision.
  • Do not overwrite an existing --marp-shiki-* declaration in the same scope. Prefer the author's explicit v5 value.
  • Infer a missing --marp-shiki-line-highlight only where both Shiki foreground and background resolve. Mix foreground 20% into background, preserve scope, and report the inference for review.
  • Do not infer a Shiki color from font-only or layout-only rules.
  • Do not migrate .hljs-* text found in ordinary Markdown prose or fenced code examples.
  • Use only the bundled converter and Node.js standard library. Do not install or invoke Marp Core, Marp CLI, Shiki, highlight.js, or any @marp-team/* package.
  • Leave Marp Core rendering and visual comparison to the user. Do not start Marp Core or render slides as part of this workflow.
  • Treat a complete migrate-marp-theme-to-v5:start/end block as an editable migration snapshot. Keep reviewed values inside its matching scope; never move them into a duplicate author-override block merely for ownership or idempotence.
  • The converter preserves a complete marked block verbatim, including manual edits. --check returning 0 confirms that the converter will leave the snapshot alone; it does not prove that the snapshot still matches changed legacy rules.
  • Remove the complete marked block before regenerating it from changed legacy rules. Never remove only one marker.

Converter behavior

The dependency-free converter requires only Node.js 20.19 or later and accepts one CSS or Markdown file. It has no npm or Marp tool dependency. It writes the complete proposed file to stdout by default, edits in place with --write, and reports findings on stderr. Use --check in automation: it exits 1 when a migration would change an unmarked file, 0 when no action is needed or a complete marked snapshot is already present, and 2 for invalid input or unsupported format.

Markdown support covers:

  • <style> and <style scoped> elements outside fenced code blocks
  • YAML block-scalar style directives in leading front matter
  • YAML block-scalar style directives inside <!-- ... --> comments

If diagnostics report legacy selectors outside those regions, inspect them manually rather than transforming the whole Markdown document.

highlight.js to Marp Shiki color mapping

Use this table as a semantic default, not as a promise of visual equivalence. highlight.js assigns broad CSS classes while Shiki assigns TextMate scopes, so one legacy class can cover several Shiki variables.

Evidence basis

The defaults below were checked against the original official theme packages rather than Marp's built-in themes. The analysis first inventoried both complete collections:

  • highlight.js 11.11.1: all 256 non-minified CSS files in the official package
  • @shikijs/themes 4.3.1: all 65 official themes

From those collections, 29 common or directly corresponding theme families were rendered over the same 24-language corpus.

The direct pairs were GitHub light/dark/dimmed, Monokai, Night Owl, Nord, Rosé Pine and its Dawn/Moon variants, and Tokyo Night. The corresponding-family pairs were Dracula; all six Gruvbox hard/medium/soft light/dark variants; Material, Darker, Lighter, and Palenight; Atom One Dark/Light to One Dark Pro/One Light; Solarized Dark/Light; Horizon Dark/Light; and VS/VS2015 to Light Plus/Dark Plus.

For every non-whitespace character, the analysis aligned the highlight.js class with Shiki's createCssVariablesTheme bucket. It then compared the actual colors from the paired official themes in OKLab. Scope agreement is primary evidence; color proximity is supporting evidence because themes from the same family are often separate ports with non-identical palettes.

Strong observed correspondences included:

highlight.js class Shiki bucket Aligned-character share Median paired-theme color distance
comment comment 97.3% 0.057
keyword keyword 87.6% 0.138
title function 84.6% 0.076
regexp string-expression 80.0% 0.169
string string-expression 77.7% 0.028
selector-attr function 76.0% 0.156
link link 73.1% 0.142
name string-expression 68.3% 0.104
punctuation punctuation 64.7% 0.072
number constant 60.4% 0.028

selector-class, selector-id, and selector-pseudo aligned to function at 100% in the CSS/SCSS corpus; selector-tag aligned to string-expression at 100%. Inserted and deleted diff tokens also aligned at 100%.

Automatic mappings

Legacy selector token Marp v5 variable Confidence
.hljs color --marp-shiki-foreground high
.hljs background / background-color --marp-shiki-background high
attribute, literal, number, property --marp-shiki-token-constant high
char, name, regexp, string, selector-tag --marp-shiki-token-string-expression high
comment --marp-shiki-token-comment high
doctag, keyword, operator --marp-shiki-token-keyword high
class, constructor, function, selector-attr, selector-class, selector-id, selector-pseudo, title --marp-shiki-token-function high
bullet, code --marp-shiki-token-string high
punctuation, tag --marp-shiki-token-punctuation high
link --marp-shiki-token-link high
addition, inserted --marp-shiki-token-inserted high
deletion, deleted --marp-shiki-token-deleted high
change, changed --marp-shiki-token-changed high
attr --marp-shiki-token-function medium: HTML attributes align here, but JSON/YAML keys often align to keyword
built_in, symbol, template-variable, variable --marp-shiki-token-constant medium: broad language-dependent classes
meta, template-tag --marp-shiki-token-keyword medium: meta/template scopes split across several buckets
params --marp-shiki-token-parameter medium: highlight.js styles the whole parameter wrapper
subst --marp-shiki-token-string-expression medium: Shiki may use foreground inside interpolation expressions
type --marp-shiki-token-function medium: named types map to function, while type keywords map to keyword

The converter emits an explicit review warning for every medium-confidence mapping.

Review-only classes

Do not automatically map formula, quote, or section. Shiki normally gives these markup constructs inherited foreground plus font styling rather than a dedicated color variable. emphasis and strong are also style-only in Shiki's CSS-variable theme. Preserve their legacy rules and review manually if they declare a color.

Review rules

  • For a compound selector, map the final .hljs-* token. For example, .hljs-meta .hljs-keyword maps to keyword.
  • A grouped legacy rule may populate several variables with the same color. This is expected.
  • Treat attr, built_in, meta, params, subst, symbol, template-tag, template-variable, type, and variable as language-dependent. Report the affected languages and ask the user to verify their appearance before accepting the defaults.
  • Shiki variables represent colors. Leave font-weight, font-style, text-decoration, and token-level background-color on legacy rules and report them for manual review.
  • There is no direct legacy source for --marp-shiki-line-highlight. When both foreground and background resolve, propose color-mix(in srgb, var(--marp-shiki-foreground) 20%, var(--marp-shiki-background)) in the same scope. Treat the 20% mix as a reviewable estimate, not an exact migration. Prefer an existing line-highlight value or a deliberate semantic accent color.
  • Existing v5 variables are authoritative. Add only missing values in the same selector scope.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment