Add .html support (#3867)

* feat: add html package

* feat: support assets in HTML

* feat(html): upgrade html integration

* feat(html): add `@astrojs/html` integration

* feat(html): add html support to astro core

* test(html): update html tests with package.json files

* chore: add changeset

* fix: remove import cycle

* chore: fix types

* refactor: remove @astrojs/html, add to core

* chore: update types for `*.html`

* fix: move *.html to astro/env

Co-authored-by: Nate Moore <nate@astro.build>
This commit is contained in:
Nate Moore 2022-07-22 10:32:36 -05:00 committed by GitHub
parent 8b468ccccc
commit 7250e4e86d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 534 additions and 12 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Add support for `.html` components and pages

View file

@ -26,3 +26,8 @@ declare module '*.md' {
const load: MD['default'];
export default load;
}
declare module "*.html" {
const Component: { render(opts: { slots: Record<string, string> }): string };
export default Component;
}

View file

@ -125,6 +125,7 @@
"prismjs": "^1.28.0",
"prompts": "^2.4.2",
"recast": "^0.20.5",
"rehype": "^12.0.1",
"resolve": "^1.22.0",
"rollup": "^2.75.6",
"semver": "^7.3.7",
@ -136,6 +137,8 @@
"strip-ansi": "^7.0.1",
"supports-esm": "^1.0.0",
"tsconfig-resolver": "^3.0.1",
"unist-util-visit": "^4.1.0",
"vfile": "^5.3.2",
"vite": "3.0.2",
"yargs-parser": "^21.0.1",
"zod": "^3.17.3"

View file

@ -325,7 +325,7 @@ export async function validateConfig(
const result = {
...(await AstroConfigRelativeSchema.parseAsync(userConfig)),
_ctx: {
pageExtensions: ['.astro', '.md'],
pageExtensions: ['.astro', '.md', '.html'],
scripts: [],
renderers: [],
injectedRoutes: [],

View file

@ -11,6 +11,7 @@ import configAliasVitePlugin from '../vite-plugin-config-alias/index.js';
import envVitePlugin from '../vite-plugin-env/index.js';
import astroIntegrationsContainerPlugin from '../vite-plugin-integrations-container/index.js';
import jsxVitePlugin from '../vite-plugin-jsx/index.js';
import htmlVitePlugin from '../vite-plugin-html/index.js';
import markdownVitePlugin from '../vite-plugin-markdown/index.js';
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
import { createCustomViteLogger } from './errors.js';
@ -73,6 +74,7 @@ export async function createVite(
mode === 'dev' && astroViteServerPlugin({ config: astroConfig, logging }),
envVitePlugin({ config: astroConfig }),
markdownVitePlugin({ config: astroConfig }),
htmlVitePlugin(),
jsxVitePlugin({ config: astroConfig, logging }),
astroPostprocessVitePlugin({ config: astroConfig }),
astroIntegrationsContainerPlugin({ config: astroConfig }),

View file

@ -45,14 +45,7 @@ export default async function dev(config: AstroConfig, options: DevOptions): Pro
mode: 'development',
server: { host },
optimizeDeps: {
include: [
'astro/client/idle.js',
'astro/client/load.js',
'astro/client/visible.js',
'astro/client/media.js',
'astro/client/only.js',
...rendererClientEntries,
],
include: rendererClientEntries,
},
},
{ astroConfig: config, logging: options.logging, mode: 'dev' }

View file

@ -58,7 +58,8 @@ export function fixViteErrorMessage(_err: unknown, server: ViteDevServer, filePa
const content = fs.readFileSync(fileURLToPath(filePath)).toString();
const lns = content.split('\n');
const line = lns.findIndex((ln) => ln.includes(importName));
const column = lns[line].indexOf(importName);
if (line == -1) return err;
const column = lns[line]?.indexOf(importName);
if (!(err as any).id) {
(err as any).id = `${fileURLToPath(filePath)}:${line + 1}:${column + 1}`;
}

View file

@ -210,6 +210,21 @@ export async function renderComponent(
return markHTMLString(children);
}
if (Component && typeof Component === 'object' && (Component as any)['astro:html']) {
const children: Record<string, string> = {};
if (slots) {
await Promise.all(
Object.entries(slots).map(([key, value]) =>
renderSlot(result, value as string).then((output) => {
children[key] = output;
})
)
);
}
const html = (Component as any).render({ slots: children });
return markHTMLString(html);
}
if (Component && (Component as any).isAstroComponentFactory) {
async function* renderAstroComponentInline(): AsyncGenerator<string, void, undefined> {
let iterable = await renderToIterable(result, Component as any, _props, slots);
@ -265,6 +280,7 @@ Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`')
)
);
}
// Call the renderers `check` hook to see if any claim this component.
let renderer: SSRLoadedRenderer | undefined;
if (metadata.hydrate !== 'only') {

View file

@ -0,0 +1,14 @@
import { transform } from './transform/index.js';
export default function html() {
return {
name: 'astro:html',
options(options: any) {
options.plugins = options.plugins?.filter((p: any) => p.name !== 'vite:build-html');
},
async transform(source: string, id: string) {
if (!id.endsWith('.html')) return;
return await transform(source, id);
}
}
}

View file

@ -0,0 +1,27 @@
import type { Plugin } from 'unified';
import type { Root, RootContent } from 'hast';
import type MagicString from 'magic-string';
import { visit } from 'unist-util-visit';
import { replaceAttribute, needsEscape, escape } from './utils.js';
const rehypeEscape: Plugin<[{ s: MagicString }], Root> = ({ s }) => {
return (tree, file) => {
visit(tree, (node: Root | RootContent, index, parent) => {
if (node.type === 'text' || node.type === 'comment') {
if (needsEscape(node.value)) {
s.overwrite(node.position!.start.offset!, node.position!.end.offset!, escape(node.value));
}
} else if (node.type === 'element') {
for (const [key, value] of Object.entries(node.properties ?? {})) {
const newKey = needsEscape(key) ? escape(key) : key;
const newValue = needsEscape(value) ? escape(value) : value;
if (newKey === key && newValue === value) continue;
replaceAttribute(s, node, key, (value === '') ? newKey : `${newKey}="${newValue}"`);
}
}
});
};
};
export default rehypeEscape;

View file

@ -0,0 +1,32 @@
import MagicString from 'magic-string';
import { rehype } from 'rehype';
import { VFile } from 'vfile';
import escape from './escape.js';
import slots, { SLOT_PREFIX } from './slots.js';
export async function transform(code: string, id: string) {
const s = new MagicString(code, { filename: id });
const imports = new Map();
const parser = rehype()
.data('settings', { fragment: true })
.use(escape, { s })
.use(slots, { s });
const vfile = new VFile({ value: code, path: id })
await parser.process(vfile)
s.prepend(`export default {\n\t"astro:html": true,\n\trender({ slots: ${SLOT_PREFIX} }) {\n\t\treturn \``);
s.append('`\n\t}\n}');
if (imports.size > 0) {
let importText = ''
for (const [path, importName] of imports.entries()) {
importText += `import ${importName} from "${path}";\n`
}
s.prepend(importText);
}
return {
code: s.toString(),
map: s.generateMap()
}
}

View file

@ -0,0 +1,27 @@
import type { Plugin } from 'unified';
import type { Root, RootContent } from 'hast';
import { visit } from 'unist-util-visit';
import MagicString from 'magic-string';
import { escape } from './utils.js';
const rehypeSlots: Plugin<[{ s: MagicString }], Root> = ({ s }) => {
return (tree, file) => {
visit(tree, (node: Root | RootContent, index, parent) => {
if (node.type === 'element' && node.tagName === 'slot') {
if (typeof node.properties?.['is:inline'] !== 'undefined') return;
const name = node.properties?.['name'] ?? 'default';
const start = node.position?.start.offset ?? 0;
const end = node.position?.end.offset ?? 0;
const first = node.children.at(0) ?? node;
const last = node.children.at(-1) ?? node;
const text = file.value.slice(first.position?.start.offset ?? 0, last.position?.end.offset ?? 0).toString();
s.overwrite(start, end, `\${${SLOT_PREFIX}["${name}"] ?? \`${escape(text).trim()}\`}`)
}
});
}
}
export default rehypeSlots;
export const SLOT_PREFIX = `___SLOTS___`;

View file

@ -0,0 +1,27 @@
import type { Element } from 'hast';
import MagicString from 'magic-string';
const splitAttrsTokenizer = /([\$\{\}\@a-z0-9_\:\-]*)\s*?=\s*?(['"]?)(.*?)\2\s+/gim;
export function replaceAttribute(s: MagicString, node: Element, key: string, newValue: string) {
splitAttrsTokenizer.lastIndex = 0;
const text = s.original.slice(node.position?.start.offset ?? 0, node.position?.end.offset ?? 0).toString();
const offset = text.indexOf(key);
if (offset === -1) return;
const start = node.position!.start.offset! + offset;
const tokens = text.slice(offset).split(splitAttrsTokenizer);
const token = tokens[0].replace(/([^>])(\>[\s\S]*$)/gmi, '$1');
if (token.trim() === key) {
const end = start + key.length;
s.overwrite(start, end, newValue)
} else {
const end = start + `${key}=${tokens[2]}${tokens[3]}${tokens[2]}`.length;
s.overwrite(start, end, newValue)
}
}
export function needsEscape(value: any): value is string {
return typeof value === 'string' && (value.includes('`') || value.includes('${'));
}
export function escape(value: string) {
return value.replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
}

View file

@ -0,0 +1,8 @@
{
"name": "@test/html-component",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,3 @@
<h1>Hello component!</h1>
<div id="foo">bar</div>

View file

@ -0,0 +1,5 @@
---
import Test from '../components/Test.html';
---
<Test />

View file

@ -0,0 +1,8 @@
{
"name": "@test/html-escape",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,4 @@
<div>${foo}</div>
<span ${attr}></span>
<custom-element x-data="`${test}`"></custom-element>
<script>console.log(`hello ${"world"}!`)</script>

View file

@ -0,0 +1,5 @@
---
import Test from '../components/Test.html';
---
<Test />

View file

@ -0,0 +1,8 @@
{
"name": "@test/html-page",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -0,0 +1 @@
<h1>Hello page!</h1>

View file

@ -0,0 +1,8 @@
{
"name": "@test/html-slots",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -0,0 +1 @@
<div id="default"><slot></slot></div>

View file

@ -0,0 +1 @@
<div id="inline"><slot is:inline></slot></div>

View file

@ -0,0 +1,3 @@
<div id="a"><slot name="a"></slot></div>
<div id="b"><slot name="b"></slot></div>
<div id="c"><slot name="c"></slot></div>

View file

@ -0,0 +1,13 @@
---
import Default from '../components/Default.html';
import Named from '../components/Named.html';
import Inline from '../components/Inline.html';
---
<Default>Default</Default>
<Named>
<span slot="a">A</span>
<span slot="b">B</span>
<span slot="c">C</span>
</Named>
<Inline></Inline>

View file

@ -0,0 +1,57 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
describe('HTML Component', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/html-component/',
});
});
describe('build', () => {
before(async () => {
await fixture.build();
});
it('works', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
const h1 = $('h1');
const foo = $('#foo');
expect(h1.text()).to.equal('Hello component!');
expect(foo.text()).to.equal('bar');
});
});
describe('dev', () => {
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('works', async () => {
const res = await fixture.fetch('/');
expect(res.status).to.equal(200);
const html = await res.text();
const $ = cheerio.load(html);
const h1 = $('h1');
const foo = $('#foo');
expect(h1.text()).to.equal('Hello component!');
expect(foo.text()).to.equal('bar');
});
});
});

View file

@ -0,0 +1,69 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
describe('HTML Escape', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/html-escape/',
});
});
describe('build', () => {
before(async () => {
await fixture.build();
});
it('works', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
const div = $('div');
expect(div.text()).to.equal('${foo}');
const span = $('span');
expect(span.attr('${attr}')).to.equal("");
const ce = $('custom-element');
expect(ce.attr('x-data')).to.equal("`${test}`");
const script = $('script');
expect(script.text()).to.equal('console.log(`hello ${"world"}!`)');
});
});
describe('dev', () => {
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('works', async () => {
const res = await fixture.fetch('/');
expect(res.status).to.equal(200);
const html = await res.text();
const $ = cheerio.load(html);
const div = $('div');
expect(div.text()).to.equal('${foo}');
const span = $('span');
expect(span.attr('${attr}')).to.equal("");
const ce = $('custom-element');
expect(ce.attr('x-data')).to.equal("`${test}`");
const script = $('script');
expect(script.text()).to.equal('console.log(`hello ${"world"}!`)');
});
});
});

View file

@ -0,0 +1,53 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
describe('HTML Page', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/html-page/',
});
});
describe('build', () => {
before(async () => {
await fixture.build();
});
it('works', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html)
const h1 = $('h1');
expect(h1.text()).to.equal('Hello page!');
});
});
describe('dev', () => {
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('works', async () => {
const res = await fixture.fetch('/');
expect(res.status).to.equal(200);
const html = await res.text();
const $ = cheerio.load(html)
const h1 = $('h1');
expect(h1.text()).to.equal('Hello page!');
});
});
});

