summaryrefslogtreecommitdiff
path: root/packages/integrations/markdoc/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/integrations/markdoc/src')
-rw-r--r--packages/integrations/markdoc/src/config.ts15
-rw-r--r--packages/integrations/markdoc/src/extensions/shiki.ts138
-rw-r--r--packages/integrations/markdoc/src/heading-ids.ts (renamed from packages/integrations/markdoc/src/nodes/heading.ts)32
-rw-r--r--packages/integrations/markdoc/src/index.ts6
-rw-r--r--packages/integrations/markdoc/src/nodes/index.ts4
-rw-r--r--packages/integrations/markdoc/src/runtime.ts44
6 files changed, 210 insertions, 29 deletions
diff --git a/packages/integrations/markdoc/src/config.ts b/packages/integrations/markdoc/src/config.ts
index f8943ba1a..a8f202424 100644
--- a/packages/integrations/markdoc/src/config.ts
+++ b/packages/integrations/markdoc/src/config.ts
@@ -1,10 +1,19 @@
import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc';
import _Markdoc from '@markdoc/markdoc';
-import { nodes as astroNodes } from './nodes/index.js';
+import { heading } from './heading-ids.js';
+
+export type AstroMarkdocConfig<C extends Record<string, any> = Record<string, any>> =
+ MarkdocConfig & {
+ ctx?: C;
+ extends?: ResolvedAstroMarkdocConfig[];
+ };
+
+export type ResolvedAstroMarkdocConfig = Omit<AstroMarkdocConfig, 'extends'>;
export const Markdoc = _Markdoc;
-export const nodes = { ...Markdoc.nodes, ...astroNodes };
+export const nodes = { ...Markdoc.nodes, heading };
+export { shiki } from './extensions/shiki.js';
-export function defineMarkdocConfig(config: MarkdocConfig): MarkdocConfig {
+export function defineMarkdocConfig(config: AstroMarkdocConfig): AstroMarkdocConfig {
return config;
}
diff --git a/packages/integrations/markdoc/src/extensions/shiki.ts b/packages/integrations/markdoc/src/extensions/shiki.ts
new file mode 100644
index 000000000..96d91d541
--- /dev/null
+++ b/packages/integrations/markdoc/src/extensions/shiki.ts
@@ -0,0 +1,138 @@
+// @ts-expect-error Cannot find module 'astro/runtime/server/index.js' or its corresponding type declarations.
+import { unescapeHTML } from 'astro/runtime/server/index.js';
+import type { ShikiConfig } from 'astro';
+import type * as shikiTypes from 'shiki';
+import type { AstroMarkdocConfig } from '../config.js';
+import Markdoc from '@markdoc/markdoc';
+import { MarkdocError } from '../utils.js';
+
+// Map of old theme names to new names to preserve compatibility when we upgrade shiki
+const compatThemes: Record<string, string> = {
+ 'material-darker': 'material-theme-darker',
+ 'material-default': 'material-theme',
+ 'material-lighter': 'material-theme-lighter',
+ 'material-ocean': 'material-theme-ocean',
+ 'material-palenight': 'material-theme-palenight',
+};
+
+const normalizeTheme = (theme: string | shikiTypes.IShikiTheme) => {
+ if (typeof theme === 'string') {
+ return compatThemes[theme] || theme;
+ } else if (compatThemes[theme.name]) {
+ return { ...theme, name: compatThemes[theme.name] };
+ } else {
+ return theme;
+ }
+};
+
+const ASTRO_COLOR_REPLACEMENTS = {
+ '#000001': 'var(--astro-code-color-text)',
+ '#000002': 'var(--astro-code-color-background)',
+ '#000004': 'var(--astro-code-token-constant)',
+ '#000005': 'var(--astro-code-token-string)',
+ '#000006': 'var(--astro-code-token-comment)',
+ '#000007': 'var(--astro-code-token-keyword)',
+ '#000008': 'var(--astro-code-token-parameter)',
+ '#000009': 'var(--astro-code-token-function)',
+ '#000010': 'var(--astro-code-token-string-expression)',
+ '#000011': 'var(--astro-code-token-punctuation)',
+ '#000012': 'var(--astro-code-token-link)',
+};
+
+const PRE_SELECTOR = /<pre class="(.*?)shiki(.*?)"/;
+const LINE_SELECTOR = /<span class="line"><span style="(.*?)">([\+|\-])/g;
+const INLINE_STYLE_SELECTOR = /style="(.*?)"/;
+
+/**
+ * Note: cache only needed for dev server reloads, internal test suites, and manual calls to `Markdoc.transform` by the user.
+ * Otherwise, `shiki()` is only called once per build, NOT once per page, so a cache isn't needed!
+ */
+const highlighterCache = new Map<string, shikiTypes.Highlighter>();
+
+export async function shiki({
+ langs = [],
+ theme = 'github-dark',
+ wrap = false,
+}: ShikiConfig = {}): Promise<AstroMarkdocConfig> {
+ let getHighlighter: (options: shikiTypes.HighlighterOptions) => Promise<shikiTypes.Highlighter>;
+ try {
+ getHighlighter = (await import('shiki')).getHighlighter;
+ } catch {
+ throw new MarkdocError({
+ message: 'Shiki is not installed. Run `npm install shiki` to use the `shiki` extension.',
+ });
+ }
+ theme = normalizeTheme(theme);
+
+ const cacheID: string = typeof theme === 'string' ? theme : theme.name;
+ if (!highlighterCache.has(cacheID)) {
+ highlighterCache.set(
+ cacheID,
+ await getHighlighter({ theme }).then((hl) => {
+ hl.setColorReplacements(ASTRO_COLOR_REPLACEMENTS);
+ return hl;
+ })
+ );
+ }
+ const highlighter = highlighterCache.get(cacheID)!;
+
+ for (const lang of langs) {
+ await highlighter.loadLanguage(lang);
+ }
+ return {
+ nodes: {
+ fence: {
+ attributes: Markdoc.nodes.fence.attributes!,
+ transform({ attributes }) {
+ let lang: string;
+
+ if (typeof attributes.language === 'string') {
+ const langExists = highlighter
+ .getLoadedLanguages()
+ .includes(attributes.language as any);
+ if (langExists) {
+ lang = attributes.language;
+ } else {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[Shiki highlighter] The language "${attributes.language}" doesn't exist, falling back to plaintext.`
+ );
+ lang = 'plaintext';
+ }
+ } else {
+ lang = 'plaintext';
+ }
+
+ let html = highlighter.codeToHtml(attributes.content, { lang });
+
+ // Q: Could these regexes match on a user's inputted code blocks?
+ // A: Nope! All rendered HTML is properly escaped.
+ // Ex. If a user typed `<span class="line"` into a code block,
+ // It would become this before hitting our regexes:
+ // &lt;span class=&quot;line&quot;
+
+ html = html.replace(PRE_SELECTOR, `<pre class="$1astro-code$2"`);
+ // Add "user-select: none;" for "+"/"-" diff symbols
+ if (attributes.language === 'diff') {
+ html = html.replace(
+ LINE_SELECTOR,
+ '<span class="line"><span style="$1"><span style="user-select: none;">$2</span>'
+ );
+ }
+
+ if (wrap === false) {
+ html = html.replace(INLINE_STYLE_SELECTOR, 'style="$1; overflow-x: auto;"');
+ } else if (wrap === true) {
+ html = html.replace(
+ INLINE_STYLE_SELECTOR,
+ 'style="$1; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;"'
+ );
+ }
+
+ // Use `unescapeHTML` to return `HTMLString` for Astro renderer to inline as HTML
+ return unescapeHTML(html);
+ },
+ },
+ },
+ };
+}
diff --git a/packages/integrations/markdoc/src/nodes/heading.ts b/packages/integrations/markdoc/src/heading-ids.ts
index 0210e9b90..57b84d059 100644
--- a/packages/integrations/markdoc/src/nodes/heading.ts
+++ b/packages/integrations/markdoc/src/heading-ids.ts
@@ -1,13 +1,8 @@
import Markdoc, { type ConfigType, type RenderableTreeNode, type Schema } from '@markdoc/markdoc';
import Slugger from 'github-slugger';
-import { getTextContent } from '../runtime.js';
-
-type ConfigTypeWithCtx = ConfigType & {
- // TODO: decide on `ctx` as a convention for config merging
- ctx: {
- headingSlugger: Slugger;
- };
-};
+import { getTextContent } from './runtime.js';
+import type { AstroMarkdocConfig } from './config.js';
+import { MarkdocError } from './utils.js';
function getSlug(
attributes: Record<string, any>,
@@ -24,16 +19,31 @@ function getSlug(
return slug;
}
+type HeadingIdConfig = AstroMarkdocConfig<{
+ headingSlugger: Slugger;
+}>;
+
+/*
+ Expose standalone node for users to import in their config.
+ Allows users to apply a custom `render: AstroComponent`
+ and spread our default heading attributes.
+*/
export const heading: Schema = {
children: ['inline'],
attributes: {
id: { type: String },
level: { type: Number, required: true, default: 1 },
},
- transform(node, config: ConfigTypeWithCtx) {
+ transform(node, config: HeadingIdConfig) {
const { level, ...attributes } = node.transformAttributes(config);
const children = node.transformChildren(config);
+ if (!config.ctx?.headingSlugger) {
+ throw new MarkdocError({
+ message:
+ 'Unexpected problem adding heading IDs to Markdoc file. Did you modify the `ctx.headingSlugger` property in your Markdoc config?',
+ });
+ }
const slug = getSlug(attributes, children, config.ctx.headingSlugger);
const render = config.nodes?.heading?.render ?? `h${level}`;
@@ -49,9 +59,9 @@ export const heading: Schema = {
},
};
-export function setupHeadingConfig(): ConfigTypeWithCtx {
+// Called internally to ensure `ctx` is generated per-file, instead of per-build.
+export function setupHeadingConfig(): HeadingIdConfig {
const headingSlugger = new Slugger();
-
return {
ctx: {
headingSlugger,
diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts
index 627f08c77..64ae4cbc0 100644
--- a/packages/integrations/markdoc/src/index.ts
+++ b/packages/integrations/markdoc/src/index.ts
@@ -52,7 +52,11 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
async getRenderModule({ entry, viteId }) {
const ast = Markdoc.parse(entry.body);
const pluginContext = this;
- const markdocConfig = setupConfig(userMarkdocConfig, entry);
+ const markdocConfig = setupConfig(
+ userMarkdocConfig,
+ entry,
+ markdocConfigResult?.fileUrl.pathname
+ );
const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
return (
diff --git a/packages/integrations/markdoc/src/nodes/index.ts b/packages/integrations/markdoc/src/nodes/index.ts
deleted file mode 100644
index 4cd7e3667..000000000
--- a/packages/integrations/markdoc/src/nodes/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { heading } from './heading.js';
-export { setupHeadingConfig } from './heading.js';
-
-export const nodes = { heading };
diff --git a/packages/integrations/markdoc/src/runtime.ts b/packages/integrations/markdoc/src/runtime.ts
index 3164cda13..4c5614b56 100644
--- a/packages/integrations/markdoc/src/runtime.ts
+++ b/packages/integrations/markdoc/src/runtime.ts
@@ -1,32 +1,56 @@
import type { MarkdownHeading } from '@astrojs/markdown-remark';
-import Markdoc, {
- type ConfigType as MarkdocConfig,
- type RenderableTreeNode,
-} from '@markdoc/markdoc';
+import Markdoc, { type RenderableTreeNode } from '@markdoc/markdoc';
import type { ContentEntryModule } from 'astro';
-import { setupHeadingConfig } from './nodes/index.js';
+import { setupHeadingConfig } from './heading-ids.js';
+import type { AstroMarkdocConfig } from './config.js';
+import { MarkdocError } from './utils.js';
/** Used to call `Markdoc.transform()` and `Markdoc.Ast` in runtime modules */
export { default as Markdoc } from '@markdoc/markdoc';
/**
* Merge user config with default config and set up context (ex. heading ID slugger)
- * Called on each file's individual transform
+ * Called on each file's individual transform.
+ * TODO: virtual module to merge configs per-build instead of per-file?
*/
-export function setupConfig(userConfig: MarkdocConfig, entry: ContentEntryModule): MarkdocConfig {
- const defaultConfig: MarkdocConfig = {
- // `setupXConfig()` could become a "plugin" convention as well?
+export function setupConfig(
+ userConfig: AstroMarkdocConfig,
+ entry: ContentEntryModule,
+ markdocConfigPath?: string
+): Omit<AstroMarkdocConfig, 'extends'> {
+ let defaultConfig: AstroMarkdocConfig = {
...setupHeadingConfig(),
variables: { entry },
};
+
+ if (userConfig.extends) {
+ for (const extension of userConfig.extends) {
+ if (extension instanceof Promise) {
+ throw new MarkdocError({
+ message: 'An extension passed to `extends` in your markdoc config returns a Promise.',
+ hint: 'Call `await` for async extensions. Example: `extends: [await myExtension()]`',
+ location: {
+ file: markdocConfigPath,
+ },
+ });
+ }
+
+ defaultConfig = mergeConfig(defaultConfig, extension);
+ }
+ }
+
return mergeConfig(defaultConfig, userConfig);
}
/** Merge function from `@markdoc/markdoc` internals */
-function mergeConfig(configA: MarkdocConfig, configB: MarkdocConfig): MarkdocConfig {
+function mergeConfig(configA: AstroMarkdocConfig, configB: AstroMarkdocConfig): AstroMarkdocConfig {
return {
...configA,
...configB,
+ ctx: {
+ ...configA.ctx,
+ ...configB.ctx,
+ },
tags: {
...configA.tags,
...configB.tags,