Skip to content

Commit

Permalink
feat: generate single file build with module
Browse files Browse the repository at this point in the history
  • Loading branch information
IonianPlayboy committed Jan 27, 2024
1 parent 94c04d7 commit 8038092
Show file tree
Hide file tree
Showing 9 changed files with 200 additions and 11 deletions.
62 changes: 56 additions & 6 deletions 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<ModuleOptions>({
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"));
},
});
22 changes: 22 additions & 0 deletions 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(`<script[^>]*? src="[./]*(.+)"[^>]*></script>`, "g"),
style: new RegExp(`<link[^>]*? href="[./]*(.+)"[^>]*?>`, "g"),
} as const;

export type InlinedTagType = keyof typeof regexesPerInlinedType;

export const inlinedTagTypes = Object.keys(
regexesPerInlinedType,
) as Array<InlinedTagType>;

export type InlineContentForTagsType = {
baseHtml: string;
pathToFiles: string;
type: InlinedTagType;
};
22 changes: 22 additions & 0 deletions 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);
});
});
5 changes: 0 additions & 5 deletions src/runtime/plugin.ts

This file was deleted.

9 changes: 9 additions & 0 deletions 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}</${type}>`;
};
22 changes: 22 additions & 0 deletions 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;
};
31 changes: 31 additions & 0 deletions 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 };
}),
);
};
34 changes: 34 additions & 0 deletions 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,
);
}),
);
4 changes: 4 additions & 0 deletions src/runtime/utils/index.ts
@@ -0,0 +1,4 @@
export * from "./generateSingleFileRenderContext";
export * from "./generateInlinedStringForTag";
export * from "./getInlinedHeadContent";
export * from "./getInlineContentForTagsType";

0 comments on commit 8038092

Please sign in to comment.