nuxt-builderio
Version:
An unofficial Nuxt module for Builder.io, a visual headless CMS.
295 lines (289 loc) • 9.99 kB
JavaScript
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 };