@projectdochelp/s3te 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.
@@ -0,0 +1,535 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+
5
+ import {
6
+ S3teError,
7
+ createManualRenderTargets,
8
+ isRenderableKey,
9
+ loadProjectConfig,
10
+ renderSourceTemplate,
11
+ validateAndResolveProjectConfig
12
+ } from "../../core/src/index.mjs";
13
+
14
+ import {
15
+ deployAwsProject,
16
+ ensureAwsCliAvailable,
17
+ ensureAwsCredentials,
18
+ packageAwsProject
19
+ } from "../../aws-adapter/src/index.mjs";
20
+
21
+ import {
22
+ FileSystemTemplateRepository,
23
+ copyFile,
24
+ ensureDirectory,
25
+ loadLocalContent,
26
+ removeDirectory,
27
+ writeTextFile
28
+ } from "./fs-adapters.mjs";
29
+
30
+ function normalizePath(value) {
31
+ return String(value).replace(/\\/g, "/");
32
+ }
33
+
34
+ function schemaTemplate() {
35
+ return {
36
+ $schema: "https://json-schema.org/draft/2020-12/schema",
37
+ title: "S3TemplateEngine Project Config",
38
+ type: "object",
39
+ additionalProperties: false,
40
+ required: ["project", "environments", "variants"],
41
+ properties: {
42
+ $schema: { type: "string" },
43
+ configVersion: { type: "integer", minimum: 1 },
44
+ project: {
45
+ type: "object",
46
+ additionalProperties: false,
47
+ required: ["name"],
48
+ properties: {
49
+ name: {
50
+ type: "string",
51
+ pattern: "^[a-z0-9-]+$"
52
+ },
53
+ displayName: { type: "string" }
54
+ }
55
+ },
56
+ environments: {
57
+ type: "object",
58
+ additionalProperties: {
59
+ type: "object",
60
+ additionalProperties: false,
61
+ required: ["awsRegion", "certificateArn"],
62
+ properties: {
63
+ awsRegion: { type: "string" },
64
+ stackPrefix: { type: "string" },
65
+ certificateArn: { type: "string" },
66
+ route53HostedZoneId: { type: "string" }
67
+ }
68
+ }
69
+ },
70
+ variants: {
71
+ type: "object",
72
+ additionalProperties: {
73
+ type: "object",
74
+ additionalProperties: true,
75
+ properties: {
76
+ languages: {
77
+ type: "object",
78
+ additionalProperties: {
79
+ type: "object",
80
+ additionalProperties: false,
81
+ properties: {
82
+ baseUrl: { type: "string" },
83
+ targetBucket: { type: "string" },
84
+ cloudFrontAliases: {
85
+ type: "array",
86
+ items: { type: "string" }
87
+ },
88
+ webinyLocale: { type: "string" }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ },
95
+ integrations: {
96
+ type: "object",
97
+ additionalProperties: false,
98
+ properties: {
99
+ webiny: {
100
+ type: "object",
101
+ additionalProperties: false,
102
+ properties: {
103
+ enabled: { type: "boolean" },
104
+ sourceTableName: { type: "string" },
105
+ mirrorTableName: { type: "string" },
106
+ tenant: { type: "string" },
107
+ relevantModels: {
108
+ type: "array",
109
+ items: { type: "string" }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ };
117
+ }
118
+
119
+ async function fileExists(targetPath) {
120
+ try {
121
+ await fs.stat(targetPath);
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ async function writeProjectFile(targetPath, body, force = false) {
129
+ if (!force && await fileExists(targetPath)) {
130
+ throw new Error(`Refusing to overwrite existing file: ${targetPath}`);
131
+ }
132
+ await writeTextFile(targetPath, body);
133
+ }
134
+
135
+ async function loadRenderState(projectDir, environment) {
136
+ const statePath = path.join(projectDir, "offline", "S3TELocal", "render-state", `${environment}.json`);
137
+ try {
138
+ const raw = await fs.readFile(statePath, "utf8");
139
+ return { statePath, state: JSON.parse(raw) };
140
+ } catch {
141
+ return { statePath, state: { templates: {} } };
142
+ }
143
+ }
144
+
145
+ async function saveRenderState(statePath, state) {
146
+ await writeTextFile(statePath, JSON.stringify(state, null, 2) + "\n");
147
+ }
148
+
149
+ function renderStateKey(target) {
150
+ return `${target.variant}#${target.language}#${target.sourceKey}`;
151
+ }
152
+
153
+ function normalizeStringList(values) {
154
+ return [...new Set((values ?? [])
155
+ .map((value) => String(value).trim())
156
+ .filter(Boolean))];
157
+ }
158
+
159
+ export async function loadResolvedConfig(projectDir, configPath) {
160
+ const rawConfig = await loadProjectConfig(configPath);
161
+ const result = await validateAndResolveProjectConfig(rawConfig, { projectDir });
162
+ return { rawConfig, ...result };
163
+ }
164
+
165
+ export async function validateProject(projectDir, config, options = {}) {
166
+ const templateRepository = new FileSystemTemplateRepository(projectDir, config);
167
+ const contentRepository = await loadLocalContent(projectDir, config);
168
+ const warnings = [];
169
+ const errors = [];
170
+ const checkedTemplates = [];
171
+ const environments = options.environment ? [options.environment] : Object.keys(config.environments);
172
+
173
+ for (const environment of environments) {
174
+ for (const [variantName, variantConfig] of Object.entries(config.variants)) {
175
+ const entries = await templateRepository.listVariantEntries(variantName);
176
+ for (const entry of entries) {
177
+ if (!isRenderableKey(config, entry.key)) {
178
+ continue;
179
+ }
180
+
181
+ for (const languageCode of Object.keys(variantConfig.languages)) {
182
+ try {
183
+ const results = await renderSourceTemplate({
184
+ config,
185
+ templateRepository,
186
+ contentRepository,
187
+ environment,
188
+ variantName,
189
+ languageCode,
190
+ sourceKey: entry.key
191
+ });
192
+ warnings.push(...results.flatMap((result) => result.warnings));
193
+ checkedTemplates.push(`${environment}:${variantName}:${languageCode}:${entry.key}`);
194
+ } catch (error) {
195
+ errors.push({
196
+ code: error.code ?? "TEMPLATE_SYNTAX_ERROR",
197
+ message: error.message,
198
+ details: error.details
199
+ });
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ return {
207
+ ok: errors.length === 0,
208
+ errors,
209
+ warnings,
210
+ checkedTemplates
211
+ };
212
+ }
213
+
214
+ export async function scaffoldProject(projectDir, options = {}) {
215
+ const projectName = options.projectName ?? path.basename(projectDir).toLowerCase().replace(/[^a-z0-9-]/g, "-");
216
+ const baseUrl = options.baseUrl ?? "example.com";
217
+ const variant = options.variant ?? "website";
218
+ const language = options.language ?? "en";
219
+ const force = Boolean(options.force);
220
+
221
+ await ensureDirectory(path.join(projectDir, "app", "part"));
222
+ await ensureDirectory(path.join(projectDir, "app", variant));
223
+ await ensureDirectory(path.join(projectDir, "offline", "tests"));
224
+ await ensureDirectory(path.join(projectDir, "offline", "content"));
225
+ await ensureDirectory(path.join(projectDir, "offline", "schemas"));
226
+ await ensureDirectory(path.join(projectDir, ".vscode"));
227
+
228
+ const projectPackageJson = {
229
+ name: projectName,
230
+ private: true,
231
+ type: "module",
232
+ scripts: {
233
+ validate: "s3te validate",
234
+ render: "s3te render --env dev",
235
+ test: "s3te test"
236
+ }
237
+ };
238
+
239
+ const config = {
240
+ $schema: "./offline/schemas/s3te.config.schema.json",
241
+ configVersion: 1,
242
+ project: {
243
+ name: projectName
244
+ },
245
+ rendering: {
246
+ outputDir: "offline/S3TELocal/preview"
247
+ },
248
+ environments: {
249
+ dev: {
250
+ awsRegion: "eu-central-1",
251
+ stackPrefix: "DEV",
252
+ certificateArn: "arn:aws:acm:us-east-1:123456789012:certificate/replace-me"
253
+ }
254
+ },
255
+ variants: {
256
+ [variant]: {
257
+ sourceDir: `app/${variant}`,
258
+ partDir: "app/part",
259
+ defaultLanguage: language,
260
+ languages: {
261
+ [language]: {
262
+ baseUrl,
263
+ cloudFrontAliases: [baseUrl],
264
+ webinyLocale: language
265
+ }
266
+ }
267
+ }
268
+ }
269
+ };
270
+
271
+ await writeProjectFile(path.join(projectDir, "package.json"), JSON.stringify(projectPackageJson, null, 2) + "\n", force);
272
+ await writeProjectFile(path.join(projectDir, "s3te.config.json"), JSON.stringify(config, null, 2) + "\n", force);
273
+ await writeProjectFile(path.join(projectDir, "offline", "schemas", "s3te.config.schema.json"), JSON.stringify(schemaTemplate(), null, 2) + "\n", force);
274
+ await writeProjectFile(path.join(projectDir, "app", "part", "head.part"), "<meta charset='utf-8'>\n<title>My S3TE Site</title>\n", force);
275
+ await writeProjectFile(path.join(projectDir, "app", variant, "index.html"), "<!doctype html>\n<html lang=\"<lang>2</lang>\">\n <head>\n <part>head.part</part>\n </head>\n <body>\n <h1>Hello from S3TemplateEngine</h1>\n </body>\n</html>\n", force);
276
+ await writeProjectFile(path.join(projectDir, "offline", "content", `${language}.json`), "[]\n", force);
277
+ await writeProjectFile(path.join(projectDir, ".vscode", "extensions.json"), JSON.stringify({
278
+ recommendations: [
279
+ "redhat.vscode-yaml",
280
+ "amazonwebservices.aws-toolkit-vscode"
281
+ ]
282
+ }, null, 2) + "\n", force);
283
+ await writeProjectFile(path.join(projectDir, "offline", "tests", "project.test.mjs"), "import test from 'node:test';\nimport assert from 'node:assert/strict';\n\ntest('placeholder project test', () => {\n assert.equal(1, 1);\n});\n", force);
284
+
285
+ return { projectName, variant, language };
286
+ }
287
+
288
+ export async function renderProject(projectDir, config, options = {}) {
289
+ const templateRepository = new FileSystemTemplateRepository(projectDir, config);
290
+ const contentRepository = await loadLocalContent(projectDir, config);
291
+ const outputRoot = path.join(projectDir, options.outputDir ?? config.rendering.outputDir);
292
+ const templateEntries = [];
293
+
294
+ for (const variantName of Object.keys(config.variants)) {
295
+ templateEntries.push(...await templateRepository.listVariantEntries(variantName));
296
+ }
297
+
298
+ const targets = createManualRenderTargets({
299
+ config,
300
+ templateEntries,
301
+ environment: options.environment,
302
+ variant: options.variant,
303
+ language: options.language,
304
+ entry: options.entry
305
+ });
306
+
307
+ const { statePath, state } = await loadRenderState(projectDir, options.environment);
308
+ if (!options.entry) {
309
+ await removeDirectory(path.join(outputRoot, options.environment));
310
+ state.templates = {};
311
+ }
312
+
313
+ const renderedArtifacts = [];
314
+ const deletedArtifacts = [];
315
+ const warnings = [];
316
+
317
+ for (const target of targets) {
318
+ const results = await renderSourceTemplate({
319
+ config,
320
+ templateRepository,
321
+ contentRepository,
322
+ environment: target.environment,
323
+ variantName: target.variant,
324
+ languageCode: target.language,
325
+ sourceKey: target.sourceKey
326
+ });
327
+
328
+ const renderedOutputKeys = new Set();
329
+ for (const result of results) {
330
+ warnings.push(...result.warnings);
331
+ const targetPath = path.join(outputRoot, target.environment, target.variant, target.language, result.artifact.outputKey);
332
+ await writeTextFile(targetPath, String(result.artifact.body));
333
+ renderedArtifacts.push(normalizePath(path.relative(projectDir, targetPath)));
334
+ renderedOutputKeys.add(result.artifact.outputKey);
335
+ }
336
+
337
+ const stateKey = renderStateKey(target);
338
+ const previousOutputs = state.templates[stateKey] ?? [];
339
+ for (const previousOutput of previousOutputs) {
340
+ if (renderedOutputKeys.has(previousOutput)) {
341
+ continue;
342
+ }
343
+ const previousPath = path.join(outputRoot, target.environment, target.variant, target.language, previousOutput);
344
+ await fs.rm(previousPath, { force: true });
345
+ deletedArtifacts.push(normalizePath(path.relative(projectDir, previousPath)));
346
+ }
347
+ state.templates[stateKey] = [...renderedOutputKeys].sort();
348
+ }
349
+
350
+ for (const variantName of options.variant ? [options.variant] : Object.keys(config.variants)) {
351
+ const variantConfig = config.variants[variantName];
352
+ const variantEntries = await templateRepository.listVariantEntries(variantName);
353
+ for (const entry of variantEntries) {
354
+ if (isRenderableKey(config, entry.key)) {
355
+ continue;
356
+ }
357
+
358
+ const suffix = entry.key.slice(variantName.length + 1);
359
+ const sourcePath = path.join(projectDir, variantConfig.sourceDir, suffix);
360
+ for (const languageCode of options.language ? [options.language] : Object.keys(variantConfig.languages)) {
361
+ const targetPath = path.join(outputRoot, options.environment, variantName, languageCode, suffix);
362
+ await copyFile(sourcePath, targetPath);
363
+ renderedArtifacts.push(normalizePath(path.relative(projectDir, targetPath)));
364
+ }
365
+ }
366
+ }
367
+
368
+ await saveRenderState(statePath, state);
369
+
370
+ return {
371
+ outputDir: normalizePath(path.relative(projectDir, outputRoot)),
372
+ renderedArtifacts,
373
+ deletedArtifacts,
374
+ warnings
375
+ };
376
+ }
377
+
378
+ export async function runProjectTests(projectDir) {
379
+ const testsDir = await fileExists(path.join(projectDir, "offline", "tests"))
380
+ ? "offline/tests"
381
+ : "tests";
382
+ return new Promise((resolve) => {
383
+ const child = spawn(process.execPath, ["--test", testsDir], {
384
+ cwd: projectDir,
385
+ stdio: "inherit"
386
+ });
387
+ child.on("close", (code) => {
388
+ resolve(code ?? 1);
389
+ });
390
+ });
391
+ }
392
+
393
+ export async function packageProject(projectDir, config, options = {}) {
394
+ return packageAwsProject({
395
+ projectDir,
396
+ config,
397
+ environment: options.environment,
398
+ outDir: options.outDir,
399
+ clean: Boolean(options.clean),
400
+ features: options.features ?? []
401
+ });
402
+ }
403
+
404
+ export async function deployProject(projectDir, config, options = {}) {
405
+ return deployAwsProject({
406
+ projectDir,
407
+ config,
408
+ environment: options.environment,
409
+ packageDir: options.packageDir,
410
+ features: options.features ?? [],
411
+ profile: options.profile,
412
+ plan: Boolean(options.plan),
413
+ noSync: Boolean(options.noSync),
414
+ stdio: options.stdio ?? "pipe"
415
+ });
416
+ }
417
+
418
+ export async function doctorProject(projectDir, configPath, options = {}) {
419
+ const checks = [];
420
+ const majorVersion = Number(process.versions.node.split(".")[0]);
421
+
422
+ checks.push({
423
+ name: "node",
424
+ ok: majorVersion >= 20,
425
+ message: `Node version ${process.versions.node}`
426
+ });
427
+
428
+ try {
429
+ await fs.stat(configPath);
430
+ checks.push({ name: "config", ok: true, message: "s3te.config.json found" });
431
+ } catch {
432
+ checks.push({ name: "config", ok: false, message: "s3te.config.json missing" });
433
+ }
434
+
435
+ try {
436
+ await fs.access(projectDir, fs.constants.W_OK);
437
+ checks.push({ name: "write", ok: true, message: "Project is writable" });
438
+ } catch {
439
+ checks.push({ name: "write", ok: false, message: "Project is not writable" });
440
+ }
441
+
442
+ try {
443
+ await ensureAwsCliAvailable({ cwd: projectDir });
444
+ checks.push({ name: "aws-cli", ok: true, message: "AWS CLI available" });
445
+ } catch (error) {
446
+ checks.push({ name: "aws-cli", ok: false, message: error.message });
447
+ }
448
+
449
+ if (options.environment && options.config) {
450
+ try {
451
+ await ensureAwsCredentials({
452
+ region: options.config.environments[options.environment].awsRegion,
453
+ profile: options.profile,
454
+ cwd: projectDir
455
+ });
456
+ checks.push({ name: "aws-auth", ok: true, message: `AWS credentials valid for ${options.environment}` });
457
+ } catch (error) {
458
+ checks.push({ name: "aws-auth", ok: false, message: error.message });
459
+ }
460
+ }
461
+
462
+ return checks;
463
+ }
464
+
465
+ export async function migrateProject(configPath, rawConfig, writeChanges) {
466
+ const options = typeof writeChanges === "object" && writeChanges !== null
467
+ ? writeChanges
468
+ : { writeChanges };
469
+ const nextConfig = {
470
+ ...rawConfig,
471
+ configVersion: rawConfig.configVersion ?? 1
472
+ };
473
+ const changes = [];
474
+
475
+ const enableWebiny = Boolean(options.enableWebiny);
476
+ const disableWebiny = Boolean(options.disableWebiny);
477
+ const webinySourceTable = options.webinySourceTable ? String(options.webinySourceTable).trim() : "";
478
+ const webinyTenant = options.webinyTenant ? String(options.webinyTenant).trim() : "";
479
+ const webinyModels = normalizeStringList(options.webinyModels);
480
+
481
+ if (enableWebiny && disableWebiny) {
482
+ throw new S3teError("CONFIG_CONFLICT_ERROR", "migrate does not allow --enable-webiny and --disable-webiny at the same time.");
483
+ }
484
+
485
+ const touchesWebiny = enableWebiny || disableWebiny || Boolean(webinySourceTable) || webinyModels.length > 0;
486
+ if (touchesWebiny) {
487
+ const existingIntegrations = nextConfig.integrations ?? {};
488
+ const existingWebiny = existingIntegrations.webiny ?? {};
489
+ const existingModels = normalizeStringList(existingWebiny.relevantModels ?? ["staticContent", "staticCodeContent"]);
490
+ const shouldEnableWebiny = disableWebiny
491
+ ? false
492
+ : (enableWebiny || Boolean(webinySourceTable) || webinyModels.length > 0
493
+ ? true
494
+ : Boolean(existingWebiny.enabled));
495
+ const nextSourceTableName = webinySourceTable || existingWebiny.sourceTableName || "";
496
+
497
+ if (shouldEnableWebiny && !nextSourceTableName) {
498
+ throw new S3teError("CONFIG_CONFLICT_ERROR", "Enabling Webiny requires --webiny-source-table <table> or an existing integrations.webiny.sourceTableName.");
499
+ }
500
+
501
+ nextConfig.integrations = {
502
+ ...existingIntegrations,
503
+ webiny: {
504
+ enabled: shouldEnableWebiny,
505
+ sourceTableName: nextSourceTableName || undefined,
506
+ mirrorTableName: existingWebiny.mirrorTableName ?? "{stackPrefix}_s3te_content_{project}",
507
+ tenant: webinyTenant || existingWebiny.tenant || undefined,
508
+ relevantModels: normalizeStringList([
509
+ ...(existingModels.length > 0 ? existingModels : ["staticContent", "staticCodeContent"]),
510
+ ...webinyModels
511
+ ])
512
+ }
513
+ };
514
+
515
+ changes.push(shouldEnableWebiny ? "Enabled Webiny integration." : "Disabled Webiny integration.");
516
+ if (webinySourceTable) {
517
+ changes.push(`Set Webiny source table to ${webinySourceTable}.`);
518
+ }
519
+ if (webinyTenant) {
520
+ changes.push(`Set Webiny tenant to ${webinyTenant}.`);
521
+ }
522
+ if (webinyModels.length > 0) {
523
+ changes.push(`Added Webiny models: ${webinyModels.join(", ")}.`);
524
+ }
525
+ }
526
+
527
+ if (options.writeChanges) {
528
+ await writeTextFile(configPath, JSON.stringify(nextConfig, null, 2) + "\n");
529
+ }
530
+
531
+ return {
532
+ config: nextConfig,
533
+ changes
534
+ };
535
+ }