diff options
Diffstat (limited to 'packages/astro/src')
18 files changed, 315 insertions, 204 deletions
diff --git a/packages/astro/src/@types/micromark.ts b/packages/astro/src/@types/micromark.ts index 9725aabb9..5060ab468 100644 --- a/packages/astro/src/@types/micromark.ts +++ b/packages/astro/src/@types/micromark.ts @@ -1,6 +1,9 @@ export interface MicromarkExtensionContext { sliceSerialize(node: any): string; raw(value: string): void; + tag(value: string): void; + data(value: string): void; + resume(): any; } export type MicromarkExtensionCallback = (this: MicromarkExtensionContext, node: any) => void; diff --git a/packages/astro/src/build/page.ts b/packages/astro/src/build/page.ts index cc28040d6..a83a945d3 100644 --- a/packages/astro/src/build/page.ts +++ b/packages/astro/src/build/page.ts @@ -181,7 +181,7 @@ async function gatherRuntimes({ astroConfig, buildState, filepath, logging, reso let source = await fs.promises.readFile(filepath, 'utf8'); if (filepath.pathname.endsWith('.md')) { - source = await convertMdToAstroSource(source); + source = await convertMdToAstroSource(source, { filename: fileURLToPath(filepath) }); } const ast = parse(source, { filepath }); diff --git a/packages/astro/src/compiler/codegen/index.ts b/packages/astro/src/compiler/codegen/index.ts index 2da1ceec5..ab66ee47d 100644 --- a/packages/astro/src/compiler/codegen/index.ts +++ b/packages/astro/src/compiler/codegen/index.ts @@ -305,6 +305,9 @@ interface CodegenState { filename: string; components: Components; css: string[]; + markers: { + insideMarkdown: boolean|string; + }; importExportStatements: Set<string>; dynamicImports: DynamicImportMap; } @@ -318,6 +321,7 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp const componentExports: ExportNamedDeclaration[] = []; const contentImports = new Map<string, { spec: string; declarator: string }>(); + const importSpecifierTypes = new Set(['ImportDefaultSpecifier', 'ImportSpecifier']); let script = ''; let propsStatement = ''; @@ -418,7 +422,7 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp const specifier = componentImport.specifiers[0]; if (!specifier) continue; // this is unused // set componentName to default import if used (user), or use filename if no default import (mostly internal use) - const componentName = specifier.type === 'ImportDefaultSpecifier' ? specifier.local.name : path.posix.basename(importUrl, componentType); + const componentName = importSpecifierTypes.has(specifier.type) ? specifier.local.name : path.posix.basename(importUrl, componentType); const plugin = extensions[componentType] || defaultExtensions[componentType]; state.components[componentName] = { type: componentType, @@ -541,7 +545,7 @@ function compileHtml(enterNode: TemplateNode, state: CodegenState, compileOption let outSource = ''; walk(enterNode, { - enter(node: TemplateNode) { + enter(node: TemplateNode, parent: TemplateNode) { switch (node.type) { case 'Expression': { let children: string[] = []; @@ -579,27 +583,42 @@ function compileHtml(enterNode: TemplateNode, state: CodegenState, compileOption try { const attributes = getAttributes(node.attributes); - outSource += outSource === '' ? '' : ','; - if (node.type === 'Slot') { - outSource += `(children`; - return; + outSource += outSource === '' ? '' : ','; + if (node.type === 'Slot') { + outSource += `(children`; + return; + } + const COMPONENT_NAME_SCANNER = /^[A-Z]/; + if (!COMPONENT_NAME_SCANNER.test(name)) { + outSource += `h("${name}", ${attributes ? generateAttributes(attributes) : 'null'}`; + if (state.markers.insideMarkdown) { + outSource += `,h(__astroMarkdownRender, null` } - const COMPONENT_NAME_SCANNER = /^[A-Z]/; - if (!COMPONENT_NAME_SCANNER.test(name)) { - outSource += `h("${name}", ${attributes ? generateAttributes(attributes) : 'null'}`; + return; + } + const [componentName, componentKind] = name.split(':'); + const componentImportData = components[componentName]; + if (!componentImportData) { + throw new Error(`Unknown Component: ${componentName}`); + } + if (componentImportData.type === '.astro') { + if (componentName === 'Markdown') { + const attributeStr = attributes ? generateAttributes(attributes) : 'null'; + state.markers.insideMarkdown = attributeStr; + outSource += `h(__astroMarkdownRender, ${attributeStr}` return; } - const [componentName, componentKind] = name.split(':'); - const componentImportData = components[componentName]; - if (!componentImportData) { - throw new Error(`Unknown Component: ${componentName}`); - } - const { wrapper, wrapperImport } = getComponentWrapper(name, components[componentName], { astroConfig, dynamicImports, filename }); - if (wrapperImport) { - importExportStatements.add(wrapperImport); - } + } + const { wrapper, wrapperImport } = getComponentWrapper(name, components[componentName], { astroConfig, dynamicImports, filename }); + if (wrapperImport) { + importExportStatements.add(wrapperImport); + } outSource += `h(${wrapper}, ${attributes ? generateAttributes(attributes) : 'null'}`; + if (state.markers.insideMarkdown) { + const attributeStr = state.markers.insideMarkdown; + outSource += `,h(__astroMarkdownRender, ${attributeStr}` + } } catch (err) { // handle errors in scope with filename const rel = filename.replace(astroConfig.projectRoot.pathname, ''); @@ -617,9 +636,16 @@ function compileHtml(enterNode: TemplateNode, state: CodegenState, compileOption this.skip(); return; } + case 'CodeSpan': + case 'CodeFence': { + outSource += ',' + JSON.stringify(node.raw); + return; + } case 'Text': { const text = getTextFromAttribute(node); - if (!text.trim()) { + // Whitespace is significant if we are immediately inside of <Markdown>, + // but not if we're inside of another component in <Markdown> + if (parent.name !== 'Markdown' && !text.trim()) { return; } outSource += ',' + JSON.stringify(text); @@ -632,6 +658,8 @@ function compileHtml(enterNode: TemplateNode, state: CodegenState, compileOption leave(node, parent, prop, index) { switch (node.type) { case 'Text': + case 'CodeSpan': + case 'CodeFence': case 'Attribute': case 'Comment': case 'Fragment': @@ -643,9 +671,16 @@ function compileHtml(enterNode: TemplateNode, state: CodegenState, compileOption case 'Body': case 'Title': case 'Element': - case 'InlineComponent': + case 'InlineComponent': { + if (node.type === 'InlineComponent' && node.name === 'Markdown') { + state.markers.insideMarkdown = false; + } + if (state.markers.insideMarkdown) { + outSource += ')'; + } outSource += ')'; return; + } case 'Style': { this.remove(); // this will be optimized in a global CSS file; remove so it‘s not accidentally inlined return; @@ -674,8 +709,11 @@ export async function codegen(ast: Ast, { compileOptions, filename }: CodeGenOpt filename, components: {}, css: [], + markers: { + insideMarkdown: false + }, importExportStatements: new Set(), - dynamicImports: new Map(), + dynamicImports: new Map() }; const { script, componentPlugins, createCollection } = compileModule(ast.module, state, compileOptions); diff --git a/packages/astro/src/compiler/index.ts b/packages/astro/src/compiler/index.ts index f4bfbb19d..afdaac986 100644 --- a/packages/astro/src/compiler/index.ts +++ b/packages/astro/src/compiler/index.ts @@ -3,15 +3,9 @@ import type { CompileResult, TransformResult } from '../@types/astro'; import type { CompileOptions } from '../@types/compiler.js'; import path from 'path'; -import micromark from 'micromark'; -import gfmSyntax from 'micromark-extension-gfm'; -import matter from 'gray-matter'; -import gfmHtml from 'micromark-extension-gfm/html.js'; +import { renderMarkdownWithFrontmatter } from './utils.js'; import { parse } from 'astro-parser'; -import { createMarkdownHeadersCollector } from './markdown/micromark-collect-headers.js'; -import { encodeMarkdown } from './markdown/micromark-encode.js'; -import { encodeAstroMdx } from './markdown/micromark-mdx-astro.js'; import { transform } from './transform/index.js'; import { codegen } from './codegen/index.js'; @@ -53,38 +47,24 @@ async function convertAstroToJsx(template: string, opts: ConvertAstroOptions): P /** * .md -> .astro source */ -export async function convertMdToAstroSource(contents: string): Promise<string> { - const { data: frontmatterData, content } = matter(contents); - const { headers, headersExtension } = createMarkdownHeadersCollector(); - const { htmlAstro, mdAstro } = encodeAstroMdx(); - const mdHtml = micromark(content, { - allowDangerousHtml: true, - extensions: [gfmSyntax(), ...htmlAstro], - htmlExtensions: [gfmHtml, encodeMarkdown, headersExtension, mdAstro], - }); - - // TODO: Warn if reserved word is used in "frontmatterData" +export async function convertMdToAstroSource(contents: string, { filename }: { filename: string }): Promise<string> { + const { content, frontmatter: { layout, ...frontmatter }, ...data } = await renderMarkdownWithFrontmatter(contents); + if (frontmatter['astro'] !== undefined) { + throw new Error(`"astro" is a reserved word but was used as a frontmatter value!\n\tat ${filename}`); + } const contentData: any = { - ...frontmatterData, - headers, - source: content, + ...frontmatter, + ...data }; - - let imports = ''; - for (let [ComponentName, specifier] of Object.entries(frontmatterData.import || {})) { - imports += `import ${ComponentName} from '${specifier}';\n`; - } - // </script> can't be anywhere inside of a JS string, otherwise the HTML parser fails. // Break it up here so that the HTML parser won't detect it. const stringifiedSetupContext = JSON.stringify(contentData).replace(/\<\/script\>/g, `</scrip" + "t>`); return `--- - ${imports} - ${frontmatterData.layout ? `import {__renderPage as __layout} from '${frontmatterData.layout}';` : 'const __layout = undefined;'} - export const __content = ${stringifiedSetupContext}; +${layout ? `import {__renderPage as __layout} from '${layout}';` : 'const __layout = undefined;'} +export const __content = ${stringifiedSetupContext}; --- -<section>${mdHtml}</section>`; +${content}`; } /** @@ -95,24 +75,24 @@ async function convertMdToJsx( contents: string, { compileOptions, filename, fileID }: { compileOptions: CompileOptions; filename: string; fileID: string } ): Promise<TransformResult> { - const raw = await convertMdToAstroSource(contents); +const raw = await convertMdToAstroSource(contents, { filename }); const convertOptions = { compileOptions, filename, fileID }; return await convertAstroToJsx(raw, convertOptions); } -type SupportedExtensions = '.astro' | '.md'; - -/** Given a file, process it either as .astro or .md. */ +/** Given a file, process it either as .astro, .md */ async function transformFromSource( contents: string, { compileOptions, filename, projectRoot }: { compileOptions: CompileOptions; filename: string; projectRoot: string } ): Promise<TransformResult> { const fileID = path.relative(projectRoot, filename); - switch (path.extname(filename) as SupportedExtensions) { - case '.astro': + switch (true) { + case filename.slice(-6) === '.astro': return await convertAstroToJsx(contents, { compileOptions, filename, fileID }); - case '.md': + + case filename.slice(-3) === '.md': return await convertMdToJsx(contents, { compileOptions, filename, fileID }); + default: throw new Error('Not Supported!'); } @@ -125,6 +105,7 @@ export async function compileComponent( ): Promise<CompileResult> { const result = await transformFromSource(source, { compileOptions, filename, projectRoot }); const site = compileOptions.astroConfig.buildOptions.site || `http://localhost:${compileOptions.astroConfig.devOptions.port}`; + const usesMarkdown = !!result.imports.find(spec => spec.indexOf('Markdown') > -1); // return template let modJsx = ` @@ -135,6 +116,7 @@ ${result.imports.join('\n')} // \`__render()\`: Render the contents of the Astro module. import { h, Fragment } from '${internalImport('h.js')}'; +${usesMarkdown ? `import __astroMarkdownRender from '${internalImport('markdown.js')}';` : ''}; const __astroRequestSymbol = Symbol('astro.request'); async function __render(props, ...children) { const Astro = { diff --git a/packages/astro/src/compiler/markdown/micromark-collect-headers.ts b/packages/astro/src/compiler/markdown/micromark-collect-headers.ts deleted file mode 100644 index 69781231a..000000000 --- a/packages/astro/src/compiler/markdown/micromark-collect-headers.ts +++ /dev/null @@ -1,38 +0,0 @@ -import slugger from 'github-slugger'; - -/** - * Create Markdown Headers Collector - * NOTE: micromark has terrible TS types. Instead of fighting with the - * limited/broken TS types that they ship, we just reach for our good friend, "any". - */ -export function createMarkdownHeadersCollector() { - const headers: any[] = []; - let currentHeader: any; - return { - headers, - headersExtension: { - enter: { - atxHeading(node: any) { - currentHeader = {}; - headers.push(currentHeader); - this.buffer(); - }, - atxHeadingSequence(node: any) { - currentHeader.depth = this.sliceSerialize(node).length; - }, - atxHeadingText(node: any) { - currentHeader.text = this.sliceSerialize(node); - }, - } as any, - exit: { - atxHeading(node: any) { - currentHeader.slug = slugger.slug(currentHeader.text); - this.resume(); - this.tag(`<h${currentHeader.depth} id="${currentHeader.slug}">`); - this.raw(currentHeader.text); - this.tag(`</h${currentHeader.depth}>`); - }, - } as any, - } as any, - }; -} diff --git a/packages/astro/src/compiler/markdown/micromark-encode.ts b/packages/astro/src/compiler/markdown/micromark-encode.ts deleted file mode 100644 index 635ab3b54..000000000 --- a/packages/astro/src/compiler/markdown/micromark-encode.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Token } from 'micromark/dist/shared-types'; -import type { MicromarkExtension, MicromarkExtensionContext } from '../../@types/micromark'; - -const characterReferences = { - '"': 'quot', - '&': 'amp', - '<': 'lt', - '>': 'gt', - '{': 'lbrace', - '}': 'rbrace', -}; - -type EncodedChars = '"' | '&' | '<' | '>' | '{' | '}'; - -/** Encode HTML entity */ -function encode(value: string): string { - return value.replace(/["&<>{}]/g, (raw: string) => { - return '&' + characterReferences[raw as EncodedChars] + ';'; - }); -} - -/** Encode Markdown node */ -function encodeToken(this: MicromarkExtensionContext) { - const token: Token = arguments[0]; - const value = this.sliceSerialize(token); - this.raw(encode(value)); -} - -const plugin: MicromarkExtension = { - exit: { - codeTextData: encodeToken, - codeFlowValue: encodeToken, - }, -}; - -export { plugin as encodeMarkdown }; diff --git a/packages/astro/src/compiler/markdown/micromark-mdx-astro.ts b/packages/astro/src/compiler/markdown/micromark-mdx-astro.ts deleted file mode 100644 index b978ad407..000000000 --- a/packages/astro/src/compiler/markdown/micromark-mdx-astro.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { MicromarkExtension } from '../../@types/micromark'; -import mdxExpression from 'micromark-extension-mdx-expression'; -import mdxJsx from 'micromark-extension-mdx-jsx'; - -/** - * Keep MDX. - */ -export function encodeAstroMdx() { - const extension: MicromarkExtension = { - enter: { - mdxJsxFlowTag(node: any) { - const mdx = this.sliceSerialize(node); - this.raw(mdx); - }, - }, - }; - - return { - htmlAstro: [mdxExpression(), mdxJsx()], - mdAstro: extension, - }; -} diff --git a/packages/astro/src/compiler/markdown/micromark.d.ts b/packages/astro/src/compiler/markdown/micromark.d.ts index fd094306e..9c084f437 100644 --- a/packages/astro/src/compiler/markdown/micromark.d.ts +++ b/packages/astro/src/compiler/markdown/micromark.d.ts @@ -1,11 +1,11 @@ -declare module 'micromark-extension-mdx-expression' { - import type { HtmlExtension } from 'micromark/dist/shared-types'; - - export default function (): HtmlExtension; +declare module '@silvenon/remark-smartypants' { + export default function (): any; } -declare module 'micromark-extension-mdx-jsx' { - import type { HtmlExtension } from 'micromark/dist/shared-types'; +declare module 'mdast-util-mdx/from-markdown.js' { + export default function (): any; +} - export default function (): HtmlExtension; +declare module 'mdast-util-mdx/to-markdown.js' { + export default function (): any; } diff --git a/packages/astro/src/compiler/markdown/rehype-collect-headers.ts b/packages/astro/src/compiler/markdown/rehype-collect-headers.ts new file mode 100644 index 000000000..3ebf3257d --- /dev/null +++ b/packages/astro/src/compiler/markdown/rehype-collect-headers.ts @@ -0,0 +1,30 @@ +import { visit } from 'unist-util-visit'; +import slugger from 'github-slugger'; + +/** */ +export default function createCollectHeaders() { + const headers: any[] = []; + + const visitor = (node: any) => { + if (node.type !== 'element') return; + const { tagName, children } = node + if (tagName[0] !== 'h') return; + let [_, depth] = tagName.match(/h([0-6])/) ?? []; + if (!depth) return; + depth = Number.parseInt(depth); + + let text = ''; + visit(node, 'text', (child) => { + text += child.value; + }) + + let slug = slugger.slug(text); + node.properties = node.properties || {}; + node.properties.id = slug; + headers.push({ depth, slug, text }); + + return node; + } + + return { headers, rehypeCollectHeaders: () => (tree: any) => visit(tree, visitor) } +} diff --git a/packages/astro/src/compiler/markdown/remark-mdx-lite.ts b/packages/astro/src/compiler/markdown/remark-mdx-lite.ts new file mode 100644 index 000000000..27eed917e --- /dev/null +++ b/packages/astro/src/compiler/markdown/remark-mdx-lite.ts @@ -0,0 +1,26 @@ +import fromMarkdown from 'mdast-util-mdx/from-markdown.js'; +import toMarkdown from 'mdast-util-mdx/to-markdown.js'; + +/** See https://github.com/micromark/micromark-extension-mdx-md */ +const syntax = { disable: {null: ['autolink', 'codeIndented']} }; + +/** + * Lite version of https://github.com/mdx-js/mdx/tree/main/packages/remark-mdx + * We don't need all the features MDX does because all components are precompiled + * to HTML. We just want to disable a few MD features that cause issues. + */ +function mdxLite (this: any) { + let data = this.data() + + add('micromarkExtensions', syntax); + add('fromMarkdownExtensions', fromMarkdown) + add('toMarkdownExtensions', toMarkdown) + + /** Adds remark plugin */ + function add(field: string, value: any) { + if (data[field]) data[field].push(value) + else data[field] = [value] + } +} + +export default mdxLite; diff --git a/packages/astro/src/compiler/markdown/remark-scoped-styles.ts b/packages/astro/src/compiler/markdown/remark-scoped-styles.ts new file mode 100644 index 000000000..9e2c8c290 --- /dev/null +++ b/packages/astro/src/compiler/markdown/remark-scoped-styles.ts @@ -0,0 +1,18 @@ +import { visit } from 'unist-util-visit'; +const noVisit = new Set(['root', 'html', 'text']); + +/** */ +export default function scopedStyles(className: string) { + const visitor = (node: any) => { + if (noVisit.has(node.type)) return; + + const {data} = node + const currentClassName = data?.hProperties?.class ?? ''; + node.data = node.data || {}; + node.data.hProperties = node.data.hProperties || {}; + node.data.hProperties.className = `${className} ${currentClassName}`.trim(); + + return node; + } + return () => (tree: any) => visit(tree, visitor); +} diff --git a/packages/astro/src/compiler/transform/styles.ts b/packages/astro/src/compiler/transform/styles.ts index 89a8c9c7f..10d9158a0 100644 --- a/packages/astro/src/compiler/transform/styles.ts +++ b/packages/astro/src/compiler/transform/styles.ts @@ -156,6 +156,36 @@ async function transformStyle(code: string, { logging, type, filename, scopedCla return { css, type: styleType }; } +/** For a given node, inject or append a `scopedClass` to its `class` attribute */ +function injectScopedClassAttribute(node: TemplateNode, scopedClass: string, attribute = 'class') { + if (!node.attributes) node.attributes = []; + const classIndex = node.attributes.findIndex(({ name }: any) => name === attribute); + if (classIndex === -1) { + // 3a. element has no class="" attribute; add one and append scopedClass + node.attributes.push({ start: -1, end: -1, type: 'Attribute', name: attribute, value: [{ type: 'Text', raw: scopedClass, data: scopedClass }] }); + } else { + // 3b. element has class=""; append scopedClass + const attr = node.attributes[classIndex]; + for (let k = 0; k < attr.value.length; k++) { + if (attr.value[k].type === 'Text') { + // don‘t add same scopedClass twice + if (!hasClass(attr.value[k].data, scopedClass)) { + // string literal + attr.value[k].raw += ' ' + scopedClass; + attr.value[k].data += ' ' + scopedClass; + } + } else if (attr.value[k].type === 'MustacheTag' && attr.value[k]) { + // don‘t add same scopedClass twice (this check is a little more basic, but should suffice) + if (!attr.value[k].expression.codeChunks[0].includes(`' ${scopedClass}'`)) { + // MustacheTag + // FIXME: this won't work when JSX element can appear in attributes (rare but possible). + attr.value[k].expression.codeChunks[0] = `(${attr.value[k].expression.codeChunks[0]}) + ' ${scopedClass}'`; + } + } + } + } +} + /** Transform <style> tags */ export default function transformStyles({ compileOptions, filename, fileID }: TransformOptions): Transformer { const styleNodes: TemplateNode[] = []; // <style> tags to be updated @@ -180,6 +210,12 @@ export default function transformStyles({ compileOptions, filename, fileID }: Tr return { visitors: { html: { + InlineComponent: { + enter(node) { + if (node.name !== 'Markdown') return; + injectScopedClassAttribute(node, scopedClass, '$scope'); + } + }, Element: { enter(node) { // 1. if <style> tag, transform it and continue to next node @@ -204,32 +240,7 @@ export default function transformStyles({ compileOptions, filename, fileID }: Tr if (NEVER_SCOPED_TAGS.has(node.name)) return; // only continue if this is NOT a <script> tag, etc. // Note: currently we _do_ scope web components/custom elements. This seems correct? - if (!node.attributes) node.attributes = []; - const classIndex = node.attributes.findIndex(({ name }: any) => name === 'class'); - if (classIndex === -1) { - // 3a. element has no class="" attribute; add one and append scopedClass - node.attributes.push({ start: -1, end: -1, type: 'Attribute', name: 'class', value: [{ type: 'Text', raw: scopedClass, data: scopedClass }] }); - } else { - // 3b. element has class=""; append scopedClass - const attr = node.attributes[classIndex]; - for (let k = 0; k < attr.value.length; k++) { - if (attr.value[k].type === 'Text') { - // don‘t add same scopedClass twice - if (!hasClass(attr.value[k].data, scopedClass)) { - // string literal - attr.value[k].raw += ' ' + scopedClass; - attr.value[k].data += ' ' + scopedClass; - } - } else if (attr.value[k].type === 'MustacheTag' && attr.value[k]) { - // don‘t add same scopedClass twice (this check is a little more basic, but should suffice) - if (!attr.value[k].expression.codeChunks[0].includes(`' ${scopedClass}'`)) { - // MustacheTag - // FIXME: this won't work when JSX element can appear in attributes (rare but possible). - attr.value[k].expression.codeChunks[0] = `(${attr.value[k].expression.codeChunks[0]}) + ' ${scopedClass}'`; - } - } - } - } + injectScopedClassAttribute(node, scopedClass); }, }, }, diff --git a/packages/astro/src/compiler/utils.ts b/packages/astro/src/compiler/utils.ts new file mode 100644 index 000000000..701dc2adf --- /dev/null +++ b/packages/astro/src/compiler/utils.ts @@ -0,0 +1,70 @@ +import mdxLite from './markdown/remark-mdx-lite.js'; +import createCollectHeaders from './markdown/rehype-collect-headers.js'; +import scopedStyles from './markdown/remark-scoped-styles.js'; +import raw from 'rehype-raw'; +import unified from 'unified'; +import markdown from 'remark-parse'; +import markdownToHtml from 'remark-rehype'; +import smartypants from '@silvenon/remark-smartypants'; +import stringify from 'rehype-stringify'; + +export interface MarkdownRenderingOptions { + $?: { + scopedClassName: string | null; + }; + footnotes?: boolean; + gfm?: boolean; + plugins?: any[]; +} + +/** Internal utility for rendering a full markdown file and extracting Frontmatter data */ +export async function renderMarkdownWithFrontmatter(contents: string, opts?: MarkdownRenderingOptions|null) { + // Dynamic import to ensure that "gray-matter" isn't built by Snowpack + const { default: matter } = await import('gray-matter'); + const { + data: frontmatter, + content, + } = matter(contents); + const value = await renderMarkdown(content, opts); + return { ...value, frontmatter }; +} + +/** Shared utility for rendering markdown */ +export async function renderMarkdown(content: string, opts?: MarkdownRenderingOptions | null) { + const { $: { scopedClassName = null } = {}, footnotes: useFootnotes = true, gfm: useGfm = true, plugins = [] } = opts ?? {}; + const { headers, rehypeCollectHeaders } = createCollectHeaders(); + + let parser = unified().use(markdown).use(mdxLite).use(smartypants); + + if (scopedClassName) { + parser = parser.use(scopedStyles(scopedClassName)); + } + + if (useGfm) { + const {default:gfm} = await import('remark-gfm'); + parser = parser.use(gfm); + } + + if (useFootnotes) { + const {default:footnotes} = await import('remark-footnotes'); + parser = parser.use(footnotes); + } + + let result: string; + try { + const vfile = await parser + .use(markdownToHtml, { allowDangerousHtml: true, passThrough: ['raw'] }) + .use(raw) + .use(rehypeCollectHeaders) + .use(stringify) + .process(content); + result = vfile.contents.toString(); + } catch (err) { + throw err; + } + + return { + astro: { headers, source: content }, + content: result.toString(), + }; +} diff --git a/packages/astro/src/frontend/markdown.ts b/packages/astro/src/frontend/markdown.ts new file mode 100644 index 000000000..8fb013d76 --- /dev/null +++ b/packages/astro/src/frontend/markdown.ts @@ -0,0 +1,26 @@ +import { renderMarkdown } from '../compiler/utils.js'; + +/** + * Functional component which uses Astro's built-in Markdown rendering + * to render out its children. + * + * Note: the children have already been properly escaped/rendered + * by the parser and Astro, so at this point we're just rendering + * out plain markdown, no need for JSX support + */ +export default async function Markdown(props: { $scope: string|null }, ...children: string[]): Promise<string> { + const { $scope = null } = props ?? {}; + const text = dedent(children.join('').trimEnd()); + let { content } = await renderMarkdown(text, { $: { scopedClassName: $scope } }); + if (content.split('<p>').length === 2) { + content = content.replace(/^\<p\>/i, '').replace(/\<\/p\>$/i, ''); + } + return content; +} + +/** Remove leading indentation based on first line */ +function dedent(str: string) { + let arr = str.match(/^[ \t]*(?=\S)/gm); + let first = !!arr && arr.find(x => x.length > 0)?.length; + return (!arr || !first) ? str : str.replace(new RegExp(`^[ \\t]{0,${first}}`, 'gm'), ''); +} diff --git a/packages/astro/src/frontend/render/renderer.ts b/packages/astro/src/frontend/render/renderer.ts index 7bdf7d8a8..86d74fa84 100644 --- a/packages/astro/src/frontend/render/renderer.ts +++ b/packages/astro/src/frontend/render/renderer.ts @@ -36,17 +36,15 @@ export function createRenderer(renderer: SupportedComponentRenderer) { } value = `<div data-astro-id="${innerContext['data-astro-id']}" style="display:contents">${value}</div>`; - const script = ` - ${typeof wrapperStart === 'function' ? wrapperStart(innerContext) : wrapperStart} - ${_imports(renderContext)} - ${renderer.render({ + const script = `${typeof wrapperStart === 'function' ? wrapperStart(innerContext) : wrapperStart} +${_imports(renderContext)} +${renderer.render({ ...innerContext, props: serializeProps(props), children: `[${childrenToH(renderer, children) ?? ''}]`, childrenAsString: `\`${children}\``, })} - ${typeof wrapperEnd === 'function' ? wrapperEnd(innerContext) : wrapperEnd} - `; +${typeof wrapperEnd === 'function' ? wrapperEnd(innerContext) : wrapperEnd}`; return [value, `<script type="module">${script.trim()}</script>`].join('\n'); }; diff --git a/packages/astro/src/frontend/render/utils.ts b/packages/astro/src/frontend/render/utils.ts index 2dddf083e..29eaf64b5 100644 --- a/packages/astro/src/frontend/render/utils.ts +++ b/packages/astro/src/frontend/render/utils.ts @@ -3,12 +3,10 @@ import parse from 'rehype-parse'; import toH from 'hast-to-hyperscript'; import { ComponentRenderer } from '../../@types/renderer'; import moize from 'moize'; -// This prevents tree-shaking of render. -Function.prototype(toH); /** @internal */ -function childrenToTree(children: string[]) { - return children.map((child) => (unified().use(parse, { fragment: true }).parse(child) as any).children.pop()); +function childrenToTree(children: string[]): any[] { + return [].concat(...children.map((child) => (unified().use(parse, { fragment: true }).parse(child) as any).children)); } /** @@ -32,17 +30,20 @@ export const childrenToVnodes = moize.deep(function childrenToVnodes(h: any, chi */ export const childrenToH = moize.deep(function childrenToH(renderer: ComponentRenderer<any>, children: string[]): any { if (!renderer.jsxPragma) return; + const tree = childrenToTree(children); const innerH = (name: any, attrs: Record<string, any> | null = null, _children: string[] | null = null) => { const vnode = renderer.jsxPragma?.(name, attrs, _children); const childStr = _children ? `, [${_children.map((child) => serializeChild(child)).join(',')}]` : ''; - /* fix(react): avoid hard-coding keys into the serialized tree */ - if (attrs && attrs.key) attrs.key = undefined; + if (attrs && attrs.key) attrs.key = Math.random(); const __SERIALIZED = `${renderer.jsxPragmaName}("${name}", ${attrs ? JSON.stringify(attrs) : 'null'}${childStr})` as string; return { ...vnode, __SERIALIZED }; }; + + const simpleTypes = new Set(['number', 'boolean']); const serializeChild = (child: unknown) => { - if (['string', 'number', 'boolean'].includes(typeof child)) return JSON.stringify(child); + if (typeof child === 'string') return JSON.stringify(child).replace(/<\/script>/gmi, '</script" + ">'); + if (simpleTypes.has(typeof child)) return JSON.stringify(child); if (child === null) return `null`; if ((child as any).__SERIALIZED) return (child as any).__SERIALIZED; return innerH(child).__SERIALIZED; diff --git a/packages/astro/src/runtime.ts b/packages/astro/src/runtime.ts index 965ea641a..1eabbd364 100644 --- a/packages/astro/src/runtime.ts +++ b/packages/astro/src/runtime.ts @@ -314,7 +314,11 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO }, packageOptions: { knownEntrypoints: ['preact-render-to-string'], - external: ['@vue/server-renderer', 'node-fetch', 'prismjs/components/index.js'], + external: [ + '@vue/server-renderer', + 'node-fetch', + 'prismjs/components/index.js' + ], }, }); diff --git a/packages/astro/src/search.ts b/packages/astro/src/search.ts index 20f600d31..84a2ee634 100644 --- a/packages/astro/src/search.ts +++ b/packages/astro/src/search.ts @@ -45,7 +45,7 @@ export function searchForPage(url: URL, astroRoot: URL): SearchResult { // Try to find index.astro/md paths if (reqPath.endsWith('/')) { - const candidates = [`${base}index.astro`, `${base}index.md`]; + const candidates = [`${base}index.astro`, `${base}index.md`,]; const location = findAnyPage(candidates, astroRoot); if (location) { return { |