diff --git a/src/module.ts b/src/module.ts index 35ca24c..011d221 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,19 +1,69 @@ -import { defineNuxtModule, addPlugin, createResolver } from "@nuxt/kit"; +import { defineNuxtModule, addServerPlugin, createResolver } from "@nuxt/kit"; +import { RUNTIME_BUILD_DIR_KEY } from "./runtime/constants"; // Module options TypeScript interface definition -export interface ModuleOptions {} +export interface ModuleOptions { + buildDirPath?: string; +} + +export interface ModuleRuntimeConfig { + NUXTSINGLEFILE_BUILD_DIR_PATH: string; +} export default defineNuxtModule({ meta: { - name: "my-module", - configKey: "myModule", + name: "nuxt-singlefile", + configKey: "nuxtSingleFile", }, // Default configuration options of the Nuxt module defaults: {}, - setup(options, nuxt) { + hooks: { + "vite:extendConfig": async (config) => { + config.build ||= {}; + config.build.rollupOptions ||= {}; + config.build.rollupOptions.output ||= {}; + // output typing from rollup allows array, but it should never be relevant + if (Array.isArray(config.build.rollupOptions.output)) + return null as never; + // Disable code splitting + config.build.rollupOptions.output.inlineDynamicImports = true; + }, + "nitro:config": async (config) => { + // we only want to prerender the index page + // so the static preset is the best option + config.preset = "static"; + config.prerender ||= {}; + config.prerender.ignore ||= []; + // the 200 and 404 pages are rendered by default in the static preset + // so we need to ignore them + config.prerender.ignore.push("/200", "/404"); + }, + "build:manifest": async (manifest) => { + Object.entries(manifest).forEach(([key, value]) => { + manifest[key] = { + ...value, + // Disable preloading since we are inlining the code + preload: false, + }; + }); + }, + }, + setup({ buildDirPath }, nuxt) { const resolver = createResolver(import.meta.url); + // Disable SSR + nuxt.options.ssr = false; + + // Set the router in hash mode for client only routing + nuxt.options.router.options.hashMode = true; + + // Disable the app manifest since we won't have a server to handle the get request + nuxt.options.experimental.appManifest = false; + + // Provide the build dir path to the runtime config for the nitro plugin + nuxt.options.runtimeConfig[RUNTIME_BUILD_DIR_KEY] = + buildDirPath ?? nuxt.options.buildDir; // Do not add the extension since the `.ts` will be transpiled to `.mjs` after `npm run prepack` - addPlugin(resolver.resolve("./runtime/plugin")); + addServerPlugin(resolver.resolve("./runtime/nitro-plugin")); }, }); diff --git a/src/runtime/constants/index.ts b/src/runtime/constants/index.ts new file mode 100644 index 0000000..1d4d110 --- /dev/null +++ b/src/runtime/constants/index.ts @@ -0,0 +1,22 @@ +import type { ModuleRuntimeConfig } from "../../module"; + +export const RUNTIME_BUILD_DIR_KEY: keyof ModuleRuntimeConfig = + "NUXTSINGLEFILE_BUILD_DIR_PATH"; + +// the capture group is the path to the file, relative to the public dir +export const regexesPerInlinedType = { + script: new RegExp(`]*? src="[./]*(.+)"[^>]*>`, "g"), + style: new RegExp(`]*? href="[./]*(.+)"[^>]*?>`, "g"), +} as const; + +export type InlinedTagType = keyof typeof regexesPerInlinedType; + +export const inlinedTagTypes = Object.keys( + regexesPerInlinedType, +) as Array; + +export type InlineContentForTagsType = { + baseHtml: string; + pathToFiles: string; + type: InlinedTagType; +}; diff --git a/src/runtime/nitro-plugin.ts b/src/runtime/nitro-plugin.ts new file mode 100644 index 0000000..372985e --- /dev/null +++ b/src/runtime/nitro-plugin.ts @@ -0,0 +1,22 @@ +import type { ModuleRuntimeConfig } from "../module"; +import { RUNTIME_BUILD_DIR_KEY } from "./constants"; +import { generateSingleFileRenderContext } from "./utils"; + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook("render:html", async (response) => { + if (process.env.NODE_ENV !== "prerender") return; + + const runtimeConfig = useRuntimeConfig(); + // TODO: improve runtime config typing + const buildDir = runtimeConfig[ + RUNTIME_BUILD_DIR_KEY + ] as ModuleRuntimeConfig[typeof RUNTIME_BUILD_DIR_KEY]; + + const generatedContext = await generateSingleFileRenderContext( + response, + buildDir, + ); + + Object.assign(response, generatedContext); + }); +}); diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts deleted file mode 100644 index b202ae4..0000000 --- a/src/runtime/plugin.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { defineNuxtPlugin } from "#app"; - -export default defineNuxtPlugin((nuxtApp) => { - console.log("Plugin injected by my-module!"); -}); diff --git a/src/runtime/utils/generateInlinedStringForTag.ts b/src/runtime/utils/generateInlinedStringForTag.ts new file mode 100644 index 0000000..9cdec0c --- /dev/null +++ b/src/runtime/utils/generateInlinedStringForTag.ts @@ -0,0 +1,9 @@ +import type { InlinedTagType } from "../constants"; + +export const generateInlinedStringForTag = ( + content: string, + type: InlinedTagType, +) => { + const attributes = type === "script" ? ' type="module"' : ""; + return `<${type}${attributes}>${content}`; +}; diff --git a/src/runtime/utils/generateSingleFileRenderContext.ts b/src/runtime/utils/generateSingleFileRenderContext.ts new file mode 100644 index 0000000..3b6b43b --- /dev/null +++ b/src/runtime/utils/generateSingleFileRenderContext.ts @@ -0,0 +1,22 @@ +import type { NuxtRenderHTMLContext } from "nuxt/dist/core/runtime/nitro/renderer"; + +import { resolve } from "node:path"; +import { getInlinedHeadContent } from "./index"; + +export const generateSingleFileRenderContext = async ( + baseHtmlRenderContext: NuxtRenderHTMLContext, + buildDir: string, +) => { + const pathToFiles = resolve(buildDir, "./dist/client"); + + const inlinedHead = await getInlinedHeadContent( + baseHtmlRenderContext.head, + pathToFiles, + ); + + const result: NuxtRenderHTMLContext = { + ...baseHtmlRenderContext, + head: inlinedHead, + }; + return result; +}; diff --git a/src/runtime/utils/getInlineContentForTagsType.ts b/src/runtime/utils/getInlineContentForTagsType.ts new file mode 100644 index 0000000..2afaf22 --- /dev/null +++ b/src/runtime/utils/getInlineContentForTagsType.ts @@ -0,0 +1,31 @@ +import { resolve } from "node:path"; +import { readFile } from "node:fs/promises"; +import { + regexesPerInlinedType, + type InlineContentForTagsType, +} from "../constants"; + +export const getInlineContentForTagsType = async ({ + baseHtml, + pathToFiles, + type, +}: InlineContentForTagsType) => { + if (!regexesPerInlinedType[type]) + return Promise.reject(`invalid type: ${type}`); + + const regex = regexesPerInlinedType[type]; + + const matchedTags = [...baseHtml.matchAll(regex)]; + + return Promise.all( + matchedTags.map(async (match) => { + // the first element is the full match, the second is the capture group + const [tagString, publicPath] = match; + + const filePath = resolve(pathToFiles, `./${publicPath}`); + const content = await readFile(filePath, "utf8"); + + return { tagString, content, type }; + }), + ); +}; diff --git a/src/runtime/utils/getInlinedHeadContent.ts b/src/runtime/utils/getInlinedHeadContent.ts new file mode 100644 index 0000000..a06f1f2 --- /dev/null +++ b/src/runtime/utils/getInlinedHeadContent.ts @@ -0,0 +1,34 @@ +import type { NuxtRenderHTMLContext } from "nuxt/dist/core/runtime/nitro/renderer"; + +import { inlinedTagTypes } from "../constants"; +import { + generateInlinedStringForTag, + getInlineContentForTagsType, +} from "./index"; + +export const getInlinedHeadContent = async ( + head: NuxtRenderHTMLContext["head"], + pathToFiles: string, +) => + Promise.all( + head.map(async (baseHeadHtml) => { + const inlinedTagsByType = await Promise.all( + inlinedTagTypes.map((type) => + getInlineContentForTagsType({ + baseHtml: baseHeadHtml, + pathToFiles, + type, + }), + ), + ); + return inlinedTagsByType + .flat() + .reduce( + (html, { tagString, content, type }) => + html.replace(tagString, () => + generateInlinedStringForTag(content, type), + ), + baseHeadHtml, + ); + }), + ); diff --git a/src/runtime/utils/index.ts b/src/runtime/utils/index.ts new file mode 100644 index 0000000..9322a80 --- /dev/null +++ b/src/runtime/utils/index.ts @@ -0,0 +1,4 @@ +export * from "./generateSingleFileRenderContext"; +export * from "./generateInlinedStringForTag"; +export * from "./getInlinedHeadContent"; +export * from "./getInlineContentForTagsType";