View file

@ -0,0 +1,75 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
describe('HTML Slots', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/html-slots/',
});
});
describe('build', () => {
before(async () => {
await fixture.build();
});
it('works', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
const slotDefault = $('#default');
expect(slotDefault.text()).to.equal('Default');
const a = $('#a');
expect(a.text().trim()).to.equal('A');
const b = $('#b');
expect(b.text().trim()).to.equal('B');
const c = $('#c');
expect(c.text().trim()).to.equal('C');
const inline = $('#inline');
expect(inline.html()).to.equal('<slot is:inline=""></slot>');
});
});
describe('dev', () => {
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('works', async () => {
const res = await fixture.fetch('/');
expect(res.status).to.equal(200);
const html = await res.text();
const $ = cheerio.load(html);
const slotDefault = $('#default');
expect(slotDefault.text()).to.equal('Default');
const a = $('#a');
expect(a.text().trim()).to.equal('A');
const b = $('#b');
expect(b.text().trim()).to.equal('B');
const c = $('#c');
expect(c.text().trim()).to.equal('C');
const inline = $('#inline');
expect(inline.html()).to.equal('<slot is:inline=""></slot>');
});
});
});

View file

@ -9,4 +9,4 @@ interface PolyfillOptions {
override?: Record<string, {
(...args: any[]): any;
}>;
}
}

