From b5478924117fad3a477c13f24b307af1822be8f0 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 21 Jun 2021 12:28:30 -0400 Subject: [PATCH] Allow the head element to be optional (#447) * First take * Allow omitting head element This makes it possible to omit the head element but still inject the style and HMR script into the right place. * Add changeset * More progress on this * Only render if it's a page * Include fragments in compiled jsx * Adds a changeset --- .changeset/red-humans-carry.md | 5 + .changeset/serious-peas-jog.md | 5 + packages/astro/src/compiler/codegen/index.ts | 9 +- packages/astro/src/compiler/index.ts | 23 +++- packages/astro/src/compiler/transform/head.ts | 119 +++++++++++++++--- .../compiler/transform/util/end-of-head.ts | 69 ++++++++++ packages/astro/src/runtime.ts | 12 +- .../fixtures/no-head-el/snowpack.config.json | 3 + .../no-head-el/src/components/Child.astro | 10 ++ .../no-head-el/src/components/Something.jsx | 5 + .../fixtures/no-head-el/src/pages/index.astro | 14 +++ packages/astro/test/helpers.js | 11 ++ packages/astro/test/no-head-el.test.js | 29 +++++ yarn.lock | 5 + 14 files changed, 288 insertions(+), 31 deletions(-) create mode 100644 .changeset/red-humans-carry.md create mode 100644 .changeset/serious-peas-jog.md create mode 100644 packages/astro/src/compiler/transform/util/end-of-head.ts create mode 100644 packages/astro/test/fixtures/no-head-el/snowpack.config.json create mode 100644 packages/astro/test/fixtures/no-head-el/src/components/Child.astro create mode 100644 packages/astro/test/fixtures/no-head-el/src/components/Something.jsx create mode 100644 packages/astro/test/fixtures/no-head-el/src/pages/index.astro create mode 100644 packages/astro/test/no-head-el.test.js diff --git a/.changeset/red-humans-carry.md b/.changeset/red-humans-carry.md new file mode 100644 index 000000000..eaec0d295 --- /dev/null +++ b/.changeset/red-humans-carry.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Makes providing a head element on pages optional diff --git a/.changeset/serious-peas-jog.md b/.changeset/serious-peas-jog.md new file mode 100644 index 000000000..d8b112e59 --- /dev/null +++ b/.changeset/serious-peas-jog.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Allows astro documents to omit the head element diff --git a/packages/astro/src/compiler/codegen/index.ts b/packages/astro/src/compiler/codegen/index.ts index 188029a2d..145c20576 100644 --- a/packages/astro/src/compiler/codegen/index.ts +++ b/packages/astro/src/compiler/codegen/index.ts @@ -524,8 +524,10 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile return; case 'Comment': return; - case 'Fragment': + case 'Fragment': { + buffers[curr] += `h(Fragment, null,`; break; + } case 'SlotTemplate': { buffers[curr] += `h(Fragment, null, children`; paren++; @@ -659,10 +661,13 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile }, async leave(node, parent, prop, index) { switch (node.type) { + case 'Fragment': { + buffers[curr] += `)`; + break; + } case 'Text': case 'Attribute': case 'Comment': - case 'Fragment': case 'Expression': case 'MustacheTag': case 'CodeSpan': diff --git a/packages/astro/src/compiler/index.ts b/packages/astro/src/compiler/index.ts index c74dd05f2..0ba3d1898 100644 --- a/packages/astro/src/compiler/index.ts +++ b/packages/astro/src/compiler/index.ts @@ -99,9 +99,15 @@ async function transformFromSource( } /** Return internal code that gets processed in Snowpack */ +interface CompileComponentOptions { + compileOptions: CompileOptions; + filename: string; + projectRoot: string, + isPage?: boolean; +} export async function compileComponent( source: string, - { compileOptions, filename, projectRoot }: { compileOptions: CompileOptions; filename: string; projectRoot: string } + { compileOptions, filename, projectRoot, isPage }: CompileComponentOptions ): Promise { const result = await transformFromSource(source, { compileOptions, filename, projectRoot }); const site = compileOptions.astroConfig.buildOptions.site || `http://localhost:${compileOptions.astroConfig.devOptions.port}`; @@ -115,11 +121,13 @@ ${result.imports.join('\n')} // \`__render()\`: Render the contents of the Astro module. import { h, Fragment } from 'astro/dist/internal/h.js'; -const __astroRequestSymbol = Symbol('astro.request'); +const __astroInternal = Symbol('astro.internal'); async function __render(props, ...children) { const Astro = { - request: props[__astroRequestSymbol] || {}, + css: props[__astroInternal]?.css || [], + request: props[__astroInternal]?.request || {}, site: new URL('/', ${JSON.stringify(site)}), + isPage: props[__astroInternal]?.isPage || false }; ${result.script} @@ -131,7 +139,7 @@ ${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}) { +export async function __renderPage({request, children, props, css}) { const currentChild = { isAstroComponent: true, layout: typeof __layout === 'undefined' ? undefined : __layout, @@ -139,7 +147,12 @@ export async function __renderPage({request, children, props}) { __render, }; - props[__astroRequestSymbol] = request; + props[__astroInternal] = { + request, + css, + isPage: true + }; + const childBodyResult = await currentChild.__render(props, children); // find layout, if one was given. diff --git a/packages/astro/src/compiler/transform/head.ts b/packages/astro/src/compiler/transform/head.ts index fe3f3ed88..935e0fd1e 100644 --- a/packages/astro/src/compiler/transform/head.ts +++ b/packages/astro/src/compiler/transform/head.ts @@ -1,18 +1,27 @@ import type { Transformer, TransformOptions } from '../../@types/transformer'; import type { TemplateNode } from '@astrojs/parser'; +import { EndOfHead } from './util/end-of-head.js'; /** If there are hydrated components, inject styles for [data-astro-root] and [data-astro-children] */ export default function (opts: TransformOptions): Transformer { - let head: TemplateNode; let hasComponents = false; let isHmrEnabled = typeof opts.compileOptions.hmrPort !== 'undefined' && opts.compileOptions.mode === 'development'; + const eoh = new EndOfHead(); return { visitors: { html: { + Fragment: { + enter(node) { + eoh.enter(node); + }, + leave(node) { + eoh.leave(node); + } + }, InlineComponent: { enter(node) { - const [name, kind] = node.name.split(':'); + const [_name, kind] = node.name.split(':'); if (kind && !hasComponents) { hasComponents = true; } @@ -20,22 +29,86 @@ export default function (opts: TransformOptions): Transformer { }, Element: { enter(node) { - switch (node.name) { - case 'head': { - head = node; - return; - } - default: - return; - } + eoh.enter(node); }, + leave(node) { + eoh.leave(node); + } }, }, }, async finalize() { - if (!head) return; - const children = []; + + /** + * Injects an expression that adds link tags for provided css. + * Turns into: + * ``` + * { Astro.css.map(css => ( + * + * ))} + * ``` + */ + + children.push({ + start: 0, + end: 0, + type: 'Fragment', + children: [ + { + start: 0, + end: 0, + type: 'Expression', + codeChunks: [ + 'Astro.css.map(css => (', + '))' + ], + children: [ + { + type: 'Element', + name: 'link', + attributes: [ + { + name: 'rel', + type: 'Attribute', + value: [ + { + type: 'Text', + raw: 'stylesheet', + data: 'stylesheet' + } + ] + }, + { + name: 'href', + type: 'Attribute', + value: [ + { + start: 0, + end: 0, + type: 'MustacheTag', + expression: { + start: 0, + end: 0, + type: 'Expression', + codeChunks: [ + 'css' + ], + children: [] + } + } + ] + } + ], + start: 0, + end: 0, + children: [] + } + ] + } + ] + }); + if (hasComponents) { children.push({ type: 'Element', @@ -79,8 +152,26 @@ export default function (opts: TransformOptions): Transformer { } ); } - head.children = head.children ?? []; - head.children.push(...children); + + const conditionalNode = { + start: 0, + end: 0, + type: 'Expression', + codeChunks: [ + 'Astro.isPage ? (', + ') : null' + ], + children: [ + { + start: 0, + end: 0, + type: 'Fragment', + children + } + ] + } + + eoh.append(conditionalNode); }, }; } diff --git a/packages/astro/src/compiler/transform/util/end-of-head.ts b/packages/astro/src/compiler/transform/util/end-of-head.ts new file mode 100644 index 000000000..d1af82fa5 --- /dev/null +++ b/packages/astro/src/compiler/transform/util/end-of-head.ts @@ -0,0 +1,69 @@ +import type { TemplateNode } from '@astrojs/parser'; + +const validHeadElements = new Set([ + '!doctype', + 'title', + 'meta', + 'link', + 'style', + 'script', + 'noscript', + 'base' +]); + +export class EndOfHead { + private head: TemplateNode | null = null; + private firstNonHead: TemplateNode | null = null; + private parent: TemplateNode | null = null; + private stack: TemplateNode[] = []; + + public append: (...node: TemplateNode[]) => void = () => void 0; + + get found(): boolean { + return !!(this.head || this.firstNonHead); + } + + enter(node: TemplateNode) { + if(this.found) { + return; + } + + this.stack.push(node); + + // Fragment has no name + if(!node.name) { + return; + } + + const name = node.name.toLowerCase(); + + if(name === 'head') { + this.head = node; + this.parent = this.stack[this.stack.length - 2]; + this.append = this.appendToHead; + return; + } + + if(!validHeadElements.has(name)) { + this.firstNonHead = node; + this.parent = this.stack[this.stack.length - 2]; + this.append = this.prependToFirstNonHead; + return; + } + } + + leave(_node: TemplateNode) { + this.stack.pop(); + } + + private appendToHead(...nodes: TemplateNode[]) { + const head = this.head!; + head.children = head.children ?? []; + head.children.push(...nodes); + } + + private prependToFirstNonHead(...nodes: TemplateNode[]) { + let idx: number = this.parent?.children!.indexOf(this.firstNonHead!) || 0; + this.parent?.children?.splice(idx, 0, ...nodes); + } +} \ No newline at end of file diff --git a/packages/astro/src/runtime.ts b/packages/astro/src/runtime.ts index a8992cae0..bd4c4b525 100644 --- a/packages/astro/src/runtime.ts +++ b/packages/astro/src/runtime.ts @@ -228,17 +228,9 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro }, children: [], props: { collection }, + css: Array.isArray(mod.css) ? mod.css : typeof mod.css === 'string' ? [mod.css] : [] })) 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`, '') : ``; - if (html.indexOf('') !== -1) { - html = html.replace('', `${styleTags}`); - } else { - html = styleTags + html; - } - return { statusCode: 200, contentType: 'text/html; charset=utf-8', @@ -304,7 +296,7 @@ export interface AstroRuntime { shutdown: () => Promise; } -interface RuntimeOptions { +export interface RuntimeOptions { mode: RuntimeMode; logging: LogOptions; } diff --git a/packages/astro/test/fixtures/no-head-el/snowpack.config.json b/packages/astro/test/fixtures/no-head-el/snowpack.config.json new file mode 100644 index 000000000..8f034781d --- /dev/null +++ b/packages/astro/test/fixtures/no-head-el/snowpack.config.json @@ -0,0 +1,3 @@ +{ + "workspaceRoot": "../../../../../" +} diff --git a/packages/astro/test/fixtures/no-head-el/src/components/Child.astro b/packages/astro/test/fixtures/no-head-el/src/components/Child.astro new file mode 100644 index 000000000..41cee95c1 --- /dev/null +++ b/packages/astro/test/fixtures/no-head-el/src/components/Child.astro @@ -0,0 +1,10 @@ +--- + import Something from './Something.jsx'; +--- + +
Something here
+ \ No newline at end of file diff --git a/packages/astro/test/fixtures/no-head-el/src/components/Something.jsx b/packages/astro/test/fixtures/no-head-el/src/components/Something.jsx new file mode 100644 index 000000000..45c9032e7 --- /dev/null +++ b/packages/astro/test/fixtures/no-head-el/src/components/Something.jsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function() { + return
Test
; +} \ No newline at end of file diff --git a/packages/astro/test/fixtures/no-head-el/src/pages/index.astro b/packages/astro/test/fixtures/no-head-el/src/pages/index.astro new file mode 100644 index 000000000..10d8e2e24 --- /dev/null +++ b/packages/astro/test/fixtures/no-head-el/src/pages/index.astro @@ -0,0 +1,14 @@ +--- +import Something from '../components/Something.jsx'; +import Child from '../components/Child.astro'; +--- +My page + +

Title of this Blog

+ + + \ No newline at end of file diff --git a/packages/astro/test/helpers.js b/packages/astro/test/helpers.js index 434fa7f62..ea7a470f1 100644 --- a/packages/astro/test/helpers.js +++ b/packages/astro/test/helpers.js @@ -11,6 +11,17 @@ const MAX_TEST_TIME = 10000; // max time an individual test may take const MAX_SHUTDOWN_TIME = 3000; // max time shutdown() may take /** setup fixtures for tests */ + +/** + * @typedef {Object} SetupOptions + * @prop {import('../src/runtime').RuntimeOptions} runtimeOptions + */ + +/** + * @param {{}} Suite + * @param {string} fixturePath + * @param {SetupOptions} setupOptions + */ export function setup(Suite, fixturePath, { runtimeOptions = {} } = {}) { let runtime; const timers = {}; diff --git a/packages/astro/test/no-head-el.test.js b/packages/astro/test/no-head-el.test.js new file mode 100644 index 000000000..76c756241 --- /dev/null +++ b/packages/astro/test/no-head-el.test.js @@ -0,0 +1,29 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup } from './helpers.js'; + +const NoHeadEl = suite('Documents without a head'); + +setup(NoHeadEl, './fixtures/no-head-el', { + runtimeOptions: { + mode: 'development' + } +}); + +NoHeadEl('Places style and scripts before the first non-head element', async ({ runtime }) => { + const result = await runtime.load('/'); + if (result.error) throw new Error(result.error); + + const html = result.contents; + const $ = doc(html); + assert.equal($('title').next().is('link'), true, 'Link to css placed after the title'); + assert.equal($('title').next().next().is('link'), true, 'Link for a child component'); + assert.equal($('title').next().next().next().is('style'), true, 'astro-root style placed after the link'); + assert.equal($('title').next().next().next().next().is('script'), true, 'HMR script after the style'); + + assert.equal($('script[src="/_snowpack/hmr-client.js"]').length, 1, 'Only the hmr client for the page'); +}); + + +NoHeadEl.run(); diff --git a/yarn.lock b/yarn.lock index 5be83d7fa..8e9dcb2e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1483,6 +1483,11 @@ resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== +"@types/prettier@^2.2.1": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.3.0.tgz#2e8332cc7363f887d32ec5496b207d26ba8052bb" + integrity sha512-hkc1DATxFLQo4VxPDpMH1gCkPpBbpOoJ/4nhuXw4n63/0R6bCpQECj4+K226UJ4JO/eJQz+1mC2I7JsWanAdQw== + "@types/prompts@^2.0.12": version "2.0.12" resolved "https://registry.yarnpkg.com/@types/prompts/-/prompts-2.0.12.tgz#5cc1557f88e4d69dad93230fff97a583006f858b"