lsr-text-catalog 0.1.2
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/LICENSE +21 -0
- package/README.md +184 -0
- package/dist/compiled/index.d.ts +12 -0
- package/dist/compiled/index.d.ts.map +1 -0
- package/dist/compiled/index.js +39 -0
- package/dist/compiled/index.js.map +1 -0
- package/dist/language.d.ts +4 -0
- package/dist/language.d.ts.map +1 -0
- package/dist/language.js +50 -0
- package/dist/language.js.map +1 -0
- package/dist/runtime/index.d.ts +17 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +68 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/vite/index.d.ts +21 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +559 -0
- package/dist/vite/index.js.map +1 -0
- package/dist/vite/manifest.d.ts +24 -0
- package/dist/vite/manifest.d.ts.map +1 -0
- package/dist/vite/manifest.js +110 -0
- package/dist/vite/manifest.js.map +1 -0
- package/dist/vite/transform.d.ts +5 -0
- package/dist/vite/transform.d.ts.map +1 -0
- package/dist/vite/transform.js +873 -0
- package/dist/vite/transform.js.map +1 -0
- package/dist/vite/types.d.ts +23 -0
- package/dist/vite/types.d.ts.map +1 -0
- package/dist/vite/types.js +2 -0
- package/dist/vite/types.js.map +1 -0
- package/package.json +99 -0
- package/src/compiled/index.ts +81 -0
- package/src/language.ts +62 -0
- package/src/runtime/index.ts +126 -0
- package/src/types.ts +55 -0
- package/src/vite/index.ts +642 -0
- package/src/vite/manifest.ts +158 -0
- package/src/vite/transform.ts +1228 -0
- package/src/vite/types.ts +27 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { CatalogIdentity, TextCatalogSnapshot } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface CatalogManifest extends TextCatalogSnapshot, CatalogIdentity {
|
|
7
|
+
readonly formatVersion: 1;
|
|
8
|
+
readonly artifacts: {
|
|
9
|
+
readonly runtime: { readonly path: "catalog.ts"; readonly sha256: string };
|
|
10
|
+
readonly compiled: {
|
|
11
|
+
readonly path: "catalog.compiled.ts";
|
|
12
|
+
readonly sha256: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
readonly generation: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Byte-ordered compact UTF-8 JSON, matching PHP's unescaped Unicode/slash encoding. */
|
|
19
|
+
export function canonicalJson(value: unknown): string {
|
|
20
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
21
|
+
if (value !== null && typeof value === "object") {
|
|
22
|
+
return `{${Object.keys(value)
|
|
23
|
+
.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))
|
|
24
|
+
.map(
|
|
25
|
+
(key) =>
|
|
26
|
+
`${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`,
|
|
27
|
+
)
|
|
28
|
+
.join(",")}}`;
|
|
29
|
+
}
|
|
30
|
+
const encoded = JSON.stringify(value);
|
|
31
|
+
if (encoded === undefined)
|
|
32
|
+
throw new Error("Catalog manifest contains a non-JSON value.");
|
|
33
|
+
return encoded;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const keyPattern = /^[a-z][a-zA-Z0-9]*(?:\.[a-z][a-zA-Z0-9]*)*$/u;
|
|
37
|
+
const digestPattern = /^[a-f0-9]{64}$/u;
|
|
38
|
+
|
|
39
|
+
function object(value: unknown, label: string): Record<string, unknown> {
|
|
40
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
41
|
+
throw new Error(`Catalog ${label} must be an object.`);
|
|
42
|
+
return value as Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ascii(value: unknown, label: string): asserts value is string {
|
|
46
|
+
if (typeof value !== "string" || !/^[\x21-\x7e]+$/u.test(value))
|
|
47
|
+
throw new Error(`Catalog ${label} must be a nonempty ASCII string.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read one complete generation; no legacy manifest or stale-artifact fallback is accepted. */
|
|
51
|
+
export function readCatalogManifest(
|
|
52
|
+
directory: string,
|
|
53
|
+
identity: CatalogIdentity,
|
|
54
|
+
): { manifest: CatalogManifest; runtime: string; compiled: string } {
|
|
55
|
+
const manifestFile = path.join(directory, "catalog.build.json");
|
|
56
|
+
const raw = object(
|
|
57
|
+
JSON.parse(readFileSync(manifestFile, "utf8")),
|
|
58
|
+
"manifest",
|
|
59
|
+
);
|
|
60
|
+
if (raw.formatVersion !== 1)
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Unsupported catalog formatVersion: ${String(raw.formatVersion)}.`,
|
|
63
|
+
);
|
|
64
|
+
ascii(raw.sourceLocale, "sourceLocale");
|
|
65
|
+
ascii(raw.domain, "domain");
|
|
66
|
+
if (!Array.isArray(raw.locales) || raw.locales.length === 0)
|
|
67
|
+
throw new Error("Catalog locales must be a nonempty array.");
|
|
68
|
+
for (const locale of raw.locales) ascii(locale, "locale");
|
|
69
|
+
if (
|
|
70
|
+
new Set(raw.locales).size !== raw.locales.length ||
|
|
71
|
+
!raw.locales.includes(raw.sourceLocale)
|
|
72
|
+
) {
|
|
73
|
+
throw new Error("Catalog locales must be unique and contain sourceLocale.");
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
raw.sourceLocale !== identity.sourceLocale ||
|
|
77
|
+
raw.domain !== identity.domain ||
|
|
78
|
+
JSON.stringify(raw.locales) !== JSON.stringify(identity.locales)
|
|
79
|
+
) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"Catalog manifest identity does not match the configured domain/locales/sourceLocale.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const texts = object(raw.texts, "texts");
|
|
85
|
+
const htmlKeys = object(raw.htmlKeys, "htmlKeys");
|
|
86
|
+
const plurals = object(raw.plurals, "plurals");
|
|
87
|
+
for (const [key, value] of Object.entries(texts)) {
|
|
88
|
+
if (!keyPattern.test(key) || typeof value !== "string")
|
|
89
|
+
throw new Error(`Invalid catalog text ${JSON.stringify(key)}.`);
|
|
90
|
+
}
|
|
91
|
+
for (const [key, value] of Object.entries(htmlKeys)) {
|
|
92
|
+
if (value !== true || !Object.hasOwn(texts, key))
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Catalog HTML key ${JSON.stringify(key)} must reference an existing text.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
for (const [key, value] of Object.entries(plurals)) {
|
|
98
|
+
const pair = object(value, `plural ${JSON.stringify(key)}`);
|
|
99
|
+
if (
|
|
100
|
+
!keyPattern.test(key) ||
|
|
101
|
+
pair.one !== `${key}.one` ||
|
|
102
|
+
pair.plural !== `${key}.plural` ||
|
|
103
|
+
!Object.hasOwn(texts, pair.one) ||
|
|
104
|
+
!Object.hasOwn(texts, pair.plural)
|
|
105
|
+
) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Catalog plural ${JSON.stringify(key)} must reference existing sibling one/plural text keys.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const artifacts = object(raw.artifacts, "artifacts");
|
|
112
|
+
const contents = { runtime: "", compiled: "" };
|
|
113
|
+
const realDirectory = realpathSync(directory);
|
|
114
|
+
const paths = new Set<string>();
|
|
115
|
+
for (const mode of ["runtime", "compiled"] as const) {
|
|
116
|
+
const artifact = object(artifacts[mode], `${mode} artifact`);
|
|
117
|
+
const expected = mode === "runtime" ? "catalog.ts" : "catalog.compiled.ts";
|
|
118
|
+
if (
|
|
119
|
+
typeof artifact.path !== "string" ||
|
|
120
|
+
artifact.path !== expected ||
|
|
121
|
+
paths.has(artifact.path)
|
|
122
|
+
)
|
|
123
|
+
throw new Error(`Unsafe or duplicate catalog artifact path for ${mode}.`);
|
|
124
|
+
paths.add(artifact.path);
|
|
125
|
+
if (
|
|
126
|
+
typeof artifact.sha256 !== "string" ||
|
|
127
|
+
!digestPattern.test(artifact.sha256)
|
|
128
|
+
)
|
|
129
|
+
throw new Error(`Invalid ${mode} artifact digest.`);
|
|
130
|
+
const file = realpathSync(path.join(directory, artifact.path));
|
|
131
|
+
if (path.dirname(file) !== realDirectory || paths.has(file))
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Unsafe or duplicate resolved catalog artifact path for ${mode}.`,
|
|
134
|
+
);
|
|
135
|
+
paths.add(file);
|
|
136
|
+
const bytes = readFileSync(file);
|
|
137
|
+
if (createHash("sha256").update(bytes).digest("hex") !== artifact.sha256)
|
|
138
|
+
throw new Error(`Catalog ${mode} artifact digest mismatch.`);
|
|
139
|
+
contents[mode] = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
140
|
+
}
|
|
141
|
+
const { generation, ...payload } = raw;
|
|
142
|
+
if (
|
|
143
|
+
typeof generation !== "string" ||
|
|
144
|
+
!digestPattern.test(generation) ||
|
|
145
|
+
createHash("sha256").update(canonicalJson(payload)).digest("hex") !==
|
|
146
|
+
generation
|
|
147
|
+
) {
|
|
148
|
+
throw new Error("Catalog manifest generation digest mismatch.");
|
|
149
|
+
}
|
|
150
|
+
// A concurrently published manifest must not authorize the artifacts read above.
|
|
151
|
+
if (
|
|
152
|
+
canonicalJson(JSON.parse(readFileSync(manifestFile, "utf8"))) !==
|
|
153
|
+
canonicalJson(raw)
|
|
154
|
+
) {
|
|
155
|
+
throw new Error("Catalog generation changed while reading its artifacts.");
|
|
156
|
+
}
|
|
157
|
+
return { manifest: raw as unknown as CatalogManifest, ...contents };
|
|
158
|
+
}
|