View file

@ -506,6 +506,7 @@ importers:
prismjs: ^1.28.0
prompts: ^2.4.2
recast: ^0.20.5
rehype: ^12.0.1
resolve: ^1.22.0
rollup: ^2.75.6
sass: ^1.52.2
@ -519,6 +520,8 @@ importers:
strip-ansi: ^7.0.1
supports-esm: ^1.0.0
tsconfig-resolver: ^3.0.1
unist-util-visit: ^4.1.0
vfile: ^5.3.2
vite: 3.0.2
yargs-parser: ^21.0.1
zod: ^3.17.3
@ -566,6 +569,7 @@ importers:
prismjs: 1.28.0
prompts: 2.4.2
recast: 0.20.5
rehype: 12.0.1
resolve: 1.22.1
rollup: 2.76.0
semver: 7.3.7
@ -577,6 +581,8 @@ importers:
strip-ansi: 7.0.1
supports-esm: 1.0.0
tsconfig-resolver: 3.0.1
unist-util-visit: 4.1.0
vfile: 5.3.4
vite: 3.0.2_sass@1.53.0
yargs-parser: 21.0.1
zod: 3.17.3
@ -1526,6 +1532,30 @@ importers:
dependencies:
astro: link:../../..
packages/astro/test/fixtures/html-component:
specifiers:
astro: workspace:*
dependencies:
astro: link:../../..
packages/astro/test/fixtures/html-escape:
specifiers:
astro: workspace:*
dependencies:
astro: link:../../..
packages/astro/test/fixtures/html-page:
specifiers:
astro: workspace:*
dependencies:
astro: link:../../..
packages/astro/test/fixtures/html-slots:
specifiers:
astro: workspace:*
dependencies:
astro: link:../../..
packages/astro/test/fixtures/import-ts-with-js:
specifiers:
astro: workspace:*
@ -9547,7 +9577,7 @@ packages:
dev: true
/concat-map/0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
/concurrently/7.2.2:
resolution: {integrity: sha512-DcQkI0ruil5BA/g7Xy3EWySGrFJovF5RYAYxwGvv9Jf9q9B1v3jPFP2tl6axExNf1qgF30kjoNYrangZ0ey4Aw==}
@ -14217,6 +14247,15 @@ packages:
unist-util-visit: 4.1.0
dev: true
/rehype-parse/8.0.4:
resolution: {integrity: sha512-MJJKONunHjoTh4kc3dsM1v3C9kGrrxvA3U8PxZlP2SjH8RNUSrb+lF7Y0KVaUDnGH2QZ5vAn7ulkiajM9ifuqg==}
dependencies:
'@types/hast': 2.3.4
hast-util-from-parse5: 7.1.0
parse5: 6.0.1
unified: 10.1.2
dev: false
/rehype-raw/6.1.1:
resolution: {integrity: sha512-d6AKtisSRtDRX4aSPsJGTfnzrX2ZkHQLE5kiUuGOeEoLpbEulFF4hj0mLPbsa+7vmguDKOVVEQdHKDSwoaIDsQ==}
dependencies:
@ -14251,6 +14290,15 @@ packages:
'@jsdevtools/rehype-toc': 3.0.2
dev: true
/rehype/12.0.1:
resolution: {integrity: sha512-ey6kAqwLM3X6QnMDILJthGvG1m1ULROS9NT4uG9IDCuv08SFyLlreSuvOa//DgEvbXx62DS6elGVqusWhRUbgw==}
dependencies:
'@types/hast': 2.3.4
rehype-parse: 8.0.4
rehype-stringify: 9.0.3
unified: 10.1.2
dev: false
/remark-code-titles/0.1.2:
resolution: {integrity: sha512-KsHQbaI4FX8Ozxqk7YErxwmBiveUqloKuVqyPG2YPLHojpgomodWgRfG4B+bOtmn/5bfJ8khw4rR0lvgVFl2Uw==}
dependencies: