diff options
Diffstat (limited to 'packages/integrations/markdoc/src')
-rw-r--r-- | packages/integrations/markdoc/src/config.ts | 15 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/extensions/shiki.ts | 138 | ||||
-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.ts | 6 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/nodes/index.ts | 4 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/runtime.ts | 44 |
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: + // <span class="line" + + 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, |