@projectdochelp/s3te 3.2.1 → 3.2.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/README.md CHANGED
@@ -18,6 +18,7 @@ This README is the user guide for the rewrite generation. The deeper implementat
18
18
  - [Daily Workflow](#daily-workflow)
19
19
  - [CLI Commands](#cli-commands)
20
20
  - [Template Commands](#template-commands)
21
+ - [Optional: Sitemap](#optional-sitemap)
21
22
  - [Optional: Webiny CMS](#optional-webiny-cms)
22
23
 
23
24
  ## Motivation
@@ -65,6 +66,8 @@ That source sync is not limited to Lambda code. It includes your `.html`, `.part
65
66
 
66
67
  The persistent environment stack contains the long-lived AWS resources such as buckets, Lambda functions, DynamoDB tables, CloudFront distributions and the runtime manifest parameter. The temporary deploy stack exists only so CloudFormation can consume the packaged Lambda artifacts cleanly.
67
68
 
69
+ If optional runtime features such as `sitemap` or Webiny are enabled in `s3te.config.json`, the same environment stack also carries those extra Lambdas and event bindings.
70
+
68
71
  </details>
69
72
 
70
73
  ## Installation (AWS)
@@ -226,13 +229,18 @@ The most important fields for a first deployment are:
226
229
 
227
230
  `route53HostedZoneId` is optional. Leave it out if you want to manage DNS yourself.
228
231
 
229
- Use plain hostnames in `baseUrl` and `cloudFrontAliases`, not full URLs. If your config contains a `prod` environment plus additional environments such as `test` or `stage`, S3TE keeps the `prod` hostname unchanged and derives non-production hostnames automatically by prepending `<env>.`.
232
+ Use plain hostnames in `baseUrl` and `cloudFrontAliases`, not full URLs. If your config contains a `prod` environment plus additional environments such as `test` or `stage`, S3TE keeps the `prod` hostname unchanged and derives non-production hostnames like this:
233
+
234
+ - apex host: `example.com` -> `test.example.com`
235
+ - first-level subdomain: `app.example.com` -> `test-app.example.com`
236
+ - deeper host: `admin.app.example.com` -> `test-admin.app.example.com`
230
237
 
231
238
  Your ACM certificate must cover the final derived aliases of the environment you deploy. Example:
232
239
 
233
240
  - `*.example.com` covers `test.example.com`
234
- - `*.example.com` does not cover `test.app.example.com`
235
- - for nested aliases like `test.app.example.com`, add a SAN such as `*.app.example.com`, the exact hostname, or use a different `certificateArn` for that environment
241
+ - `*.example.com` also covers `test-app.example.com`
242
+ - `*.example.com` does not cover `test-admin.app.example.com`
243
+ - for deeper aliases like `test-admin.app.example.com`, add a SAN such as `*.app.example.com`, the exact hostname, or use a different `certificateArn` for that environment
236
244
 
237
245
  </details>
238
246
 
@@ -476,7 +484,7 @@ Once Webiny is installed and the stack is deployed with Webiny enabled, CMS cont
476
484
  | `s3te sync --env <name>` | Uploads current project sources into the configured code buckets. |
477
485
  | `s3te doctor --env <name>` | Checks local machine and AWS access before deploy. |
478
486
  | `s3te deploy --env <name>` | Deploys or updates the AWS environment and syncs source files. |
479
- | `s3te migrate` | Updates older project configs and can retrofit Webiny into an existing S3TE project. |
487
+ | `s3te migrate` | Updates older project configs and can retrofit optional features such as `sitemap` or Webiny into an existing S3TE project. |
480
488
 
481
489
  </details>
482
490
 
@@ -538,6 +546,63 @@ These are the core S3TE commands you will use even in a plain HTML-only project.
538
546
 
539
547
  If you also want content-driven commands such as `dbmulti` or `dbmultifile`, continue with the optional Webiny section below. The same commands can also read from local `offline/content/*.json` files when you are not using Webiny yet.
540
548
 
549
+ ## Optional: Sitemap
550
+
551
+ You do not need `sitemap.xml` automation to use S3TE. If you want it, S3TE can maintain one `sitemap.xml` per published output bucket through a dedicated Lambda, just like the older 2.x generation.
552
+
553
+ <details>
554
+ <summary>Enable sitemap maintenance</summary>
555
+
556
+ Add this block to `s3te.config.json`:
557
+
558
+ ```json
559
+ "integrations": {
560
+ "sitemap": {
561
+ "enabled": true,
562
+ "environments": {
563
+ "dev": {
564
+ "enabled": false
565
+ }
566
+ }
567
+ }
568
+ }
569
+ ```
570
+
571
+ The top-level `enabled` acts as the default. `integrations.sitemap.environments.<env>.enabled` can override that for a single environment.
572
+
573
+ If you prefer the CLI path, this does the same retrofit:
574
+
575
+ ```bash
576
+ npx s3te migrate --enable-sitemap --write
577
+ npx s3te migrate --env test --enable-sitemap --write
578
+ ```
579
+
580
+ After enabling or disabling `sitemap`, redeploy the affected environment once:
581
+
582
+ ```bash
583
+ npx s3te deploy --env prod
584
+ ```
585
+
586
+ </details>
587
+
588
+ <details>
589
+ <summary>What the sitemap feature does</summary>
590
+
591
+ When `sitemap` is enabled for an environment, S3TE adds one `sitemap-updater` Lambda to that environment stack and wires every output bucket to it for HTML create/delete events.
592
+
593
+ The Lambda maintains `sitemap.xml` directly inside the same output bucket:
594
+
595
+ - one sitemap per variant/language output bucket
596
+ - only published HTML files are tracked
597
+ - `404.html` is ignored
598
+ - `index.html` becomes `https://example.com/`
599
+ - nested `news/index.html` becomes `https://example.com/news/`
600
+ - regular pages such as `about.html` stay `https://example.com/about.html`
601
+
602
+ Because the trigger sits on the output bucket, the sitemap also stays correct when HTML is regenerated from Webiny content changes in AWS. Asset-only changes do not affect it.
603
+
604
+ </details>
605
+
541
606
  ## Optional: Webiny CMS
542
607
 
543
608
  You do not need Webiny to use S3TE. Start with plain HTML first. Add Webiny only when editors should maintain content in a CMS instead of editing local JSON files under `offline/content/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectdochelp/s3te",
3
- "version": "3.2.1",
3
+ "version": "3.2.2",
4
4
  "description": "CLI, render core, AWS adapter, and testkit for S3TemplateEngine projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,7 +51,8 @@
51
51
  "@aws-sdk/client-sfn": "^3.0.0",
52
52
  "@aws-sdk/client-ssm": "^3.0.0",
53
53
  "@aws-sdk/lib-dynamodb": "^3.0.0",
54
- "@aws-sdk/util-dynamodb": "^3.0.0"
54
+ "@aws-sdk/util-dynamodb": "^3.0.0",
55
+ "fast-xml-parser": "^4.0.8"
55
56
  },
56
57
  "publishConfig": {
57
58
  "access": "public"
@@ -344,6 +344,7 @@ function buildEnvironmentStackParameters({
344
344
  `InvalidationSchedulerArtifactKey=${uploadedArtifacts.invalidationScheduler}`,
345
345
  `InvalidationExecutorArtifactKey=${uploadedArtifacts.invalidationExecutor}`,
346
346
  `ContentMirrorArtifactKey=${uploadedArtifacts.contentMirror}`,
347
+ `SitemapUpdaterArtifactKey=${uploadedArtifacts.sitemapUpdater}`,
347
348
  `RuntimeManifestValue=${runtimeManifestValue}`,
348
349
  `WebinySourceTableStreamArn=${webinyStreamArn}`
349
350
  ];
@@ -370,6 +371,9 @@ export async function deployAwsProject({
370
371
  if (requestedFeatureSet.has("webiny") && !runtimeConfig.integrations.webiny.enabled) {
371
372
  throw new S3teError("ADAPTER_ERROR", "Feature webiny was requested but is not enabled in s3te.config.json.");
372
373
  }
374
+ if (requestedFeatureSet.has("sitemap") && !runtimeConfig.integrations.sitemap.enabled) {
375
+ throw new S3teError("ADAPTER_ERROR", "Feature sitemap was requested but is not enabled in s3te.config.json.");
376
+ }
373
377
 
374
378
  await ensureAwsCliAvailable({ cwd: projectDir });
375
379
  await ensureAwsCredentials({
@@ -1,4 +1,7 @@
1
- import { resolveEnvironmentWebinyIntegration } from "../../core/src/index.mjs";
1
+ import {
2
+ resolveEnvironmentSitemapIntegration,
3
+ resolveEnvironmentWebinyIntegration
4
+ } from "../../core/src/index.mjs";
2
5
 
3
6
  export function getConfiguredFeatures(config, environment) {
4
7
  const features = [];
@@ -7,16 +10,25 @@ export function getConfiguredFeatures(config, environment) {
7
10
  if (resolveEnvironmentWebinyIntegration(config, environment).enabled) {
8
11
  features.push("webiny");
9
12
  }
13
+ if (resolveEnvironmentSitemapIntegration(config, environment).enabled) {
14
+ features.push("sitemap");
15
+ }
10
16
  return features;
11
17
  }
12
18
 
13
19
  const hasAnyEnvironmentWebiny = Object.keys(config.environments ?? {}).some((environmentName) => (
14
20
  resolveEnvironmentWebinyIntegration(config, environmentName).enabled
15
21
  ));
22
+ const hasAnyEnvironmentSitemap = Object.keys(config.environments ?? {}).some((environmentName) => (
23
+ resolveEnvironmentSitemapIntegration(config, environmentName).enabled
24
+ ));
16
25
 
17
26
  if (hasAnyEnvironmentWebiny) {
18
27
  features.push("webiny");
19
28
  }
29
+ if (hasAnyEnvironmentSitemap) {
30
+ features.push("sitemap");
31
+ }
20
32
 
21
33
  return features;
22
34
  }
@@ -30,7 +30,8 @@ function buildFunctionNames(runtimeConfig) {
30
30
  renderWorker: `${runtimeConfig.stackPrefix}_s3te_render_worker`,
31
31
  invalidationScheduler: `${runtimeConfig.stackPrefix}_s3te_invalidation_scheduler`,
32
32
  invalidationExecutor: `${runtimeConfig.stackPrefix}_s3te_invalidation_executor`,
33
- contentMirror: runtimeConfig.integrations.webiny.enabled ? `${runtimeConfig.stackPrefix}_s3te_content_mirror` : ""
33
+ contentMirror: runtimeConfig.integrations.webiny.enabled ? `${runtimeConfig.stackPrefix}_s3te_content_mirror` : "",
34
+ sitemapUpdater: runtimeConfig.integrations.sitemap.enabled ? `${runtimeConfig.stackPrefix}_s3te_sitemap_updater` : ""
34
35
  };
35
36
  }
36
37
 
@@ -79,6 +80,9 @@ export function buildAwsRuntimeManifest({ config, environment, stackOutputs = {}
79
80
  mirrorTableName: runtimeConfig.integrations.webiny.mirrorTableName,
80
81
  relevantModels: [...runtimeConfig.integrations.webiny.relevantModels],
81
82
  tenant: runtimeConfig.integrations.webiny.tenant
83
+ },
84
+ sitemap: {
85
+ enabled: runtimeConfig.integrations.sitemap.enabled
82
86
  }
83
87
  },
84
88
  variants: runtimeConfig.variants
@@ -25,7 +25,8 @@ const RUNTIME_PACKAGE_DEPENDENCIES = [
25
25
  "@aws-sdk/client-sfn",
26
26
  "@aws-sdk/client-ssm",
27
27
  "@aws-sdk/lib-dynamodb",
28
- "@aws-sdk/util-dynamodb"
28
+ "@aws-sdk/util-dynamodb",
29
+ "fast-xml-parser"
29
30
  ];
30
31
  const INTERNAL_RUNTIME_DIRECTORIES = [
31
32
  {
@@ -226,6 +227,9 @@ export async function packageAwsProject({
226
227
  if (features.includes("webiny") && !runtimeConfig.integrations.webiny.enabled) {
227
228
  throw new S3teError("ADAPTER_ERROR", "Feature webiny was requested but is not enabled in s3te.config.json.");
228
229
  }
230
+ if (features.includes("sitemap") && !runtimeConfig.integrations.sitemap.enabled) {
231
+ throw new S3teError("ADAPTER_ERROR", "Feature sitemap was requested but is not enabled in s3te.config.json.");
232
+ }
229
233
 
230
234
  const resolvedFeatures = resolveRequestedFeatures(config, features, environment);
231
235
  const packageDir = outDir
@@ -267,6 +271,11 @@ export async function packageAwsProject({
267
271
  archive: path.join(lambdaDir, "content-mirror.zip"),
268
272
  parameter: "ContentMirrorArtifactKey",
269
273
  s3Key: `lambda/content-mirror.zip`
274
+ },
275
+ sitemapUpdater: {
276
+ archive: path.join(lambdaDir, "sitemap-updater.zip"),
277
+ parameter: "SitemapUpdaterArtifactKey",
278
+ s3Key: "lambda/sitemap-updater.zip"
270
279
  }
271
280
  };
272
281
 
@@ -258,6 +258,9 @@ export function buildCoreConfigFromEnvironment(manifest, environmentName) {
258
258
  mirrorTableName: environment.integrations.webiny.mirrorTableName,
259
259
  relevantModels: [...environment.integrations.webiny.relevantModels],
260
260
  tenant: environment.integrations.webiny.tenant
261
+ },
262
+ sitemap: {
263
+ enabled: environment.integrations.sitemap?.enabled ?? false
261
264
  }
262
265
  }
263
266
  };
@@ -0,0 +1,221 @@
1
+ import { XMLBuilder, XMLParser } from "fast-xml-parser";
2
+
3
+ import {
4
+ createAwsClients,
5
+ decodeS3Key,
6
+ loadEnvironmentManifest
7
+ } from "./common.mjs";
8
+
9
+ const SITEMAP_XML_NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9";
10
+ const parser = new XMLParser({
11
+ ignoreAttributes: false,
12
+ isArray: (name) => name === "url"
13
+ });
14
+ const builder = new XMLBuilder({
15
+ ignoreAttributes: false,
16
+ format: true
17
+ });
18
+
19
+ function createEmptySitemapDocument() {
20
+ return {
21
+ "?xml": {
22
+ "@_version": "1.0",
23
+ "@_encoding": "UTF-8"
24
+ },
25
+ urlset: {
26
+ "@_xmlns": SITEMAP_XML_NAMESPACE,
27
+ url: []
28
+ }
29
+ };
30
+ }
31
+
32
+ async function bodyToUtf8(body) {
33
+ if (typeof body?.transformToString === "function") {
34
+ return body.transformToString("utf8");
35
+ }
36
+
37
+ if (Buffer.isBuffer(body)) {
38
+ return body.toString("utf8");
39
+ }
40
+
41
+ if (body instanceof Uint8Array) {
42
+ return Buffer.from(body).toString("utf8");
43
+ }
44
+
45
+ return String(body ?? "");
46
+ }
47
+
48
+ export function normalizeSitemapDocument(document) {
49
+ const candidate = document?.urlset ? document : createEmptySitemapDocument();
50
+ const urls = candidate?.urlset?.url;
51
+
52
+ return {
53
+ "?xml": {
54
+ "@_version": candidate?.["?xml"]?.["@_version"] ?? "1.0",
55
+ "@_encoding": candidate?.["?xml"]?.["@_encoding"] ?? "UTF-8"
56
+ },
57
+ urlset: {
58
+ "@_xmlns": candidate?.urlset?.["@_xmlns"] ?? SITEMAP_XML_NAMESPACE,
59
+ url: Array.isArray(urls)
60
+ ? urls.filter((entry) => entry?.loc)
61
+ : (urls?.loc ? [urls] : [])
62
+ }
63
+ };
64
+ }
65
+
66
+ async function loadCurrentSitemap(s3, bucketName) {
67
+ try {
68
+ const response = await s3.getObject({
69
+ Bucket: bucketName,
70
+ Key: "sitemap.xml"
71
+ }).promise();
72
+ return normalizeSitemapDocument(parser.parse(await bodyToUtf8(response.Body)));
73
+ } catch (error) {
74
+ const errorCode = error?.name ?? error?.Code ?? error?.code;
75
+ if (errorCode === "NoSuchKey" || errorCode === "NoSuchBucket" || errorCode === "NotFound") {
76
+ return createEmptySitemapDocument();
77
+ }
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ export function findLanguageTargetByBucket(environmentManifest, bucketName) {
83
+ for (const [variantName, variantConfig] of Object.entries(environmentManifest.variants ?? {})) {
84
+ for (const [languageCode, languageConfig] of Object.entries(variantConfig.languages ?? {})) {
85
+ if (languageConfig.targetBucket === bucketName) {
86
+ return {
87
+ variantName,
88
+ variantConfig,
89
+ languageCode,
90
+ languageConfig
91
+ };
92
+ }
93
+ }
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ function encodePathSegments(key) {
100
+ return String(key)
101
+ .split("/")
102
+ .filter((segment) => segment.length > 0)
103
+ .map((segment) => encodeURIComponent(segment))
104
+ .join("/");
105
+ }
106
+
107
+ export function buildSitemapUrl({ baseUrl, key, indexDocument, notFoundDocument }) {
108
+ const normalizedKey = String(key ?? "").replace(/^\/+/, "");
109
+ if (!normalizedKey || normalizedKey === "sitemap.xml" || normalizedKey === notFoundDocument) {
110
+ return null;
111
+ }
112
+
113
+ const normalizedBaseUrl = String(baseUrl ?? "").replace(/^https?:\/\//i, "").replace(/\/+$/, "");
114
+ if (!normalizedBaseUrl) {
115
+ return null;
116
+ }
117
+
118
+ if (normalizedKey === indexDocument) {
119
+ return `https://${normalizedBaseUrl}/`;
120
+ }
121
+
122
+ if (normalizedKey.endsWith(`/${indexDocument}`)) {
123
+ const directoryKey = normalizedKey.slice(0, -(indexDocument.length + 1));
124
+ const encodedDirectory = encodePathSegments(directoryKey);
125
+ return `https://${normalizedBaseUrl}/${encodedDirectory}/`;
126
+ }
127
+
128
+ return `https://${normalizedBaseUrl}/${encodePathSegments(normalizedKey)}`;
129
+ }
130
+
131
+ export function applySitemapRecords(sitemapDocument, sitemapRecords = []) {
132
+ const normalizedDocument = normalizeSitemapDocument(sitemapDocument);
133
+ const entries = new Map((normalizedDocument.urlset.url ?? []).map((entry) => [entry.loc, {
134
+ loc: entry.loc,
135
+ lastmod: entry.lastmod
136
+ }]));
137
+
138
+ for (const record of sitemapRecords) {
139
+ if (!record?.loc) {
140
+ continue;
141
+ }
142
+
143
+ const lastmod = String(record.lastmod ?? new Date().toISOString()).slice(0, 10);
144
+ if (record.action === "delete") {
145
+ entries.delete(record.loc);
146
+ continue;
147
+ }
148
+
149
+ entries.set(record.loc, {
150
+ loc: record.loc,
151
+ lastmod
152
+ });
153
+ }
154
+
155
+ normalizedDocument.urlset.url = [...entries.values()].sort((left, right) => left.loc.localeCompare(right.loc));
156
+ return normalizedDocument;
157
+ }
158
+
159
+ export async function handler(event) {
160
+ const environmentName = process.env.S3TE_ENVIRONMENT;
161
+ const runtimeParameter = process.env.S3TE_RUNTIME_PARAMETER;
162
+
163
+ const clients = createAwsClients();
164
+ const { environment: environmentManifest } = await loadEnvironmentManifest(
165
+ clients.ssm,
166
+ runtimeParameter,
167
+ environmentName
168
+ );
169
+
170
+ const updatesByBucket = new Map();
171
+
172
+ for (const record of event.Records ?? []) {
173
+ const bucketName = record.s3?.bucket?.name;
174
+ const key = decodeS3Key(record.s3?.object?.key ?? "");
175
+ const target = findLanguageTargetByBucket(environmentManifest, bucketName);
176
+ if (!bucketName || !key || !target) {
177
+ continue;
178
+ }
179
+
180
+ const loc = buildSitemapUrl({
181
+ baseUrl: target.languageConfig.baseUrl,
182
+ key,
183
+ indexDocument: target.variantConfig.routing.indexDocument,
184
+ notFoundDocument: target.variantConfig.routing.notFoundDocument
185
+ });
186
+ if (!loc) {
187
+ continue;
188
+ }
189
+
190
+ if (!updatesByBucket.has(bucketName)) {
191
+ updatesByBucket.set(bucketName, []);
192
+ }
193
+ updatesByBucket.get(bucketName).push({
194
+ action: String(record.eventName).startsWith("ObjectRemoved:") ? "delete" : "upsert",
195
+ loc,
196
+ lastmod: record.eventTime
197
+ });
198
+ }
199
+
200
+ let updatedBuckets = 0;
201
+
202
+ for (const [bucketName, updates] of updatesByBucket.entries()) {
203
+ const sitemapDocument = applySitemapRecords(
204
+ await loadCurrentSitemap(clients.s3, bucketName),
205
+ updates
206
+ );
207
+
208
+ await clients.s3.putObject({
209
+ Bucket: bucketName,
210
+ Key: "sitemap.xml",
211
+ Body: builder.build(sitemapDocument),
212
+ ContentType: "application/xml; charset=utf-8"
213
+ }).promise();
214
+
215
+ updatedBuckets += 1;
216
+ }
217
+
218
+ return {
219
+ updatedBuckets
220
+ };
221
+ }
@@ -35,6 +35,26 @@ function lambdaCode(keyParameter) {
35
35
  };
36
36
  }
37
37
 
38
+ function buildSitemapNotificationConfigurations(functionLogicalId) {
39
+ const suffixes = [".html", ".htm"];
40
+ const events = ["s3:ObjectCreated:*", "s3:ObjectRemoved:*"];
41
+
42
+ return events.flatMap((eventName) => suffixes.map((suffix) => ({
43
+ Event: eventName,
44
+ Function: { "Fn::GetAtt": [functionLogicalId, "Arn"] },
45
+ Filter: {
46
+ S3Key: {
47
+ Rules: [
48
+ {
49
+ Name: "suffix",
50
+ Value: suffix
51
+ }
52
+ ]
53
+ }
54
+ }
55
+ })));
56
+ }
57
+
38
58
  function lambdaRuntimeProperties(runtimeConfig, roleRef, name, keyParameter, handlerName, extra = {}) {
39
59
  return {
40
60
  Type: "AWS::Lambda::Function",
@@ -117,7 +137,8 @@ export function buildCloudFormationTemplate({ config, environment, features = []
117
137
  renderWorker: `${runtimeConfig.stackPrefix}_s3te_render_worker`,
118
138
  invalidationScheduler: `${runtimeConfig.stackPrefix}_s3te_invalidation_scheduler`,
119
139
  invalidationExecutor: `${runtimeConfig.stackPrefix}_s3te_invalidation_executor`,
120
- contentMirror: `${runtimeConfig.stackPrefix}_s3te_content_mirror`
140
+ contentMirror: `${runtimeConfig.stackPrefix}_s3te_content_mirror`,
141
+ sitemapUpdater: `${runtimeConfig.stackPrefix}_s3te_sitemap_updater`
121
142
  };
122
143
 
123
144
  const parameters = {
@@ -140,6 +161,10 @@ export function buildCloudFormationTemplate({ config, environment, features = []
140
161
  Type: "String",
141
162
  Default: ""
142
163
  },
164
+ SitemapUpdaterArtifactKey: {
165
+ Type: "String",
166
+ Default: ""
167
+ },
143
168
  RuntimeManifestValue: {
144
169
  Type: "String",
145
170
  Default: "{}"
@@ -369,6 +394,26 @@ export function buildCloudFormationTemplate({ config, environment, features = []
369
394
  };
370
395
  }
371
396
 
397
+ if (featureSet.has("sitemap") && runtimeConfig.integrations.sitemap.enabled) {
398
+ resources.SitemapUpdater = lambdaRuntimeProperties(
399
+ runtimeConfig,
400
+ "ExecutionRole",
401
+ functionNames.sitemapUpdater,
402
+ "SitemapUpdaterArtifactKey",
403
+ "sitemap-updater",
404
+ {
405
+ Timeout: 300,
406
+ MemorySize: 512,
407
+ Environment: {
408
+ Variables: {
409
+ S3TE_ENVIRONMENT: environment,
410
+ S3TE_RUNTIME_PARAMETER: runtimeConfig.runtimeParameterName
411
+ }
412
+ }
413
+ }
414
+ );
415
+ }
416
+
372
417
  outputs.StackName = { Value: runtimeConfig.stackName };
373
418
  outputs.RuntimeManifestParameterName = {
374
419
  Value: runtimeConfig.runtimeParameterName
@@ -384,6 +429,9 @@ export function buildCloudFormationTemplate({ config, environment, features = []
384
429
  if (resources.ContentMirror) {
385
430
  outputs.ContentMirrorFunctionName = { Value: functionNames.contentMirror };
386
431
  }
432
+ if (resources.SitemapUpdater) {
433
+ outputs.SitemapUpdaterFunctionName = { Value: functionNames.sitemapUpdater };
434
+ }
387
435
 
388
436
  for (const [variantName, variantConfig] of Object.entries(runtimeConfig.variants)) {
389
437
  const codeBucketLogicalId = cfName(sanitizeLogicalId(variantName), "CodeBucket");
@@ -417,12 +465,20 @@ export function buildCloudFormationTemplate({ config, environment, features = []
417
465
 
418
466
  resources[outputBucketLogicalId] = {
419
467
  Type: "AWS::S3::Bucket",
468
+ ...(resources.SitemapUpdater ? { DependsOn: ["SitemapUpdaterPermission"] } : {}),
420
469
  Properties: {
421
470
  BucketName: languageConfig.targetBucket,
422
471
  WebsiteConfiguration: {
423
472
  IndexDocument: variantConfig.routing.indexDocument,
424
473
  ErrorDocument: variantConfig.routing.notFoundDocument
425
474
  },
475
+ ...(resources.SitemapUpdater
476
+ ? {
477
+ NotificationConfiguration: {
478
+ LambdaConfigurations: buildSitemapNotificationConfigurations("SitemapUpdater")
479
+ }
480
+ }
481
+ : {}),
426
482
  PublicAccessBlockConfiguration: {
427
483
  BlockPublicAcls: false,
428
484
  BlockPublicPolicy: false,
@@ -544,6 +600,17 @@ export function buildCloudFormationTemplate({ config, environment, features = []
544
600
  }
545
601
  };
546
602
 
603
+ if (resources.SitemapUpdater) {
604
+ resources.SitemapUpdaterPermission = {
605
+ Type: "AWS::Lambda::Permission",
606
+ Properties: {
607
+ Action: "lambda:InvokeFunction",
608
+ FunctionName: { Ref: "SitemapUpdater" },
609
+ Principal: "s3.amazonaws.com"
610
+ }
611
+ };
612
+ }
613
+
547
614
  return {
548
615
  AWSTemplateFormatVersion: "2010-09-09",
549
616
  Description: `S3TE environment stack for ${config.project.name} (${environment})`,
@@ -385,6 +385,8 @@ async function main() {
385
385
  environment: asArray(options.env)[0],
386
386
  enableWebiny: Boolean(options["enable-webiny"]),
387
387
  disableWebiny: Boolean(options["disable-webiny"]),
388
+ enableSitemap: Boolean(options["enable-sitemap"]),
389
+ disableSitemap: Boolean(options["disable-sitemap"]),
388
390
  webinySourceTable: options["webiny-source-table"],
389
391
  webinyTenant: options["webiny-tenant"],
390
392
  webinyModels: asArray(options["webiny-model"])
@@ -335,6 +335,23 @@ function schemaTemplate() {
335
335
  }
336
336
  }
337
337
  }
338
+ },
339
+ sitemap: {
340
+ type: "object",
341
+ additionalProperties: false,
342
+ properties: {
343
+ enabled: { type: "boolean" },
344
+ environments: {
345
+ type: "object",
346
+ additionalProperties: {
347
+ type: "object",
348
+ additionalProperties: false,
349
+ properties: {
350
+ enabled: { type: "boolean" }
351
+ }
352
+ }
353
+ }
354
+ }
338
355
  }
339
356
  }
340
357
  }
@@ -986,6 +1003,8 @@ export async function migrateProject(configPath, rawConfig, writeChanges) {
986
1003
 
987
1004
  const enableWebiny = Boolean(options.enableWebiny);
988
1005
  const disableWebiny = Boolean(options.disableWebiny);
1006
+ const enableSitemap = Boolean(options.enableSitemap);
1007
+ const disableSitemap = Boolean(options.disableSitemap);
989
1008
  const targetEnvironment = options.environment ? String(options.environment).trim() : "";
990
1009
  const webinySourceTable = options.webinySourceTable ? String(options.webinySourceTable).trim() : "";
991
1010
  const webinyTenant = options.webinyTenant ? String(options.webinyTenant).trim() : "";
@@ -994,6 +1013,9 @@ export async function migrateProject(configPath, rawConfig, writeChanges) {
994
1013
  if (enableWebiny && disableWebiny) {
995
1014
  throw new S3teError("CONFIG_CONFLICT_ERROR", "migrate does not allow --enable-webiny and --disable-webiny at the same time.");
996
1015
  }
1016
+ if (enableSitemap && disableSitemap) {
1017
+ throw new S3teError("CONFIG_CONFLICT_ERROR", "migrate does not allow --enable-sitemap and --disable-sitemap at the same time.");
1018
+ }
997
1019
 
998
1020
  const touchesWebiny = enableWebiny || disableWebiny || Boolean(webinySourceTable) || Boolean(webinyTenant) || webinyModels.length > 0;
999
1021
  if (touchesWebiny) {
@@ -1076,6 +1098,48 @@ export async function migrateProject(configPath, rawConfig, writeChanges) {
1076
1098
  }
1077
1099
  }
1078
1100
 
1101
+ const touchesSitemap = enableSitemap || disableSitemap;
1102
+ if (touchesSitemap) {
1103
+ if (targetEnvironment && !nextConfig.environments?.[targetEnvironment]) {
1104
+ throw new S3teError("CONFIG_CONFLICT_ERROR", `Unknown environment for migrate: ${targetEnvironment}.`);
1105
+ }
1106
+
1107
+ const existingIntegrations = nextConfig.integrations ?? {};
1108
+ const existingSitemap = existingIntegrations.sitemap ?? {};
1109
+ const existingEnvironmentOverrides = existingSitemap.environments ?? {};
1110
+ const existingTargetSitemap = targetEnvironment
1111
+ ? (existingEnvironmentOverrides[targetEnvironment] ?? {})
1112
+ : existingSitemap;
1113
+ const nextEnabled = disableSitemap
1114
+ ? false
1115
+ : (enableSitemap || Boolean(targetEnvironment
1116
+ ? (existingTargetSitemap.enabled ?? existingSitemap.enabled)
1117
+ : existingSitemap.enabled));
1118
+
1119
+ nextConfig.integrations = {
1120
+ ...existingIntegrations,
1121
+ sitemap: targetEnvironment
1122
+ ? {
1123
+ ...existingSitemap,
1124
+ environments: {
1125
+ ...existingEnvironmentOverrides,
1126
+ [targetEnvironment]: {
1127
+ ...existingTargetSitemap,
1128
+ enabled: nextEnabled
1129
+ }
1130
+ }
1131
+ }
1132
+ : {
1133
+ ...existingSitemap,
1134
+ enabled: nextEnabled,
1135
+ environments: existingEnvironmentOverrides
1136
+ }
1137
+ };
1138
+
1139
+ const scopeLabel = targetEnvironment ? ` for environment ${targetEnvironment}` : "";
1140
+ changes.push(nextEnabled ? `Enabled sitemap integration${scopeLabel}.` : `Disabled sitemap integration${scopeLabel}.`);
1141
+ }
1142
+
1079
1143
  if (options.writeChanges) {
1080
1144
  await writeTextFile(configPath, JSON.stringify(nextConfig, null, 2) + "\n");
1081
1145
  }
@@ -75,12 +75,27 @@ function environmentHostPrefix(config, environmentName) {
75
75
  }
76
76
 
77
77
  function prefixHostForEnvironment(config, host, environmentName) {
78
- const prefix = environmentHostPrefix(config, environmentName);
79
- if (!prefix) {
78
+ if (!hasProductionEnvironment(config) || isProductionEnvironment(environmentName)) {
80
79
  return host;
81
80
  }
82
81
 
83
- return host.startsWith(prefix) ? host : `${prefix}${host}`;
82
+ const normalizedHost = String(host).trim();
83
+ if (!normalizedHost) {
84
+ return normalizedHost;
85
+ }
86
+
87
+ if (normalizedHost.startsWith(`${environmentName}.`) || normalizedHost.startsWith(`${environmentName}-`)) {
88
+ return normalizedHost;
89
+ }
90
+
91
+ const labels = normalizedHost.split(".");
92
+ if (labels.length <= 2) {
93
+ const prefix = environmentHostPrefix(config, environmentName);
94
+ return `${prefix}${normalizedHost}`;
95
+ }
96
+
97
+ const [firstLabel, ...remainingLabels] = labels;
98
+ return `${environmentName}-${firstLabel}.${remainingLabels.join(".")}`;
84
99
  }
85
100
 
86
101
  function isValidConfiguredHost(value) {
@@ -143,6 +158,12 @@ function resolveWebinyConfigDefaults(webinyConfig = {}) {
143
158
  };
144
159
  }
145
160
 
161
+ function resolveSitemapConfigDefaults(sitemapConfig = {}) {
162
+ return {
163
+ enabled: sitemapConfig.enabled ?? false
164
+ };
165
+ }
166
+
146
167
  function resolveProjectWebinyConfig(projectConfig) {
147
168
  const baseConfig = resolveWebinyConfigDefaults(projectConfig.integrations?.webiny ?? {});
148
169
  const environmentConfigs = Object.fromEntries(Object.entries(projectConfig.integrations?.webiny?.environments ?? {}).map(([environmentName, webinyConfig]) => ([
@@ -162,6 +183,21 @@ function resolveProjectWebinyConfig(projectConfig) {
162
183
  };
163
184
  }
164
185
 
186
+ function resolveProjectSitemapConfig(projectConfig) {
187
+ const baseConfig = resolveSitemapConfigDefaults(projectConfig.integrations?.sitemap ?? {});
188
+ const environmentConfigs = Object.fromEntries(Object.entries(projectConfig.integrations?.sitemap?.environments ?? {}).map(([environmentName, sitemapConfig]) => ([
189
+ environmentName,
190
+ {
191
+ enabled: sitemapConfig.enabled
192
+ }
193
+ ])));
194
+
195
+ return {
196
+ ...baseConfig,
197
+ environments: environmentConfigs
198
+ };
199
+ }
200
+
165
201
  export async function loadProjectConfig(configPath) {
166
202
  const raw = await fs.readFile(configPath, "utf8");
167
203
  try {
@@ -247,7 +283,8 @@ export function resolveProjectConfig(projectConfig) {
247
283
  };
248
284
 
249
285
  const integrations = {
250
- webiny: resolveProjectWebinyConfig(projectConfig)
286
+ webiny: resolveProjectWebinyConfig(projectConfig),
287
+ sitemap: resolveProjectSitemapConfig(projectConfig)
251
288
  };
252
289
 
253
290
  for (const [variantName, variantConfig] of Object.entries(variants)) {
@@ -317,6 +354,15 @@ export function resolveEnvironmentWebinyIntegration(config, environmentName) {
317
354
  };
318
355
  }
319
356
 
357
+ export function resolveEnvironmentSitemapIntegration(config, environmentName) {
358
+ const baseConfig = resolveSitemapConfigDefaults(config.integrations?.sitemap ?? {});
359
+ const environmentOverride = config.integrations?.sitemap?.environments?.[environmentName] ?? {};
360
+
361
+ return {
362
+ enabled: environmentOverride.enabled ?? baseConfig.enabled
363
+ };
364
+ }
365
+
320
366
  export function resolveTableNames(config, environmentName) {
321
367
  const context = createPlaceholderContext(config, environmentName);
322
368
  const webinyConfig = resolveEnvironmentWebinyIntegration(config, environmentName);
@@ -340,6 +386,7 @@ export function resolveStackName(config, environmentName) {
340
386
  export function buildEnvironmentRuntimeConfig(config, environmentName, stackOutputs = {}) {
341
387
  const environmentConfig = config.environments[environmentName];
342
388
  const webinyConfig = resolveEnvironmentWebinyIntegration(config, environmentName);
389
+ const sitemapConfig = resolveEnvironmentSitemapIntegration(config, environmentName);
343
390
  const tables = resolveTableNames(config, environmentName);
344
391
  const runtimeParameterName = resolveRuntimeManifestParameterName(config, environmentName);
345
392
  const stackName = resolveStackName(config, environmentName);
@@ -388,6 +435,9 @@ export function buildEnvironmentRuntimeConfig(config, environmentName, stackOutp
388
435
  webiny: {
389
436
  ...webinyConfig,
390
437
  mirrorTableName: tables.webinyMirror
438
+ },
439
+ sitemap: {
440
+ ...sitemapConfig
391
441
  }
392
442
  },
393
443
  variants
@@ -502,6 +552,7 @@ export async function validateAndResolveProjectConfig(projectConfig, options = {
502
552
  }
503
553
 
504
554
  const configuredWebiny = projectConfig.integrations?.webiny;
555
+ const configuredSitemap = projectConfig.integrations?.sitemap;
505
556
  for (const [environmentName] of environmentEntries) {
506
557
  const environmentWebinyConfig = resolveEnvironmentWebinyIntegration(resolveProjectConfig({
507
558
  ...projectConfig,
@@ -524,6 +575,15 @@ export async function validateAndResolveProjectConfig(projectConfig, options = {
524
575
  }
525
576
  }
526
577
 
578
+ for (const environmentName of Object.keys(configuredSitemap?.environments ?? {})) {
579
+ if (!projectConfig.environments?.[environmentName]) {
580
+ errors.push({
581
+ code: "CONFIG_CONFLICT_ERROR",
582
+ message: `integrations.sitemap.environments.${environmentName} does not match a configured environment.`
583
+ });
584
+ }
585
+ }
586
+
527
587
  for (const [variantName, pattern] of Object.entries(projectConfig.aws?.codeBuckets ?? {})) {
528
588
  ensureKnownPlaceholders(pattern, `aws.codeBuckets.${variantName}`, errors);
529
589
  }
@@ -7,6 +7,7 @@ export {
7
7
  resolveBaseUrl,
8
8
  resolveCodeBucketName,
9
9
  resolveCloudFrontAliases,
10
+ resolveEnvironmentSitemapIntegration,
10
11
  resolveEnvironmentWebinyIntegration,
11
12
  resolveRuntimeManifestParameterName,
12
13
  resolveProjectConfig,