@projectdochelp/s3te 3.3.2 → 3.4.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/README.md CHANGED
@@ -447,18 +447,22 @@ Once the project is installed, your everyday loop splits into two paths: deploy
447
447
 
448
448
  1. Edit files in `app/part/` and `app/website/`.
449
449
  2. If you use content-driven tags without Webiny, edit `offline/content/en.json` or `offline/content/items.json`.
450
- 3. Validate and render locally.
451
- 4. Run your tests.
452
- 5. Use `deploy` for the first installation or after infrastructure/config/runtime changes.
453
- 6. Use `sync` for day-to-day source publishing into the code buckets.
450
+ 3. If you use Webiny and want the current mirrored live content locally, download the content snapshot first.
451
+ 4. Validate and render locally.
452
+ 5. Run your tests.
453
+ 6. Use `deploy` for the first installation or after infrastructure/config/runtime changes.
454
+ 7. Use `sync` for day-to-day source publishing into the code buckets.
454
455
 
455
456
  ```bash
457
+ npx s3te download-content --env dev
456
458
  npx s3te validate
457
459
  npx s3te render --env dev
458
460
  npx s3te test
459
461
  npx s3te sync --env dev
460
462
  ```
461
463
 
464
+ If you are not using Webiny yet, skip `download-content` and keep editing the local JSON files directly.
465
+
462
466
  Use a full deploy only when needed:
463
467
 
