diff options
Diffstat (limited to 'packages/astro')
32 files changed, 549 insertions, 228 deletions
diff --git a/packages/astro/components/Markdown.astro b/packages/astro/components/Markdown.astro new file mode 100644 index 000000000..8e4e17cee --- /dev/null +++ b/packages/astro/components/Markdown.astro @@ -0,0 +1,3 @@ +<!-- Probably not what you're looking for! --> +<!-- Check `astro-parser` or /frontend/markdown.ts --> +<slot /> diff --git a/packages/astro/components/Prism.astro b/packages/astro/components/Prism.astro index 5207d8bda..6b73d5bbc 100644 --- a/packages/astro/components/Prism.astro +++ b/packages/astro/components/Prism.astro @@ -26,6 +26,7 @@ if(languageMap.has(lang)) { ensureLoaded('typescript'); addAstro(Prism); } else { + ensureLoaded('markup-templating'); // Prism expects this to exist for a number of other langs ensureLoaded(lang); } diff --git a/packages/astro/package.json b/packages/astro/package.json index 8328350a7..b686c6ae8 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -35,6 +35,7 @@ "@babel/generator": "^7.13.9", "@babel/parser": "^7.13.15", "@babel/traverse": "^7.13.15", + "@silvenon/remark-smartypants": "^1.0.0", "@snowpack/plugin-sass": "^1.4.0", "@snowpack/plugin-svelte": "^3.6.1", "@snowpack/plugin-vue": "^2.4.0", @@ -58,10 +59,7 @@ "kleur": "^4.1.4", "locate-character": "^2.0.5", "magic-string": "^0.25.3", - "micromark": "^2.11.4", - "micromark-extension-gfm": "^0.3.3", - "micromark-extension-mdx-expression": "^0.3.2", - "micromark-extension-mdx-jsx": "^0.3.3", + "mdast-util-mdx": "^0.1.1", "mime": "^2.5.2", "moize": "^6.0.1", "node-fetch": "^2.6.1", @@ -74,6 +72,12 @@ "react": "^17.0.1", "react-dom": "^17.0.1", "rehype-parse": "^7.0.1", + "rehype-raw": "^5.1.0", + "rehype-stringify": "^8.0.0", + "remark-footnotes": "^3.0.0", + "remark-gfm": "^1.0.0", + "remark-parse": "^9.0.0", + "remark-rehype": "^8.1.0", "rollup": "^2.43.1", "rollup-plugin-terser": "^7.0.2", "sass": "^1.32.13", @@ -102,7 +106,8 @@ "@types/react-dom": "^17.0.2", "@types/sass": "^1.16.0", "@types/yargs-parser": "^20.2.0", - "astro-scripts": "0.0.1" + "astro-scripts": "0.0.1", + "unist-util-visit": "^3.1.0" }, "engines": { "node": ">=14.0.0", 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 { diff --git a/packages/astro/test/astro-markdown.test.js b/packages/astro/test/astro-markdown.test.js index 97be990d8..f531ad2e5 100644 --- a/packages/astro/test/astro-markdown.test.js +++ b/packages/astro/test/astro-markdown.test.js @@ -3,12 +3,13 @@ import * as assert from 'uvu/assert'; import { doc } from './test-utils.js'; import { setup, setupBuild } from './helpers.js'; -const Markdown = suite('Astro Markdown'); +const Markdown = suite('Astro Markdown tests'); setup(Markdown, './fixtures/astro-markdown'); setupBuild(Markdown, './fixtures/astro-markdown'); -Markdown('Can load markdown pages with hmx', async ({ runtime }) => { + +Markdown('Can load markdown pages with Astro', async ({ runtime }) => { const result = await runtime.load('/post'); if (result.error) throw new Error(result.error); diff --git a/packages/astro/test/fixtures/astro-markdown/snowpack.config.json b/packages/astro/test/fixtures/astro-markdown/snowpack.config.json new file mode 100644 index 000000000..8f034781d --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/snowpack.config.json @@ -0,0 +1,3 @@ +{ + "workspaceRoot": "../../../../../" +} diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/complex.astro b/packages/astro/test/fixtures/astro-markdown/src/pages/complex.astro new file mode 100644 index 000000000..aa9a872eb --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/complex.astro @@ -0,0 +1,20 @@ +--- +import Markdown from 'astro/components/Markdown.astro'; +import Layout from '../layouts/content.astro'; +import Hello from '../components/Hello.jsx'; +import Counter from '../components/Counter.jsx'; + +export const title = 'My Blog Post'; +export const description = 'This is a post about some stuff.'; +--- + +<Markdown> + <Layout> + + ## Interesting Topic + + <Hello name={`world`} /> + <Counter:load /> + + </Layout> +</Markdown> diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/complex.md b/packages/astro/test/fixtures/astro-markdown/src/pages/complex.md deleted file mode 100644 index f55a11ad6..000000000 --- a/packages/astro/test/fixtures/astro-markdown/src/pages/complex.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: ../layouts/content.astro -title: My Blog Post -description: This is a post about some stuff. -import: - Hello: '../components/Hello.jsx' - Counter: '../components/Counter.jsx' ---- - -## Interesting Topic - -<Hello name={`world`} /> -<Counter:load /> diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/post.astro b/packages/astro/test/fixtures/astro-markdown/src/pages/post.astro new file mode 100644 index 000000000..05e740c04 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/post.astro @@ -0,0 +1,16 @@ +--- +import Markdown from 'astro/components/Markdown.astro'; +import Layout from '../layouts/content.astro'; +import Example from '../components/Example.jsx'; + +export const title = 'My Blog Post'; +export const description = 'This is a post about some stuff.'; +--- + +<Markdown> + ## Interesting Topic + + <div id="first">Some content</div> + + <Example></Example> +</Markdown> diff --git a/packages/astro/test/fixtures/plain-markdown/astro.config.mjs b/packages/astro/test/fixtures/plain-markdown/astro.config.mjs new file mode 100644 index 000000000..c8631c503 --- /dev/null +++ b/packages/astro/test/fixtures/plain-markdown/astro.config.mjs @@ -0,0 +1,8 @@ +export default { + extensions: { + '.jsx': 'preact', + }, + buildOptions: { + sitemap: false, + }, +}; diff --git a/packages/astro/test/fixtures/plain-markdown/snowpack.config.json b/packages/astro/test/fixtures/plain-markdown/snowpack.config.json new file mode 100644 index 000000000..8f034781d --- /dev/null +++ b/packages/astro/test/fixtures/plain-markdown/snowpack.config.json @@ -0,0 +1,3 @@ +{ + "workspaceRoot": "../../../../../" +} diff --git a/packages/astro/test/fixtures/plain-markdown/src/layouts/content.astro b/packages/astro/test/fixtures/plain-markdown/src/layouts/content.astro new file mode 100644 index 000000000..925a243a9 --- /dev/null +++ b/packages/astro/test/fixtures/plain-markdown/src/layouts/content.astro @@ -0,0 +1,10 @@ +<html> + <head> + <!-- Head Stuff --> + </head> + <body> + <div class="container"> + <slot></slot> + </div> + </body> +</html> diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/post.md b/packages/astro/test/fixtures/plain-markdown/src/pages/post.md index 58ebdc945..5b2f32348 100644 --- a/packages/astro/test/fixtures/astro-markdown/src/pages/post.md +++ b/packages/astro/test/fixtures/plain-markdown/src/pages/post.md @@ -2,12 +2,10 @@ layout: ../layouts/content.astro title: My Blog Post description: This is a post about some stuff. -import: - Example: '../components/Example.jsx' --- ## Interesting Topic -<div id="first">Some content</div> +Hello world! -<Example /> +<div id="first">Some content</div> diff --git a/packages/astro/test/fixtures/plain-markdown/src/pages/realworld.md b/packages/astro/test/fixtures/plain-markdown/src/pages/realworld.md new file mode 100644 index 000000000..7c6264678 --- /dev/null +++ b/packages/astro/test/fixtures/plain-markdown/src/pages/realworld.md @@ -0,0 +1,117 @@ +--- +# Taken from https://github.com/endymion1818/deliciousreverie/blob/master/src/pages/post/advanced-custom-fields-bootstrap-tabs.md +categories: +- development +date: "2015-06-02T15:21:21+01:00" +description: I'm not a huge fan of Advanced Custom Fields, but there was a requirement + to use it in a recent project that had Bootstrap as a basis for the UI. The challenge + for me was to get Bootstrap `nav-tabs` to play nice with an ACF repeater field. +draft: false +tags: +- wordpress +- advanced custom fields +title: Advanced Custom Fields and Bootstrap Tabs +--- + +**I'm not a huge fan of Advanced Custom Fields, but there was a requirement to use it in a recent project that had Bootstrap as a basis for the UI. The challenge for me was to get Bootstrap [nav-tabs](http://getbootstrap.com/components/#nav-tabs "Bootstrap nav-tabs component") to play nice with an [ACF repeater field](http://www.advancedcustomfields.com/resources/querying-the-database-for-repeater-sub-field-values/ "Repeater sub-field on Advanced Custom Fields website").** + +I started with the basic HTML markup for Bootstrap's Nav Tabs: + +```html +<ul class="nav nav-tabs"> + <li role="presentation" class="active"><a href="tabone">TabOne</a></li> + <li role="presentation"><a href="tabtwo">TabTwo</a></li> + <li role="presentation"><a href="tabthree">TabThree</a></li> +</ul> +<div class="tab-content"> + <div class="tab-pane active" id="tabone"> + Some content in tab one +</div> + <div class="tab-pane active" id="tabtwo"> + Some content in tab two +</div> + <div class="tab-pane active" id="tabthree"> + Some content in tab three +</div> +</div> +``` +In the Field Groups settings, I created a Repeater (this is a paid-for add on to the standard Advanced Custom Fields) called "tab Panes", with 2 sub-fields, "Tab Title" and "Tab Contents". + +```php +<?php +<!-- Check for parent repeater row --> +<?php if( have_rows('tab_panes') ): ?> + <ul class="nav nav-tabs" role="tablist"> + <?php // Step 1: Loop through rows, first displaying tab titles in a list + while( have_rows('tab_panes') ): the_row(); +?> + <li role="presentation" class="active"> + <a + href="#tabone" + role="tab" + data-toggle="tab" + > + <?php the_sub_field('tab_title'); ?> + </a> + </li> + <?php endwhile; // end of (have_rows('tab_panes') ):?> + </ul> +<?php endif; // end of (have_rows('tab_panes') ): ?> +``` + +The PHP above displays the tabs. The code below, very similarly, displays the tab panes: + +```php +<?php if( have_rows('tab_panes') ): ?> + <div class="tab-content"> + <?php// number rows ?> + <?php // Step 2: Loop through rows, now displaying tab contents + while( have_rows('tab_panes') ): the_row(); + // Display each item as a list ?> + <div class="tab-pane active" id="tabone"> + <?php the_sub_field('tab_contents'); ?> + </div> + <?php endwhile; // (have_rows('tab_panes') ):?> + </div> +<?php endif; // (have_rows('tab_panes') ): ?> +``` + +By looping through the same repeater, we can get all the tabs out of the database, no problem. But we still have two problems: 1) linking the tab to the pane 2) Assigning the class of "active" so the Javascript is able to add and remove the CSS to reveal / hide the appropriate pane. + +### 1) Linking to the Pane + +There are a number of ways to do this. I could ask the user to input a number to uniquely identify the tab pane. But that would add extra work to the users flow, and they might easily find themselves out of their depth. I want to make this as easy as possible for the user. + +On the other hand, Wordpress has a very useful function called Sanitize HTML, which we input the value of the title, take out spaces and capitals, and use this as the link: + +```php +<a href="#<?php echo sanitize_html_class( the_sub_field( 'tab_title' ) ); ?>" +``` + +### 2) Assigning the 'Active' Class + +So now we need to get a class of 'active' _only on_ the first tab. The Bootstrap Javascript will do the rest for us. How do we do that? + +I added this code just inside the `while` loop, inside the `ul` tag: + +```php +<?php $row = 1; // number rows ?> +``` + +This php is a counter. So we can identify the first instance and assign an `if` statement to it. + +```php +<a class="<?php if($row == 1) {echo 'active';}?>"> +``` + +The final thing to do, is to keep the counter running, but adding this jsut before the `endwhile`. + +```php +<?php $row++; endwhile; // (have_rows('tab_panes') ):?> +``` + +Once you've added these to the tab panes in a similar way, you'll be up and running with Boostrap Tabs. + +Below is a Github Gist, with the complete code for reference. [Link to this (if you can't see the iFrame)](https://gist.github.com/endymion1818/478d86025f41c8060888 "Github GIST for Advanced Custom Fields bootstrap tabs"). + +<script src="https://gist.github.com/endymion1818/478d86025f41c8060888.js"></script> diff --git a/packages/astro/test/plain-markdown.test.js b/packages/astro/test/plain-markdown.test.js new file mode 100644 index 000000000..8e2f1a2ec --- /dev/null +++ b/packages/astro/test/plain-markdown.test.js @@ -0,0 +1,38 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup, setupBuild } from './helpers.js'; + +const Markdown = suite('Plain Markdown tests'); + +setup(Markdown, './fixtures/plain-markdown'); +setupBuild(Markdown, './fixtures/plain-markdown'); + +Markdown('Can load a simple markdown page with Astro', async ({ runtime }) => { + const result = await runtime.load('/post'); + + assert.equal(result.statusCode, 200); + + const $ = doc(result.contents); + + assert.equal($('p').first().text(), 'Hello world!'); + assert.equal($('#first').text(), 'Some content'); + assert.equal($('#interesting-topic').text(), 'Interesting Topic'); +}); + +Markdown('Can load a realworld markdown page with Astro', async ({ runtime }) => { + const result = await runtime.load('/realworld'); + if (result.error) throw new Error(result.error); + + assert.equal(result.statusCode, 200); + const $ = doc(result.contents); + + assert.equal($('pre').length, 7); +}); + +Markdown('Builds markdown pages for prod', async (context) => { + await context.build(); +}); + + +Markdown.run(); |