|
#!/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() |