@pagepocket/single-file-unit 0.8.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.
Files changed (35) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +1 -0
  3. package/dist/single-file/build-single-file-files.d.ts +14 -0
  4. package/dist/single-file/build-single-file-files.js +57 -0
  5. package/dist/single-file/collect-bytes.d.ts +2 -0
  6. package/dist/single-file/collect-bytes.js +27 -0
  7. package/dist/single-file/embed-resources.d.ts +11 -0
  8. package/dist/single-file/embed-resources.js +54 -0
  9. package/dist/single-file/inline-path-url.d.ts +9 -0
  10. package/dist/single-file/inline-path-url.js +31 -0
  11. package/dist/single-file/oversize-behavior.d.ts +7 -0
  12. package/dist/single-file/oversize-behavior.js +15 -0
  13. package/dist/single-file/read-snapshot.d.ts +24 -0
  14. package/dist/single-file/read-snapshot.js +49 -0
  15. package/dist/single-file/rewrite-index-html.d.ts +13 -0
  16. package/dist/single-file/rewrite-index-html.js +213 -0
  17. package/dist/single-file-plugin.d.ts +28 -0
  18. package/dist/single-file-plugin.js +69 -0
  19. package/dist/single-file-unit.d.ts +17 -0
  20. package/dist/single-file-unit.js +20 -0
  21. package/dist/utils/decode-utf8-or-null.d.ts +1 -0
  22. package/dist/utils/decode-utf8-or-null.js +5 -0
  23. package/dist/utils/file-tree.d.ts +5 -0
  24. package/dist/utils/file-tree.js +28 -0
  25. package/dist/utils/oversize.d.ts +16 -0
  26. package/dist/utils/oversize.js +25 -0
  27. package/dist/utils/placeholder-svg.d.ts +1 -0
  28. package/dist/utils/placeholder-svg.js +5 -0
  29. package/dist/utils/should-skip-css-value.d.ts +1 -0
  30. package/dist/utils/should-skip-css-value.js +10 -0
  31. package/dist/utils/stream-to-uint8array.d.ts +1 -0
  32. package/dist/utils/stream-to-uint8array.js +22 -0
  33. package/dist/utils/to-data-url.d.ts +1 -0
  34. package/dist/utils/to-data-url.js +5 -0
  35. package/package.json +31 -0
