diff options
Diffstat (limited to 'packages/astro')
134 files changed, 6348 insertions, 0 deletions
diff --git a/packages/astro/README.md b/packages/astro/README.md new file mode 100644 index 000000000..a89ed9a70 --- /dev/null +++ b/packages/astro/README.md @@ -0,0 +1,194 @@ + + +**Astro** is a _fresh but familiar_ approach to building websites. Astro combines decades of proven performance best practices with the DX improvements of the component-oriented era. + +With Astro, you can use your favorite JavaScript framework and automatically ship the bare-minimum amount of JavaScript—by default, it's none at all! + +## 🔧 Setup + +```bash +# currently "hidden" during private beta +npm init astro@shhhhh ./my-astro-project + +# then... cd => install => start +cd ./my-astro-project +npm install +npm start +``` + +### 🚀 Build & Deployment + +The default Astro project has the following `scripts` in the `/package.json` file: + +```json +{ + "scripts": { + "start": "astro dev .", + "build": "astro build ." + } +} +``` + +For local development, run: + +``` +npm run start +``` + +To build for production, run the following command: + +``` +npm run build +``` + +To deploy your Astro site to production, upload the contents of `/dist` to your favorite static site host. + + +## 🥾 Guides + +### 🚀 Basic Usage + +Even though nearly-everything [is configurable][docs-config], we recommend starting out by creating an `src/` folder in your project with the following structure: + +``` +├── src/ +│ ├── components/ +│ └── pages/ +│ └── index.astro +├── public/ +└── package.json +``` + +- `src/components/*`: where your reusable components go. You can place these anywhere, but we recommend a single folder to keep them organized. +- `src/pages/*`: this is a special folder where your [routing][routing] lives. + +#### 🚦 Routing + +Routing happens in `src/pages/*`. Every `.astro` or `.md.astro` 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` | + +#### 🗂 Static Assets + +Static assets should be placed in a `public/` folder in your project. You can place any images, fonts, files, or global CSS in here you need to reference. + +#### 🪨 Generating HTML with Astro + +TODO: Astro syntax guide + +#### ⚡ Dynamic Components + +TODO: Astro dynamic components guide + +### 💧 Partial Hydration + +By default, Astro outputs zero client-side JS. If you'd like to include an interactive component in the client output, you may use any of the following techniques. + +- `<MyComponent />` will render an HTML-only version of `MyComponent` (default) +- `<MyComponent:load />` will render `MyComponent` on page load +- `<MyComponent:idle />` will use [requestIdleCallback()][mdn-ric] to render `MyComponent` as soon as main thread is free +- `<MyComponent:visible />` will use an [IntersectionObserver][mdn-io] to render `MyComponent` when the element enters the viewport + +### ⚛️ State Management + +Frontend state management depends on your framework of choice. Below is a list of popular frontend state management libraries, and their current support with Astro. + +Our goal is to support all popular state management libraries, as long as there is no technical reason that we cannot. + +- **React/Preact** + - [ ] **Redux: Partial Support** (Note: You can access a Redux store directly, but full `react-redux` support requires the ability to set a custom `<Provider>` wrapper to every component island. Planned.) + - [x] **Recoil: Full Support** +- **Svelte** + - [x] **Svelte Stores: Full Support** +- **Vue:** + - [ ] **Vuex: Partial Support** (Note: You can access a vuex store directly, but full `vuex` support requires the ability to set a custom `vue.use(store)` call to every component island. Planned.) + +_Are we missing your favorite state management library? Add it to the list above in a PR (or create an issue)!_ + +### 💅 Styling + +Styling in Astro is meant to be as flexible as you’d like it to be! The following options are all supported: + +| Framework | Global CSS | Scoped CSS | CSS Modules | +| :--------------- | :--------: | :--------: | :---------: | +| Astro (`.astro`) | ✅ | ✅ | N/A¹ | +| React / Preact | ✅ | ❌ | ✅ | +| Vue | ✅ | ✅ | ✅ | +| Svelte | ✅ | ✅ | ❌ | + +¹ _`.astro` files have no runtime, therefore Scoped CSS takes the place of CSS Modules (styles are still scoped to components, but don’t need dynamic values)_ + +To learn more about writing styles in Astro, see our [Styling Guide][docs-styling]. + +👉 [**Styling**][docs-styling] + +### 🐶 Fetching Data + +Fetching data is what Astro is all about! Whether your data lives remotely in an API or in your local project, Astro has got you covered. + +For fetching from a remote API, use a native JavaScript `fetch()` ([docs][fetch-js]) as you are used to. For fetching local content, use `Astro.fetchContent()` ([docs][fetch-content]). + +```js +// src/components/MyComponent.Astro + +--- +// Example 1: fetch remote data from your own API +const remoteData = await fetch('https://api.mysite.com/v1/people').then((res) => res.json()); + +// Example 2: load local markdown files +const localData = Astro.fetchContent('../post/*.md'); +--- +``` + +### 🗺️ Sitemap + +Astro will automatically create a `/sitemap.xml` for you for SEO! Be sure to set `buildOptions.site` in your [Astro config][docs-config] so the URLs can be generated properly. + +⚠️ Note that Astro won’t inject this into your HTML for you! You’ll have to add the tag yourself in your `<head>` on all pages that need it: + +```html +<link rel="sitemap" href="/sitemap.xml" /> +``` + +##### Examples + +- [Blog Example][example-blog] +- TODO: Headless CMS Example + +### 🍱 Collections (beta) + +[Fetching data is easy in Astro](#-fetching-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. + +👉 [**Collections API**][docs-collections] + + +## ⚙️ Config + +👉 [**`astro.config.mjs` Reference**][docs-config] +## 📚 API + +👉 [**Full API Reference**][docs-api] + +## 👩🏽💻 CLI + +👉 [**Command Line Docs**][docs-cli] + +## 🏗 Development Server + +👉 [**Dev Server Docs**][docs-dev] + +[docs-config]: ./docs/config.md +[docs-api]: ./docs/api.md +[docs-collections]: ./docs/collections.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 +[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 +[docs-cli]: ./docs/cli.md diff --git a/packages/astro/astro.mjs b/packages/astro/astro.mjs new file mode 100755 index 000000000..5cc56e9ef --- /dev/null +++ b/packages/astro/astro.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { cli } from './dist/cli.js'; + +cli(process.argv); diff --git a/packages/astro/components/Prism.astro b/packages/astro/components/Prism.astro new file mode 100644 index 000000000..a1d504882 --- /dev/null +++ b/packages/astro/components/Prism.astro @@ -0,0 +1,42 @@ +--- +import Prism from 'prismjs'; +import { addAstro } from '../astro-prism/index.mjs'; +import * as loadLanguages from 'prismjs/components/index.js'; + +export let lang; +export let code; + +const languageMap = new Map([ + ['ts', 'typescript'] +]); + +if(lang == null) { + console.warn('Prism.astro: No language provided.'); +} + +const ensureLoaded = lang => { + if(!Prism.languages[lang]) { + loadLanguages([lang]); + } +}; + +if(languageMap.has(lang)) { + ensureLoaded(languageMap.get(lang)); +} else if(lang === 'astro') { + ensureLoaded('typescript'); + addAstro(Prism); +} else { + ensureLoaded(lang); +} + +if(!Prism.languages[lang]) { + console.warn(`Unable to load the language: ${lang}`); +} + +const grammar = Prism.languages[lang]; +let html = Prism.highlight(code, grammar, lang); + +let className = `language-${lang}`; +--- + +<pre class={className}><code class={className}>{html}</code></pre>
\ No newline at end of file diff --git a/packages/astro/package.json b/packages/astro/package.json new file mode 100644 index 000000000..513e50dc5 --- /dev/null +++ b/packages/astro/package.json @@ -0,0 +1,111 @@ +{ + "name": "astro", + "version": "0.0.9", + "author": "Skypack", + "license": "MIT", + "type": "module", + "exports": { + ".": "./astro.mjs", + "./snowpack-plugin": "./snowpack-plugin.cjs", + "./components/*.astro": "./components/*.astro", + "./runtime/svelte": "./runtime/svelte.js" + }, + "bin": { + "astro": "astro.mjs" + }, + "files": [ + "components", + "lib", + "runtime", + "astro-prism", + "snowpack-plugin.cjs", + "astro.mjs" + ], + "scripts": { + "build": "astro-scripts build 'src/**/*.ts' && tsc -p tsconfig.json", + "postbuild": "astro-scripts copy 'src/**/*.astro'", + "dev": "astro-scripts dev 'src/**/*.ts'", + "test": "uvu test -i fixtures -i test-utils.js" + }, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@babel/generator": "^7.13.9", + "@babel/parser": "^7.13.15", + "@babel/traverse": "^7.13.15", + "@snowpack/plugin-sass": "^1.4.0", + "@snowpack/plugin-svelte": "^3.6.1", + "@snowpack/plugin-vue": "^2.4.0", + "@vue/server-renderer": "^3.0.10", + "acorn": "^7.4.0", + "astro-parser": "0.0.9", + "autoprefixer": "^10.2.5", + "cheerio": "^1.0.0-rc.5", + "es-module-lexer": "^0.4.1", + "esbuild": "^0.10.1", + "estree-walker": "^3.0.0", + "fast-xml-parser": "^3.19.0", + "fdir": "^5.0.0", + "find-up": "^5.0.0", + "github-slugger": "^1.3.0", + "gray-matter": "^4.0.2", + "hast-to-hyperscript": "~9.0.0", + "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", + "moize": "^6.0.1", + "node-fetch": "^2.6.1", + "picomatch": "^2.2.3", + "postcss": "^8.2.8", + "postcss-icss-keyframes": "^0.2.1", + "preact": "^10.5.13", + "preact-render-to-string": "^5.1.18", + "prismjs": "^1.23.0", + "react": "^17.0.1", + "react-dom": "^17.0.1", + "rehype-parse": "^7.0.1", + "rollup": "^2.43.1", + "rollup-plugin-terser": "^7.0.2", + "sass": "^1.32.8", + "snowpack": "^3.3.7", + "svelte": "^3.35.0", + "tiny-glob": "^0.2.8", + "unified": "^9.2.1", + "vue": "^3.0.10", + "yargs-parser": "^20.2.7" + }, + "devDependencies": { + "astro-scripts": "0.0.1", + "@babel/types": "^7.13.14", + "@types/babel__code-frame": "^7.0.2", + "@types/babel__generator": "^7.6.2", + "@types/babel__parser": "^7.1.1", + "@types/babel__traverse": "^7.11.1", + "@types/estree": "0.0.46", + "@types/github-slugger": "^1.3.0", + "@types/node": "^14.14.31", + "@types/react": "^17.0.3", + "@types/react-dom": "^17.0.2", + "@types/sass": "^1.16.0", + "@types/yargs-parser": "^20.2.0", + "@typescript-eslint/eslint-plugin": "^4.18.0", + "@typescript-eslint/parser": "^4.18.0", + "concurrently": "^6.0.0", + "del": "^6.0.0", + "domhandler": "^4.1.0", + "eslint": "^7.22.0", + "eslint-config-prettier": "^8.1.0", + "eslint-plugin-prettier": "^3.3.1", + "execa": "^5.0.0", + "prettier": "^2.2.1", + "typescript": "^4.2.3", + "uvu": "^0.5.1" + }, + "engines": { + "node": ">=14.0.0", + "npm": ">=6.14.0" + } +} diff --git a/packages/astro/runtime/svelte.mjs b/packages/astro/runtime/svelte.mjs new file mode 100644 index 000000000..f23e8eb1d --- /dev/null +++ b/packages/astro/runtime/svelte.mjs @@ -0,0 +1,4 @@ +import SvelteRuntime from '../lib/frontend/runtime/svelte.js'; +Function.prototype(SvelteRuntime); + +export default SvelteRuntime; diff --git a/packages/astro/snowpack-plugin.cjs b/packages/astro/snowpack-plugin.cjs new file mode 100644 index 000000000..8a17c3ec1 --- /dev/null +++ b/packages/astro/snowpack-plugin.cjs @@ -0,0 +1,31 @@ +const { readFile } = require('fs').promises; + +// Snowpack plugins must be CommonJS :( +const transformPromise = import('./dist/compiler/index.js'); + +module.exports = function (snowpackConfig, { resolvePackageUrl, extensions, astroConfig } = {}) { + return { + name: 'snowpack-astro', + knownEntrypoints: [], + resolve: { + input: ['.astro', '.md'], + output: ['.js', '.css'], + }, + async load({ filePath }) { + const { compileComponent } = await transformPromise; + const projectRoot = snowpackConfig.root; + const contents = await readFile(filePath, 'utf-8'); + const compileOptions = { + astroConfig, + resolvePackageUrl, + extensions, + }; + const result = await compileComponent(contents, { compileOptions, filename: filePath, projectRoot }); + const output = { + '.js': result.contents, + }; + if (result.css) output['.css'] = result.css; + return output; + }, + }; +}; diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts new file mode 100644 index 000000000..049105970 --- /dev/null +++ b/packages/astro/src/@types/astro.ts @@ -0,0 +1,132 @@ +export interface AstroConfigRaw { + dist: string; + projectRoot: string; + astroRoot: string; + public: string; + jsx?: string; +} + +export type ValidExtensionPlugins = 'astro' | 'react' | 'preact' | 'svelte' | 'vue'; + +export interface AstroConfig { + dist: string; + projectRoot: URL; + astroRoot: URL; + public: URL; + extensions?: Record<string, ValidExtensionPlugins>; + /** Options specific to `astro build` */ + buildOptions: { + /** Your public domain, e.g.: https://my-site.dev/. Used to generate sitemaps and canonical URLs. */ + site?: string; + /** Generate sitemap (set to "false" to disable) */ + sitemap: boolean; + }; + /** Options for the development server run with `astro dev`. */ + devOptions: { + /** The port to run the dev server on. */ + port: number; + projectRoot?: string; + }; +} + +export type AstroUserConfig = Omit<AstroConfig, 'buildOptions' | 'devOptions'> & { + buildOptions: { + sitemap: boolean; + }; + devOptions: { + port?: number; + projectRoot?: string; + }; +}; + +export interface JsxItem { + name: string; + jsx: string; +} + +export interface TransformResult { + script: string; + imports: string[]; + html: string; + css?: string; + /** If this page exports a collection, the JS to be executed as a string */ + createCollection?: string; +} + +export interface CompileResult { + result: TransformResult; + contents: string; + css?: string; +} + +export type RuntimeMode = 'development' | 'production'; + +export type Params = Record<string, string | number>; + +export interface CreateCollection<T = any> { + data: ({ params }: { params: Params }) => T[]; + routes?: Params[]; + /** tool for generating current page URL */ + permalink?: ({ params }: { params: Params }) => string; + /** page size */ + pageSize?: number; + /** Generate RSS feed from data() */ + rss?: CollectionRSS<T>; +} + +export interface CollectionRSS<T = any> { + /** (required) Title of the RSS Feed */ + title: string; + /** (required) Description of the RSS Feed */ + description: string; + /** Specify arbitrary metadata on opening <xml> tag */ + xmlns?: Record<string, string>; + /** Specify custom data in opening of file */ + customData?: string; + /** Return data about each item */ + item: ( + item: T + ) => { + /** (required) Title of item */ + title: string; + /** (required) Link to item */ + link: string; + /** Publication date of item */ + pubDate?: Date; + /** Item description */ + description?: string; + /** Append some other XML-valid data to this item */ + customData?: string; + }; +} + +export interface CollectionResult<T = any> { + /** result */ + data: T[]; + + /** metadata */ + /** the count of the first item on the page, starting from 0 */ + start: number; + /** the count of the last item on the page, starting from 0 */ + end: number; + /** total number of results */ + total: number; + page: { + /** the current page number, starting from 1 */ + current: number; + /** number of items per page (default: 25) */ + size: number; + /** number of last page */ + last: number; + }; + url: { + /** url of the current page */ + current: string; + /** url of the previous page (if there is one) */ + prev?: string; + /** url of the next page (if there is one) */ + next?: string; + }; + /** Matched parameters, if any */ + params: Params; +} diff --git a/packages/astro/src/@types/compiler.ts b/packages/astro/src/@types/compiler.ts new file mode 100644 index 000000000..7da0afaf2 --- /dev/null +++ b/packages/astro/src/@types/compiler.ts @@ -0,0 +1,10 @@ +import type { LogOptions } from '../logger'; +import type { AstroConfig, RuntimeMode, ValidExtensionPlugins } from './astro'; + +export interface CompileOptions { + logging: LogOptions; + resolvePackageUrl: (p: string) => Promise<string>; + astroConfig: AstroConfig; + extensions?: Record<string, ValidExtensionPlugins>; + mode: RuntimeMode; +} diff --git a/packages/astro/src/@types/estree-walker.d.ts b/packages/astro/src/@types/estree-walker.d.ts new file mode 100644 index 000000000..a3b7da859 --- /dev/null +++ b/packages/astro/src/@types/estree-walker.d.ts @@ -0,0 +1,25 @@ +import { BaseNode } from 'estree-walker'; + +declare module 'estree-walker' { + export function walk<T = BaseNode>( + ast: T, + { + enter, + leave, + }: { + enter?: (this: { skip: () => void; remove: () => void; replace: (node: T) => void }, node: T, parent: T, key: string, index: number) => void; + leave?: (this: { skip: () => void; remove: () => void; replace: (node: T) => void }, node: T, parent: T, key: string, index: number) => void; + } + ): T; + + export function asyncWalk<T = BaseNode>( + ast: T, + { + enter, + leave, + }: { + enter?: (this: { skip: () => void; remove: () => void; replace: (node: T) => void }, node: T, parent: T, key: string, index: number) => void; + leave?: (this: { skip: () => void; remove: () => void; replace: (node: T) => void }, node: T, parent: T, key: string, index: number) => void; + } + ): T; +} diff --git a/packages/astro/src/@types/micromark-extension-gfm.d.ts b/packages/astro/src/@types/micromark-extension-gfm.d.ts new file mode 100644 index 000000000..ebdfe3b3a --- /dev/null +++ b/packages/astro/src/@types/micromark-extension-gfm.d.ts @@ -0,0 +1,3 @@ +// TODO: add types (if helpful) +declare module 'micromark-extension-gfm'; +declare module 'micromark-extension-gfm/html.js'; diff --git a/packages/astro/src/@types/micromark.ts b/packages/astro/src/@types/micromark.ts new file mode 100644 index 000000000..9725aabb9 --- /dev/null +++ b/packages/astro/src/@types/micromark.ts @@ -0,0 +1,11 @@ +export interface MicromarkExtensionContext { + sliceSerialize(node: any): string; + raw(value: string): void; +} + +export type MicromarkExtensionCallback = (this: MicromarkExtensionContext, node: any) => void; + +export interface MicromarkExtension { + enter?: Record<string, MicromarkExtensionCallback>; + exit?: Record<string, MicromarkExtensionCallback>; +} diff --git a/packages/astro/src/@types/postcss-icss-keyframes.d.ts b/packages/astro/src/@types/postcss-icss-keyframes.d.ts new file mode 100644 index 000000000..14c330b6e --- /dev/null +++ b/packages/astro/src/@types/postcss-icss-keyframes.d.ts @@ -0,0 +1,5 @@ +declare module 'postcss-icss-keyframes' { + import type { Plugin } from 'postcss'; + + export default function (options: { generateScopedName(keyframesName: string, filepath: string, css: string): string }): Plugin; +} diff --git a/packages/astro/src/@types/renderer.ts b/packages/astro/src/@types/renderer.ts new file mode 100644 index 000000000..f89cb6664 --- /dev/null +++ b/packages/astro/src/@types/renderer.ts @@ -0,0 +1,37 @@ +import type { Component as VueComponent } from 'vue'; +import type { ComponentType as PreactComponent } from 'preact'; +import type { ComponentType as ReactComponent } from 'react'; +import type { SvelteComponent } from 'svelte'; + +export interface DynamicRenderContext { + componentUrl: string; + componentExport: string; + frameworkUrls: string; +} + +export interface ComponentRenderer<T> { + renderStatic: StaticRendererGenerator<T>; + jsxPragma?: (...args: any) => any; + jsxPragmaName?: string; + render(context: { root: string; Component: string; props: string; [key: string]: string }): string; + imports?: Record<string, string[]>; +} + +export interface ComponentContext { + 'data-astro-id': string; + root: string; +} + +export type SupportedComponentRenderer = + | ComponentRenderer<VueComponent> + | ComponentRenderer<PreactComponent> + | ComponentRenderer<ReactComponent> + | ComponentRenderer<SvelteComponent>; +export type StaticRenderer = (props: Record<string, any>, ...children: any[]) => Promise<string>; +export type StaticRendererGenerator<T = any> = (Component: T) => StaticRenderer; +export type DynamicRenderer = (props: Record<string, any>, ...children: any[]) => Promise<string>; +export type DynamicRendererContext<T = any> = (Component: T, renderContext: DynamicRenderContext) => DynamicRenderer; +export type DynamicRendererGenerator = ( + wrapperStart: string | ((context: ComponentContext) => string), + wrapperEnd: string | ((context: ComponentContext) => string) +) => DynamicRendererContext; diff --git a/packages/astro/src/@types/tailwind.d.ts b/packages/astro/src/@types/tailwind.d.ts new file mode 100644 index 000000000..d25eaae2f --- /dev/null +++ b/packages/astro/src/@types/tailwind.d.ts @@ -0,0 +1,2 @@ +// we shouldn‘t have this as a dependency for Astro, but we may dynamically import it if a user requests it, so let TS know about it +declare module 'tailwindcss'; diff --git a/packages/astro/src/@types/transformer.ts b/packages/astro/src/@types/transformer.ts new file mode 100644 index 000000000..8a2099d61 --- /dev/null +++ b/packages/astro/src/@types/transformer.ts @@ -0,0 +1,23 @@ +import type { TemplateNode } from 'astro-parser'; +import type { CompileOptions } from './compiler'; + +export type VisitorFn<T = TemplateNode> = (this: { skip: () => void; remove: () => void; replace: (node: T) => void }, node: T, parent: T, type: string, index: number) => void; + +export interface NodeVisitor { + enter?: VisitorFn; + leave?: VisitorFn; +} + +export interface Transformer { + visitors?: { + html?: Record<string, NodeVisitor>; + css?: Record<string, NodeVisitor>; + }; + finalize: () => Promise<void>; +} + +export interface TransformOptions { + compileOptions: CompileOptions; + filename: string; + fileID: string; +} diff --git a/packages/astro/src/ast.ts b/packages/astro/src/ast.ts new file mode 100644 index 000000000..4f6848c89 --- /dev/null +++ b/packages/astro/src/ast.ts @@ -0,0 +1,28 @@ +import type { Attribute } from 'astro-parser'; + +// AST utility functions + +/** Get TemplateNode attribute from name */ +export function getAttr(attributes: Attribute[], name: string): Attribute | undefined { + const attr = attributes.find((a) => a.name === name); + return attr; +} + +/** Get TemplateNode attribute by value */ +export function getAttrValue(attributes: Attribute[], name: string): string | undefined { + const attr = getAttr(attributes, name); + if (attr) { + return attr.value[0]?.data; + } +} + +/** Set TemplateNode attribute value */ +export function setAttrValue(attributes: Attribute[], name: string, value: string): void { + const attr = attributes.find((a) => a.name === name); + if (attr) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + attr.value[0]!.data = value; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + attr.value[0]!.raw = value; + } +} diff --git a/packages/astro/src/build.ts b/packages/astro/src/build.ts new file mode 100644 index 000000000..392b0a920 --- /dev/null +++ b/packages/astro/src/build.ts @@ -0,0 +1,303 @@ +import type { AstroConfig, RuntimeMode } from './@types/astro'; +import type { LogOptions } from './logger'; +import type { AstroRuntime, LoadResult } from './runtime'; + +import { existsSync, promises as fsPromises } from 'fs'; +import { bold, green, yellow, underline } from 'kleur/colors'; +import path from 'path'; +import cheerio from 'cheerio'; +import { fileURLToPath } from 'url'; +import { fdir } from 'fdir'; +import { defaultLogDestination, error, info, trapWarn } from './logger.js'; +import { createRuntime } from './runtime.js'; +import { bundle, collectDynamicImports } from './build/bundle.js'; +import { generateRSS } from './build/rss.js'; +import { generateSitemap } from './build/sitemap.js'; +import { collectStatics } from './build/static.js'; +import { canonicalURL } from './build/util.js'; + + +const { mkdir, readFile, writeFile } = fsPromises; + +interface PageBuildOptions { + astroRoot: URL; + dist: URL; + filepath: URL; + runtime: AstroRuntime; + site?: string; + sitemap: boolean; + statics: Set<string>; +} + +interface PageResult { + canonicalURLs: string[]; + rss?: string; + statusCode: number; +} + +const logging: LogOptions = { + level: 'debug', + dest: defaultLogDestination, +}; + +/** Return contents of src/pages */ +async function allPages(root: URL) { + const api = new fdir() + .filter((p) => /\.(astro|md)$/.test(p)) + .withFullPaths() + .crawl(fileURLToPath(root)); + const files = await api.withPromise(); + return files as string[]; +} + +/** Utility for merging two Set()s */ +function mergeSet(a: Set<string>, b: Set<string>) { + for (let str of b) { + a.add(str); + } + return a; +} + +/** Utility for writing to file (async) */ +async function writeFilep(outPath: URL, bytes: string | Buffer, encoding: 'utf8' | null) { + const outFolder = new URL('./', outPath); + await mkdir(outFolder, { recursive: true }); + await writeFile(outPath, bytes, encoding || 'binary'); +} + +/** Utility for writing a build result to disk */ +async function writeResult(result: LoadResult, outPath: URL, encoding: null | 'utf8') { + if (result.statusCode === 500 || result.statusCode === 404) { + error(logging, 'build', result.error || result.statusCode); + } else if (result.statusCode !== 200) { + error(logging, 'build', `Unexpected load result (${result.statusCode}) for ${fileURLToPath(outPath)}`); + } else { + const bytes = result.contents; + await writeFilep(outPath, bytes, encoding); + } +} + +/** Collection utility */ +function getPageType(filepath: URL): 'collection' | 'static' { + if (/\$[^.]+.astro$/.test(filepath.pathname)) return 'collection'; + return 'static'; +} + +/** Build collection */ +async function buildCollectionPage({ astroRoot, dist, filepath, runtime, site, statics }: PageBuildOptions): Promise<PageResult> { + const rel = path.relative(fileURLToPath(astroRoot) + '/pages', fileURLToPath(filepath)); // pages/index.astro + const pagePath = `/${rel.replace(/\$([^.]+)\.astro$/, '$1')}`; + const builtURLs = new Set<string>(); // !important: internal cache that prevents building the same URLs + + /** Recursively build collection URLs */ + async function loadCollection(url: string): Promise<LoadResult | undefined> { + if (builtURLs.has(url)) return; // this stops us from recursively building the same pages over and over + const result = await runtime.load(url); + builtURLs.add(url); + if (result.statusCode === 200) { + const outPath = new URL('./' + url + '/index.html', dist); + await writeResult(result, outPath, 'utf8'); + mergeSet(statics, collectStatics(result.contents.toString('utf8'))); + } + return result; + } + + const result = (await loadCollection(pagePath)) as LoadResult; + + if (result.statusCode >= 500) { + throw new Error((result as any).error); + } + if (result.statusCode === 200 && !result.collectionInfo) { + throw new Error(`[${rel}]: Collection page must export createCollection() function`); + } + + let rss: string | undefined; + + // note: for pages that require params (/tag/:tag), we will get a 404 but will still get back collectionInfo that tell us what the URLs should be + if (result.collectionInfo) { + // build subsequent pages + await Promise.all( + [...result.collectionInfo.additionalURLs].map(async (url) => { + // for the top set of additional URLs, we render every new URL generated + const addlResult = await loadCollection(url); + builtURLs.add(url); + if (addlResult && addlResult.collectionInfo) { + // believe it or not, we may still have a few unbuilt pages left. this is our last crawl: + await Promise.all([...addlResult.collectionInfo.additionalURLs].map(async (url2) => loadCollection(url2))); + } + }) + ); + + if (result.collectionInfo.rss) { + if (!site) throw new Error(`[${rel}] createCollection() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`); + rss = generateRSS({ ...(result.collectionInfo.rss as any), site }, rel.replace(/\$([^.]+)\.astro$/, '$1')); + } + } + + return { + canonicalURLs: [...builtURLs].filter((url) => !url.endsWith('/1')), // note: canonical URLs are controlled by the collection, so these are canonical (but exclude "/1" pages as those are duplicates of the index) + statusCode: result.statusCode, + rss, + }; +} + +/** Build static page */ +async function buildStaticPage({ astroRoot, dist, filepath, runtime, sitemap, statics }: PageBuildOptions): Promise<PageResult> { + const rel = path.relative(fileURLToPath(astroRoot) + '/pages', fileURLToPath(filepath)); // pages/index.astro + const pagePath = `/${rel.replace(/\.(astro|md)$/, '')}`; + let canonicalURLs: string[] = []; + + let relPath = './' + rel.replace(/\.(astro|md)$/, '.html'); + if (!relPath.endsWith('index.html')) { + relPath = relPath.replace(/\.html$/, '/index.html'); + } + + const outPath = new URL(relPath, dist); + const result = await runtime.load(pagePath); + + await writeResult(result, outPath, 'utf8'); + + if (result.statusCode === 200) { + mergeSet(statics, collectStatics(result.contents.toString('utf8'))); + + // get Canonical URL (if user has specified one manually, use that) + if (sitemap) { + const $ = cheerio.load(result.contents); + const canonicalTag = $('link[rel="canonical"]'); + canonicalURLs.push(canonicalTag.attr('href') || pagePath.replace(/index$/, '')); + } + } + + return { + canonicalURLs, + statusCode: result.statusCode, + }; +} + +/** The primary build action */ +export async function build(astroConfig: AstroConfig): Promise<0 | 1> { + const { projectRoot, astroRoot } = astroConfig; + const pageRoot = new URL('./pages/', astroRoot); + const componentRoot = new URL('./components/', astroRoot); + const dist = new URL(astroConfig.dist + '/', projectRoot); + + const runtimeLogging: LogOptions = { + level: 'error', + dest: defaultLogDestination, + }; + + const mode: RuntimeMode = 'production'; + const runtime = await createRuntime(astroConfig, { mode, logging: runtimeLogging }); + const { runtimeConfig } = runtime; + const { backendSnowpack: snowpack } = runtimeConfig; + const resolvePackageUrl = (pkgName: string) => snowpack.getUrlForPackage(pkgName); + + const imports = new Set<string>(); + const statics = new Set<string>(); + const collectImportsOptions = { astroConfig, logging, resolvePackageUrl, mode }; + + const pages = await allPages(pageRoot); + let builtURLs: string[] = []; + + + try { + info(logging , 'build', yellow('! building pages...')); + // Vue also console.warns, this silences it. + const release = trapWarn(); + await Promise.all( + pages.map(async (pathname) => { + const filepath = new URL(`file://${pathname}`); + + const pageType = getPageType(filepath); + const pageOptions: PageBuildOptions = { astroRoot, dist, filepath, runtime, site: astroConfig.buildOptions.site, sitemap: astroConfig.buildOptions.sitemap, statics }; + if (pageType === 'collection') { + const { canonicalURLs, rss } = await buildCollectionPage(pageOptions); + builtURLs.push(...canonicalURLs); + if (rss) { + const basename = path + .relative(fileURLToPath(astroRoot) + '/pages', pathname) + .replace(/^\$/, '') + .replace(/\.astro$/, ''); + await writeFilep(new URL(`file://${path.join(fileURLToPath(dist), 'feed', basename + '.xml')}`), rss, 'utf8'); + } + } else { + const { canonicalURLs } = await buildStaticPage(pageOptions); + builtURLs.push(...canonicalURLs); + } + + mergeSet(imports, await collectDynamicImports(filepath, collectImportsOptions)); + }) + ); + info(logging, 'build', green('✔'), 'pages built.'); + release(); + } catch (err) { + error(logging, 'generate', err); + await runtime.shutdown(); + return 1; + } + + info(logging, 'build', yellow('! scanning pages...')); + for (const pathname of await allPages(componentRoot)) { + mergeSet(imports, await collectDynamicImports(new URL(`file://${pathname}`), collectImportsOptions)); + } + info(logging, 'build', green('✔'), 'pages scanned.'); + + if (imports.size > 0) { + try { + info(logging, 'build', yellow('! bundling client-side code.')); + await bundle(imports, { dist, runtime, astroConfig }); + info(logging, 'build', green('✔'), 'bundling complete.'); + } catch (err) { + error(logging, 'build', err); + await runtime.shutdown(); + return 1; + } + } + + for (let url of statics) { + const outPath = new URL('.' + url, dist); + const result = await runtime.load(url); + + await writeResult(result, outPath, null); + } + + if (existsSync(astroConfig.public)) { + info(logging, 'build', yellow(`! copying public folder...`)); + const pub = astroConfig.public; + const publicFiles = (await new fdir().withFullPaths().crawl(fileURLToPath(pub)).withPromise()) as string[]; + for (const filepath of publicFiles) { + const fileUrl = new URL(`file://${filepath}`); + const rel = path.relative(pub.pathname, fileUrl.pathname); + const outUrl = new URL('./' + rel, dist); + + const bytes = await readFile(fileUrl); + await writeFilep(outUrl, bytes, null); + } + info(logging, 'build', green('✔'), 'public folder copied.'); + } else { + if(path.basename(astroConfig.public.toString()) !=='public'){ + info(logging, 'tip', yellow(`! no public folder ${astroConfig.public} found...`)); + } + } + // build sitemap + if (astroConfig.buildOptions.sitemap && astroConfig.buildOptions.site) { + info(logging, 'build', yellow('! creating a sitemap...')); + const sitemap = generateSitemap(builtURLs.map((url) => ({ canonicalURL: canonicalURL(url, astroConfig.buildOptions.site) }))); + await writeFile(new URL('./sitemap.xml', dist), sitemap, 'utf8'); + info(logging, 'build', green('✔'), 'sitemap built.'); + } else if (astroConfig.buildOptions.sitemap) { + info(logging, 'tip', `Set "buildOptions.site" in astro.config.mjs to generate a sitemap.xml`); + } + + builtURLs.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); + info(logging, 'build', underline('Pages')); + const lastIndex = builtURLs.length - 1; + builtURLs.forEach((url, index) => { + const sep = index === 0 ? '┌' : index === lastIndex ? '└' : '├'; + info(logging, null, ' ' + sep, url === '/' ? url : url + '/'); + }); + + await runtime.shutdown(); + info(logging, 'build', bold(green('▶ Build Complete!'))); + return 0; +} diff --git a/packages/astro/src/build/bundle.ts b/packages/astro/src/build/bundle.ts new file mode 100644 index 000000000..0191e8c09 --- /dev/null +++ b/packages/astro/src/build/bundle.ts @@ -0,0 +1,313 @@ +import type { AstroConfig, RuntimeMode, ValidExtensionPlugins } from '../@types/astro'; +import type { ImportDeclaration } from '@babel/types'; +import type { InputOptions, OutputOptions } from 'rollup'; +import type { AstroRuntime } from '../runtime'; +import type { LogOptions } from '../logger'; + +import esbuild from 'esbuild'; +import { promises as fsPromises } from 'fs'; +import { fileURLToPath } from 'url'; +import { parse } from 'astro-parser'; +import { transform } from '../compiler/transform/index.js'; +import { convertMdToAstroSource } from '../compiler/index.js'; +import { getAttrValue } from '../ast.js'; +import { walk } from 'estree-walker'; +import babelParser from '@babel/parser'; +import path from 'path'; +import { rollup } from 'rollup'; +import { terser } from 'rollup-plugin-terser'; + +const { transformSync } = esbuild; +const { readFile } = fsPromises; + +type DynamicImportMap = Map<'vue' | 'react' | 'react-dom' | 'preact' | 'svelte', string>; + +/** Add framework runtimes when needed */ +async function acquireDynamicComponentImports(plugins: Set<ValidExtensionPlugins>, resolvePackageUrl: (s: string) => Promise<string>): Promise<DynamicImportMap> { + const importMap: DynamicImportMap = new Map(); + for (let plugin of plugins) { + switch (plugin) { + case 'svelte': { + importMap.set('svelte', await resolvePackageUrl('svelte')); + break; + } + case 'vue': { + importMap.set('vue', await resolvePackageUrl('vue')); + break; + } + case 'react': { + importMap.set('react', await resolvePackageUrl('react')); + importMap.set('react-dom', await resolvePackageUrl('react-dom')); + break; + } + case 'preact': { + importMap.set('preact', await resolvePackageUrl('preact')); + break; + } + } + } + return importMap; +} + +/** Evaluate mustache expression (safely) */ +function compileExpressionSafe(raw: string): string { + let { code } = transformSync(raw, { + loader: 'tsx', + jsxFactory: 'h', + jsxFragment: 'Fragment', + charset: 'utf8', + }); + return code; +} + +const defaultExtensions: Readonly<Record<string, ValidExtensionPlugins>> = { + '.jsx': 'react', + '.tsx': 'react', + '.svelte': 'svelte', + '.vue': 'vue', +}; + +interface CollectDynamic { + astroConfig: AstroConfig; + resolvePackageUrl: (s: string) => Promise<string>; + logging: LogOptions; + mode: RuntimeMode; +} + +/** Gather necessary framework runtimes for dynamic components */ +export async function collectDynamicImports(filename: URL, { astroConfig, logging, resolvePackageUrl, mode }: CollectDynamic) { + const imports = new Set<string>(); + + // Only astro files + if (!filename.pathname.endsWith('.astro') && !filename.pathname.endsWith('.md')) { + return imports; + } + + const extensions = astroConfig.extensions || defaultExtensions; + + let source = await readFile(filename, 'utf-8'); + if (filename.pathname.endsWith('.md')) { + source = await convertMdToAstroSource(source); + } + + const ast = parse(source, { + filename, + }); + + if (!ast.module) { + return imports; + } + + await transform(ast, { + filename: fileURLToPath(filename), + fileID: '', + compileOptions: { + astroConfig, + resolvePackageUrl, + logging, + mode, + }, + }); + + const componentImports: ImportDeclaration[] = []; + const components: Record<string, { plugin: ValidExtensionPlugins; type: string; specifier: string }> = {}; + const plugins = new Set<ValidExtensionPlugins>(); + + const program = babelParser.parse(ast.module.content, { + sourceType: 'module', + plugins: ['jsx', 'typescript', 'topLevelAwait'], + }).program; + + const { body } = program; + let i = body.length; + while (--i >= 0) { + const node = body[i]; + if (node.type === 'ImportDeclaration') { + componentImports.push(node); + } + } + + for (const componentImport of componentImports) { + const importUrl = componentImport.source.value; + const componentType = path.posix.extname(importUrl); + const componentName = path.posix.basename(importUrl, componentType); + const plugin = extensions[componentType] || defaultExtensions[componentType]; + plugins.add(plugin); + components[componentName] = { + plugin, + type: componentType, + specifier: importUrl, + }; + } + + const dynamic = await acquireDynamicComponentImports(plugins, resolvePackageUrl); + + /** Add dynamic component runtimes to imports */ + function appendImports(rawName: string, importUrl: URL) { + const [componentName, componentType] = rawName.split(':'); + if (!componentType) { + return; + } + + if (!components[componentName]) { + throw new Error(`Unknown Component: ${componentName}`); + } + + const defn = components[componentName]; + const fileUrl = new URL(defn.specifier, importUrl); + let rel = path.posix.relative(astroConfig.astroRoot.pathname, fileUrl.pathname); + + switch (defn.plugin) { + case 'preact': { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + imports.add(dynamic.get('preact')!); + rel = rel.replace(/\.[^.]+$/, '.js'); + break; + } + case 'react': { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + imports.add(dynamic.get('react')!); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + imports.add(dynamic.get('react-dom')!); + rel = rel.replace(/\.[^.]+$/, '.js'); + break; + } + case 'vue': { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + imports.add(dynamic.get('vue')!); + rel = rel.replace(/\.[^.]+$/, '.vue.js'); + break; + } + case 'svelte': { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + imports.add(dynamic.get('svelte')!); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + imports.add('/_astro_internal/runtime/svelte.js'); + rel = rel.replace(/\.[^.]+$/, '.svelte.js'); + break; + } + } + + imports.add(`/_astro/${rel}`); + } + + walk(ast.html, { + enter(node) { + switch (node.type) { + case 'Element': { + if (node.name !== 'script') return; + if (getAttrValue(node.attributes, 'type') !== 'module') return; + + const src = getAttrValue(node.attributes, 'src'); + + if (src && src.startsWith('/')) { + imports.add(src); + } + break; + } + + case 'MustacheTag': { + let code: string; + try { + code = compileExpressionSafe(node.content); + } catch { + return; + } + + let matches: RegExpExecArray[] = []; + let match: RegExpExecArray | null | undefined; + const H_COMPONENT_SCANNER = /h\(['"]?([A-Z].*?)['"]?,/gs; + const regex = new RegExp(H_COMPONENT_SCANNER); + while ((match = regex.exec(code))) { + matches.push(match); + } + for (const foundImport of matches.reverse()) { + const name = foundImport[1]; + appendImports(name, filename); + } + break; + } + case 'InlineComponent': { + if (/^[A-Z]/.test(node.name)) { + appendImports(node.name, filename); + return; + } + + break; + } + } + }, + }); + + return imports; +} + +interface BundleOptions { + runtime: AstroRuntime; + dist: URL; + astroConfig: AstroConfig; +} + +/** The primary bundling/optimization action */ +export async function bundle(imports: Set<string>, { runtime, dist }: BundleOptions) { + const ROOT = 'astro:root'; + const root = ` + ${[...imports].map((url) => `import '${url}';`).join('\n')} + `; + + const inputOptions: InputOptions = { + input: [...imports], + plugins: [ + { + name: 'astro:build', + resolveId(source: string, imported?: string) { + if (source === ROOT) { + return source; + } + if (source.startsWith('/')) { + return source; + } + + if (imported) { + const outUrl = new URL(source, 'http://example.com' + imported); + return outUrl.pathname; + } + + return null; + }, + async load(id: string) { + if (id === ROOT) { + return root; + } + + const result = await runtime.load(id); + + if (result.statusCode !== 200) { + return null; + } + + return result.contents.toString('utf-8'); + }, + }, + ], + }; + + const build = await rollup(inputOptions); + + const outputOptions: OutputOptions = { + dir: fileURLToPath(dist), + format: 'esm', + exports: 'named', + entryFileNames(chunk) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return chunk.facadeModuleId!.substr(1); + }, + plugins: [ + // We are using terser for the demo, but might switch to something else long term + // Look into that rather than adding options here. + terser(), + ], + }; + + await build.write(outputOptions); +} diff --git a/packages/astro/src/build/rss.ts b/packages/astro/src/build/rss.ts new file mode 100644 index 000000000..b75ed908b --- /dev/null +++ b/packages/astro/src/build/rss.ts @@ -0,0 +1,68 @@ +import type { CollectionRSS } from '../@types/astro'; +import parser from 'fast-xml-parser'; +import { canonicalURL } from './util.js'; + +/** Validates createCollection.rss */ +export function validateRSS(rss: CollectionRSS, filename: string): void { + if (!rss.title) throw new Error(`[${filename}] rss.title required`); + if (!rss.description) throw new Error(`[${filename}] rss.description required`); + if (typeof rss.item !== 'function') throw new Error(`[${filename}] rss.item() function required`); +} + +/** Generate RSS 2.0 feed */ +export function generateRSS<T>(input: { data: T[]; site: string } & CollectionRSS<T>, filename: string): string { + let xml = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"`; + + validateRSS(input as any, filename); + + // xmlns + if (input.xmlns) { + for (const [k, v] of Object.entries(input.xmlns)) { + xml += ` xmlns:${k}="${v}"`; + } + } + xml += `>`; + xml += `<channel>`; + + // title, description, customData + xml += `<title><![CDATA[${input.title}]]></title>`; + xml += `<description><![CDATA[${input.description}]]></description>`; + xml += `<link>${canonicalURL('/feed/' + filename + '.xml', input.site)}</link>`; + if (typeof input.customData === 'string') xml += input.customData; + + // items + if (!Array.isArray(input.data) || !input.data.length) throw new Error(`[${filename}] data() returned no items. Can’t generate RSS feed.`); + for (const item of input.data) { + xml += `<item>`; + const result = input.item(item); + // validate + if (typeof result !== 'object') throw new Error(`[${filename}] rss.item() expected to return an object, returned ${typeof result}.`); + if (!result.title) throw new Error(`[${filename}] rss.item() returned object but required "title" is missing.`); + if (!result.link) throw new Error(`[${filename}] rss.item() returned object but required "link" is missing.`); + xml += `<title><![CDATA[${result.title}]]></title>`; + xml += `<link>${canonicalURL(result.link, input.site)}</link>`; + if (result.description) xml += `<description><![CDATA[${result.description}]]></description>`; + if (result.pubDate) { + // note: this should be a Date, but if user provided a string or number, we can work with that, too. + if (typeof result.pubDate === 'number' || typeof result.pubDate === 'string') { + result.pubDate = new Date(result.pubDate); + } else if (result.pubDate instanceof Date === false) { + throw new Error('[${filename}] rss.item().pubDate must be a Date'); + } + xml += `<pubDate>${result.pubDate.toUTCString()}</pubDate>`; + } + if (typeof result.customData === 'string') xml += result.customData; + xml += `</item>`; + } + + xml += `</channel></rss>`; + + // validate user’s inputs to see if it’s valid XML + const isValid = parser.validate(xml); + if (isValid !== true) { + // If valid XML, isValid will be `true`. Otherwise, this will be an error object. Throw. + throw new Error(isValid as any); + } + + return xml; +} diff --git a/packages/astro/src/build/sitemap.ts b/packages/astro/src/build/sitemap.ts new file mode 100644 index 000000000..1cb3f3e40 --- /dev/null +++ b/packages/astro/src/build/sitemap.ts @@ -0,0 +1,15 @@ +export interface PageMeta { + /** (required) The canonical URL of the page */ + canonicalURL: string; +} + +/** Construct sitemap.xml given a set of URLs */ +export function generateSitemap(pages: PageMeta[]): string { + let sitemap = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`; + pages.sort((a, b) => a.canonicalURL.localeCompare(b.canonicalURL, 'en', { numeric: true })); // sort alphabetically + for (const page of pages) { + sitemap += `<url><loc>${page.canonicalURL}</loc></url>`; + } + sitemap += `</urlset>\n`; + return sitemap; +} diff --git a/packages/astro/src/build/static.ts b/packages/astro/src/build/static.ts new file mode 100644 index 000000000..af99c33cb --- /dev/null +++ b/packages/astro/src/build/static.ts @@ -0,0 +1,28 @@ +import type { Element } from 'domhandler'; +import cheerio from 'cheerio'; + +/** Given an HTML string, collect <link> and <img> tags */ +export function collectStatics(html: string) { + const statics = new Set<string>(); + + const $ = cheerio.load(html); + + const append = (el: Element, attr: string) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const value: string = $(el).attr(attr)!; + if (value.startsWith('http') || $(el).attr('rel') === 'alternate') { + return; + } + statics.add(value); + }; + + $('link[href]').each((i, el) => { + append(el, 'href'); + }); + + $('img[src]').each((i, el) => { + append(el, 'src'); + }); + + return statics; +} diff --git a/packages/astro/src/build/util.ts b/packages/astro/src/build/util.ts new file mode 100644 index 000000000..505e6f183 --- /dev/null +++ b/packages/astro/src/build/util.ts @@ -0,0 +1,9 @@ +import path from 'path'; + +/** Normalize URL to its canonical form */ +export function canonicalURL(url: string, base?: string): string { + return new URL( + path.extname(url) ? url : url.replace(/(\/+)?$/, '/'), // add trailing slash if there’s no extension + base + ).href; +} diff --git a/packages/astro/src/cli.ts b/packages/astro/src/cli.ts new file mode 100644 index 000000000..be0dfe27a --- /dev/null +++ b/packages/astro/src/cli.ts @@ -0,0 +1,127 @@ +/* eslint-disable no-console */ +import type { AstroConfig } from './@types/astro'; + +import * as colors from 'kleur/colors'; +import { promises as fsPromises } from 'fs'; +import yargs from 'yargs-parser'; + +import { loadConfig } from './config.js'; +import { build } from './build.js'; +import devServer from './dev.js'; + +const { readFile } = fsPromises; +const buildAndExit = async (...args: Parameters<typeof build>) => { + const ret = await build(...args); + process.exit(ret); +}; + +type Arguments = yargs.Arguments; +type cliCommand = 'help' | 'version' | 'dev' | 'build'; +interface CLIState { + cmd: cliCommand; + options: { + projectRoot?: string; + sitemap?: boolean; + port?: number; + config?: string; + }; +} + +/** Determine which action the user requested */ +function resolveArgs(flags: Arguments): CLIState { + const options: CLIState['options'] = { + projectRoot: typeof flags.projectRoot === 'string' ? flags.projectRoot: undefined, + sitemap: typeof flags.sitemap === 'boolean' ? flags.sitemap : undefined, + port: typeof flags.port === 'number' ? flags.port : undefined, + config: typeof flags.config === 'string' ? flags.config : undefined + }; + + if (flags.version) { + return { cmd: 'version', options }; + } else if (flags.help) { + return { cmd: 'help', options }; + } + + const cmd = flags._[2]; + switch (cmd) { + case 'dev': + return { cmd: 'dev', options }; + case 'build': + return { cmd: 'build', options }; + default: + return { cmd: 'help', options }; + } +} + +/** Display --help flag */ +function printHelp() { + console.error(` ${colors.bold('astro')} - Futuristic web development tool. + + ${colors.bold('Commands:')} + astro dev Run Astro in development mode. + astro build Build a pre-compiled production version of your site. + + ${colors.bold('Flags:')} + --config <path> Specify the path to the Astro config file. + --project-root <path> Specify the path to the project root folder. + --no-sitemap Disable sitemap generation (build only). + --version Show the version number and exit. + --help Show this help message. +`); +} + +/** Display --version flag */ +async function printVersion() { + const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8')); + console.error(pkg.version); +} + +/** Merge CLI flags & config options (CLI flags take priority) */ +function mergeCLIFlags(astroConfig: AstroConfig, flags: CLIState['options']) { + if (typeof flags.sitemap === 'boolean') astroConfig.buildOptions.sitemap = flags.sitemap; + if (typeof flags.port === 'number') astroConfig.devOptions.port = flags.port; +} + +/** Handle `astro run` command */ +async function runCommand(rawRoot: string, cmd: (a: AstroConfig) => Promise<void>, options: CLIState['options']) { + try { + const projectRoot = options.projectRoot || rawRoot; + const astroConfig = await loadConfig(projectRoot, options.config); + mergeCLIFlags(astroConfig, options); + + return cmd(astroConfig); + } catch (err) { + console.error(colors.red(err.toString() || err)); + process.exit(1); + } +} + +const cmdMap = new Map([ + ['build', buildAndExit], + ['dev', devServer], +]); + +/** The primary CLI action */ +export async function cli(args: string[]) { + const flags = yargs(args); + const state = resolveArgs(flags); + + switch (state.cmd) { + case 'help': { + printHelp(); + process.exit(1); + break; + } + case 'version': { + await printVersion(); + process.exit(0); + break; + } + case 'build': + case 'dev': { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cmd = cmdMap.get(state.cmd)!; + runCommand(flags._[3], cmd, state.options); + } + } +} diff --git a/packages/astro/src/compiler/codegen/content.ts b/packages/astro/src/compiler/codegen/content.ts new file mode 100644 index 000000000..fb8f9e307 --- /dev/null +++ b/packages/astro/src/compiler/codegen/content.ts @@ -0,0 +1,78 @@ +import path from 'path'; +import { fdir, PathsOutput } from 'fdir'; + +/** + * Handling for import.meta.glob and import.meta.globEager + */ + +interface GlobOptions { + namespace: string; + filename: string; +} + +interface GlobResult { + /** Array of import statements to inject */ + imports: Set<string>; + /** Replace original code with */ + code: string; +} + +const crawler = new fdir(); + +/** General glob handling */ +function globSearch(spec: string, { filename }: { filename: string }): string[] { + try { + // Note: fdir’s glob requires you to do some work finding the closest non-glob folder. + // For example, this fails: .glob("./post/*.md").crawl("/…/src/pages") ❌ + // …but this doesn’t: .glob("*.md").crawl("/…/src/pages/post") ✅ + let globDir = ''; + let glob = spec; + for (const part of spec.split('/')) { + if (!part.includes('*')) { + // iterate through spec until first '*' is reached + globDir = path.posix.join(globDir, part); // this must be POSIX-style + glob = glob.replace(`${part}/`, ''); // move parent dirs off spec, and onto globDir + } else { + // at first '*', exit + break; + } + } + + const cwd = path.join(path.dirname(filename), globDir.replace(/\//g, path.sep)); // this must match OS (could be '/' or '\') + let found = crawler.glob(glob).crawl(cwd).sync() as PathsOutput; + if (!found.length) { + throw new Error(`No files matched "${spec}" from ${filename}`); + } + return found.map((importPath) => { + if (importPath.startsWith('http') || importPath.startsWith('.')) return importPath; + return `./` + globDir + '/' + importPath; + }); + } catch (err) { + throw new Error(`No files matched "${spec}" from ${filename}`); + } +} + +/** Astro.fetchContent() */ +export function fetchContent(spec: string, { namespace, filename }: GlobOptions): GlobResult { + let code = ''; + const imports = new Set<string>(); + const importPaths = globSearch(spec, { filename }); + + // gather imports + importPaths.forEach((importPath, j) => { + const id = `${namespace}_${j}`; + imports.add(`import { __content as ${id} } from '${importPath}';`); + + // add URL if this appears within the /pages/ directory (probably can be improved) + const fullPath = path.resolve(path.dirname(filename), importPath); + if (fullPath.includes(`${path.sep}pages${path.sep}`)) { + const url = importPath.replace(/^\./, '').replace(/\.md$/, ''); + imports.add(`${id}.url = '${url}';`); + } + }); + + // generate replacement code + code += `${namespace} = [${importPaths.map((_, j) => `${namespace}_${j}`).join(',')}];\n`; + + return { imports, code }; +} diff --git a/packages/astro/src/compiler/codegen/index.ts b/packages/astro/src/compiler/codegen/index.ts new file mode 100644 index 000000000..6caed85a3 --- /dev/null +++ b/packages/astro/src/compiler/codegen/index.ts @@ -0,0 +1,686 @@ +import type { CompileOptions } from '../../@types/compiler'; +import type { AstroConfig, ValidExtensionPlugins } from '../../@types/astro'; +import type { Ast, Script, Style, TemplateNode } from 'astro-parser'; +import type { TransformResult } from '../../@types/astro'; + +import eslexer from 'es-module-lexer'; +import esbuild from 'esbuild'; +import path from 'path'; +import { walk } from 'estree-walker'; +import _babelGenerator from '@babel/generator'; +import babelParser from '@babel/parser'; +import { codeFrameColumns } from '@babel/code-frame'; +import * as babelTraverse from '@babel/traverse'; +import { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier } from '@babel/types'; +import { warn } from '../../logger.js'; +import { fetchContent } from './content.js'; +import { isFetchContent } from './utils.js'; +import { yellow } from 'kleur/colors'; + +const traverse: typeof babelTraverse.default = (babelTraverse.default as any).default; +const babelGenerator: typeof _babelGenerator = + // @ts-ignore + _babelGenerator.default; +const { transformSync } = esbuild; + +interface Attribute { + start: number; + end: number; + type: 'Attribute'; + name: string; + value: TemplateNode[] | boolean; +} + +interface CodeGenOptions { + compileOptions: CompileOptions; + filename: string; + fileID: string; +} + +/** Format Astro internal import URL */ +function internalImport(internalPath: string) { + return `/_astro_internal/${internalPath}`; +} + +/** Retrieve attributes from TemplateNode */ +function getAttributes(attrs: Attribute[]): Record<string, string> { + let result: Record<string, string> = {}; + for (const attr of attrs) { + if (attr.value === true) { + result[attr.name] = JSON.stringify(attr.value); + continue; + } + if (attr.value === false || attr.value === undefined) { + // note: attr.value shouldn’t be `undefined`, but a bad transform would cause a compile error here, so prevent that + continue; + } + if (attr.value.length > 1) { + result[attr.name] = + '(' + + attr.value + .map((v: TemplateNode) => { + if (v.content) { + return v.content; + } else { + return JSON.stringify(getTextFromAttribute(v)); + } + }) + .join('+') + + ')'; + continue; + } + const val = attr.value[0]; + if (!val) { + result[attr.name] = '(' + val + ')'; + continue; + } + switch (val.type) { + case 'MustacheTag': { + // FIXME: this won't work when JSX element can appear in attributes (rare but possible). + result[attr.name] = '(' + val.expression.codeChunks[0] + ')'; + continue; + } + case 'Text': + result[attr.name] = JSON.stringify(getTextFromAttribute(val)); + continue; + default: + throw new Error(`UNKNOWN: ${val.type}`); + } + } + return result; +} + +/** Get value from a TemplateNode Attribute (text attributes only!) */ +function getTextFromAttribute(attr: any): string { + switch (attr.type) { + case 'Text': { + if (attr.raw !== undefined) { + return attr.raw; + } + if (attr.data !== undefined) { + return attr.data; + } + break; + } + case 'MustacheTag': { + // FIXME: this won't work when JSX element can appear in attributes (rare but possible). + return attr.expression.codeChunks[0]; + } + } + throw new Error(`Unknown attribute type ${attr.type}`); +} + +/** Convert TemplateNode attributes to string */ +function generateAttributes(attrs: Record<string, string>): string { + let result = '{'; + for (const [key, val] of Object.entries(attrs)) { + result += JSON.stringify(key) + ':' + val + ','; + } + return result + '}'; +} + +interface ComponentInfo { + type: string; + url: string; + plugin: string | undefined; +} + +const defaultExtensions: Readonly<Record<string, ValidExtensionPlugins>> = { + '.astro': 'astro', + '.jsx': 'react', + '.tsx': 'react', + '.vue': 'vue', + '.svelte': 'svelte', +}; + +type DynamicImportMap = Map<'vue' | 'react' | 'react-dom' | 'preact' | 'svelte', string>; + +interface GetComponentWrapperOptions { + filename: string; + astroConfig: AstroConfig; + dynamicImports: DynamicImportMap; +} + +/** Generate Astro-friendly component import */ +function getComponentWrapper(_name: string, { type, plugin, url }: ComponentInfo, opts: GetComponentWrapperOptions) { + const { astroConfig, dynamicImports, filename } = opts; + const { astroRoot } = astroConfig; + const [name, kind] = _name.split(':'); + const currFileUrl = new URL(`file://${filename}`); + + if (!plugin) { + throw new Error(`No supported plugin found for ${type ? `extension ${type}` : `${url} (try adding an extension)`}`); + } + + const getComponentUrl = (ext = '.js') => { + const outUrl = new URL(url, currFileUrl); + return '/_astro/' + path.posix.relative(astroRoot.pathname, outUrl.pathname).replace(/\.[^.]+$/, ext); + }; + + switch (plugin) { + case 'astro': { + if (kind) { + throw new Error(`Astro does not support :${kind}`); + } + return { + wrapper: name, + wrapperImport: ``, + }; + } + case 'preact': { + if (['load', 'idle', 'visible'].includes(kind)) { + return { + wrapper: `__preact_${kind}(${name}, ${JSON.stringify({ + componentUrl: getComponentUrl(), + componentExport: 'default', + frameworkUrls: { + preact: dynamicImports.get('preact'), + }, + })})`, + wrapperImport: `import {__preact_${kind}} from '${internalImport('render/preact.js')}';`, + }; + } + + return { + wrapper: `__preact_static(${name})`, + wrapperImport: `import {__preact_static} from '${internalImport('render/preact.js')}';`, + }; + } + case 'react': { + if (['load', 'idle', 'visible'].includes(kind)) { + return { + wrapper: `__react_${kind}(${name}, ${JSON.stringify({ + componentUrl: getComponentUrl(), + componentExport: 'default', + frameworkUrls: { + react: dynamicImports.get('react'), + 'react-dom': dynamicImports.get('react-dom'), + }, + })})`, + wrapperImport: `import {__react_${kind}} from '${internalImport('render/react.js')}';`, + }; + } + + return { + wrapper: `__react_static(${name})`, + wrapperImport: `import {__react_static} from '${internalImport('render/react.js')}';`, + }; + } + case 'svelte': { + if (['load', 'idle', 'visible'].includes(kind)) { + return { + wrapper: `__svelte_${kind}(${name}, ${JSON.stringify({ + componentUrl: getComponentUrl('.svelte.js'), + componentExport: 'default', + frameworkUrls: { + 'svelte-runtime': internalImport('runtime/svelte.js'), + }, + })})`, + wrapperImport: `import {__svelte_${kind}} from '${internalImport('render/svelte.js')}';`, + }; + } + + return { + wrapper: `__svelte_static(${name})`, + wrapperImport: `import {__svelte_static} from '${internalImport('render/svelte.js')}';`, + }; + } + case 'vue': { + if (['load', 'idle', 'visible'].includes(kind)) { + return { + wrapper: `__vue_${kind}(${name}, ${JSON.stringify({ + componentUrl: getComponentUrl('.vue.js'), + componentExport: 'default', + frameworkUrls: { + vue: dynamicImports.get('vue'), + }, + })})`, + wrapperImport: `import {__vue_${kind}} from '${internalImport('render/vue.js')}';`, + }; + } + + return { + wrapper: `__vue_static(${name})`, + wrapperImport: `import {__vue_static} from '${internalImport('render/vue.js')}';`, + }; + } + default: { + throw new Error(`Unknown component type`); + } + } +} + +/** Evaluate expression (safely) */ +function compileExpressionSafe(raw: string): string { + let { code } = transformSync(raw, { + loader: 'tsx', + jsxFactory: 'h', + jsxFragment: 'Fragment', + charset: 'utf8', + }); + return code; +} + +/** Build dependency map of dynamic component runtime frameworks */ +async function acquireDynamicComponentImports(plugins: Set<ValidExtensionPlugins>, resolvePackageUrl: (s: string) => Promise<string>): Promise<DynamicImportMap> { + const importMap: DynamicImportMap = new Map(); + for (let plugin of plugins) { + switch (plugin) { + case 'vue': { + importMap.set('vue', await resolvePackageUrl('vue')); + break; + } + case 'react': { + importMap.set('react', await resolvePackageUrl('react')); + importMap.set('react-dom', await resolvePackageUrl('react-dom')); + break; + } + case 'preact': { + importMap.set('preact', await resolvePackageUrl('preact')); + break; + } + case 'svelte': { + importMap.set('svelte', await resolvePackageUrl('svelte')); + break; + } + } + } + return importMap; +} + +type Components = Record<string, { type: string; url: string; plugin: string | undefined }>; + +interface CompileResult { + script: string; + componentPlugins: Set<ValidExtensionPlugins>; + createCollection?: string; +} + +interface CodegenState { + filename: string; + components: Components; + css: string[]; + importExportStatements: Set<string>; + dynamicImports: DynamicImportMap; +} + +/** Compile/prepare Astro frontmatter scripts */ +function compileModule(module: Script, state: CodegenState, compileOptions: CompileOptions): CompileResult { + const { extensions = defaultExtensions } = compileOptions; + + const componentImports: ImportDeclaration[] = []; + const componentProps: VariableDeclarator[] = []; + const componentExports: ExportNamedDeclaration[] = []; + + const contentImports = new Map<string, { spec: string; declarator: string }>(); + + let script = ''; + let propsStatement = ''; + let contentCode = ''; // code for handling Astro.fetchContent(), if any; + let createCollection = ''; // function for executing collection + const componentPlugins = new Set<ValidExtensionPlugins>(); + + if (module) { + const parseOptions: babelParser.ParserOptions = { + sourceType: 'module', + plugins: ['jsx', 'typescript', 'topLevelAwait'], + }; + let parseResult; + try { + parseResult = babelParser.parse(module.content, parseOptions); + } catch (err) { + const location = { start: err.loc }; + const frame = codeFrameColumns(module.content, location); + err.frame = frame; + err.filename = state.filename; + err.start = err.loc; + throw err; + } + const program = parseResult.program; + + const { body } = program; + let i = body.length; + while (--i >= 0) { + const node = body[i]; + switch (node.type) { + case 'ExportNamedDeclaration': { + if (!node.declaration) break; + // const replacement = extract_exports(node); + + if (node.declaration.type === 'VariableDeclaration') { + // case 1: prop (export let title) + + const declaration = node.declaration.declarations[0]; + if ((declaration.id as Identifier).name === '__layout' || (declaration.id as Identifier).name === '__content') { + componentExports.push(node); + } else { + componentProps.push(declaration); + } + body.splice(i, 1); + } else if (node.declaration.type === 'FunctionDeclaration') { + // case 2: createCollection (export async function) + if (!node.declaration.id || node.declaration.id.name !== 'createCollection') break; + createCollection = module.content.substring(node.declaration.start || 0, node.declaration.end || 0); + + // remove node + body.splice(i, 1); + } + break; + } + case 'FunctionDeclaration': { + break; + } + case 'ImportDeclaration': { + componentImports.push(node); + body.splice(i, 1); // remove node + break; + } + case 'VariableDeclaration': { + for (const declaration of node.declarations) { + // only select Astro.fetchContent() calls here. this utility filters those out for us. + if (!isFetchContent(declaration)) continue; + + // remove node + body.splice(i, 1); + + // a bit of munging + let { id, init } = declaration; + if (!id || !init || id.type !== 'Identifier') continue; + if (init.type === 'AwaitExpression') { + init = init.argument; + const shortname = path.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename); + warn(compileOptions.logging, shortname, yellow('awaiting Astro.fetchContent() not necessary')); + } + if (init.type !== 'CallExpression') continue; + + // gather data + const namespace = id.name; + + if ((init as any).arguments[0].type !== 'StringLiteral') { + throw new Error(`[Astro.fetchContent] Only string literals allowed, ex: \`Astro.fetchContent('./post/*.md')\`\n ${state.filename}`); + } + const spec = (init as any).arguments[0].value; + if (typeof spec === 'string') contentImports.set(namespace, { spec, declarator: node.kind }); + } + break; + } + } + } + + for (const componentImport of componentImports) { + const importUrl = componentImport.source.value; + const componentType = path.posix.extname(importUrl); + 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 plugin = extensions[componentType] || defaultExtensions[componentType]; + state.components[componentName] = { + type: componentType, + plugin, + url: importUrl, + }; + if (plugin) { + componentPlugins.add(plugin); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + state.importExportStatements.add(module.content.slice(componentImport.start!, componentImport.end!)); + } + for (const componentImport of componentExports) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + state.importExportStatements.add(module.content.slice(componentImport.start!, componentImport.end!)); + } + + if (componentProps.length > 0) { + propsStatement = 'let {'; + for (const componentExport of componentProps) { + propsStatement += `${(componentExport.id as Identifier).name}`; + if (componentExport.init) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + propsStatement += `= ${babelGenerator(componentExport.init!).code}`; + } + propsStatement += `,`; + } + propsStatement += `} = props;\n`; + } + + // handle createCollection, if any + if (createCollection) { + // TODO: improve this? while transforming in-place isn’t great, this happens at most once per-route + const ast = babelParser.parse(createCollection, { + sourceType: 'module', + }); + traverse(ast, { + enter({ node }) { + switch (node.type) { + case 'VariableDeclaration': { + for (const declaration of node.declarations) { + // only select Astro.fetchContent() calls here. this utility filters those out for us. + if (!isFetchContent(declaration)) continue; + + // a bit of munging + let { id, init } = declaration; + if (!id || !init || id.type !== 'Identifier') continue; + if (init.type === 'AwaitExpression') { + init = init.argument; + const shortname = path.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename); + warn(compileOptions.logging, shortname, yellow('awaiting Astro.fetchContent() not necessary')); + } + if (init.type !== 'CallExpression') continue; + + // gather data + const namespace = id.name; + + if ((init as any).arguments[0].type !== 'StringLiteral') { + throw new Error(`[Astro.fetchContent] Only string literals allowed, ex: \`Astro.fetchContent('./post/*.md')\`\n ${state.filename}`); + } + const spec = (init as any).arguments[0].value; + if (typeof spec !== 'string') break; + + const globResult = fetchContent(spec, { namespace, filename: state.filename }); + + let imports = ''; + for (const importStatement of globResult.imports) { + imports += importStatement + '\n'; + } + + createCollection = + imports + '\nexport ' + createCollection.substring(0, declaration.start || 0) + globResult.code + createCollection.substring(declaration.end || 0); + } + break; + } + } + }, + }); + } + + // Astro.fetchContent() + for (const [namespace, { spec }] of contentImports.entries()) { + const globResult = fetchContent(spec, { namespace, filename: state.filename }); + for (const importStatement of globResult.imports) { + state.importExportStatements.add(importStatement); + } + contentCode += globResult.code; + } + + script = propsStatement + contentCode + babelGenerator(program).code; + } + + return { + script, + componentPlugins, + createCollection: createCollection || undefined, + }; +} + +/** Compile styles */ +function compileCss(style: Style, state: CodegenState) { + walk(style, { + enter(node: TemplateNode) { + if (node.type === 'Style') { + state.css.push(node.content.styles); // if multiple <style> tags, combine together + this.skip(); + } + }, + leave(node: TemplateNode) { + if (node.type === 'Style') { + this.remove(); // this will be optimized in a global CSS file; remove so it‘s not accidentally inlined + } + }, + }); +} + +/** Compile page markup */ +function compileHtml(enterNode: TemplateNode, state: CodegenState, compileOptions: CompileOptions) { + const { components, css, importExportStatements, dynamicImports, filename } = state; + const { astroConfig } = compileOptions; + + let outSource = ''; + walk(enterNode, { + enter(node: TemplateNode) { + switch (node.type) { + case 'Expression': { + let children: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + for (const child of node.children!) { + children.push(compileHtml(child, state, compileOptions)); + } + let raw = ''; + let nextChildIndex = 0; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + for (const chunk of node.codeChunks!) { + raw += chunk; + if (nextChildIndex < children.length) { + raw += children[nextChildIndex++]; + } + } + // TODO Do we need to compile this now, or should we compile the entire module at the end? + let code = compileExpressionSafe(raw).trim().replace(/\;$/, ''); + outSource += `,(${code})`; + this.skip(); + break; + } + case 'MustacheTag': + case 'Comment': + return; + case 'Fragment': + break; + case 'Slot': + case 'Head': + case 'InlineComponent': + case 'Title': + case 'Element': { + const name: string = node.name; + if (!name) { + throw new Error('AHHHH'); + } + const attributes = getAttributes(node.attributes); + + 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'}`; + 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); + } + + outSource += `h(${wrapper}, ${attributes ? generateAttributes(attributes) : 'null'}`; + return; + } + case 'Attribute': { + this.skip(); + return; + } + case 'Style': { + css.push(node.content.styles); // if multiple <style> tags, combine together + this.skip(); + return; + } + case 'Text': { + const text = getTextFromAttribute(node); + if (!text.trim()) { + return; + } + outSource += ',' + JSON.stringify(text); + return; + } + default: + throw new Error('Unexpected (enter) node type: ' + node.type); + } + }, + leave(node, parent, prop, index) { + switch (node.type) { + case 'Text': + case 'Attribute': + case 'Comment': + case 'Fragment': + case 'Expression': + case 'MustacheTag': + return; + case 'Slot': + case 'Head': + case 'Body': + case 'Title': + case 'Element': + case 'InlineComponent': + outSource += ')'; + return; + case 'Style': { + this.remove(); // this will be optimized in a global CSS file; remove so it‘s not accidentally inlined + return; + } + default: + throw new Error('Unexpected (leave) node type: ' + node.type); + } + }, + }); + + return outSource; +} + +/** + * Codegen + * Step 3/3 in Astro SSR. + * This is the final pass over a document AST before it‘s converted to an h() function + * and handed off to Snowpack to build. + * @param {Ast} AST The parsed AST to crawl + * @param {object} CodeGenOptions + */ +export async function codegen(ast: Ast, { compileOptions, filename }: CodeGenOptions): Promise<TransformResult> { + await eslexer.init; + + const state: CodegenState = { + filename, + components: {}, + css: [], + importExportStatements: new Set(), + dynamicImports: new Map(), + }; + + const { script, componentPlugins, createCollection } = compileModule(ast.module, state, compileOptions); + state.dynamicImports = await acquireDynamicComponentImports(componentPlugins, compileOptions.resolvePackageUrl); + + compileCss(ast.css, state); + + const html = compileHtml(ast.html, state, compileOptions); + + return { + script: script, + imports: Array.from(state.importExportStatements), + html, + css: state.css.length ? state.css.join('\n\n') : undefined, + createCollection, + }; +} diff --git a/packages/astro/src/compiler/codegen/utils.ts b/packages/astro/src/compiler/codegen/utils.ts new file mode 100644 index 000000000..e1c558bc4 --- /dev/null +++ b/packages/astro/src/compiler/codegen/utils.ts @@ -0,0 +1,39 @@ +/** + * Codegen utils + */ + +import type { VariableDeclarator } from '@babel/types'; + +/** Is this an import.meta.* built-in? You can pass an optional 2nd param to see if the name matches as well. */ +export function isImportMetaDeclaration(declaration: VariableDeclarator, metaName?: string): boolean { + let { init } = declaration; + if (!init) return false; // definitely not import.meta + // this could be `await import.meta`; if so, evaluate that: + if (init.type === 'AwaitExpression') { + init = init.argument; + } + // continue evaluating + if (init.type !== 'CallExpression' || init.callee.type !== 'MemberExpression' || init.callee.object.type !== 'MetaProperty') return false; + // optional: if metaName specified, match that + if (metaName && (init.callee.property.type !== 'Identifier' || init.callee.property.name !== metaName)) return false; + return true; +} + +/** Is this an Astro.fetchContent() call? */ +export function isFetchContent(declaration: VariableDeclarator): boolean { + let { init } = declaration; + if (!init) return false; // definitely not import.meta + // this could be `await import.meta`; if so, evaluate that: + if (init.type === 'AwaitExpression') { + init = init.argument; + } + // continue evaluating + if ( + init.type !== 'CallExpression' || + init.callee.type !== 'MemberExpression' || + (init.callee.object as any).name !== 'Astro' || + (init.callee.property as any).name !== 'fetchContent' + ) + return false; + return true; +} diff --git a/packages/astro/src/compiler/index.ts b/packages/astro/src/compiler/index.ts new file mode 100644 index 000000000..bb8ac61d4 --- /dev/null +++ b/packages/astro/src/compiler/index.ts @@ -0,0 +1,176 @@ +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 { 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'; + +/** Return Astro internal import URL */ +function internalImport(internalPath: string) { + return `/_astro_internal/${internalPath}`; +} + +interface ConvertAstroOptions { + compileOptions: CompileOptions; + filename: string; + fileID: string; +} + +/** + * .astro -> .jsx + * Core function processing .astro files. Initiates all 3 phases of compilation: + * 1. Parse + * 2. Transform + * 3. Codegen + */ +async function convertAstroToJsx(template: string, opts: ConvertAstroOptions): Promise<TransformResult> { + const { filename } = opts; + + // 1. Parse + const ast = parse(template, { + filename, + }); + + // 2. Transform the AST + await transform(ast, opts); + + // 3. Turn AST into JSX + return await codegen(ast, opts); +} + +/** + * .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" + const contentData: any = { + ...frontmatterData, + headers, + source: content, + }; + + 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}; +--- +<section>${mdHtml}</section>`; +} + +/** + * .md -> .jsx + * Core function processing Markdown, but along the way also calls convertAstroToJsx(). + */ +async function convertMdToJsx( + contents: string, + { compileOptions, filename, fileID }: { compileOptions: CompileOptions; filename: string; fileID: string } +): Promise<TransformResult> { + const raw = await convertMdToAstroSource(contents); + const convertOptions = { compileOptions, filename, fileID }; + return await convertAstroToJsx(raw, convertOptions); +} + +type SupportedExtensions = '.astro' | '.md'; + +/** Given a file, process it either as .astro or .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': + return await convertAstroToJsx(contents, { compileOptions, filename, fileID }); + case '.md': + return await convertMdToJsx(contents, { compileOptions, filename, fileID }); + default: + throw new Error('Not Supported!'); + } +} + +/** Return internal code that gets processed in Snowpack */ +export async function compileComponent( + source: string, + { compileOptions, filename, projectRoot }: { compileOptions: CompileOptions; filename: string; projectRoot: string } +): Promise<CompileResult> { + const result = await transformFromSource(source, { compileOptions, filename, projectRoot }); + + // return template + let modJsx = ` +import fetch from 'node-fetch'; + +// <script astro></script> +${result.imports.join('\n')} + +// \`__render()\`: Render the contents of the Astro module. +import { h, Fragment } from '${internalImport('h.js')}'; +const __astroRequestSymbol = Symbol('astro.request'); +async function __render(props, ...children) { + const Astro = { + request: props[__astroRequestSymbol] + }; + + ${result.script} + return h(Fragment, null, ${result.html}); +} +export default __render; + +${result.createCollection || ''} + +// \`__renderPage()\`: Render the contents of the Astro module as a page. This is a special flow, +// triggered by loading a component directly by URL. +export async function __renderPage({request, children, props}) { + const currentChild = { + layout: typeof __layout === 'undefined' ? undefined : __layout, + content: typeof __content === 'undefined' ? undefined : __content, + __render, + }; + + props[__astroRequestSymbol] = request; + const childBodyResult = await currentChild.__render(props, children); + + // find layout, if one was given. + if (currentChild.layout) { + return currentChild.layout({ + request, + props: {content: currentChild.content}, + children: [childBodyResult], + }); + } + + return childBodyResult; +};\n`; + + return { + result, + contents: modJsx, + css: result.css, + }; +} diff --git a/packages/astro/src/compiler/markdown/micromark-collect-headers.ts b/packages/astro/src/compiler/markdown/micromark-collect-headers.ts new file mode 100644 index 000000000..69781231a --- /dev/null +++ b/packages/astro/src/compiler/markdown/micromark-collect-headers.ts @@ -0,0 +1,38 @@ +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 new file mode 100644 index 000000000..635ab3b54 --- /dev/null +++ b/packages/astro/src/compiler/markdown/micromark-encode.ts @@ -0,0 +1,36 @@ +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 new file mode 100644 index 000000000..b978ad407 --- /dev/null +++ b/packages/astro/src/compiler/markdown/micromark-mdx-astro.ts @@ -0,0 +1,22 @@ +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 new file mode 100644 index 000000000..fd094306e --- /dev/null +++ b/packages/astro/src/compiler/markdown/micromark.d.ts @@ -0,0 +1,11 @@ +declare module 'micromark-extension-mdx-expression' { + import type { HtmlExtension } from 'micromark/dist/shared-types'; + + export default function (): HtmlExtension; +} + +declare module 'micromark-extension-mdx-jsx' { + import type { HtmlExtension } from 'micromark/dist/shared-types'; + + export default function (): HtmlExtension; +} diff --git a/packages/astro/src/compiler/transform/doctype.ts b/packages/astro/src/compiler/transform/doctype.ts new file mode 100644 index 000000000..e871f5b48 --- /dev/null +++ b/packages/astro/src/compiler/transform/doctype.ts @@ -0,0 +1,36 @@ +import { Transformer } from '../../@types/transformer'; + +/** Transform <!doctype> tg */ +export default function (_opts: { filename: string; fileID: string }): Transformer { + let hasDoctype = false; + + return { + visitors: { + html: { + Element: { + enter(node, parent, _key, index) { + if (node.name === '!doctype') { + hasDoctype = true; + } + if (node.name === 'html' && !hasDoctype) { + const dtNode = { + start: 0, + end: 0, + attributes: [{ type: 'Attribute', name: 'html', value: true, start: 0, end: 0 }], + children: [], + name: '!doctype', + type: 'Element', + }; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + parent.children!.splice(index, 0, dtNode); + hasDoctype = true; + } + }, + }, + }, + }, + async finalize() { + // Nothing happening here. + }, + }; +} diff --git a/packages/astro/src/compiler/transform/index.ts b/packages/astro/src/compiler/transform/index.ts new file mode 100644 index 000000000..2fe0d0e73 --- /dev/null +++ b/packages/astro/src/compiler/transform/index.ts @@ -0,0 +1,100 @@ +import type { Ast, TemplateNode } from 'astro-parser'; +import type { NodeVisitor, TransformOptions, Transformer, VisitorFn } from '../../@types/transformer'; + +import { walk } from 'estree-walker'; + +// Transformers +import transformStyles from './styles.js'; +import transformDoctype from './doctype.js'; +import transformModuleScripts from './module-scripts.js'; +import transformCodeBlocks from './prism.js'; + +interface VisitorCollection { + enter: Map<string, VisitorFn[]>; + leave: Map<string, VisitorFn[]>; +} + +/** Add visitors to given collection */ +function addVisitor(visitor: NodeVisitor, collection: VisitorCollection, nodeName: string, event: 'enter' | 'leave') { + if (typeof visitor[event] !== 'function') return; + if (!collection[event]) collection[event] = new Map<string, VisitorFn[]>(); + + const visitors = collection[event].get(nodeName) || []; + visitors.push(visitor[event] as any); + collection[event].set(nodeName, visitors); +} + +/** Compile visitor actions from transformer */ +function collectVisitors(transformer: Transformer, htmlVisitors: VisitorCollection, cssVisitors: VisitorCollection, finalizers: Array<() => Promise<void>>) { + if (transformer.visitors) { + if (transformer.visitors.html) { + for (const [nodeName, visitor] of Object.entries(transformer.visitors.html)) { + addVisitor(visitor, htmlVisitors, nodeName, 'enter'); + addVisitor(visitor, htmlVisitors, nodeName, 'leave'); + } + } + if (transformer.visitors.css) { + for (const [nodeName, visitor] of Object.entries(transformer.visitors.css)) { + addVisitor(visitor, cssVisitors, nodeName, 'enter'); + addVisitor(visitor, cssVisitors, nodeName, 'leave'); + } + } + } + finalizers.push(transformer.finalize); +} + +/** Utility for formatting visitors */ +function createVisitorCollection() { + return { + enter: new Map<string, VisitorFn[]>(), + leave: new Map<string, VisitorFn[]>(), + }; +} + +/** Walk AST with collected visitors */ +function walkAstWithVisitors(tmpl: TemplateNode, collection: VisitorCollection) { + walk(tmpl, { + enter(node, parent, key, index) { + if (collection.enter.has(node.type)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const fns = collection.enter.get(node.type)!; + for (let fn of fns) { + fn.call(this, node, parent, key, index); + } + } + }, + leave(node, parent, key, index) { + if (collection.leave.has(node.type)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const fns = collection.leave.get(node.type)!; + for (let fn of fns) { + fn.call(this, node, parent, key, index); + } + } + }, + }); +} + +/** + * Transform + * Step 2/3 in Astro SSR. + * Transform is the point at which we mutate the AST before sending off to + * Codegen, and then to Snowpack. In some ways, it‘s a preprocessor. + */ +export async function transform(ast: Ast, opts: TransformOptions) { + const htmlVisitors = createVisitorCollection(); + const cssVisitors = createVisitorCollection(); + const finalizers: Array<() => Promise<void>> = []; + + const optimizers = [transformStyles(opts), transformDoctype(opts), transformModuleScripts(opts), transformCodeBlocks(ast.module)]; + + for (const optimizer of optimizers) { + collectVisitors(optimizer, htmlVisitors, cssVisitors, finalizers); + } + + walkAstWithVisitors(ast.css, cssVisitors); + walkAstWithVisitors(ast.html, htmlVisitors); + + // Run all of the finalizer functions in parallel because why not. + await Promise.all(finalizers.map((fn) => fn())); +} diff --git a/packages/astro/src/compiler/transform/module-scripts.ts b/packages/astro/src/compiler/transform/module-scripts.ts new file mode 100644 index 000000000..aff1ec4f6 --- /dev/null +++ b/packages/astro/src/compiler/transform/module-scripts.ts @@ -0,0 +1,43 @@ +import type { Transformer } from '../../@types/transformer'; +import type { CompileOptions } from '../../@types/compiler'; + +import path from 'path'; +import { getAttrValue, setAttrValue } from '../../ast.js'; + +/** Transform <script type="module"> */ +export default function ({ compileOptions, filename }: { compileOptions: CompileOptions; filename: string; fileID: string }): Transformer { + const { astroConfig } = compileOptions; + const { astroRoot } = astroConfig; + const fileUrl = new URL(`file://${filename}`); + + return { + visitors: { + html: { + Element: { + enter(node) { + let name = node.name; + if (name !== 'script') { + return; + } + + let type = getAttrValue(node.attributes, 'type'); + if (type !== 'module') { + return; + } + + let src = getAttrValue(node.attributes, 'src'); + if (!src || !src.startsWith('.')) { + return; + } + + const srcUrl = new URL(src, fileUrl); + const fromAstroRoot = path.posix.relative(astroRoot.pathname, srcUrl.pathname); + const absoluteUrl = `/_astro/${fromAstroRoot}`; + setAttrValue(node.attributes, 'src', absoluteUrl); + }, + }, + }, + }, + async finalize() {}, + }; +} diff --git a/packages/astro/src/compiler/transform/postcss-scoped-styles/index.ts b/packages/astro/src/compiler/transform/postcss-scoped-styles/index.ts new file mode 100644 index 000000000..23350869c --- /dev/null +++ b/packages/astro/src/compiler/transform/postcss-scoped-styles/index.ts @@ -0,0 +1,106 @@ +import { Declaration, Plugin } from 'postcss'; + +interface AstroScopedOptions { + className: string; +} + +interface Selector { + start: number; + end: number; + value: string; +} + +const CSS_SEPARATORS = new Set([' ', ',', '+', '>', '~']); +const KEYFRAME_PERCENT = /\d+\.?\d*%/; + +/** HTML tags that should never get scoped classes */ +export const NEVER_SCOPED_TAGS = new Set<string>(['base', 'body', 'font', 'frame', 'frameset', 'head', 'html', 'link', 'meta', 'noframes', 'noscript', 'script', 'style', 'title']); + +/** + * Scope Rules + * Given a selector string (`.btn>span,.nav>span`), add an additional CSS class to every selector (`.btn.myClass>span.myClass,.nav.myClass>span.myClass`) + * @param {string} selector The minified selector string to parse. Cannot contain arbitrary whitespace (other than child selector syntax). + * @param {string} className The CSS class to apply. + */ +export function scopeRule(selector: string, className: string) { + // if this is a keyframe keyword, return original selector + if (selector === 'from' || selector === 'to' || KEYFRAME_PERCENT.test(selector)) { + return selector; + } + + // For everything else, parse & scope + const c = className.replace(/^\.?/, '.'); // make sure class always has leading '.' + const selectors: Selector[] = []; + let ss = selector; // final output + + // Pass 1: parse selector string; extract top-level selectors + { + let start = 0; + let lastValue = ''; + let parensOpen = false; + for (let n = 0; n < ss.length; n++) { + const isEnd = n === selector.length - 1; + if (selector[n] === '(') parensOpen = true; + if (selector[n] === ')') parensOpen = false; + if (isEnd || (parensOpen === false && CSS_SEPARATORS.has(selector[n]))) { + lastValue = selector.substring(start, isEnd ? undefined : n); + if (!lastValue) continue; + selectors.push({ start, end: isEnd ? n + 1 : n, value: lastValue }); + start = n + 1; + } + } + } + + // Pass 2: starting from end, transform selectors w/ scoped class + for (let i = selectors.length - 1; i >= 0; i--) { + const { start, end, value } = selectors[i]; + const head = ss.substring(0, start); + const tail = ss.substring(end); + + // replace '*' with className + if (value === '*') { + ss = head + c + tail; + continue; + } + + // leave :global() alone! + if (value.startsWith(':global(')) { + ss = + head + + ss + .substring(start, end) + .replace(/^:global\(/, '') + .replace(/\)$/, '') + + tail; + continue; + } + + // don‘t scope body, title, etc. + if (NEVER_SCOPED_TAGS.has(value)) { + ss = head + value + tail; + continue; + } + + // scope everything else + let newSelector = ss.substring(start, end); + const pseudoIndex = newSelector.indexOf(':'); + if (pseudoIndex > 0) { + // if there‘s a pseudoclass (:focus) + ss = head + newSelector.substring(start, pseudoIndex) + c + newSelector.substr(pseudoIndex) + tail; + } else { + ss = head + newSelector + c + tail; + } + } + + return ss; +} + +/** PostCSS Scope plugin */ +export default function astroScopedStyles(options: AstroScopedOptions): Plugin { + return { + postcssPlugin: '@astro/postcss-scoped-styles', + Rule(rule) { + rule.selector = scopeRule(rule.selector, options.className); + }, + }; +} diff --git a/packages/astro/src/compiler/transform/prism.ts b/packages/astro/src/compiler/transform/prism.ts new file mode 100644 index 000000000..6f8eb5bba --- /dev/null +++ b/packages/astro/src/compiler/transform/prism.ts @@ -0,0 +1,89 @@ +import type { Transformer } from '../../@types/transformer'; +import type { Script } from 'astro-parser'; +import { getAttrValue } from '../../ast.js'; + +const PRISM_IMPORT = `import Prism from 'astro/components/Prism.astro';\n`; +const prismImportExp = /import Prism from ['"]astro\/components\/Prism.astro['"]/; +/** escaping code samples that contain template string replacement parts, ${foo} or example. */ +function escape(code: string) { + return code.replace(/[`$]/g, (match) => { + return '\\' + match; + }); +} +/** default export - Transform prism */ +export default function (module: Script): Transformer { + let usesPrism = false; + + return { + visitors: { + html: { + Element: { + enter(node) { + if (node.name !== 'code') return; + const className = getAttrValue(node.attributes, 'class') || ''; + const classes = className.split(' '); + + let lang; + for (let cn of classes) { + const matches = /language-(.+)/.exec(cn); + if (matches) { + lang = matches[1]; + } + } + + if (!lang) return; + + let code; + if (node.children?.length) { + code = node.children[0].data; + } + + const repl = { + start: 0, + end: 0, + type: 'InlineComponent', + name: 'Prism', + attributes: [ + { + type: 'Attribute', + name: 'lang', + value: [ + { + type: 'Text', + raw: lang, + data: lang, + }, + ], + }, + { + type: 'Attribute', + name: 'code', + value: [ + { + type: 'MustacheTag', + expression: { + type: 'Expression', + codeChunks: ['`' + escape(code) + '`'], + children: [], + }, + }, + ], + }, + ], + children: [], + }; + + this.replace(repl); + usesPrism = true; + }, + }, + }, + }, + async finalize() { + // Add the Prism import if needed. + if (usesPrism && !prismImportExp.test(module.content)) { + module.content = PRISM_IMPORT + module.content; + } + }, + }; +} diff --git a/packages/astro/src/compiler/transform/styles.ts b/packages/astro/src/compiler/transform/styles.ts new file mode 100644 index 000000000..efabf11fe --- /dev/null +++ b/packages/astro/src/compiler/transform/styles.ts @@ -0,0 +1,290 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import { createRequire } from 'module'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import autoprefixer from 'autoprefixer'; +import postcss, { Plugin } from 'postcss'; +import postcssKeyframes from 'postcss-icss-keyframes'; +import findUp from 'find-up'; +import sass from 'sass'; +import type { RuntimeMode } from '../../@types/astro'; +import type { TransformOptions, Transformer } from '../../@types/transformer'; +import type { TemplateNode } from 'astro-parser'; +import { debug } from '../../logger.js'; +import astroScopedStyles, { NEVER_SCOPED_TAGS } from './postcss-scoped-styles/index.js'; + +type StyleType = 'css' | 'scss' | 'sass' | 'postcss'; + +declare global { + interface ImportMeta { + /** https://nodejs.org/api/esm.html#esm_import_meta_resolve_specifier_parent */ + resolve(specifier: string, parent?: string): Promise<any>; + } +} + +const getStyleType: Map<string, StyleType> = new Map([ + ['.css', 'css'], + ['.pcss', 'postcss'], + ['.sass', 'sass'], + ['.scss', 'scss'], + ['css', 'css'], + ['sass', 'sass'], + ['scss', 'scss'], + ['text/css', 'css'], + ['text/sass', 'sass'], + ['text/scss', 'scss'], +]); + +/** Should be deterministic, given a unique filename */ +function hashFromFilename(filename: string): string { + const hash = crypto.createHash('sha256'); + return hash + .update(filename.replace(/\\/g, '/')) + .digest('base64') + .toString() + .replace(/[^A-Za-z0-9-]/g, '') + .substr(0, 8); +} + +export interface StyleTransformResult { + css: string; + type: StyleType; +} + +interface StylesMiniCache { + nodeModules: Map<string, string>; // filename: node_modules location + tailwindEnabled?: boolean; // cache once per-run +} + +/** Simple cache that only exists in memory per-run. Prevents the same lookups from happening over and over again within the same build or dev server session. */ +const miniCache: StylesMiniCache = { + nodeModules: new Map<string, string>(), +}; + +export interface TransformStyleOptions { + type?: string; + filename: string; + scopedClass: string; + mode: RuntimeMode; +} + +/** given a class="" string, does it contain a given class? */ +function hasClass(classList: string, className: string): boolean { + if (!className) return false; + for (const c of classList.split(' ')) { + if (className === c.trim()) return true; + } + return false; +} + +/** Convert styles to scoped CSS */ +async function transformStyle(code: string, { type, filename, scopedClass, mode }: TransformStyleOptions): Promise<StyleTransformResult> { + let styleType: StyleType = 'css'; // important: assume CSS as default + if (type) { + styleType = getStyleType.get(type) || styleType; + } + + // add file path to includePaths + let includePaths: string[] = [path.dirname(filename)]; + + // include node_modules to includePaths (allows @use-ing node modules, if it can be located) + const cachedNodeModulesDir = miniCache.nodeModules.get(filename); + if (cachedNodeModulesDir) { + includePaths.push(cachedNodeModulesDir); + } else { + const nodeModulesDir = await findUp('node_modules', { type: 'directory', cwd: path.dirname(filename) }); + if (nodeModulesDir) { + miniCache.nodeModules.set(filename, nodeModulesDir); + includePaths.push(nodeModulesDir); + } + } + + // 1. Preprocess (currently only Sass supported) + let css = ''; + switch (styleType) { + case 'css': { + css = code; + break; + } + case 'sass': + case 'scss': { + css = sass.renderSync({ data: code, includePaths }).css.toString('utf8'); + break; + } + default: { + throw new Error(`Unsupported: <style lang="${styleType}">`); + } + } + + // 2. Post-process (PostCSS) + const postcssPlugins: Plugin[] = []; + + // 2a. Tailwind (only if project uses Tailwind) + if (miniCache.tailwindEnabled) { + try { + const require = createRequire(import.meta.url); + const tw = require.resolve('tailwindcss', { paths: [import.meta.url, process.cwd()] }); + postcssPlugins.push(require(tw) as any); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + throw new Error(`tailwindcss not installed. Try running \`npm install tailwindcss\` and trying again.`); + } + } + + // 2b. Astro scoped styles (always on) + postcssPlugins.push(astroScopedStyles({ className: scopedClass })); + + // 2c. Scoped @keyframes + postcssPlugins.push( + postcssKeyframes({ + generateScopedName(keyframesName) { + return `${keyframesName}-${scopedClass}`; + }, + }) + ); + + // 2d. Autoprefixer (always on) + postcssPlugins.push(autoprefixer()); + + // 2e. Run PostCSS + css = await postcss(postcssPlugins) + .process(css, { from: filename, to: undefined }) + .then((result) => result.css); + + return { css, type: styleType }; +} + +/** Transform <style> tags */ +export default function transformStyles({ compileOptions, filename, fileID }: TransformOptions): Transformer { + const styleNodes: TemplateNode[] = []; // <style> tags to be updated + const styleTransformPromises: Promise<StyleTransformResult>[] = []; // async style transform results to be finished in finalize(); + const scopedClass = `astro-${hashFromFilename(fileID)}`; // this *should* generate same hash from fileID every time + + // find Tailwind config, if first run (cache for subsequent runs) + if (miniCache.tailwindEnabled === undefined) { + const tailwindNames = ['tailwind.config.js', 'tailwind.config.mjs']; + for (const loc of tailwindNames) { + const tailwindLoc = path.join(fileURLToPath(compileOptions.astroConfig.projectRoot), loc); + if (fs.existsSync(tailwindLoc)) { + miniCache.tailwindEnabled = true; // Success! We have a Tailwind config file. + debug(compileOptions.logging, 'tailwind', 'Found config. Enabling.'); + break; + } + } + if (miniCache.tailwindEnabled !== true) miniCache.tailwindEnabled = false; // We couldn‘t find one; mark as false + debug(compileOptions.logging, 'tailwind', 'No config found. Skipping.'); + } + + return { + visitors: { + html: { + Element: { + enter(node) { + // 1. if <style> tag, transform it and continue to next node + if (node.name === 'style') { + // Same as ast.css (below) + const code = Array.isArray(node.children) ? node.children.map(({ data }: any) => data).join('\n') : ''; + if (!code) return; + const langAttr = (node.attributes || []).find(({ name }: any) => name === 'lang'); + styleNodes.push(node); + styleTransformPromises.push( + transformStyle(code, { + type: (langAttr && langAttr.value[0] && langAttr.value[0].data) || undefined, + filename, + scopedClass, + mode: compileOptions.mode, + }) + ); + return; + } + + // 2. add scoped HTML classes + 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}'`; + } + } + } + } + }, + }, + }, + // CSS: compile styles, apply CSS Modules scoping + css: { + Style: { + enter(node) { + // Same as ast.html (above) + // Note: this is duplicated from html because of the compiler we‘re using; in a future version we should combine these + if (!node.content || !node.content.styles) return; + const code = node.content.styles; + const langAttr = (node.attributes || []).find(({ name }: any) => name === 'lang'); + styleNodes.push(node); + styleTransformPromises.push( + transformStyle(code, { + type: (langAttr && langAttr.value[0] && langAttr.value[0].data) || undefined, + filename, + scopedClass, + mode: compileOptions.mode, + }) + ); + }, + }, + }, + }, + async finalize() { + const styleTransforms = await Promise.all(styleTransformPromises); + + styleTransforms.forEach((result, n) => { + if (styleNodes[n].attributes) { + // 1. Replace with final CSS + const isHeadStyle = !styleNodes[n].content; + if (isHeadStyle) { + // Note: <style> tags in <head> have different attributes/rules, because of the parser. Unknown why + (styleNodes[n].children as any) = [{ ...(styleNodes[n].children as any)[0], data: result.css }]; + } else { + styleNodes[n].content.styles = result.css; + } + + // 2. Update <style> attributes + const styleTypeIndex = styleNodes[n].attributes.findIndex(({ name }: any) => name === 'type'); + // add type="text/css" + if (styleTypeIndex !== -1) { + styleNodes[n].attributes[styleTypeIndex].value[0].raw = 'text/css'; + styleNodes[n].attributes[styleTypeIndex].value[0].data = 'text/css'; + } else { + styleNodes[n].attributes.push({ name: 'type', type: 'Attribute', value: [{ type: 'Text', raw: 'text/css', data: 'text/css' }] }); + } + // remove lang="*" + const styleLangIndex = styleNodes[n].attributes.findIndex(({ name }: any) => name === 'lang'); + if (styleLangIndex !== -1) styleNodes[n].attributes.splice(styleLangIndex, 1); + // TODO: add data-astro for later + // styleNodes[n].attributes.push({ name: 'data-astro', type: 'Attribute', value: true }); + } + }); + }, + }; +} diff --git a/packages/astro/src/config.ts b/packages/astro/src/config.ts new file mode 100644 index 000000000..8f3ebaf5a --- /dev/null +++ b/packages/astro/src/config.ts @@ -0,0 +1,85 @@ +import type { AstroConfig } from './@types/astro'; +import { join as pathJoin, resolve as pathResolve } from 'path'; +import { existsSync } from 'fs'; + +/** Type util */ +const type = (thing: any): string => (Array.isArray(thing) ? 'Array' : typeof thing); + +/** Throws error if a user provided an invalid config. Manually-implemented to avoid a heavy validation library. */ +function validateConfig(config: any): void { + // basic + if (config === undefined || config === null) throw new Error(`[astro config] Config empty!`); + if (typeof config !== 'object') throw new Error(`[astro config] Expected object, received ${typeof config}`); + + // strings + for (const key of ['projectRoot', 'astroRoot', 'dist', 'public', 'site']) { + if (config[key] !== undefined && config[key] !== null && typeof config[key] !== 'string') { + throw new Error(`[astro config] ${key}: ${JSON.stringify(config[key])}\n Expected string, received ${type(config[key])}.`); + } + } + + // booleans + for (const key of ['sitemap']) { + if (config[key] !== undefined && config[key] !== null && typeof config[key] !== 'boolean') { + throw new Error(`[astro config] ${key}: ${JSON.stringify(config[key])}\n Expected boolean, received ${type(config[key])}.`); + } + } + + if(typeof config.devOptions?.port !== 'number') { + throw new Error(`[astro config] devOptions.port: Expected number, received ${type(config.devOptions?.port)}`) + } +} + +/** Set default config values */ +function configDefaults(userConfig?: any): any { + const config: any = { ...(userConfig || {}) }; + + if (!config.projectRoot) config.projectRoot = '.'; + if (!config.astroRoot) config.astroRoot = './src'; + if (!config.dist) config.dist = './dist'; + if (!config.public) config.public = './public'; + if (!config.devOptions) config.devOptions = {}; + if (!config.devOptions.port) config.devOptions.port = 3000; + if (!config.buildOptions) config.buildOptions = {}; + if (typeof config.buildOptions.sitemap === 'undefined') config.buildOptions.sitemap = true; + + return config; +} + +/** Turn raw config values into normalized values */ +function normalizeConfig(userConfig: any, root: string): AstroConfig { + const config: any = { ...(userConfig || {}) }; + + const fileProtocolRoot = `file://${root}/`; + config.projectRoot = new URL(config.projectRoot + '/', fileProtocolRoot); + config.astroRoot = new URL(config.astroRoot + '/', fileProtocolRoot); + config.public = new URL(config.public + '/', fileProtocolRoot); + + return config as AstroConfig; +} + +/** Attempt to load an `astro.config.mjs` file */ +export async function loadConfig(rawRoot: string | undefined, configFileName = 'astro.config.mjs'): Promise<AstroConfig> { + if (typeof rawRoot === 'undefined') { + rawRoot = process.cwd(); + } + + const root = pathResolve(rawRoot); + const astroConfigPath = pathJoin(root, configFileName); + + // load + let config: any; + if (existsSync(astroConfigPath)) { + config = configDefaults((await import(astroConfigPath)).default); + } else { + config = configDefaults(); + } + + // validate + validateConfig(config); + + // normalize + config = normalizeConfig(config, root); + + return config as AstroConfig; +} diff --git a/packages/astro/src/dev.ts b/packages/astro/src/dev.ts new file mode 100644 index 000000000..4ca8e28e9 --- /dev/null +++ b/packages/astro/src/dev.ts @@ -0,0 +1,97 @@ +import type { AstroConfig } from './@types/astro'; +import type { LogOptions } from './logger.js'; + +import { logger as snowpackLogger } from 'snowpack'; +import { bold, green } from 'kleur/colors'; +import http from 'http'; +import { relative as pathRelative } from 'path'; +import { performance } from 'perf_hooks'; +import { defaultLogDestination, error, info, parseError } from './logger.js'; +import { createRuntime } from './runtime.js'; + +const hostname = '127.0.0.1'; + +// Disable snowpack from writing to stdout/err. +snowpackLogger.level = 'silent'; + +const logging: LogOptions = { + level: 'debug', + dest: defaultLogDestination, +}; + +/** The primary dev action */ +export default async function dev(astroConfig: AstroConfig) { + const startServerTime = performance.now(); + const { projectRoot } = astroConfig; + + const runtime = await createRuntime(astroConfig, { mode: 'development', logging }); + + const server = http.createServer(async (req, res) => { + const result = await runtime.load(req.url); + + switch (result.statusCode) { + case 200: { + if (result.contentType) { + res.setHeader('Content-Type', result.contentType); + } + res.statusCode = 200; + res.write(result.contents); + res.end(); + break; + } + case 404: { + const fullurl = new URL(req.url || '/', 'https://example.org/'); + const reqPath = decodeURI(fullurl.pathname); + error(logging, 'static', 'Not found', reqPath); + res.statusCode = 404; + + const fourOhFourResult = await runtime.load('/404'); + if (fourOhFourResult.statusCode === 200) { + if (fourOhFourResult.contentType) { + res.setHeader('Content-Type', fourOhFourResult.contentType); + } + res.write(fourOhFourResult.contents); + } else { + res.setHeader('Content-Type', 'text/plain'); + res.write('Not Found'); + } + res.end(); + break; + } + case 500: { + switch (result.type) { + case 'parse-error': { + const err = result.error; + err.filename = pathRelative(projectRoot.pathname, err.filename); + parseError(logging, err); + break; + } + default: { + error(logging, 'executing astro', result.error); + break; + } + } + res.statusCode = 500; + + let errorResult = await runtime.load(`/500?error=${encodeURIComponent(result.error.stack || result.error.toString())}`); + if(errorResult.statusCode === 200) { + if (errorResult.contentType) { + res.setHeader('Content-Type', errorResult.contentType); + } + res.write(errorResult.contents); + } else { + res.write(result.error.toString()); + } + res.end(); + break; + } + } + }); + + const port = astroConfig.devOptions.port; + server.listen(port, hostname, () => { + const endServerTime = performance.now(); + info(logging, 'dev server', green(`Server started in ${Math.floor(endServerTime - startServerTime)}ms.`)); + info(logging, 'dev server', `${green('Local:')} http://${hostname}:${port}/`); + }); +} diff --git a/packages/astro/src/frontend/500.astro b/packages/astro/src/frontend/500.astro new file mode 100644 index 000000000..01fab8bea --- /dev/null +++ b/packages/astro/src/frontend/500.astro @@ -0,0 +1,128 @@ +--- +import Prism from 'astro/components/Prism.astro'; +let title = 'Uh oh...'; + +const error = Astro.request.url.searchParams.get('error'); +--- + +<!doctype html> +<html lang="en"> + <head> + <title>Error 500</title> + <link rel="preconnect"href="https://fonts.gstatic.com"> + <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&family=IBM+Plex+Sans&display=swap"> + <link rel="stylesheet" href="http://cdn.skypack.dev/prism-themes/themes/prism-material-dark.css"> + + <style> + * { + box-sizing: border-box; + margin: 0; + } + + :global(:root) { + --font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + --font-mono: "IBM Plex Mono", Consolas, "Andale Mono WT", "Andale Mono", + "Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono", + "Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", Monaco, + "Courier New", Courier, monospace; + --color-gray-800: #1F2937; + --color-gray-500: #6B7280; + --color-gray-400: #9CA3AF; + --color-gray-100: #F3F4F6; + --color-red: #FF1639; + } + + html, body { + width: 100vw; + height: 100%; + min-height: 100vh; + + font-family: var(--font-sans); + font-weight: 400; + background: var(--color-gray-100); + text-align: center; + } + + body { + display: grid; + place-content: center; + } + + header { + display: flex; + flex-direction: column; + align-items: center; + font-family: var(--font-sans); + font-size: 2.5rem; + font-size: clamp(24px, calc(2vw + 1rem), 2.5rem); + } + + header h1 { + margin: 0.25em; + margin-right: 0; + font-weight: 400; + letter-spacing: -2px; + line-height: 1; + } + + header h1 .title { + color: var(--color-gray-400); + white-space: nowrap; + } + + header svg { + margin-bottom: -0.125em; + color: var(--color-red); + } + + p { + font-size: 1.75rem; + font-size: clamp(14px, calc(2vw + 0.5rem), 1.75rem); + flex: 1; + } + + .error-message { + display: grid; + justify-content: center; + margin-top: 4rem; + } + + .error-message :global(code[class*="language-"]) { + background: var(--color-gray-800); + } + .error-message :global(pre) { + margin: 0; + font-family: var(--font-mono); + font-size: 0.85rem; + background: var(--color-gray-800); + border-radius: 8px; + } + + .error-message :global(.token.punctuation) { + color: var(--color-gray-400); + } + + .error-message :global(.token.operator) { + color: var(--color-gray-400); + } + </style> + </head> + <body> + <main> + <header> + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" width="1.75em" height="1.75em"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> + </svg> + <h1><span class="error">500 Error </span><span class="title">{title}</span></h1> + </header> + + <article> + <p>Astro had some trouble loading this page.</p> + + <div class="error-message"> + <Prism lang="shell" code={error} /> + </div> + </article> + </main> + </body> +</html> diff --git a/packages/astro/src/frontend/SvelteWrapper.svelte b/packages/astro/src/frontend/SvelteWrapper.svelte new file mode 100644 index 000000000..eb4cbb7d9 --- /dev/null +++ b/packages/astro/src/frontend/SvelteWrapper.svelte @@ -0,0 +1,7 @@ +<script> +const { __astro_component: Component, __astro_children, ...props } = $$props; +</script> + +<Component {...props}> + {@html __astro_children} +</Component> diff --git a/packages/astro/src/frontend/SvelteWrapper.svelte.client.ts b/packages/astro/src/frontend/SvelteWrapper.svelte.client.ts new file mode 100644 index 000000000..9df168895 --- /dev/null +++ b/packages/astro/src/frontend/SvelteWrapper.svelte.client.ts @@ -0,0 +1,166 @@ +/* eslint-disable */ +// @ts-nocheck +// TODO: don't precompile this, but it works for now +import { + HtmlTag, + SvelteComponentDev, + assign, + claim_component, + create_component, + destroy_component, + detach_dev, + dispatch_dev, + empty, + exclude_internal_props, + get_spread_object, + get_spread_update, + init, + insert_dev, + mount_component, + noop, + not_equal, + transition_in, + transition_out, + validate_slots, +} from 'svelte/internal'; + +const file = 'App.svelte'; + +// (5:0) <Component {...props}> +function create_default_slot(ctx) { + let html_tag; + let html_anchor; + + const block = { + c: function create() { + html_anchor = empty(); + this.h(); + }, + l: function claim(nodes) { + html_anchor = empty(); + this.h(); + }, + h: function hydrate() { + html_tag = new HtmlTag(html_anchor); + }, + m: function mount(target, anchor) { + html_tag.m(/*__astro_children*/ ctx[1], target, anchor); + insert_dev(target, html_anchor, anchor); + }, + p: noop, + d: function destroy(detaching) { + if (detaching) detach_dev(html_anchor); + if (detaching) html_tag.d(); + }, + }; + + dispatch_dev('SvelteRegisterBlock', { + block, + id: create_default_slot.name, + type: 'slot', + source: '(5:0) <Component {...props}>', + ctx, + }); + + return block; +} + +function create_fragment(ctx) { + let component; + let current; + const component_spread_levels = [/*props*/ ctx[2]]; + + let component_props = { + $$slots: { default: [create_default_slot] }, + $$scope: { ctx }, + }; + + for (let i = 0; i < component_spread_levels.length; i += 1) { + component_props = assign(component_props, component_spread_levels[i]); + } + + component = new /*Component*/ ctx[0]({ props: component_props, $$inline: true }); + + const block = { + c: function create() { + create_component(component.$$.fragment); + }, + l: function claim(nodes) { + claim_component(component.$$.fragment, nodes); + }, + m: function mount(target, anchor) { + mount_component(component, target, anchor); + current = true; + }, + p: function update(ctx, [dirty]) { + const component_changes = dirty & /*props*/ 4 ? get_spread_update(component_spread_levels, [get_spread_object(/*props*/ ctx[2])]) : {}; + + if (dirty & /*$$scope*/ 16) { + component_changes.$$scope = { dirty, ctx }; + } + + component.$set(component_changes); + }, + i: function intro(local) { + if (current) return; + transition_in(component.$$.fragment, local); + current = true; + }, + o: function outro(local) { + transition_out(component.$$.fragment, local); + current = false; + }, + d: function destroy(detaching) { + destroy_component(component, detaching); + }, + }; + + dispatch_dev('SvelteRegisterBlock', { + block, + id: create_fragment.name, + type: 'component', + source: '', + ctx, + }); + + return block; +} + +function instance($$self, $$props, $$invalidate) { + let { $$slots: slots = {}, $$scope } = $$props; + validate_slots('App', slots, []); + const { __astro_component: Component, __astro_children, ...props } = $$props; + + $$self.$$set = ($$new_props) => { + $$invalidate(3, ($$props = assign(assign({}, $$props), exclude_internal_props($$new_props)))); + }; + + $$self.$capture_state = () => ({ Component, __astro_children, props }); + + $$self.$inject_state = ($$new_props) => { + $$invalidate(3, ($$props = assign(assign({}, $$props), $$new_props))); + }; + + if ($$props && '$$inject' in $$props) { + $$self.$inject_state($$props.$$inject); + } + + $$props = exclude_internal_props($$props); + return [Component, __astro_children, props]; +} + +class App extends SvelteComponentDev { + constructor(options) { + super(options); + init(this, options, instance, create_fragment, not_equal, {}); + + dispatch_dev('SvelteRegisterComponent', { + component: this, + tagName: 'App', + options, + id: create_fragment.name, + }); + } +} + +export default App; diff --git a/packages/astro/src/frontend/SvelteWrapper.svelte.server.ts b/packages/astro/src/frontend/SvelteWrapper.svelte.server.ts new file mode 100644 index 000000000..c5a25ff03 --- /dev/null +++ b/packages/astro/src/frontend/SvelteWrapper.svelte.server.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +// @ts-nocheck +// TODO: don't precompile this, but it works for now +/* App.svelte generated by Svelte v3.37.0 */ +import { create_ssr_component, validate_component } from 'svelte/internal'; + +const App = create_ssr_component(($$result, $$props, $$bindings, slots) => { + const { __astro_component: Component, __astro_children, ...props } = $$props; + return `${validate_component(Component, 'Component').$$render($$result, Object.assign(props), {}, { default: () => `${__astro_children}` })}`; +}); + +export default App; diff --git a/packages/astro/src/frontend/h.ts b/packages/astro/src/frontend/h.ts new file mode 100644 index 000000000..c1e21dc95 --- /dev/null +++ b/packages/astro/src/frontend/h.ts @@ -0,0 +1,65 @@ +export type HProps = Record<string, string> | null | undefined; +export type HChild = string | undefined | (() => string); +export type AstroComponent = (props: HProps, ...children: Array<HChild>) => string; +export type HTag = string | AstroComponent; + +const voidTags = new Set(['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']); + +/** Generator for primary h() function */ +function* _h(tag: string, attrs: HProps, children: Array<HChild>) { + if (tag === '!doctype') { + yield '<!doctype '; + if (attrs) { + yield Object.keys(attrs).join(' '); + } + yield '>'; + return; + } + + yield `<${tag}`; + if (attrs) { + yield ' '; + for (let [key, value] of Object.entries(attrs)) { + yield `${key}="${value}"`; + } + } + yield '>'; + + // Void tags have no children. + if (voidTags.has(tag)) { + return; + } + + for (let child of children) { + // Special: If a child is a function, call it automatically. + // This lets you do {() => ...} without the extra boilerplate + // of wrapping it in a function and calling it. + if (typeof child === 'function') { + yield child(); + } else if (typeof child === 'string') { + yield child; + } else if (!child) { + // do nothing, safe to ignore falsey values. + } else { + yield child; + } + } + + yield `</${tag}>`; +} + +/** Astro‘s primary h() function. Allows it to use JSX-like syntax. */ +export async function h(tag: HTag, attrs: HProps, ...pChildren: Array<Promise<HChild>>) { + const children = await Promise.all(pChildren.flat(Infinity)); + if (typeof tag === 'function') { + // We assume it's an astro component + return tag(attrs, ...children); + } + + return Array.from(_h(tag, attrs, children)).join(''); +} + +/** Fragment helper, similar to React.Fragment */ +export function Fragment(_: HProps, ...children: Array<string>) { + return children.join(''); +} diff --git a/packages/astro/src/frontend/render/preact.ts b/packages/astro/src/frontend/render/preact.ts new file mode 100644 index 000000000..5c50b6fe3 --- /dev/null +++ b/packages/astro/src/frontend/render/preact.ts @@ -0,0 +1,31 @@ +import { h, render, ComponentType } from 'preact'; +import { renderToString } from 'preact-render-to-string'; +import { childrenToVnodes } from './utils'; +import type { ComponentRenderer } from '../../@types/renderer'; +import { createRenderer } from './renderer'; + +// This prevents tree-shaking of render. +Function.prototype(render); + +const Preact: ComponentRenderer<ComponentType> = { + jsxPragma: h, + jsxPragmaName: 'h', + renderStatic(Component) { + return async (props, ...children) => { + return renderToString(h(Component, props, childrenToVnodes(h, children))); + }; + }, + imports: { + preact: ['render', 'Fragment', 'h'], + }, + render({ Component, root, props, children }) { + return `render(h(${Component}, ${props}, h(Fragment, null, ...${children})), ${root})`; + }, +}; + +const renderer = createRenderer(Preact); + +export const __preact_static = renderer.static; +export const __preact_load = renderer.load; +export const __preact_idle = renderer.idle; +export const __preact_visible = renderer.visible; diff --git a/packages/astro/src/frontend/render/react.ts b/packages/astro/src/frontend/render/react.ts new file mode 100644 index 000000000..063c6a2b5 --- /dev/null +++ b/packages/astro/src/frontend/render/react.ts @@ -0,0 +1,32 @@ +import type { ComponentRenderer } from '../../@types/renderer'; +import React, { ComponentType } from 'react'; +import ReactDOMServer from 'react-dom/server'; +import { createRenderer } from './renderer'; +import { childrenToVnodes } from './utils'; + +// This prevents tree-shaking of render. +Function.prototype(ReactDOMServer); + +const ReactRenderer: ComponentRenderer<ComponentType> = { + jsxPragma: React.createElement, + jsxPragmaName: 'React.createElement', + renderStatic(Component) { + return async (props, ...children) => { + return ReactDOMServer.renderToString(React.createElement(Component, props, childrenToVnodes(React.createElement, children))); + }; + }, + imports: { + react: ['default: React'], + 'react-dom': ['default: ReactDOM'], + }, + render({ Component, root, children, props }) { + return `ReactDOM.hydrate(React.createElement(${Component}, ${props}, React.createElement(React.Fragment, null, ...${children})), ${root})`; + }, +}; + +const renderer = createRenderer(ReactRenderer); + +export const __react_static = renderer.static; +export const __react_load = renderer.load; +export const __react_idle = renderer.idle; +export const __react_visible = renderer.visible; diff --git a/packages/astro/src/frontend/render/renderer.ts b/packages/astro/src/frontend/render/renderer.ts new file mode 100644 index 000000000..7bdf7d8a8 --- /dev/null +++ b/packages/astro/src/frontend/render/renderer.ts @@ -0,0 +1,64 @@ +import type { DynamicRenderContext, DynamicRendererGenerator, SupportedComponentRenderer, StaticRendererGenerator } from '../../@types/renderer'; +import { childrenToH } from './utils'; + +// This prevents tree-shaking of render. +Function.prototype(childrenToH); + +/** Initialize Astro Component renderer for Static and Dynamic components */ +export function createRenderer(renderer: SupportedComponentRenderer) { + const _static: StaticRendererGenerator = (Component) => renderer.renderStatic(Component); + const _imports = (context: DynamicRenderContext) => { + const values = Object.values(renderer.imports ?? {}) + .reduce((acc, v) => { + return [...acc, `{ ${v.join(', ')} }`]; + }, []) + .join(', '); + const libs = Object.keys(renderer.imports ?? {}) + .reduce((acc: string[], lib: string) => { + return [...acc, `import("${context.frameworkUrls[lib as any]}")`]; + }, []) + .join(','); + return `const [{${context.componentExport}: Component}, ${values}] = await Promise.all([import("${context.componentUrl}")${renderer.imports ? ', ' + libs : ''}]);`; + }; + const serializeProps = ({ children: _, ...props }: Record<string, any>) => JSON.stringify(props); + const createContext = () => { + const astroId = `${Math.floor(Math.random() * 1e16)}`; + return { ['data-astro-id']: astroId, root: `document.querySelector('[data-astro-id="${astroId}"]')`, Component: 'Component' }; + }; + const createDynamicRender: DynamicRendererGenerator = (wrapperStart, wrapperEnd) => (Component, renderContext) => { + const innerContext = createContext(); + return async (props, ...children) => { + let value: string; + try { + value = await _static(Component)(props, ...children); + } catch (e) { + value = ''; + } + 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({ + ...innerContext, + props: serializeProps(props), + children: `[${childrenToH(renderer, children) ?? ''}]`, + childrenAsString: `\`${children}\``, + })} + ${typeof wrapperEnd === 'function' ? wrapperEnd(innerContext) : wrapperEnd} + `; + + return [value, `<script type="module">${script.trim()}</script>`].join('\n'); + }; + }; + + return { + static: _static, + load: createDynamicRender('(async () => {', '})()'), + idle: createDynamicRender('requestIdleCallback(async () => {', '})'), + visible: createDynamicRender( + 'const o = new IntersectionObserver(async ([entry]) => { if (!entry.isIntersecting) { return; } o.disconnect();', + ({ root }) => `}); Array.from(${root}.children).forEach(child => o.observe(child))` + ), + }; +} diff --git a/packages/astro/src/frontend/render/svelte.ts b/packages/astro/src/frontend/render/svelte.ts new file mode 100644 index 000000000..13e2b8f58 --- /dev/null +++ b/packages/astro/src/frontend/render/svelte.ts @@ -0,0 +1,26 @@ +import type { ComponentRenderer } from '../../@types/renderer'; +import type { SvelteComponent } from 'svelte'; +import { createRenderer } from './renderer'; +import SvelteWrapper from '../SvelteWrapper.svelte.server'; + +const SvelteRenderer: ComponentRenderer<SvelteComponent> = { + renderStatic(Component) { + return async (props, ...children) => { + const { html } = SvelteWrapper.render({ __astro_component: Component, __astro_children: children.join('\n'), ...props }); + return html; + }; + }, + imports: { + 'svelte-runtime': ['default: render'], + }, + render({ Component, root, props, childrenAsString }) { + return `render(${root}, ${Component}, ${props}, ${childrenAsString});`; + }, +}; + +const renderer = createRenderer(SvelteRenderer); + +export const __svelte_static = renderer.static; +export const __svelte_load = renderer.load; +export const __svelte_idle = renderer.idle; +export const __svelte_visible = renderer.visible; diff --git a/packages/astro/src/frontend/render/utils.ts b/packages/astro/src/frontend/render/utils.ts new file mode 100644 index 000000000..5d13ca136 --- /dev/null +++ b/packages/astro/src/frontend/render/utils.ts @@ -0,0 +1,54 @@ +import unified from 'unified'; +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()); +} + +/** + * Converts an HTML fragment string into vnodes for rendering via provided framework + * @param h framework's `createElement` function + * @param children the HTML string children + */ +export const childrenToVnodes = moize.deep(function childrenToVnodes(h: any, children: string[]) { + const tree = childrenToTree(children); + const vnodes = tree.map((subtree) => { + if (subtree.type === 'text') return subtree.value; + return toH(h, subtree); + }); + return vnodes; +}); + +/** + * Converts an HTML fragment string into h function calls as a string + * @param h framework's `createElement` function + * @param children the HTML string children + */ +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; + const __SERIALIZED = `${renderer.jsxPragmaName}("${name}", ${attrs ? JSON.stringify(attrs) : 'null'}${childStr})` as string; + return { ...vnode, __SERIALIZED }; + }; + const serializeChild = (child: unknown) => { + if (['string', 'number', 'boolean'].includes(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; + }; + return tree.map((subtree) => { + if (subtree.type === 'text') return JSON.stringify(subtree.value); + return toH(innerH, subtree).__SERIALIZED + }); +}); diff --git a/packages/astro/src/frontend/render/vue.ts b/packages/astro/src/frontend/render/vue.ts new file mode 100644 index 000000000..57c3c8276 --- /dev/null +++ b/packages/astro/src/frontend/render/vue.ts @@ -0,0 +1,65 @@ +import type { ComponentRenderer } from '../../@types/renderer'; +import type { Component as VueComponent } from 'vue'; +import { renderToString } from '@vue/server-renderer'; +import { defineComponent, createSSRApp, h as createElement } from 'vue'; +import { createRenderer } from './renderer'; + +// This prevents tree-shaking of render. +Function.prototype(renderToString); + +/** + * Users might attempt to use :vueAttribute syntax to pass primitive values. + * If so, try to JSON.parse them to get the primitives + */ +function cleanPropsForVue(obj: Record<string, any>) { + let cleaned = {} as any; + for (let [key, value] of Object.entries(obj)) { + if (key.startsWith(':')) { + key = key.slice(1); + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch (e) {} + } + } + cleaned[key] = value; + } + return cleaned; +} + +const Vue: ComponentRenderer<VueComponent> = { + jsxPragma: createElement, + jsxPragmaName: 'createElement', + renderStatic(Component) { + return async (props, ...children) => { + const App = defineComponent({ + components: { + Component, + }, + data() { + return { props }; + }, + template: `<Component v-bind="props">${children.join('\n')}</Component>`, + }); + + const app = createSSRApp(App); + const html = await renderToString(app); + return html; + }; + }, + imports: { + vue: ['createApp', 'h: createElement'], + }, + render({ Component, root, props, children }) { + const vueProps = cleanPropsForVue(JSON.parse(props)); + return `const App = { render: () => createElement(${Component}, ${JSON.stringify(vueProps)}, { default: () => ${children} }) }; +createApp(App).mount(${root});`; + }, +}; + +const renderer = createRenderer(Vue); + +export const __vue_static = renderer.static; +export const __vue_load = renderer.load; +export const __vue_idle = renderer.idle; +export const __vue_visible = renderer.visible; diff --git a/packages/astro/src/frontend/runtime/svelte.ts b/packages/astro/src/frontend/runtime/svelte.ts new file mode 100644 index 000000000..78b6af6b6 --- /dev/null +++ b/packages/astro/src/frontend/runtime/svelte.ts @@ -0,0 +1,10 @@ +import SvelteWrapper from '../SvelteWrapper.svelte.client'; +import type { SvelteComponent } from 'svelte'; + +export default (target: Element, component: SvelteComponent, props: any, children: string) => { + new SvelteWrapper({ + target, + props: { __astro_component: component, __astro_children: children, ...props }, + hydrate: true, + }); +}; diff --git a/packages/astro/src/logger.ts b/packages/astro/src/logger.ts new file mode 100644 index 000000000..c42c889f1 --- /dev/null +++ b/packages/astro/src/logger.ts @@ -0,0 +1,143 @@ +import type { CompileError } from 'astro-parser'; +import { bold, blue, red, grey, underline } from 'kleur/colors'; +import { Writable } from 'stream'; +import { format as utilFormat } from 'util'; + +type ConsoleStream = Writable & { + fd: 1 | 2; +}; + +export const defaultLogDestination = new Writable({ + objectMode: true, + write(event: LogMessage, _, callback) { + let dest: ConsoleStream = process.stderr; + if (levels[event.level] < levels['error']) { + dest = process.stdout; + } + let type = event.type; + if(type !== null) { + if (event.level === 'info') { + type = bold(blue(type)); + } else if (event.level === 'error') { + type = bold(red(type)); + } + + dest.write(`[${type}] `); + } + + dest.write(utilFormat(...event.args)); + dest.write('\n'); + + callback(); + }, +}); + +interface LogWritable<T> extends Writable { + write: (chunk: T) => boolean; +} + +export type LoggerLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'; // same as Pino +export type LoggerEvent = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogOptions { + dest: LogWritable<LogMessage>; + level: LoggerLevel; +} + +export const defaultLogOptions: LogOptions = { + dest: defaultLogDestination, + level: 'info', +}; + +export interface LogMessage { + type: string | null; + level: LoggerLevel; + message: string; + args: Array<any>; +} + +const levels: Record<LoggerLevel, number> = { + debug: 20, + info: 30, + warn: 40, + error: 50, + silent: 90, +}; + +/** Full logging API */ +export function log(opts: LogOptions = defaultLogOptions, level: LoggerLevel, type: string | null, ...args: Array<any>) { + const event: LogMessage = { + type, + level, + args, + message: '', + }; + + // test if this level is enabled or not + if (levels[opts.level] > levels[level]) { + return; // do nothing + } + + opts.dest.write(event); +} + +/** Emit a message only shown in debug mode */ +export function debug(opts: LogOptions, type: string | null, ...messages: Array<any>) { + return log(opts, 'debug', type, ...messages); +} + +/** Emit a general info message (be careful using this too much!) */ +export function info(opts: LogOptions, type: string | null, ...messages: Array<any>) { + return log(opts, 'info', type, ...messages); +} + +/** Emit a warning a user should be aware of */ +export function warn(opts: LogOptions, type: string | null, ...messages: Array<any>) { + return log(opts, 'warn', type, ...messages); +} + +/** Emit a fatal error message the user should address. */ +export function error(opts: LogOptions, type: string | null, ...messages: Array<any>) { + return log(opts, 'error', type, ...messages); +} + +/** Pretty format error for display */ +export function parseError(opts: LogOptions, err: CompileError) { + let frame = err.frame + // Switch colons for pipes + .replace(/^([0-9]+)(:)/gm, `${bold('$1')} │`) + // Make the caret red. + .replace(/(?<=^\s+)(\^)/gm, bold(red(' ^'))) + // Add identation + .replace(/^/gm, ' '); + + error( + opts, + 'parse-error', + ` + + ${underline(bold(grey(`${err.filename}:${err.start.line}:${err.start.column}`)))} + + ${bold(red(`𝘅 ${err.message}`))} + +${frame} +` + ); +} + +// A default logger for when too lazy to pass LogOptions around. +export const logger = { + debug: debug.bind(null, defaultLogOptions), + info: info.bind(null, defaultLogOptions), + warn: warn.bind(null, defaultLogOptions), + error: error.bind(null, defaultLogOptions), +}; + +// For silencing libraries that go directly to console.warn +export function trapWarn(cb: (...args: any[]) => void = () =>{}) { + const warn = console.warn; + console.warn = function(...args: any[]) { + cb(...args); + }; + return () => console.warn = warn; +} diff --git a/packages/astro/src/runtime.ts b/packages/astro/src/runtime.ts new file mode 100644 index 000000000..5369996f4 --- /dev/null +++ b/packages/astro/src/runtime.ts @@ -0,0 +1,365 @@ +import { fileURLToPath } from 'url'; +import type { SnowpackDevServer, ServerRuntime as SnowpackServerRuntime, SnowpackConfig } from 'snowpack'; +import type { AstroConfig, CollectionResult, CollectionRSS, CreateCollection, Params, RuntimeMode } from './@types/astro'; +import type { LogOptions } from './logger'; +import type { CompileError } from 'astro-parser'; +import { debug, info } from './logger.js'; +import { searchForPage } from './search.js'; + +import { existsSync } from 'fs'; +import { loadConfiguration, logger as snowpackLogger, startServer as startSnowpackServer } from 'snowpack'; + +// We need to use require.resolve for snowpack plugins, so create a require function here. +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +interface RuntimeConfig { + astroConfig: AstroConfig; + logging: LogOptions; + mode: RuntimeMode; + backendSnowpack: SnowpackDevServer; + backendSnowpackRuntime: SnowpackServerRuntime; + backendSnowpackConfig: SnowpackConfig; + frontendSnowpack: SnowpackDevServer; + frontendSnowpackRuntime: SnowpackServerRuntime; + frontendSnowpackConfig: SnowpackConfig; +} + +// info needed for collection generation +interface CollectionInfo { + additionalURLs: Set<string>; + rss?: { data: any[] & CollectionRSS }; +} + +type LoadResultSuccess = { + statusCode: 200; + contents: string | Buffer; + contentType?: string | false; +}; +type LoadResultNotFound = { statusCode: 404; error: Error; collectionInfo?: CollectionInfo }; +type LoadResultRedirect = { statusCode: 301 | 302; location: string; collectionInfo?: CollectionInfo }; +type LoadResultError = { statusCode: 500 } & ({ type: 'parse-error'; error: CompileError } | { type: 'unknown'; error: Error }); + +export type LoadResult = (LoadResultSuccess | LoadResultNotFound | LoadResultRedirect | LoadResultError) & { collectionInfo?: CollectionInfo }; + +// Disable snowpack from writing to stdout/err. +snowpackLogger.level = 'silent'; + +/** Pass a URL to Astro to resolve and build */ +async function load(config: RuntimeConfig, rawPathname: string | undefined): Promise<LoadResult> { + const { logging, backendSnowpackRuntime, frontendSnowpack } = config; + const { astroRoot } = config.astroConfig; + + const fullurl = new URL(rawPathname || '/', 'https://example.org/'); + + const reqPath = decodeURI(fullurl.pathname); + info(logging, 'access', reqPath); + + const searchResult = searchForPage(fullurl, astroRoot); + if (searchResult.statusCode === 404) { + try { + const result = await frontendSnowpack.loadUrl(reqPath); + if (!result) throw new Error(`Unable to load ${reqPath}`); + // success + return { + statusCode: 200, + ...result, + }; + } catch (err) { + // build error + if (err.failed) { + return { statusCode: 500, type: 'unknown', error: err }; + } + + // not found + return { statusCode: 404, error: err }; + } + } + + if (searchResult.statusCode === 301) { + return { statusCode: 301, location: searchResult.pathname }; + } + + const snowpackURL = searchResult.location.snowpackURL; + let rss: { data: any[] & CollectionRSS } = {} as any; + + try { + const mod = await backendSnowpackRuntime.importModule(snowpackURL); + debug(logging, 'resolve', `${reqPath} -> ${snowpackURL}`); + + // handle collection + let collection = {} as CollectionResult; + let additionalURLs = new Set<string>(); + + if (mod.exports.createCollection) { + const createCollection: CreateCollection = await mod.exports.createCollection(); + for (const key of Object.keys(createCollection)) { + if (key !== 'data' && key !== 'routes' && key !== 'permalink' && key !== 'pageSize' && key !== 'rss') { + throw new Error(`[createCollection] unknown option: "${key}"`); + } + } + let { data: loadData, routes, permalink, pageSize, rss: createRSS } = createCollection; + if (!pageSize) pageSize = 25; // can’t be 0 + let currentParams: Params = {}; + + // params + if (routes || permalink) { + if (!routes || !permalink) { + throw new Error('createCollection() must have both routes and permalink options. Include both together, or omit both.'); + } + let requestedParams = routes.find((p) => { + const baseURL = (permalink as any)({ params: p }); + additionalURLs.add(baseURL); + return baseURL === reqPath || `${baseURL}/${searchResult.currentPage || 1}` === reqPath; + }); + if (requestedParams) { + currentParams = requestedParams; + collection.params = requestedParams; + } + } + + let data: any[] = await loadData({ params: currentParams }); + + // handle RSS + if (createRSS) { + rss = { + ...createRSS, + data: [...data] as any, + }; + } + + collection.start = 0; + collection.end = data.length - 1; + collection.total = data.length; + collection.page = { current: 1, size: pageSize, last: 1 }; + collection.url = { current: reqPath }; + + // paginate + if (searchResult.currentPage) { + const start = (searchResult.currentPage - 1) * pageSize; // currentPage is 1-indexed + const end = Math.min(start + pageSize, data.length); + + collection.start = start; + collection.end = end - 1; + collection.page.current = searchResult.currentPage; + collection.page.last = Math.ceil(data.length / pageSize); + // TODO: fix the .replace() hack + if (end < data.length) { + collection.url.next = collection.url.current.replace(/(\/\d+)?$/, `/${searchResult.currentPage + 1}`); + } + if (searchResult.currentPage > 1) { + collection.url.prev = collection.url.current + .replace(/\d+$/, `${searchResult.currentPage - 1 || 1}`) // update page # + .replace(/\/1$/, ''); // if end is `/1`, then just omit + } + + // from page 2 to the end, add all pages as additional URLs (needed for build) + for (let n = 1; n <= collection.page.last; n++) { + if (additionalURLs.size) { + // if this is a param-based collection, paginate all params + additionalURLs.forEach((url) => { + additionalURLs.add(url.replace(/(\/\d+)?$/, `/${n}`)); + }); + } else { + // if this has no params, simply add page + additionalURLs.add(reqPath.replace(/(\/\d+)?$/, `/${n}`)); + } + } + + data = data.slice(start, end); + } else if (createCollection.pageSize) { + // TODO: fix bug where redirect doesn’t happen + // This happens because a pageSize is set, but the user isn’t on a paginated route. Redirect: + return { + statusCode: 301, + location: reqPath + '/1', + collectionInfo: { + additionalURLs, + rss: rss.data ? rss : undefined, + }, + }; + } + + // if we’ve paginated too far, this is a 404 + if (!data.length) { + return { + statusCode: 404, + error: new Error('Not Found'), + collectionInfo: { + additionalURLs, + rss: rss.data ? rss : undefined, + }, + }; + } + + collection.data = data; + } + + const requestURL = new URL(fullurl.toString()); + + // For first release query params are not passed to components. + // An exception is made for dev server specific routes. + if(reqPath !== '/500') { + requestURL.search = ''; + } + + let html = (await mod.exports.__renderPage({ + request: { + // params should go here when implemented + url: requestURL + }, + children: [], + props: { collection }, + })) as string; + + // inject styles + // TODO: handle this in compiler + const styleTags = Array.isArray(mod.css) && mod.css.length ? mod.css.reduce((markup, href) => `${markup}\n<link rel="stylesheet" type="text/css" href="${href}" />`, '') : ``; + if (html.indexOf('</head>') !== -1) { + html = html.replace('</head>', `${styleTags}</head>`); + } else { + html = styleTags + html; + } + + return { + statusCode: 200, + contentType: 'text/html; charset=utf-8', + contents: html, + collectionInfo: { + additionalURLs, + rss: rss.data ? rss : undefined, + }, + }; + } catch (err) { + if (err.code === 'parse-error' || err instanceof SyntaxError) { + return { + statusCode: 500, + type: 'parse-error', + error: err, + }; + } + return { + statusCode: 500, + type: 'unknown', + error: err, + }; + } +} + +export interface AstroRuntime { + runtimeConfig: RuntimeConfig; + load: (rawPathname: string | undefined) => Promise<LoadResult>; + shutdown: () => Promise<void>; +} + +interface RuntimeOptions { + mode: RuntimeMode; + logging: LogOptions; +} + +interface CreateSnowpackOptions { + env: Record<string, any>; + mode: RuntimeMode; + resolvePackageUrl?: (pkgName: string) => Promise<string>; +} + +/** Create a new Snowpack instance to power Astro */ +async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackOptions) { + const { projectRoot, astroRoot, extensions } = astroConfig; + const { env, mode, resolvePackageUrl } = options; + + const internalPath = new URL('./frontend/', import.meta.url); + + let snowpack: SnowpackDevServer; + const astroPlugOptions: { + resolvePackageUrl?: (s: string) => Promise<string>; + extensions?: Record<string, string>; + astroConfig: AstroConfig; + } = { + astroConfig, + extensions, + resolvePackageUrl, + }; + + const mountOptions = { + [fileURLToPath(astroRoot)]: '/_astro', + [fileURLToPath(internalPath)]: '/_astro_internal', + }; + + if (existsSync(astroConfig.public)) { + mountOptions[fileURLToPath(astroConfig.public)] = '/'; + } + + const snowpackConfig = await loadConfiguration({ + root: fileURLToPath(projectRoot), + mount: mountOptions, + mode, + plugins: [ + [fileURLToPath(new URL('../snowpack-plugin.cjs', import.meta.url)), astroPlugOptions], + require.resolve('@snowpack/plugin-sass'), + [require.resolve('@snowpack/plugin-svelte'), { compilerOptions: { hydratable: true } }], + require.resolve('@snowpack/plugin-vue'), + ], + devOptions: { + open: 'none', + output: 'stream', + port: 0, + }, + buildOptions: { + out: astroConfig.dist, + }, + packageOptions: { + knownEntrypoints: ['preact-render-to-string'], + external: ['@vue/server-renderer', 'node-fetch', 'prismjs/components/index.js'], + }, + }); + + const envConfig = snowpackConfig.env || (snowpackConfig.env = {}); + Object.assign(envConfig, env); + + snowpack = await startSnowpackServer({ + config: snowpackConfig, + lockfile: null, + }); + const snowpackRuntime = snowpack.getServerRuntime(); + + return { snowpack, snowpackRuntime, snowpackConfig }; +} + +/** Core Astro runtime */ +export async function createRuntime(astroConfig: AstroConfig, { mode, logging }: RuntimeOptions): Promise<AstroRuntime> { + const resolvePackageUrl = async (pkgName: string) => frontendSnowpack.getUrlForPackage(pkgName); + + const { snowpack: backendSnowpack, snowpackRuntime: backendSnowpackRuntime, snowpackConfig: backendSnowpackConfig } = await createSnowpack(astroConfig, { + env: { + astro: true, + }, + mode, + resolvePackageUrl, + }); + + const { snowpack: frontendSnowpack, snowpackRuntime: frontendSnowpackRuntime, snowpackConfig: frontendSnowpackConfig } = await createSnowpack(astroConfig, { + env: { + astro: false, + }, + mode, + }); + + const runtimeConfig: RuntimeConfig = { + astroConfig, + logging, + mode, + backendSnowpack, + backendSnowpackRuntime, + backendSnowpackConfig, + frontendSnowpack, + frontendSnowpackRuntime, + frontendSnowpackConfig, + }; + + return { + runtimeConfig, + load: load.bind(null, runtimeConfig), + shutdown: () => Promise.all([backendSnowpack.shutdown(), frontendSnowpack.shutdown()]).then(() => void 0), + }; +} diff --git a/packages/astro/src/search.ts b/packages/astro/src/search.ts new file mode 100644 index 000000000..c141e4a77 --- /dev/null +++ b/packages/astro/src/search.ts @@ -0,0 +1,141 @@ +import { existsSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { fdir, PathsOutput } from 'fdir'; + +interface PageLocation { + fileURL: URL; + snowpackURL: string; +} +/** findAnyPage and return the _astro candidate for snowpack */ +function findAnyPage(candidates: Array<string>, astroRoot: URL): PageLocation | false { + for (let candidate of candidates) { + const url = new URL(`./pages/${candidate}`, astroRoot); + if (existsSync(url)) { + return { + fileURL: url, + snowpackURL: `/_astro/pages/${candidate}.js`, + }; + } + } + return false; +} + +type SearchResult = + | { + statusCode: 200; + location: PageLocation; + pathname: string; + currentPage?: number; + } + | { + statusCode: 301; + location: null; + pathname: string; + } + | { + statusCode: 404; + }; + +/** Given a URL, attempt to locate its source file (similar to Snowpack’s load()) */ +export function searchForPage(url: URL, astroRoot: URL): SearchResult { + const reqPath = decodeURI(url.pathname); + const base = reqPath.substr(1); + + // Try to find index.astro/md paths + if (reqPath.endsWith('/')) { + const candidates = [`${base}index.astro`, `${base}index.md`]; + const location = findAnyPage(candidates, astroRoot); + if (location) { + return { + statusCode: 200, + location, + pathname: reqPath, + }; + } + } else { + // Try to find the page by its name. + const candidates = [`${base}.astro`, `${base}.md`]; + let location = findAnyPage(candidates, astroRoot); + if (location) { + return { + statusCode: 200, + location, + pathname: reqPath, + }; + } + } + + // Try to find name/index.astro/md + const candidates = [`${base}/index.astro`, `${base}/index.md`]; + const location = findAnyPage(candidates, astroRoot); + if (location) { + return { + statusCode: 301, + location: null, + pathname: reqPath + '/', + }; + } + + // Try and load collections (but only for non-extension files) + const hasExt = !!path.extname(reqPath); + if (!location && !hasExt) { + const collection = loadCollection(reqPath, astroRoot); + if (collection) { + return { + statusCode: 200, + location: collection.location, + pathname: reqPath, + currentPage: collection.currentPage || 1, + }; + } + } + + if(reqPath === '/500') { + return { + statusCode: 200, + location: { + fileURL: new URL('./frontend/500.astro', import.meta.url), + snowpackURL: `/_astro_internal/500.astro.js` + }, + pathname: reqPath + }; + } + + return { + statusCode: 404, + }; +} + +const crawler = new fdir(); + +/** load a collection route */ +function loadCollection(url: string, astroRoot: URL): { currentPage?: number; location: PageLocation } | undefined { + const pages = (crawler + .glob('**/*') + .crawl(path.join(fileURLToPath(astroRoot), 'pages')) + .sync() as PathsOutput).filter((filepath) => filepath.startsWith('$') || filepath.includes('/$')); + for (const pageURL of pages) { + const reqURL = new RegExp('^/' + pageURL.replace(/\$([^/]+)\.astro/, '$1') + '/?(.*)'); + const match = url.match(reqURL); + if (match) { + let currentPage: number | undefined; + if (match[1]) { + const segments = match[1].split('/').filter((s) => !!s); + if (segments.length) { + const last = segments.pop() as string; + if (parseInt(last, 10)) { + currentPage = parseInt(last, 10); + } + } + } + return { + location: { + fileURL: new URL(`./pages/${pageURL}`, astroRoot), + snowpackURL: `/_astro/pages/${pageURL}.js`, + }, + currentPage, + }; + } + } +} diff --git a/packages/astro/test/astro-basic.test.js b/packages/astro/test/astro-basic.test.js new file mode 100644 index 000000000..89dcf3553 --- /dev/null +++ b/packages/astro/test/astro-basic.test.js @@ -0,0 +1,19 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup } from './helpers.js'; + +const Basics = suite('Basic test'); + +setup(Basics, './fixtures/astro-basic'); + +Basics('Can load page', async ({ runtime }) => { + const result = await runtime.load('/'); + + assert.equal(result.statusCode, 200); + const $ = doc(result.contents); + + assert.equal($('h1').text(), 'Hello world!'); +}); + +Basics.run(); diff --git a/packages/astro/test/astro-children.test.js b/packages/astro/test/astro-children.test.js new file mode 100644 index 000000000..368cfc9f9 --- /dev/null +++ b/packages/astro/test/astro-children.test.js @@ -0,0 +1,75 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup, setupBuild } from './helpers.js'; + +const ComponentChildren = suite('Component children tests'); + +setup(ComponentChildren, './fixtures/astro-children'); +setupBuild(ComponentChildren, './fixtures/astro-children'); + +ComponentChildren('Passes string children to framework components', async ({ runtime }) => { + let result = await runtime.load('/strings'); + + assert.equal(result.statusCode, 200); + const $ = doc(result.contents); + + const $preact = $('#preact'); + assert.equal($preact.text().trim(), 'Hello world', 'Can pass text to Preact components'); + + const $vue = $('#vue'); + assert.equal($vue.text().trim(), 'Hello world', 'Can pass text to Vue components'); + + const $svelte = $('#svelte'); + assert.equal($svelte.text().trim(), 'Hello world', 'Can pass text to Svelte components'); +}); + +ComponentChildren('Passes markup children to framework components', async ({ runtime }) => { + let result = await runtime.load('/markup'); + + assert.equal(result.statusCode, 200); + const $ = doc(result.contents); + + const $preact = $('#preact > h1'); + assert.equal($preact.text().trim(), 'Hello world', 'Can pass markup to Preact components'); + + const $vue = $('#vue > h1'); + assert.equal($vue.text().trim(), 'Hello world', 'Can pass markup to Vue components'); + + const $svelte = $('#svelte > h1'); + assert.equal($svelte.text().trim(), 'Hello world', 'Can pass markup to Svelte components'); +}); + +ComponentChildren('Passes multiple children to framework components', async ({ runtime }) => { + let result = await runtime.load('/multiple'); + + assert.equal(result.statusCode, 200); + const $ = doc(result.contents); + + const $preact = $('#preact'); + assert.equal($preact.children().length, 2, 'Can pass multiple children to Preact components'); + assert.equal($preact.children(':first-child').text().trim(), 'Hello world'); + assert.equal($preact.children(':last-child').text().trim(), 'Goodbye world'); + + const $vue = $('#vue'); + assert.equal($vue.children().length, 2, 'Can pass multiple children to Vue components'); + assert.equal($vue.children(':first-child').text().trim(), 'Hello world'); + assert.equal($vue.children(':last-child').text().trim(), 'Goodbye world'); + + const $svelte = $('#svelte'); + assert.equal($svelte.children().length, 2, 'Can pass multiple children to Svelte components'); + assert.equal($svelte.children(':first-child').text().trim(), 'Hello world'); + assert.equal($svelte.children(':last-child').text().trim(), 'Goodbye world'); +}); + +ComponentChildren('Can be built', async ({ build }) => { + try { + await build(); + assert.ok(true, 'Can build a project with component children'); + } catch (err) { + console.log(err); + assert.ok(false, 'build threw'); + } +}); + +ComponentChildren.run(); diff --git a/packages/astro/test/astro-collection.test.js b/packages/astro/test/astro-collection.test.js new file mode 100644 index 000000000..3fdb3b817 --- /dev/null +++ b/packages/astro/test/astro-collection.test.js @@ -0,0 +1,30 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup } from './helpers.js'; + +const Collections = suite('Collections'); + +setup(Collections, './fixtures/astro-collection'); + +Collections('generates list & sorts successfully', async ({ runtime }) => { + const result = await runtime.load('/posts'); + const $ = doc(result.contents); + const urls = [ + ...$('#posts a').map(function () { + return $(this).attr('href'); + }), + ]; + assert.equal(urls, ['/post/three', '/post/two']); +}); + +Collections('generates pagination successfully', async ({ runtime }) => { + const result = await runtime.load('/posts'); + const $ = doc(result.contents); + const prev = $('#prev-page'); + const next = $('#next-page'); + assert.equal(prev.length, 0); // this is first page; should be missing + assert.equal(next.length, 1); // this should be on-page +}); + +Collections.run(); diff --git a/packages/astro/test/astro-doctype.test.js b/packages/astro/test/astro-doctype.test.js new file mode 100644 index 000000000..7f6bea189 --- /dev/null +++ b/packages/astro/test/astro-doctype.test.js @@ -0,0 +1,53 @@ +import { fileURLToPath } from 'url'; +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { loadConfig } from '../dist/config.js'; +import { createRuntime } from '../dist/runtime.js'; + +const DType = suite('doctype'); + +let runtime, setupError; + +DType.before(async () => { + try { + const astroConfig = await loadConfig(fileURLToPath(new URL('./fixtures/astro-doctype', import.meta.url))); + + const logging = { + level: 'error', + dest: process.stderr, + }; + + runtime = await createRuntime(astroConfig, { logging }); + } catch (err) { + console.error(err); + setupError = err; + } +}); + +DType.after(async () => { + (await runtime) && runtime.shutdown(); +}); + +DType('No errors creating a runtime', () => { + assert.equal(setupError, undefined); +}); + +DType('Automatically prepends the standards mode doctype', async () => { + const result = await runtime.load('/prepend'); + + assert.equal(result.statusCode, 200); + + const html = result.contents.toString('utf-8'); + assert.ok(html.startsWith('<!doctype html>'), 'Doctype always included'); +}); + +DType.skip('Preserves user provided doctype', async () => { + const result = await runtime.load('/preserve'); + + assert.equal(result.statusCode, 200); + + const html = result.contents.toString('utf-8'); + assert.ok(html.startsWith('<!doctype HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">'), 'Doctype included was preserved'); +}); + +DType.run(); diff --git a/packages/astro/test/astro-dynamic.test.js b/packages/astro/test/astro-dynamic.test.js new file mode 100644 index 000000000..c3743ddad --- /dev/null +++ b/packages/astro/test/astro-dynamic.test.js @@ -0,0 +1,41 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup, setupBuild } from './helpers.js'; + +const DynamicComponents = suite('Dynamic components tests'); + +setup(DynamicComponents, './fixtures/astro-dynamic'); +setupBuild(DynamicComponents, './fixtures/astro-dynamic'); + +DynamicComponents('Loads client-only packages', async ({ runtime }) => { + let result = await runtime.load('/'); + + assert.equal(result.statusCode, 200); + + // Grab the react-dom import + const exp = /import\("(.+?)"\)/g; + let match, reactDomURL; + while ((match = exp.exec(result.contents))) { + if (match[1].includes('react-dom')) { + reactDomURL = match[1]; + } + } + + assert.ok(reactDomURL, 'React dom is on the page'); + + result = await runtime.load(reactDomURL); + assert.equal(result.statusCode, 200, 'Can load react-dom'); +}); + +DynamicComponents('Can be built', async ({ build }) => { + try { + await build(); + assert.ok(true, 'Can build a project with svelte dynamic components'); + } catch (err) { + console.log(err); + assert.ok(false, 'build threw'); + } +}); + +DynamicComponents.run(); diff --git a/packages/astro/test/astro-expr.test.js b/packages/astro/test/astro-expr.test.js new file mode 100644 index 000000000..c3c985712 --- /dev/null +++ b/packages/astro/test/astro-expr.test.js @@ -0,0 +1,63 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup } from './helpers.js'; + +const Expressions = suite('Expressions'); + +setup(Expressions, './fixtures/astro-expr'); + +Expressions('Can load page', async ({ runtime }) => { + const result = await runtime.load('/'); + + assert.equal(result.statusCode, 200); + + const $ = doc(result.contents); + + for (let col of ['red', 'yellow', 'blue']) { + assert.equal($('#' + col).length, 1); + } +}); + +Expressions('Ignores characters inside of strings', async ({ runtime }) => { + const result = await runtime.load('/strings'); + + assert.equal(result.statusCode, 200); + + const $ = doc(result.contents); + + for (let col of ['red', 'yellow', 'blue']) { + assert.equal($('#' + col).length, 1); + } +}); + +Expressions('Ignores characters inside of line comments', async ({ runtime }) => { + const result = await runtime.load('/line-comments'); + assert.equal(result.statusCode, 200); + + const $ = doc(result.contents); + + for (let col of ['red', 'yellow', 'blue']) { + assert.equal($('#' + col).length, 1); + } +}); + +Expressions('Ignores characters inside of multiline comments', async ({ runtime }) => { + const result = await runtime.load('/multiline-comments'); + assert.equal(result.statusCode, 200); + + const $ = doc(result.contents); + + for (let col of ['red', 'yellow', 'blue']) { + assert.equal($('#' + col).length, 1); + } +}); + +Expressions('Allows multiple JSX children in mustache', async ({ runtime }) => { + const result = await runtime.load('/multiple-children'); + assert.equal(result.statusCode, 200); + + assert.ok(result.contents.includes('#f') && !result.contents.includes('#t')); +}); + +Expressions.run(); diff --git a/packages/astro/test/astro-fallback.test.js b/packages/astro/test/astro-fallback.test.js new file mode 100644 index 000000000..2acf29f8e --- /dev/null +++ b/packages/astro/test/astro-fallback.test.js @@ -0,0 +1,19 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup } from './helpers.js'; + +const Fallback = suite('Dynamic component fallback'); + +setup(Fallback, './fixtures/astro-fallback'); + +Fallback('Shows static content', async (context) => { + const result = await context.runtime.load('/'); + + assert.equal(result.statusCode, 200); + + const $ = doc(result.contents); + assert.equal($('#fallback').text(), 'static'); +}); + +Fallback.run(); diff --git a/packages/astro/test/astro-markdown.test.js b/packages/astro/test/astro-markdown.test.js new file mode 100644 index 000000000..5845a8c5c --- /dev/null +++ b/packages/astro/test/astro-markdown.test.js @@ -0,0 +1,71 @@ +import { existsSync, promises as fsPromises } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { createRuntime } from '../dist/runtime.js'; +import { build } from '../dist/build.js'; +import { loadConfig } from '../dist/config.js'; +import { doc } from './test-utils.js'; + +const { rmdir, readFile } = fsPromises; + +const Markdown = suite('Astro Markdown'); + +let runtime, setupError, fixturePath, astroConfig; + +Markdown.before(async () => { + fixturePath = fileURLToPath(new URL('./fixtures/astro-markdown', import.meta.url)); + + astroConfig = await loadConfig(fixturePath); + + const logging = { + level: 'error', + dest: process.stderr, + }; + + try { + runtime = await createRuntime(astroConfig, { logging }); + } catch (err) { + console.error(err); + setupError = err; + } +}); + +Markdown.after(async () => { + (await runtime) && runtime.shutdown(); + rmdir(join(fixturePath, 'dist'), { recursive: true }); +}); + +Markdown('No errors creating a runtime', () => { + assert.equal(setupError, undefined); +}); + +Markdown('Can load markdown pages with hmx', async () => { + const result = await runtime.load('/post'); + + assert.equal(result.statusCode, 200); + + const $ = doc(result.contents); + assert.ok($('#first').length, 'There is a div added in markdown'); + assert.ok($('#test').length, 'There is a div added via a component from markdown'); +}); + +Markdown('Can load more complex jsxy stuff', async () => { + const result = await runtime.load('/complex'); + + const $ = doc(result.contents); + const $el = $('#test'); + assert.equal($el.text(), 'Hello world'); +}); + +Markdown('Bundles client-side JS for prod', async () => { + await build(astroConfig); + + const complexHtml = await readFile(join(fixturePath, './dist/complex/index.html'), 'utf-8'); + + assert.match(complexHtml, `import("/_astro/components/Counter.js"`); + assert.ok(existsSync(join(fixturePath, `./dist/_astro/components/Counter.js`)), 'Counter.jsx is bundled for prod'); +}); + +Markdown.run(); diff --git a/packages/astro/test/astro-request.test.js b/packages/astro/test/astro-request.test.js new file mode 100644 index 000000000..1156714dd --- /dev/null +++ b/packages/astro/test/astro-request.test.js @@ -0,0 +1,19 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup } from './helpers.js'; + +const Request = suite('Astro.request'); + +setup(Request, './fixtures/astro-request'); + +Request('Astro.request available', async (context) => { + const result = await context.runtime.load('/'); + + assert.equal(result.statusCode, 200); + + const $ = doc(result.contents); + assert.equal($('h1').text(), '/'); +}); + +Request.run(); diff --git a/packages/astro/test/astro-rss.test.js b/packages/astro/test/astro-rss.test.js new file mode 100644 index 000000000..1fc70a9a7 --- /dev/null +++ b/packages/astro/test/astro-rss.test.js @@ -0,0 +1,25 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { setupBuild } from './helpers.js'; + +const RSS = suite('RSS Generation'); + +setupBuild(RSS, './fixtures/astro-rss'); + +const snapshot = + `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title><![CDATA[MF Doomcast]]></title><description><![CDATA[The podcast about the things you find on a picnic, or at a picnic table]]></description><link>https://mysite.dev/feed/episodes.xml</link><language>en-us</language><itunes:author>MF Doom</itunes:author><item><title><![CDATA[Rap Snitch Knishes (feat. Mr. Fantastik)]]></title><link>https://mysite.dev/episode/rap-snitch-knishes/</link><description><![CDATA[Complex named this song the “22nd funniest rap song of all time.”]]></description><pubDate>Tue, 16 Nov 2004 00:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>172</itunes:duration><itunes:explicit>true</itunes:explicit></item><item><title><![CDATA[Fazers]]></title><link>https://mysite.dev/episode/fazers/</link><description><![CDATA[Rhapsody ranked Take Me to Your Leader 17th on its list “Hip-Hop’s Best Albums of the Decade”]]></description><pubDate>Thu, 03 Jul 2003 00:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>197</itunes:duration><itunes:explicit>true</itunes:explicit></item><item><title><![CDATA[Rhymes Like Dimes (feat. Cucumber Slice)]]></title><link>https://mysite.dev/episode/rhymes-like-dimes/</link><description><![CDATA[Operation: Doomsday has been heralded as an underground classic that established MF Doom's rank within the underground hip-hop scene during the early to mid-2000s.\n` + + ']]></description><pubDate>Tue, 19 Oct 1999 00:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>259</itunes:duration><itunes:explicit>true</itunes:explicit></item></channel></rss>'; + +RSS('Generates RSS correctly', async (context) => { + let rss; + try { + await context.build(); + rss = await context.readFile('/feed/episodes.xml'); + assert.ok(true, 'Build successful'); + } catch (err) { + assert.ok(false, 'Build threw'); + } + assert.match(rss, snapshot); +}); + +RSS.run(); diff --git a/packages/astro/test/astro-scoped-styles.test.js b/packages/astro/test/astro-scoped-styles.test.js new file mode 100644 index 000000000..e50a9bc8b --- /dev/null +++ b/packages/astro/test/astro-scoped-styles.test.js @@ -0,0 +1,34 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { scopeRule } from '../dist/compiler/transform/postcss-scoped-styles/index.js'; + +const ScopedStyles = suite('Astro PostCSS Scoped Styles Plugin'); + +const className = 'astro-abcd1234'; + +ScopedStyles('Scopes rules correctly', () => { + // Note: assume all selectors have no unnecessary spaces (i.e. must be minified) + const tests = { + '.class': `.class.${className}`, + h1: `h1.${className}`, + '.nav h1': `.nav.${className} h1.${className}`, + '.class+.class': `.class.${className}+.class.${className}`, + '.class~:global(a)': `.class.${className}~a`, + '.class *': `.class.${className} .${className}`, + '.class>*': `.class.${className}>.${className}`, + '.class :global(*)': `.class.${className} *`, + '.class :global(.nav:not(.is-active))': `.class.${className} .nav:not(.is-active)`, // preserve nested parens + '.class :global(ul li)': `.class.${className} ul li`, // allow doubly-scoped selectors + '.class:not(.is-active)': `.class.${className}:not(.is-active)`, // Note: the :not() selector can NOT contain multiple classes, so this is correct; if this causes issues for some people then it‘s worth a discussion + 'body h1': `body h1.${className}`, // body shouldn‘t be scoped; it‘s not a component + from: 'from', // ignore keyframe keywords (below) + to: 'to', + '55%': '55%', + }; + + for (const [given, expected] of Object.entries(tests)) { + assert.equal(scopeRule(given, className), expected); + } +}); + +ScopedStyles.run(); diff --git a/packages/astro/test/astro-search.test.js b/packages/astro/test/astro-search.test.js new file mode 100644 index 000000000..415bc4432 --- /dev/null +++ b/packages/astro/test/astro-search.test.js @@ -0,0 +1,41 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { setup } from './helpers.js'; + +const Search = suite('Search paths'); + +setup(Search, './fixtures/astro-basic'); + +Search('Finds the root page', async ({ runtime }) => { + const result = await runtime.load('/'); + assert.equal(result.statusCode, 200); +}); + +Search('Matches pathname to filename', async ({ runtime }) => { + const result = await runtime.load('/news'); + assert.equal(result.statusCode, 200); +}); + +Search('A URL with a trailing slash can match a folder with an index.astro', async ({ runtime }) => { + const result = await runtime.load('/nested-astro/'); + assert.equal(result.statusCode, 200); +}); + +Search('A URL with a trailing slash can match a folder with an index.md', async ({ runtime }) => { + const result = await runtime.load('/nested-md/'); + assert.equal(result.statusCode, 200); +}); + +Search('A URL without a trailing slash can redirect to a folder with an index.astro', async ({ runtime }) => { + const result = await runtime.load('/nested-astro'); + assert.equal(result.statusCode, 301); + assert.equal(result.location, '/nested-astro/'); +}); + +Search('A URL without a trailing slash can redirect to a folder with an index.md', async ({ runtime }) => { + const result = await runtime.load('/nested-md'); + assert.equal(result.statusCode, 301); + assert.equal(result.location, '/nested-md/'); +}); + +Search.run(); diff --git a/packages/astro/test/astro-sitemap.test.js b/packages/astro/test/astro-sitemap.test.js new file mode 100644 index 000000000..5e47c5d81 --- /dev/null +++ b/packages/astro/test/astro-sitemap.test.js @@ -0,0 +1,23 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { setupBuild } from './helpers.js'; + +const Sitemap = suite('Sitemap Generation'); + +setupBuild(Sitemap, './fixtures/astro-rss'); + +const snapshot = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://mysite.dev/episode/fazers/</loc></url><url><loc>https://mysite.dev/episode/rap-snitch-knishes/</loc></url><url><loc>https://mysite.dev/episode/rhymes-like-dimes/</loc></url><url><loc>https://mysite.dev/episodes/</loc></url></urlset>`; + +Sitemap('Generates Sitemap correctly', async (context) => { + let rss; + try { + await context.build(); + rss = await context.readFile('/sitemap.xml'); + assert.ok(true, 'Build successful'); + } catch (err) { + assert.ok(false, 'Build threw'); + } + assert.match(rss, snapshot); +}); + +Sitemap.run(); diff --git a/packages/astro/test/astro-styles-ssr.test.js b/packages/astro/test/astro-styles-ssr.test.js new file mode 100644 index 000000000..2fd87b37a --- /dev/null +++ b/packages/astro/test/astro-styles-ssr.test.js @@ -0,0 +1,104 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup } from './helpers.js'; + +const StylesSSR = suite('Styles SSR'); + +/** Basic CSS minification; removes some flakiness in testing CSS */ +function cssMinify(css) { + return css + .trim() // remove whitespace + .replace(/\n\s*/g, '') // collapse lines + .replace(/\s*\{/g, '{') // collapse selectors + .replace(/:\s*/g, ':') // collapse attributes + .replace(/;}/g, '}'); // collapse block +} + +setup(StylesSSR, './fixtures/astro-styles-ssr'); + +StylesSSR('Has <link> tags', async ({ runtime }) => { + const MUST_HAVE_LINK_TAGS = [ + '/_astro/components/ReactCSS.css', + '/_astro/components/ReactModules.module.css', + '/_astro/components/SvelteScoped.svelte.css', + '/_astro/components/VueCSS.vue.css', + '/_astro/components/VueModules.vue.css', + '/_astro/components/VueScoped.vue.css', + ]; + + const result = await runtime.load('/'); + const $ = doc(result.contents); + + for (const href of MUST_HAVE_LINK_TAGS) { + const el = $(`link[href="${href}"]`); + assert.equal(el.length, 1); + } +}); + +StylesSSR('Has correct CSS classes', async ({ runtime }) => { + // TODO: remove this (temporary CI patch) + if (process.version.startsWith('v14.')) { + return; + } + + const result = await runtime.load('/'); + const $ = doc(result.contents); + + const MUST_HAVE_CLASSES = { + '#react-css': 'react-title', + '#react-modules': 'title', // ⚠️ this should be transformed + '#vue-css': 'vue-title', + '#vue-modules': 'title', // ⚠️ this should also be transformed + '#vue-scoped': 'vue-title', // also has data-v-* property + '#svelte-scoped': 'svelte-title', // also has additional class + }; + + for (const [selector, className] of Object.entries(MUST_HAVE_CLASSES)) { + const el = $(selector); + if (selector === '#react-modules' || selector === '#vue-modules') { + // this will generate differently on Unix vs Windows. Here we simply test that it has transformed + assert.match(el.attr('class'), new RegExp(`^_${className}_[A-Za-z0-9-_]+`)); // className should be transformed, surrounded by underscores and other stuff + } else { + // if this is not a CSS module, it should remain as expected + assert.ok(el.attr('class').includes(className)); + } + + // add’l test: Vue Scoped styles should have data-v-* attribute + if (selector === '#vue-scoped') { + const { attribs } = el.get(0); + const scopeId = Object.keys(attribs).find((k) => k.startsWith('data-v-')); + assert.ok(scopeId); + } + + // add’l test: Svelte should have another class + if (selector === '#svelte-title') { + assert.not.equal(el.attr('class'), className); + } + } +}); + +StylesSSR('CSS Module support in .astro', async ({ runtime }) => { + const result = await runtime.load('/'); + const $ = doc(result.contents); + + let scopedClass; + + // test 1: <style> tag in <head> is transformed + const css = cssMinify( + $('style') + .html() + .replace(/\.astro-[A-Za-z0-9-]+/, (match) => { + scopedClass = match; // get class hash from result + return match; + }) + ); + + assert.match(css, `.wrapper${scopedClass}{margin-left:auto;margin-right:auto;max-width:1200px}`); + + // test 2: element received .astro-XXXXXX class (this selector will succeed if transformed correctly) + const wrapper = $(`.wrapper${scopedClass}`); + assert.equal(wrapper.length, 1); +}); + +StylesSSR.run(); diff --git a/packages/astro/test/config-path.test.js b/packages/astro/test/config-path.test.js new file mode 100644 index 000000000..33e2cf3b7 --- /dev/null +++ b/packages/astro/test/config-path.test.js @@ -0,0 +1,24 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { runDevServer } from './helpers.js'; + +const ConfigPath = suite('Config path'); + +const root = new URL('./fixtures/config-path/', import.meta.url); +ConfigPath('can be passed via --config', async (context) => { + const configPath = new URL('./config/my-config.mjs', root).pathname; + const args = ['--config', configPath]; + const process = runDevServer(root, args); + + process.stdout.setEncoding('utf8'); + for await (const chunk of process.stdout) { + if(/Server started/.test(chunk)) { + break; + } + } + + process.kill(); + assert.ok(true, 'Server started'); +}); + +ConfigPath.run(); diff --git a/packages/astro/test/config-port.test.js b/packages/astro/test/config-port.test.js new file mode 100644 index 000000000..87c57536c --- /dev/null +++ b/packages/astro/test/config-port.test.js @@ -0,0 +1,29 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { runDevServer } from './helpers.js'; +import { loadConfig } from '../dist/config.js'; + +const ConfigPort = suite('Config path'); + +const root = new URL('./fixtures/config-port/', import.meta.url); +ConfigPort('can be specified in the astro config', async (context) => { + const astroConfig = await loadConfig(root.pathname); + assert.equal(astroConfig.devOptions.port, 3001); +}); + +ConfigPort('can be specified via --port flag', async (context) => { + const args = ['--port', '3002']; + const process = runDevServer(root, args); + + process.stdout.setEncoding('utf8'); + for await (const chunk of process.stdout) { + if(/Local:/.test(chunk)) { + assert.ok(/:3002/.test(chunk), 'Using the right port'); + break; + } + } + + process.kill(); +}); + +ConfigPort.run(); diff --git a/packages/astro/test/fixtures/astro-basic/src/layouts/base.astro b/packages/astro/test/fixtures/astro-basic/src/layouts/base.astro new file mode 100644 index 000000000..ec996a32f --- /dev/null +++ b/packages/astro/test/fixtures/astro-basic/src/layouts/base.astro @@ -0,0 +1,17 @@ +--- +export let content: any; +--- + +<!doctype html> +<html lang="en"> +<head> + <title>{content.title}</title> + <meta charset="utf-8"> +</head> + +<body> + <h1>{content.title}</h1> + + <main><slot></slot></main> +</body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-basic/src/pages/index.astro b/packages/astro/test/fixtures/astro-basic/src/pages/index.astro new file mode 100644 index 000000000..5ae5380c5 --- /dev/null +++ b/packages/astro/test/fixtures/astro-basic/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +let title = 'My App' +--- + +<html> + <head> + <!-- Head Stuff --> + </head> + <body> + <h1>Hello world!</h1> + </body> +</html> diff --git a/packages/astro/test/fixtures/astro-basic/src/pages/nested-astro/index.astro b/packages/astro/test/fixtures/astro-basic/src/pages/nested-astro/index.astro new file mode 100644 index 000000000..a28992ee6 --- /dev/null +++ b/packages/astro/test/fixtures/astro-basic/src/pages/nested-astro/index.astro @@ -0,0 +1,12 @@ +--- +let title = 'Nested page' +--- + +<html> + <head> + <!-- Head Stuff --> + </head> + <body> + <h1>{title}</h1> + </body> +</html> diff --git a/packages/astro/test/fixtures/astro-basic/src/pages/nested-md/index.md b/packages/astro/test/fixtures/astro-basic/src/pages/nested-md/index.md new file mode 100644 index 000000000..23374f9b8 --- /dev/null +++ b/packages/astro/test/fixtures/astro-basic/src/pages/nested-md/index.md @@ -0,0 +1,6 @@ +--- +layout: ../../layouts/base.astro +title: My Page +--- + +Hello world
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-basic/src/pages/news.astro b/packages/astro/test/fixtures/astro-basic/src/pages/news.astro new file mode 100644 index 000000000..71a00b8a9 --- /dev/null +++ b/packages/astro/test/fixtures/astro-basic/src/pages/news.astro @@ -0,0 +1,12 @@ +--- +let title = 'The News' +--- + +<html lang="en"> + <head> + <title>{title}</title> + </head> + <body> + <h1>Hello world!</h1> + </body> +</html> diff --git a/packages/astro/test/fixtures/astro-children/astro.config.mjs b/packages/astro/test/fixtures/astro-children/astro.config.mjs new file mode 100644 index 000000000..e2a209f83 --- /dev/null +++ b/packages/astro/test/fixtures/astro-children/astro.config.mjs @@ -0,0 +1,5 @@ +export default { + extensions: { + '.jsx': 'preact' + }, +}; diff --git a/packages/astro/test/fixtures/astro-children/src/components/Component.jsx b/packages/astro/test/fixtures/astro-children/src/components/Component.jsx new file mode 100644 index 000000000..bf9280f86 --- /dev/null +++ b/packages/astro/test/fixtures/astro-children/src/components/Component.jsx @@ -0,0 +1,5 @@ +import { h } from 'preact'; + +export default function PreactComponent({ children }) { + return <div id="preact">{children}</div> +} diff --git a/packages/astro/test/fixtures/astro-children/src/components/Component.svelte b/packages/astro/test/fixtures/astro-children/src/components/Component.svelte new file mode 100644 index 000000000..4276a78b8 --- /dev/null +++ b/packages/astro/test/fixtures/astro-children/src/components/Component.svelte @@ -0,0 +1,3 @@ +<div id="svelte"> + <slot /> +</div> diff --git a/packages/astro/test/fixtures/astro-children/src/components/Component.vue b/packages/astro/test/fixtures/astro-children/src/components/Component.vue new file mode 100644 index 000000000..22e6e1143 --- /dev/null +++ b/packages/astro/test/fixtures/astro-children/src/components/Component.vue @@ -0,0 +1,9 @@ +<template> + <div id="vue"> + <slot /> + </div> +</template> + +<script> +export default {} +</script> diff --git a/packages/astro/test/fixtures/astro-children/src/pages/markup.astro b/packages/astro/test/fixtures/astro-children/src/pages/markup.astro new file mode 100644 index 000000000..b771c2433 --- /dev/null +++ b/packages/astro/test/fixtures/astro-children/src/pages/markup.astro @@ -0,0 +1,21 @@ +--- +import PreactComponent from '../components/Component.jsx'; +import VueComponent from '../components/Component.vue'; +import SvelteComponent from '../components/Component.svelte'; +--- +<html> +<head><title>Children</title></head> +<body> + <PreactComponent> + <h1>Hello world</h1> + </PreactComponent> + + <VueComponent> + <h1>Hello world</h1> + </VueComponent> + + <SvelteComponent> + <h1>Hello world</h1> + </SvelteComponent> +</body> +</html> diff --git a/packages/astro/test/fixtures/astro-children/src/pages/multiple.astro b/packages/astro/test/fixtures/astro-children/src/pages/multiple.astro new file mode 100644 index 000000000..8c2f73a91 --- /dev/null +++ b/packages/astro/test/fixtures/astro-children/src/pages/multiple.astro @@ -0,0 +1,24 @@ +--- +import PreactComponent from '../components/Component.jsx'; +import VueComponent from '../components/Component.vue'; +import SvelteComponent from '../components/Component.svelte'; +--- +<html> +<head><title>Children</title></head> +<body> + <PreactComponent> + <h1>Hello world</h1> + <h1>Goodbye world</h1> + </PreactComponent> + + <VueComponent> + <h1>Hello world</h1> + <h1>Goodbye world</h1> + </VueComponent> + + <SvelteComponent> + <h1>Hello world</h1> + <h1>Goodbye world</h1> + </SvelteComponent> +</body> +</html> diff --git a/packages/astro/test/fixtures/astro-children/src/pages/strings.astro b/packages/astro/test/fixtures/astro-children/src/pages/strings.astro new file mode 100644 index 000000000..10b1a887f --- /dev/null +++ b/packages/astro/test/fixtures/astro-children/src/pages/strings.astro @@ -0,0 +1,21 @@ +--- +import PreactComponent from '../components/Component.jsx'; +import VueComponent from '../components/Component.vue'; +import SvelteComponent from '../components/Component.svelte'; +--- +<html> +<head><title>Children</title></head> +<body> + <PreactComponent> + Hello world + </PreactComponent> + + <VueComponent> + Hello world + </VueComponent> + + <SvelteComponent> + Hello world + </SvelteComponent> +</body> +</html> diff --git a/packages/astro/test/fixtures/astro-collection/src/pages/$posts.astro b/packages/astro/test/fixtures/astro-collection/src/pages/$posts.astro new file mode 100644 index 000000000..4186a9a5c --- /dev/null +++ b/packages/astro/test/fixtures/astro-collection/src/pages/$posts.astro @@ -0,0 +1,28 @@ +--- +export let collection: any; + +export async function createCollection() { + return { + async data() { + let data = Astro.fetchContent('./post/*.md'); + data.sort((a, b) => new Date(b.date) - new Date(a.date)); + return data; + }, + pageSize: 2 + }; +} +--- + +<div id="posts"> +{collection.data.map((post) => ( + <article> + <h1>{post.title}</h1> + <a href={post.url}>Read more</a> + </article> +))} +</div> + +<nav> + {collection.url.prev && <a id="prev-page" href={collection.url.prev}>Previous page</a>} + {collection.url.next && <a id="next-page" href={collection.url.next}>Next page</a>} +</nav> diff --git a/packages/astro/test/fixtures/astro-collection/src/pages/post/one.md b/packages/astro/test/fixtures/astro-collection/src/pages/post/one.md new file mode 100644 index 000000000..9d68e12dd --- /dev/null +++ b/packages/astro/test/fixtures/astro-collection/src/pages/post/one.md @@ -0,0 +1,8 @@ +--- +title: Post One +date: 2021-04-13 00:00:00 +--- + +# Post One + +I’m the first blog post diff --git a/packages/astro/test/fixtures/astro-collection/src/pages/post/three.md b/packages/astro/test/fixtures/astro-collection/src/pages/post/three.md new file mode 100644 index 000000000..c495a5195 --- /dev/null +++ b/packages/astro/test/fixtures/astro-collection/src/pages/post/three.md @@ -0,0 +1,8 @@ +--- +title: Post Three +date: 2021-04-15 00:00:00 +--- + +# Post Three + +I’m the third blog post diff --git a/packages/astro/test/fixtures/astro-collection/src/pages/post/two.md b/packages/astro/test/fixtures/astro-collection/src/pages/post/two.md new file mode 100644 index 000000000..39855e701 --- /dev/null +++ b/packages/astro/test/fixtures/astro-collection/src/pages/post/two.md @@ -0,0 +1,8 @@ +--- +title: Post Two +date: 2021-04-14 00:00:00 +--- + +# Post Two + +I’m the second blog post diff --git a/packages/astro/test/fixtures/astro-doctype/src/pages/prepend.astro b/packages/astro/test/fixtures/astro-doctype/src/pages/prepend.astro new file mode 100644 index 000000000..f8fb1bacd --- /dev/null +++ b/packages/astro/test/fixtures/astro-doctype/src/pages/prepend.astro @@ -0,0 +1,8 @@ +--- +let title = 'My Site'; +--- + +<html lang="en"> + <head><title>{title}</title></head> + <body><h1>Hello world</h1></body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-doctype/src/pages/preserve.astro b/packages/astro/test/fixtures/astro-doctype/src/pages/preserve.astro new file mode 100644 index 000000000..3e1ca934f --- /dev/null +++ b/packages/astro/test/fixtures/astro-doctype/src/pages/preserve.astro @@ -0,0 +1,9 @@ +--- +let title = 'My Site'; +--- + +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> +<html lang="en"> + <head><title>{title}</title></head> + <body><h1>Hello world</h1></body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-dynamic/astro.config.mjs b/packages/astro/test/fixtures/astro-dynamic/astro.config.mjs new file mode 100644 index 000000000..09731ba28 --- /dev/null +++ b/packages/astro/test/fixtures/astro-dynamic/astro.config.mjs @@ -0,0 +1,5 @@ +export default { + buildOptions: { + sitemap: false, + }, +}; diff --git a/packages/astro/test/fixtures/astro-dynamic/src/components/Counter.jsx b/packages/astro/test/fixtures/astro-dynamic/src/components/Counter.jsx new file mode 100644 index 000000000..31472c3ac --- /dev/null +++ b/packages/astro/test/fixtures/astro-dynamic/src/components/Counter.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function() { + return ( + <div> + <button type="button">Increment -</button> + </div> + ) +}
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-dynamic/src/components/SvelteCounter.svelte b/packages/astro/test/fixtures/astro-dynamic/src/components/SvelteCounter.svelte new file mode 100644 index 000000000..8d6b3f5e1 --- /dev/null +++ b/packages/astro/test/fixtures/astro-dynamic/src/components/SvelteCounter.svelte @@ -0,0 +1,22 @@ + +<script> + let children; + let count = 0; + + function add() { + count += 1; + } + + function subtract() { + count -= 1; + } +</script> + +<div class="counter"> + <button on:click={subtract}>-</button> + <pre>{ count }</pre> + <button on:click={add}>+</button> +</div> +<div class="children"> + <slot /> +</div> diff --git a/packages/astro/test/fixtures/astro-dynamic/src/pages/index.astro b/packages/astro/test/fixtures/astro-dynamic/src/pages/index.astro new file mode 100644 index 000000000..c4d0fef17 --- /dev/null +++ b/packages/astro/test/fixtures/astro-dynamic/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +import Counter from '../components/Counter.jsx'; +import SvelteCounter from '../components/SvelteCounter.svelte'; +--- +<html> +<head><title>Dynamic pages</title></head> +<body> + <Counter:load /> + + <SvelteCounter:load /> +</body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-expr/astro.config.mjs b/packages/astro/test/fixtures/astro-expr/astro.config.mjs new file mode 100644 index 000000000..80d0860c3 --- /dev/null +++ b/packages/astro/test/fixtures/astro-expr/astro.config.mjs @@ -0,0 +1,6 @@ + +export default { + extensions: { + '.jsx': 'preact' + } +}
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-expr/src/components/Color.jsx b/packages/astro/test/fixtures/astro-expr/src/components/Color.jsx new file mode 100644 index 000000000..c2681cc9b --- /dev/null +++ b/packages/astro/test/fixtures/astro-expr/src/components/Color.jsx @@ -0,0 +1,5 @@ +import { h } from 'preact'; + +export default function({ name }) { + return <div id={name}>{name}</div> +}
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-expr/src/pages/index.astro b/packages/astro/test/fixtures/astro-expr/src/pages/index.astro new file mode 100644 index 000000000..50af05d93 --- /dev/null +++ b/packages/astro/test/fixtures/astro-expr/src/pages/index.astro @@ -0,0 +1,22 @@ +--- +import Color from '../components/Color.jsx'; + +let title = 'My Site'; + +const colors = ['red', 'yellow', 'blue']; +--- + +<html lang="en"> +<head> + <title>My site</title> +</head> +<body> + <h1>{title}</h1> + + {colors.map(color => ( + <div> + <Color name={color} /> + </div> + ))} +</body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-expr/src/pages/line-comments.astro b/packages/astro/test/fixtures/astro-expr/src/pages/line-comments.astro new file mode 100644 index 000000000..2fb7bf643 --- /dev/null +++ b/packages/astro/test/fixtures/astro-expr/src/pages/line-comments.astro @@ -0,0 +1,17 @@ +--- +let title = 'My App'; + +let colors = ['red', 'yellow', 'blue']; +--- + +<html> +<head> + <title>{title}</title> +</head> +<body> + {colors.map(color => ( + // foo < > < } + <div id={color}>color</div> + ))} +</body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-expr/src/pages/multiline-comments.astro b/packages/astro/test/fixtures/astro-expr/src/pages/multiline-comments.astro new file mode 100644 index 000000000..5c7016ee8 --- /dev/null +++ b/packages/astro/test/fixtures/astro-expr/src/pages/multiline-comments.astro @@ -0,0 +1,16 @@ +--- +let title = 'My App'; + +let colors = ['red', 'yellow', 'blue']; +--- + +<html> +<head> + <title>{title}</title> +</head> +<body> + {colors.map(color => ( + /* foo < > < } */ <div id={color}>color</div> + ))} +</body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-expr/src/pages/multiple-children.astro b/packages/astro/test/fixtures/astro-expr/src/pages/multiple-children.astro new file mode 100644 index 000000000..fb0fafd4a --- /dev/null +++ b/packages/astro/test/fixtures/astro-expr/src/pages/multiple-children.astro @@ -0,0 +1,14 @@ +--- +let title = 'My Site'; +--- + +<html lang="en"> +<head> + <title>My site</title> +</head> +<body> + <h1>{title}</h1> + + {false ? <h1>#t</h1> : <h1>#f</h1>} +</body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-expr/src/pages/strings.astro b/packages/astro/test/fixtures/astro-expr/src/pages/strings.astro new file mode 100644 index 000000000..712df6120 --- /dev/null +++ b/packages/astro/test/fixtures/astro-expr/src/pages/strings.astro @@ -0,0 +1,16 @@ +--- +let title = 'My App'; + +let colors = ['red', 'yellow', 'blue']; +--- + +<html> +<head> + <title>{title}</title> +</head> +<body> + {colors.map(color => ( + 'foo < > < }' && <div id={color}>color</div> + ))} +</body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-fallback/astro.config.mjs b/packages/astro/test/fixtures/astro-fallback/astro.config.mjs new file mode 100644 index 000000000..f50751cfd --- /dev/null +++ b/packages/astro/test/fixtures/astro-fallback/astro.config.mjs @@ -0,0 +1,5 @@ +export default { + extensions: { + '.jsx': 'preact', + }, +}; diff --git a/packages/astro/test/fixtures/astro-fallback/src/components/Client.jsx b/packages/astro/test/fixtures/astro-fallback/src/components/Client.jsx new file mode 100644 index 000000000..d79536e27 --- /dev/null +++ b/packages/astro/test/fixtures/astro-fallback/src/components/Client.jsx @@ -0,0 +1,7 @@ +import { h } from 'preact'; + +export default function(props) { + return ( + <div id="fallback">{import.meta.env.astro ? 'static' : 'dynamic'}</div> + ); +};
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-fallback/src/pages/index.astro b/packages/astro/test/fixtures/astro-fallback/src/pages/index.astro new file mode 100644 index 000000000..f4f20c322 --- /dev/null +++ b/packages/astro/test/fixtures/astro-fallback/src/pages/index.astro @@ -0,0 +1,16 @@ +--- +import Client from '../components/Client.jsx'; + +let title = 'My Page' +--- + +<html> +<head> + <title>{title}</title> +</head> +<body> + <h1>{title}</h1> + + <Client:load /> +</body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-markdown/astro.config.mjs b/packages/astro/test/fixtures/astro-markdown/astro.config.mjs new file mode 100644 index 000000000..c8631c503 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/astro.config.mjs @@ -0,0 +1,8 @@ +export default { + extensions: { + '.jsx': 'preact', + }, + buildOptions: { + sitemap: false, + }, +}; diff --git a/packages/astro/test/fixtures/astro-markdown/src/components/Counter.jsx b/packages/astro/test/fixtures/astro-markdown/src/components/Counter.jsx new file mode 100644 index 000000000..a75f858b5 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/components/Counter.jsx @@ -0,0 +1,7 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; + +export default function () { + const [count, setCount] = useState(0); + return <button onClick={() => setCount(count + 1)}>{count}</button>; +} diff --git a/packages/astro/test/fixtures/astro-markdown/src/components/Example.jsx b/packages/astro/test/fixtures/astro-markdown/src/components/Example.jsx new file mode 100644 index 000000000..57bde3a95 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/components/Example.jsx @@ -0,0 +1,5 @@ +import { h } from 'preact'; + +export default function() { + return <div id="test">Testing</div> +}
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-markdown/src/components/Hello.jsx b/packages/astro/test/fixtures/astro-markdown/src/components/Hello.jsx new file mode 100644 index 000000000..787ca587b --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/components/Hello.jsx @@ -0,0 +1,5 @@ +import { h } from 'preact'; + +export default function({ name }) { + return <div id="test">Hello {name}</div> +}
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-markdown/src/layouts/content.astro b/packages/astro/test/fixtures/astro-markdown/src/layouts/content.astro new file mode 100644 index 000000000..925a243a9 --- /dev/null +++ b/packages/astro/test/fixtures/astro-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/complex.md b/packages/astro/test/fixtures/astro-markdown/src/pages/complex.md new file mode 100644 index 000000000..6367948b9 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/complex.md @@ -0,0 +1,13 @@ +--- +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 />
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/post.md b/packages/astro/test/fixtures/astro-markdown/src/pages/post.md new file mode 100644 index 000000000..58ebdc945 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/post.md @@ -0,0 +1,13 @@ +--- +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> + +<Example /> diff --git a/packages/astro/test/fixtures/astro-request/src/pages/index.astro b/packages/astro/test/fixtures/astro-request/src/pages/index.astro new file mode 100644 index 000000000..f809a76e3 --- /dev/null +++ b/packages/astro/test/fixtures/astro-request/src/pages/index.astro @@ -0,0 +1,10 @@ +--- +let path = Astro.request.url.pathname; +--- + +<html> +<head><title>Test</title></head> +<body> + <h1>{path}</h1> +</body> +</html>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-rss/astro.config.mjs b/packages/astro/test/fixtures/astro-rss/astro.config.mjs new file mode 100644 index 000000000..c19ba79f1 --- /dev/null +++ b/packages/astro/test/fixtures/astro-rss/astro.config.mjs @@ -0,0 +1,5 @@ +export default { + buildOptions: { + site: 'https://mysite.dev', + }, +}; diff --git a/packages/astro/test/fixtures/astro-rss/src/pages/$episodes.astro b/packages/astro/test/fixtures/astro-rss/src/pages/$episodes.astro new file mode 100644 index 000000000..686770480 --- /dev/null +++ b/packages/astro/test/fixtures/astro-rss/src/pages/$episodes.astro @@ -0,0 +1,40 @@ +--- +export let collection; + +export async function createCollection() { + return { + async data() { + const episodes = Astro.fetchContent('./episode/*.md'); + episodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate)); + return episodes; + }, + rss: { + title: 'MF Doomcast', + description: 'The podcast about the things you find on a picnic, or at a picnic table', + xmlns: { + itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', + content: 'http://purl.org/rss/1.0/modules/content/', + }, + customData: `<language>en-us</language>` + + `<itunes:author>MF Doom</itunes:author>`, + item: (item) => ({ + title: item.title, + link: item.url, + description: item.description, + pubDate: item.pubDate + 'Z', + customData: `<itunes:episodeType>${item.type}</itunes:episodeType>` + + `<itunes:duration>${item.duration}</itunes:duration>` + + `<itunes:explicit>${item.explicit || false}</itunes:explicit>`, + }), + } + } +} +--- + +<html> + <head> + <title>Podcast Episodes</title> + <link rel="alternate" type="application/rss+2.0" href="/feed/episodes.xml" /> + </head> + <body></body> +</html> diff --git a/packages/astro/test/fixtures/astro-rss/src/pages/episode/fazers.md b/packages/astro/test/fixtures/astro-rss/src/pages/episode/fazers.md new file mode 100644 index 000000000..9efbf1fa2 --- /dev/null +++ b/packages/astro/test/fixtures/astro-rss/src/pages/episode/fazers.md @@ -0,0 +1,13 @@ +--- +title: Fazers +artist: King Geedorah +type: music +duration: 197 +pubDate: '2003-07-03 00:00:00' +description: Rhapsody ranked Take Me to Your Leader 17th on its list “Hip-Hop’s Best Albums of the Decade” +explicit: true +--- + +# Fazers + +Rhapsody ranked Take Me to Your Leader 17th on its list “Hip-Hop’s Best Albums of the Decade” diff --git a/packages/astro/test/fixtures/astro-rss/src/pages/episode/rap-snitch-knishes.md b/packages/astro/test/fixtures/astro-rss/src/pages/episode/rap-snitch-knishes.md new file mode 100644 index 000000000..e7ade24b4 --- /dev/null +++ b/packages/astro/test/fixtures/astro-rss/src/pages/episode/rap-snitch-knishes.md @@ -0,0 +1,13 @@ +--- +title: Rap Snitch Knishes (feat. Mr. Fantastik) +artist: MF Doom +type: music +duration: 172 +pubDate: '2004-11-16 00:00:00' +description: Complex named this song the “22nd funniest rap song of all time.” +explicit: true +--- + +# Rap Snitch Knishes (feat. Mr. Fantastik) + +Complex named this song the “22nd funniest rap song of all time.” diff --git a/packages/astro/test/fixtures/astro-rss/src/pages/episode/rhymes-like-dimes.md b/packages/astro/test/fixtures/astro-rss/src/pages/episode/rhymes-like-dimes.md new file mode 100644 index 000000000..ba73c28d8 --- /dev/null +++ b/packages/astro/test/fixtures/astro-rss/src/pages/episode/rhymes-like-dimes.md @@ -0,0 +1,14 @@ +--- +title: Rhymes Like Dimes (feat. Cucumber Slice) +artist: MF Doom +type: music +duration: 259 +pubDate: '1999-10-19 00:00:00' +description: | + Operation: Doomsday has been heralded as an underground classic that established MF Doom's rank within the underground hip-hop scene during the early to mid-2000s. +explicit: true +--- + +# Rhymes Like Dimes (feat. Cucumber Slice) + +Operation: Doomsday has been heralded as an underground classic that established MF Doom's rank within the underground hip-hop scene during the early to mid-2000s. diff --git a/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactCSS.css b/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactCSS.css new file mode 100644 index 000000000..a29595b86 --- /dev/null +++ b/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactCSS.css @@ -0,0 +1,3 @@ +.react-title { + font-family: fantasy; +} diff --git a/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactCSS.jsx b/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactCSS.jsx new file mode 100644 index 000000000..88d731a3f --- /dev/null +++ b/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactCSS.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import './ReactCSS.css'; + +function ReactCSS() { + return ( + <h1 id="react-css" className="react-title"> + React Global CSS + </h1> + ); +} +export default ReactCSS; diff --git a/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactModules.jsx b/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactModules.jsx new file mode 100644 index 000000000..b3aef6bb2 --- /dev/null +++ b/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactModules.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import Styles from './ReactModules.module.css'; + +function ReactModules() { + return ( + <h1 id="react-modules" className={Styles.title}> + React Modules + </h1> + ); +} +export default ReactModules; diff --git a/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactModules.module.css b/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactModules.module.css new file mode 100644 index 000000000..465178859 --- /dev/null +++ b/packages/astro/test/fixtures/astro-styles-ssr/src/components/ReactModules.module.css @@ -0,0 +1,3 @@ +.title { + font-family: fantasy; +} diff --git a/packages/astro/test/fixtures/astro-styles-ssr/src/components/SvelteScoped.svelte b/packages/astro/test/fixtures/astro-styles-ssr/src/components/SvelteScoped.svelte new file mode 100644 index 000000000..8c2b7d451 --- /dev/null +++ b/packages/astro/test/fixtures/astro-styles-ssr/src/components/SvelteScoped.svelte @@ -0,0 +1,7 @@ +<h1 id="svelte-scoped" class="svelte-title">Svelte Scoped CSS</h1> + +<style> + .svelte-title { + font-family: 'Comic Sans MS', sans-serif; + } +</style> diff --git a/packages/astro/test/fixtures/astro-styles-ssr/src/components/VueCSS.vue b/packages/astro/test/fixtures/astro-styles-ssr/src/components/VueCSS.vue new file mode 100644 index 000000000..23ac9a291 --- /dev/null +++ b/packages/astro/test/fixtures/astro-styles-ssr/src/components/VueCSS.vue @@ -0,0 +1,9 @@ +<style> +.vue-title { + font-family: cursive; +} +</style> + +<template> + <h1 id="vue-css" class="vue-title">Vue Global CSS</h1> +</template> diff --git a/packages/astro/test/fixtures/astro-styles-ssr/src/components/VueModules.vue b/packages/astro/test/fixtures/astro-styles-ssr/src/components/VueModules.vue new file mode 100644 index 000000000..bd520fec4 --- /dev/null +++ b/packages/astro/test/fixtures/astro-styles-ssr/src/components/VueModules.vue @@ -0,0 +1,9 @@ +<style module> +.title { + font-family: cursive; +} +</style> + +<template> + <h1 id="vue-modules" :class="$style.title">Vue CSS Modules</h1> +</template> diff --git a/packages/astro/test/fixtures/astro-styles-ssr/src/components/VueScoped.vue b/packages/astro/test/fixtures/astro-styles-ssr/src/components/VueScoped.vue new file mode 100644 index 000000000..0eee4dff1 --- /dev/null +++ b/packages/astro/test/fixtures/astro-styles-ssr/src/components/VueScoped.vue @@ -0,0 +1,9 @@ +<style scoped> +.vue-title { + font-family: cursive; +} +</style> + +<template> + <h1 id="vue-scoped" class="vue-title">Vue Scoped CSS</h1> +</template> diff --git a/packages/astro/test/fixtures/astro-styles-ssr/src/pages/index.astro b/packages/astro/test/fixtures/astro-styles-ssr/src/pages/index.astro new file mode 100644 index 000000000..45f9683ac --- /dev/null +++ b/packages/astro/test/fixtures/astro-styles-ssr/src/pages/index.astro @@ -0,0 +1,31 @@ +--- +import ReactCSS from '../components/ReactCSS.jsx'; +import ReactModules from '../components/ReactModules.jsx'; +import VueCSS from '../components/VueCSS.vue'; +import VueScoped from '../components/VueScoped.vue'; +import VueModules from '../components/VueModules.vue'; +import SvelteScoped from '../components/SvelteScoped.svelte'; +--- + +<html> + <head> + <meta charset="UTF-8" /> + <style lang="scss"> + .wrapper { + margin-left: auto; + margin-right: auto; + max-width: 1200px; + } + </style> + </head> + <body> + <div class="wrapper"> + <ReactCSS /> + <ReactModules /> + <VueCSS /> + <VueScoped /> + <VueModules /> + <SvelteScoped /> + </div> + </body> +</html> diff --git a/packages/astro/test/fixtures/config-path/config/my-config.mjs b/packages/astro/test/fixtures/config-path/config/my-config.mjs new file mode 100644 index 000000000..f50751cfd --- /dev/null +++ b/packages/astro/test/fixtures/config-path/config/my-config.mjs @@ -0,0 +1,5 @@ +export default { + extensions: { + '.jsx': 'preact', + }, +}; diff --git a/packages/astro/test/fixtures/config-port/astro.config.mjs b/packages/astro/test/fixtures/config-port/astro.config.mjs new file mode 100644 index 000000000..61858cdae --- /dev/null +++ b/packages/astro/test/fixtures/config-port/astro.config.mjs @@ -0,0 +1,6 @@ + +export default { + devOptions: { + port: 3001 + } +}
\ No newline at end of file diff --git a/packages/astro/test/fixtures/react-component/src/components/Goodbye.vue b/packages/astro/test/fixtures/react-component/src/components/Goodbye.vue new file mode 100644 index 000000000..430dfdb71 --- /dev/null +++ b/packages/astro/test/fixtures/react-component/src/components/Goodbye.vue @@ -0,0 +1,11 @@ +<template> + <h2 id="vue-h2">Hasta la vista, {{ name }}</h2> +</template> + +<script> +export default { + props: { + name: String, + }, +}; +</script> diff --git a/packages/astro/test/fixtures/react-component/src/components/Hello.jsx b/packages/astro/test/fixtures/react-component/src/components/Hello.jsx new file mode 100644 index 000000000..4b6c416a9 --- /dev/null +++ b/packages/astro/test/fixtures/react-component/src/components/Hello.jsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function ({ name }) { + return <h2 id="react-h2">Hello {name}!</h2>; +} diff --git a/packages/astro/test/fixtures/react-component/src/pages/index.astro b/packages/astro/test/fixtures/react-component/src/pages/index.astro new file mode 100644 index 000000000..74475f34e --- /dev/null +++ b/packages/astro/test/fixtures/react-component/src/pages/index.astro @@ -0,0 +1,14 @@ +--- +import Hello from '../components/Hello.jsx'; +import Later from '../components/Goodbye.vue'; // use different specifier +--- + +<html> + <head> + <!-- Head Stuff --> + </head> + <body> + <Hello name="world" /> + <Later name="baby" /> + </body> +</html> diff --git a/packages/astro/test/helpers.js b/packages/astro/test/helpers.js new file mode 100644 index 000000000..625f1a831 --- /dev/null +++ b/packages/astro/test/helpers.js @@ -0,0 +1,73 @@ +import { fileURLToPath } from 'url'; +import { build as astroBuild } from '../dist/build.js'; +import { readFile } from 'fs/promises'; +import { createRuntime } from '../dist/runtime.js'; +import { loadConfig } from '../dist/config.js'; +import * as assert from 'uvu/assert'; +import execa from 'execa'; + +/** setup fixtures for tests */ +export function setup(Suite, fixturePath) { + let runtime, setupError; + + Suite.before(async (context) => { + const astroConfig = await loadConfig(fileURLToPath(new URL(fixturePath, import.meta.url))); + + const logging = { + level: 'error', + dest: process.stderr, + }; + + try { + runtime = await createRuntime(astroConfig, { logging }); + } catch (err) { + console.error(err); + setupError = err; + } + + context.runtime = runtime; + }); + + Suite.after(async () => { + (await runtime) && runtime.shutdown(); + }); + + Suite('No errors creating a runtime', () => { + assert.equal(setupError, undefined); + }); +} + +export function setupBuild(Suite, fixturePath) { + let build, setupError; + + Suite.before(async (context) => { + const astroConfig = await loadConfig(fileURLToPath(new URL(fixturePath, import.meta.url))); + + const logging = { + level: 'error', + dest: process.stderr, + }; + + build = (...args) => astroBuild(astroConfig, ...args); + context.build = build; + context.readFile = async (path) => { + const resolved = fileURLToPath(new URL(`${fixturePath}/${astroConfig.dist}${path}`, import.meta.url)); + return readFile(resolved).then((r) => r.toString('utf-8')); + }; + }); + + Suite.after(async () => { + // Shutdown i guess. + }); + + Suite('No errors creating a runtime', () => { + assert.equal(setupError, undefined); + }); +} + +const cliURL = new URL('../astro.mjs', import.meta.url); +export function runDevServer(root, additionalArgs = []) { + const args = [cliURL.pathname, 'dev', '--project-root', root.pathname].concat(additionalArgs); + const proc = execa('node', args); + return proc; +} diff --git a/packages/astro/test/react-component.test.js b/packages/astro/test/react-component.test.js new file mode 100644 index 000000000..cdb4e2db2 --- /dev/null +++ b/packages/astro/test/react-component.test.js @@ -0,0 +1,54 @@ +import { fileURLToPath } from 'url'; +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { createRuntime } from '../dist/runtime.js'; +import { loadConfig } from '../dist/config.js'; +import { doc } from './test-utils.js'; + +const React = suite('React Components'); + +let runtime, setupError; + +React.before(async () => { + const astroConfig = await loadConfig(fileURLToPath(new URL('./fixtures/react-component', import.meta.url))); + + const logging = { + level: 'error', + dest: process.stderr, + }; + + try { + runtime = await createRuntime(astroConfig, { logging }); + } catch (err) { + console.error(err); + setupError = err; + } +}); + +React.after(async () => { + (await runtime) && runtime.shutdown(); +}); + +React('No error creating the runtime', () => { + assert.equal(setupError, undefined); +}); + +React('Can load React', async () => { + const result = await runtime.load('/'); + + assert.equal(result.statusCode, 200); + + const $ = doc(result.contents); + assert.equal($('#react-h2').text(), 'Hello world!'); +}); + +React('Can load Vue', async () => { + const result = await runtime.load('/'); + + assert.equal(result.statusCode, 200); + + const $ = doc(result.contents); + assert.equal($('#vue-h2').text(), 'Hasta la vista, baby'); +}); + +React.run(); diff --git a/packages/astro/test/snowpack-integration.test.js b/packages/astro/test/snowpack-integration.test.js new file mode 100644 index 000000000..86d73ea95 --- /dev/null +++ b/packages/astro/test/snowpack-integration.test.js @@ -0,0 +1,91 @@ +import { fileURLToPath } from 'url'; +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { createRuntime } from '../dist/runtime.js'; +import { loadConfig } from '../dist/config.js'; +import { promises as fsPromises } from 'fs'; +import { relative as pathRelative } from 'path'; + +const { readdir, stat } = fsPromises; + +const SnowpackDev = suite('snowpack.dev'); + +let runtime, cwd, setupError; + +SnowpackDev.before(async () => { + // Bug: Snowpack config is still loaded relative to the current working directory. + cwd = process.cwd(); + process.chdir(fileURLToPath(new URL('../examples/snowpack/', import.meta.url))); + + const astroConfig = await loadConfig(fileURLToPath(new URL('../examples/snowpack', import.meta.url))); + + const logging = { + level: 'error', + dest: process.stderr, + }; + + try { + runtime = await createRuntime(astroConfig, { logging }); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + setupError = err; + } +}); + +SnowpackDev.after(async () => { + process.chdir(cwd); + (await runtime) && runtime.shutdown(); +}); +/** create an iterator for all page files */ +async function* allPageFiles(root) { + for (const filename of await readdir(root)) { + const fullpath = new URL(filename, root); + const info = await stat(fullpath); + + if (info.isDirectory()) { + yield* allPageFiles(new URL(fullpath + '/')); + } else { + yield fullpath; + } + } +} +/** create an iterator for all pages and yield the relative paths */ +async function* allPages(root) { + for await (let fileURL of allPageFiles(root)) { + let bare = fileURLToPath(fileURL) + .replace(/\.(astro|md)$/, '') + .replace(/index$/, ''); + + yield '/' + pathRelative(fileURLToPath(root), bare); + } +} + +SnowpackDev('No error creating the runtime', () => { + assert.equal(setupError, undefined); +}); + +SnowpackDev('Can load every page', async () => { + const failed = []; + + const pageRoot = new URL('../examples/snowpack/src/pages/', import.meta.url); + for await (let pathname of allPages(pageRoot)) { + if (pathname.includes('proof-of-concept-dynamic')) { + continue; + } + const result = await runtime.load(pathname); + if (result.statusCode === 500) { + failed.push({ ...result, pathname }); + continue; + } + assert.equal(result.statusCode, 200, `Loading ${pathname}`); + } + + if (failed.length > 0) { + // eslint-disable-next-line no-console + console.error(failed); + } + assert.equal(failed.length, 0, 'Failed pages'); +}); + +SnowpackDev.run(); diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js new file mode 100644 index 000000000..1afab6a20 --- /dev/null +++ b/packages/astro/test/test-utils.js @@ -0,0 +1,7 @@ +import cheerio from 'cheerio'; + +/** load html */ +export function doc(html) { + return cheerio.load(html); +} + diff --git a/packages/astro/tsconfig.json b/packages/astro/tsconfig.json new file mode 100644 index 000000000..2174981fa --- /dev/null +++ b/packages/astro/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "outDir": "./dist" + } +} |