@ovineko/spa-guard-node 0.0.1-alpha-18
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/dist/index.js +135 -0
- package/dist/node/index.d.ts +54 -0
- package/package.json +48 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/node/index.ts
|
|
2
|
+
import { matchLang, translations } from "@ovineko/spa-guard/i18n";
|
|
3
|
+
import { negotiate } from "@fastify/accept-negotiator";
|
|
4
|
+
import { extractVersionFromHtml } from "@ovineko/spa-guard/_internal";
|
|
5
|
+
import { matchLang as matchLang2, translations as translations2 } from "@ovineko/spa-guard/i18n";
|
|
6
|
+
import { createHash } from "crypto";
|
|
7
|
+
import { promisify } from "util";
|
|
8
|
+
import { brotliCompress, gzip, zstdCompress } from "zlib";
|
|
9
|
+
import { parse, html as parse5Html, serialize } from "parse5";
|
|
10
|
+
var gzipAsync = promisify(gzip);
|
|
11
|
+
var brotliAsync = promisify(brotliCompress);
|
|
12
|
+
var zstdAsync = promisify(zstdCompress);
|
|
13
|
+
async function createHtmlCache(options) {
|
|
14
|
+
const { html, translations: customTranslations } = options;
|
|
15
|
+
const merged = mergeTranslations(customTranslations);
|
|
16
|
+
const mergedKeys = new Set(Object.keys(merged));
|
|
17
|
+
const languages = (options.languages ?? Object.keys(merged)).filter(
|
|
18
|
+
(lang) => mergedKeys.has(lang)
|
|
19
|
+
);
|
|
20
|
+
if (languages.length === 0) {
|
|
21
|
+
throw new Error("createHtmlCache requires at least one language");
|
|
22
|
+
}
|
|
23
|
+
const version = extractVersionFromHtml(html);
|
|
24
|
+
const sha256Prefix = version ? null : createHash("sha256").update(html).digest("hex").slice(0, 16);
|
|
25
|
+
const entries = /* @__PURE__ */ new Map();
|
|
26
|
+
await Promise.all(
|
|
27
|
+
languages.map(async (lang) => {
|
|
28
|
+
const patched = patchHtmlI18n({ html, lang, translations: customTranslations });
|
|
29
|
+
const buf = Buffer.from(patched, "utf8");
|
|
30
|
+
const etag = version ? `"${version}-${lang}"` : `"${sha256Prefix}-${lang}"`;
|
|
31
|
+
const [gzipped, brotli, zstdBuf] = await Promise.all([
|
|
32
|
+
gzipAsync(buf),
|
|
33
|
+
brotliAsync(buf),
|
|
34
|
+
zstdAsync(buf)
|
|
35
|
+
]);
|
|
36
|
+
entries.set(lang, {
|
|
37
|
+
br: brotli,
|
|
38
|
+
etag,
|
|
39
|
+
gzip: gzipped,
|
|
40
|
+
identity: buf,
|
|
41
|
+
lang,
|
|
42
|
+
zstd: zstdBuf
|
|
43
|
+
});
|
|
44
|
+
})
|
|
45
|
+
);
|
|
46
|
+
const available = languages;
|
|
47
|
+
return {
|
|
48
|
+
get({ acceptEncoding, acceptLanguage, lang: langOverride }) {
|
|
49
|
+
const resolvedLang = matchLang2(langOverride ?? acceptLanguage, available);
|
|
50
|
+
const entry = entries.get(resolvedLang) ?? entries.get(available[0]);
|
|
51
|
+
const headers = {
|
|
52
|
+
"Content-Language": entry.lang,
|
|
53
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
54
|
+
ETag: entry.etag,
|
|
55
|
+
Vary: "Accept-Language, Accept-Encoding"
|
|
56
|
+
};
|
|
57
|
+
if (!acceptEncoding) {
|
|
58
|
+
return { body: entry.identity, headers };
|
|
59
|
+
}
|
|
60
|
+
const encoding = negotiate(acceptEncoding, ["br", "zstd", "gzip"]);
|
|
61
|
+
if (!encoding) {
|
|
62
|
+
return { body: entry.identity, headers };
|
|
63
|
+
}
|
|
64
|
+
headers["Content-Encoding"] = encoding;
|
|
65
|
+
const bodyMap = {
|
|
66
|
+
br: entry.br,
|
|
67
|
+
gzip: entry.gzip,
|
|
68
|
+
zstd: entry.zstd
|
|
69
|
+
};
|
|
70
|
+
return { body: bodyMap[encoding], headers };
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function patchHtmlI18n(options) {
|
|
75
|
+
const { acceptLanguage, html, lang: langOverride, translations: customTranslations } = options;
|
|
76
|
+
const merged = mergeTranslations(customTranslations);
|
|
77
|
+
const available = Object.keys(merged);
|
|
78
|
+
const input = langOverride ?? acceptLanguage;
|
|
79
|
+
const resolvedLang = matchLang2(input, available);
|
|
80
|
+
const t = merged[resolvedLang];
|
|
81
|
+
if (resolvedLang === "en" && !customTranslations?.en) {
|
|
82
|
+
return html;
|
|
83
|
+
}
|
|
84
|
+
if (!t) {
|
|
85
|
+
return html;
|
|
86
|
+
}
|
|
87
|
+
const doc = parse(html);
|
|
88
|
+
const htmlEl = doc.childNodes.find(
|
|
89
|
+
(n) => n.nodeName === "html"
|
|
90
|
+
);
|
|
91
|
+
if (!htmlEl) {
|
|
92
|
+
return html;
|
|
93
|
+
}
|
|
94
|
+
const langAttr = htmlEl.attrs.find((a) => a.name === "lang");
|
|
95
|
+
if (langAttr) {
|
|
96
|
+
langAttr.value = resolvedLang;
|
|
97
|
+
} else {
|
|
98
|
+
htmlEl.attrs.push({ name: "lang", value: resolvedLang });
|
|
99
|
+
}
|
|
100
|
+
const headEl = htmlEl.childNodes.find(
|
|
101
|
+
(n) => n.nodeName === "head"
|
|
102
|
+
);
|
|
103
|
+
if (!headEl) {
|
|
104
|
+
return html;
|
|
105
|
+
}
|
|
106
|
+
const meta = {
|
|
107
|
+
attrs: [
|
|
108
|
+
{ name: "name", value: "spa-guard-i18n" },
|
|
109
|
+
{ name: "content", value: JSON.stringify(t) }
|
|
110
|
+
],
|
|
111
|
+
childNodes: [],
|
|
112
|
+
namespaceURI: parse5Html.NS.HTML,
|
|
113
|
+
nodeName: "meta",
|
|
114
|
+
parentNode: headEl,
|
|
115
|
+
tagName: "meta"
|
|
116
|
+
};
|
|
117
|
+
headEl.childNodes.unshift(meta);
|
|
118
|
+
return serialize(doc);
|
|
119
|
+
}
|
|
120
|
+
function mergeTranslations(customTranslations) {
|
|
121
|
+
const merged = { ...translations2 };
|
|
122
|
+
if (customTranslations) {
|
|
123
|
+
for (const [key, partial] of Object.entries(customTranslations)) {
|
|
124
|
+
const base = merged[key];
|
|
125
|
+
merged[key] = base ? { ...base, ...partial } : partial;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return merged;
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
createHtmlCache,
|
|
132
|
+
matchLang,
|
|
133
|
+
patchHtmlI18n,
|
|
134
|
+
translations
|
|
135
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type { SpaGuardTranslations } from "@ovineko/spa-guard/i18n";
|
|
2
|
+
export { matchLang, translations } from "@ovineko/spa-guard/i18n";
|
|
3
|
+
import type { SpaGuardTranslations } from "@ovineko/spa-guard/i18n";
|
|
4
|
+
export interface CreateHtmlCacheOptions {
|
|
5
|
+
/** The HTML string to cache */
|
|
6
|
+
html: string;
|
|
7
|
+
/** Languages to pre-generate (defaults to all keys from built-in + custom translations) */
|
|
8
|
+
languages?: string[];
|
|
9
|
+
/** Custom translations (deep-merged per-language with built-ins) */
|
|
10
|
+
translations?: Record<string, Partial<SpaGuardTranslations>>;
|
|
11
|
+
}
|
|
12
|
+
export interface HtmlCache {
|
|
13
|
+
get(options: {
|
|
14
|
+
acceptEncoding?: string;
|
|
15
|
+
acceptLanguage?: string;
|
|
16
|
+
lang?: string;
|
|
17
|
+
}): HtmlCacheResponse;
|
|
18
|
+
}
|
|
19
|
+
export interface HtmlCacheResponse {
|
|
20
|
+
body: Buffer | string;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
export interface PatchHtmlI18nOptions {
|
|
24
|
+
/** Raw Accept-Language header value */
|
|
25
|
+
acceptLanguage?: string;
|
|
26
|
+
/** The HTML string to patch */
|
|
27
|
+
html: string;
|
|
28
|
+
/** Explicit language override (takes priority over acceptLanguage) */
|
|
29
|
+
lang?: string;
|
|
30
|
+
/** Custom translations (deep-merged per-language with built-ins) */
|
|
31
|
+
translations?: Record<string, Partial<SpaGuardTranslations>>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Create a pre-computed HTML cache with compressed variants for all languages.
|
|
35
|
+
*
|
|
36
|
+
* At startup, generates all language variants via patchHtmlI18n and
|
|
37
|
+
* pre-compresses each with gzip, brotli, and zstd. ETag is derived from
|
|
38
|
+
* `__SPA_GUARD_VERSION__` in the HTML (falls back to sha256 prefix).
|
|
39
|
+
*
|
|
40
|
+
* The returned cache's `get()` method resolves language via `matchLang`
|
|
41
|
+
* and negotiates encoding via Accept-Encoding, returning a ready-to-use
|
|
42
|
+
* response with body and headers.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createHtmlCache(options: CreateHtmlCacheOptions): Promise<HtmlCache>;
|
|
45
|
+
/**
|
|
46
|
+
* Server-side HTML patching for i18n.
|
|
47
|
+
*
|
|
48
|
+
* Resolves language from `lang` (explicit) or `acceptLanguage` (header),
|
|
49
|
+
* merges translations, and injects a `<meta name="spa-guard-i18n">` tag
|
|
50
|
+
* into `<head>`. Also updates `<html lang="...">`.
|
|
51
|
+
*
|
|
52
|
+
* English without custom translations is a no-op (returns unchanged HTML).
|
|
53
|
+
*/
|
|
54
|
+
export declare function patchHtmlI18n(options: PatchHtmlI18nOptions): string;
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ovineko/spa-guard-node",
|
|
3
|
+
"version": "0.0.1-alpha-18",
|
|
4
|
+
"description": "Server-side HTML patching and i18n for spa-guard",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"spa",
|
|
7
|
+
"node",
|
|
8
|
+
"i18n",
|
|
9
|
+
"html",
|
|
10
|
+
"server-side"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/ovineko/ovineko/tree/main/spa-guard/node",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/ovineko/ovineko/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/ovineko/ovineko.git",
|
|
19
|
+
"directory": "spa-guard/node"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "Alexander Svinarev <shibanet0@gmail.com> (shibanet0.com)",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/node/index.d.ts",
|
|
28
|
+
"default": "./dist/node/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@fastify/accept-negotiator": "2.0.1",
|
|
37
|
+
"@ovineko/spa-guard": "0.0.1-alpha-18"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"parse5": "^8"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22.15.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|