vite-plugin-html-pages 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts ADDED
@@ -0,0 +1,272 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { pathToFileURL } from 'node:url';
4
+ import pLimit from 'p-limit';
5
+ import type { Plugin, ViteDevServer } from 'vite';
6
+
7
+ import { discoverEntryPages } from './discover';
8
+ import { installDevServer } from './dev-server';
9
+ import { buildPageIndex } from './page-index';
10
+ import { renderPage } from './render-runtime';
11
+
12
+ import type { HtPageInfo, HtPageModule, HtPagesPluginOptions } from './types';
13
+ import {
14
+ PLUGIN_NAME,
15
+ VIRTUAL_BUILD_ENTRY_ID,
16
+ } from './constants';
17
+
18
+ function chunkArray<T>(items: T[], size: number): T[][] {
19
+ const out: T[][] = [];
20
+ for (let i = 0; i < items.length; i += size) {
21
+ out.push(items.slice(i, i + size));
22
+ }
23
+ return out;
24
+ }
25
+
26
+ async function importPageModule(entryPath: string): Promise<HtPageModule> {
27
+ const mod = await import(pathToFileURL(entryPath).href + `?t=${Date.now()}`);
28
+ return mod as HtPageModule;
29
+ }
30
+
31
+ async function warnIfNotEsm(root: string): Promise<void> {
32
+ try {
33
+ const pkgPath = path.join(root, 'package.json');
34
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
35
+
36
+ if (pkg.type !== 'module') {
37
+ console.warn(
38
+ `[${PLUGIN_NAME}] Tip: add "type": "module" to package.json to avoid Node ESM warnings.`,
39
+ );
40
+ }
41
+ } catch {
42
+ // ignore
43
+ }
44
+ }
45
+
46
+ export function htPages(options: HtPagesPluginOptions = {}): Plugin {
47
+ let root = process.cwd();
48
+ let server: ViteDevServer | null = null;
49
+ let devPages: HtPageInfo[] = [];
50
+
51
+ const cleanUrls = options.cleanUrls ?? true;
52
+
53
+ function logDebug(enabled: boolean | undefined, ...args: unknown[]) {
54
+ if (!enabled) return;
55
+ console.log(`[${PLUGIN_NAME}]`, ...args);
56
+ }
57
+
58
+ async function loadDevPages(): Promise<HtPageInfo[]> {
59
+ const entries = await discoverEntryPages(root, options);
60
+ const modulesByEntry = new Map<string, HtPageModule>();
61
+
62
+ logDebug(
63
+ options.debug,
64
+ 'discovered entries',
65
+ entries.map((e) => e.relativePath),
66
+ );
67
+
68
+ if (!server) return [];
69
+
70
+ for (const entry of entries) {
71
+ const mod = (await server.ssrLoadModule(
72
+ `/${entry.relativePath}`,
73
+ )) as HtPageModule;
74
+
75
+ modulesByEntry.set(entry.entryPath, mod);
76
+ }
77
+
78
+ devPages = await buildPageIndex({
79
+ entries,
80
+ modulesByEntry,
81
+ cleanUrls,
82
+ });
83
+
84
+ logDebug(
85
+ options.debug,
86
+ 'dev pages',
87
+ devPages.map((p) => `${p.routePath} -> ${p.relativePath}`),
88
+ );
89
+
90
+ return devPages;
91
+ }
92
+
93
+ async function buildPagesPipeline() {
94
+ const entries = await discoverEntryPages(root, options);
95
+ const modulesByEntry = new Map<string, HtPageModule>();
96
+
97
+ for (const entry of entries) {
98
+ const mod = await importPageModule(entry.entryPath);
99
+ modulesByEntry.set(entry.entryPath, mod);
100
+ }
101
+
102
+ const pages = await buildPageIndex({
103
+ entries,
104
+ modulesByEntry,
105
+ cleanUrls,
106
+ });
107
+
108
+ return { entries, modulesByEntry, pages };
109
+ }
110
+
111
+ return {
112
+ name: PLUGIN_NAME,
113
+
114
+ config(userConfig, env) {
115
+ if (env.command !== 'build') return;
116
+
117
+ const hasExplicitInput = userConfig.build?.rollupOptions?.input != null;
118
+ if (hasExplicitInput) return;
119
+
120
+ return {
121
+ build: {
122
+ rollupOptions: {
123
+ input: VIRTUAL_BUILD_ENTRY_ID,
124
+ },
125
+ },
126
+ };
127
+ },
128
+
129
+ resolveId(id) {
130
+ if (id === VIRTUAL_BUILD_ENTRY_ID) return id;
131
+ return null;
132
+ },
133
+
134
+ load(id) {
135
+ if (id === VIRTUAL_BUILD_ENTRY_ID) {
136
+ return 'export default {};';
137
+ }
138
+ return null;
139
+ },
140
+
141
+ configResolved(resolved) {
142
+ root = resolved.root;
143
+ void warnIfNotEsm(root);
144
+ },
145
+
146
+ async buildStart() {
147
+ const entries = await discoverEntryPages(root, options);
148
+
149
+ for (const entry of entries) {
150
+ this.addWatchFile(entry.entryPath);
151
+ }
152
+ },
153
+
154
+ configureServer(_server) {
155
+ server = _server;
156
+
157
+ installDevServer({
158
+ server,
159
+ getPages: async () => {
160
+ if (devPages.length > 0) return devPages;
161
+ return loadDevPages();
162
+ },
163
+ getEntries: async () => discoverEntryPages(root, options),
164
+ });
165
+
166
+ loadDevPages().catch((error) => {
167
+ server?.config.logger.error(
168
+ `[${PLUGIN_NAME}] loadDevPages failed: ${
169
+ error instanceof Error ? error.stack ?? error.message : String(error)
170
+ }`,
171
+ );
172
+ });
173
+ },
174
+
175
+ async handleHotUpdate(ctx) {
176
+ if (!server) return;
177
+
178
+ if (!ctx.file.endsWith('.ht.js')) {
179
+ return;
180
+ }
181
+
182
+ logDebug(options.debug, 'page updated', ctx.file);
183
+
184
+ await loadDevPages();
185
+ return undefined;
186
+ },
187
+
188
+ async generateBundle(_, bundle) {
189
+ const { modulesByEntry, pages } = await buildPagesPipeline();
190
+
191
+ logDebug(
192
+ options.debug,
193
+ 'emitting pages',
194
+ pages.map((p) => p.fileName),
195
+ );
196
+
197
+ const limit = pLimit(options.renderConcurrency ?? 8);
198
+ const batchSize =
199
+ options.renderBatchSize ??
200
+ Math.max(options.renderConcurrency ?? 8, 32);
201
+
202
+ for (const batch of chunkArray(pages, batchSize)) {
203
+ await Promise.all(
204
+ batch.map((page) =>
205
+ limit(async () => {
206
+ const mod = modulesByEntry.get(page.entryPath);
207
+ if (!mod) {
208
+ throw new Error(
209
+ `[${PLUGIN_NAME}] Missing module for page entry: ${page.entryPath}`,
210
+ );
211
+ }
212
+
213
+ const html = await renderPage(page, mod, false);
214
+
215
+ this.emitFile({
216
+ type: 'asset',
217
+ fileName: options.mapOutputPath?.(page) ?? page.fileName,
218
+ source: html,
219
+ });
220
+ }),
221
+ ),
222
+ );
223
+ }
224
+
225
+ const sitemapBase = options.site ?? '';
226
+ const sitemapRoutes = [...new Set(pages.map((p) => p.routePath))].filter(
227
+ (route) => !route.includes(':') && !route.includes('*'),
228
+ );
229
+
230
+ if (sitemapRoutes.length > 0) {
231
+ const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapRoutes
232
+ .map((route) => ` <url><loc>${sitemapBase}${route}</loc></url>`)
233
+ .join('\n')}\n</urlset>\n`;
234
+
235
+ this.emitFile({
236
+ type: 'asset',
237
+ fileName: 'sitemap.xml',
238
+ source: sitemap,
239
+ });
240
+ }
241
+
242
+ if (options.rss?.site) {
243
+ const routePrefix = options.rss.routePrefix ?? '/blog';
244
+
245
+ const rssItems = pages
246
+ .filter((page) => page.routePath.startsWith(routePrefix))
247
+ .map((page) => {
248
+ const url = `${options.rss!.site}${page.routePath}`;
249
+ return ` <item>\n <title>${page.routePath}</title>\n <link>${url}</link>\n <guid>${url}</guid>\n </item>`;
250
+ })
251
+ .join('\n');
252
+
253
+ const rss = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0">\n<channel>\n <title>${options.rss.title ?? PLUGIN_NAME}</title>\n <link>${options.rss.site}</link>\n <description>${options.rss.description ?? 'RSS feed'}</description>\n${rssItems}\n</channel>\n</rss>\n`;
254
+
255
+ this.emitFile({
256
+ type: 'asset',
257
+ fileName: 'rss.xml',
258
+ source: rss,
259
+ });
260
+ }
261
+
262
+ for (const [fileName, output] of Object.entries(bundle)) {
263
+ if (
264
+ output.type === 'chunk' &&
265
+ output.facadeModuleId === VIRTUAL_BUILD_ENTRY_ID
266
+ ) {
267
+ delete bundle[fileName];
268
+ }
269
+ }
270
+ },
271
+ };
272
+ }
@@ -0,0 +1,92 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import type { Plugin as RollupPlugin } from 'rollup';
5
+
6
+ import { createManifestModule } from './manifest';
7
+ import type { HtPageInfo } from './types';
8
+ import { PLUGIN_NAME, VIRTUAL_MANIFEST_ID } from './constants';
9
+
10
+ async function createRenderBundleHash(
11
+ entries: HtPageInfo[],
12
+ manifestSource: string,
13
+ ): Promise<string> {
14
+ const hash = createHash('sha256');
15
+ hash.update(manifestSource);
16
+
17
+ for (const entry of entries) {
18
+ hash.update(entry.entryPath);
19
+ const source = await fs.readFile(entry.entryPath, 'utf8');
20
+ hash.update(source);
21
+ }
22
+
23
+ return hash.digest('hex').slice(0, 12);
24
+ }
25
+
26
+ export async function buildRenderBundle(args: {
27
+ entries: HtPageInfo[];
28
+ cacheDir: string;
29
+ ssrPlugins?: RollupPlugin[];
30
+ }): Promise<string> {
31
+ const { entries, cacheDir, ssrPlugins = [] } = args;
32
+
33
+ const [{ rollup }, { nodeResolve }] = await Promise.all([
34
+ import('rollup'),
35
+ import('@rollup/plugin-node-resolve'),
36
+ ]);
37
+
38
+ const manifestSource = createManifestModule(entries);
39
+ const hash = await createRenderBundleHash(entries, manifestSource);
40
+ const bundlePath = path.join(cacheDir, `render-${hash}.mjs`);
41
+
42
+ await fs.mkdir(cacheDir, { recursive: true });
43
+
44
+ try {
45
+ await fs.access(bundlePath);
46
+ return bundlePath;
47
+ } catch {
48
+ // cache miss
49
+ }
50
+
51
+ const bundle = await rollup({
52
+ input: VIRTUAL_MANIFEST_ID,
53
+ plugins: [
54
+ {
55
+ name: `${PLUGIN_NAME}:virtual-manifest`,
56
+ resolveId(id) {
57
+ return id === VIRTUAL_MANIFEST_ID ? id : null;
58
+ },
59
+ load(id) {
60
+ return id === VIRTUAL_MANIFEST_ID ? manifestSource : null;
61
+ },
62
+ },
63
+ nodeResolve({
64
+ preferBuiltins: true,
65
+ exportConditions: ['node'],
66
+ }),
67
+ ...ssrPlugins,
68
+ ],
69
+ treeshake: true,
70
+ });
71
+
72
+ try {
73
+ const { output } = await bundle.generate({
74
+ format: 'esm',
75
+ exports: 'named',
76
+ inlineDynamicImports: true,
77
+ });
78
+
79
+ const chunk = output.find((item) => item.type === 'chunk');
80
+
81
+ if (!chunk || chunk.type !== 'chunk') {
82
+ throw new Error(
83
+ `[${PLUGIN_NAME}] Failed to generate HT pages render bundle.`,
84
+ );
85
+ }
86
+
87
+ await fs.writeFile(bundlePath, chunk.code, 'utf8');
88
+ return bundlePath;
89
+ } finally {
90
+ await bundle.close();
91
+ }
92
+ }
@@ -0,0 +1,36 @@
1
+ import { invalidHtmlReturn, pageError, missingDefaultExport } from './errors';
2
+ import type { HtPageInfo, HtPageModule, HtPageRenderContext } from './types';
3
+
4
+ export async function renderPage(
5
+ page: HtPageInfo,
6
+ mod: HtPageModule,
7
+ dev = false,
8
+ ): Promise<string> {
9
+ const ctx: HtPageRenderContext = {
10
+ page,
11
+ params: page.params,
12
+ dev,
13
+ };
14
+
15
+ try {
16
+ if (typeof mod.data === 'function') {
17
+ ctx.data = await mod.data(ctx);
18
+ }
19
+
20
+ const entry = mod.default;
21
+
22
+ if (entry == null) {
23
+ throw missingDefaultExport(page);
24
+ }
25
+
26
+ const html = typeof entry === 'function' ? await entry(ctx) : entry;
27
+
28
+ if (typeof html !== 'string') {
29
+ throw invalidHtmlReturn(page, html);
30
+ }
31
+
32
+ return html;
33
+ } catch (error) {
34
+ throw pageError(page, error);
35
+ }
36
+ }
@@ -0,0 +1,182 @@
1
+ import { normalizeRoutePath, stripHtSuffix, toPosix } from './path-utils';
2
+ import type { HtPageInfo, StaticParamRecord } from './types';
3
+
4
+ function safeDecodeURIComponent(str: string): string {
5
+ try {
6
+ return decodeURIComponent(str);
7
+ } catch {
8
+ return str;
9
+ }
10
+ }
11
+
12
+ const DYNAMIC_SEGMENT_RE = /\[([A-Za-z0-9_]+)\]/g;
13
+ const CATCH_ALL_SEGMENT_RE = /\[\.\.\.([A-Za-z0-9_]+)\]/g;
14
+ const OPTIONAL_CATCH_ALL_SEGMENT_RE = /\[\.\.\.([A-Za-z0-9_]+)\]\?/g;
15
+ const ANY_PARAM_RE = /\[(?:\.\.\.)?([A-Za-z0-9_]+)\]\??/g;
16
+ const ROUTE_GROUP_RE = /(^|\/)\(([^)]+)\)(?=\/|$)/g;
17
+
18
+ export function getParamNames(relativeFromPagesDir: string): string[] {
19
+ return [...relativeFromPagesDir.matchAll(ANY_PARAM_RE)].map((m) => m[1]);
20
+ }
21
+
22
+ export function isDynamicPage(relativeFromPagesDir: string): boolean {
23
+ return /\[(?:\.\.\.)?[A-Za-z0-9_]+\]\??/.test(relativeFromPagesDir);
24
+ }
25
+
26
+ export function toRoutePattern(relativeFromPagesDir: string): string {
27
+ const noExt = stripHtSuffix(toPosix(relativeFromPagesDir));
28
+
29
+ const withoutGroups = noExt.replace(ROUTE_GROUP_RE, '$1');
30
+ const withoutIndex = withoutGroups.replace(/\/index$/i, '').replace(/^index$/i, '');
31
+
32
+ const raw = withoutIndex
33
+ .replace(OPTIONAL_CATCH_ALL_SEGMENT_RE, '*?:$1')
34
+ .replace(CATCH_ALL_SEGMENT_RE, '*:$1')
35
+ .replace(DYNAMIC_SEGMENT_RE, ':$1');
36
+
37
+ return normalizeRoutePath(raw || '/');
38
+ }
39
+
40
+ export function fillParams(
41
+ pattern: string,
42
+ params: Record<string, string>,
43
+ ): string {
44
+ const result = pattern
45
+ .replace(/\*\?:([A-Za-z0-9_]+)/g, (_, key) => {
46
+ const value = params[key];
47
+ if (value == null || value === '') {
48
+ return '';
49
+ }
50
+
51
+ return String(value)
52
+ .split('/')
53
+ .map((part) => encodeURIComponent(part))
54
+ .join('/');
55
+ })
56
+ .replace(/\*:([A-Za-z0-9_]+)/g, (_, key) => {
57
+ if (!(key in params)) {
58
+ throw new Error(`Missing catch-all route param "${key}"`);
59
+ }
60
+
61
+ return String(params[key])
62
+ .split('/')
63
+ .map((part) => encodeURIComponent(part))
64
+ .join('/');
65
+ })
66
+ .replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
67
+ if (!(key in params)) {
68
+ throw new Error(`Missing route param "${key}"`);
69
+ }
70
+
71
+ return encodeURIComponent(params[key]);
72
+ });
73
+
74
+ return normalizeRoutePath(result || '/');
75
+ }
76
+
77
+ export function fileNameFromRoute(
78
+ routePath: string,
79
+ cleanUrls: boolean,
80
+ ): string {
81
+ const normalized = normalizeRoutePath(routePath);
82
+
83
+ if (normalized === '/') return 'index.html';
84
+
85
+ const base = normalized.slice(1);
86
+ return cleanUrls ? `${base}/index.html` : `${base}.html`;
87
+ }
88
+
89
+ export function expandStaticPaths(
90
+ basePage: Omit<HtPageInfo, 'routePath' | 'fileName' | 'params'>,
91
+ rows: StaticParamRecord[],
92
+ cleanUrls: boolean,
93
+ ): HtPageInfo[] {
94
+ return rows.map((row) => {
95
+ const params = Object.fromEntries(
96
+ Object.entries(row).map(([k, v]) => [k, String(v)]),
97
+ );
98
+
99
+ const routePath = fillParams(basePage.routePattern, params);
100
+
101
+ return {
102
+ ...basePage,
103
+ routePath,
104
+ fileName: fileNameFromRoute(routePath, cleanUrls),
105
+ params,
106
+ };
107
+ });
108
+ }
109
+
110
+ export function routeMatch(
111
+ pattern: string,
112
+ urlPath: string,
113
+ ): Record<string, string> | null {
114
+ const a = normalizeRoutePath(pattern).split('/').filter(Boolean);
115
+ const b = normalizeRoutePath(urlPath).split('/').filter(Boolean);
116
+ const params: Record<string, string> = {};
117
+
118
+ for (let i = 0; i < a.length; i++) {
119
+ const patternSeg = a[i];
120
+ const urlSeg = b[i];
121
+
122
+ if (patternSeg.startsWith('*?:')) {
123
+ params[patternSeg.slice(3)] =
124
+ i < b.length ? b.slice(i).map(safeDecodeURIComponent).join('/') : '';
125
+ return params;
126
+ }
127
+
128
+ if (patternSeg.startsWith('*:')) {
129
+ const rest = b.slice(i);
130
+ if (rest.length === 0) return null;
131
+
132
+ params[patternSeg.slice(2)] = rest.map(safeDecodeURIComponent).join('/');
133
+ return params;
134
+ }
135
+
136
+ if (!urlSeg) return null;
137
+
138
+ if (patternSeg.startsWith(':')) {
139
+ params[patternSeg.slice(1)] = safeDecodeURIComponent(urlSeg);
140
+ continue;
141
+ }
142
+
143
+ if (patternSeg !== urlSeg) return null;
144
+ }
145
+
146
+ return a.length === b.length ? params : null;
147
+ }
148
+
149
+ export function compareRoutePriority(a: string, b: string): number {
150
+ const aSegs = normalizeRoutePath(a).split('/').filter(Boolean);
151
+ const bSegs = normalizeRoutePath(b).split('/').filter(Boolean);
152
+ const len = Math.max(aSegs.length, bSegs.length);
153
+
154
+ for (let i = 0; i < len; i++) {
155
+ const aa = aSegs[i];
156
+ const bb = bSegs[i];
157
+
158
+ if (aa == null) return 1;
159
+ if (bb == null) return -1;
160
+
161
+ const aOptionalCatchAll = aa.startsWith('*?:');
162
+ const bOptionalCatchAll = bb.startsWith('*?:');
163
+ if (aOptionalCatchAll !== bOptionalCatchAll) {
164
+ return aOptionalCatchAll ? 1 : -1;
165
+ }
166
+
167
+ const aCatchAll = aa.startsWith('*:');
168
+ const bCatchAll = bb.startsWith('*:');
169
+ if (aCatchAll !== bCatchAll) {
170
+ return aCatchAll ? 1 : -1;
171
+ }
172
+
173
+ const aDynamic = aa.startsWith(':');
174
+ const bDynamic = bb.startsWith(':');
175
+ if (aDynamic !== bDynamic) {
176
+ return aDynamic ? 1 : -1;
177
+ }
178
+ }
179
+
180
+ // More specific / longer routes first when otherwise equal
181
+ return bSegs.length - aSegs.length;
182
+ }
package/src/types.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { Plugin as RollupPlugin } from 'rollup';
2
+
3
+ export interface StaticParamRecord {
4
+ [key: string]: string | number | boolean;
5
+ }
6
+
7
+ export interface HtPageInfo {
8
+ id: string;
9
+ entryPath: string;
10
+ absolutePath: string;
11
+ relativePath: string;
12
+ routePattern: string;
13
+ routePath: string;
14
+ fileName: string;
15
+ dynamic: boolean;
16
+ paramNames: string[];
17
+ params: Record<string, string>;
18
+ }
19
+
20
+ export interface HtPageRenderContext {
21
+ page: HtPageInfo;
22
+ params: Record<string, string>;
23
+ data?: unknown;
24
+ dev: boolean;
25
+ }
26
+
27
+ export interface HtPageModule {
28
+ default?:
29
+ | string
30
+ | ((ctx: HtPageRenderContext) => string | Promise<string>);
31
+ data?: (ctx: HtPageRenderContext) => unknown | Promise<unknown>;
32
+ generateStaticParams?: () =>
33
+ | StaticParamRecord[]
34
+ | Promise<StaticParamRecord[]>;
35
+ dynamic?: boolean;
36
+ prerender?: boolean;
37
+ }
38
+
39
+ export interface HtPagesPluginOptions {
40
+ root?: string;
41
+ include?: string | string[];
42
+ exclude?: string | string[];
43
+ pagesDir?: string;
44
+ renderConcurrency?: number;
45
+ renderBatchSize?: number;
46
+ cleanUrls?: boolean;
47
+ ssrPlugins?: RollupPlugin[];
48
+ mapOutputPath?: (page: HtPageInfo) => string;
49
+ debug?: boolean;
50
+ site?: string;
51
+ rss?: {
52
+ site: string;
53
+ title?: string;
54
+ description?: string;
55
+ routePrefix?: string;
56
+ };
57
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "declaration": true,
8
+ "outDir": "dist",
9
+ "skipLibCheck": true,
10
+ "esModuleInterop": true,
11
+ "types": ["node"]
12
+ },
13
+ "include": ["src"]
14
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm'],
6
+ dts: true,
7
+ sourcemap: true,
8
+ clean: true,
9
+ target: 'node18',
10
+ splitting: false,
11
+ external: [
12
+ 'vite',
13
+ 'fast-glob',
14
+ 'p-limit'
15
+ ],
16
+ });