diff options
Diffstat (limited to 'packages/markdown/remark/src')
-rw-r--r-- | packages/markdown/remark/src/frontmatter-injection.ts | 34 | ||||
-rw-r--r-- | packages/markdown/remark/src/frontmatter.ts | 74 | ||||
-rw-r--r-- | packages/markdown/remark/src/highlight.ts | 23 | ||||
-rw-r--r-- | packages/markdown/remark/src/index.ts | 46 | ||||
-rw-r--r-- | packages/markdown/remark/src/internal.ts | 1 | ||||
-rw-r--r-- | packages/markdown/remark/src/rehype-collect-headings.ts | 28 | ||||
-rw-r--r-- | packages/markdown/remark/src/rehype-images.ts | 6 | ||||
-rw-r--r-- | packages/markdown/remark/src/rehype-shiki.ts | 16 | ||||
-rw-r--r-- | packages/markdown/remark/src/remark-collect-images.ts | 7 | ||||
-rw-r--r-- | packages/markdown/remark/src/shiki.ts | 301 | ||||
-rw-r--r-- | packages/markdown/remark/src/types.ts | 46 |
11 files changed, 327 insertions, 255 deletions
diff --git a/packages/markdown/remark/src/frontmatter-injection.ts b/packages/markdown/remark/src/frontmatter-injection.ts deleted file mode 100644 index 91b98ebcb..000000000 --- a/packages/markdown/remark/src/frontmatter-injection.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { VFileData as Data, VFile } from 'vfile'; -import type { MarkdownAstroData } from './types.js'; - -function isValidAstroData(obj: unknown): obj is MarkdownAstroData { - if (typeof obj === 'object' && obj !== null && obj.hasOwnProperty('frontmatter')) { - const { frontmatter } = obj as any; - try { - // ensure frontmatter is JSON-serializable - JSON.stringify(frontmatter); - } catch { - return false; - } - return typeof frontmatter === 'object' && frontmatter !== null; - } - return false; -} - -export class InvalidAstroDataError extends TypeError {} - -export function safelyGetAstroData(vfileData: Data): MarkdownAstroData | InvalidAstroDataError { - const { astro } = vfileData; - - if (!astro || !isValidAstroData(astro)) { - return new InvalidAstroDataError(); - } - - return astro; -} - -export function setVfileFrontmatter(vfile: VFile, frontmatter: Record<string, any>) { - vfile.data ??= {}; - vfile.data.astro ??= {}; - (vfile.data.astro as any).frontmatter = frontmatter; -} diff --git a/packages/markdown/remark/src/frontmatter.ts b/packages/markdown/remark/src/frontmatter.ts new file mode 100644 index 000000000..60ae0b5da --- /dev/null +++ b/packages/markdown/remark/src/frontmatter.ts @@ -0,0 +1,74 @@ +import yaml from 'js-yaml'; + +export function isFrontmatterValid(frontmatter: Record<string, any>) { + try { + // ensure frontmatter is JSON-serializable + JSON.stringify(frontmatter); + } catch { + return false; + } + return typeof frontmatter === 'object' && frontmatter !== null; +} + +const frontmatterRE = /^---(.*?)^---/ms; +export function extractFrontmatter(code: string): string | undefined { + return frontmatterRE.exec(code)?.[1]; +} + +export interface ParseFrontmatterOptions { + /** + * How the frontmatter should be handled in the returned `content` string. + * - `preserve`: Keep the frontmatter. + * - `remove`: Remove the frontmatter. + * - `empty-with-spaces`: Replace the frontmatter with empty spaces. (preserves sourcemap line/col/offset) + * - `empty-with-lines`: Replace the frontmatter with empty line breaks. (preserves sourcemap line/col) + * + * @default 'remove' + */ + frontmatter: 'preserve' | 'remove' | 'empty-with-spaces' | 'empty-with-lines'; +} + +export interface ParseFrontmatterResult { + frontmatter: Record<string, any>; + rawFrontmatter: string; + content: string; +} + +export function parseFrontmatter( + code: string, + options?: ParseFrontmatterOptions, +): ParseFrontmatterResult { + const rawFrontmatter = extractFrontmatter(code); + + if (rawFrontmatter == null) { + return { frontmatter: {}, rawFrontmatter: '', content: code }; + } + + const parsed = yaml.load(rawFrontmatter); + const frontmatter = (parsed && typeof parsed === 'object' ? parsed : {}) as Record<string, any>; + + let content: string; + switch (options?.frontmatter ?? 'remove') { + case 'preserve': + content = code; + break; + case 'remove': + content = code.replace(`---${rawFrontmatter}---`, ''); + break; + case 'empty-with-spaces': + content = code.replace( + `---${rawFrontmatter}---`, + ` ${rawFrontmatter.replace(/[^\r\n]/g, ' ')} `, + ); + break; + case 'empty-with-lines': + content = code.replace(`---${rawFrontmatter}---`, rawFrontmatter.replace(/[^\r\n]/g, '')); + break; + } + + return { + frontmatter, + rawFrontmatter, + content, + }; +} diff --git a/packages/markdown/remark/src/highlight.ts b/packages/markdown/remark/src/highlight.ts index ef1a734ba..5cdb2af19 100644 --- a/packages/markdown/remark/src/highlight.ts +++ b/packages/markdown/remark/src/highlight.ts @@ -4,7 +4,11 @@ import { toText } from 'hast-util-to-text'; import { removePosition } from 'unist-util-remove-position'; import { visitParents } from 'unist-util-visit-parents'; -type Highlighter = (code: string, language: string, options?: { meta?: string }) => Promise<string>; +type Highlighter = ( + code: string, + language: string, + options?: { meta?: string }, +) => Promise<Root | string>; const languagePattern = /\blanguage-(\S+)\b/; @@ -73,12 +77,17 @@ export async function highlightCodeBlocks(tree: Root, highlighter: Highlighter) for (const { node, language, grandParent, parent } of nodes) { const meta = (node.data as any)?.meta ?? node.properties.metastring ?? undefined; const code = toText(node, { whitespace: 'pre' }); - // TODO: In Astro 5, have `highlighter()` return hast directly to skip expensive HTML parsing and serialization. - const html = await highlighter(code, language, { meta }); - // The replacement returns a root node with 1 child, the `<pr>` element replacement. - const replacement = fromHtml(html, { fragment: true }).children[0] as Element; - // We just generated this node, so any positional information is invalid. - removePosition(replacement); + const result = await highlighter(code, language, { meta }); + + let replacement: Element; + if (typeof result === 'string') { + // The replacement returns a root node with 1 child, the `<pre>` element replacement. + replacement = fromHtml(result, { fragment: true }).children[0] as Element; + // We just generated this node, so any positional information is invalid. + removePosition(replacement); + } else { + replacement = result.children[0] as Element; + } // We replace the parent in its parent with the new `<pre>` element. const index = grandParent.children.indexOf(parent); diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index a9ae7ed59..de13523fe 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -1,10 +1,5 @@ -import type { AstroMarkdownOptions, MarkdownProcessor, MarkdownVFile } from './types.js'; +import type { AstroMarkdownOptions, MarkdownProcessor } from './types.js'; -import { - InvalidAstroDataError, - safelyGetAstroData, - setVfileFrontmatter, -} from './frontmatter-injection.js'; import { loadPlugins } from './load-plugins.js'; import { rehypeHeadingIds } from './rehype-collect-headings.js'; import { rehypePrism } from './rehype-prism.js'; @@ -21,12 +16,23 @@ import { unified } from 'unified'; import { VFile } from 'vfile'; import { rehypeImages } from './rehype-images.js'; -export { InvalidAstroDataError, setVfileFrontmatter } from './frontmatter-injection.js'; export { rehypeHeadingIds } from './rehype-collect-headings.js'; export { remarkCollectImages } from './remark-collect-images.js'; export { rehypePrism } from './rehype-prism.js'; export { rehypeShiki } from './rehype-shiki.js'; -export { createShikiHighlighter, type ShikiHighlighter } from './shiki.js'; +export { + isFrontmatterValid, + extractFrontmatter, + parseFrontmatter, + type ParseFrontmatterOptions, + type ParseFrontmatterResult, +} from './frontmatter.js'; +export { + createShikiHighlighter, + type ShikiHighlighter, + type CreateShikiHighlighterOptions, + type ShikiHighlighterHighlightOptions, +} from './shiki.js'; export * from './types.js'; export const markdownConfigDefaults: Required<AstroMarkdownOptions> = { @@ -124,10 +130,17 @@ export async function createMarkdownProcessor( return { async render(content, renderOpts) { - const vfile = new VFile({ value: content, path: renderOpts?.fileURL }); - setVfileFrontmatter(vfile, renderOpts?.frontmatter ?? {}); + const vfile = new VFile({ + value: content, + path: renderOpts?.fileURL, + data: { + astro: { + frontmatter: renderOpts?.frontmatter ?? {}, + }, + }, + }); - const result: MarkdownVFile = await parser.process(vfile).catch((err) => { + const result = await parser.process(vfile).catch((err) => { // Ensure that the error message contains the input filename // to make it easier for the user to fix the issue err = prefixError(err, `Failed to parse Markdown file "${vfile.path}"`); @@ -135,17 +148,12 @@ export async function createMarkdownProcessor( throw err; }); - const astroData = safelyGetAstroData(result.data); - if (astroData instanceof InvalidAstroDataError) { - throw astroData; - } - return { code: String(result.value), metadata: { - headings: result.data.__astroHeadings ?? [], - imagePaths: result.data.imagePaths ?? new Set(), - frontmatter: astroData.frontmatter ?? {}, + headings: result.data.astro?.headings ?? [], + imagePaths: result.data.astro?.imagePaths ?? [], + frontmatter: result.data.astro?.frontmatter ?? {}, }, }; }, diff --git a/packages/markdown/remark/src/internal.ts b/packages/markdown/remark/src/internal.ts deleted file mode 100644 index 6201ef62f..000000000 --- a/packages/markdown/remark/src/internal.ts +++ /dev/null @@ -1 +0,0 @@ -export { InvalidAstroDataError, safelyGetAstroData } from './frontmatter-injection.js'; diff --git a/packages/markdown/remark/src/rehype-collect-headings.ts b/packages/markdown/remark/src/rehype-collect-headings.ts index 05afae1ba..ab2113f49 100644 --- a/packages/markdown/remark/src/rehype-collect-headings.ts +++ b/packages/markdown/remark/src/rehype-collect-headings.ts @@ -3,19 +3,18 @@ import Slugger from 'github-slugger'; import type { MdxTextExpression } from 'mdast-util-mdx-expression'; import type { Node } from 'unist'; import { visit } from 'unist-util-visit'; - -import { InvalidAstroDataError, safelyGetAstroData } from './frontmatter-injection.js'; -import type { MarkdownAstroData, MarkdownHeading, MarkdownVFile, RehypePlugin } from './types.js'; +import type { VFile } from 'vfile'; +import type { MarkdownHeading, RehypePlugin } from './types.js'; const rawNodeTypes = new Set(['text', 'raw', 'mdxTextExpression']); const codeTagNames = new Set(['code', 'pre']); export function rehypeHeadingIds(): ReturnType<RehypePlugin> { - return function (tree, file: MarkdownVFile) { + return function (tree, file) { const headings: MarkdownHeading[] = []; + const frontmatter = file.data.astro?.frontmatter; const slugger = new Slugger(); const isMDX = isMDXFile(file); - const astroData = safelyGetAstroData(file.data); visit(tree, (node) => { if (node.type !== 'element') return; const { tagName } = node; @@ -37,10 +36,13 @@ export function rehypeHeadingIds(): ReturnType<RehypePlugin> { if (rawNodeTypes.has(child.type)) { if (isMDX || codeTagNames.has(parent.tagName)) { let value = child.value; - if (isMdxTextExpression(child) && !(astroData instanceof InvalidAstroDataError)) { + if (isMdxTextExpression(child) && frontmatter) { const frontmatterPath = getMdxFrontmatterVariablePath(child); if (Array.isArray(frontmatterPath) && frontmatterPath.length > 0) { - const frontmatterValue = getMdxFrontmatterVariableValue(astroData, frontmatterPath); + const frontmatterValue = getMdxFrontmatterVariableValue( + frontmatter, + frontmatterPath, + ); if (typeof frontmatterValue === 'string') { value = frontmatterValue; } @@ -65,11 +67,12 @@ export function rehypeHeadingIds(): ReturnType<RehypePlugin> { headings.push({ depth, slug: node.properties.id, text }); }); - file.data.__astroHeadings = headings; + file.data.astro ??= {}; + file.data.astro.headings = headings; }; } -function isMDXFile(file: MarkdownVFile) { +function isMDXFile(file: VFile) { return Boolean(file.history[0]?.endsWith('.mdx')); } @@ -109,8 +112,8 @@ function getMdxFrontmatterVariablePath(node: MdxTextExpression): string[] | Erro return expressionPath.reverse(); } -function getMdxFrontmatterVariableValue(astroData: MarkdownAstroData, path: string[]) { - let value: MdxFrontmatterVariableValue = astroData.frontmatter; +function getMdxFrontmatterVariableValue(frontmatter: Record<string, any>, path: string[]) { + let value = frontmatter; for (const key of path) { if (!value[key]) return undefined; @@ -124,6 +127,3 @@ function getMdxFrontmatterVariableValue(astroData: MarkdownAstroData, path: stri function isMdxTextExpression(node: Node): node is MdxTextExpression { return node.type === 'mdxTextExpression'; } - -type MdxFrontmatterVariableValue = - MarkdownAstroData['frontmatter'][keyof MarkdownAstroData['frontmatter']]; diff --git a/packages/markdown/remark/src/rehype-images.ts b/packages/markdown/remark/src/rehype-images.ts index 01e5aa6d6..11d33df9c 100644 --- a/packages/markdown/remark/src/rehype-images.ts +++ b/packages/markdown/remark/src/rehype-images.ts @@ -1,9 +1,9 @@ import { visit } from 'unist-util-visit'; -import type { MarkdownVFile } from './types.js'; +import type { VFile } from 'vfile'; export function rehypeImages() { return () => - function (tree: any, file: MarkdownVFile) { + function (tree: any, file: VFile) { const imageOccurrenceMap = new Map(); visit(tree, (node) => { @@ -13,7 +13,7 @@ export function rehypeImages() { if (node.properties?.src) { node.properties.src = decodeURI(node.properties.src); - if (file.data.imagePaths?.has(node.properties.src)) { + if (file.data.astro?.imagePaths?.includes(node.properties.src)) { const { ...props } = node.properties; // Initialize or increment occurrence count for this image diff --git a/packages/markdown/remark/src/rehype-shiki.ts b/packages/markdown/remark/src/rehype-shiki.ts index fdab3ddf3..43b38f095 100644 --- a/packages/markdown/remark/src/rehype-shiki.ts +++ b/packages/markdown/remark/src/rehype-shiki.ts @@ -8,9 +8,21 @@ export const rehypeShiki: Plugin<[ShikiConfig?], Root> = (config) => { let highlighterAsync: Promise<ShikiHighlighter> | undefined; return async (tree) => { - highlighterAsync ??= createShikiHighlighter(config); + highlighterAsync ??= createShikiHighlighter({ + langs: config?.langs, + theme: config?.theme, + themes: config?.themes, + langAlias: config?.langAlias, + }); const highlighter = await highlighterAsync; - await highlightCodeBlocks(tree, highlighter.highlight); + await highlightCodeBlocks(tree, (code, language, options) => { + return highlighter.codeToHast(code, language, { + meta: options?.meta, + wrap: config?.wrap, + defaultColor: config?.defaultColor, + transformers: config?.transformers, + }); + }); }; }; diff --git a/packages/markdown/remark/src/remark-collect-images.ts b/packages/markdown/remark/src/remark-collect-images.ts index 22774d5f1..f09f1c580 100644 --- a/packages/markdown/remark/src/remark-collect-images.ts +++ b/packages/markdown/remark/src/remark-collect-images.ts @@ -1,10 +1,10 @@ import type { Image, ImageReference } from 'mdast'; import { definitions } from 'mdast-util-definitions'; import { visit } from 'unist-util-visit'; -import type { MarkdownVFile } from './types.js'; +import type { VFile } from 'vfile'; export function remarkCollectImages() { - return function (tree: any, vfile: MarkdownVFile) { + return function (tree: any, vfile: VFile) { if (typeof vfile?.path !== 'string') return; const definition = definitions(tree); @@ -22,7 +22,8 @@ export function remarkCollectImages() { } }); - vfile.data.imagePaths = imagePaths; + vfile.data.astro ??= {}; + vfile.data.astro.imagePaths = Array.from(imagePaths); }; } diff --git a/packages/markdown/remark/src/shiki.ts b/packages/markdown/remark/src/shiki.ts index 2f06ef9a9..ed4daf527 100644 --- a/packages/markdown/remark/src/shiki.ts +++ b/packages/markdown/remark/src/shiki.ts @@ -1,173 +1,196 @@ -import type { Properties } from 'hast'; +import type { Properties, Root } from 'hast'; import { type BundledLanguage, + type HighlighterCoreOptions, + type LanguageRegistration, + type ShikiTransformer, + type ThemeRegistration, + type ThemeRegistrationRaw, createCssVariablesTheme, - getHighlighter, + createHighlighter, isSpecialLang, } from 'shiki'; -import { visit } from 'unist-util-visit'; -import type { ShikiConfig } from './types.js'; +import type { ThemePresets } from './types.js'; export interface ShikiHighlighter { - highlight( + codeToHast( code: string, lang?: string, - options?: { - inline?: boolean; - attributes?: Record<string, string>; - /** - * Raw `meta` information to be used by Shiki transformers - */ - meta?: string; - }, + options?: ShikiHighlighterHighlightOptions, + ): Promise<Root>; + codeToHtml( + code: string, + lang?: string, + options?: ShikiHighlighterHighlightOptions, ): Promise<string>; } -// TODO: Remove this special replacement in Astro 5 -const ASTRO_COLOR_REPLACEMENTS: Record<string, string> = { - '--astro-code-foreground': '--astro-code-color-text', - '--astro-code-background': '--astro-code-color-background', -}; -const COLOR_REPLACEMENT_REGEX = new RegExp( - `${Object.keys(ASTRO_COLOR_REPLACEMENTS).join('|')}`, - 'g', -); +export interface CreateShikiHighlighterOptions { + langs?: LanguageRegistration[]; + theme?: ThemePresets | ThemeRegistration | ThemeRegistrationRaw; + themes?: Record<string, ThemePresets | ThemeRegistration | ThemeRegistrationRaw>; + langAlias?: HighlighterCoreOptions['langAlias']; +} + +export interface ShikiHighlighterHighlightOptions { + /** + * Generate inline code element only, without the pre element wrapper. + */ + inline?: boolean; + /** + * Enable word wrapping. + * - true: enabled. + * - false: disabled. + * - null: All overflow styling removed. Code will overflow the element by default. + */ + wrap?: boolean | null; + /** + * Chooses a theme from the "themes" option that you've defined as the default styling theme. + */ + defaultColor?: 'light' | 'dark' | string | false; + /** + * Shiki transformers to customize the generated HTML by manipulating the hast tree. + */ + transformers?: ShikiTransformer[]; + /** + * Additional attributes to be added to the root code block element. + */ + attributes?: Record<string, string>; + /** + * Raw `meta` information to be used by Shiki transformers. + */ + meta?: string; +} let _cssVariablesTheme: ReturnType<typeof createCssVariablesTheme>; const cssVariablesTheme = () => _cssVariablesTheme ?? - (_cssVariablesTheme = createCssVariablesTheme({ variablePrefix: '--astro-code-' })); + (_cssVariablesTheme = createCssVariablesTheme({ + variablePrefix: '--astro-code-', + })); export async function createShikiHighlighter({ langs = [], theme = 'github-dark', themes = {}, - defaultColor, - wrap = false, - transformers = [], langAlias = {}, -}: ShikiConfig = {}): Promise<ShikiHighlighter> { +}: CreateShikiHighlighterOptions = {}): Promise<ShikiHighlighter> { theme = theme === 'css-variables' ? cssVariablesTheme() : theme; - const highlighter = await getHighlighter({ + const highlighter = await createHighlighter({ langs: ['plaintext', ...langs], langAlias, themes: Object.values(themes).length ? Object.values(themes) : [theme], }); - return { - async highlight(code, lang = 'plaintext', options) { - const resolvedLang = langAlias[lang] ?? lang; - const loadedLanguages = highlighter.getLoadedLanguages(); - - if (!isSpecialLang(lang) && !loadedLanguages.includes(resolvedLang)) { - try { - await highlighter.loadLanguage(resolvedLang as BundledLanguage); - } catch (_err) { - const langStr = - lang === resolvedLang ? `"${lang}"` : `"${lang}" (aliased to "${resolvedLang}")`; - console.warn( - `[Shiki] The language ${langStr} doesn't exist, falling back to "plaintext".`, - ); - lang = 'plaintext'; - } + async function highlight( + code: string, + lang = 'plaintext', + options: ShikiHighlighterHighlightOptions, + to: 'hast' | 'html', + ) { + const resolvedLang = langAlias[lang] ?? lang; + const loadedLanguages = highlighter.getLoadedLanguages(); + + if (!isSpecialLang(lang) && !loadedLanguages.includes(resolvedLang)) { + try { + await highlighter.loadLanguage(resolvedLang as BundledLanguage); + } catch (_err) { + const langStr = + lang === resolvedLang ? `"${lang}"` : `"${lang}" (aliased to "${resolvedLang}")`; + console.warn(`[Shiki] The language ${langStr} doesn't exist, falling back to "plaintext".`); + lang = 'plaintext'; } - - const themeOptions = Object.values(themes).length ? { themes } : { theme }; - const inline = options?.inline ?? false; - - return highlighter.codeToHtml(code, { - ...themeOptions, - defaultColor, - lang, - // NOTE: while we can spread `options.attributes` here so that Shiki can auto-serialize this as rendered - // attributes on the top-level tag, it's not clear whether it is fine to pass all attributes as meta, as - // they're technically not meta, nor parsed from Shiki's `parseMetaString` API. - meta: options?.meta ? { __raw: options?.meta } : undefined, - transformers: [ - { - pre(node) { - // Swap to `code` tag if inline - if (inline) { - node.tagName = 'code'; - } - - const { - class: attributesClass, - style: attributesStyle, - ...rest - } = options?.attributes ?? {}; - Object.assign(node.properties, rest); - - const classValue = - (normalizePropAsString(node.properties.class) ?? '') + - (attributesClass ? ` ${attributesClass}` : ''); - const styleValue = - (normalizePropAsString(node.properties.style) ?? '') + - (attributesStyle ? `; ${attributesStyle}` : ''); - - // Replace "shiki" class naming with "astro-code" - node.properties.class = classValue.replace(/shiki/g, 'astro-code'); - - // Add data-language attribute - node.properties.dataLanguage = lang; - - // Handle code wrapping - // if wrap=null, do nothing. - if (wrap === false) { - node.properties.style = styleValue + '; overflow-x: auto;'; - } else if (wrap === true) { - node.properties.style = - styleValue + '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'; - } - }, - line(node) { - // Add "user-select: none;" for "+"/"-" diff symbols. - // Transform `<span class="line"><span style="...">+ something</span></span> - // into `<span class="line"><span style="..."><span style="user-select: none;">+</span> something</span></span>` - if (resolvedLang === 'diff') { - const innerSpanNode = node.children[0]; - const innerSpanTextNode = - innerSpanNode?.type === 'element' && innerSpanNode.children?.[0]; - - if (innerSpanTextNode && innerSpanTextNode.type === 'text') { - const start = innerSpanTextNode.value[0]; - if (start === '+' || start === '-') { - innerSpanTextNode.value = innerSpanTextNode.value.slice(1); - innerSpanNode.children.unshift({ - type: 'element', - tagName: 'span', - properties: { style: 'user-select: none;' }, - children: [{ type: 'text', value: start }], - }); - } + } + + const themeOptions = Object.values(themes).length ? { themes } : { theme }; + const inline = options?.inline ?? false; + + return highlighter[to === 'html' ? 'codeToHtml' : 'codeToHast'](code, { + ...themeOptions, + defaultColor: options.defaultColor, + lang, + // NOTE: while we can spread `options.attributes` here so that Shiki can auto-serialize this as rendered + // attributes on the top-level tag, it's not clear whether it is fine to pass all attributes as meta, as + // they're technically not meta, nor parsed from Shiki's `parseMetaString` API. + meta: options?.meta ? { __raw: options?.meta } : undefined, + transformers: [ + { + pre(node) { + // Swap to `code` tag if inline + if (inline) { + node.tagName = 'code'; + } + + const { + class: attributesClass, + style: attributesStyle, + ...rest + } = options?.attributes ?? {}; + Object.assign(node.properties, rest); + + const classValue = + (normalizePropAsString(node.properties.class) ?? '') + + (attributesClass ? ` ${attributesClass}` : ''); + const styleValue = + (normalizePropAsString(node.properties.style) ?? '') + + (attributesStyle ? `; ${attributesStyle}` : ''); + + // Replace "shiki" class naming with "astro-code" + node.properties.class = classValue.replace(/shiki/g, 'astro-code'); + + // Add data-language attribute + node.properties.dataLanguage = lang; + + // Handle code wrapping + // if wrap=null, do nothing. + if (options.wrap === false || options.wrap === undefined) { + node.properties.style = styleValue + '; overflow-x: auto;'; + } else if (options.wrap === true) { + node.properties.style = + styleValue + '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'; + } + }, + line(node) { + // Add "user-select: none;" for "+"/"-" diff symbols. + // Transform `<span class="line"><span style="...">+ something</span></span> + // into `<span class="line"><span style="..."><span style="user-select: none;">+</span> something</span></span>` + if (resolvedLang === 'diff') { + const innerSpanNode = node.children[0]; + const innerSpanTextNode = + innerSpanNode?.type === 'element' && innerSpanNode.children?.[0]; + + if (innerSpanTextNode && innerSpanTextNode.type === 'text') { + const start = innerSpanTextNode.value[0]; + if (start === '+' || start === '-') { + innerSpanTextNode.value = innerSpanTextNode.value.slice(1); + innerSpanNode.children.unshift({ + type: 'element', + tagName: 'span', + properties: { style: 'user-select: none;' }, + children: [{ type: 'text', value: start }], + }); } } - }, - code(node) { - if (inline) { - return node.children[0] as typeof node; - } - }, - root(node) { - if (Object.values(themes).length) { - return; - } - - const themeName = typeof theme === 'string' ? theme : theme.name; - if (themeName === 'css-variables') { - // Replace special color tokens to CSS variables - visit(node as any, 'element', (child) => { - if (child.properties?.style) { - child.properties.style = replaceCssVariables(child.properties.style); - } - }); - } - }, + } + }, + code(node) { + if (inline) { + return node.children[0] as typeof node; + } }, - ...transformers, - ], - }); + }, + ...(options.transformers ?? []), + ], + }); + } + + return { + codeToHast(code, lang, options = {}) { + return highlight(code, lang, options, 'hast') as Promise<Root>; + }, + codeToHtml(code, lang, options = {}) { + return highlight(code, lang, options, 'html') as Promise<string>; }, }; } @@ -175,7 +198,3 @@ export async function createShikiHighlighter({ function normalizePropAsString(value: Properties[string]): string | null { return Array.isArray(value) ? value.join(' ') : (value as string | null); } - -function replaceCssVariables(str: string) { - return str.replace(COLOR_REPLACEMENT_REGEX, (match) => ASTRO_COLOR_REPLACEMENTS[match] || match); -} diff --git a/packages/markdown/remark/src/types.ts b/packages/markdown/remark/src/types.ts index d95676b55..e6a9d362b 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/markdown/remark/src/types.ts @@ -1,22 +1,21 @@ import type * as hast from 'hast'; import type * as mdast from 'mdast'; import type { Options as RemarkRehypeOptions } from 'remark-rehype'; -import type { - BuiltinTheme, - HighlighterCoreOptions, - LanguageRegistration, - ShikiTransformer, - ThemeRegistration, - ThemeRegistrationRaw, -} from 'shiki'; +import type { BuiltinTheme } from 'shiki'; import type * as unified from 'unified'; -import type { DataMap, VFile } from 'vfile'; +import type { CreateShikiHighlighterOptions, ShikiHighlighterHighlightOptions } from './shiki.js'; export type { Node } from 'unist'; -export type MarkdownAstroData = { - frontmatter: Record<string, any>; -}; +declare module 'vfile' { + interface DataMap { + astro: { + headings?: MarkdownHeading[]; + imagePaths?: string[]; + frontmatter?: Record<string, any>; + }; + } +} export type RemarkPlugin<PluginParameters extends any[] = any[]> = unified.Plugin< PluginParameters, @@ -36,15 +35,9 @@ export type RemarkRehype = RemarkRehypeOptions; export type ThemePresets = BuiltinTheme | 'css-variables'; -export interface ShikiConfig { - langs?: LanguageRegistration[]; - langAlias?: HighlighterCoreOptions['langAlias']; - theme?: ThemePresets | ThemeRegistration | ThemeRegistrationRaw; - themes?: Record<string, ThemePresets | ThemeRegistration | ThemeRegistrationRaw>; - defaultColor?: 'light' | 'dark' | string | false; - wrap?: boolean | null; - transformers?: ShikiTransformer[]; -} +export interface ShikiConfig + extends Pick<CreateShikiHighlighterOptions, 'langs' | 'theme' | 'themes' | 'langAlias'>, + Pick<ShikiHighlighterHighlightOptions, 'defaultColor' | 'wrap' | 'transformers'> {} export interface AstroMarkdownOptions { syntaxHighlight?: 'shiki' | 'prism' | false; @@ -74,7 +67,7 @@ export interface MarkdownProcessorRenderResult { code: string; metadata: { headings: MarkdownHeading[]; - imagePaths: Set<string>; + imagePaths: string[]; frontmatter: Record<string, any>; }; } @@ -84,12 +77,3 @@ export interface MarkdownHeading { slug: string; text: string; } - -// TODO: Remove `MarkdownVFile` and move all additional properties to `DataMap` instead -export interface MarkdownVFile extends VFile { - data: Record<string, unknown> & - Partial<DataMap> & { - __astroHeadings?: MarkdownHeading[]; - imagePaths?: Set<string>; - }; -} |