summaryrefslogtreecommitdiff
path: root/packages/markdown/remark/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/markdown/remark/src')
-rw-r--r--packages/markdown/remark/src/frontmatter-injection.ts34
-rw-r--r--packages/markdown/remark/src/frontmatter.ts74
-rw-r--r--packages/markdown/remark/src/highlight.ts23
-rw-r--r--packages/markdown/remark/src/index.ts46
-rw-r--r--packages/markdown/remark/src/internal.ts1
-rw-r--r--packages/markdown/remark/src/rehype-collect-headings.ts28
-rw-r--r--packages/markdown/remark/src/rehype-images.ts6
-rw-r--r--packages/markdown/remark/src/rehype-shiki.ts16
-rw-r--r--packages/markdown/remark/src/remark-collect-images.ts7
-rw-r--r--packages/markdown/remark/src/shiki.ts301
-rw-r--r--packages/markdown/remark/src/types.ts46
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>;
- };
-}