UNPKG

nuxt-builderio

Version:

An unofficial Nuxt module for Builder.io, a visual headless CMS.

295 lines (289 loc) 9.99 kB
import { resolveFiles, defineNuxtModule, createResolver, addComponent, addVitePlugin, addWebpackPlugin, addTemplate, addPlugin, addComponentsDir, addImports, logger } from '@nuxt/kit'; import defu from 'defu'; import { pathToFileURL } from 'node:url'; import { createUnplugin } from 'unplugin'; import { parseURL, parseQuery } from 'ufo'; import { findStaticImports, findExports, parseStaticImport } from 'mlly'; import { walk } from 'estree-walker'; import MagicString from 'magic-string'; import { normalize, isAbsolute } from 'pathe'; import { hash } from 'ohash'; import { genSafeVariableName, genImport, genArrayFromRaw, genDynamicImport } from 'knitwork'; import { filename } from 'pathe/utils'; const HAS_MACRO_RE = /\bdefineBuilderComponent\s*\(\s*/; const CODE_EMPTY = ` const __builder_component = null export default __builder_component `; const CODE_HMR = ` // Vite if (import.meta.hot) { import.meta.hot.accept(mod => { Object.assign(__builder_component, mod) }) } // Webpack if (import.meta.webpackHot) { import.meta.webpackHot.accept((err) => { if (err) { window.location = window.location.href } }) }`; const BuilderComponentPlugin = createUnplugin((options) => { return { name: "nuxt-builderio:component-plugin-transform", enforce: "post", transformInclude(id) { const query = parseMacroQuery(id); id = normalize(id); return !!query.builder; }, transform(code, id) { const query = parseMacroQuery(id); if (query.type && query.type !== "script") { return; } const s = new MagicString(code); function result() { if (s.hasChanged()) { return { code: s.toString(), map: options.sourcemap ? s.generateMap({ hires: true }) : void 0 }; } } const hasMacro = HAS_MACRO_RE.test(code); const imports = findStaticImports(code); const scriptImport = imports.find((i) => parseMacroQuery(i.specifier).type === "script"); if (scriptImport) { const specifier = rewriteQuery(scriptImport.specifier); s.overwrite(0, code.length, `export { default } from ${JSON.stringify(specifier)}`); return result(); } const currentExports = findExports(code); for (const match of currentExports) { if (match.type !== "default" || !match.specifier) { continue; } const specifier = rewriteQuery(match.specifier); s.overwrite(0, code.length, `export { default } from ${JSON.stringify(specifier)}`); return result(); } if (!hasMacro && !code.includes("export { default }") && !code.includes("__builder_component")) { if (!code) { s.append(CODE_EMPTY + (options.dev ? CODE_HMR : "")); const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)); console.error(`The file \`${pathname}\` is not a valid Builder component as it has no content.`); } else { s.overwrite(0, code.length, CODE_EMPTY + (options.dev ? CODE_HMR : "")); } return result(); } const importMap = /* @__PURE__ */ new Map(); const addedImports = /* @__PURE__ */ new Set(); for (const i of imports) { const parsed = parseStaticImport(i); for (const name of [ parsed.defaultImport, ...Object.values(parsed.namedImports || {}), parsed.namespacedImport ].filter(Boolean)) { importMap.set(name, i); } } walk(this.parse(code, { sourceType: "module", ecmaVersion: "latest" }), { enter(_node) { if (_node.type !== "CallExpression" || _node.callee.type !== "Identifier") { return; } const node = _node; const name = "name" in node.callee && node.callee.name; if (name !== "defineBuilderComponent") { return; } const meta = node.arguments[0]; let contents = `const __builder_component = ${code.slice(meta.start, meta.end) || "null"} export default __builder_component` + (options.dev ? CODE_HMR : ""); function addImport(name2) { if (name2 && importMap.has(name2)) { const importValue = importMap.get(name2).code; if (!addedImports.has(importValue)) { contents = importMap.get(name2).code + "\n" + contents; addedImports.add(importValue); } } } walk(meta, { enter(_node2) { if (_node2.type === "CallExpression") { const node2 = _node2; addImport("name" in node2.callee && node2.callee.name); } if (_node2.type === "Identifier") { const node2 = _node2; addImport(node2.name); } } }); s.overwrite(0, code.length, contents); } }); if (!s.hasChanged() && !code.includes("__builder_component")) { s.overwrite(0, code.length, CODE_EMPTY + (options.dev ? CODE_HMR : "")); } return result(); }, vite: { handleHotUpdate: { order: "pre", handler: ({ modules }) => { const index = modules.findIndex((i) => i.id?.includes("?builder=true")); if (index !== -1) { modules.splice(index, 1); } } } } }; }); function rewriteQuery(id) { return id.replace(/\?.+$/, (r) => "?builder=true&" + r.replace(/^\?/, "").replace(/&builder=true/, "")); } function parseMacroQuery(id) { const { search } = parseURL(decodeURIComponent(isAbsolute(id) ? pathToFileURL(id).href : id).replace(/\?builder=true$/, "")); const query = parseQuery(search); if (id.includes("?builder=true")) { return { builder: "true", ...query }; } return query; } const generateComponentsTemplate = async (componentsPath) => { const componentPaths = await resolveFiles(componentsPath, "**/**/*.vue"); const components = componentPaths.map((path) => ({ path, dataImportName: genSafeVariableName(filename(path) + hash(path)) + "Data" })); const imports = components.map((component) => genImport( `${component.path}?builder=true`, [{ name: "default", as: component.dataImportName }] )).join("\n"); const exportString = genArrayFromRaw(components.map((component) => ({ name: `"${filename(component.path)}"`, data: component.dataImportName, component: genDynamicImport(component.path) }))); return `${imports} export default ${exportString}`; }; const getBuilderApiKey = (options) => { if (options.apiKey) { return options.apiKey; } if (process.env.BUILDER_API_KEY) { return process.env.BUILDER_API_KEY; } logger.warn("Builder API key not found. Add `builder.apiKey` to your `nuxt.config` or set the BUILDER_API_KEY environment variable."); }; const module = defineNuxtModule({ meta: { name: "nuxt-builderio", // The `builder` key is already used by Nuxt configKey: "builderIO", compatibility: { nuxt: "^3.0.0" } }, defaults: { autoImports: [ "fetchEntries", "fetchOneEntry", "fetchBuilderProps", "isEditing", "isPreviewing", "setEditorSettings", "getBuilderSearchParams", "createRegisterComponentMessage" ], injectCss: true, defaultModel: "page", components: { enabled: true, dir: "builder/components", prefix: "BuilderCustom" } }, async setup(options, nuxt) { const { resolve } = createResolver(import.meta.url); const baseLayer = nuxt.options._layers[0]; const { resolve: resolveBaseLayer } = createResolver(baseLayer.config.srcDir); const apiKey = getBuilderApiKey(options); nuxt.options.runtimeConfig.public = defu(nuxt.options.runtimeConfig.public || {}, { builderIO: { apiKey, defaultModel: options.defaultModel, components: { enabled: options.components.enabled, prefix: options.components.prefix } } }); addComponent({ name: "BuilderContent", filePath: resolve("./runtime/components/BuilderContent.vue") }); if (options.components.enabled) { const componentPluginOptions = { dev: nuxt.options.dev, sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client }; nuxt.hook("modules:done", () => { addVitePlugin(() => BuilderComponentPlugin.vite(componentPluginOptions)); addWebpackPlugin(() => BuilderComponentPlugin.webpack(componentPluginOptions)); }); const builderComponentsPath = resolveBaseLayer(`./${options.components.dir}`); addTemplate({ filename: "builder/components.mjs", getContents: async () => await generateComponentsTemplate(builderComponentsPath) }); addPlugin({ mode: "client", src: resolve("./runtime/plugins/components") }); addComponentsDir({ path: builderComponentsPath, prefix: options.components.prefix, global: true }); addImports({ name: "defineBuilderComponent", as: "defineBuilderComponent", from: resolve( "./runtime/composables/define-builder-component" ) }); } const builderLibraryPath = "@builder.io/sdk-vue"; if (options.autoImports) { addImports(options.autoImports.map((item) => ({ name: item, as: item, from: builderLibraryPath }))); } addImports([{ name: "Content", as: "InternalBuilderRenderContent", from: builderLibraryPath }, { name: "useBuilderComponents", as: "useBuilderComponents", from: resolve("./runtime/composables/builder-components") }]); if (options.injectCss) { nuxt.options.css.push("@builder.io/sdk-vue/css"); } } }); export { module as default };