@jxsuite/compiler 1.4.0 → 2.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/dist/cli.js +564 -230
- package/dist/compiler.js +334 -133
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +461 -51
- package/src/site/bundler.ts +149 -18
- package/src/site/client-runtime.ts +374 -0
- package/src/site/context-injection.ts +45 -2
- package/src/site/csp.ts +224 -0
- package/src/site/head-merger.ts +57 -13
- package/src/site/headers-emitter.ts +232 -0
- package/src/site/i18n.ts +365 -0
- package/src/site/image-optimizer.ts +7 -0
- package/src/site/image-transform.ts +269 -118
- package/src/site/img-loading.ts +55 -0
- package/src/site/link-relations.ts +251 -0
- package/src/site/locale-negotiation.ts +258 -0
- package/src/site/pages-discovery.ts +149 -0
- package/src/site/preview-server.ts +9 -1
- package/src/site/schema-command.ts +138 -87
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +956 -109
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +134 -38
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +144 -81
- package/src/targets/compile-element.ts +620 -58
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +12 -3
- package/src/types.ts +2 -0
package/src/site/site-loader.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Extends the original project.json check into the full compliance walk:
|
|
5
5
|
*
|
|
6
|
-
* 1.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 1. The committed entry documents are SELF-CONTAINED and SINGLE-RESOURCE: every `$ref` is a
|
|
7
|
+
* root-relative JSON Pointer that resolves inside the file — the editor-resolution guarantee of
|
|
8
|
+
* the committed form (specs/extensions.md §5.4). Checked first and fatal, since walks 2–4
|
|
9
|
+
* compile those documents;
|
|
10
|
+
* 2. `project.json` against the generated entry schema (validateProjectFile);
|
|
9
11
|
* 3. Every document under `components/`, `pages/`, and `layouts/` against the bundled document schema
|
|
10
12
|
* (core + extension `$paths` union — the same schema the studio consumes);
|
|
11
13
|
* 4. Every project-local `*.class.json` against the generated class schema;
|
|
@@ -94,38 +96,44 @@ export async function validateProjectTree(
|
|
|
94
96
|
const issues: ProjectTreeIssue[] = [];
|
|
95
97
|
let checked = 0;
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// 2. Committed entry documents must be self-contained (the editor-resolution guarantee).
|
|
99
|
+
/* 1. Committed entry documents must be self-contained (the editor-resolution guarantee). This
|
|
100
|
+
runs FIRST because every later walk compiles one of them: an unresolvable ref surfaces from ajv
|
|
101
|
+
as a thrown MissingRefError, so diagnosing it here turns a stack trace into "regenerate with
|
|
102
|
+
`jx schema`". Nothing downstream is meaningful until it holds, hence the early return. */
|
|
105
103
|
for (const name of ["project.schema.json", "document.schema.json"]) {
|
|
106
104
|
const path = resolve(root, name);
|
|
107
105
|
if (!existsSync(path)) {
|
|
108
106
|
continue;
|
|
109
107
|
}
|
|
110
108
|
checked += 1;
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
if (
|
|
109
|
+
const entry = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
110
|
+
const unresolvable = collectUnresolvableRefs(entry);
|
|
111
|
+
if (unresolvable.length > 0) {
|
|
114
112
|
issues.push({
|
|
115
|
-
errors:
|
|
113
|
+
errors: unresolvable.map(({ reason, ref }) => ({
|
|
116
114
|
message:
|
|
117
|
-
|
|
118
|
-
`regenerate with \`jx schema\``,
|
|
115
|
+
`$ref "${ref}" ${reason} — committed entry documents must be self-contained ` +
|
|
116
|
+
`single-resource schemas; regenerate with \`jx schema\``,
|
|
119
117
|
})),
|
|
120
118
|
file: name,
|
|
121
119
|
});
|
|
122
120
|
}
|
|
123
121
|
}
|
|
122
|
+
if (issues.length > 0) {
|
|
123
|
+
return { checked, issues, valid: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 2. project.json against the generated entry schema.
|
|
127
|
+
const projectResult = await validateProjectFile(root);
|
|
128
|
+
checked += 1;
|
|
129
|
+
if (!projectResult.valid) {
|
|
130
|
+
issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
|
|
131
|
+
}
|
|
124
132
|
|
|
125
133
|
const { addFormats, Ajv } = await loadAjv();
|
|
126
134
|
|
|
127
135
|
// 3. Documents against the bundled document schema (the same schema the studio consumes).
|
|
128
|
-
const { document: documentSchema } = await readBundledProjectSchemas(root);
|
|
136
|
+
const { document: documentSchema } = await readBundledProjectSchemas(root, { write: false });
|
|
129
137
|
const docAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
|
|
130
138
|
addFormats(docAjv);
|
|
131
139
|
const validateDoc = docAjv.compile(documentSchema);
|
|
@@ -186,30 +194,118 @@ export async function validateProjectTree(
|
|
|
186
194
|
return { checked, issues, valid: issues.length === 0 };
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Every `$ref` a committed entry document may carry must be a root-relative JSON Pointer that
|
|
199
|
+
* resolves inside the same file. Three ways that fails, all of which break editor resolution: a
|
|
200
|
+
* relative path (needs Node module resolution), a URI (VS Code fetches it over the network instead
|
|
201
|
+
* of matching an embedded `$id`), and a pointer with no node at the other end.
|
|
202
|
+
*
|
|
203
|
+
* Walks only subschema keyword positions, since `examples`/`default`/`const`/`enum` hold instance
|
|
204
|
+
* data and Jx document examples legitimately contain `$ref` strings of their own.
|
|
205
|
+
*
|
|
206
|
+
* @param {Record<string, unknown>} entry - Parsed committed entry document
|
|
207
|
+
* @returns {{ ref: string; reason: string }[]} One entry per offending `$ref`
|
|
208
|
+
*/
|
|
209
|
+
function collectUnresolvableRefs(
|
|
210
|
+
entry: Record<string, unknown>,
|
|
211
|
+
): { ref: string; reason: string }[] {
|
|
212
|
+
const out: { ref: string; reason: string }[] = [];
|
|
213
|
+
const seen = new Set<Record<string, unknown>>();
|
|
214
|
+
|
|
215
|
+
const resolvePointer = (pointer: string): boolean => {
|
|
216
|
+
let current: unknown = entry;
|
|
217
|
+
for (const raw of pointer.slice(1).split("/")) {
|
|
218
|
+
if (raw === "") {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const segment = raw.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
222
|
+
if (typeof current !== "object" || current === null) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
current = (current as Record<string, unknown>)[segment];
|
|
226
|
+
if (current === undefined) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return true;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const walk = (node: unknown): void => {
|
|
234
|
+
if (!isRecord(node) || seen.has(node)) {
|
|
235
|
+
return;
|
|
194
236
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
!
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
out.push(value);
|
|
237
|
+
seen.add(node);
|
|
238
|
+
const ref = node.$ref;
|
|
239
|
+
if (typeof ref === "string") {
|
|
240
|
+
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(ref)) {
|
|
241
|
+
out.push({ reason: "is an absolute URI editors resolve over the network", ref });
|
|
242
|
+
} else if (!ref.startsWith("#")) {
|
|
243
|
+
out.push({ reason: "is a relative path only Node module resolution can follow", ref });
|
|
244
|
+
} else if (ref.length > 1 && !ref.startsWith("#/")) {
|
|
245
|
+
out.push({ reason: "is an $anchor reference, which has no root-pointer form", ref });
|
|
246
|
+
} else if (ref.length > 1 && !resolvePointer(ref.slice(1))) {
|
|
247
|
+
out.push({ reason: "points at no node in this document", ref });
|
|
248
|
+
}
|
|
208
249
|
}
|
|
209
|
-
|
|
210
|
-
|
|
250
|
+
for (const [keyword, value] of Object.entries(node)) {
|
|
251
|
+
if (!SUBSCHEMA_KEYWORDS.has(keyword)) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (Array.isArray(value)) {
|
|
255
|
+
for (const item of value) {
|
|
256
|
+
walk(item);
|
|
257
|
+
}
|
|
258
|
+
} else if (isRecord(value)) {
|
|
259
|
+
// Map-valued keywords hold subschemas one level down; single-valued ones are the schema.
|
|
260
|
+
walk(value);
|
|
261
|
+
if (MAP_SUBSCHEMA_KEYWORDS.has(keyword)) {
|
|
262
|
+
for (const item of Object.values(value)) {
|
|
263
|
+
walk(item);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
walk(entry);
|
|
270
|
+
return out;
|
|
211
271
|
}
|
|
212
272
|
|
|
273
|
+
/** True for a plain object (a candidate subschema node). */
|
|
274
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
275
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Keywords whose values map names to subschemas. */
|
|
279
|
+
const MAP_SUBSCHEMA_KEYWORDS = new Set([
|
|
280
|
+
"$defs",
|
|
281
|
+
"definitions",
|
|
282
|
+
"dependencies",
|
|
283
|
+
"dependentSchemas",
|
|
284
|
+
"patternProperties",
|
|
285
|
+
"properties",
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
/** Every keyword position a subschema can occupy (see {@link MAP_SUBSCHEMA_KEYWORDS} for maps). */
|
|
289
|
+
const SUBSCHEMA_KEYWORDS = new Set([
|
|
290
|
+
...MAP_SUBSCHEMA_KEYWORDS,
|
|
291
|
+
"additionalItems",
|
|
292
|
+
"additionalProperties",
|
|
293
|
+
"allOf",
|
|
294
|
+
"anyOf",
|
|
295
|
+
"contains",
|
|
296
|
+
"contentSchema",
|
|
297
|
+
"else",
|
|
298
|
+
"if",
|
|
299
|
+
"items",
|
|
300
|
+
"not",
|
|
301
|
+
"oneOf",
|
|
302
|
+
"prefixItems",
|
|
303
|
+
"propertyNames",
|
|
304
|
+
"then",
|
|
305
|
+
"unevaluatedItems",
|
|
306
|
+
"unevaluatedProperties",
|
|
307
|
+
]);
|
|
308
|
+
|
|
213
309
|
/** Recursively list `.json` files under a directory (absent directories yield nothing). */
|
|
214
310
|
function walkJsonFiles(dir: string): string[] {
|
|
215
311
|
if (!existsSync(dir)) {
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two files a site is expected to publish and nothing generates: `manifest.webmanifest` and
|
|
3
|
+
* `.well-known/security.txt`.
|
|
4
|
+
*
|
|
5
|
+
* Both are pure functions of `project.json` plus what the build already knows about the site, which
|
|
6
|
+
* is the reason they belong here rather than in `public/` — an author copying either between
|
|
7
|
+
* projects also copies the values that were right for the other one.
|
|
8
|
+
*
|
|
9
|
+
* @docs framework/site/deployment
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { canonicalizeLocale } from "@jxsuite/schema/locale";
|
|
15
|
+
import type { ManifestConfig, ProjectConfig, SecurityTxtConfig } from "@jxsuite/schema/types";
|
|
16
|
+
|
|
17
|
+
/** The two sizes a browser wants before it will offer to install a site. */
|
|
18
|
+
const INSTALL_ICON_SIZES = [192, 512];
|
|
19
|
+
|
|
20
|
+
export interface GeneratedFile {
|
|
21
|
+
/** Path relative to `outDir`. */
|
|
22
|
+
path: string;
|
|
23
|
+
content: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WellKnownOutput {
|
|
27
|
+
files: GeneratedFile[];
|
|
28
|
+
errors: string[];
|
|
29
|
+
warnings: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build `manifest.webmanifest` from the `manifest` section, falling back to what the project
|
|
34
|
+
* already says about itself.
|
|
35
|
+
*
|
|
36
|
+
* `name` and `start_url` are not asked for twice: a project has a `name`, and `start_url` is the
|
|
37
|
+
* site root unless the author says otherwise. Everything else is theirs.
|
|
38
|
+
*
|
|
39
|
+
* @param {ProjectConfig} projectConfig
|
|
40
|
+
* @returns {WellKnownOutput}
|
|
41
|
+
*/
|
|
42
|
+
export function buildManifest(projectConfig: ProjectConfig): WellKnownOutput {
|
|
43
|
+
const config: ManifestConfig | undefined = projectConfig.manifest;
|
|
44
|
+
const errors: string[] = [];
|
|
45
|
+
const warnings: string[] = [];
|
|
46
|
+
if (config === undefined || config.enabled === false) {
|
|
47
|
+
return { errors, files: [], warnings };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const name = config.name ?? projectConfig.name ?? "Jx Site";
|
|
51
|
+
const icons = config.icons ?? [];
|
|
52
|
+
const manifest: Record<string, unknown> = {
|
|
53
|
+
display: config.display ?? "standalone",
|
|
54
|
+
icons,
|
|
55
|
+
name,
|
|
56
|
+
short_name: config.shortName ?? name,
|
|
57
|
+
start_url: config.startUrl ?? "/",
|
|
58
|
+
...(config.description === undefined ? {} : { description: config.description }),
|
|
59
|
+
...(config.themeColor === undefined ? {} : { theme_color: config.themeColor }),
|
|
60
|
+
...(config.backgroundColor === undefined ? {} : { background_color: config.backgroundColor }),
|
|
61
|
+
...(config.scope === undefined ? {} : { scope: config.scope }),
|
|
62
|
+
...(config.orientation === undefined ? {} : { orientation: config.orientation }),
|
|
63
|
+
...(config.lang === undefined ? {} : { lang: config.lang }),
|
|
64
|
+
...(config.dir === undefined ? {} : { dir: config.dir }),
|
|
65
|
+
...(config.categories === undefined ? {} : { categories: config.categories }),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/*
|
|
69
|
+
* A warning, not an error. The manifest is valid and useful without these — it still supplies
|
|
70
|
+
* the name and theme colour a browser shows — but a site cannot be *installed* without an icon
|
|
71
|
+
* at each size, and that is the whole reason most people add one.
|
|
72
|
+
*/
|
|
73
|
+
const declared = new Set(
|
|
74
|
+
icons.flatMap((icon) =>
|
|
75
|
+
(icon.sizes ?? "").split(/\s+/).map((size) => Math.trunc(Number(size.split("x")[0]))),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
const missing = INSTALL_ICON_SIZES.filter((size) => !declared.has(size));
|
|
79
|
+
if (missing.length > 0) {
|
|
80
|
+
warnings.push(
|
|
81
|
+
`manifest: no ${missing.join("px and no ")}px icon declared — browsers will not offer to ` +
|
|
82
|
+
"install the site without one at each size.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
errors,
|
|
88
|
+
files: [{ content: `${JSON.stringify(manifest, null, 2)}\n`, path: "manifest.webmanifest" }],
|
|
89
|
+
warnings,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The `<head>` entries a generated manifest needs to be found and to look right when installed. */
|
|
94
|
+
export function manifestHeadEntries(
|
|
95
|
+
projectConfig: ProjectConfig,
|
|
96
|
+
): { tagName: string; attributes: Record<string, string> }[] {
|
|
97
|
+
const config = projectConfig.manifest;
|
|
98
|
+
if (config === undefined || config.enabled === false) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const entries: { tagName: string; attributes: Record<string, string> }[] = [
|
|
102
|
+
{ attributes: { href: "/manifest.webmanifest", rel: "manifest" }, tagName: "link" },
|
|
103
|
+
];
|
|
104
|
+
if (config.themeColor !== undefined) {
|
|
105
|
+
entries.push({
|
|
106
|
+
attributes: { content: config.themeColor, name: "theme-color" },
|
|
107
|
+
tagName: "meta",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build `.well-known/security.txt` (RFC 9116).
|
|
115
|
+
*
|
|
116
|
+
* `.well-known` only. §3 makes that the canonical location, and a second copy at the root is a
|
|
117
|
+
* second thing to forget to update.
|
|
118
|
+
*
|
|
119
|
+
* @param {ProjectConfig} projectConfig
|
|
120
|
+
* @param {Date} now - Evaluated against `Expires`; injected so the check is testable
|
|
121
|
+
* @returns {WellKnownOutput}
|
|
122
|
+
*/
|
|
123
|
+
export function buildSecurityTxt(projectConfig: ProjectConfig, now: Date): WellKnownOutput {
|
|
124
|
+
const config: SecurityTxtConfig | undefined = projectConfig.securityTxt;
|
|
125
|
+
const errors: string[] = [];
|
|
126
|
+
const warnings: string[] = [];
|
|
127
|
+
if (config === undefined || config.enabled === false) {
|
|
128
|
+
return { errors, files: [], warnings };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const contact = config.contact ?? [];
|
|
132
|
+
if (contact.length === 0) {
|
|
133
|
+
errors.push("securityTxt: at least one `contact` is required (RFC 9116 §2.5.3).");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/*
|
|
137
|
+
* `Expires` is required by §2.5.5, and it is the field everyone forgets. A past date is worse
|
|
138
|
+
* than a missing file: it advertises a reporting channel while telling the reporter the
|
|
139
|
+
* information is stale, so both cases fail the build rather than emitting something misleading.
|
|
140
|
+
*/
|
|
141
|
+
if (config.expires === undefined) {
|
|
142
|
+
errors.push(
|
|
143
|
+
"securityTxt: `expires` is required (RFC 9116 §2.5.5) — a security.txt with no expiry " +
|
|
144
|
+
"never goes stale visibly, which is how they end up pointing at people who left.",
|
|
145
|
+
);
|
|
146
|
+
} else {
|
|
147
|
+
const expires = new Date(config.expires);
|
|
148
|
+
if (Number.isNaN(expires.getTime())) {
|
|
149
|
+
errors.push(`securityTxt: \`expires\` is not a valid date: "${config.expires}".`);
|
|
150
|
+
} else if (expires.getTime() <= now.getTime()) {
|
|
151
|
+
errors.push(
|
|
152
|
+
`securityTxt: \`expires\` is in the past (${expires.toISOString()}) — an expired ` +
|
|
153
|
+
"security.txt tells a reporter not to trust what it says.",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const languages: string[] = [];
|
|
159
|
+
for (const tag of config.preferredLanguages ?? []) {
|
|
160
|
+
const canonical = canonicalizeLocale(tag);
|
|
161
|
+
if (canonical === null) {
|
|
162
|
+
errors.push(`securityTxt.preferredLanguages: "${tag}" is not a well-formed language tag.`);
|
|
163
|
+
} else {
|
|
164
|
+
languages.push(canonical);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (errors.length > 0) {
|
|
169
|
+
return { errors, files: [], warnings };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const lines: string[] = [
|
|
173
|
+
"# Generated by @jxsuite/compiler — configure via securityTxt in project.json.",
|
|
174
|
+
...contact.map((value) => `Contact: ${value}`),
|
|
175
|
+
`Expires: ${new Date(config.expires as string).toISOString().replace(/\.\d{3}Z$/, "Z")}`,
|
|
176
|
+
...(config.encryption ?? []).map((value) => `Encryption: ${value}`),
|
|
177
|
+
...(config.acknowledgments ?? []).map((value) => `Acknowledgments: ${value}`),
|
|
178
|
+
...(languages.length > 0 ? [`Preferred-Languages: ${languages.join(", ")}`] : []),
|
|
179
|
+
...(config.canonical === undefined ? [] : [`Canonical: ${config.canonical}`]),
|
|
180
|
+
...(config.policy ?? []).map((value) => `Policy: ${value}`),
|
|
181
|
+
...(config.hiring ?? []).map((value) => `Hiring: ${value}`),
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
errors,
|
|
186
|
+
files: [{ content: `${lines.join("\n")}\n`, path: ".well-known/security.txt" }],
|
|
187
|
+
warnings,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Write generated files under `outDir`, skipping any the author already supplied.
|
|
193
|
+
*
|
|
194
|
+
* The skip is the point: `public/.well-known/security.txt` is copied before this runs, and a
|
|
195
|
+
* hand-placed file there is how an author ships a **clearsigned** one — which needs a private key
|
|
196
|
+
* at build time and is therefore not something the build can do. Deferring costs zero code.
|
|
197
|
+
*
|
|
198
|
+
* @param {readonly GeneratedFile[]} files
|
|
199
|
+
* @param {string} outDir
|
|
200
|
+
* @param {(path: string) => boolean} exists
|
|
201
|
+
* @returns {{ written: number; skipped: string[] }}
|
|
202
|
+
*/
|
|
203
|
+
export function writeWellKnown(
|
|
204
|
+
files: readonly GeneratedFile[],
|
|
205
|
+
outDir: string,
|
|
206
|
+
exists: (path: string) => boolean,
|
|
207
|
+
): { written: number; skipped: string[] } {
|
|
208
|
+
let written = 0;
|
|
209
|
+
const skipped: string[] = [];
|
|
210
|
+
for (const file of files) {
|
|
211
|
+
const target = join(outDir, file.path);
|
|
212
|
+
if (exists(target)) {
|
|
213
|
+
skipped.push(file.path);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
217
|
+
writeFileSync(target, file.content, "utf8");
|
|
218
|
+
written += 1;
|
|
219
|
+
}
|
|
220
|
+
return { skipped, written };
|
|
221
|
+
}
|