diff --git a/CHANGELOG.md b/CHANGELOG.md index 237058b0..06c04024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ ## Unreleased +* Adds component error boundaries to the Astro engine. +* Adds support for client rendered components inside Bookshop components. +* Astro Bookshop will now attempt to load build plugins from your integrations. +* Fixes an issue where the asset URLs generated by Astro Bookshop during live editing were wrong. +* Fixes an issue where Astro Dynamic tags would break live editing. +* Fixes some inconsistencies in content collections between Bookshop and Astro. +* Fixes some inconsistencies in the `Image` component between Bookshop and Astro. +* Fixes an issue where component names containing dashes would break MDX auto-imports. +* Fixes an issue where React TSX components would fail to parse. +* Fixes an issue where scripts in Astro components would be run multiple times when live editing. +* Fixes an issue where the Astro engine was too lenient when checking bookshop names. + ## v3.9.0 (December 22, 2023) * Adds support for the `astro:content` and `astro:assets` modules inside Bookshop components. diff --git a/javascript-modules/engines/astro-engine/lib/builder.js b/javascript-modules/engines/astro-engine/lib/builder.js index 0c04ec7b..78f51da0 100644 --- a/javascript-modules/engines/astro-engine/lib/builder.js +++ b/javascript-modules/engines/astro-engine/lib/builder.js @@ -8,7 +8,10 @@ import { sassPlugin, postcssModules } from "esbuild-sass-plugin"; export const extensions = [".astro", ".jsx", ".tsx"]; -const { transform: bookshopTransform } = AstroPluginVite(); +const { transform: bookshopTransform } = AstroPluginVite({ + __includeErrorBoundaries: true, + __removeClientDirectives: true, +}); export const buildPlugins = [ sassPlugin({ @@ -35,10 +38,49 @@ export const buildPlugins = [ : "attribute"; astroConfig = (await import(join(process.cwd(), "astro.config.mjs"))) .default; + astroConfig.output = astroConfig.output ?? "static"; + + const root = `${astroConfig.root ?? process.cwd()}/`.replace( + /\/+/g, + "/" + ); + astroConfig.root = new URL(`file://${root}`); } catch (err) { astroConfig = {}; } + if (Array.isArray(astroConfig.integrations)) { + await Promise.allSettled( + astroConfig.integrations?.map((integration) => { + return integration?.hooks?.["astro:config:setup"]?.({ + config: astroConfig, + logger: { + info: console.log, + warn: console.log, + error: console.log, + debug: console.log, + }, + command: "build", + isRestart: true, + updateConfig: (config) => { + if (config?.vite?.plugins) { + astroConfig.vite = astroConfig.vite ?? {}; + astroConfig.vite.plugins = astroConfig.vite.plugins ?? []; + astroConfig.vite.plugins.push(...config.vite.plugins); + } + }, + addRenderer: () => {}, + addClientDirective: () => {}, + addMiddleware: () => {}, + addDevToolbarApp: () => {}, + addWatchFile: () => {}, + injectScript: () => {}, + injectRoute: () => {}, + }); + }) + ); + } + build.onResolve({ filter: /^astro:.*$/ }, async (args) => { const type = args.path.replace("astro:", ""); if (type !== "content" && type !== "assets") { @@ -59,6 +101,23 @@ export const buildPlugins = [ }; }); + build.onResolve({ filter: /.*/ }, async (args) => { + try { + if (astroConfig.vite?.plugins) { + for (const plugin of astroConfig.vite.plugins) { + if (plugin.resolveId) { + const result = await plugin.resolveId(args.path); + if (result) { + return { path: result, namespace: "virtual" }; + } + } + } + } + } catch (err) { + // Intentionally ignored + } + }); + build.onLoad({ filter: /\.astro$/, namespace: "style" }, async (args) => { let text = await fs.promises.readFile(args.path, "utf8"); let transformed = await transform(text, { @@ -73,13 +132,25 @@ export const buildPlugins = [ }; }); build.onLoad({ filter: /\.astro$/ }, async (args) => { - let text = await fs.promises.readFile(args.path, "utf8"); - let tsResult = await transform(text, { + const astroOptions = { internalURL: "astro/runtime/server/index.js", filename: args.path.replace(process.cwd(), ""), scopedStyleStrategy: astroConfig.scopedStyleStrategy ?? defaultScopedStyleStrategy, - }); + }; + let text = await fs.promises.readFile(args.path, "utf8"); + let tsResult; + try { + tsResult = await transform( + text.replace(/(.|\n)*?<\/script>/g, ""), + astroOptions + ); + } catch (err) {} + + if (!tsResult) { + tsResult = await transform(text, astroOptions); + } + let jsResult = await esbuild.transform(tsResult.code, { loader: "ts", target: "esnext", @@ -159,36 +230,82 @@ export const buildPlugins = [ return { path: args.importer, namespace: "style" }; } ); + + build.onLoad({ filter: /.*/, namespace: "virtual" }, async (args) => { + if (astroConfig.vite?.plugins) { + for (const plugin of astroConfig.vite.plugins) { + try { + if (!plugin.load) { + continue; + } + + const result = await plugin.load(args.path); + if (!result) { + continue; + } + + if (typeof result !== "string" && !result.code) { + continue; + } + + return { + contents: typeof result === "string" ? result : result.code, + loader: "js", + }; + } catch (err) { + // Intentionally ignored + } + } + } + }); + build.onLoad({ filter: /.*/ }, async (args) => { - try{ - if (astroConfig.vite?.plugins) { - const text = await fs.promises.readFile(args.path, "utf8"); - for (const plugin of astroConfig.vite.plugins) { + if ( + args.path.endsWith(".png") || + args.path.endsWith(".svg") || + args.path.endsWith(".jpg") || + args.path.endsWith(".jpeg") || + args.path.endsWith(".webp") || + args.path.endsWith(".json") || + args.path.endsWith(".ts") + ) { + return; + } + if (astroConfig.vite?.plugins) { + let text; + try { + text = await fs.promises.readFile(args.path, "utf8"); + } catch (err) { + return; + } + + for (const plugin of astroConfig.vite.plugins) { + try { if (!plugin.transform) { continue; } - + const result = await plugin.transform( text, args.path.replace(process.cwd(), "") ); - + if (!result) { continue; } - + if (typeof result !== "string" && !result.code) { - return; + continue; } - + return { contents: typeof result === "string" ? result : result.code, loader: "js", }; + } catch (err) { + // Intentionally ignored } } - } catch(err){ - // Intentionally ignored } }); }, @@ -196,6 +313,7 @@ export const buildPlugins = [ ]; export const esbuildConfigFn = (esbuildOptions, options) => { + esbuildOptions.publicPath = "/_cloudcannon/"; esbuildOptions.loader = esbuildOptions.loader ?? {}; esbuildOptions.loader = { ".png": "file", @@ -203,6 +321,7 @@ export const esbuildConfigFn = (esbuildOptions, options) => { ".jpg": "file", ".jpeg": "file", ".webp": "file", + ".ts": "ts", ...esbuildOptions.loader, }; }; diff --git a/javascript-modules/engines/astro-engine/lib/engine.js b/javascript-modules/engines/astro-engine/lib/engine.js index 4b3e7d6b..852abe70 100644 --- a/javascript-modules/engines/astro-engine/lib/engine.js +++ b/javascript-modules/engines/astro-engine/lib/engine.js @@ -8,21 +8,41 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server.browser"; import { flushSync } from "react-dom"; -const renderers = [ - { - name: "@astrojs/react", - ssr: { - check: () => true, - renderToStaticMarkup: async (Component, props) => { - const reactNode = await Component(props); - - return { html: renderToStaticMarkup(reactNode) }; +export class Engine { + renderers = [ + { + name: "dynamic-tags", + ssr: { + check: (Component) => { + return typeof Component === 'string'; + }, + renderToStaticMarkup: async (Component, props, slots) => { + const propsString = Object.entries(props) + .map(([key, value]) => `${key}="${value}"`) + .join(' '); + return `<${Component} ${propsString}>${slots.default ?? ''}` + }, + }, + }, + { + name: "@astrojs/react", + ssr: { + check: () => true, + renderToStaticMarkup: async (Component, props) => { + const clientRendered = props.__client_rendered; + delete props.__client_rendered; + if(clientRendered){ + this.reactRoots.push({Component, props}); + return { html: `
` }; + } + + const reactNode = await Component(props); + return { html: renderToStaticMarkup(reactNode) }; + }, }, }, - }, -]; + ]; -export class Engine { constructor(options) { options = { name: "Astro", @@ -33,21 +53,20 @@ export class Engine { this.key = "astro"; this.name = options.name; this.files = options.files; + this.reactRoots = []; // Hide our files somewhere global so that // the astro plugin can grab them instead of using its Vite import. window.__bookshop_astro_files = options.files; - - this.activeApps = []; } - getShared(name) { + getSharedKey(name) { const base = name.split("/").reverse()[0]; const root = `shared/astro/${name}`; return ( Object.keys(this.files).find((key) => - key.startsWith(`${root}/${base}`) - ) ?? Object.keys(this.files).find((key) => key.startsWith(root)) + key.startsWith(`${root}/${base}.`) + ) ?? Object.keys(this.files).find((key) => key.startsWith(`${root}.`)) ); } @@ -56,29 +75,29 @@ export class Engine { const root = `components/${name}`; return ( Object.keys(this.files).find((key) => - key.startsWith(`${root}/${base}`) - ) ?? Object.keys(this.files).find((key) => key.startsWith(root)) + key.startsWith(`${root}/${base}.`) + ) ?? Object.keys(this.files).find((key) => key.startsWith(`${root}.`)) ); } getComponent(name) { - const key = this.getComponentKey(name); + const key = this.getComponentKey(name) ?? this.getSharedKey(name); return this.files?.[key]; } hasComponent(name) { - const key = this.getComponentKey(name); + const key = this.getComponentKey(name) ?? this.getSharedKey(name); return !!this.files?.[key]; } resolveComponentType(name) { - if (this.getComponent(name)) return "component"; - if (this.getShared(name)) return "shared"; + if (this.getComponentKey(name)) return "component"; + if (this.getSharedKey(name)) return "shared"; return false; } async render(target, name, props, globals) { - const key = this.getComponentKey(name) ?? this.getShared(name); + const key = this.getComponentKey(name) ?? this.getSharedKey(name); if (key.endsWith(".astro")) { return this.renderAstroComponent(target, key, props, globals); @@ -107,9 +126,9 @@ export class Engine { propagators: new Map(), extraHead: [], componentMetadata: new Map(), - renderers, + renderers: this.renderers, _metadata: { - renderers, + renderers: this.renderers, hasHydrationScript: false, hasRenderedHead: true, hasDirectives: new Set(), @@ -130,6 +149,7 @@ export class Engine { __proto__: astroGlobal, props, slots: astroSlots, + request: new Request(window.location), }; }, }; @@ -138,6 +158,14 @@ export class Engine { doc.body.innerHTML = result; this.updateBindings(doc); target.innerHTML = doc.body.innerHTML; + target.querySelectorAll('[data-react-root]').forEach((node) => { + const reactRootId = Number(node.getAttribute('data-react-root')); + const {Component, props} = this.reactRoots[reactRootId]; + const reactNode = createElement(Component, props, null); + const root = createRoot(node); + flushSync(() => root.render(reactNode)); + }); + this.reactRoots = []; } async eval(str, props = [{}]) { @@ -153,13 +181,14 @@ export class Engine { .collection ?? key; const collection = val.map((item) => { let id = item.path.replace(`src/content/${collectionKey}/`, ""); + const slug = id.replace(/\.[^.]*$/, ""); if (!id.match(/\.md(x|oc)?$/)) { - id = id.replace(/\..*$/, ""); + id = slug; } return { id, collection: collectionKey, - slug: item.slug ?? id.replace(/\..*$/, ""), + slug: item.slug ?? slug, render: () => () => "Content is not available when live editing", body: "Content is not available when live editing", data: item, diff --git a/javascript-modules/engines/astro-engine/lib/modules/content.js b/javascript-modules/engines/astro-engine/lib/modules/content.js index 3c4e7a1e..7a8053fe 100644 --- a/javascript-modules/engines/astro-engine/lib/modules/content.js +++ b/javascript-modules/engines/astro-engine/lib/modules/content.js @@ -1,4 +1,4 @@ -export const getCollection = (collectionKey, filter) => { +export const getCollection = async (collectionKey, filter) => { if (!window.__bookshop_collections) { console.warn("[Bookshop] Failed to load site collections for live editing"); return []; @@ -15,10 +15,10 @@ export const getCollection = (collectionKey, filter) => { return window.__bookshop_collections[collectionKey]; }; -export const getEntry = (...args) => { +export const getEntry = async (...args) => { if (args.length === 1) { const { collection: collectionKey, slug: entrySlug, id: entryId } = args[0]; - const collection = getCollection(collectionKey); + const collection = await getCollection(collectionKey); if (entryId) { return collection.find(({ id }) => id === entryId); } else if (entrySlug) { @@ -31,13 +31,13 @@ export const getEntry = (...args) => { } const [collectionKey, entryKey] = args; - const collection = getCollection(collectionKey); + const collection = await getCollection(collectionKey); return collection.find(({ id, slug }) => entryKey === (slug ?? id)); }; export const getEntries = (entries) => { - return entries.map(getEntry); + return Promise.all(entries.map(getEntry)); }; export const getEntryBySlug = (collection, slug) => { diff --git a/javascript-modules/engines/astro-engine/lib/modules/image.astro b/javascript-modules/engines/astro-engine/lib/modules/image.astro index 9f3b7214..0b9b8cb4 100644 --- a/javascript-modules/engines/astro-engine/lib/modules/image.astro +++ b/javascript-modules/engines/astro-engine/lib/modules/image.astro @@ -2,7 +2,7 @@ const props = Astro.props; if (props.alt === undefined || props.alt === null) { - throw new Error("Image missing alt"); + throw new Error("Image missing 'alt'"); } // As a convenience, allow width and height to be string with a number in them, to match HTML's native `img`. @@ -13,11 +13,21 @@ if (typeof props.width === "string") { if (typeof props.height === "string") { props.height = parseInt(props.height); } + +if (!props || typeof props !== "object") { + throw new Error("Image missing options"); +} +if (typeof props.src === "undefined") { + throw new Error("Image missing 'src'"); +} + +const resolvedSrc = + typeof props.src === "object" && "then" in props.src + ? (await props.src).default ?? (await props.src) + : props.src; + +const src = typeof resolvedSrc === "object" ? resolvedSrc.src : resolvedSrc; +delete props.src; --- -{props.alt} + diff --git a/javascript-modules/generator-plugins/astro/astro-bookshop/main.js b/javascript-modules/generator-plugins/astro/astro-bookshop/main.js index 7934c39d..7817084b 100644 --- a/javascript-modules/generator-plugins/astro/astro-bookshop/main.js +++ b/javascript-modules/generator-plugins/astro/astro-bookshop/main.js @@ -2,17 +2,25 @@ import remarkAutoImport from "@cloudcannon/remark-auto-import"; import mdxProcessFrontmatter from "./mdx-process-frontmatter.js"; import vitePluginBookshop from "@bookshop/vite-plugin-astro-bookshop"; -const COMPONENT_REGEX = /(\/|\\)src(\/|\\)components(\/|\\)(?.*)\.(astro|jsx)$/; +const COMPONENT_REGEX = + /(\/|\\)src(\/|\\)components(\/|\\)(?.*)\.(astro|jsx|tsx)$/; -export default () => { +export default (options) => { + const enableMDX = options?.enableMDX ?? true; return { name: "bookshop", hooks: { - "astro:config:setup": async ({ - updateConfig - }) => { - updateConfig({ - markdown: { + "astro:config:setup": async ({ updateConfig }) => { + const config = { + vite: { + define: { + ENV_BOOKSHOP_LIVE: false, + }, + plugins: [vitePluginBookshop()], + }, + }; + if (enableMDX) { + config.markdown = { remarkPlugins: [ [ remarkAutoImport, @@ -34,6 +42,10 @@ export default () => { (part) => part.slice(0, 1).toUpperCase() + part.slice(1) ) .join(""); + + componentName = componentName.replace(/-\w/g, (part) => { + return part.slice(1, 2).toUpperCase(); + }) return componentName; }, additionals: [ @@ -49,14 +61,9 @@ export default () => { }, ], ], - }, - vite: { - define: { - ENV_BOOKSHOP_LIVE: false, - }, - plugins: [vitePluginBookshop()], - }, - }); + }; + } + updateConfig(config); }, }, }; diff --git a/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/main.js b/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/main.js index 787f4370..910caa8f 100644 --- a/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/main.js +++ b/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/main.js @@ -8,15 +8,17 @@ const PAGE_REGEX = /.*src((\/|\\)|(\/|\\).*(\/|\\))(layouts|pages).*(\/|\\)(?.*)\.(astro|jsx|tsx)$/; -const process = (src, id) => { +const process = (src, id, includeErrorBoundaries, removeClientDirectives) => { id = id.replace(cwd().replace(/\\/g, "/"), ""); const pageMatch = id.match(PAGE_REGEX); const componentMatch = id.match(COMPONENT_REGEX); + let componentName = componentMatch?.groups?.component; + if (id.endsWith(".astro")) { try { - src = processAstro(src); + src = processAstro(src, componentName, includeErrorBoundaries, removeClientDirectives); } catch (err) { err.processor = "astro"; throw err; @@ -36,7 +38,6 @@ const process = (src, id) => { return { code: src }; } - let { component: componentName } = componentMatch.groups; let parts = componentName.replace(/\\/g, "/").split("/"); if ( parts.length >= 2 && @@ -49,7 +50,7 @@ const process = (src, id) => { if (id.endsWith(".jsx") || id.endsWith(".tsx")) { try { return { - code: processReactJSX(src, componentName), + code: processReactJSX(src, componentName, includeErrorBoundaries), }; } catch (err) { err.processor = "react-jsx"; @@ -65,14 +66,17 @@ const process = (src, id) => { } }; -export default () => { +export default (options) => { + const includeErrorBoundaries = options?.__includeErrorBoundaries ?? false + const removeClientDirectives = options?.__removeClientDirectives ?? false + return { name: "vite-plugin-astro-bookshop", enforce: "pre", transform(src, id) { try { - return process(src, id); + return process(src, id, includeErrorBoundaries, removeClientDirectives); } catch (err) { let prefix = "[vite-plugin-astro-bookshop]"; if (err.processor) { diff --git a/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/processors/astro.js b/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/processors/astro.js index 3e00c0c1..95fbda2a 100644 --- a/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/processors/astro.js +++ b/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/processors/astro.js @@ -13,7 +13,14 @@ const findComponents = createFinder( true ); -export default (src) => { +const findComponentsDefs = createFinder( + (node) => + node?.type === "CallExpression" && + node.callee?.name === "$$createComponent", + true +); + +export default (src, componentName, includeErrorBoundaries, removeClientDirectives) => { src = src.replace( /const Astro2.*$/m, `$& @@ -38,11 +45,35 @@ export default (src) => { (prop) => prop.key?.value === "bookshop:binding" )?.value.value ?? true; + let clientRendered = false; + if(removeClientDirectives){ + clientRendered = node.arguments[3].properties.find((prop) => prop.key?.value.startsWith('client:')); + } + node.arguments[3].properties = node.arguments[3].properties.filter( (prop) => prop.key?.value !== "bookshop:live" && - prop.key?.value !== "bookshop:binding" + prop.key?.value !== "bookshop:binding" && + (!prop.key?.value.startsWith('client:') || !removeClientDirectives) ); + + if (clientRendered) { + node.arguments[3].properties.unshift({ + type: "ObjectProperty", + method: false, + key: { + type: "Identifier", + name: "__client_rendered", + }, + computed: false, + shorthand: false, + value: { + type: "BooleanLiteral", + value: true, + }, + }); + } + const component = node.arguments[2].name; const propsString = node.arguments[3].properties .filter((prop) => prop.key?.value !== "class") @@ -182,6 +213,24 @@ export default (src) => { Object.keys(template).forEach((key) => (node[key] = template[key])); }); + if(includeErrorBoundaries){ + findComponentsDefs(tree).forEach((node) => { + const handler = parse(`() => { + try{ + + } catch (__err){ + console.error(__err); + return $$render\`
+

Error rendering ${componentName ?? 'Unknown'}!

+

\${__err.message}

+
\`; + } + };`).program.body[0].expression.body.body; + handler[0].block.body = node.arguments[0].body.body + node.arguments[0].body.body = handler; + }); + } + src = (generate.default ?? generate)(tree).code; return src; diff --git a/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/processors/react-jsx.js b/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/processors/react-jsx.js index 7a9659bc..083928db 100644 --- a/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/processors/react-jsx.js +++ b/javascript-modules/generator-plugins/astro/vite-plugin-astro-bookshop/processors/react-jsx.js @@ -128,11 +128,11 @@ const findDefaultExportDeclaration = createFinder( (node) => node.type === "ExportDefaultDeclaration" ); -export default (src, componentName) => { +export default (src, componentName, includeErrorBoundaries) => { const tree = parse(src, { sourceType: "module", ecmaVersion: "latest", - plugins: ["jsx"], + plugins: ["jsx", "typescript"], }).program; let name = src.match( @@ -157,7 +157,7 @@ export default (src, componentName) => { } else if (defaultExport.declaration.type === "FunctionDeclaration") { name = defaultExport.declaration.id.name; } else { - name = componentName.split('/').pop();; + name = componentName.split("/").pop(); defaultExport.type = "VariableDeclaration"; defaultExport.declarations = [ { @@ -360,7 +360,9 @@ export default (src, componentName) => { ); }); } - return `{key:"${prop.name?.name ?? prop.key?.name}", values: [${identifiers + return `{key:"${ + prop.name?.name ?? prop.key?.name + }", values: [${identifiers .join(",") .replace(".[", "[")}], identifiers: [\`${identifiers .join("`,`") @@ -464,6 +466,27 @@ export default (src, componentName) => { Object.keys(template).forEach((key) => (node[key] = template[key])); }); + if (includeErrorBoundaries) { + functionStatements.forEach((node) => { + const handler = parse(`() => { + try{ + + } catch (__err){ + console.error(__err); + return __React.createElement("div", {style: {border: '3px solid red', borderRadius: '2px', backgroundColor: "#F99", padding: "4px"}}, [ + __React.createElement("p", {key: 0, style: {fontSize: "18px", "fontWeight": "600"}}, ["Error rendering ${ + componentName ?? "Unknown" + }!"]), + __React.createElement("p", {key: 1, style: {fontSize: "16px", "fontWeight": "normal"}}, [__err.message]) + ]); + + } + };`).program.body[0].expression.body.body; + handler[0].block.body = node.body.body; + node.body.body = handler; + }); + } + src = (generate.default ?? generate)(tree).code; if (name) {