diff --git a/.changeset/smart-moles-double.md b/.changeset/smart-moles-double.md new file mode 100644 index 000000000..072626ea5 --- /dev/null +++ b/.changeset/smart-moles-double.md @@ -0,0 +1,7 @@ +--- +'astro': patch +'@astrojs/markdoc': patch +'@astrojs/mdx': patch +--- + +Bring back improved style and script handling across content collection files. This addresses bugs found in a previous release to `@astrojs/markdoc`. diff --git a/examples/with-markdoc/src/content/config.ts b/examples/with-markdoc/src/content/config.ts deleted file mode 100644 index 2eccab0a3..000000000 --- a/examples/with-markdoc/src/content/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineCollection, z } from 'astro:content'; - -const docs = defineCollection({ - schema: z.object({ - title: z.string(), - }), -}); - -export const collections = { docs }; diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index c3372ec25..e1e71c501 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1276,6 +1276,12 @@ export interface ContentEntryType { } ): rollup.LoadResult | Promise; contentModuleTypes?: string; + /** + * Handle asset propagation for rendered content to avoid bleed. + * Ex. MDX content can import styles and scripts, so `handlePropagation` should be true. + * @default true + */ + handlePropagation?: boolean; } type GetContentEntryInfoReturnType = { diff --git a/packages/astro/src/content/consts.ts b/packages/astro/src/content/consts.ts index bda154692..9bfb2e865 100644 --- a/packages/astro/src/content/consts.ts +++ b/packages/astro/src/content/consts.ts @@ -1,11 +1,18 @@ export const PROPAGATED_ASSET_FLAG = 'astroPropagatedAssets'; +export const CONTENT_RENDER_FLAG = 'astroRenderContent'; export const CONTENT_FLAG = 'astroContentCollectionEntry'; export const DATA_FLAG = 'astroDataCollectionEntry'; -export const CONTENT_FLAGS = [CONTENT_FLAG, DATA_FLAG, PROPAGATED_ASSET_FLAG] as const; export const VIRTUAL_MODULE_ID = 'astro:content'; export const LINKS_PLACEHOLDER = '@@ASTRO-LINKS@@'; export const STYLES_PLACEHOLDER = '@@ASTRO-STYLES@@'; export const SCRIPTS_PLACEHOLDER = '@@ASTRO-SCRIPTS@@'; +export const CONTENT_FLAGS = [ + CONTENT_FLAG, + CONTENT_RENDER_FLAG, + DATA_FLAG, + PROPAGATED_ASSET_FLAG, +] as const; + export const CONTENT_TYPES_FILE = 'types.d.ts'; diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index 9187c4fce..84e3162bf 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -269,63 +269,80 @@ async function render({ const baseMod = await renderEntryImport(); if (baseMod == null || typeof baseMod !== 'object') throw UnexpectedRenderError; + const { default: defaultMod } = baseMod; - const { collectedStyles, collectedLinks, collectedScripts, getMod } = baseMod; - if (typeof getMod !== 'function') throw UnexpectedRenderError; - const mod = await getMod(); - if (mod == null || typeof mod !== 'object') throw UnexpectedRenderError; + if (isPropagatedAssetsModule(defaultMod)) { + const { collectedStyles, collectedLinks, collectedScripts, getMod } = defaultMod; + if (typeof getMod !== 'function') throw UnexpectedRenderError; + const propagationMod = await getMod(); + if (propagationMod == null || typeof propagationMod !== 'object') throw UnexpectedRenderError; - const Content = createComponent({ - factory(result, baseProps, slots) { - let styles = '', - links = '', - scripts = ''; - if (Array.isArray(collectedStyles)) { - styles = collectedStyles - .map((style: any) => { - return renderUniqueStylesheet(result, { - type: 'inline', - content: style, - }); - }) - .join(''); - } - if (Array.isArray(collectedLinks)) { - links = collectedLinks - .map((link: any) => { - return renderUniqueStylesheet(result, { - type: 'external', - src: prependForwardSlash(link), - }); - }) - .join(''); - } - if (Array.isArray(collectedScripts)) { - scripts = collectedScripts.map((script: any) => renderScriptElement(script)).join(''); - } + const Content = createComponent({ + factory(result, baseProps, slots) { + let styles = '', + links = '', + scripts = ''; + if (Array.isArray(collectedStyles)) { + styles = collectedStyles + .map((style: any) => { + return renderUniqueStylesheet(result, { + type: 'inline', + content: style, + }); + }) + .join(''); + } + if (Array.isArray(collectedLinks)) { + links = collectedLinks + .map((link: any) => { + return renderUniqueStylesheet(result, { + type: 'external', + src: prependForwardSlash(link), + }); + }) + .join(''); + } + if (Array.isArray(collectedScripts)) { + scripts = collectedScripts.map((script: any) => renderScriptElement(script)).join(''); + } - let props = baseProps; - // Auto-apply MDX components export - if (id.endsWith('.mdx')) { - props = { - components: mod.components ?? {}, - ...baseProps, - }; - } + let props = baseProps; + // Auto-apply MDX components export + if (id.endsWith('mdx')) { + props = { + components: propagationMod.components ?? {}, + ...baseProps, + }; + } - return createHeadAndContent( - unescapeHTML(styles + links + scripts) as any, - renderTemplate`${renderComponent(result, 'Content', mod.Content, props, slots)}` - ); - }, - propagation: 'self', - }); + return createHeadAndContent( + unescapeHTML(styles + links + scripts) as any, + renderTemplate`${renderComponent( + result, + 'Content', + propagationMod.Content, + props, + slots + )}` + ); + }, + propagation: 'self', + }); - return { - Content, - headings: mod.getHeadings?.() ?? [], - remarkPluginFrontmatter: mod.frontmatter ?? {}, - }; + return { + Content, + headings: propagationMod.getHeadings?.() ?? [], + remarkPluginFrontmatter: propagationMod.frontmatter ?? {}, + }; + } else if (baseMod.Content && typeof baseMod.Content === 'function') { + return { + Content: baseMod.Content, + headings: baseMod.getHeadings?.() ?? [], + remarkPluginFrontmatter: baseMod.frontmatter ?? {}, + }; + } else { + throw UnexpectedRenderError; + } } export function createReference({ lookupMap }: { lookupMap: ContentLookupMap }) { @@ -362,3 +379,15 @@ export function createReference({ lookupMap }: { lookupMap: ContentLookupMap }) }); }; } + +type PropagatedAssetsModule = { + __astroPropagation: true; + getMod: () => Promise; + collectedStyles: string[]; + collectedLinks: string[]; + collectedScripts: string[]; +}; + +function isPropagatedAssetsModule(module: any): module is PropagatedAssetsModule { + return typeof module === 'object' && module != null && '__astroPropagation' in module; +} diff --git a/packages/astro/src/content/template/virtual-mod.mjs b/packages/astro/src/content/template/virtual-mod.mjs index 491e594a6..e0ac7a564 100644 --- a/packages/astro/src/content/template/virtual-mod.mjs +++ b/packages/astro/src/content/template/virtual-mod.mjs @@ -46,7 +46,7 @@ function createGlobLookup(glob) { } const renderEntryGlob = import.meta.glob('@@RENDER_ENTRY_GLOB_PATH@@', { - query: { astroPropagatedAssets: true }, + query: { astroRenderContent: true }, }); const collectionToRenderEntryMap = createCollectionToGlobResultMap({ globResult: renderEntryGlob, diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index 40b2ab9e7..3cee0765c 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -15,6 +15,7 @@ import type { } from '../@types/astro.js'; import { VALID_INPUT_FORMATS } from '../assets/consts.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; + import { formatYAMLException, isYAMLException } from '../core/errors/utils.js'; import { CONTENT_FLAGS, CONTENT_TYPES_FILE } from './consts.js'; import { errorMap } from './error-map.js'; @@ -331,7 +332,7 @@ export function parseFrontmatter(fileContents: string, filePath: string) { */ export const globalContentConfigObserver = contentObservable({ status: 'init' }); -export function hasContentFlag(viteId: string, flag: (typeof CONTENT_FLAGS)[number]) { +export function hasContentFlag(viteId: string, flag: (typeof CONTENT_FLAGS)[number]): boolean { const flags = new URLSearchParams(viteId.split('?')[1] ?? ''); return flags.has(flag); } diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts index 7e73f9f6b..bbd9749a8 100644 --- a/packages/astro/src/content/vite-plugin-content-assets.ts +++ b/packages/astro/src/content/vite-plugin-content-assets.ts @@ -1,4 +1,5 @@ -import { pathToFileURL } from 'url'; +import { extname } from 'node:path'; +import { pathToFileURL } from 'node:url'; import type { Plugin } from 'vite'; import type { AstroSettings } from '../@types/astro.js'; import { moduleIsTopLevelPage, walkParentInfos } from '../core/build/graph.js'; @@ -11,16 +12,13 @@ import { joinPaths, prependForwardSlash } from '../core/path.js'; import { getStylesForURL } from '../core/render/dev/css.js'; import { getScriptsForURL } from '../core/render/dev/scripts.js'; import { + CONTENT_RENDER_FLAG, LINKS_PLACEHOLDER, PROPAGATED_ASSET_FLAG, SCRIPTS_PLACEHOLDER, STYLES_PLACEHOLDER, } from './consts.js'; - -function isPropagatedAsset(viteId: string) { - const flags = new URLSearchParams(viteId.split('?')[1]); - return flags.has(PROPAGATED_ASSET_FLAG); -} +import { hasContentFlag } from './utils.js'; export function astroContentAssetPropagationPlugin({ mode, @@ -32,13 +30,31 @@ export function astroContentAssetPropagationPlugin({ let devModuleLoader: ModuleLoader; return { name: 'astro:content-asset-propagation', + enforce: 'pre', + async resolveId(id, importer, opts) { + if (hasContentFlag(id, CONTENT_RENDER_FLAG)) { + const base = id.split('?')[0]; + + for (const { extensions, handlePropagation = true } of settings.contentEntryTypes) { + if (handlePropagation && extensions.includes(extname(base))) { + return this.resolve(`${base}?${PROPAGATED_ASSET_FLAG}`, importer, { + skipSelf: true, + ...opts, + }); + } + } + // Resolve to the base id (no content flags) + // if Astro doesn't need to handle propagation. + return this.resolve(base, importer, { skipSelf: true, ...opts }); + } + }, configureServer(server) { if (mode === 'dev') { devModuleLoader = createViteLoader(server); } }, async transform(_, id, options) { - if (isPropagatedAsset(id)) { + if (hasContentFlag(id, PROPAGATED_ASSET_FLAG)) { const basePath = id.split('?')[0]; let stringifiedLinks: string, stringifiedStyles: string, stringifiedScripts: string; @@ -73,14 +89,17 @@ export function astroContentAssetPropagationPlugin({ } const code = ` - export async function getMod() { + async function getMod() { return import(${JSON.stringify(basePath)}); } - export const collectedLinks = ${stringifiedLinks}; - export const collectedStyles = ${stringifiedStyles}; - export const collectedScripts = ${stringifiedScripts}; + const collectedLinks = ${stringifiedLinks}; + const collectedStyles = ${stringifiedStyles}; + const collectedScripts = ${stringifiedScripts}; + const defaultMod = { __astroPropagation: true, getMod, collectedLinks, collectedStyles, collectedScripts }; + export default defaultMod; `; - + // ^ Use a default export for tools like Markdoc + // to catch the `__astroPropagation` identifier return { code, map: { mappings: '' } }; } }, diff --git a/packages/astro/src/core/render/dev/vite.ts b/packages/astro/src/core/render/dev/vite.ts index 724ad172f..8d9baafa2 100644 --- a/packages/astro/src/core/render/dev/vite.ts +++ b/packages/astro/src/core/render/dev/vite.ts @@ -1,7 +1,6 @@ import type { ModuleLoader, ModuleNode } from '../../module-loader/index'; import npath from 'path'; -import { PROPAGATED_ASSET_FLAG } from '../../../content/consts.js'; import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js'; import { unwrapId } from '../../util.js'; import { isCSSRequest } from './util.js'; @@ -10,9 +9,10 @@ import { isCSSRequest } from './util.js'; * List of file extensions signalling we can (and should) SSR ahead-of-time * See usage below */ -const fileExtensionsToSSR = new Set(['.astro', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS]); +const fileExtensionsToSSR = new Set(['.astro', '.mdoc', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS]); const STRIP_QUERY_PARAMS_REGEX = /\?.*$/; +const ASTRO_PROPAGATED_ASSET_REGEX = /\?astroPropagatedAssets/; /** recursively crawl the module graph to get all style files imported by parent id */ export async function* crawlGraph( @@ -23,7 +23,6 @@ export async function* crawlGraph( ): AsyncGenerator { const id = unwrapId(_id); const importedModules = new Set(); - if (new URL(id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG)) return; const moduleEntriesForId = isRootFile ? // "getModulesByFile" pulls from a delayed module cache (fun implementation detail), @@ -44,7 +43,11 @@ export async function* crawlGraph( if (id === entry.id) { scanned.add(id); const entryIsStyle = isCSSRequest(id); + for (const importedModule of entry.importedModules) { + // A propagation stopping point is a module with the ?astroPropagatedAssets flag. + // When we encounter one of these modules we don't want to continue traversing. + let isPropagationStoppingPoint = false; // some dynamically imported modules are *not* server rendered in time // to only SSR modules that we can safely transform, we check against // a list of file extensions based on our built-in vite plugins @@ -60,15 +63,14 @@ export async function* crawlGraph( if (entryIsStyle && !isCSSRequest(importedModulePathname)) { continue; } + const isFileTypeNeedingSSR = fileExtensionsToSSR.has( + npath.extname(importedModulePathname) + ); + isPropagationStoppingPoint = ASTRO_PROPAGATED_ASSET_REGEX.test(importedModule.id); if ( - fileExtensionsToSSR.has( - npath.extname( - // Use `id` instead of `pathname` to preserve query params. - // Should not SSR a module with an unexpected query param, - // like "?astroPropagatedAssets" - importedModule.id - ) - ) + isFileTypeNeedingSSR && + // Should not SSR a module with ?astroPropagatedAssets + !isPropagationStoppingPoint ) { const mod = loader.getModuleById(importedModule.id); if (!mod?.ssrModule) { @@ -80,7 +82,9 @@ export async function* crawlGraph( } } } - importedModules.add(importedModule); + if(!isPropagationStoppingPoint) { + importedModules.add(importedModule); + } } } } diff --git a/packages/astro/src/vite-plugin-head/index.ts b/packages/astro/src/vite-plugin-head/index.ts index fc19893e8..4f44aaf6e 100644 --- a/packages/astro/src/vite-plugin-head/index.ts +++ b/packages/astro/src/vite-plugin-head/index.ts @@ -9,7 +9,8 @@ import { getTopLevelPages, walkParentInfos } from '../core/build/graph.js'; import type { BuildInternals } from '../core/build/internal.js'; import { getAstroMetadata } from '../vite-plugin-astro/index.js'; -const injectExp = /^\/\/\s*astro-head-inject/; +// Detect this in comments, both in .astro components and in js/ts files. +const injectExp = /(^\/\/|\/\/!)\s*astro-head-inject/; export default function configHeadVitePlugin({ settings, @@ -32,6 +33,7 @@ export default function configHeadVitePlugin({ seen.add(id); const mod = server.moduleGraph.getModuleById(id); const info = this.getModuleInfo(id); + if (info?.meta.astro) { const astroMetadata = getAstroMetadata(info); if (astroMetadata) { diff --git a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts index cbf5cc957..a3489c940 100644 --- a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts +++ b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts @@ -13,6 +13,8 @@ export const markdownContentEntryType: ContentEntryType = { rawData: parsed.matter, }; }, + // We need to handle propagation for Markdown because they support layouts which will bring in styles. + handlePropagation: true, }; /** @@ -30,6 +32,9 @@ export const mdxContentEntryType: ContentEntryType = { rawData: parsed.matter, }; }, + // MDX can import scripts and styles, + // so wrap all MDX files with script / style propagation checks + handlePropagation: true, contentModuleTypes: `declare module 'astro:content' { interface Render { '.mdx': Promise<{ diff --git a/packages/integrations/markdoc/components/Renderer.astro b/packages/integrations/markdoc/components/Renderer.astro index 5e2b6833a..6571e8c71 100644 --- a/packages/integrations/markdoc/components/Renderer.astro +++ b/packages/integrations/markdoc/components/Renderer.astro @@ -1,4 +1,5 @@ --- +//! astro-head-inject import type { Config } from '@markdoc/markdoc'; import Markdoc from '@markdoc/markdoc'; import { ComponentNode, createTreeNode } from './TreeNode.js'; @@ -14,4 +15,4 @@ const ast = Markdoc.Ast.fromJSON(stringifiedAst); const content = Markdoc.transform(ast, config); --- - + diff --git a/packages/integrations/markdoc/components/TreeNode.ts b/packages/integrations/markdoc/components/TreeNode.ts index d12180a18..8ad778063 100644 --- a/packages/integrations/markdoc/components/TreeNode.ts +++ b/packages/integrations/markdoc/components/TreeNode.ts @@ -6,6 +6,11 @@ import { createComponent, renderComponent, render, + renderScriptElement, + renderUniqueStylesheet, + createHeadAndContent, + unescapeHTML, + renderTemplate, HTMLString, isHTMLString, } from 'astro/runtime/server/index.js'; @@ -18,6 +23,9 @@ export type TreeNode = | { type: 'component'; component: AstroInstance['default']; + collectedLinks?: string[]; + collectedStyles?: string[]; + collectedScripts?: string[]; props: Record; children: TreeNode[]; } @@ -39,20 +47,66 @@ export const ComponentNode = createComponent({ )}`, }; if (treeNode.type === 'component') { - return renderComponent( - result, - treeNode.component.name, - treeNode.component, - treeNode.props, - slots + let styles = '', + links = '', + scripts = ''; + if (Array.isArray(treeNode.collectedStyles)) { + styles = treeNode.collectedStyles + .map((style: any) => + renderUniqueStylesheet(result, { + type: 'inline', + content: style, + }) + ) + .join(''); + } + if (Array.isArray(treeNode.collectedLinks)) { + links = treeNode.collectedLinks + .map((link: any) => { + return renderUniqueStylesheet(result, { + type: 'external', + src: link[0] === '/' ? link : '/' + link, + }); + }) + .join(''); + } + if (Array.isArray(treeNode.collectedScripts)) { + scripts = treeNode.collectedScripts + .map((script: any) => renderScriptElement(script)) + .join(''); + } + + const head = unescapeHTML(styles + links + scripts); + + let headAndContent = createHeadAndContent( + head, + renderTemplate`${renderComponent( + result, + treeNode.component.name, + treeNode.component, + treeNode.props, + slots + )}` ); + + // Let the runtime know that this component is being used. + result.propagators.set( + {}, + { + init() { + return headAndContent; + }, + } + ); + + return headAndContent; } return renderComponent(result, treeNode.tag, treeNode.tag, treeNode.attributes, slots); }, - propagation: 'none', + propagation: 'self', }); -export function createTreeNode(node: RenderableTreeNode | RenderableTreeNode[]): TreeNode { +export async function createTreeNode(node: RenderableTreeNode | RenderableTreeNode[]): TreeNode { if (isHTMLString(node)) { return { type: 'text', content: node as HTMLString }; } else if (typeof node === 'string' || typeof node === 'number') { @@ -62,16 +116,17 @@ export function createTreeNode(node: RenderableTreeNode | RenderableTreeNode[]): type: 'component', component: Fragment, props: {}, - children: node.map((child) => createTreeNode(child)), + children: await Promise.all(node.map((child) => createTreeNode(child))), }; } else if (node === null || typeof node !== 'object' || !Markdoc.Tag.isTag(node)) { return { type: 'text', content: '' }; } + const children = await Promise.all(node.children.map((child) => createTreeNode(child))); + if (typeof node.name === 'function') { const component = node.name; const props = node.attributes; - const children = node.children.map((child) => createTreeNode(child)); return { type: 'component', @@ -79,12 +134,38 @@ export function createTreeNode(node: RenderableTreeNode | RenderableTreeNode[]): props, children, }; + } else if (isPropagatedAssetsModule(node.name)) { + const { collectedStyles, collectedLinks, collectedScripts } = node.name; + const component = (await node.name.getMod())?.default ?? Fragment; + const props = node.attributes; + + return { + type: 'component', + component, + collectedStyles, + collectedLinks, + collectedScripts, + props, + children, + }; } else { return { type: 'element', tag: node.name, attributes: node.attributes, - children: node.children.map((child) => createTreeNode(child)), + children, }; } } + +type PropagatedAssetsModule = { + __astroPropagation: true; + getMod: () => Promise; + collectedStyles: string[]; + collectedLinks: string[]; + collectedScripts: string[]; +}; + +function isPropagatedAssetsModule(module: any): module is PropagatedAssetsModule { + return typeof module === 'object' && module != null && '__astroPropagation' in module; +} diff --git a/packages/integrations/markdoc/src/heading-ids.ts b/packages/integrations/markdoc/src/heading-ids.ts index 5c2f197f2..5e54af9a7 100644 --- a/packages/integrations/markdoc/src/heading-ids.ts +++ b/packages/integrations/markdoc/src/heading-ids.ts @@ -47,13 +47,14 @@ export const heading: Schema = { const slug = getSlug(attributes, children, config.ctx.headingSlugger); const render = config.nodes?.heading?.render ?? `h${level}`; + const tagProps = // For components, pass down `level` as a prop, // alongside `__collectHeading` for our `headings` collector. // Avoid accidentally rendering `level` as an HTML attribute otherwise! - typeof render === 'function' - ? { ...attributes, id: slug, __collectHeading: true, level } - : { ...attributes, id: slug }; + typeof render === 'string' + ? { ...attributes, id: slug } + : { ...attributes, id: slug, __collectHeading: true, level }; return new Markdoc.Tag(render, tagProps, children); }, diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts index f33c3a1be..5bcd8dff3 100644 --- a/packages/integrations/markdoc/src/index.ts +++ b/packages/integrations/markdoc/src/index.ts @@ -4,7 +4,15 @@ import Markdoc from '@markdoc/markdoc'; import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro'; import fs from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { isValidUrl, MarkdocError, parseFrontmatter, prependForwardSlash } from './utils.js'; +import { + createNameHash, + hasContentFlag, + isValidUrl, + MarkdocError, + parseFrontmatter, + prependForwardSlash, + PROPAGATED_ASSET_FLAG, +} from './utils.js'; // @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations. import { emitESMImage } from 'astro/assets'; import { bold, red, yellow } from 'kleur/colors'; @@ -12,6 +20,7 @@ import path from 'node:path'; import type * as rollup from 'rollup'; import { loadMarkdocConfig, type MarkdocConfigResult } from './load-config.js'; import { setupConfig } from './runtime.js'; +import { normalizePath } from 'vite'; type SetupHookParams = HookParameters<'astro:config:setup'> & { // `contentEntryType` is not a public API @@ -35,6 +44,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration process.exit(0); } let markdocConfigResult: MarkdocConfigResult | undefined; + let markdocConfigResultId = ''; return { name: '@astrojs/markdoc', hooks: { @@ -45,15 +55,10 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration addContentEntryType, } = params as SetupHookParams; - updateConfig({ - vite: { - ssr: { - external: ['@astrojs/markdoc/prism', '@astrojs/markdoc/shiki'], - }, - }, - }); - markdocConfigResult = await loadMarkdocConfig(astroConfig); + if(markdocConfigResult) { + markdocConfigResultId = normalizePath(fileURLToPath(markdocConfigResult.fileUrl)); + } const userMarkdocConfig = markdocConfigResult?.config ?? {}; function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) { @@ -68,6 +73,9 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration addContentEntryType({ extensions: ['.mdoc'], getEntryInfo, + // Markdoc handles script / style propagation + // for Astro components internally + handlePropagation: false, async getRenderModule({ contents, fileUrl, viteId }) { const entry = getEntryInfo({ contents, fileUrl }); const tokens = markdocTokenizer.tokenize(entry.body); @@ -112,14 +120,16 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration }); } - const res = `import { jsx as h } from 'astro/jsx-runtime'; + const res = `import { + createComponent, + renderComponent, + } from 'astro/runtime/server/index.js'; import { Renderer } from '@astrojs/markdoc/components'; import { collectHeadings, setupConfig, setupConfigSync, Markdoc } from '@astrojs/markdoc/runtime'; -import * as entry from ${JSON.stringify(viteId + '?astroContentCollectionEntry')}; ${ markdocConfigResult ? `import _userConfig from ${JSON.stringify( - markdocConfigResult.fileUrl.pathname + markdocConfigResultId )};\nconst userConfig = _userConfig ?? {};` : 'const userConfig = {};' }${ @@ -138,19 +148,29 @@ export function getHeadings() { '' } const headingConfig = userConfig.nodes?.heading; - const config = setupConfigSync(headingConfig ? { nodes: { heading: headingConfig } } : {}, entry); + const config = setupConfigSync(headingConfig ? { nodes: { heading: headingConfig } } : {}); const ast = Markdoc.Ast.fromJSON(stringifiedAst); const content = Markdoc.transform(ast, config); return collectHeadings(Array.isArray(content) ? content : content.children); } -export async function Content (props) { - const config = await setupConfig({ - ...userConfig, - variables: { ...userConfig.variables, ...props }, - }, entry); - return h(Renderer, { config, stringifiedAst }); -}`; +export const Content = createComponent({ + async factory(result, props) { + const config = await setupConfig({ + ...userConfig, + variables: { ...userConfig.variables, ...props }, + }); + + return renderComponent( + result, + Renderer.name, + Renderer, + { stringifiedAst, config }, + {} + ); + }, + propagation: 'self', +});`; return { code: res }; }, contentModuleTypes: await fs.promises.readFile( @@ -158,10 +178,58 @@ export async function Content (props) { 'utf-8' ), }); + + let rollupOptions: rollup.RollupOptions = {}; + if (markdocConfigResult) { + rollupOptions = { + output: { + // Split Astro components from your `markdoc.config` + // to only inject component styles and scripts at runtime. + manualChunks(id, { getModuleInfo }) { + if ( + markdocConfigResult && + hasContentFlag(id, PROPAGATED_ASSET_FLAG) && + getModuleInfo(id)?.importers?.includes(markdocConfigResultId) + ) { + return createNameHash(id, [id]); + } + }, + }, + }; + } + + updateConfig({ + vite: { + vite: { + ssr: { + external: ['@astrojs/markdoc/prism', '@astrojs/markdoc/shiki'], + }, + }, + build: { + rollupOptions, + }, + plugins: [ + { + name: '@astrojs/markdoc:astro-propagated-assets', + enforce: 'pre', + // Astro component styles and scripts should only be injected + // When a given Markdoc file actually uses that component. + // Add the `astroPropagatedAssets` flag to inject only when rendered. + resolveId(this: rollup.TransformPluginContext, id: string, importer: string) { + if (importer === markdocConfigResultId && id.endsWith('.astro')) { + return this.resolve(id + '?astroPropagatedAssets', importer, { + skipSelf: true, + }); + } + }, + }, + ], + }, + }); }, 'astro:server:setup': async ({ server }) => { server.watcher.on('all', (event, entry) => { - if (pathToFileURL(entry).pathname === markdocConfigResult?.fileUrl.pathname) { + if (prependForwardSlash(pathToFileURL(entry).pathname) === markdocConfigResultId) { console.log( yellow( `${bold('[Markdoc]')} Restart the dev server for config changes to take effect.` diff --git a/packages/integrations/markdoc/src/runtime.ts b/packages/integrations/markdoc/src/runtime.ts index a1861c68c..bbbd85739 100644 --- a/packages/integrations/markdoc/src/runtime.ts +++ b/packages/integrations/markdoc/src/runtime.ts @@ -32,13 +32,9 @@ export async function setupConfig( /** Used for synchronous `getHeadings()` function */ export function setupConfigSync( - userConfig: AstroMarkdocConfig, - entry: ContentEntryModule + userConfig: AstroMarkdocConfig ): Omit { - let defaultConfig: AstroMarkdocConfig = { - ...setupHeadingConfig(), - variables: { entry }, - }; + const defaultConfig: AstroMarkdocConfig = setupHeadingConfig(); return mergeConfig(defaultConfig, userConfig); } diff --git a/packages/integrations/markdoc/src/utils.ts b/packages/integrations/markdoc/src/utils.ts index ea5dda6db..ad964f56c 100644 --- a/packages/integrations/markdoc/src/utils.ts +++ b/packages/integrations/markdoc/src/utils.ts @@ -1,3 +1,5 @@ +import crypto from 'node:crypto'; +import path from 'node:path'; import matter from 'gray-matter'; import type { ErrorPayload as ViteErrorPayload } from 'vite'; @@ -96,3 +98,32 @@ export function isValidUrl(str: string): boolean { return false; } } + +/** + * Identifies Astro components with propagated assets + * @see 'packages/astro/src/content/consts.ts' + */ +export const PROPAGATED_ASSET_FLAG = 'astroPropagatedAssets'; + +/** + * @see 'packages/astro/src/content/utils.ts' + */ +export function hasContentFlag(viteId: string, flag: string): boolean { + const flags = new URLSearchParams(viteId.split('?')[1] ?? ''); + return flags.has(flag); +} + +/** + * Create build hash for manual Rollup chunks. + * @see 'packages/astro/src/core/build/plugins/plugin-css.ts' + */ +export function createNameHash(baseId: string, hashIds: string[]): string { + const baseName = baseId ? path.parse(baseId).name : 'index'; + const hash = crypto.createHash('sha256'); + for (const id of hashIds) { + hash.update(id, 'utf-8'); + } + const h = hash.digest('hex').slice(0, 8); + const proposedName = baseName + '.' + h; + return proposedName; +} diff --git a/packages/integrations/markdoc/test/fixtures/propagated-assets/astro.config.mjs b/packages/integrations/markdoc/test/fixtures/propagated-assets/astro.config.mjs new file mode 100644 index 000000000..29d846359 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/propagated-assets/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [markdoc()], +}); diff --git a/packages/integrations/markdoc/test/fixtures/propagated-assets/markdoc.config.mjs b/packages/integrations/markdoc/test/fixtures/propagated-assets/markdoc.config.mjs new file mode 100644 index 000000000..5389eb99d --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/propagated-assets/markdoc.config.mjs @@ -0,0 +1,18 @@ +import Aside from './src/components/Aside.astro'; +import LogHello from './src/components/LogHello.astro'; +import { defineMarkdocConfig } from '@astrojs/markdoc/config'; + +export default defineMarkdocConfig({ + tags: { + aside: { + render: Aside, + attributes: { + type: { type: String }, + title: { type: String }, + } + }, + logHello: { + render: LogHello, + } + }, +}) diff --git a/packages/integrations/markdoc/test/fixtures/propagated-assets/package.json b/packages/integrations/markdoc/test/fixtures/propagated-assets/package.json new file mode 100644 index 000000000..3b51e158d --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/propagated-assets/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/markdoc-propagated-assets", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/markdoc": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/markdoc/test/fixtures/propagated-assets/src/components/Aside.astro b/packages/integrations/markdoc/test/fixtures/propagated-assets/src/components/Aside.astro new file mode 100644 index 000000000..5d92a0993 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/propagated-assets/src/components/Aside.astro @@ -0,0 +1,116 @@ +--- +// Inspired by the `Aside` component from docs.astro.build +// https://github.com/withastro/docs/blob/main/src/components/Aside.astro + +export interface Props { + type?: 'note' | 'tip' | 'caution' | 'danger'; + title?: string; +} + +const labelByType = { + note: 'Note', + tip: 'Tip', + caution: 'Caution', + danger: 'Danger', +}; +const { type = 'note' } = Astro.props as Props; +const title = Astro.props.title ?? labelByType[type] ?? ''; + +// SVG icon paths based on GitHub Octicons +const icons: Record, { viewBox: string; d: string }> = { + note: { + viewBox: '0 0 18 18', + d: 'M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0114.25 14H1.75A1.75 1.75 0 010 12.25v-8.5zm1.75-.25a.25.25 0 00-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-8.5a.25.25 0 00-.25-.25H1.75zM3.5 6.25a.75.75 0 01.75-.75h7a.75.75 0 010 1.5h-7a.75.75 0 01-.75-.75zm.75 2.25a.75.75 0 000 1.5h4a.75.75 0 000-1.5h-4z', + }, + tip: { + viewBox: '0 0 18 18', + d: 'M14 0a8.8 8.8 0 0 0-6 2.6l-.5.4-.9 1H3.3a1.8 1.8 0 0 0-1.5.8L.1 7.6a.8.8 0 0 0 .4 1.1l3.1 1 .2.1 2.4 2.4.1.2 1 3a.8.8 0 0 0 1 .5l2.9-1.7a1.8 1.8 0 0 0 .8-1.5V9.5l1-1 .4-.4A8.8 8.8 0 0 0 16 2v-.1A1.8 1.8 0 0 0 14.2 0h-.1zm-3.5 10.6-.3.2L8 12.3l.5 1.8 2-1.2a.3.3 0 0 0 .1-.2v-2zM3.7 8.1l1.5-2.3.2-.3h-2a.3.3 0 0 0-.3.1l-1.2 2 1.8.5zm5.2-4.5a7.3 7.3 0 0 1 5.2-2.1h.1a.3.3 0 0 1 .3.3v.1a7.3 7.3 0 0 1-2.1 5.2l-.5.4a15.2 15.2 0 0 1-2.5 2L7.1 11 5 9l1.5-2.3a15.3 15.3 0 0 1 2-2.5l.4-.5zM12 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-8.4 9.6a1.5 1.5 0 1 0-2.2-2.2 7 7 0 0 0-1.1 3 .2.2 0 0 0 .3.3c.6 0 2.2-.4 3-1.1z', + }, + caution: { + viewBox: '-1 1 18 18', + d: 'M8.9 1.5C8.7 1.2 8.4 1 8 1s-.7.2-.9.5l-7 12a1 1 0 0 0 0 1c.2.3.6.5 1 .5H15c.4 0 .7-.2.9-.5a1 1 0 0 0 0-1l-7-12zM9 13H7v-2h2v2zm0-3H7V6h2v4z', + }, + danger: { + viewBox: '0 1 14 17', + d: 'M5 .3c.9 2.2.5 3.4-.5 4.3C3.5 5.6 2 6.5 1 8c-1.5 2-1.7 6.5 3.5 7.7-2.2-1.2-2.6-4.5-.3-6.6-.6 2 .6 3.3 2 2.8 1.4-.4 2.3.6 2.2 1.7 0 .8-.3 1.4-1 1.8A5.6 5.6 0 0 0 12 10c0-2.9-2.5-3.3-1.3-5.7-1.5.2-2 1.2-1.8 2.8 0 1-1 1.8-2 1.3-.6-.4-.6-1.2 0-1.8C8.2 5.3 8.7 2.5 5 .3z', + }, +}; +const { viewBox, d } = icons[type]; +--- + + + + diff --git a/packages/integrations/markdoc/test/fixtures/propagated-assets/src/components/LogHello.astro b/packages/integrations/markdoc/test/fixtures/propagated-assets/src/components/LogHello.astro new file mode 100644 index 000000000..6d994378b --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/propagated-assets/src/components/LogHello.astro @@ -0,0 +1,5 @@ +

I'm gonna log hello

+ + diff --git a/packages/integrations/markdoc/test/fixtures/propagated-assets/src/content/blog/scripts.mdoc b/packages/integrations/markdoc/test/fixtures/propagated-assets/src/content/blog/scripts.mdoc new file mode 100644 index 000000000..808cffaca --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/propagated-assets/src/content/blog/scripts.mdoc @@ -0,0 +1,5 @@ +--- +title: Scripts +--- + +{% logHello /%} diff --git a/packages/integrations/markdoc/test/fixtures/propagated-assets/src/content/blog/styles.mdoc b/packages/integrations/markdoc/test/fixtures/propagated-assets/src/content/blog/styles.mdoc new file mode 100644 index 000000000..20960e4cf --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/propagated-assets/src/content/blog/styles.mdoc @@ -0,0 +1,9 @@ +--- +title: Styles +--- + +{% aside type="tip" %} + +## Example + +{% /aside %} diff --git a/packages/integrations/markdoc/test/fixtures/propagated-assets/src/pages/[slug].astro b/packages/integrations/markdoc/test/fixtures/propagated-assets/src/pages/[slug].astro new file mode 100644 index 000000000..baee15375 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/propagated-assets/src/pages/[slug].astro @@ -0,0 +1,26 @@ +--- +import { getCollection } from 'astro:content'; + +export async function getStaticPaths() { + const posts = await getCollection('blog'); + return posts.map((post) => ({ + params: { slug: post.slug }, + props: post, + })); +} + +const { Content } = await Astro.props.render(); +--- + + + + + + + + {Astro.props.data.title} + + + + + diff --git a/packages/integrations/markdoc/test/propagated-assets.test.js b/packages/integrations/markdoc/test/propagated-assets.test.js new file mode 100644 index 000000000..429e07141 --- /dev/null +++ b/packages/integrations/markdoc/test/propagated-assets.test.js @@ -0,0 +1,66 @@ +import { parseHTML } from 'linkedom'; +import { expect } from 'chai'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +describe('Markdoc - propagated assets', () => { + let fixture; + let devServer; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/propagated-assets/', import.meta.url), + }); + }); + + const modes = ['dev', 'prod']; + + for (const mode of modes) { + describe(mode, () => { + /** @type {Document} */ + let stylesDocument; + /** @type {Document} */ + let scriptsDocument; + + before(async () => { + if (mode === 'prod') { + await fixture.build(); + stylesDocument = parseHTML(await fixture.readFile('/styles/index.html')).document; + scriptsDocument = parseHTML(await fixture.readFile('/scripts/index.html')).document; + } else if (mode === 'dev') { + devServer = await fixture.startDevServer(); + const styleRes = await fixture.fetch('/styles'); + const scriptRes = await fixture.fetch('/scripts'); + stylesDocument = parseHTML(await styleRes.text()).document; + scriptsDocument = parseHTML(await scriptRes.text()).document; + } + }); + + after(async () => { + if (mode === 'dev') devServer?.stop(); + }); + + it('Bundles styles', async () => { + let styleContents; + if (mode === 'dev') { + const styles = stylesDocument.querySelectorAll('style'); + expect(styles).to.have.lengthOf(1); + styleContents = styles[0].textContent; + } else { + const links = stylesDocument.querySelectorAll('link[rel="stylesheet"]'); + expect(links).to.have.lengthOf(1); + styleContents = await fixture.readFile(links[0].href); + } + expect(styleContents).to.include('--color-base-purple: 269, 79%;'); + }); + + it('[fails] Does not bleed styles to other page', async () => { + if (mode === 'dev') { + const styles = scriptsDocument.querySelectorAll('style'); + expect(styles).to.have.lengthOf(0); + } else { + const links = scriptsDocument.querySelectorAll('link[rel="stylesheet"]'); + expect(links).to.have.lengthOf(0); + } + }); + }); + } +}); diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index e11cd1ac5..18c9aceca 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -56,6 +56,9 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI new URL('../template/content-module-types.d.ts', import.meta.url), 'utf-8' ), + // MDX can import scripts and styles, + // so wrap all MDX files with script / style propagation checks + handlePropagation: true, }); const extendMarkdownConfig = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6f81bd15..dab25439a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4071,6 +4071,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/markdoc/test/fixtures/propagated-assets: + dependencies: + '@astrojs/markdoc': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/markdoc/test/fixtures/render-null: dependencies: '@astrojs/markdoc':