464
468
  ```bash
@@ -480,6 +484,7 @@ Once Webiny is installed and the stack is deployed with Webiny enabled, CMS cont
480
484
  | `s3te validate` | Checks config and template syntax without rendering outputs. |
481
485
  | `s3te render --env <name>` | Renders locally into `offline/S3TELocal/preview/<env>/...`. |
482
486
  | `s3te test` | Runs the project tests from `offline/tests/`. |
487
+ | `s3te download-content --env <name>` | Downloads the mirrored S3TE content table into `offline/content/items.json` for local render and test runs. |
483
488
  | `s3te package --env <name>` | Builds the AWS deployment artifacts without deploying them yet. |
484
489
  | `s3te sync --env <name>` | Uploads current project sources into the configured code buckets. |
485
490
  | `s3te doctor --env <name>` | Checks local machine and AWS access before deploy. |
@@ -1039,6 +1044,16 @@ npx s3te deploy --env prod
1039
1044
 
1040
1045
  That deploy updates the existing environment stack and, when Webiny is enabled, also deploys the separate Webiny option stack for `content-mirror` and its DynamoDB stream mapping. You do not need a fresh S3TE installation. After that, Webiny content changes flow through the deployed AWS resources automatically; only template or asset changes still need `s3te sync --env <name>`.
1041
1046
 
1047
+ 10. To test current live CMS content locally, pull the mirrored S3TE content table into the local fixture folder before rendering or running tests:
1048
+
1049
+ ```bash
1050
+ npx s3te download-content --env prod
1051
+ npx s3te render --env prod
1052
+ npx s3te test
1053
+ ```
1054
+
1055
+ `download-content` reads the mirrored S3TE content table, keeps only the newest mirrored item per `contentId` and locale, and writes the result to `offline/content/items.json`. The normal local `dbpart`, `dbmulti`, `dbmultifile`, `dbitem`, and `dbmultifileitem` flow then reads from that file.
1056
+
1042
1057
  Manual versus automatic responsibilities in this step:
1043
1058
 
1044
1059
  - Manual: enable DynamoDB Streams on the Webiny source table
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectdochelp/s3te",
3
- "version": "3.3.2",
3
+ "version": "3.4.0",
4
4
  "description": "CLI, render core, AWS adapter, and testkit for S3TemplateEngine projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -76,8 +76,37 @@ function localeMatchScore(itemLocale, language, languageLocaleMap) {
76
76
  return 0;
77
77
  }
78
78
 
79
- function matchesRequestedLocale(item, language, languageLocaleMap) {
80
- return localeMatchScore(item?.locale, language, languageLocaleMap) > 0;
79
+ function comparableTimestamp(value) {
80
+ if (typeof value === "number" && Number.isFinite(value)) {
81
+ return value;
82
+ }
83
+
84
+ const timestamp = Date.parse(String(value ?? ""));
85
+ return Number.isFinite(timestamp) ? timestamp : -1;
86
+ }
87
+
88
+ function compareContentFreshness(left, right) {
89
+ const updatedDiff = comparableTimestamp(right.updatedAt) - comparableTimestamp(left.updatedAt);
90
+ if (updatedDiff !== 0) {
91
+ return updatedDiff;
92
+ }
93
+
94
+ const changedDiff = comparableTimestamp(right.lastChangedAt) - comparableTimestamp(left.lastChangedAt);
95
+ if (changedDiff !== 0) {
96
+ return changedDiff;
97
+ }
98
+
99
+ const createdDiff = comparableTimestamp(right.createdAt) - comparableTimestamp(left.createdAt);
100
+ if (createdDiff !== 0) {
101
+ return createdDiff;
102
+ }
103
+
104
+ const versionDiff = Number(right.version ?? -1) - Number(left.version ?? -1);
105
+ if (versionDiff !== 0) {
106
+ return versionDiff;
107
+ }
108
+
109
+ return String(right.id ?? "").localeCompare(String(left.id ?? ""));
81
110
  }
82
111
 
83
112
  function filterItemsByRequestedLocale(items, language, languageLocaleMap) {
@@ -103,7 +132,11 @@ function filterItemsByRequestedLocale(items, language, languageLocaleMap) {
103
132
  }
104
133
 
105
134
  const bestScore = Math.max(...scored.map((entry) => entry.score));
106
- return scored.filter((entry) => entry.score === bestScore).map((entry) => entry.item);
135
+ return scored
136
+ .filter((entry) => entry.score === bestScore)
137
+ .map((entry) => entry.item)
138
+ .sort(compareContentFreshness)
139
+ .slice(0, 1);
107
140
  });
108
141
  }
109
142
 
@@ -390,14 +423,7 @@ export class DynamoContentRepository {
390
423
  }
391
424
  }).promise();
392
425
  const items = response.Items ?? [];
393
- const candidates = items
394
- .map((item) => ({
395
- item,
396
- score: localeMatchScore(item.locale, language, this.languageLocaleMap)
397
- }))
398
- .filter((entry) => entry.score > 0)
399
- .sort((left, right) => right.score - left.score || String(left.item.id).localeCompare(String(right.item.id)));
400
- return candidates[0]?.item ?? null;
426
+ return filterItemsByRequestedLocale(items, language, this.languageLocaleMap)[0] ?? null;
401
427
  }
402
428
 
403
429
  async query(query, language) {
@@ -314,6 +314,17 @@ function extractWebinyTenant(item) {
314
314
  ?? null;
315
315
  }
316
316
 
317
+ function normalizeMirrorLocale(value) {
318
+ return value == null ? "" : String(value).trim().toLowerCase();
319
+ }
320
+
321
+ function isSameMirroredContentIdentity(existingItem, contentItem) {
322
+ return String(existingItem.contentId ?? "") === String(contentItem.contentId ?? "")
323
+ && String(existingItem.model ?? "") === String(contentItem.model ?? "")
324
+ && String(existingItem.tenant ?? "") === String(contentItem.tenant ?? "")
325
+ && normalizeMirrorLocale(existingItem.locale) === normalizeMirrorLocale(contentItem.locale);
326
+ }
327
+
317
328
  export function normalizeContentItem(item, options = {}) {
318
329
  const root = getItemRoot(item);
319
330
  const values = normalizeValues(item, options.modelFields);
@@ -369,12 +380,53 @@ async function loadModelFields(clients, sourceTableName, tenant, modelId, cache)
369
380
  return fields;
370
381
  }
371
382
 
383
+ async function listMirroredContentItems(clients, tableName, indexName, contentId) {
384
+ const items = [];
385
+ let lastEvaluatedKey;
386
+
387
+ do {
388
+ const response = await clients.dynamo.query({
389
+ TableName: tableName,
390
+ IndexName: indexName,
391
+ KeyConditionExpression: "contentId = :contentId",
392
+ ExpressionAttributeValues: {
393
+ ":contentId": contentId
394
+ },
395
+ ExclusiveStartKey: lastEvaluatedKey
396
+ }).promise();
397
+ items.push(...(response.Items ?? []));
398
+ lastEvaluatedKey = response.LastEvaluatedKey;
399
+ } while (lastEvaluatedKey);
400
+
401
+ return items;
402
+ }
403
+
404
+ async function removeMirroredContentRevisions(clients, tableName, indexName, contentItem, excludeId = null) {
405
+ const mirroredItems = await listMirroredContentItems(clients, tableName, indexName, contentItem.contentId);
406
+ const removals = mirroredItems.filter((existingItem) => (
407
+ isSameMirroredContentIdentity(existingItem, contentItem)
408
+ && existingItem.id !== excludeId
409
+ ));
410
+
411
+ for (const existingItem of removals) {
412
+ await clients.dynamo.delete({
413
+ TableName: tableName,
414
+ Key: {
415
+ id: existingItem.id
416
+ }
417
+ }).promise();
418
+ }
419
+
420
+ return removals.length;
421
+ }
422
+
372
423
  export async function handler(event) {
373
424
  const clients = createAwsClients();
374
425
  const tableName = process.env.S3TE_CONTENT_TABLE;
375
426
  const renderWorkerName = process.env.S3TE_RENDER_WORKER_NAME;
376
427
  const environmentName = process.env.S3TE_ENVIRONMENT;
377
428
  const sourceTableName = process.env.S3TE_WEBINY_SOURCE_TABLE;
429
+ const contentIndexName = process.env.S3TE_CONTENT_ID_INDEX_NAME ?? "contentid";
378
430
  const configuredTenant = String(process.env.S3TE_WEBINY_TENANT ?? "").trim();
379
431
  const relevantModels = new Set(String(process.env.S3TE_RELEVANT_MODELS ?? "")
380
432
  .split(",")
@@ -411,13 +463,7 @@ export async function handler(event) {
411
463
 
412
464
  const shouldDelete = record.eventName === "REMOVE" || !isPublished(item);
413
465
  if (shouldDelete) {
414
- await clients.dynamo.delete({
415
- TableName: tableName,
416
- Key: {
417
- id: contentItem.id
418
- }
419
- }).promise();
420
- deleted += 1;
466
+ deleted += await removeMirroredContentRevisions(clients, tableName, contentIndexName, contentItem);
421
467
  await invokeLambdaEvent(clients.lambda, renderWorkerName, {
422
468
  type: "content-item",
423
469
  action: "delete",
@@ -429,6 +475,7 @@ export async function handler(event) {
429
475
  continue;
430
476
  }
431
477
 
478
+ await removeMirroredContentRevisions(clients, tableName, contentIndexName, contentItem, contentItem.id);
432
479
  await clients.dynamo.put({
433
480
  TableName: tableName,
434
481
  Item: contentItem
@@ -631,6 +631,7 @@ export function buildWebinyCloudFormationTemplate({ config, environment }) {
631
631
  Variables: {
632
632
  S3TE_ENVIRONMENT: environment,
633
633
  S3TE_CONTENT_TABLE: runtimeConfig.tables.content,
634
+ S3TE_CONTENT_ID_INDEX_NAME: config.aws.contentStore.contentIdIndexName,
634
635
  S3TE_RELEVANT_MODELS: runtimeConfig.integrations.webiny.relevantModels.join(","),
635
636
  S3TE_WEBINY_SOURCE_TABLE: runtimeConfig.integrations.webiny.sourceTableName,
636
637
  S3TE_WEBINY_TENANT: runtimeConfig.integrations.webiny.tenant ?? "",
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import {
6
6
  configureProjectOption,
7
7
  deployProject,
8
+ downloadProjectContent,
8
9
  doctorProject,
9
10
  loadResolvedConfig,
10
11
  packageProject,
@@ -82,6 +83,7 @@ function printHelp() {
82
83
  " validate\n" +
83
84
  " render\n" +
84
85
  " test\n" +
86
+ " download-content\n" +
85
87
  " package\n" +
86
88
  " sync\n" +
87
89
  " deploy\n" +
@@ -252,6 +254,41 @@ async function main() {
252
254
  return;
253
255
  }
254
256
 
257
+ if (command === "download-content") {
258
+ const loaded = await loadConfigForCommand(cwd, options.config);
259
+ if (!loaded.ok) {
260
+ if (wantsJson) {
261
+ printJson("download-content", false, loaded.warnings, loaded.errors, startedAt);
262
+ } else {
263
+ for (const error of loaded.errors) {
264
+ process.stderr.write(`${error.code}: ${error.message}\n`);
265
+ }
266
+ }
267
+ process.exitCode = 2;
268
+ return;
269
+ }
270
+
271
+ if (!options.env) {
272
+ process.stderr.write("download-content requires --env <name>\n");
273
+ process.exitCode = 1;
274
+ return;
275
+ }
276
+
277
+ const report = await downloadProjectContent(cwd, loaded.config, {
278
+ environment: asArray(options.env)[0],
279
+ out: options.out,
280
+ profile: options.profile
281
+ });
282
+
283
+ if (wantsJson) {
284
+ printJson("download-content", true, [], [], startedAt, report);
285
+ return;
286
+ }
287
+
288
+ process.stdout.write(`Downloaded ${report.writtenItems} content item(s) into ${report.outputPath}\n`);
289
+ return;
290
+ }
291
+
255
292
  if (command === "package") {
256
293
  const loaded = await loadConfigForCommand(cwd, options.config);
257
294
  if (!loaded.ok) {
@@ -11,6 +11,39 @@ function normalizeLocale(value) {
11
11
  return String(value).trim().toLowerCase();
12
12
  }
13
13
 
14
+ function comparableTimestamp(value) {
15
+ if (typeof value === "number" && Number.isFinite(value)) {
16
+ return value;
17
+ }
18
+
19
+ const timestamp = Date.parse(String(value ?? ""));
20
+ return Number.isFinite(timestamp) ? timestamp : -1;
21
+ }
22
+
23
+ function compareContentFreshness(left, right) {
24
+ const updatedDiff = comparableTimestamp(right.updatedAt) - comparableTimestamp(left.updatedAt);
25
+ if (updatedDiff !== 0) {
26
+ return updatedDiff;
27
+ }
28
+
29
+ const changedDiff = comparableTimestamp(right.lastChangedAt) - comparableTimestamp(left.lastChangedAt);
30
+ if (changedDiff !== 0) {
31
+ return changedDiff;
32
+ }
33
+
34
+ const createdDiff = comparableTimestamp(right.createdAt) - comparableTimestamp(left.createdAt);
35
+ if (createdDiff !== 0) {
36
+ return createdDiff;
37
+ }
38
+
39
+ const versionDiff = Number(right.version ?? -1) - Number(left.version ?? -1);
40
+ if (versionDiff !== 0) {
41
+ return versionDiff;
42
+ }
43
+
44
+ return String(right.id ?? "").localeCompare(String(left.id ?? ""));
45
+ }
46
+
14
47
  function buildLocaleCandidates(language, languageLocaleMap) {
15
48
  const requested = String(language ?? "").trim();
16
49
  const configured = String(languageLocaleMap?.[requested] ?? requested).trim();
@@ -20,7 +53,6 @@ function buildLocaleCandidates(language, languageLocaleMap) {
20
53
  function matchesRequestedLocale(item, language, languageLocaleMap) {
21
54
  return localeMatchScore(item?.locale, language, languageLocaleMap) > 0;
22
55
  }
23
-
24
56
  function localeMatchScore(itemLocale, language, languageLocaleMap) {
25
57
  if (!itemLocale) {
26
58
  return 1;
@@ -59,7 +91,11 @@ function filterItemsByRequestedLocale(items, language, languageLocaleMap) {
59
91
  }
60
92
 
61
93
  const bestScore = Math.max(...scored.map((entry) => entry.score));
62
- return scored.filter((entry) => entry.score === bestScore).map((entry) => entry.item);
94
+ return scored
95
+ .filter((entry) => entry.score === bestScore)
96
+ .map((entry) => entry.item)
97
+ .sort(compareContentFreshness)
98
+ .slice(0, 1);
63
99
  });
64
100
  }
65
101
 
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
5
+ import { DynamoDBDocumentClient, ScanCommand } from "@aws-sdk/lib-dynamodb";
4
6
 
5
7
  import {
6
8
  S3teError,
@@ -597,6 +599,114 @@ function normalizeStringList(values) {
597
599
  .filter(Boolean))];
598
600
  }
599
601
 
602
+ function normalizeLocale(value) {
603
+ return value == null ? "" : String(value).trim().toLowerCase();
604
+ }
605
+
606
+ function comparableTimestamp(value) {
607
+ if (typeof value === "number" && Number.isFinite(value)) {
608
+ return value;
609
+ }
610
+
611
+ const timestamp = Date.parse(String(value ?? ""));
612
+ return Number.isFinite(timestamp) ? timestamp : -1;
613
+ }
614
+
615
+ function compareContentFreshness(left, right) {
616
+ const updatedDiff = comparableTimestamp(right.updatedAt) - comparableTimestamp(left.updatedAt);
617
+ if (updatedDiff !== 0) {
618
+ return updatedDiff;
619
+ }
620
+
621
+ const changedDiff = comparableTimestamp(right.lastChangedAt) - comparableTimestamp(left.lastChangedAt);
622
+ if (changedDiff !== 0) {
623
+ return changedDiff;
624
+ }
625
+
626
+ const createdDiff = comparableTimestamp(right.createdAt) - comparableTimestamp(left.createdAt);
627
+ if (createdDiff !== 0) {
628
+ return createdDiff;
629
+ }
630
+
631
+ const versionDiff = Number(right.version ?? -1) - Number(left.version ?? -1);
632
+ if (versionDiff !== 0) {
633
+ return versionDiff;
634
+ }
635
+
636
+ return String(right.id ?? "").localeCompare(String(left.id ?? ""));
637
+ }
638
+
639
+ function buildContentIdentityKey(item) {
640
+ return [
641
+ item.contentId ?? item.id ?? "",
642
+ item.model ?? "",
643
+ item.tenant ?? "",
644
+ normalizeLocale(item.locale)
645
+ ].join("#");
646
+ }
647
+
648
+ function compareDownloadedContentOrder(left, right) {
649
+ return String(left.contentId ?? left.id ?? "").localeCompare(String(right.contentId ?? right.id ?? ""))
650
+ || normalizeLocale(left.locale).localeCompare(normalizeLocale(right.locale))
651
+ || String(left.model ?? "").localeCompare(String(right.model ?? ""))
652
+ || String(left.tenant ?? "").localeCompare(String(right.tenant ?? ""))
653
+ || compareContentFreshness(left, right);
654
+ }
655
+
656
+ function deduplicateContentItems(items) {
657
+ const latestItems = new Map();
658
+
659
+ for (const item of items ?? []) {
660
+ const identityKey = buildContentIdentityKey(item);
661
+ const current = latestItems.get(identityKey);
662
+ if (!current || compareContentFreshness(item, current) < 0) {
663
+ latestItems.set(identityKey, item);
664
+ }
665
+ }
666
+
667
+ return [...latestItems.values()].sort(compareDownloadedContentOrder);
668
+ }
669
+
670
+ async function scanRemoteContentTable({ tableName, region, profile }) {
671
+ const previousProfile = process.env.AWS_PROFILE;
672
+ if (profile) {
673
+ process.env.AWS_PROFILE = profile;
674
+ }
675
+
676
+ const baseClient = new DynamoDBClient({ region });
677
+ const documentClient = DynamoDBDocumentClient.from(baseClient);
678
+ const items = [];
679
+ let lastEvaluatedKey;
680
+
681
+ try {
682
+ do {
683
+ const response = await documentClient.send(new ScanCommand({
684
+ TableName: tableName,
685
+ ExclusiveStartKey: lastEvaluatedKey
686
+ }));
687
+ items.push(...(response.Items ?? []));
688
+ lastEvaluatedKey = response.LastEvaluatedKey;
689
+ } while (lastEvaluatedKey);
690
+
691
+ return items;
692
+ } catch (error) {
693
+ throw new S3teError("AWS_AUTH_ERROR", `Unable to download content from DynamoDB table ${tableName}.`, {
694
+ tableName,
695
+ region,
696
+ cause: error.message
697
+ });
698
+ } finally {
699
+ baseClient.destroy();
700
+ if (profile) {
701
+ if (previousProfile === undefined) {
702
+ delete process.env.AWS_PROFILE;
703
+ } else {
704
+ process.env.AWS_PROFILE = previousProfile;
705
+ }
706
+ }
707
+ }
708
+ }
709
+
600
710
  export async function loadResolvedConfig(projectDir, configPath) {
601
711
  const rawConfig = await loadProjectConfig(configPath);
602
712
  const result = await validateAndResolveProjectConfig(rawConfig, { projectDir });
@@ -861,6 +971,34 @@ export async function runProjectTests(projectDir) {
861
971
  });
862
972
  }
863
973
 
974
+ export async function downloadProjectContent(projectDir, config, options = {}) {
975
+ assertKnownEnvironment(config, options.environment);
976
+ const runtimeConfig = buildEnvironmentRuntimeConfig(config, options.environment);
977
+ const tableName = runtimeConfig.tables.content;
978
+ const region = runtimeConfig.awsRegion;
979
+ const outputPath = path.resolve(projectDir, options.out ?? path.join("offline", "content", "items.json"));
980
+ const scanContentItemsFn = options.scanContentItemsFn ?? scanRemoteContentTable;
981
+
982
+ const remoteItems = await scanContentItemsFn({
983
+ tableName,
984
+ region,
985
+ profile: options.profile
986
+ });
987
+ const items = deduplicateContentItems(remoteItems);
988
+
989
+ await writeTextFile(outputPath, JSON.stringify(items, null, 2) + "\n");
990
+
991
+ return {
992
+ environment: options.environment,
993
+ region,
994
+ tableName,
995
+ outputPath: normalizePath(path.relative(projectDir, outputPath)),
996
+ downloadedItems: remoteItems.length,
997
+ writtenItems: items.length,
998
+ deduplicatedItems: Math.max(0, remoteItems.length - items.length)
999
+ };
1000
+ }
1001
+
864
1002
  export async function packageProject(projectDir, config, options = {}) {
865
1003
  assertKnownEnvironment(config, options.environment);
866
1004
  return packageAwsProject({