@projectdochelp/s3te 3.3.3 → 3.4.1
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 +19 -4
- package/package.json +1 -1
- package/packages/aws-adapter/src/runtime/content-mirror.mjs +20 -0
- package/packages/cli/bin/s3te.mjs +37 -0
- package/packages/cli/src/fs-adapters.mjs +38 -2
- package/packages/cli/src/project.mjs +138 -0
- package/packages/core/src/content-query.mjs +32 -4
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.
|
|
451
|
-
4.
|
|
452
|
-
5.
|
|
453
|
-
6. Use `
|
|
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
|
@@ -106,7 +106,27 @@ function serializeStructuredValue(value) {
|
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
function maybeParseStructuredString(value) {
|
|
110
|
+
if (typeof value !== "string") {
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const trimmed = value.trim();
|
|
115
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(trimmed);
|
|
121
|
+
} catch {
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
function toSimpleValue(value) {
|
|
127
|
+
const normalizedValue = maybeParseStructuredString(value);
|
|
128
|
+
|
|
129
|
+
value = normalizedValue;
|
|
110
130
|
if (value == null) {
|
|
111
131
|
return null;
|
|
112
132
|
}
|
|
@@ -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
|
|
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({
|
|
@@ -22,6 +22,23 @@ function legacyAttributeValueToPlain(attribute) {
|
|
|
22
22
|
return undefined;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function maybeParseStructuredString(value) {
|
|
26
|
+
if (typeof value !== "string") {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(trimmed);
|
|
37
|
+
} catch {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
function readComparableValue(item, field) {
|
|
26
43
|
if (field === "__typename") {
|
|
27
44
|
return item.model;
|
|
@@ -159,12 +176,14 @@ export function readContentField(item, field, language) {
|
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
export function serializeContentValue(value) {
|
|
162
|
-
|
|
179
|
+
const normalizedValue = maybeParseStructuredString(value);
|
|
180
|
+
|
|
181
|
+
if (normalizedValue == null) {
|
|
163
182
|
return "";
|
|
164
183
|
}
|
|
165
184
|
|
|
166
|
-
if (Array.isArray(
|
|
167
|
-
return
|
|
185
|
+
if (Array.isArray(normalizedValue)) {
|
|
186
|
+
return normalizedValue.map((entry) => {
|
|
168
187
|
const text = typeof entry === "string" && entry.includes("-")
|
|
169
188
|
? entry.slice(entry.lastIndexOf("-") + 1)
|
|
170
189
|
: String(entry);
|
|
@@ -172,5 +191,14 @@ export function serializeContentValue(value) {
|
|
|
172
191
|
}).join("");
|
|
173
192
|
}
|
|
174
193
|
|
|
175
|
-
|
|
194
|
+
if (normalizedValue && typeof normalizedValue === "object") {
|
|
195
|
+
if (typeof normalizedValue.html === "string") {
|
|
196
|
+
return normalizedValue.html;
|
|
197
|
+
}
|
|
198
|
+
if (typeof normalizedValue.text === "string") {
|
|
199
|
+
return normalizedValue.text;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return String(normalizedValue);
|
|
176
204
|
}
|