summaryrefslogtreecommitdiff
path: root/packages/astro
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro')
-rw-r--r--packages/astro/components/Markdown.astro3
-rw-r--r--packages/astro/components/Prism.astro1
-rw-r--r--packages/astro/package.json15
-rw-r--r--packages/astro/src/@types/micromark.ts3
-rw-r--r--packages/astro/src/build/page.ts2
-rw-r--r--packages/astro/src/compiler/codegen/index.ts80
-rw-r--r--packages/astro/src/compiler/index.ts58
-rw-r--r--packages/astro/src/compiler/markdown/micromark-collect-headers.ts38
-rw-r--r--packages/astro/src/compiler/markdown/micromark-encode.ts36
-rw-r--r--packages/astro/src/compiler/markdown/micromark-mdx-astro.ts22
-rw-r--r--packages/astro/src/compiler/markdown/micromark.d.ts14
-rw-r--r--packages/astro/src/compiler/markdown/rehype-collect-headers.ts30
-rw-r--r--packages/astro/src/compiler/markdown/remark-mdx-lite.ts26
-rw-r--r--packages/astro/src/compiler/markdown/remark-scoped-styles.ts18
-rw-r--r--packages/astro/src/compiler/transform/styles.ts63
-rw-r--r--packages/astro/src/compiler/utils.ts70
-rw-r--r--packages/astro/src/frontend/markdown.ts26
-rw-r--r--packages/astro/src/frontend/render/renderer.ts10
-rw-r--r--packages/astro/src/frontend/render/utils.ts15
-rw-r--r--packages/astro/src/runtime.ts6
-rw-r--r--packages/astro/src/search.ts2
-rw-r--r--packages/astro/test/astro-markdown.test.js5
-rw-r--r--packages/astro/test/fixtures/astro-markdown/snowpack.config.json3
-rw-r--r--packages/astro/test/fixtures/astro-markdown/src/pages/complex.astro20
-rw-r--r--packages/astro/test/fixtures/astro-markdown/src/pages/complex.md13
-rw-r--r--packages/astro/test/fixtures/astro-markdown/src/pages/post.astro16
-rw-r--r--packages/astro/test/fixtures/plain-markdown/astro.config.mjs8
-rw-r--r--packages/astro/test/fixtures/plain-markdown/snowpack.config.json3
-rw-r--r--packages/astro/test/fixtures/plain-markdown/src/layouts/content.astro10
-rw-r--r--packages/astro/test/fixtures/plain-markdown/src/pages/post.md (renamed from packages/astro/test/fixtures/astro-markdown/src/pages/post.md)6
-rw-r--r--packages/astro/test/fixtures/plain-markdown/src/pages/realworld.md117
-rw-r--r--packages/astro/test/plain-markdown.test.js38
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();