@@ -0,0 +1,2 @@
1
+ export { SingleFileUnit } from "./single-file-unit.js";
2
+ export type { SingleFileUnitOptions } from "./single-file-unit.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { SingleFileUnit } from "./single-file-unit.js";
@@ -0,0 +1,14 @@
1
+ import type { FileTree } from "@pagepocket/lib";
2
+ export type SingleFileUnitOptions = {
3
+ maxInlineBytes?: number;
4
+ oversize?: {
5
+ script?: "error" | "placeholder";
6
+ stylesheet?: "error" | "placeholder";
7
+ image?: "error" | "placeholder";
8
+ media?: "error" | "placeholder";
9
+ };
10
+ };
11
+ export declare const buildSingleFileFiles: (input: {
12
+ files: FileTree;
13
+ options?: SingleFileUnitOptions;
14
+ }) => Promise<FileTree>;
@@ -0,0 +1,57 @@
1
+ import { defaultOversizeBehavior } from "../utils/oversize.js";
2
+ import { collectSnapshotBytesByPath } from "./collect-bytes.js";
3
+ import { embedResourcesPathData } from "./embed-resources.js";
4
+ import { createInlinePathUrl } from "./inline-path-url.js";
5
+ import { createOversizeBehavior } from "./oversize-behavior.js";
6
+ import { readIndexHtml, readOptionalApiSnapshot, readResourcesPathSnapshot } from "./read-snapshot.js";
7
+ import { rewriteIndexHtmlToSingleFile } from "./rewrite-index-html.js";
8
+ const DEFAULT_MAX_INLINE_BYTES = 25 * 1024 * 1024;
9
+ export const buildSingleFileFiles = async (input) => {
10
+ const maxInlineBytes = input.options?.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;
11
+ const oversize = {
12
+ script: input.options?.oversize?.script ?? defaultOversizeBehavior.script,
13
+ stylesheet: input.options?.oversize?.stylesheet ?? defaultOversizeBehavior.stylesheet,
14
+ image: input.options?.oversize?.image ?? defaultOversizeBehavior.image,
15
+ media: input.options?.oversize?.media ?? defaultOversizeBehavior.media
16
+ };
17
+ const bytesByPath = await collectSnapshotBytesByPath(input.files);
18
+ const indexHtml = readIndexHtml(bytesByPath);
19
+ const apiSnapshot = readOptionalApiSnapshot(bytesByPath);
20
+ const resourcesPath = readResourcesPathSnapshot(bytesByPath);
21
+ const oversizeBehavior = createOversizeBehavior(oversize);
22
+ const inlinePathUrl = createInlinePathUrl({
23
+ bytesByPath,
24
+ maxInlineBytes,
25
+ oversizeBehavior
26
+ });
27
+ await embedResourcesPathData({
28
+ resourcesPath,
29
+ bytesByPath,
30
+ maxInlineBytes,
31
+ oversizeBehavior,
32
+ inlinePathUrl
33
+ });
34
+ const finalHtml = await rewriteIndexHtmlToSingleFile({
35
+ indexHtml,
36
+ bytesByPath,
37
+ resourcesPath,
38
+ apiSnapshot,
39
+ maxInlineBytes,
40
+ oversizeBehavior,
41
+ inlinePathUrl
42
+ });
43
+ return {
44
+ root: {
45
+ kind: "directory",
46
+ path: "",
47
+ entries: [
48
+ {
49
+ kind: "file",
50
+ path: "/index.html",
51
+ source: { kind: "text", text: finalHtml }
52
+ }
53
+ ]
54
+ },
55
+ content: input.files.content
56
+ };
57
+ };
@@ -0,0 +1,2 @@
1
+ import type { FileTree } from "@pagepocket/lib";
2
+ export declare const collectSnapshotBytesByPath: (files: FileTree) => Promise<Map<string, Uint8Array<ArrayBufferLike>>>;
@@ -0,0 +1,27 @@
1
+ import { flattenEntries } from "../utils/file-tree.js";
2
+ import { streamToUint8Array } from "../utils/stream-to-uint8array.js";
3
+ const readSourceBytes = async (files, source) => {
4
+ if (source.kind === "bytes") {
5
+ return source.data;
6
+ }
7
+ if (source.kind === "text") {
8
+ return new TextEncoder().encode(source.text);
9
+ }
10
+ const ref = source.ref;
11
+ if (!files.content) {
12
+ throw new Error("SingleFilePlugin requires ctx.value.files.content to resolve content-ref");
13
+ }
14
+ const stream = await files.content.open(ref);
15
+ return await streamToUint8Array(stream);
16
+ };
17
+ export const collectSnapshotBytesByPath = async (files) => {
18
+ if (!files.root || files.root.kind !== "directory") {
19
+ throw new Error("SingleFilePlugin requires ctx.value.files.root directory");
20
+ }
21
+ const flattened = flattenEntries(files.root, "");
22
+ const bytesByPath = new Map();
23
+ for (const item of flattened) {
24
+ bytesByPath.set(item.path, await readSourceBytes(files, item.file.source));
25
+ }
26
+ return bytesByPath;
27
+ };
@@ -0,0 +1,11 @@
1
+ import type { ResourcesPathSnapshot } from "./read-snapshot.js";
2
+ export declare const embedResourcesPathData: (options: {
3
+ resourcesPath: ResourcesPathSnapshot;
4
+ bytesByPath: Map<string, Uint8Array>;
5
+ maxInlineBytes: number;
6
+ oversizeBehavior: (mimeType?: string) => "error" | "placeholder";
7
+ inlinePathUrl: (raw: string, context: {
8
+ kind: "attr" | "css";
9
+ selector: string;
10
+ }) => string | null;
11
+ }) => Promise<void>;
@@ -0,0 +1,54 @@
1
+ import { rewriteCssText } from "@pagepocket/lib";
2
+ import { bytesToBase64 } from "@pagepocket/shared";
3
+ import { decodeUtf8OrNull } from "../utils/decode-utf8-or-null.js";
4
+ import { shouldSkipCssValue } from "../utils/should-skip-css-value.js";
5
+ export const embedResourcesPathData = async (options) => {
6
+ const { resourcesPath, bytesByPath, maxInlineBytes, oversizeBehavior, inlinePathUrl } = options;
7
+ for (const item of resourcesPath.items) {
8
+ const path = item && item.path;
9
+ if (!path) {
10
+ continue;
11
+ }
12
+ const bytes = bytesByPath.get(path);
13
+ if (!bytes) {
14
+ continue;
15
+ }
16
+ // If the embedded resource is CSS, inline its url()/@import dependencies so file://
17
+ // replay doesn't attempt to load /... from disk.
18
+ if (item.mimeType === "text/css") {
19
+ const cssText = decodeUtf8OrNull(bytes);
20
+ if (cssText !== null) {
21
+ const rewritten = await rewriteCssText({
22
+ cssText,
23
+ cssUrl: item.path,
24
+ resolveUrl: async (absolute) => {
25
+ if (shouldSkipCssValue(absolute)) {
26
+ return null;
27
+ }
28
+ if (!absolute.startsWith("/")) {
29
+ return null;
30
+ }
31
+ return inlinePathUrl(absolute, {
32
+ kind: "css",
33
+ selector: `resources_path.json:${item.path}`
34
+ });
35
+ }
36
+ });
37
+ const outBytes = new TextEncoder().encode(rewritten);
38
+ item.dataEncoding = "base64";
39
+ item.data = bytesToBase64(outBytes);
40
+ continue;
41
+ }
42
+ }
43
+ if (bytes.byteLength > maxInlineBytes) {
44
+ const behavior = oversizeBehavior(item.mimeType);
45
+ if (behavior === "error") {
46
+ throw new Error(`SingleFilePlugin oversize resource (>${maxInlineBytes} bytes): ${path} (${item.mimeType || "unknown"})`);
47
+ }
48
+ // Skip embedding. HTML rewrite will handle placeholders for /... refs.
49
+ continue;
50
+ }
51
+ item.dataEncoding = "base64";
52
+ item.data = bytesToBase64(bytes);
53
+ }
54
+ };
@@ -0,0 +1,9 @@
1
+ export type InlinePathUrlContext = {
2
+ kind: "attr" | "css";
3
+ selector: string;
4
+ };
5
+ export declare const createInlinePathUrl: (options: {
6
+ bytesByPath: Map<string, Uint8Array>;
7
+ maxInlineBytes: number;
8
+ oversizeBehavior: (mimeType?: string) => "error" | "placeholder";
9
+ }) => (raw: string, context: InlinePathUrlContext) => string | null;
@@ -0,0 +1,31 @@
1
+ import { lookupMimeTypeFromPath } from "@pagepocket/shared";
2
+ import { placeholderSvgDataUrl } from "../utils/placeholder-svg.js";
3
+ import { toDataUrlBase64 } from "../utils/to-data-url.js";
4
+ export const createInlinePathUrl = (options) => {
5
+ const { bytesByPath, maxInlineBytes, oversizeBehavior } = options;
6
+ return (raw, context) => {
7
+ const value = String(raw || "").trim();
8
+ if (!value.startsWith("/")) {
9
+ return null;
10
+ }
11
+ const bytes = bytesByPath.get(value);
12
+ if (!bytes) {
13
+ return null;
14
+ }
15
+ const inferredMime = lookupMimeTypeFromPath(value);
16
+ if (bytes.byteLength > maxInlineBytes) {
17
+ const behavior = oversizeBehavior(inferredMime);
18
+ if (behavior === "error") {
19
+ throw new Error(`SingleFilePlugin oversize ${inferredMime} (> ${maxInlineBytes} bytes) referenced at ${value} from ${context.selector}`);
20
+ }
21
+ if (inferredMime.startsWith("image/")) {
22
+ return placeholderSvgDataUrl(`image omitted: ${value}`);
23
+ }
24
+ if (inferredMime.startsWith("video/") || inferredMime.startsWith("audio/")) {
25
+ return placeholderSvgDataUrl(`media omitted: ${value}`);
26
+ }
27
+ return null;
28
+ }
29
+ return toDataUrlBase64(inferredMime, bytes);
30
+ };
31
+ };
@@ -0,0 +1,7 @@
1
+ export type OversizeConfig = {
2
+ script: "error" | "placeholder";
3
+ stylesheet: "error" | "placeholder";
4
+ image: "error" | "placeholder";
5
+ media: "error" | "placeholder";
6
+ };
7
+ export declare const createOversizeBehavior: (oversize: OversizeConfig) => (mimeType?: string) => "error" | "placeholder";
@@ -0,0 +1,15 @@
1
+ import { parseResourceTypeFromMime } from "../utils/oversize.js";
2
+ export const createOversizeBehavior = (oversize) => {
3
+ return (mimeType) => {
4
+ const type = parseResourceTypeFromMime(mimeType);
5
+ if (type === "script")
6
+ return oversize.script;
7
+ if (type === "stylesheet")
8
+ return oversize.stylesheet;
9
+ if (type === "image")
10
+ return oversize.image;
11
+ if (type === "media")
12
+ return oversize.media;
13
+ return "placeholder";
14
+ };
15
+ };
@@ -0,0 +1,24 @@
1
+ type ResourcesPathSnapshotItem = {
2
+ url: string;
3
+ path: string;
4
+ resourceType?: string;
5
+ mimeType?: string;
6
+ size?: number;
7
+ dataEncoding?: "base64";
8
+ data?: string;
9
+ };
10
+ export type ResourcesPathSnapshot = {
11
+ version: "1.0";
12
+ createdAt: number;
13
+ items: ResourcesPathSnapshotItem[];
14
+ };
15
+ export type ApiSnapshot = {
16
+ version: "1.0";
17
+ url: string;
18
+ createdAt: number;
19
+ records: unknown[];
20
+ };
21
+ export declare const readIndexHtml: (bytesByPath: Map<string, Uint8Array>) => string;
22
+ export declare const readOptionalApiSnapshot: (bytesByPath: Map<string, Uint8Array>) => ApiSnapshot | null;
23
+ export declare const readResourcesPathSnapshot: (bytesByPath: Map<string, Uint8Array>) => ResourcesPathSnapshot;
24
+ export {};
@@ -0,0 +1,49 @@
1
+ import { decodeUtf8OrNull } from "../utils/decode-utf8-or-null.js";
2
+ export const readIndexHtml = (bytesByPath) => {
3
+ const indexPath = "/index.html";
4
+ const indexBytes = bytesByPath.get(indexPath);
5
+ if (!indexBytes) {
6
+ throw new Error(`SingleFilePlugin requires ${indexPath}`);
7
+ }
8
+ const indexHtml = decodeUtf8OrNull(indexBytes);
9
+ if (indexHtml === null) {
10
+ throw new Error("SingleFilePlugin failed to decode index.html as utf-8");
11
+ }
12
+ return indexHtml;
13
+ };
14
+ export const readOptionalApiSnapshot = (bytesByPath) => {
15
+ const apiPath = "/api.json";
16
+ const apiBytes = bytesByPath.get(apiPath);
17
+ const apiJsonText = apiBytes ? decodeUtf8OrNull(apiBytes) : null;
18
+ if (!apiJsonText) {
19
+ return null;
20
+ }
21
+ try {
22
+ return JSON.parse(apiJsonText);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ };
28
+ export const readResourcesPathSnapshot = (bytesByPath) => {
29
+ const resourcesPathPath = "/resources_path.json";
30
+ const bytes = bytesByPath.get(resourcesPathPath);
31
+ if (!bytes) {
32
+ throw new Error(`SingleFilePlugin requires ${resourcesPathPath}`);
33
+ }
34
+ const text = decodeUtf8OrNull(bytes);
35
+ if (text === null) {
36
+ throw new Error("SingleFilePlugin failed to decode resources_path.json as utf-8");
37
+ }
38
+ let parsed = null;
39
+ try {
40
+ parsed = JSON.parse(text);
41
+ }
42
+ catch {
43
+ parsed = null;
44
+ }
45
+ if (!parsed || parsed.version !== "1.0" || !Array.isArray(parsed.items)) {
46
+ throw new Error("SingleFilePlugin got invalid resources_path.json");
47
+ }
48
+ return parsed;
49
+ };
@@ -0,0 +1,13 @@
1
+ import type { ApiSnapshot, ResourcesPathSnapshot } from "./read-snapshot.js";
2
+ export declare const rewriteIndexHtmlToSingleFile: (options: {
3
+ indexHtml: string;
4
+ bytesByPath: Map<string, Uint8Array>;
5
+ resourcesPath: ResourcesPathSnapshot;
6
+ apiSnapshot: ApiSnapshot | null;
7
+ maxInlineBytes: number;
8
+ oversizeBehavior: (mimeType?: string) => "error" | "placeholder";
9
+ inlinePathUrl: (raw: string, context: {
10
+ kind: "attr" | "css";
11
+ selector: string;
12
+ }) => string | null;
13
+ }) => Promise<string>;
@@ -0,0 +1,213 @@
1
+ import { rewriteCssText } from "@pagepocket/lib";
2
+ import { lookupMimeTypeFromPath } from "@pagepocket/shared";
3
+ import * as cheerio from "cheerio";
4
+ import { decodeUtf8OrNull } from "../utils/decode-utf8-or-null.js";
5
+ import { placeholderSvgDataUrl } from "../utils/placeholder-svg.js";
6
+ import { shouldSkipCssValue } from "../utils/should-skip-css-value.js";
7
+ import { toDataUrlBase64 } from "../utils/to-data-url.js";
8
+ const buildWindowBootstrap = (payload) => {
9
+ const resourcesJson = JSON.stringify(payload.resourcesPath);
10
+ const apiJson = payload.apiSnapshot ? JSON.stringify(payload.apiSnapshot) : "null";
11
+ return `\n<script>(function(){\n window.__pagepocketResourcesPath = ${resourcesJson};\n window.__pagepocketApiSnapshot = ${apiJson};\n})();</script>\n`;
12
+ };
13
+ export const rewriteIndexHtmlToSingleFile = async (options) => {
14
+ const { indexHtml, bytesByPath, resourcesPath, apiSnapshot, maxInlineBytes, oversizeBehavior, inlinePathUrl } = options;
15
+ const $ = cheerio.load(indexHtml);
16
+ // rewriteCssText() relies on URL() which requires an absolute base URL.
17
+ // We use a stable synthetic origin and convert back to snapshot-local paths.
18
+ const CSS_BASE_URL = "https://pagepocket.local";
19
+ const toSnapshotLocalPath = (absoluteOrPath) => {
20
+ const raw = String(absoluteOrPath || "").trim();
21
+ if (!raw) {
22
+ return null;
23
+ }
24
+ if (raw.startsWith("/")) {
25
+ return raw;
26
+ }
27
+ try {
28
+ const parsed = new URL(raw);
29
+ if (parsed.origin !== CSS_BASE_URL) {
30
+ return null;
31
+ }
32
+ const withSearch = `${parsed.pathname}${parsed.search || ""}`;
33
+ return withSearch || parsed.pathname || null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ };
39
+ const rewriteCssFileToDataUrl = async (cssPath, cssBytes) => {
40
+ if (cssBytes.byteLength > maxInlineBytes) {
41
+ const behavior = oversizeBehavior("text/css");
42
+ if (behavior === "error") {
43
+ throw new Error(`SingleFilePlugin oversize text/css (>${maxInlineBytes} bytes): ${cssPath}`);
44
+ }
45
+ return placeholderSvgDataUrl(`stylesheet omitted: ${cssPath}`);
46
+ }
47
+ const cssText = decodeUtf8OrNull(cssBytes);
48
+ if (cssText === null) {
49
+ return toDataUrlBase64("text/css", cssBytes);
50
+ }
51
+ const rewritten = await rewriteCssText({
52
+ cssText,
53
+ cssUrl: `${CSS_BASE_URL}${cssPath}`,
54
+ resolveUrl: async (absolute) => {
55
+ if (shouldSkipCssValue(absolute)) {
56
+ return null;
57
+ }
58
+ const snapshotPath = toSnapshotLocalPath(absolute);
59
+ if (!snapshotPath) {
60
+ return null;
61
+ }
62
+ const mime = lookupMimeTypeFromPath(snapshotPath);
63
+ const behavior = oversizeBehavior(mime);
64
+ const bytes = bytesByPath.get(snapshotPath) ??
65
+ bytesByPath.get(snapshotPath.split("?")[0] || snapshotPath);
66
+ if (!bytes) {
67
+ return null;
68
+ }
69
+ if (bytes.byteLength > maxInlineBytes) {
70
+ if (behavior === "error") {
71
+ throw new Error(`SingleFilePlugin oversize ${mime} (>${maxInlineBytes} bytes) referenced from ${cssPath}: ${absolute}`);
72
+ }
73
+ if (mime.startsWith("image/")) {
74
+ return placeholderSvgDataUrl(`image omitted: ${snapshotPath}`);
75
+ }
76
+ if (mime.startsWith("video/") || mime.startsWith("audio/")) {
77
+ return placeholderSvgDataUrl(`media omitted: ${snapshotPath}`);
78
+ }
79
+ return null;
80
+ }
81
+ return toDataUrlBase64(mime, bytes);
82
+ }
83
+ });
84
+ const outBytes = new TextEncoder().encode(rewritten);
85
+ return toDataUrlBase64("text/css", outBytes);
86
+ };
87
+ const rewriteAttrToInline = (selector, attr) => {
88
+ $(selector).each((_, element) => {
89
+ const current = $(element).attr(attr);
90
+ if (!current) {
91
+ return;
92
+ }
93
+ const next = inlinePathUrl(current, { kind: "attr", selector: `${selector}[${attr}]` });
94
+ if (next) {
95
+ $(element).attr(attr, next);
96
+ }
97
+ });
98
+ };
99
+ rewriteAttrToInline("script[src]", "src");
100
+ rewriteAttrToInline("img[src]", "src");
101
+ rewriteAttrToInline("source[src]", "src");
102
+ rewriteAttrToInline("video[src]", "src");
103
+ rewriteAttrToInline("audio[src]", "src");
104
+ rewriteAttrToInline("track[src]", "src");
105
+ rewriteAttrToInline("iframe[src]", "src");
106
+ rewriteAttrToInline("embed[src]", "src");
107
+ rewriteAttrToInline("object[data]", "data");
108
+ rewriteAttrToInline("[poster]", "poster");
109
+ const linkNodes = $("link[href]").toArray();
110
+ for (const element of linkNodes) {
111
+ const value = $(element).attr("href");
112
+ if (!value) {
113
+ continue;
114
+ }
115
+ const href = String(value).trim();
116
+ if (!href.startsWith("/")) {
117
+ continue;
118
+ }
119
+ const rel = String($(element).attr("rel") || "").toLowerCase();
120
+ const isStylesheet = rel.includes("stylesheet");
121
+ if (isStylesheet && href.endsWith(".css")) {
122
+ const bytes = bytesByPath.get(href);
123
+ if (!bytes) {
124
+ continue;
125
+ }
126
+ const next = await rewriteCssFileToDataUrl(href, bytes);
127
+ $(element).attr("href", next);
128
+ continue;
129
+ }
130
+ const next = inlinePathUrl(href, { kind: "attr", selector: "link[href]" });
131
+ if (next) {
132
+ $(element).attr("href", next);
133
+ }
134
+ }
135
+ const rewriteSrcsetInline = (value) => {
136
+ return String(value || "")
137
+ .split(",")
138
+ .map((part) => {
139
+ const trimmed = part.trim();
140
+ if (!trimmed)
141
+ return trimmed;
142
+ const pieces = trimmed.split(/\s+/, 2);
143
+ const url = pieces[0] || "";
144
+ const descriptor = pieces[1];
145
+ const next = inlinePathUrl(url, { kind: "attr", selector: "[srcset]" });
146
+ if (!next) {
147
+ return trimmed;
148
+ }
149
+ return descriptor ? `${next} ${descriptor}` : next;
150
+ })
151
+ .join(", ");
152
+ };
153
+ $("img[srcset], source[srcset]").each((_index, element) => {
154
+ const value = $(element).attr("srcset");
155
+ if (!value)
156
+ return;
157
+ $(element).attr("srcset", rewriteSrcsetInline(value));
158
+ });
159
+ $("link[imagesrcset]").each((_index, element) => {
160
+ const value = $(element).attr("imagesrcset");
161
+ if (!value)
162
+ return;
163
+ $(element).attr("imagesrcset", rewriteSrcsetInline(value));
164
+ });
165
+ const inlineStyleText = async (cssText) => {
166
+ if (!cssText) {
167
+ return cssText;
168
+ }
169
+ return await rewriteCssText({
170
+ cssText,
171
+ cssUrl: `${CSS_BASE_URL}/`,
172
+ resolveUrl: async (absolute) => {
173
+ if (shouldSkipCssValue(absolute)) {
174
+ return null;
175
+ }
176
+ const snapshotPath = toSnapshotLocalPath(absolute);
177
+ if (!snapshotPath) {
178
+ return null;
179
+ }
180
+ return inlinePathUrl(snapshotPath, { kind: "css", selector: "css" });
181
+ }
182
+ });
183
+ };
184
+ const styleNodes = $("style").toArray();
185
+ for (const el of styleNodes) {
186
+ const current = $(el).html();
187
+ if (!current)
188
+ continue;
189
+ const next = await inlineStyleText(current);
190
+ if (next !== current) {
191
+ $(el).html(next);
192
+ }
193
+ }
194
+ const styled = $("[style]").toArray();
195
+ for (const el of styled) {
196
+ const current = $(el).attr("style");
197
+ if (!current)
198
+ continue;
199
+ const next = await inlineStyleText(current);
200
+ if (next !== current) {
201
+ $(el).attr("style", next);
202
+ }
203
+ }
204
+ const bootstrap = buildWindowBootstrap({ resourcesPath, apiSnapshot });
205
+ const head = $("head");
206
+ if (head.length) {
207
+ head.prepend(bootstrap);
208
+ }
209
+ else {
210
+ $.root().prepend(bootstrap);
211
+ }
212
+ return `${$.html()}\n`;
213
+ };
@@ -0,0 +1,28 @@
1
+ import type { PagePocketContext, PagePocketPlugin } from "@pagepocket/lib";
2
+ export type SingleFilePluginOptions = {
3
+ /**
4
+ * Max bytes to inline per resource.
5
+ *
6
+ * In strong-offline single-file mode, script/stylesheet over this limit will error.
7
+ * image/media over this limit will be replaced with placeholders in HTML.
8
+ */
9
+ maxInlineBytes?: number;
10
+ /**
11
+ * Oversize behavior by resource type.
12
+ * - "error": throw and stop single-file build
13
+ * - "placeholder": replace URL with a placeholder data URL
14
+ */
15
+ oversize?: {
16
+ script?: "error" | "placeholder";
17
+ stylesheet?: "error" | "placeholder";
18
+ image?: "error" | "placeholder";
19
+ media?: "error" | "placeholder";
20
+ };
21
+ };
22
+ export declare class SingleFilePlugin implements PagePocketPlugin {
23
+ readonly name = "plugin:single-file";
24
+ private maxInlineBytes;
25
+ private oversize;
26
+ constructor(options?: SingleFilePluginOptions);
27
+ apply(ctx: PagePocketContext): void;
28
+ }
@@ -0,0 +1,69 @@
1
+ import { collectSnapshotBytesByPath } from "./single-file/collect-bytes.js";
2
+ import { embedResourcesPathData } from "./single-file/embed-resources.js";
3
+ import { createInlinePathUrl } from "./single-file/inline-path-url.js";
4
+ import { createOversizeBehavior } from "./single-file/oversize-behavior.js";
5
+ import { readIndexHtml, readOptionalApiSnapshot, readResourcesPathSnapshot } from "./single-file/read-snapshot.js";
6
+ import { rewriteIndexHtmlToSingleFile } from "./single-file/rewrite-index-html.js";
7
+ import { defaultOversizeBehavior } from "./utils/oversize.js";
8
+ const DEFAULT_MAX_INLINE_BYTES = 25 * 1024 * 1024;
9
+ const defaultOversize = defaultOversizeBehavior;
10
+ export class SingleFilePlugin {
11
+ constructor(options) {
12
+ this.name = "plugin:single-file";
13
+ this.maxInlineBytes = options?.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;
14
+ this.oversize = {
15
+ script: options?.oversize?.script ?? defaultOversize.script,
16
+ stylesheet: options?.oversize?.stylesheet ?? defaultOversize.stylesheet,
17
+ image: options?.oversize?.image ?? defaultOversize.image,
18
+ media: options?.oversize?.media ?? defaultOversize.media
19
+ };
20
+ }
21
+ apply(ctx) {
22
+ ctx.onFinalize(async () => {
23
+ const files = ctx.files;
24
+ if (!files) {
25
+ throw new Error("SingleFilePlugin requires ctx.files (run after snapshot builders)");
26
+ }
27
+ const bytesByPath = await collectSnapshotBytesByPath(files);
28
+ const indexHtml = readIndexHtml(bytesByPath);
29
+ const apiSnapshot = readOptionalApiSnapshot(bytesByPath);
30
+ const resourcesPath = readResourcesPathSnapshot(bytesByPath);
31
+ const oversizeBehavior = createOversizeBehavior(this.oversize);
32
+ const inlinePathUrl = createInlinePathUrl({
33
+ bytesByPath,
34
+ maxInlineBytes: this.maxInlineBytes,
35
+ oversizeBehavior
36
+ });
37
+ await embedResourcesPathData({
38
+ resourcesPath,
39
+ bytesByPath,
40
+ maxInlineBytes: this.maxInlineBytes,
41
+ oversizeBehavior,
42
+ inlinePathUrl
43
+ });
44
+ const finalHtml = await rewriteIndexHtmlToSingleFile({
45
+ indexHtml,
46
+ bytesByPath,
47
+ resourcesPath,
48
+ apiSnapshot,
49
+ maxInlineBytes: this.maxInlineBytes,
50
+ oversizeBehavior,
51
+ inlinePathUrl
52
+ });
53
+ ctx.files = {
54
+ root: {
55
+ kind: "directory",
56
+ path: "",
57
+ entries: [
58
+ {
59
+ kind: "file",
60
+ path: "/index.html",
61
+ source: { kind: "text", text: finalHtml }
62
+ }
63
+ ]
64
+ },
65
+ content: files.content
66
+ };
67
+ });
68
+ }
69
+ }
@@ -0,0 +1,17 @@
1
+ import { Unit, type UnitContext } from "@pagepocket/lib";
2
+ export type SingleFileUnitOptions = {
3
+ maxInlineBytes?: number;
4
+ oversize?: {
5
+ script?: "error" | "placeholder";
6
+ stylesheet?: "error" | "placeholder";
7
+ image?: "error" | "placeholder";
8
+ media?: "error" | "placeholder";
9
+ };
10
+ };
11
+ export declare class SingleFileUnit extends Unit {
12
+ readonly id = "singleFile";
13
+ readonly kind = "build.singleFile";
14
+ private options;
15
+ constructor(options?: SingleFileUnitOptions);
16
+ run(ctx: UnitContext): Promise<void | Record<string, unknown>>;
17
+ }
@@ -0,0 +1,20 @@
1
+ import { Unit } from "@pagepocket/lib";
2
+ export class SingleFileUnit extends Unit {
3
+ constructor(options) {
4
+ super();
5
+ this.id = "singleFile";
6
+ this.kind = "build.singleFile";
7
+ this.options = options;
8
+ }
9
+ async run(ctx) {
10
+ const files = ctx.value.files;
11
+ if (!files) {
12
+ throw new Error("SingleFileUnit requires ctx.value.files");
13
+ }
14
+ // Single-file transformation is pure with respect to artifacts.
15
+ // We keep implementation local to this package; it consumes and produces files@1.
16
+ const { buildSingleFileFiles } = await import("./single-file/build-single-file-files.js");
17
+ const nextFiles = await buildSingleFileFiles({ files, options: this.options });
18
+ return { files: nextFiles };
19
+ }
20
+ }
@@ -0,0 +1 @@
1
+ export declare const decodeUtf8OrNull: (bytes: Uint8Array) => string | null;
@@ -0,0 +1,5 @@
1
+ import { decodeUtf8Lenient } from "@pagepocket/shared";
2
+ export const decodeUtf8OrNull = (bytes) => {
3
+ const decoded = decodeUtf8Lenient(bytes);
4
+ return decoded ? decoded : null;
5
+ };
@@ -0,0 +1,5 @@
1
+ import type { FileTreeDirectory, FileTreeFile } from "@pagepocket/lib";
2
+ export declare const flattenEntries: (dir: FileTreeDirectory, prefix: string) => Array<{
3
+ path: string;
4
+ file: FileTreeFile;
5
+ }>;
@@ -0,0 +1,28 @@
1
+ const joinPosix = (base, relative) => {
2
+ const cleanBase = String(base || "")
3
+ .replace(/\\/g, "/")
4
+ .replace(/\/+$/, "");
5
+ const cleanRel = String(relative || "")
6
+ .replace(/\\/g, "/")
7
+ .replace(/^\/+/, "");
8
+ if (!cleanBase) {
9
+ return cleanRel;
10
+ }
11
+ if (!cleanRel) {
12
+ return cleanBase;
13
+ }
14
+ return `${cleanBase}/${cleanRel}`;
15
+ };
16
+ export const flattenEntries = (dir, prefix) => {
17
+ const out = [];
18
+ const dirPrefix = joinPosix(prefix, dir.path);
19
+ for (const entry of dir.entries) {
20
+ if (entry.kind === "file") {
21
+ const filePath = joinPosix(dirPrefix, entry.path);
22
+ out.push({ path: filePath.startsWith("/") ? filePath : `/${filePath}`, file: entry });
23
+ continue;
24
+ }
25
+ out.push(...flattenEntries(entry, dirPrefix));
26
+ }
27
+ return out;
28
+ };
@@ -0,0 +1,16 @@
1
+ declare const defaultOversize: {
2
+ readonly script: "error";
3
+ readonly stylesheet: "error";
4
+ readonly image: "placeholder";
5
+ readonly media: "placeholder";
6
+ };
7
+ export type OversizeResourceType = keyof typeof defaultOversize;
8
+ export type OversizeBehavior = (typeof defaultOversize)[OversizeResourceType];
9
+ export declare const parseResourceTypeFromMime: (mimeType?: string) => OversizeResourceType | null;
10
+ export declare const defaultOversizeBehavior: {
11
+ readonly script: "error";
12
+ readonly stylesheet: "error";
13
+ readonly image: "placeholder";
14
+ readonly media: "placeholder";
15
+ };
16
+ export {};
@@ -0,0 +1,25 @@
1
+ const defaultOversize = {
2
+ script: "error",
3
+ stylesheet: "error",
4
+ image: "placeholder",
5
+ media: "placeholder"
6
+ };
7
+ export const parseResourceTypeFromMime = (mimeType) => {
8
+ const mime = String(mimeType || "").toLowerCase();
9
+ if (mime.includes("javascript") ||
10
+ mime === "application/x-javascript" ||
11
+ mime === "text/ecmascript") {
12
+ return "script";
13
+ }
14
+ if (mime === "text/css") {
15
+ return "stylesheet";
16
+ }
17
+ if (mime.startsWith("image/")) {
18
+ return "image";
19
+ }
20
+ if (mime.startsWith("video/") || mime.startsWith("audio/")) {
21
+ return "media";
22
+ }
23
+ return null;
24
+ };
25
+ export const defaultOversizeBehavior = defaultOversize;
@@ -0,0 +1 @@
1
+ export declare const placeholderSvgDataUrl: (label: string) => string;
@@ -0,0 +1,5 @@
1
+ export const placeholderSvgDataUrl = (label) => {
2
+ const safe = String(label || "omitted").replace(/[<>]/g, "");
3
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="100%" height="100%" fill="#eee"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="monospace" font-size="14" fill="#444">${safe}</text></svg>`;
4
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
5
+ };
@@ -0,0 +1 @@
1
+ export declare const shouldSkipCssValue: (value: string) => boolean;
@@ -0,0 +1,10 @@
1
+ export const shouldSkipCssValue = (value) => {
2
+ const trimmed = String(value || "").trim();
3
+ return (!trimmed ||
4
+ trimmed.startsWith("data:") ||
5
+ trimmed.startsWith("blob:") ||
6
+ trimmed.startsWith("mailto:") ||
7
+ trimmed.startsWith("tel:") ||
8
+ trimmed.startsWith("javascript:") ||
9
+ trimmed.startsWith("#"));
10
+ };
@@ -0,0 +1 @@
1
+ export declare const streamToUint8Array: (stream: ReadableStream<Uint8Array>) => Promise<Uint8Array>;
@@ -0,0 +1,22 @@
1
+ export const streamToUint8Array = async (stream) => {
2
+ const reader = stream.getReader();
3
+ const chunks = [];
4
+ let total = 0;
5
+ while (true) {
6
+ const result = await reader.read();
7
+ if (result.done) {
8
+ break;
9
+ }
10
+ if (result.value) {
11
+ chunks.push(result.value);
12
+ total += result.value.byteLength;
13
+ }
14
+ }
15
+ const output = new Uint8Array(total);
16
+ let offset = 0;
17
+ for (const chunk of chunks) {
18
+ output.set(chunk, offset);
19
+ offset += chunk.byteLength;
20
+ }
21
+ return output;
22
+ };
@@ -0,0 +1 @@
1
+ export declare const toDataUrlBase64: (mimeType: string, bytes: Uint8Array) => string;
@@ -0,0 +1,5 @@
1
+ import { bytesToBase64 } from "@pagepocket/shared";
2
+ export const toDataUrlBase64 = (mimeType, bytes) => {
3
+ const mime = mimeType || "application/octet-stream";
4
+ return `data:${mime};base64,${bytesToBase64(bytes)}`;
5
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@pagepocket/single-file-unit",
3
+ "version": "0.8.0",
4
+ "description": "PagePocket plugin: emit strong-offline single-file index.html",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "license": "ISC",
18
+ "dependencies": {
19
+ "cheerio": "^1.1.2",
20
+ "@pagepocket/lib": "0.8.0",
21
+ "@pagepocket/shared": "0.8.0",
22
+ "@pagepocket/contracts": "0.8.0"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "^5.4.5"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "test": "node -e \"process.exit(0)\""
30
+ }
31
+ }