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:
parent
cab79548db
commit
b547892411
14 changed files with 288 additions and 31 deletions
5
.changeset/red-humans-carry.md
Normal file
5
.changeset/red-humans-carry.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Makes providing a head element on pages optional
|
5
.changeset/serious-peas-jog.md
Normal file
5
.changeset/serious-peas-jog.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Allows astro documents to omit the head element
|
|
@ -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':
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
69
packages/astro/src/compiler/transform/util/end-of-head.ts
Normal file
69
packages/astro/src/compiler/transform/util/end-of-head.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
3
packages/astro/test/fixtures/no-head-el/snowpack.config.json
vendored
Normal file
3
packages/astro/test/fixtures/no-head-el/snowpack.config.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"workspaceRoot": "../../../../../"
|
||||
}
|
10
packages/astro/test/fixtures/no-head-el/src/components/Child.astro
vendored
Normal file
10
packages/astro/test/fixtures/no-head-el/src/components/Child.astro
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
import Something from './Something.jsx';
|
||||
---
|
||||
<style lang="scss">
|
||||
div {
|
||||
color: purple;
|
||||
}
|
||||
</style>
|
||||
<div>Something here</div>
|
||||
<Something:idle />
|
5
packages/astro/test/fixtures/no-head-el/src/components/Something.jsx
vendored
Normal file
5
packages/astro/test/fixtures/no-head-el/src/components/Something.jsx
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function() {
|
||||
return <div>Test</div>;
|
||||
}
|
14
packages/astro/test/fixtures/no-head-el/src/pages/index.astro
vendored
Normal file
14
packages/astro/test/fixtures/no-head-el/src/pages/index.astro
vendored
Normal 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 />
|
|
@ -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 = {};
|
||||
|
|
29
packages/astro/test/no-head-el.test.js
Normal file
29
packages/astro/test/no-head-el.test.js
Normal 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();
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue