@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.
- package/LICENSE +21 -0
- package/README.md +442 -0
- package/bin/s3te.mjs +2 -0
- package/package.json +66 -0
- package/packages/aws-adapter/src/aws-cli.mjs +102 -0
- package/packages/aws-adapter/src/deploy.mjs +433 -0
- package/packages/aws-adapter/src/features.mjs +16 -0
- package/packages/aws-adapter/src/index.mjs +7 -0
- package/packages/aws-adapter/src/manifest.mjs +88 -0
- package/packages/aws-adapter/src/package.mjs +323 -0
- package/packages/aws-adapter/src/runtime/common.mjs +917 -0
- package/packages/aws-adapter/src/runtime/content-mirror.mjs +301 -0
- package/packages/aws-adapter/src/runtime/invalidation-executor.mjs +61 -0
- package/packages/aws-adapter/src/runtime/invalidation-scheduler.mjs +59 -0
- package/packages/aws-adapter/src/runtime/render-worker.mjs +83 -0
- package/packages/aws-adapter/src/runtime/source-dispatcher.mjs +106 -0
- package/packages/aws-adapter/src/template.mjs +578 -0
- package/packages/aws-adapter/src/zip.mjs +111 -0
- package/packages/cli/bin/s3te.mjs +383 -0
- package/packages/cli/src/fs-adapters.mjs +221 -0
- package/packages/cli/src/project.mjs +535 -0
- package/packages/core/src/config.mjs +464 -0
- package/packages/core/src/content-query.mjs +176 -0
- package/packages/core/src/errors.mjs +14 -0
- package/packages/core/src/index.mjs +24 -0
- package/packages/core/src/mime.mjs +29 -0
- package/packages/core/src/minify.mjs +82 -0
- package/packages/core/src/render.mjs +537 -0
- package/packages/testkit/src/index.mjs +136 -0
- package/src/index.mjs +3 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const MIME_BY_EXTENSION = new Map([
|
|
2
|
+
[".html", "text/html; charset=utf-8"],
|
|
3
|
+
[".htm", "text/html; charset=utf-8"],
|
|
4
|
+
[".part", "text/plain; charset=utf-8"],
|
|
5
|
+
[".css", "text/css; charset=utf-8"],
|
|
6
|
+
[".js", "application/javascript; charset=utf-8"],
|
|
7
|
+
[".mjs", "application/javascript; charset=utf-8"],
|
|
8
|
+
[".json", "application/json; charset=utf-8"],
|
|
9
|
+
[".svg", "image/svg+xml"],
|
|
10
|
+
[".png", "image/png"],
|
|
11
|
+
[".jpg", "image/jpeg"],
|
|
12
|
+
[".jpeg", "image/jpeg"],
|
|
13
|
+
[".gif", "image/gif"],
|
|
14
|
+
[".webp", "image/webp"],
|
|
15
|
+
[".ico", "image/x-icon"],
|
|
16
|
+
[".txt", "text/plain; charset=utf-8"],
|
|
17
|
+
[".xml", "application/xml; charset=utf-8"],
|
|
18
|
+
[".woff", "font/woff"],
|
|
19
|
+
[".woff2", "font/woff2"]
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function getContentTypeForPath(filePath) {
|
|
23
|
+
const dotIndex = filePath.lastIndexOf(".");
|
|
24
|
+
if (dotIndex === -1) {
|
|
25
|
+
return "application/octet-stream";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return MIME_BY_EXTENSION.get(filePath.slice(dotIndex).toLowerCase()) ?? "application/octet-stream";
|
|
29
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const VOID_TAGS = new Set([
|
|
2
|
+
"area",
|
|
3
|
+
"base",
|
|
4
|
+
"br",
|
|
5
|
+
"col",
|
|
6
|
+
"embed",
|
|
7
|
+
"hr",
|
|
8
|
+
"img",
|
|
9
|
+
"input",
|
|
10
|
+
"link",
|
|
11
|
+
"meta",
|
|
12
|
+
"param",
|
|
13
|
+
"source",
|
|
14
|
+
"track",
|
|
15
|
+
"wbr"
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function removeHtmlComments(html) {
|
|
19
|
+
return html.replace(/<!--[\s\S]*?-->/g, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function removeCssComments(text) {
|
|
23
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function removeJsComments(text) {
|
|
27
|
+
return text
|
|
28
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
29
|
+
.replace(/(^|[\s;])\/\/[^\n\r]*/g, "$1");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function collapseWhitespace(text) {
|
|
33
|
+
return text.replace(/\s+/g, " ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function minifyHtml(html) {
|
|
37
|
+
let result = removeHtmlComments(html);
|
|
38
|
+
result = result.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (full, css) => full.replace(css, removeCssComments(css)));
|
|
39
|
+
result = result.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (full, js) => full.replace(js, removeJsComments(js)));
|
|
40
|
+
result = collapseWhitespace(result);
|
|
41
|
+
return result.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function repairTruncatedHtml(html) {
|
|
45
|
+
let result = html;
|
|
46
|
+
const lastOpen = result.lastIndexOf("<");
|
|
47
|
+
const lastClose = result.lastIndexOf(">");
|
|
48
|
+
if (lastOpen > lastClose) {
|
|
49
|
+
result = result.slice(0, lastOpen);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const matches = result.match(/<\/?[a-zA-Z][^>]*>/g) ?? [];
|
|
53
|
+
const stack = [];
|
|
54
|
+
|
|
55
|
+
for (const rawTag of matches) {
|
|
56
|
+
const tagMatch = rawTag.match(/^<\/?\s*([a-zA-Z0-9-]+)/);
|
|
57
|
+
if (!tagMatch) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tag = tagMatch[1].toLowerCase();
|
|
62
|
+
if (VOID_TAGS.has(tag) || rawTag.endsWith("/>")) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (rawTag.startsWith("</")) {
|
|
67
|
+
const index = stack.lastIndexOf(tag);
|
|
68
|
+
if (index !== -1) {
|
|
69
|
+
stack.splice(index, 1);
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
stack.push(tag);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
78
|
+
result += `</${stack[index]}>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { assert, S3teError } from "./errors.mjs";
|
|
4
|
+
import { getContentTypeForPath } from "./mime.mjs";
|
|
5
|
+
import { minifyHtml, repairTruncatedHtml } from "./minify.mjs";
|
|
6
|
+
import { readContentField, serializeContentValue } from "./content-query.mjs";
|
|
7
|
+
|
|
8
|
+
function createWarning(code, message, sourceKey) {
|
|
9
|
+
return { code, message, sourceKey };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stripLeadingWhitespace(value) {
|
|
13
|
+
return value.replace(/^\s+/, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function findTagRange(input, tagName, startIndex = 0) {
|
|
17
|
+
const openTag = `<${tagName}>`;
|
|
18
|
+
const closeTag = `</${tagName}>`;
|
|
19
|
+
const start = input.indexOf(openTag, startIndex);
|
|
20
|
+
if (start === -1) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const end = input.indexOf(closeTag, start + openTag.length);
|
|
25
|
+
if (end === -1) {
|
|
26
|
+
throw new S3teError("TEMPLATE_SYNTAX_ERROR", `Missing closing tag for ${tagName}.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
start,
|
|
31
|
+
end,
|
|
32
|
+
innerStart: start + openTag.length,
|
|
33
|
+
innerEnd: end
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findNextTag(input, tagNames) {
|
|
38
|
+
let match = null;
|
|
39
|
+
for (const tagName of tagNames) {
|
|
40
|
+
const index = input.indexOf(`<${tagName}>`);
|
|
41
|
+
if (index === -1) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!match || index < match.index) {
|
|
46
|
+
match = { tagName, index };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return match;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseJsonPayload(raw, tagName) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(raw.trim());
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new S3teError("TEMPLATE_SYNTAX_ERROR", `Invalid JSON payload in <${tagName}>.`, {
|
|
58
|
+
tagName,
|
|
59
|
+
cause: error.message
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ensureKnownKeys(object, knownKeys, tagName) {
|
|
65
|
+
for (const key of Object.keys(object)) {
|
|
66
|
+
if (!knownKeys.has(key)) {
|
|
67
|
+
throw new S3teError("TEMPLATE_SYNTAX_ERROR", `Unknown property ${key} in <${tagName}>.`, { tagName, key });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function randomIntInclusive(minimum, maximum) {
|
|
73
|
+
return Math.floor(Math.random() * (maximum - minimum + 1)) + minimum;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatDateValue(value, locale) {
|
|
77
|
+
let timestamp = Number(value);
|
|
78
|
+
if (timestamp < 1_000_000_000_000) {
|
|
79
|
+
timestamp *= 1000;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const date = new Date(timestamp);
|
|
83
|
+
const year = date.getUTCFullYear();
|
|
84
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
85
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
86
|
+
|
|
87
|
+
if (locale === "de") {
|
|
88
|
+
return `${day}.${month}.${year}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return `${month}/${day}/${year}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function nthIndex(haystack, needle, occurrence) {
|
|
95
|
+
if (occurrence <= 0) {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
let index = -1;
|
|
99
|
+
for (let current = 0; current < occurrence; current += 1) {
|
|
100
|
+
index = haystack.indexOf(needle, index + 1);
|
|
101
|
+
if (index === -1) {
|
|
102
|
+
return -1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return index;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function isRenderableKey(config, sourceKey) {
|
|
109
|
+
const extension = path.extname(sourceKey).toLowerCase();
|
|
110
|
+
return config.rendering.renderExtensions.includes(extension);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function applyIfTags(input, state) {
|
|
114
|
+
let output = input;
|
|
115
|
+
let range = findTagRange(output, "if");
|
|
116
|
+
while (range) {
|
|
117
|
+
const payload = parseJsonPayload(output.slice(range.innerStart, range.innerEnd), "if");
|
|
118
|
+
ensureKnownKeys(payload, new Set(["env", "file", "not", "template"]), "if");
|
|
119
|
+
assert(typeof payload.template === "string", "TEMPLATE_SYNTAX_ERROR", "<if> requires template.");
|
|
120
|
+
|
|
121
|
+
const conditions = [];
|
|
122
|
+
if (payload.env !== undefined) {
|
|
123
|
+
conditions.push(String(payload.env).toLowerCase() === state.target.environment.toLowerCase());
|
|
124
|
+
}
|
|
125
|
+
if (payload.file !== undefined) {
|
|
126
|
+
conditions.push(String(payload.file).toLowerCase() === state.target.outputKey.toLowerCase());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let matched = !conditions.includes(false);
|
|
130
|
+
if (payload.not === true) {
|
|
131
|
+
matched = !matched;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const replacement = matched ? payload.template : "";
|
|
135
|
+
output = `${output.slice(0, range.start)}${replacement}${output.slice(range.end + 5)}`;
|
|
136
|
+
range = findTagRange(output, "if");
|
|
137
|
+
}
|
|
138
|
+
return output;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function applyFileAttributeTags(input, state) {
|
|
142
|
+
let output = input;
|
|
143
|
+
let range = findTagRange(output, "fileattribute");
|
|
144
|
+
while (range) {
|
|
145
|
+
const attribute = output.slice(range.innerStart, range.innerEnd).trim();
|
|
146
|
+
let replacement = "";
|
|
147
|
+
if (attribute === "filename") {
|
|
148
|
+
replacement = state.target.outputKey;
|
|
149
|
+
} else {
|
|
150
|
+
state.warnings.push(createWarning("UNSUPPORTED_TAG", `Unsupported fileattribute ${attribute}.`, state.target.sourceKey));
|
|
151
|
+
}
|
|
152
|
+
output = `${output.slice(0, range.start)}${replacement}${output.slice(range.end + "</fileattribute>".length)}`;
|
|
153
|
+
range = findTagRange(output, "fileattribute");
|
|
154
|
+
}
|
|
155
|
+
return output;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function resolveContentById(contentId, state) {
|
|
159
|
+
state.dependencies.add(`content#${contentId}`);
|
|
160
|
+
const item = await state.contentRepository.getByContentId(contentId, state.target.language);
|
|
161
|
+
if (!item) {
|
|
162
|
+
state.warnings.push(createWarning("MISSING_CONTENT", `Missing content ${contentId}.`, state.target.sourceKey));
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const preferredField = `content${state.target.language}`;
|
|
167
|
+
const rawContent = item.values[preferredField] ?? item.values.content;
|
|
168
|
+
if (rawContent == null) {
|
|
169
|
+
state.warnings.push(createWarning("MISSING_CONTENT", `Content ${contentId} has no content field.`, state.target.sourceKey));
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return serializeContentValue(rawContent);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function applyLanguageTags(input, state, renderFragment) {
|
|
177
|
+
let output = input;
|
|
178
|
+
|
|
179
|
+
let range = findTagRange(output, "lang");
|
|
180
|
+
while (range) {
|
|
181
|
+
const command = output.slice(range.innerStart, range.innerEnd).trim();
|
|
182
|
+
let replacement = "";
|
|
183
|
+
if (command === "2") {
|
|
184
|
+
replacement = state.target.language;
|
|
185
|
+
} else if (command === "baseurl") {
|
|
186
|
+
replacement = state.target.baseUrl;
|
|
187
|
+
} else {
|
|
188
|
+
state.warnings.push(createWarning("UNSUPPORTED_TAG", `Unsupported <lang>${command}</lang> command.`, state.target.sourceKey));
|
|
189
|
+
}
|
|
190
|
+
output = `${output.slice(0, range.start)}${replacement}${output.slice(range.end + 7)}`;
|
|
191
|
+
range = findTagRange(output, "lang");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
range = findTagRange(output, "switchlang");
|
|
195
|
+
while (range) {
|
|
196
|
+
const block = output.slice(range.innerStart, range.innerEnd);
|
|
197
|
+
const startToken = `<${state.target.language}>`;
|
|
198
|
+
const endToken = `</${state.target.language}>`;
|
|
199
|
+
const start = block.indexOf(startToken);
|
|
200
|
+
const end = block.indexOf(endToken);
|
|
201
|
+
let replacement = "";
|
|
202
|
+
if (start === -1 || end === -1) {
|
|
203
|
+
state.warnings.push(createWarning("MISSING_LANGUAGE", `Missing switchlang block for ${state.target.language}.`, state.target.sourceKey));
|
|
204
|
+
} else {
|
|
205
|
+
replacement = block.slice(start + startToken.length, end);
|
|
206
|
+
replacement = await renderFragment(replacement, { ...state, depth: state.depth + 1 });
|
|
207
|
+
}
|
|
208
|
+
output = `${output.slice(0, range.start)}${replacement}${output.slice(range.end + "</switchlang>".length)}`;
|
|
209
|
+
range = findTagRange(output, "switchlang");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return output;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function applyDbMultifileItemTags(input, state) {
|
|
216
|
+
let output = input;
|
|
217
|
+
let range = findTagRange(output, "dbmultifileitem");
|
|
218
|
+
while (range) {
|
|
219
|
+
let replacement = "";
|
|
220
|
+
const rawPayload = output.slice(range.innerStart, range.innerEnd).trim();
|
|
221
|
+
|
|
222
|
+
if (!state.currentItem) {
|
|
223
|
+
state.warnings.push(createWarning("MISSING_CONTENT", "dbmultifileitem requires a current content item.", state.target.sourceKey));
|
|
224
|
+
} else if (rawPayload.startsWith("{")) {
|
|
225
|
+
const command = parseJsonPayload(rawPayload, "dbmultifileitem");
|
|
226
|
+
ensureKnownKeys(command, new Set(["field", "limit", "limitlow", "format", "locale", "divideattag", "startnumber", "endnumber"]), "dbmultifileitem");
|
|
227
|
+
assert(typeof command.field === "string" && command.field.length > 0, "TEMPLATE_SYNTAX_ERROR", "dbmultifileitem command requires field.");
|
|
228
|
+
|
|
229
|
+
const transformModes = Number(command.limit !== undefined) + Number(command.format !== undefined) + Number(command.divideattag !== undefined);
|
|
230
|
+
assert(transformModes <= 1, "TEMPLATE_SYNTAX_ERROR", "dbmultifileitem command may only use one transform mode.");
|
|
231
|
+
|
|
232
|
+
const rawValue = readContentField(state.currentItem, command.field, state.target.language);
|
|
233
|
+
const stringValue = serializeContentValue(rawValue);
|
|
234
|
+
|
|
235
|
+
if (command.limit !== undefined) {
|
|
236
|
+
assert(Number.isInteger(command.limit) && command.limit >= 0, "TEMPLATE_SYNTAX_ERROR", "dbmultifileitem limit must be a non-negative integer.");
|
|
237
|
+
if (command.limitlow !== undefined) {
|
|
238
|
+
assert(Number.isInteger(command.limitlow) && command.limitlow >= 0, "TEMPLATE_SYNTAX_ERROR", "dbmultifileitem limitlow must be a non-negative integer.");
|
|
239
|
+
assert(command.limitlow <= command.limit, "TEMPLATE_SYNTAX_ERROR", "dbmultifileitem limitlow must not exceed limit.");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let effectiveLimit = command.limit;
|
|
243
|
+
if (command.limitlow !== undefined) {
|
|
244
|
+
effectiveLimit = randomIntInclusive(command.limitlow, command.limit);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (effectiveLimit === 0 || effectiveLimit >= stringValue.length) {
|
|
248
|
+
replacement = stringValue;
|
|
249
|
+
} else {
|
|
250
|
+
replacement = repairTruncatedHtml(`${stringValue.slice(0, effectiveLimit)}...`);
|
|
251
|
+
}
|
|
252
|
+
} else if (command.format !== undefined) {
|
|
253
|
+
assert(command.format === "date", "TEMPLATE_SYNTAX_ERROR", "dbmultifileitem only supports format=date.");
|
|
254
|
+
replacement = formatDateValue(rawValue, command.locale);
|
|
255
|
+
} else if (command.divideattag !== undefined) {
|
|
256
|
+
assert(typeof command.divideattag === "string" && command.divideattag.length > 0, "TEMPLATE_SYNTAX_ERROR", "dbmultifileitem divideattag must be a string.");
|
|
257
|
+
if (command.startnumber !== undefined) {
|
|
258
|
+
assert(Number.isInteger(command.startnumber) && command.startnumber >= 1, "TEMPLATE_SYNTAX_ERROR", "dbmultifileitem startnumber must be >= 1.");
|
|
259
|
+
}
|
|
260
|
+
if (command.endnumber !== undefined) {
|
|
261
|
+
assert(Number.isInteger(command.endnumber) && command.endnumber >= 1, "TEMPLATE_SYNTAX_ERROR", "dbmultifileitem endnumber must be >= 1.");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const startIndex = command.startnumber ? nthIndex(stringValue, command.divideattag, command.startnumber) : 0;
|
|
265
|
+
const endIndex = command.endnumber ? nthIndex(stringValue, command.divideattag, command.endnumber) : stringValue.length;
|
|
266
|
+
|
|
267
|
+
if (startIndex === -1) {
|
|
268
|
+
state.warnings.push(createWarning("MISSING_CONTENT", `divideattag startnumber ${command.startnumber} was not found.`, state.target.sourceKey));
|
|
269
|
+
replacement = "";
|
|
270
|
+
} else {
|
|
271
|
+
replacement = stringValue.slice(startIndex, endIndex === -1 ? stringValue.length : endIndex);
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
replacement = stringValue;
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
const field = rawPayload;
|
|
278
|
+
const value = readContentField(state.currentItem, field, state.target.language);
|
|
279
|
+
replacement = serializeContentValue(value);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
output = `${output.slice(0, range.start)}${replacement}${output.slice(range.end + "</dbmultifileitem>".length)}`;
|
|
283
|
+
range = findTagRange(output, "dbmultifileitem");
|
|
284
|
+
}
|
|
285
|
+
return output;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function createRenderFragment() {
|
|
289
|
+
const renderFragment = async (input, state) => {
|
|
290
|
+
assert(state.depth <= state.config.rendering.maxRenderDepth, "TEMPLATE_CYCLE_ERROR", "Maximum render depth exceeded.", {
|
|
291
|
+
sourceKey: state.target.sourceKey
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
let output = input;
|
|
295
|
+
output = await applyIfTags(output, state);
|
|
296
|
+
output = await applyFileAttributeTags(output, state);
|
|
297
|
+
|
|
298
|
+
while (true) {
|
|
299
|
+
const next = findNextTag(output, ["part", "dbpart", "dbmulti", "dbitem"]);
|
|
300
|
+
if (!next) {
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const range = findTagRange(output, next.tagName, next.index);
|
|
305
|
+
const rawPayload = output.slice(range.innerStart, range.innerEnd);
|
|
306
|
+
let replacement = "";
|
|
307
|
+
|
|
308
|
+
if (next.tagName === "part") {
|
|
309
|
+
const requestedPath = rawPayload.trim().replace(/\\/g, "/");
|
|
310
|
+
assert(requestedPath && !requestedPath.startsWith("/") && !requestedPath.split("/").includes(".."), "TEMPLATE_SYNTAX_ERROR", "Invalid <part> path.", {
|
|
311
|
+
requestedPath
|
|
312
|
+
});
|
|
313
|
+
if (state.includeStack.includes(requestedPath)) {
|
|
314
|
+
throw new S3teError("TEMPLATE_CYCLE_ERROR", `Include cycle detected for ${requestedPath}.`, {
|
|
315
|
+
includeStack: [...state.includeStack, requestedPath]
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
state.dependencies.add(`partial#${requestedPath}`);
|
|
319
|
+
const partKey = `${state.variant.partDir}/${requestedPath}`.replace(/\\/g, "/");
|
|
320
|
+
const partFile = await state.templateRepository.get(partKey);
|
|
321
|
+
if (!partFile) {
|
|
322
|
+
state.warnings.push(createWarning("MISSING_PART", `Missing partial ${requestedPath}.`, state.target.sourceKey));
|
|
323
|
+
} else {
|
|
324
|
+
replacement = await renderFragment(String(partFile.body), {
|
|
325
|
+
...state,
|
|
326
|
+
depth: state.depth + 1,
|
|
327
|
+
includeStack: [...state.includeStack, requestedPath]
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
} else if (next.tagName === "dbpart") {
|
|
331
|
+
replacement = await resolveContentById(rawPayload.trim(), state);
|
|
332
|
+
if (replacement) {
|
|
333
|
+
replacement = await renderFragment(replacement, { ...state, depth: state.depth + 1 });
|
|
334
|
+
}
|
|
335
|
+
} else if (next.tagName === "dbmulti") {
|
|
336
|
+
const command = parseJsonPayload(rawPayload, "dbmulti");
|
|
337
|
+
ensureKnownKeys(command, new Set(["filter", "filtertype", "limit", "template"]), "dbmulti");
|
|
338
|
+
assert(Array.isArray(command.filter), "TEMPLATE_SYNTAX_ERROR", "<dbmulti> requires filter array.");
|
|
339
|
+
assert(typeof command.template === "string", "TEMPLATE_SYNTAX_ERROR", "<dbmulti> requires template string.");
|
|
340
|
+
const items = await state.contentRepository.query({
|
|
341
|
+
filter: command.filter,
|
|
342
|
+
filterType: command.filtertype ?? "equals",
|
|
343
|
+
operator: "AND",
|
|
344
|
+
limit: command.limit
|
|
345
|
+
}, state.target.language);
|
|
346
|
+
const renderedItems = [];
|
|
347
|
+
for (const item of items) {
|
|
348
|
+
state.dependencies.add(`content#${item.contentId}`);
|
|
349
|
+
renderedItems.push(await renderFragment(command.template, {
|
|
350
|
+
...state,
|
|
351
|
+
depth: state.depth + 1,
|
|
352
|
+
currentItem: item
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
replacement = renderedItems.join("");
|
|
356
|
+
} else if (next.tagName === "dbitem") {
|
|
357
|
+
const field = rawPayload.trim();
|
|
358
|
+
if (!state.currentItem) {
|
|
359
|
+
state.warnings.push(createWarning("MISSING_CONTENT", `<dbitem>${field}</dbitem> has no current content item.`, state.target.sourceKey));
|
|
360
|
+
replacement = "";
|
|
361
|
+
} else {
|
|
362
|
+
const value = readContentField(state.currentItem, field, state.target.language);
|
|
363
|
+
if (value == null) {
|
|
364
|
+
state.warnings.push(createWarning("MISSING_CONTENT", `Missing field ${field}.`, state.target.sourceKey));
|
|
365
|
+
replacement = "";
|
|
366
|
+
} else {
|
|
367
|
+
replacement = serializeContentValue(value);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
output = `${output.slice(0, range.start)}${replacement}${output.slice(range.end + next.tagName.length + 3)}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
output = await applyLanguageTags(output, state, renderFragment);
|
|
376
|
+
output = await applyDbMultifileItemTags(output, state);
|
|
377
|
+
|
|
378
|
+
return output;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
return renderFragment;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function renderSingleTarget({ config, templateRepository, contentRepository, target, variantConfig, body, currentItem = null, templateKey }) {
|
|
385
|
+
const dependencies = new Set();
|
|
386
|
+
const warnings = [];
|
|
387
|
+
const renderFragment = await createRenderFragment();
|
|
388
|
+
const state = {
|
|
389
|
+
config,
|
|
390
|
+
target,
|
|
391
|
+
variant: variantConfig,
|
|
392
|
+
templateRepository,
|
|
393
|
+
contentRepository,
|
|
394
|
+
warnings,
|
|
395
|
+
dependencies,
|
|
396
|
+
depth: 0,
|
|
397
|
+
includeStack: [],
|
|
398
|
+
currentItem
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
let rendered = await renderFragment(body, state);
|
|
402
|
+
if (config.rendering.minifyHtml) {
|
|
403
|
+
rendered = minifyHtml(rendered);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (templateKey) {
|
|
407
|
+
dependencies.add(`generated-template#${templateKey}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
target,
|
|
412
|
+
artifact: {
|
|
413
|
+
outputKey: target.outputKey,
|
|
414
|
+
contentType: getContentTypeForPath(target.outputKey),
|
|
415
|
+
body: rendered
|
|
416
|
+
},
|
|
417
|
+
dependencies: [...dependencies].map((entry) => {
|
|
418
|
+
const [kind, ...rest] = entry.split("#");
|
|
419
|
+
return { kind, id: rest.join("#") };
|
|
420
|
+
}),
|
|
421
|
+
generatedOutputs: templateKey ? [target.outputKey] : [],
|
|
422
|
+
invalidationPaths: ["/*"],
|
|
423
|
+
warnings
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function buildDefaultBaseUrl(url) {
|
|
428
|
+
return String(url).replace(/^https?:\/\//, "");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export async function renderSourceTemplate({ config, templateRepository, contentRepository, environment, variantName, languageCode, sourceKey }) {
|
|
432
|
+
const variantConfig = config.variants[variantName];
|
|
433
|
+
const languageConfig = variantConfig.languages[languageCode];
|
|
434
|
+
const file = await templateRepository.get(sourceKey);
|
|
435
|
+
assert(file, "TEMPLATE_SYNTAX_ERROR", `Missing source template ${sourceKey}.`);
|
|
436
|
+
const body = String(file.body);
|
|
437
|
+
const sourceWithinVariant = sourceKey.startsWith(`${variantName}/`) ? sourceKey.slice(variantName.length + 1) : sourceKey;
|
|
438
|
+
|
|
439
|
+
const target = {
|
|
440
|
+
environment,
|
|
441
|
+
variant: variantName,
|
|
442
|
+
language: languageCode,
|
|
443
|
+
sourceKey,
|
|
444
|
+
outputKey: sourceWithinVariant,
|
|
445
|
+
baseUrl: buildDefaultBaseUrl(languageConfig.baseUrl)
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const trimmed = stripLeadingWhitespace(body);
|
|
449
|
+
if (trimmed.startsWith("<dbmultifile>")) {
|
|
450
|
+
const range = findTagRange(trimmed, "dbmultifile");
|
|
451
|
+
const command = parseJsonPayload(trimmed.slice(range.innerStart, range.innerEnd), "dbmultifile");
|
|
452
|
+
ensureKnownKeys(command, new Set(["filenamesuffix", "filter", "filtertype", "limit"]), "dbmultifile");
|
|
453
|
+
assert(typeof command.filenamesuffix === "string" && command.filenamesuffix.length > 0, "TEMPLATE_SYNTAX_ERROR", "dbmultifile requires filenamesuffix.");
|
|
454
|
+
assert(Array.isArray(command.filter), "TEMPLATE_SYNTAX_ERROR", "dbmultifile requires filter array.");
|
|
455
|
+
const bodyTemplate = trimmed.slice(range.end + "</dbmultifile>".length);
|
|
456
|
+
const items = await contentRepository.query({
|
|
457
|
+
filter: command.filter,
|
|
458
|
+
filterType: command.filtertype ?? "equals",
|
|
459
|
+
operator: "AND",
|
|
460
|
+
limit: command.limit
|
|
461
|
+
}, languageCode);
|
|
462
|
+
|
|
463
|
+
const seenNames = new Set();
|
|
464
|
+
const results = [];
|
|
465
|
+
for (const item of items) {
|
|
466
|
+
const suffixRaw = readContentField(item, command.filenamesuffix, languageCode);
|
|
467
|
+
const suffix = serializeContentValue(suffixRaw).trim();
|
|
468
|
+
assert(suffix.length > 0, "TEMPLATE_SYNTAX_ERROR", "dbmultifile generated empty filename suffix.", { sourceKey });
|
|
469
|
+
assert(!/[\\/:]/.test(suffix), "TEMPLATE_SYNTAX_ERROR", "dbmultifile filename suffix contains invalid characters.", { suffix, sourceKey });
|
|
470
|
+
|
|
471
|
+
const extension = path.extname(target.outputKey);
|
|
472
|
+
const base = extension ? target.outputKey.slice(0, -extension.length) : target.outputKey;
|
|
473
|
+
const generatedOutputKey = `${base}-${suffix}${extension}`;
|
|
474
|
+
assert(!seenNames.has(generatedOutputKey), "TEMPLATE_SYNTAX_ERROR", "dbmultifile generated duplicate output name.", { generatedOutputKey, sourceKey });
|
|
475
|
+
seenNames.add(generatedOutputKey);
|
|
476
|
+
|
|
477
|
+
results.push(await renderSingleTarget({
|
|
478
|
+
config,
|
|
479
|
+
templateRepository,
|
|
480
|
+
contentRepository,
|
|
481
|
+
variantConfig,
|
|
482
|
+
target: { ...target, outputKey: generatedOutputKey },
|
|
483
|
+
body: bodyTemplate,
|
|
484
|
+
currentItem: item,
|
|
485
|
+
templateKey: sourceKey
|
|
486
|
+
}));
|
|
487
|
+
}
|
|
488
|
+
return results;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return [
|
|
492
|
+
await renderSingleTarget({
|
|
493
|
+
config,
|
|
494
|
+
templateRepository,
|
|
495
|
+
contentRepository,
|
|
496
|
+
variantConfig,
|
|
497
|
+
target,
|
|
498
|
+
body,
|
|
499
|
+
templateKey: sourceKey
|
|
500
|
+
})
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function createManualRenderTargets({ config, templateEntries, environment, variant, language, entry }) {
|
|
505
|
+
const targets = [];
|
|
506
|
+
const variants = variant ? [variant] : Object.keys(config.variants);
|
|
507
|
+
|
|
508
|
+
for (const variantName of variants) {
|
|
509
|
+
const variantConfig = config.variants[variantName];
|
|
510
|
+
const languages = language ? [language] : Object.keys(variantConfig.languages);
|
|
511
|
+
for (const languageCode of languages) {
|
|
512
|
+
for (const templateEntry of templateEntries) {
|
|
513
|
+
if (!templateEntry.key.startsWith(`${variantName}/`)) {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (!isRenderableKey(config, templateEntry.key)) {
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (entry && templateEntry.key !== entry) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const outputKey = templateEntry.key.slice(variantName.length + 1);
|
|
524
|
+
targets.push({
|
|
525
|
+
environment,
|
|
526
|
+
variant: variantName,
|
|
527
|
+
language: languageCode,
|
|
528
|
+
sourceKey: templateEntry.key,
|
|
529
|
+
outputKey,
|
|
530
|
+
baseUrl: config.variants[variantName].languages[languageCode].baseUrl
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return targets;
|
|
537
|
+
}
|