From b3886c206f550b53227facd0480a94500ab2515d Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Mon, 17 May 2021 09:29:16 -0500 Subject: [PATCH] Fix markdown issues (#208) * Init fix/markdown * Astro Markdown (#207) * Add Astro Markdown to VSCode Extension * Add Astro Markdown to Astro * refactor: update astro-markdown example * feat: remove embedded components from `.md` files * fix: resolve `.md.astro` files at runtime * chore: update markdown tests * feat: add component * chore: bump examples * chore: update example * fix: improve Markdown child handling * feat: harden markdown support, add code fence support, add automatic dedenting * chore: add weird markdown edge cases * chore: update remote-markdown examples * chore: add comment to Markdown.astro * feat: improve markdown support (codefences, nested inside HTML) * refactor: extract import specifier types to set * refactor: conditionally import markdown renderer * refactor: revert special-cased "astro/components" * refactor: revert special-cased "astro/components" * refactor: use astro/components/Markdown.astro * refactor: remove `.md.astro` support in favor of Markdown component * refactor: use regular .astro files * refactor: remove unused code * refactor: move Markdown inside Layout * wip: markdown scoped styles * feat: improve scoped styles in Markdown * feat: micromark => remark ecosystem * fix: markdown build * fix: markdown build * chore: add todo * fix: collect headers text * docs: add Markdown doc * chore: add changeset * docs: improve Markdown highlighting * refactor: prefer Set * refactor: exclude large unified deps * docs: update markdown docs Co-authored-by: Jonathan Neal * chore: remove extra markdown deps * perf: optimize markdown * fix: unified/rehype deps * temp: fix markdown test * test: add TODO comment * fix: do not namespace frontmatter, just astro metadata * test: fix astro-markdown test * test: add realworld markdown example * fix: prism language bug * docs: update markdown docs * chore: bump dependencies * fix: escape codespan * fix: unterminated string literal * fix(vscode): inline dependencies * fix(vscode): dependencies * feat(vscode): embedded markdown * feat: add Markdown syntax highlighting * chore: improve markdown example * fix: markdown example * feat: highlighting improvements * chore: add changeset * fix: CodeBlock => CodeSpan * chore: get astro-markdown example running Co-authored-by: Jonathan Neal --- .changeset/four-avocados-pretend.md | 5 + .changeset/loud-actors-develop.md | 8 + README.md | 20 +- docs/markdown.md | 120 + examples/astro-markdown/astro.config.mjs | 6 + examples/astro-markdown/package.json | 17 + .../src/components/PreactCounter.tsx | 20 + .../src/components/ReactCounter.jsx | 19 + .../src/components/SvelteCounter.svelte | 22 + .../src/components/VueCounter.vue | 27 + .../astro-markdown/src/layouts/main.astro | 13 + .../src/pages/collections.astro | 211 ++ examples/astro-markdown/src/pages/index.astro | 44 + examples/remote-markdown/astro.config.mjs | 5 + examples/remote-markdown/docs/dev.md | 48 + examples/remote-markdown/package.json | 17 + .../remote-markdown/src/components/Yell.jsx | 5 + .../remote-markdown/src/layouts/main.astro | 14 + .../remote-markdown/src/pages/index.astro | 72 + package.json | 4 +- packages/astro-parser/src/interfaces.ts | 16 +- .../astro-parser/src/parse/state/codefence.ts | 38 + .../astro-parser/src/parse/state/codespan.ts | 25 + .../astro-parser/src/parse/state/fragment.ts | 11 + packages/astro-parser/src/parse/state/tag.ts | 3 +- packages/astro-parser/src/parse/state/text.ts | 2 +- packages/astro/components/Markdown.astro | 3 + packages/astro/components/Prism.astro | 1 + packages/astro/package.json | 15 +- packages/astro/src/@types/micromark.ts | 3 + packages/astro/src/build/page.ts | 2 +- packages/astro/src/compiler/codegen/index.ts | 82 +- packages/astro/src/compiler/index.ts | 60 +- .../markdown/micromark-collect-headers.ts | 38 - .../src/compiler/markdown/micromark-encode.ts | 36 - .../compiler/markdown/micromark-mdx-astro.ts | 22 - .../src/compiler/markdown/micromark.d.ts | 16 +- .../markdown/rehype-collect-headers.ts | 30 + .../src/compiler/markdown/remark-mdx-lite.ts | 26 + .../compiler/markdown/remark-scoped-styles.ts | 18 + .../astro/src/compiler/transform/styles.ts | 63 +- packages/astro/src/compiler/utils.ts | 70 + packages/astro/src/frontend/markdown.ts | 26 + .../astro/src/frontend/render/renderer.ts | 10 +- packages/astro/src/frontend/render/utils.ts | 15 +- packages/astro/src/runtime.ts | 6 +- packages/astro/src/search.ts | 2 +- packages/astro/test/astro-markdown.test.js | 5 +- .../astro-markdown/snowpack.config.json | 3 + .../astro-markdown/src/pages/complex.astro | 20 + .../astro-markdown/src/pages/complex.md | 13 - .../astro-markdown/src/pages/post.astro | 16 + .../fixtures/plain-markdown/astro.config.mjs | 8 + .../plain-markdown/snowpack.config.json | 3 + .../plain-markdown/src/layouts/content.astro | 10 + .../src/pages/post.md | 6 +- .../plain-markdown/src/pages/realworld.md | 117 + packages/astro/test/plain-markdown.test.js | 38 + .../src/templates/blank/README.md | 2 +- .../src/templates/blank/package.json | 2 +- .../src/templates/starter/README.md | 2 +- .../src/templates/starter/package.json | 2 +- .../starter/src/components/Tour.astro | 2 +- ...astro-markdown-language-configuration.json | 45 + tools/astro-vscode/package.json | 74 +- tools/astro-vscode/src/index.ts | 4 +- .../syntaxes/astro-markdown.tmLanguage.json | 2829 +++++++++++++++++ .../syntaxes/astro.tmLanguage.json | 76 +- yarn.lock | 404 ++- 69 files changed, 4731 insertions(+), 286 deletions(-) create mode 100644 .changeset/four-avocados-pretend.md create mode 100644 .changeset/loud-actors-develop.md create mode 100644 docs/markdown.md create mode 100644 examples/astro-markdown/astro.config.mjs create mode 100644 examples/astro-markdown/package.json create mode 100644 examples/astro-markdown/src/components/PreactCounter.tsx create mode 100644 examples/astro-markdown/src/components/ReactCounter.jsx create mode 100644 examples/astro-markdown/src/components/SvelteCounter.svelte create mode 100644 examples/astro-markdown/src/components/VueCounter.vue create mode 100644 examples/astro-markdown/src/layouts/main.astro create mode 100644 examples/astro-markdown/src/pages/collections.astro create mode 100644 examples/astro-markdown/src/pages/index.astro create mode 100644 examples/remote-markdown/astro.config.mjs create mode 100644 examples/remote-markdown/docs/dev.md create mode 100644 examples/remote-markdown/package.json create mode 100644 examples/remote-markdown/src/components/Yell.jsx create mode 100644 examples/remote-markdown/src/layouts/main.astro create mode 100644 examples/remote-markdown/src/pages/index.astro create mode 100644 packages/astro-parser/src/parse/state/codefence.ts create mode 100644 packages/astro-parser/src/parse/state/codespan.ts create mode 100644 packages/astro/components/Markdown.astro delete mode 100644 packages/astro/src/compiler/markdown/micromark-collect-headers.ts delete mode 100644 packages/astro/src/compiler/markdown/micromark-encode.ts delete mode 100644 packages/astro/src/compiler/markdown/micromark-mdx-astro.ts create mode 100644 packages/astro/src/compiler/markdown/rehype-collect-headers.ts create mode 100644 packages/astro/src/compiler/markdown/remark-mdx-lite.ts create mode 100644 packages/astro/src/compiler/markdown/remark-scoped-styles.ts create mode 100644 packages/astro/src/compiler/utils.ts create mode 100644 packages/astro/src/frontend/markdown.ts create mode 100644 packages/astro/test/fixtures/astro-markdown/snowpack.config.json create mode 100644 packages/astro/test/fixtures/astro-markdown/src/pages/complex.astro delete mode 100644 packages/astro/test/fixtures/astro-markdown/src/pages/complex.md create mode 100644 packages/astro/test/fixtures/astro-markdown/src/pages/post.astro create mode 100644 packages/astro/test/fixtures/plain-markdown/astro.config.mjs create mode 100644 packages/astro/test/fixtures/plain-markdown/snowpack.config.json create mode 100644 packages/astro/test/fixtures/plain-markdown/src/layouts/content.astro rename packages/astro/test/fixtures/{astro-markdown => plain-markdown}/src/pages/post.md (73%) create mode 100644 packages/astro/test/fixtures/plain-markdown/src/pages/realworld.md create mode 100644 packages/astro/test/plain-markdown.test.js create mode 100644 tools/astro-vscode/languages/astro-markdown-language-configuration.json create mode 100644 tools/astro-vscode/syntaxes/astro-markdown.tmLanguage.json diff --git a/.changeset/four-avocados-pretend.md b/.changeset/four-avocados-pretend.md new file mode 100644 index 000000000..c949d4045 --- /dev/null +++ b/.changeset/four-avocados-pretend.md @@ -0,0 +1,5 @@ +--- +'astro-vscode': patch +--- + +Added support for new component diff --git a/.changeset/loud-actors-develop.md b/.changeset/loud-actors-develop.md new file mode 100644 index 000000000..1d10efd08 --- /dev/null +++ b/.changeset/loud-actors-develop.md @@ -0,0 +1,8 @@ +--- +'astro': minor +'astro-parser': minor +--- + +Enhanced **Markdown** support! Markdown processing has been moved from `micromark` to `remark` to prepare Astro for user-provided `remark` plugins _in the future_. + +This change also introduces a built-in `` component for embedding Markdown and any Astro-supported component format inside of `.astro` files. [Read more about Astro's Markdown support.](https://github.com/snowpackjs/astro/blob/main/docs/markdown.md) diff --git a/README.md b/README.md index 0311c26b9..c7f5ce625 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,12 @@ Even though nearly-everything [is configurable][docs-config], we recommend start #### 🚦 Routing -Routing happens in `src/pages/*`. Every `.astro` or `.md.astro` file in this folder corresponds with a public URL. For example: +Routing happens in `src/pages/*`. Every `.astro` or `.md` file in this folder corresponds with a public URL. For example: | Local file | Public URL | | :------------------------------------- | :------------------------------ | | `src/pages/index.astro` | `/index.html` | -| `src/pages/post/my-blog-post.md.astro` | `/post/my-blog-post/index.html` | +| `src/pages/post/my-blog-post.md` | `/post/my-blog-post/index.html` | #### 🗂 Static Assets @@ -76,7 +76,17 @@ Static assets should be placed in a `public/` folder in your project. You can pl #### 🪨 Generating HTML with Astro -TODO: Astro syntax guide +Astro introduces a special `.astro` format, which combines the best of HTML with the best of JavaScript. + +To learn more about `.astro` files, read our complete [Syntax Guide][docs-syntax]. + +#### ✍️ Markdown + +Spend less time configuring your tooling and more time writing content. Astro has phenomenal Markdown support (powered by [`remark`][remark]) baked in! + +Not only can you use local `.md` files as pages, but Astro also comes with a `` component to turn every page into a Markdown file. Using the `` component in an `.astro` file should feel very similar to [MDX][mdx], but with the ability to use components from any framework (with [partial hydration](#partial-hydration), too)! + +To learn more about use Markdown in Astro, read our [Markdown Guide][docs-markdown]. #### ⚡ Dynamic Components @@ -180,13 +190,17 @@ Astro will automatically create a `/sitemap.xml` for you for SEO! Be sure to set 👉 [**Dev Server Docs**][docs-dev] [docs-config]: ./docs/config.md +[docs-syntax]: ./docs/syntax.md [docs-api]: ./docs/api.md [docs-collections]: ./docs/collections.md +[docs-markdown]: ./docs/markdown.md [docs-dev]: ./docs/dev.md [docs-styling]: ./docs/styling.md [example-blog]: ./examples/blog [fetch-content]: ./docs/api.md#fetchcontent [fetch-js]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API +[remark]: https://github.com/remarkjs/remark +[mdx]: https://mdxjs.com/ [mdn-io]: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API [mdn-ric]: https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback [routing]: #-routing diff --git a/docs/markdown.md b/docs/markdown.md new file mode 100644 index 000000000..ceeb0b515 --- /dev/null +++ b/docs/markdown.md @@ -0,0 +1,120 @@ +## ✍️ Markdown + +Astro comes with out-of-the-box Markdown support powered by the expansive [**remark**](https://github.com/remarkjs/remark) ecosystem. + +## Remark Plugins + +**This is the first draft of Markdown support!** While we plan to support user-provided `remark` plugins soon, our hope is that you won't need `remark` plugins at all! + +In addition to [custom components inside the `` component](#markdown-component), Astro comes with [GitHub-flavored Markdown](https://github.github.com/gfm/) support, [Footnotes](https://github.com/remarkjs/remark-footnotes) syntax, [Smartypants](https://github.com/silvenon/remark-smartypants), and syntax highlighting via [Prism](https://prismjs.com/) pre-enabled. These features are likely to be configurable in the future. + +### Markdown Pages + +Astro treats any `.md` files inside of the `/src/pages` directory as pages. These pages are processed as plain Markdown files and do not support components. If you're looking to embed rich components in your Markdown, take a look at the [Markdown Component](#markdown-component) section. + +#### `layout` + +The only special Frontmatter key is `layout`, which defines the relative path to a `.astro` component which should wrap your Markdown content. + +`src/pages/index.md` +```md +--- +layout: ../layouts/main.astro +--- + +# Hello world! +``` + +Layout files are normal `.astro` components. Any Frontmatter defined in your `.md` page will be exposed to the Layout component as the `content` prop. `content` also has an `astro` key which holds special metadata about your file, like the complete Markdown `source` and a `headings` object. + +The rendered Markdown content is placed into the default `` element. + +`src/layouts/main.astro` +```jsx +--- +export let content; +--- + + + + {content.title} + + + + + + +``` + +### Markdown Component + +Similar to tools like [MDX](https://mdxjs.com/) or [MDsveX](https://github.com/pngwn/MDsveX), Astro makes it straightforward to embed rich, interactive components inside of your Markdown content. The `` component is statically rendered, so it does not add any runtime overhead. + +Astro exposes a special `Markdown` component for `.astro` files which enables markdown syntax for its children **recursively**. Within the `Markdown` component you may also use plain HTML or any other type of component that is supported by Astro. + +```jsx +--- +// For now, this import _must_ be named "Markdown" and _must not_ be wrapped with a custom component +// We're working on easing these restrictions! +import Markdown from 'astro/components/Markdown.astro'; +import Layout from '../layouts/main.astro'; +import MyFancyCodePreview from '../components/MyFancyCodePreview.tsx'; + +const expressions = 'Lorem ipsum'; +--- + + + + # Hello world! + + **Everything** supported in a `.md` file is also supported here! + + There is _zero_ runtime overhead. + + In addition, Astro supports: + - Astro {expressions} + - Automatic indentation normalization + - Automatic escaping of expressions inside code blocks + + ```jsx + // This content is not transformed! + const object = { someOtherValue }; + ``` + + - Rich component support like any `.astro` file! + - Recursive Markdown support (Component children are also processed as Markdown) + + + ```jsx + const object = { someOtherValue }; + ``` + + + +``` + +### Remote Markdown + +If you have Markdown in a remote source, you may pass it directly to the Markdown component. For example, the example below fetches the README from Snowpack's GitHub repository and renders it as HTML. + +```jsx +--- +import Markdown from 'astro/components/Markdown.astro'; + +const content = await fetch('https://raw.githubusercontent.com/snowpackjs/snowpack/main/README.md').then(res => res.text()); +--- + + + {content} + +``` + +### Security FAQs + +**Aren't there security concerns to rendering remote markdown directly to HTML?** + +Yes! Just like with regular HTML, improper use the `` component can open you up to a [cross-site scripting (XSS)](https://en.wikipedia.org/wiki/Cross-site_scripting) attack. If you are rendering untrusted content, be sure to _santize your content **before** rendering it_. + +**Why not use a prop like React's `dangerouslySetInnerHTML={{ __html: content }}`?** + +Rendering a string of HTML (or Markdown) is an extremely common use case when rendering a static site and you probably don't need the extra hoops to jump through. Rendering untrusted content is always dangerous! Be sure to _santize your content **before** rendering it_. diff --git a/examples/astro-markdown/astro.config.mjs b/examples/astro-markdown/astro.config.mjs new file mode 100644 index 000000000..e73531d1a --- /dev/null +++ b/examples/astro-markdown/astro.config.mjs @@ -0,0 +1,6 @@ +export default { + extensions: { + '.jsx': 'react', + '.tsx': 'preact', + } +}; diff --git a/examples/astro-markdown/package.json b/examples/astro-markdown/package.json new file mode 100644 index 000000000..2d415062d --- /dev/null +++ b/examples/astro-markdown/package.json @@ -0,0 +1,17 @@ +{ + "name": "@example/astro-markdown", + "version": "0.0.1", + "private": true, + "scripts": { + "start": "astro dev", + "build": "astro build", + "astro-dev": "nodemon --delay 0.5 -w ../../packages/astro/dist -x '../../packages/astro/astro.mjs dev'" + }, + "devDependencies": { + "astro": "0.0.13", + "nodemon": "^2.0.7" + }, + "snowpack": { + "workspaceRoot": "../.." + } +} diff --git a/examples/astro-markdown/src/components/PreactCounter.tsx b/examples/astro-markdown/src/components/PreactCounter.tsx new file mode 100644 index 000000000..e3761643f --- /dev/null +++ b/examples/astro-markdown/src/components/PreactCounter.tsx @@ -0,0 +1,20 @@ +import { h, Fragment } from 'preact'; +import { useState } from 'preact/hooks'; + +/** a counter written in Preact */ +export default function PreactCounter({ children }) { + const [count, setCount] = useState(0); + const add = () => setCount((i) => i + 1); + const subtract = () => setCount((i) => i - 1); + + return ( + <> +
+ +
{count}
+ +
+
{children}
+ + ); +} diff --git a/examples/astro-markdown/src/components/ReactCounter.jsx b/examples/astro-markdown/src/components/ReactCounter.jsx new file mode 100644 index 000000000..92871a8d8 --- /dev/null +++ b/examples/astro-markdown/src/components/ReactCounter.jsx @@ -0,0 +1,19 @@ +import React, { useState } from 'react'; + +/** a counter written in React */ +export default function ReactCounter({ children }) { + const [count, setCount] = useState(0); + const add = () => setCount((i) => i + 1); + const subtract = () => setCount((i) => i - 1); + + return ( + <> +
+ +
{count}
+ +
+
{children}
+ + ); +} diff --git a/examples/astro-markdown/src/components/SvelteCounter.svelte b/examples/astro-markdown/src/components/SvelteCounter.svelte new file mode 100644 index 000000000..8d6b3f5e1 --- /dev/null +++ b/examples/astro-markdown/src/components/SvelteCounter.svelte @@ -0,0 +1,22 @@ + + + +
+ +
{ count }
+ +
+
+ +
diff --git a/examples/astro-markdown/src/components/VueCounter.vue b/examples/astro-markdown/src/components/VueCounter.vue new file mode 100644 index 000000000..8179fb1d9 --- /dev/null +++ b/examples/astro-markdown/src/components/VueCounter.vue @@ -0,0 +1,27 @@ + + + diff --git a/examples/astro-markdown/src/layouts/main.astro b/examples/astro-markdown/src/layouts/main.astro new file mode 100644 index 000000000..bd7e96adf --- /dev/null +++ b/examples/astro-markdown/src/layouts/main.astro @@ -0,0 +1,13 @@ +--- +export let content; +--- + + + + {content.title} + + + + + + diff --git a/examples/astro-markdown/src/pages/collections.astro b/examples/astro-markdown/src/pages/collections.astro new file mode 100644 index 000000000..efdf85d5e --- /dev/null +++ b/examples/astro-markdown/src/pages/collections.astro @@ -0,0 +1,211 @@ +--- +import Markdown from 'astro/components/Markdown.astro'; +--- + + + + Collections + + +
+ + # 🍱 Collections + + ## ❓ What are Collections? + + [Fetching data is easy in Astro][docs-data]. But what if you wanted to make a paginated blog? What if you wanted an easy way to sort data, or filter data based on part of the URL? Or generate an RSS 2.0 feed? When you need something a little more powerful than simple data fetching, Astro’s Collections API may be what you need. + + An Astro Collection is similar to the general concept of Collections in static site generators like Jekyll, Hugo, Eleventy, etc. It’s a general way to load an entire data set. But one big difference between Astro Collections and traditional static site generators is: **Astro lets you seamlessly blend remote API data and local files in a JAMstack-friendly way.** To see how, this guide will walk through a few examples. If you’d like, you can reference the [blog example project][example-blog] to see the finished code in context. + + ## 🧑‍🎨 How to Use + + By default, any Astro component can fetch data from any API or local `*.md` files. But what if you had a blog you wanted to paginate? What if you wanted to generate dynamic URLs based on metadata (e.g. `/tag/:tag/`)? Or do both together? Astro Collections are a way to do all of that. It’s perfect for generating blog-like content, or scaffolding out dynamic URLs from your data. + + Let’s pretend we have some blog posts written already. This is our starting project structure: + + ``` + └── src/ + └── pages/ + └── post/ + └── (blog content) + ``` + + The first step in adding some dynamic collections is deciding on a URL schema. For our example website, we’re aiming for the following URLs: + + - `/post/:post`: A single blog post page + - `/posts/:page`: A list page of all blog posts, paginated, and sorted most recent first + - `/tag/:tag`: All blog posts, filtered by a specific tag + + Because `/post/:post` references the static files we have already, that doesn’t need to be a collection. But we will need collections for `/posts/:page` and `/tag/:tag` because those will be dynamically generated. For both collections we’ll create a `/src/pages/$[collection].astro` file. This is our new structure: + + ```diff + └── src/ + └── pages/ + ├── post/ + │ └── (blog content) + + ├── $posts.astro -> /posts/1, /posts/2, … + + └── $tag.astro -> /tag/:tag/1, /tag/:tag/2, … + ``` + + 💁‍ **Tip**: Any `.astro` filename beginning with a `$` is how it’s marked as a collection. + + In each `$[collection].astro` file, we’ll need 2 things: + + ```js + // 1. We need to mark “collection” as a prop (this is a special reserved name) + export let collection: any; + + // 2. We need to export an async createCollection() function that will retrieve our data. + export async function createCollection() { + return { + async data() { + // return data here to load (we’ll cover how later) + }, + }; + } + ``` + + These are important so your data is exposed to the page as a prop, and also Astro has everything it needs to gather your data and generate the proper routes. How it does this is more clear if we walk through a practical example. + + #### Example 1: Simple pagination + + Our blog posts all contain `title`, `tags`, and `published_at` in their frontmatter: + + ```md + --- + title: My Blog Post + tags: + - javascript + published_at: 2021-03-01 09:34:00 + --- + + # My Blog post + + … + ``` + + There’s nothing special or reserved about any of these names; you’re free to name everything whatever you’d like, or have as much or little frontmatter as you need. + + ```jsx + // /src/pages/$posts.astro + --- + export let collection: any; + + export async function createCollection() { + const allPosts = Astro.fetchContent('./post/*.md'); // load data that already lives at `/post/:slug` + allPosts.sort((a, b) => new Date(b.published_at) - new Date(a.published_at)); // sort newest -> oldest (we got "published_at" from frontmatter!) + + // (load more data here, if needed) + + return { + async data() { + return allPosts; + }, + pageSize: 10, // how many we want to show per-page (default: 25) + }; + } + + function formatDate(date) { + return new Date(date).toUTCString(); + } + --- + + + + Blog Posts: page {collection.page.current} + + + + + +
+
Results {collection.start + 1}–{collection.end + 1} of {collection.total}
+ {collection.data.map((post) => ( +

{post.title}

+ + Read + )} +
+
+

Page {collection.page.current} / {collection.page.last}

+ +
+ + + ``` + + Let’s walk through some of the key parts: + + - `export let collection`: this is important because it exposes a prop to the page for Astro to return with all your data loaded. ⚠️ **It must be named `collection`**. + - `export async function createCollection()`: this is also required, **and must be named this exactly.** This is an async function that lets you load data from anywhere (even a remote API!). At the end, you must return an object with `{ data: yourData }`. There are other options such as `pageSize` we’ll cover later. + - `{collection.data.map((post) => (…`: this lets us iterate over all the markdown posts. This will take the shape of whatever you loaded in `createCollection()`. It will always be an array. + - `{collection.page.current}`: this, and other properties, simply return more info such as what page a user is on, what the URL is, etc. etc. + - Curious about everything on `collection`? See the [reference][collection-api]. + + #### Example 2: Advanced filtering & pagination + + In our earlier example, we covered simple pagination for `/posts/1`, but we’d still like to make `/tag/:tag/1` and `/year/:year/1`. To do that, we’ll create 2 more collections: `/src/pages/$tag.astro` and `src/pages/$year.astro`. Assume that the markup is the same, but we’ve expanded the `createCollection()` function with more data. + + ```diff + // /src/pages/$tag.astro + --- + import Pagination from '../components/Pagination.astro'; + import PostPreview from '../components/PostPreview.astro'; + + export let collection: any; + + export async function createCollection() { + const allPosts = Astro.fetchContent('./post/*.md'); + allPosts.sort((a, b) => new Date(b.published_at) - new Date(a.published_at)); + + const allTags = [...new Set(allPosts.map((post) => post.tags).flat())]; // gather all unique tags (we got "tags" from frontmatter!) + + allTags.sort((a, b) => a.localeCompare(b)); // sort tags A -> Z + + const routes = allTags.map((tag) => ({ tag })); // this is where we set { params: { tag } } + + return { + - async data() { + - return allPosts; + + async data({ params }) { + + return allPosts.filter((post) => post.tags.includes(params.tag)); // filter posts that match the :tag from the URL ("params") + }, + pageSize: 10, + + routes, + + permalink: ({ params }) => `/tag/${params.tag}/` // this is where we generate our URL structure + }; + } + --- + ``` + + Some important concepts here: + + - `routes = allTags.map((tag) => ({ tag }))`: Astro handles pagination for you automatically. But when it needs to generate multiple routes, this is where you tell Astro about all the possible routes. This way, when you run `astro build`, your static build isn’t missing any pages. + - `` permalink: ({ params }) => `/tag/${params.tag}/` ``: this is where you tell Astro what the generated URL should be. Note that while you have control over this, the root of this must match the filename (it's best **NOT** to use `/pages/$tag.astro` to generate `/year/$year.astro`; that should live at `/pages/$year.astro` as a separate file). + - `allPosts.filter((post) => post.tag === params.tag)`: we aren’t returning all posts here; we’re only returning posts with a matching tag. _What tag,_ you ask? The `routes` array has `[{ tag: 'javascript' }, { tag: '…`, and all the routes we need to gather. So we first need to query everything, but only return the `.filter()`ed posts at the very end. + + Other things of note is that we are sorting like before, but we filter by the frontmatter `tag` property, and return those at URLs. + + These are still paginated, too! But since there are other conditions applied, they live at a different URL. + + #### Tips + + - Having to load different collections in different `$[collection].astro` files might seem like a pain at first, until you remember **you can create reusable components!** Treat `/pages/*.astro` files as your one-off routing & data fetching logic, and treat `/components/*.astro` as your reusable markup. If you find yourself duplicating things too much, you can probably use a component instead! + - Stay true to `/pages/$[collection].astro` naming. If you have an `/all-posts/*` route, then use `/pages/$all-posts.astro` to manage that. Don’t try and trick `permalink` to generate too many URL trees; it’ll only result in pages being missed when it comes time to build. + + ### 📚 Further Reading + + - [Fetching data in Astro][docs-data] + - API Reference: [collection][collection-api] + - API Reference: [createCollection()][create-collection-api] + - API Reference: [Creating an RSS feed][create-collection-api] + + [docs-data]: ../README.md#-fetching-data + [collection-api]: ./api.md#collection + [create-collection-api]: ./api.md#createcollection + [example-blog]: ../examples/blog + [fetch-content]: ./api.md#fetchcontent +
+
+ + diff --git a/examples/astro-markdown/src/pages/index.astro b/examples/astro-markdown/src/pages/index.astro new file mode 100644 index 000000000..cbff28b5c --- /dev/null +++ b/examples/astro-markdown/src/pages/index.astro @@ -0,0 +1,44 @@ +--- +import Markdown from 'astro/components/Markdown.astro'; +import Layout from '../layouts/main.astro'; +import ReactCounter from '../components/ReactCounter.jsx'; +import PreactCounter from '../components/PreactCounter.tsx'; +import VueCounter from '../components/VueCounter.vue'; +import SvelteCounter from '../components/SvelteCounter.svelte'; + +const title = 'Astro Markdown'; +const variable = 'content'; +const items = ['A', 'B', 'C']; +--- + + + + # Introducing {title} + + **Astro Markdown** brings native Markdown support to HTML! + + > It's inspired by [`mdx`](https://mdxjs.com/) and powered by [`remark`](https://github.com/remarkjs/remark)). + + The best part? It comes with all the Astro features you expect. + + ## Embed framework components + + + + + + + ## Use Expressions + + You can use any {variable} in scope and use JavaScript for templating ({items.join(', ')}) + + ## Oh yeah... + + + 🤯 It's also _recursive_! + + ### Markdown can be embedded in any child component + + + + diff --git a/examples/remote-markdown/astro.config.mjs b/examples/remote-markdown/astro.config.mjs new file mode 100644 index 000000000..c7bfe91b0 --- /dev/null +++ b/examples/remote-markdown/astro.config.mjs @@ -0,0 +1,5 @@ +export default { + extensions: { + '.jsx': 'preact' + } +} diff --git a/examples/remote-markdown/docs/dev.md b/examples/remote-markdown/docs/dev.md new file mode 100644 index 000000000..d9223cbbd --- /dev/null +++ b/examples/remote-markdown/docs/dev.md @@ -0,0 +1,48 @@ +# Development Server + +The development server comes as part of the Astro CLI. Start the server with: + +```shell +astro dev +``` + +In your project root. You can specify an alternative + +## Special routes + +The dev server will serve the following special routes: + +### /400 + +This is a custom **400** status code page. You can add this route by adding a page component to your `src/pages` folder: + +``` +├── src/ +│ ├── components/ +│ └── pages/ +│ └── 400.astro +``` + +For any URL you visit that doesn't have a corresponding page, the `400.astro` file will be used. + +### /500 + +This is a custom **500** status code page. You can add this route by adding a page component to your `src/pages` folder: + +```astro +├── src/ │ ├── components/ │ └── pages/ │ └── 500.astro +``` + +This page is used any time an error occurs in the dev server. + +The 500 page will receive an `error` query parameter which you can access with: + +``` +--- +const error = Astro.request.url.searchParams.get('error'); +--- + +{error} +``` + +A default error page is included with Astro so you will get pretty error messages even without adding a custom 500 page. diff --git a/examples/remote-markdown/package.json b/examples/remote-markdown/package.json new file mode 100644 index 000000000..dc20a1a66 --- /dev/null +++ b/examples/remote-markdown/package.json @@ -0,0 +1,17 @@ +{ + "name": "@example/remote-markdown", + "version": "0.0.1", + "private": true, + "scripts": { + "start": "astro dev", + "build": "astro build", + "astro-dev": "nodemon --delay 0.5 -w ../../packages/astro/dist -x '../../packages/astro/astro.mjs dev'" + }, + "devDependencies": { + "astro": "0.0.13", + "nodemon": "^2.0.7" + }, + "snowpack": { + "workspaceRoot": "../.." + } +} diff --git a/examples/remote-markdown/src/components/Yell.jsx b/examples/remote-markdown/src/components/Yell.jsx new file mode 100644 index 000000000..ae7d0d959 --- /dev/null +++ b/examples/remote-markdown/src/components/Yell.jsx @@ -0,0 +1,5 @@ +import { h, Fragment } from 'preact'; + +export default function Yell({ children }) { + return children.filter(v => typeof v === 'string').join('').toUpperCase() + '!' +} diff --git a/examples/remote-markdown/src/layouts/main.astro b/examples/remote-markdown/src/layouts/main.astro new file mode 100644 index 000000000..37fcc0ee7 --- /dev/null +++ b/examples/remote-markdown/src/layouts/main.astro @@ -0,0 +1,14 @@ +--- +export let content; +--- + + + + {content.title} + + + + +
{JSON.stringify(content)}
+ + diff --git a/examples/remote-markdown/src/pages/index.astro b/examples/remote-markdown/src/pages/index.astro new file mode 100644 index 000000000..402780065 --- /dev/null +++ b/examples/remote-markdown/src/pages/index.astro @@ -0,0 +1,72 @@ +--- +import Markdown from 'astro/components/Markdown.astro'; +import Yell from '../components/Yell.jsx'; +const title = 'INTERPOLATED'; +const quietTest = 'interpolated'; +const content = await fetch('https://raw.githubusercontent.com/snowpackjs/snowpack/main/README.md').then(res => res.text()); +--- + + + +# Hello world! + + + + + # Hello indent! + + + + +# Hello {title}! + + + + + + # I cannot! + +
+ # ahhhh +
+ + {quietTest} + + Dope + + `nice` + + ``` + plain fence + ``` + + ```html + don't
me
bro + ``` + + ```js + Astro.fetchContent() + ``` + + ### cool stuff? + ```astro + {'can\'t interpolate'} + {} + {title} + + Do I break? + ``` +
+ + +{content} + + + + {content} + + + + + {content} + diff --git a/package.json b/package.json index 889d9791e..61db72441 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "release": "yarn build && yarn changeset publish", "build": "yarn build:core", "build:core": "lerna run build --scope astro --scope astro-parser --scope create-astro", - "build:vscode": "lerna run build --scope astro-languageserver --scope astro-vscode", - "dev:vscode": "lerna run dev --scope astro-languageserver --scope astro-vscode --parallel --stream", + "build:vscode": "lerna run build --scope astro-languageserver --scope astro-vscode --scope astro-parser", + "dev:vscode": "lerna run dev --scope astro-languageserver --scope astro-vscode --scope astro-parser --parallel --stream", "format": "prettier -w '**/*.{js,jsx,ts,tsx,md,json}'", "lint": "eslint 'packages/**/*.ts'", "test": "yarn test:core && yarn test:prettier", diff --git a/packages/astro-parser/src/interfaces.ts b/packages/astro-parser/src/interfaces.ts index efe971941..40eeb04f5 100644 --- a/packages/astro-parser/src/interfaces.ts +++ b/packages/astro-parser/src/interfaces.ts @@ -20,6 +20,20 @@ export interface Text extends BaseNode { raw: string; } +export interface CodeFence extends BaseNode { + type: 'CodeFence'; + metadata: string; + data: string; + raw: string; +} + +export interface CodeSpan extends BaseNode { + type: 'CodeFence'; + metadata: string; + data: string; + raw: string; +} + export interface Attribute extends BaseNode { type: 'Attribute'; name: string; @@ -48,7 +62,7 @@ export interface Transition extends BaseDirective { export type Directive = BaseDirective | Transition; -export type TemplateNode = Text | MustacheTag | BaseNode | Directive | Transition; +export type TemplateNode = Text | CodeSpan | CodeFence | MustacheTag | BaseNode | Directive | Transition; export interface Expression { type: 'Expression'; diff --git a/packages/astro-parser/src/parse/state/codefence.ts b/packages/astro-parser/src/parse/state/codefence.ts new file mode 100644 index 000000000..d5b498a0f --- /dev/null +++ b/packages/astro-parser/src/parse/state/codefence.ts @@ -0,0 +1,38 @@ +// @ts-nocheck +import { Parser } from '../index.js'; + +export default function codefence(parser: Parser) { + const start = parser.index; + const open = parser.match_regex(/[`~]{3,}/); + parser.index += open!.length; + + let raw = open + ''; + + while (parser.index < parser.template.length && !parser.match(open)) { + raw += parser.template[parser.index++]; + } + + parser.eat(open, true); + raw += open; + const trailingWhitespace = parser.read_until(/\S/); + const { metadata, data } = extractCodeFence(raw); + + const node = { + start, + end: parser.index, + type: 'CodeFence', + raw: `${raw}` + trailingWhitespace, + metadata, + data + }; + + parser.current().children.push(node); +} + +/** Extract attributes on first line */ +function extractCodeFence(str: string) { + const [_, leadingLine] = str.match(/(^[^\n]*\r?\n)/m) ?? ['', '']; + const metadata = leadingLine.trim(); + const data = str.slice(leadingLine.length); + return { metadata, data }; +} diff --git a/packages/astro-parser/src/parse/state/codespan.ts b/packages/astro-parser/src/parse/state/codespan.ts new file mode 100644 index 000000000..b685800a7 --- /dev/null +++ b/packages/astro-parser/src/parse/state/codespan.ts @@ -0,0 +1,25 @@ +// @ts-nocheck +import { Parser } from '../index.js'; + +export default function codespan(parser: Parser) { + const start = parser.index; + const open = parser.match_regex(/(? boolean): TemplateNode[] { +export function read_sequence(parser: Parser, done: () => boolean): TemplateNode[] { let current_chunk: Text = { start: parser.index, end: null, diff --git a/packages/astro-parser/src/parse/state/text.ts b/packages/astro-parser/src/parse/state/text.ts index cca83f2d4..eac810a0a 100644 --- a/packages/astro-parser/src/parse/state/text.ts +++ b/packages/astro-parser/src/parse/state/text.ts @@ -8,7 +8,7 @@ export default function text(parser: Parser) { let data = ''; - while (parser.index < parser.template.length && !parser.match('---') && !parser.match('<') && !parser.match('{')) { + while (parser.index < parser.template.length && !parser.match('---') && !parser.match('<') && !parser.match('{') && !parser.match('`')) { data += parser.template[parser.index++]; } 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 @@ + + + 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; dynamicImports: DynamicImportMap; } @@ -318,6 +321,7 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp const componentExports: ExportNamedDeclaration[] = []; const contentImports = new Map(); + 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`; + 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` + } + 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 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}`); - } - 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 , + // but not if we're inside of another component in + 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 { - 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" - const contentData: any = { - ...frontmatterData, - headers, - source: content, - }; - - let imports = ''; - for (let [ComponentName, specifier] of Object.entries(frontmatterData.import || {})) { - imports += `import ${ComponentName} from '${specifier}';\n`; +export async function convertMdToAstroSource(contents: string, { filename }: { filename: string }): Promise { + 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 = { + ...frontmatter, + ...data + }; // 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, ``); 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}; --- -
${mdHtml}
`; +${content}`; } /** @@ -95,24 +75,24 @@ async function convertMdToJsx( contents: string, { compileOptions, filename, fileID }: { compileOptions: CompileOptions; filename: string; fileID: string } ): Promise { - 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 { 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 { 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(``); - this.raw(currentHeader.text); - this.tag(``); - }, - } 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'; - - export default function (): HtmlExtension; +declare module 'mdast-util-mdx/from-markdown.js' { + export default function (): any; +} + +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