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
This commit is contained in:
Matthew Phillips 2021-06-21 12:28:30 -04:00 committed by GitHub
parent cab79548db
commit b547892411
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 288 additions and 31 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Makes providing a head element on pages optional

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Allows astro documents to omit the head element

View file

@ -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':

View file

@ -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<CompileResult> {
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.

View file

@ -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 => (
* <link rel="stylesheet" href={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);
},
};
}

View file

@ -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);
}
}

View file

@ -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<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',
@ -304,7 +296,7 @@ export interface AstroRuntime {
shutdown: () => Promise<void>;
}
interface RuntimeOptions {
export interface RuntimeOptions {
mode: RuntimeMode;
logging: LogOptions;
}

View file

@ -0,0 +1,3 @@
{
"workspaceRoot": "../../../../../"
}

View file

@ -0,0 +1,10 @@
---
import Something from './Something.jsx';
---
<style lang="scss">
div {
color: purple;
}
</style>
<div>Something here</div>
<Something:idle />

View file

@ -0,0 +1,5 @@
import React from 'react';
export default function() {
return <div>Test</div>;
}

View file

@ -0,0 +1,14 @@
---
import Something from '../components/Something.jsx';
import Child from '../components/Child.astro';
---
<title>My page</title>
<style>
.h1 {
color: blue;
}
</style>
<h1>Title of this Blog</h1>
<Something:load />
<Child />

View file

@ -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 = {};

View file

@ -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();

View file

@ -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"