@projectdochelp/s3te 3.3.3 → 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.3",
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",
@@ -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({