Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add .html support #3867

Merged
merged 12 commits into from
Jul 22, 2022
5 changes: 5 additions & 0 deletions .changeset/strong-stingrays-compete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Add support for `.html` components and pages
6 changes: 6 additions & 0 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
/// <reference types="vite/types/importMeta" />

// HTML
natemoo-re marked this conversation as resolved.
Show resolved Hide resolved
declare module "*.html" {
const Component: { render(opts: { slots: Record<string, string> }): string };
export default content;
}

// CSS modules
type CSSModuleClasses = { readonly [key: string]: string };

Expand Down
3 changes: 3 additions & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }),
Expand Down
9 changes: 1 addition & 8 deletions packages/astro/src/core/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Expand Down
16 changes: 16 additions & 0 deletions packages/astro/src/runtime/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -279,6 +294,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') {
Expand Down
14 changes: 14 additions & 0 deletions packages/astro/src/vite-plugin-html/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
27 changes: 27 additions & 0 deletions packages/astro/src/vite-plugin-html/transform/escape.ts
Original file line number Diff line number Diff line change
@@ -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;
32 changes: 32 additions & 0 deletions packages/astro/src/vite-plugin-html/transform/index.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
27 changes: 27 additions & 0 deletions packages/astro/src/vite-plugin-html/transform/slots.ts
Original file line number Diff line number Diff line change
@@ -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___`;
27 changes: 27 additions & 0 deletions packages/astro/src/vite-plugin-html/transform/utils.ts
Original file line number Diff line number Diff line change
@@ -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, '\\${');
}
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/html-component/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/html-component",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<h1>Hello component!</h1>

<div id="foo">bar</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
import Test from '../components/Test.html';
---

<Test />
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/html-escape/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/html-escape",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div>${foo}</div>
<span ${attr}></span>
<custom-element x-data="`${test}`"></custom-element>
<script>console.log(`hello ${"world"}!`)</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
import Test from '../components/Test.html';
---

<Test />
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/html-page/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/html-page",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>Hello page!</h1>
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/html-slots/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/html-slots",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div id="default"><slot></slot></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div id="inline"><slot is:inline></slot></div>
Original file line number Diff line number Diff line change
@@ -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>
13 changes: 13 additions & 0 deletions packages/astro/test/fixtures/html-slots/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -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>
57 changes: 57 additions & 0 deletions packages/astro/test/html-component.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading