mirror of
https://github.com/withastro/astro.git
synced 2025-03-10 23:01:26 -05:00
refactor how client directives work
This commit is contained in:
parent
005d53145f
commit
dd6c06e229
12 changed files with 128 additions and 14 deletions
|
@ -881,6 +881,7 @@ export interface AstroConfig extends z.output<typeof AstroConfigSchema> {
|
||||||
adapter: AstroAdapter | undefined;
|
adapter: AstroAdapter | undefined;
|
||||||
renderers: AstroRenderer[];
|
renderers: AstroRenderer[];
|
||||||
scripts: { stage: InjectedScriptStage; content: string }[];
|
scripts: { stage: InjectedScriptStage; content: string }[];
|
||||||
|
clientDirectives: ClientDirectiveMap;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1200,6 +1201,7 @@ export interface SSRElement {
|
||||||
|
|
||||||
export interface SSRMetadata {
|
export interface SSRMetadata {
|
||||||
renderers: SSRLoadedRenderer[];
|
renderers: SSRLoadedRenderer[];
|
||||||
|
clientDirectives: ClientDirectiveMap;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
hasHydrationScript: boolean;
|
hasHydrationScript: boolean;
|
||||||
hasDirectives: Set<string>;
|
hasDirectives: Set<string>;
|
||||||
|
@ -1220,3 +1222,18 @@ export interface SSRResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MarkdownAstroData = { frontmatter: object };
|
export type MarkdownAstroData = { frontmatter: object };
|
||||||
|
|
||||||
|
/* Client Directives */
|
||||||
|
export type ClientDirectiveMap = Map<string, { type: 'inline' | 'external', src: string }>;
|
||||||
|
type Hydrate = () => Promise<void>;
|
||||||
|
type Load = () => Promise<Hydrate>;
|
||||||
|
|
||||||
|
type DirectiveOptions = {
|
||||||
|
// The component displayName
|
||||||
|
name: string;
|
||||||
|
// The attribute value provided,
|
||||||
|
// for ex `client:interactive="click"
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientDirective = (load: Load, opts: DirectiveOptions, element: HTMLElement) => void;
|
||||||
|
|
|
@ -353,6 +353,12 @@ export async function validateConfig(
|
||||||
renderers: [jsxRenderer],
|
renderers: [jsxRenderer],
|
||||||
injectedRoutes: [],
|
injectedRoutes: [],
|
||||||
adapter: undefined,
|
adapter: undefined,
|
||||||
|
clientDirectives: new Map([
|
||||||
|
['visible', {
|
||||||
|
type: 'external',
|
||||||
|
src: 'astro/runtime/client/visible.js'
|
||||||
|
}]
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ import legacyMarkdownVitePlugin from '../vite-plugin-markdown-legacy/index.js';
|
||||||
import markdownVitePlugin from '../vite-plugin-markdown/index.js';
|
import markdownVitePlugin from '../vite-plugin-markdown/index.js';
|
||||||
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
|
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
|
||||||
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
|
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
|
||||||
|
import astroClientDirective from '../vite-plugin-client-directive/index.js';
|
||||||
import { createCustomViteLogger } from './errors.js';
|
import { createCustomViteLogger } from './errors.js';
|
||||||
import { resolveDependency } from './util.js';
|
import { resolveDependency } from './util.js';
|
||||||
|
|
||||||
|
@ -86,6 +87,7 @@ export async function createVite(
|
||||||
astroPostprocessVitePlugin({ config: astroConfig }),
|
astroPostprocessVitePlugin({ config: astroConfig }),
|
||||||
astroIntegrationsContainerPlugin({ config: astroConfig, logging }),
|
astroIntegrationsContainerPlugin({ config: astroConfig, logging }),
|
||||||
astroScriptsPageSSRPlugin({ config: astroConfig }),
|
astroScriptsPageSSRPlugin({ config: astroConfig }),
|
||||||
|
astroClientDirective({ config: astroConfig, logging }),
|
||||||
],
|
],
|
||||||
publicDir: fileURLToPath(astroConfig.publicDir),
|
publicDir: fileURLToPath(astroConfig.publicDir),
|
||||||
root: fileURLToPath(astroConfig.root),
|
root: fileURLToPath(astroConfig.root),
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
|
import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
|
||||||
import type {
|
import type {
|
||||||
ComponentInstance,
|
ComponentInstance,
|
||||||
|
ClientDirectiveMap,
|
||||||
Params,
|
Params,
|
||||||
Props,
|
Props,
|
||||||
RouteData,
|
RouteData,
|
||||||
|
@ -79,6 +80,7 @@ export interface RenderOptions {
|
||||||
scripts: Set<SSRElement>;
|
scripts: Set<SSRElement>;
|
||||||
resolve: (s: string) => Promise<string>;
|
resolve: (s: string) => Promise<string>;
|
||||||
renderers: SSRLoadedRenderer[];
|
renderers: SSRLoadedRenderer[];
|
||||||
|
clientDirectives: ClientDirectiveMap;
|
||||||
route?: RouteData;
|
route?: RouteData;
|
||||||
routeCache: RouteCache;
|
routeCache: RouteCache;
|
||||||
site?: string;
|
site?: string;
|
||||||
|
@ -91,6 +93,7 @@ export interface RenderOptions {
|
||||||
export async function render(opts: RenderOptions): Promise<Response> {
|
export async function render(opts: RenderOptions): Promise<Response> {
|
||||||
const {
|
const {
|
||||||
adapterName,
|
adapterName,
|
||||||
|
clientDirectives,
|
||||||
links,
|
links,
|
||||||
styles,
|
styles,
|
||||||
logging,
|
logging,
|
||||||
|
@ -145,6 +148,7 @@ export async function render(opts: RenderOptions): Promise<Response> {
|
||||||
pathname,
|
pathname,
|
||||||
resolve,
|
resolve,
|
||||||
renderers,
|
renderers,
|
||||||
|
clientDirectives,
|
||||||
request,
|
request,
|
||||||
site,
|
site,
|
||||||
scripts,
|
scripts,
|
||||||
|
|
|
@ -168,6 +168,7 @@ export async function render(
|
||||||
|
|
||||||
let response = await coreRender({
|
let response = await coreRender({
|
||||||
adapterName: astroConfig.adapter?.name,
|
adapterName: astroConfig.adapter?.name,
|
||||||
|
clientDirectives: astroConfig._ctx.clientDirectives,
|
||||||
links,
|
links,
|
||||||
styles,
|
styles,
|
||||||
logging,
|
logging,
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { bold } from 'kleur/colors';
|
||||||
import type {
|
import type {
|
||||||
AstroGlobal,
|
AstroGlobal,
|
||||||
AstroGlobalPartial,
|
AstroGlobalPartial,
|
||||||
|
ClientDirectiveMap,
|
||||||
Params,
|
Params,
|
||||||
Props,
|
Props,
|
||||||
RuntimeMode,
|
RuntimeMode,
|
||||||
|
@ -36,6 +37,7 @@ export interface CreateResultArgs {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
props: Props;
|
props: Props;
|
||||||
renderers: SSRLoadedRenderer[];
|
renderers: SSRLoadedRenderer[];
|
||||||
|
clientDirectives: ClientDirectiveMap;
|
||||||
resolve: (s: string) => Promise<string>;
|
resolve: (s: string) => Promise<string>;
|
||||||
site: string | undefined;
|
site: string | undefined;
|
||||||
links?: Set<SSRElement>;
|
links?: Set<SSRElement>;
|
||||||
|
@ -122,7 +124,7 @@ class Slots {
|
||||||
let renderMarkdown: any = null;
|
let renderMarkdown: any = null;
|
||||||
|
|
||||||
export function createResult(args: CreateResultArgs): SSRResult {
|
export function createResult(args: CreateResultArgs): SSRResult {
|
||||||
const { markdown, params, pathname, props: pageProps, renderers, request, resolve } = args;
|
const { clientDirectives, markdown, params, pathname, props: pageProps, renderers, request, resolve } = args;
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
|
@ -274,6 +276,7 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||||
resolve,
|
resolve,
|
||||||
_metadata: {
|
_metadata: {
|
||||||
renderers,
|
renderers,
|
||||||
|
clientDirectives,
|
||||||
pathname,
|
pathname,
|
||||||
hasHydrationScript: false,
|
hasHydrationScript: false,
|
||||||
hasDirectives: new Set(),
|
hasDirectives: new Set(),
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
|
import { ClientDirective } from '../../@types/astro';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate this component when one of it's children becomes visible
|
* Hydrate this component when one of it's children becomes visible
|
||||||
* We target the children because `astro-island` is set to `display: contents`
|
* We target the children because `astro-island` is set to `display: contents`
|
||||||
* which doesn't work with IntersectionObserver
|
* which doesn't work with IntersectionObserver
|
||||||
*/
|
*/
|
||||||
(self.Astro = self.Astro || {}).visible = (getHydrateCallback, _opts, root) => {
|
const visible: ClientDirective = (load, _opts, root) => {
|
||||||
const cb = async () => {
|
const cb = async () => {
|
||||||
let hydrate = await getHydrateCallback();
|
let hydrate = await load();
|
||||||
await hydrate();
|
await hydrate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -24,4 +26,5 @@
|
||||||
io.observe(child);
|
io.observe(child);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.dispatchEvent(new Event('astro:visible'));
|
|
||||||
|
export default visible;
|
||||||
|
|
|
@ -62,7 +62,7 @@ declare const Astro: {
|
||||||
start() {
|
start() {
|
||||||
const opts = JSON.parse(this.getAttribute('opts')!) as Record<string, any>;
|
const opts = JSON.parse(this.getAttribute('opts')!) as Record<string, any>;
|
||||||
const directive = this.getAttribute('client') as directiveAstroKeys;
|
const directive = this.getAttribute('client') as directiveAstroKeys;
|
||||||
if (Astro[directive] === undefined) {
|
if (typeof Astro === 'undefined' || Astro[directive] === undefined) {
|
||||||
window.addEventListener(`astro:${directive}`, () => this.start(), { once: true });
|
window.addEventListener(`astro:${directive}`, () => this.start(), { once: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ export const Renderer = Symbol.for('astro:renderer');
|
||||||
// Rendering produces either marked strings of HTML or instructions for hydration.
|
// Rendering produces either marked strings of HTML or instructions for hydration.
|
||||||
// These directive instructions bubble all the way up to renderPage so that we
|
// These directive instructions bubble all the way up to renderPage so that we
|
||||||
// can ensure they are added only once, and as soon as possible.
|
// can ensure they are added only once, and as soon as possible.
|
||||||
export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruction) {
|
export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruction): string | Promise<string> {
|
||||||
switch ((chunk as any).type) {
|
switch ((chunk as any).type) {
|
||||||
case 'directive': {
|
case 'directive': {
|
||||||
const { hydration } = chunk as RenderInstruction;
|
const { hydration } = chunk as RenderInstruction;
|
||||||
|
@ -29,7 +29,7 @@ export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruct
|
||||||
? 'directive'
|
? 'directive'
|
||||||
: null;
|
: null;
|
||||||
if (prescriptType) {
|
if (prescriptType) {
|
||||||
let prescripts = getPrescripts(prescriptType, hydration.directive);
|
let prescripts = getPrescripts(result, prescriptType, hydration.directive);
|
||||||
return markHTMLString(prescripts);
|
return markHTMLString(prescripts);
|
||||||
} else {
|
} else {
|
||||||
return '';
|
return '';
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { isAstroComponent, isAstroComponentFactory, renderAstroComponent } from
|
||||||
import { stringifyChunk } from './common.js';
|
import { stringifyChunk } from './common.js';
|
||||||
import { renderComponent } from './component.js';
|
import { renderComponent } from './component.js';
|
||||||
import { maybeRenderHead } from './head.js';
|
import { maybeRenderHead } from './head.js';
|
||||||
|
import { string } from 'zod';
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const needsHeadRenderingSymbol = Symbol.for('astro.needsHeadRendering');
|
const needsHeadRenderingSymbol = Symbol.for('astro.needsHeadRendering');
|
||||||
|
@ -72,7 +73,13 @@ export async function renderPage(
|
||||||
let i = 0;
|
let i = 0;
|
||||||
try {
|
try {
|
||||||
for await (const chunk of iterable) {
|
for await (const chunk of iterable) {
|
||||||
let html = stringifyChunk(result, chunk);
|
let html: string;
|
||||||
|
let stringChunk = stringifyChunk(result, chunk);
|
||||||
|
if((stringChunk as any).then !== undefined) {
|
||||||
|
html = await stringChunk;
|
||||||
|
} else {
|
||||||
|
html = (stringChunk as string);
|
||||||
|
}
|
||||||
|
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
if (!/<!doctype html/i.test(html)) {
|
if (!/<!doctype html/i.test(html)) {
|
||||||
|
@ -94,7 +101,13 @@ export async function renderPage(
|
||||||
body = '';
|
body = '';
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for await (const chunk of iterable) {
|
for await (const chunk of iterable) {
|
||||||
let html = stringifyChunk(result, chunk);
|
let html: string;
|
||||||
|
let stringChunk = stringifyChunk(result, chunk);
|
||||||
|
if((stringChunk as any).then !== undefined) {
|
||||||
|
html = await stringChunk;
|
||||||
|
} else {
|
||||||
|
html = (stringChunk as string);
|
||||||
|
}
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
if (!/<!doctype html/i.test(html)) {
|
if (!/<!doctype html/i.test(html)) {
|
||||||
body += '<!DOCTYPE html>\n';
|
body += '<!DOCTYPE html>\n';
|
||||||
|
|
|
@ -14,6 +14,8 @@ export function determineIfNeedsHydrationScript(result: SSRResult): boolean {
|
||||||
return (result._metadata.hasHydrationScript = true);
|
return (result._metadata.hasHydrationScript = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ISLAND_STYLES = `<style>astro-island,astro-slot{display:contents}</style>`;
|
||||||
|
|
||||||
export const hydrationScripts: Record<string, string> = {
|
export const hydrationScripts: Record<string, string> = {
|
||||||
idle: idlePrebuilt,
|
idle: idlePrebuilt,
|
||||||
load: loadPrebuilt,
|
load: loadPrebuilt,
|
||||||
|
@ -40,18 +42,47 @@ function getDirectiveScriptText(directive: string): string {
|
||||||
return directiveScriptText;
|
return directiveScriptText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPrescripts(type: PrescriptType, directive: string): string {
|
function getDirectiveScript(result: SSRResult, directive: string): string | Promise<string> {
|
||||||
|
if(!result._metadata.clientDirectives.has(directive)) {
|
||||||
|
// TODO better error message
|
||||||
|
throw new Error(`Unable to find directive ${directive}`);
|
||||||
|
}
|
||||||
|
let { type, src } = result._metadata.clientDirectives.get(directive)!;
|
||||||
|
switch(type) {
|
||||||
|
case 'external': {
|
||||||
|
return result.resolve(`${src}?astro-client-directive=${directive}`).then(value => {
|
||||||
|
return `<script type="module" src="${value}"></script>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'inline': {
|
||||||
|
throw new Error(`Inline not yet supported`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromise<T>(value: any): value is Promise<T> {
|
||||||
|
if(typeof value.then === 'function') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrescripts(result: SSRResult, type: PrescriptType, directive: string): string | Promise<string> {
|
||||||
// Note that this is a classic script, not a module script.
|
// Note that this is a classic script, not a module script.
|
||||||
// This is so that it executes immediate, and when the browser encounters
|
// This is so that it executes immediate, and when the browser encounters
|
||||||
// an astro-island element the callbacks will fire immediately, causing the JS
|
// an astro-island element the callbacks will fire immediately, causing the JS
|
||||||
// deps to be loaded immediately.
|
// deps to be loaded immediately.
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'both':
|
case 'both':
|
||||||
return `<style>astro-island,astro-slot{display:contents}</style><script>${
|
let directiveScript = getDirectiveScript(result, directive);
|
||||||
getDirectiveScriptText(directive) + islandScript
|
if(isPromise<string>(directiveScript)) {
|
||||||
}</script>`;
|
return directiveScript.then(scriptText => {
|
||||||
|
return `${ISLAND_STYLES}${scriptText}<script>${islandScript}</script>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return `${ISLAND_STYLES}${directiveScript}<script>${islandScript}</script>`
|
||||||
case 'directive':
|
case 'directive':
|
||||||
return `<script>${getDirectiveScriptText(directive)}</script>`;
|
return getDirectiveScript(result, directive);
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
34
packages/astro/src/vite-plugin-client-directive/index.ts
Normal file
34
packages/astro/src/vite-plugin-client-directive/index.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import type * as vite from 'vite';
|
||||||
|
import type { AstroConfig, ManifestData } from '../@types/astro';
|
||||||
|
import { error, info, LogOptions, warn } from '../core/logger/core.js';
|
||||||
|
|
||||||
|
interface AstroPluginOptions {
|
||||||
|
config: AstroConfig;
|
||||||
|
logging: LogOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function createPlugin({ config, logging }: AstroPluginOptions): vite.Plugin {
|
||||||
|
return {
|
||||||
|
name: 'astro:client-directive',
|
||||||
|
transform(code, id, opts = {}) {
|
||||||
|
let idx = id.indexOf('?astro-client-directive');
|
||||||
|
if(idx !== -1) {
|
||||||
|
let entrypoint = id.slice(0, idx);
|
||||||
|
let params = new URLSearchParams(id.slice(idx));
|
||||||
|
let directive = params.get('astro-client-directive');
|
||||||
|
return `
|
||||||
|
import directive from '${entrypoint}';
|
||||||
|
|
||||||
|
(self.Astro = self.Astro || {}).${directive} = directive;
|
||||||
|
window.dispatchEvent(new Event('astro:${directive}'));
|
||||||
|
`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*if (opts.ssr) return;
|
||||||
|
if (!id.includes('vite/dist/client/client.mjs')) return;
|
||||||
|
return code
|
||||||
|
.replace(/\.tip \{[^}]*\}/gm, '.tip {\n display: none;\n}')
|
||||||
|
.replace(/\[vite\]/g, '[astro]');*/
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue