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;
|
return;
|
||||||
case 'Comment':
|
case 'Comment':
|
||||||
return;
|
return;
|
||||||
case 'Fragment':
|
case 'Fragment': {
|
||||||
|
buffers[curr] += `h(Fragment, null,`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'SlotTemplate': {
|
case 'SlotTemplate': {
|
||||||
buffers[curr] += `h(Fragment, null, children`;
|
buffers[curr] += `h(Fragment, null, children`;
|
||||||
paren++;
|
paren++;
|
||||||
|
@ -659,10 +661,13 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
|
||||||
},
|
},
|
||||||
async leave(node, parent, prop, index) {
|
async leave(node, parent, prop, index) {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
|
case 'Fragment': {
|
||||||
|
buffers[curr] += `)`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'Text':
|
case 'Text':
|
||||||
case 'Attribute':
|
case 'Attribute':
|
||||||
case 'Comment':
|
case 'Comment':
|
||||||
case 'Fragment':
|
|
||||||
case 'Expression':
|
case 'Expression':
|
||||||
case 'MustacheTag':
|
case 'MustacheTag':
|
||||||
case 'CodeSpan':
|
case 'CodeSpan':
|
||||||
|
|
|
@ -99,9 +99,15 @@ async function transformFromSource(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return internal code that gets processed in Snowpack */
|
/** Return internal code that gets processed in Snowpack */
|
||||||
|
interface CompileComponentOptions {
|
||||||
|
compileOptions: CompileOptions;
|
||||||
|
filename: string;
|
||||||
|
projectRoot: string,
|
||||||
|
isPage?: boolean;
|
||||||
|
}
|
||||||
export async function compileComponent(
|
export async function compileComponent(
|
||||||
source: string,
|
source: string,
|
||||||
{ compileOptions, filename, projectRoot }: { compileOptions: CompileOptions; filename: string; projectRoot: string }
|
{ compileOptions, filename, projectRoot, isPage }: CompileComponentOptions
|
||||||
): Promise<CompileResult> {
|
): Promise<CompileResult> {
|
||||||
const result = await transformFromSource(source, { compileOptions, filename, projectRoot });
|
const result = await transformFromSource(source, { compileOptions, filename, projectRoot });
|
||||||
const site = compileOptions.astroConfig.buildOptions.site || `http://localhost:${compileOptions.astroConfig.devOptions.port}`;
|
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.
|
// \`__render()\`: Render the contents of the Astro module.
|
||||||
import { h, Fragment } from 'astro/dist/internal/h.js';
|
import { h, Fragment } from 'astro/dist/internal/h.js';
|
||||||
const __astroRequestSymbol = Symbol('astro.request');
|
const __astroInternal = Symbol('astro.internal');
|
||||||
async function __render(props, ...children) {
|
async function __render(props, ...children) {
|
||||||
const Astro = {
|
const Astro = {
|
||||||
request: props[__astroRequestSymbol] || {},
|
css: props[__astroInternal]?.css || [],
|
||||||
|
request: props[__astroInternal]?.request || {},
|
||||||
site: new URL('/', ${JSON.stringify(site)}),
|
site: new URL('/', ${JSON.stringify(site)}),
|
||||||
|
isPage: props[__astroInternal]?.isPage || false
|
||||||
};
|
};
|
||||||
|
|
||||||
${result.script}
|
${result.script}
|
||||||
|
@ -131,7 +139,7 @@ ${result.createCollection || ''}
|
||||||
|
|
||||||
// \`__renderPage()\`: Render the contents of the Astro module as a page. This is a special flow,
|
// \`__renderPage()\`: Render the contents of the Astro module as a page. This is a special flow,
|
||||||
// triggered by loading a component directly by URL.
|
// 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 = {
|
const currentChild = {
|
||||||
isAstroComponent: true,
|
isAstroComponent: true,
|
||||||
layout: typeof __layout === 'undefined' ? undefined : __layout,
|
layout: typeof __layout === 'undefined' ? undefined : __layout,
|
||||||
|
@ -139,7 +147,12 @@ export async function __renderPage({request, children, props}) {
|
||||||
__render,
|
__render,
|
||||||
};
|
};
|
||||||
|
|
||||||
props[__astroRequestSymbol] = request;
|
props[__astroInternal] = {
|
||||||
|
request,
|
||||||
|
css,
|
||||||
|
isPage: true
|
||||||
|
};
|
||||||
|
|
||||||
const childBodyResult = await currentChild.__render(props, children);
|
const childBodyResult = await currentChild.__render(props, children);
|
||||||
|
|
||||||
// find layout, if one was given.
|
// find layout, if one was given.
|
||||||
|
|
|
@ -1,18 +1,27 @@
|
||||||
import type { Transformer, TransformOptions } from '../../@types/transformer';
|
import type { Transformer, TransformOptions } from '../../@types/transformer';
|
||||||
import type { TemplateNode } from '@astrojs/parser';
|
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] */
|
/** If there are hydrated components, inject styles for [data-astro-root] and [data-astro-children] */
|
||||||
export default function (opts: TransformOptions): Transformer {
|
export default function (opts: TransformOptions): Transformer {
|
||||||
let head: TemplateNode;
|
|
||||||
let hasComponents = false;
|
let hasComponents = false;
|
||||||
let isHmrEnabled = typeof opts.compileOptions.hmrPort !== 'undefined' && opts.compileOptions.mode === 'development';
|
let isHmrEnabled = typeof opts.compileOptions.hmrPort !== 'undefined' && opts.compileOptions.mode === 'development';
|
||||||
|
const eoh = new EndOfHead();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visitors: {
|
visitors: {
|
||||||
html: {
|
html: {
|
||||||
|
Fragment: {
|
||||||
|
enter(node) {
|
||||||
|
eoh.enter(node);
|
||||||
|
},
|
||||||
|
leave(node) {
|
||||||
|
eoh.leave(node);
|
||||||
|
}
|
||||||
|
},
|
||||||
InlineComponent: {
|
InlineComponent: {
|
||||||
enter(node) {
|
enter(node) {
|
||||||
const [name, kind] = node.name.split(':');
|
const [_name, kind] = node.name.split(':');
|
||||||
if (kind && !hasComponents) {
|
if (kind && !hasComponents) {
|
||||||
hasComponents = true;
|
hasComponents = true;
|
||||||
}
|
}
|
||||||
|
@ -20,22 +29,86 @@ export default function (opts: TransformOptions): Transformer {
|
||||||
},
|
},
|
||||||
Element: {
|
Element: {
|
||||||
enter(node) {
|
enter(node) {
|
||||||
switch (node.name) {
|
eoh.enter(node);
|
||||||
case 'head': {
|
|
||||||
head = node;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
leave(node) {
|
||||||
|
eoh.leave(node);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async finalize() {
|
async finalize() {
|
||||||
if (!head) return;
|
|
||||||
|
|
||||||
const children = [];
|
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) {
|
if (hasComponents) {
|
||||||
children.push({
|
children.push({
|
||||||
type: 'Element',
|
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: [],
|
children: [],
|
||||||
props: { collection },
|
props: { collection },
|
||||||
|
css: Array.isArray(mod.css) ? mod.css : typeof mod.css === 'string' ? [mod.css] : []
|
||||||
})) as string;
|
})) 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 {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
contentType: 'text/html; charset=utf-8',
|
contentType: 'text/html; charset=utf-8',
|
||||||
|
@ -304,7 +296,7 @@ export interface AstroRuntime {
|
||||||
shutdown: () => Promise<void>;
|
shutdown: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeOptions {
|
export interface RuntimeOptions {
|
||||||
mode: RuntimeMode;
|
mode: RuntimeMode;
|
||||||
logging: LogOptions;
|
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
|
const MAX_SHUTDOWN_TIME = 3000; // max time shutdown() may take
|
||||||
|
|
||||||
/** setup fixtures for tests */
|
/** 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 = {} } = {}) {
|
export function setup(Suite, fixturePath, { runtimeOptions = {} } = {}) {
|
||||||
let runtime;
|
let runtime;
|
||||||
const timers = {};
|
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"
|
resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109"
|
||||||
integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==
|
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":
|
"@types/prompts@^2.0.12":
|
||||||
version "2.0.12"
|
version "2.0.12"
|
||||||
resolved "https://registry.yarnpkg.com/@types/prompts/-/prompts-2.0.12.tgz#5cc1557f88e4d69dad93230fff97a583006f858b"
|
resolved "https://registry.yarnpkg.com/@types/prompts/-/prompts-2.0.12.tgz#5cc1557f88e4d69dad93230fff97a583006f858b"
|
||||||
|
|
Loading…
Reference in a new issue