Compare commits
1 commit
main
...
feat/astro
Author | SHA1 | Date | |
---|---|---|---|
|
1e0b670e28 |
9 changed files with 262 additions and 6 deletions
31
examples/minimal/src/components/counter.astro
Normal file
31
examples/minimal/src/components/counter.astro
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
const { count } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button>-</button>
|
||||||
|
<p>{count}</p>
|
||||||
|
<button>+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script @component>
|
||||||
|
export class Component extends HTMLElement {
|
||||||
|
handleEvent(event) {
|
||||||
|
if (event.type !== 'click') return;
|
||||||
|
|
||||||
|
if (event.target.textContent === '+') {
|
||||||
|
this.count += 1;
|
||||||
|
} else if (event.target.textContent === '-') {
|
||||||
|
this.count -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.addEventListener('click', this)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.removeEventListener('click', this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
|
import Counter from '../components/counter.astro'
|
||||||
---
|
---
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
@ -9,5 +9,6 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Astro</h1>
|
<h1>Astro</h1>
|
||||||
|
<Counter count={0} client:load />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -83,6 +83,7 @@
|
||||||
"htmlparser2": "^7.1.2",
|
"htmlparser2": "^7.1.2",
|
||||||
"kleur": "^4.1.4",
|
"kleur": "^4.1.4",
|
||||||
"magic-string": "^0.25.7",
|
"magic-string": "^0.25.7",
|
||||||
|
"micromorph": "^0.0.2",
|
||||||
"mime": "^3.0.0",
|
"mime": "^3.0.0",
|
||||||
"morphdom": "^2.6.1",
|
"morphdom": "^2.6.1",
|
||||||
"parse5": "^6.0.1",
|
"parse5": "^6.0.1",
|
||||||
|
|
15
packages/astro/src/runtime/client/client.ts
Normal file
15
packages/astro/src/runtime/client/client.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import diff from 'micromorph';
|
||||||
|
|
||||||
|
const serialize = (el: Element) => {
|
||||||
|
let str = '';
|
||||||
|
for (const attr of el.attributes) {
|
||||||
|
str += ` ${attr.name}="${attr.value}"`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = new DOMParser();
|
||||||
|
export function patch(from: Element, children: string) {
|
||||||
|
const to = p.parseFromString(`<${from.localName} ${serialize(from)}>${children}</${from.localName}>`, 'text/html').body.children[0];
|
||||||
|
return diff(from, to);
|
||||||
|
}
|
|
@ -126,6 +126,11 @@ function formatList(values: string[]): string {
|
||||||
return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`;
|
return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kebabCase = (str: string) => str
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
export async function renderComponent(result: SSRResult, displayName: string, Component: unknown, _props: Record<string | number, any>, slots: any = {}) {
|
export async function renderComponent(result: SSRResult, displayName: string, Component: unknown, _props: Record<string | number, any>, slots: any = {}) {
|
||||||
Component = await Component;
|
Component = await Component;
|
||||||
const children = await renderSlot(result, slots?.default);
|
const children = await renderSlot(result, slots?.default);
|
||||||
|
@ -135,8 +140,65 @@ export async function renderComponent(result: SSRResult, displayName: string, Co
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Component && (Component as any).isAstroComponentFactory) {
|
if (Component && (Component as any).isAstroComponentFactory) {
|
||||||
|
const { hydration, props } = extractDirectives(_props);
|
||||||
|
const metadata: AstroComponentMetadata = { displayName };
|
||||||
|
if (hydration) {
|
||||||
|
metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate'];
|
||||||
|
metadata.hydrateArgs = hydration.value;
|
||||||
|
metadata.componentExport = hydration.componentExport;
|
||||||
|
metadata.componentUrl = hydration.componentUrl;
|
||||||
|
}
|
||||||
const output = await renderToString(result, Component as any, _props, slots);
|
const output = await renderToString(result, Component as any, _props, slots);
|
||||||
return unescapeHTML(output);
|
|
||||||
|
if (!metadata.hydrate) {
|
||||||
|
return unescapeHTML(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
let component = kebabCase(displayName);
|
||||||
|
if (!component.includes('-')) {
|
||||||
|
component = `my-${component}`;
|
||||||
|
}
|
||||||
|
const observedAttributes = Object.keys(props);
|
||||||
|
// TODO: this should probably be automatically generated based on a query param so we have the full component AST to work with
|
||||||
|
result.scripts.add({
|
||||||
|
props: { type: 'module', 'data-astro-component-hydration': true },
|
||||||
|
// TODO: shared_worker?
|
||||||
|
children: `
|
||||||
|
import { patch } from 'astro/client/client.js';
|
||||||
|
// TODO: fix Vite issue with multiple \`default\` exports
|
||||||
|
import { Component } from "${metadata.componentUrl}?component_script";
|
||||||
|
import ComponentWorker from "${metadata.componentUrl}?worker";
|
||||||
|
|
||||||
|
customElements.define("${component}", class extends Component {
|
||||||
|
// Possibly expose getters and setters for observedAttributes?
|
||||||
|
${observedAttributes.map(attr => {
|
||||||
|
return `
|
||||||
|
get ${attr}() {
|
||||||
|
return JSON.parse(this.getAttribute("${attr}"));
|
||||||
|
}
|
||||||
|
set ${attr}(value) {
|
||||||
|
this.setAttribute("${attr}", JSON.stringify(value));
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})}
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
this.worker = new ComponentWorker();
|
||||||
|
this.worker.onmessage = (e) => {
|
||||||
|
if (e.data[0] === 'result') {
|
||||||
|
patch(this, e.data[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static get observedAttributes() {
|
||||||
|
return ${JSON.stringify(observedAttributes)};
|
||||||
|
}
|
||||||
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
|
this.worker.postMessage(['attributeChangedCallback', name, oldValue, newValue]);
|
||||||
|
}
|
||||||
|
})`,
|
||||||
|
});
|
||||||
|
return unescapeHTML(`<${component} ${spreadAttributes(props)}>${output}</${component}>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Component === null && !_props['client:only']) {
|
if (Component === null && !_props['client:only']) {
|
||||||
|
|
105
packages/astro/src/runtime/server/worker.ts
Normal file
105
packages/astro/src/runtime/server/worker.ts
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import { escapeHTML, UnescapedString, unescapeHTML } from './escape.js';
|
||||||
|
export { escapeHTML, unescapeHTML } from './escape.js';
|
||||||
|
|
||||||
|
export const createMetadata = () => ({});
|
||||||
|
export const createComponent = (fn: any) => fn;
|
||||||
|
|
||||||
|
async function _render(child: any): Promise<any> {
|
||||||
|
child = await child;
|
||||||
|
if (child instanceof UnescapedString) {
|
||||||
|
return child;
|
||||||
|
} else if (Array.isArray(child)) {
|
||||||
|
return unescapeHTML((await Promise.all(child.map((value) => _render(value)))).join(''));
|
||||||
|
} else if (typeof child === 'function') {
|
||||||
|
// Special: If a child is a function, call it automatically.
|
||||||
|
// This lets you do {() => ...} without the extra boilerplate
|
||||||
|
// of wrapping it in a function and calling it.
|
||||||
|
return _render(child());
|
||||||
|
} else if (typeof child === 'string') {
|
||||||
|
return escapeHTML(child, { deprecated: true });
|
||||||
|
} else if (!child && child !== 0) {
|
||||||
|
// do nothing, safe to ignore falsey values.
|
||||||
|
}
|
||||||
|
// Add a comment explaining why each of these are needed.
|
||||||
|
// Maybe create clearly named function for what this is doing.
|
||||||
|
else if (child instanceof AstroComponent || Object.prototype.toString.call(child) === '[object AstroComponent]') {
|
||||||
|
return unescapeHTML(await renderAstroComponent(child));
|
||||||
|
} else {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is used to create the top-level Astro global; the one that you can use
|
||||||
|
// Inside of getStaticPaths.
|
||||||
|
export function createAstro(filePathname: string, _site: string, projectRootStr: string): AstroGlobalPartial {
|
||||||
|
const site = new URL(_site);
|
||||||
|
const url = new URL(filePathname, site);
|
||||||
|
const projectRoot = new URL(projectRootStr);
|
||||||
|
const fetchContent = () => {};
|
||||||
|
return {
|
||||||
|
site,
|
||||||
|
fetchContent,
|
||||||
|
// INVESTIGATE is there a use-case for multi args?
|
||||||
|
resolve(...segments: string[]) {
|
||||||
|
let resolved = segments.reduce((u, segment) => new URL(segment, u), url).pathname;
|
||||||
|
// When inside of project root, remove the leading path so you are
|
||||||
|
// left with only `/src/images/tower.png`
|
||||||
|
if (resolved.startsWith(projectRoot.pathname)) {
|
||||||
|
resolved = '/' + resolved.substr(projectRoot.pathname.length);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderAstroComponent(component: InstanceType<typeof AstroComponent>) {
|
||||||
|
let template = [];
|
||||||
|
|
||||||
|
for await (const value of component) {
|
||||||
|
if (value || value === 0) {
|
||||||
|
template.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unescapeHTML(await _render(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The return value when rendering a component.
|
||||||
|
// This is the result of calling render(), should this be named to RenderResult or...?
|
||||||
|
export class AstroComponent {
|
||||||
|
private htmlParts: TemplateStringsArray;
|
||||||
|
private expressions: any[];
|
||||||
|
|
||||||
|
constructor(htmlParts: TemplateStringsArray, expressions: any[]) {
|
||||||
|
this.htmlParts = htmlParts;
|
||||||
|
this.expressions = expressions;
|
||||||
|
}
|
||||||
|
|
||||||
|
get [Symbol.toStringTag]() {
|
||||||
|
return 'AstroComponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
*[Symbol.iterator]() {
|
||||||
|
const { htmlParts, expressions } = this;
|
||||||
|
|
||||||
|
for (let i = 0; i < htmlParts.length; i++) {
|
||||||
|
const html = htmlParts[i];
|
||||||
|
const expression = expressions[i];
|
||||||
|
|
||||||
|
yield _render(unescapeHTML(html));
|
||||||
|
yield _render(expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function render(htmlParts: TemplateStringsArray, ...expressions: any[]) {
|
||||||
|
return new AstroComponent(htmlParts, expressions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calls a component and renders it into a string of HTML
|
||||||
|
export async function renderToString(result: any, componentFactory: any, props: any, children: any) {
|
||||||
|
const Component = await componentFactory(result, props, children);
|
||||||
|
let template = await renderAstroComponent(Component);
|
||||||
|
return unescapeHTML(template);
|
||||||
|
}
|
|
@ -15,6 +15,10 @@ const configCache = new WeakMap<AstroConfig, CompilationCache>();
|
||||||
type CompileResult = TransformResult & { rawCSSDeps: Set<string> };
|
type CompileResult = TransformResult & { rawCSSDeps: Set<string> };
|
||||||
|
|
||||||
async function compile(config: AstroConfig, filename: string, source: string, viteTransform: TransformHook, opts: { ssr: boolean }): Promise<CompileResult> {
|
async function compile(config: AstroConfig, filename: string, source: string, viteTransform: TransformHook, opts: { ssr: boolean }): Promise<CompileResult> {
|
||||||
|
const isWorker = filename.endsWith('?worker_file');
|
||||||
|
if (isWorker) {
|
||||||
|
filename = filename.replace(/\?worker_file$/, '');
|
||||||
|
}
|
||||||
// pages and layouts should be transformed as full documents (implicit <head> <body> etc)
|
// pages and layouts should be transformed as full documents (implicit <head> <body> etc)
|
||||||
// everything else is treated as a fragment
|
// everything else is treated as a fragment
|
||||||
const filenameURL = new URL(`file://${filename}`);
|
const filenameURL = new URL(`file://${filename}`);
|
||||||
|
@ -35,7 +39,7 @@ async function compile(config: AstroConfig, filename: string, source: string, vi
|
||||||
site: config.buildOptions.site,
|
site: config.buildOptions.site,
|
||||||
sourcefile: filename,
|
sourcefile: filename,
|
||||||
sourcemap: 'both',
|
sourcemap: 'both',
|
||||||
internalURL: 'astro/internal',
|
internalURL: isWorker ? 'astro/internal/worker.js' : 'astro/internal',
|
||||||
experimentalStaticExtraction: config.buildOptions.experimentalStaticBuild,
|
experimentalStaticExtraction: config.buildOptions.experimentalStaticBuild,
|
||||||
// TODO add experimental flag here
|
// TODO add experimental flag here
|
||||||
preprocessStyle: async (value: string, attrs: Record<string, string>) => {
|
preprocessStyle: async (value: string, attrs: Record<string, string>) => {
|
||||||
|
|
|
@ -17,6 +17,10 @@ interface AstroPluginOptions {
|
||||||
logging: LogOptions;
|
logging: LogOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isValidAstroFile = (id: string) => id.endsWith('.astro') || id.endsWith('.astro?worker_file') || id.endsWith('.astro?component_script')
|
||||||
|
const COMPONENT_SCRIPT = /\<script[^>]*\@component[^>]*\>([\s\S]*)\<\/script\>/mi;
|
||||||
|
const EXTRACT_COMPONENT_SCRIPT = /(?<=\<script[^>]*\@component[^>]*\>)[\s\S]+(?=\<\/script\>)/mi;
|
||||||
|
|
||||||
/** Transform .astro files for Vite */
|
/** Transform .astro files for Vite */
|
||||||
export default function astro({ config, logging }: AstroPluginOptions): vite.Plugin {
|
export default function astro({ config, logging }: AstroPluginOptions): vite.Plugin {
|
||||||
function normalizeFilename(filename: string) {
|
function normalizeFilename(filename: string) {
|
||||||
|
@ -106,16 +110,44 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
async transform(source, id, opts) {
|
async transform(source, id, opts) {
|
||||||
if (!id.endsWith('.astro')) {
|
if (!isValidAstroFile(id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transformResult = await cachedCompilation(config, id, source, viteTransform, { ssr: Boolean(opts?.ssr) });
|
const transformResult = await cachedCompilation(config, id, source, viteTransform, { ssr: Boolean(opts?.ssr) });
|
||||||
|
let result = transformResult.code;
|
||||||
|
if (id.endsWith('?worker_file')) {
|
||||||
|
result += `\nimport { renderToString } from 'astro/internal/worker.js';
|
||||||
|
let prevResult = '';
|
||||||
|
onmessage = async function (e) {
|
||||||
|
if (e.data[0] === 'attributeChangedCallback') {
|
||||||
|
const [_, name, oldValue, newValue] = e.data;
|
||||||
|
if (oldValue === newValue) return;
|
||||||
|
const result = await renderToString({ createAstro(_, props, slots) { return { props, slots } } }, $$Counter, { [name]: JSON.parse(newValue) }, {});
|
||||||
|
if (result !== prevResult) {
|
||||||
|
self.postMessage(['result', result])
|
||||||
|
prevResult = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
if (id.endsWith('?component_script')) {
|
||||||
|
if (COMPONENT_SCRIPT.test(result)) {
|
||||||
|
const script = result.match(EXTRACT_COMPONENT_SCRIPT)?.[0];
|
||||||
|
result = (script as string);
|
||||||
|
} else {
|
||||||
|
result = 'export class Component extends HTMLElement {}';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (COMPONENT_SCRIPT.test(result)) {
|
||||||
|
result = result.replace(COMPONENT_SCRIPT, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compile all TypeScript to JavaScript.
|
// Compile all TypeScript to JavaScript.
|
||||||
// Also, catches invalid JS/TS in the compiled output before returning.
|
// Also, catches invalid JS/TS in the compiled output before returning.
|
||||||
const { code, map } = await esbuild.transform(transformResult.code, {
|
const { code, map } = await esbuild.transform(result, {
|
||||||
loader: 'ts',
|
loader: 'ts',
|
||||||
sourcemap: 'external',
|
sourcemap: 'external',
|
||||||
sourcefile: id,
|
sourcefile: id,
|
||||||
|
|
|
@ -5930,6 +5930,11 @@ micromatch@^4.0.2, micromatch@^4.0.4:
|
||||||
braces "^3.0.1"
|
braces "^3.0.1"
|
||||||
picomatch "^2.2.3"
|
picomatch "^2.2.3"
|
||||||
|
|
||||||
|
micromorph@^0.0.2:
|
||||||
|
version "0.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/micromorph/-/micromorph-0.0.2.tgz#5cfbaea66ae5fb8629c8041897e88d9e827afc2f"
|
||||||
|
integrity sha512-hfy/OA8rtwI/vPRm4L6a3/u6uDvqsPmTok7pPmtfv2a7YfaTVfxd9HX2Kdn/SZ8rGMKkKVJ9A0WcBnzs0bjLXw==
|
||||||
|
|
||||||
mime@1.6.0:
|
mime@1.6.0:
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
|
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
|
||||||
|
|
Loading…
Reference in a new issue