@shevky/core 0.0.2 → 0.0.4
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/engines/menuEngine.js +117 -0
- package/engines/metaEngine.js +787 -0
- package/engines/pluginEngine.js +103 -0
- package/engines/renderEngine.js +823 -0
- package/lib/contentBody.js +12 -0
- package/lib/contentFile.js +170 -0
- package/lib/contentHeader.js +295 -0
- package/lib/contentSummary.js +85 -0
- package/lib/menuItem.js +51 -0
- package/lib/page.js +103 -0
- package/lib/project.js +82 -0
- package/lib/template.js +50 -0
- package/package.json +6 -2
- package/registries/contentRegistry.js +323 -0
- package/registries/pageRegistry.js +29 -0
- package/registries/pluginRegistry.js +94 -0
- package/registries/templateRegistry.js +152 -0
- package/scripts/cli.js +2 -2
- package/scripts/main.js +1 -1
- package/types/command-line-args.d.ts +20 -0
- package/types/command-line-usage.d.ts +12 -0
- package/types/degit.d.ts +15 -0
- package/types/index.d.ts +98 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import Mustache from "mustache";
|
|
3
|
+
import { marked } from "marked";
|
|
4
|
+
import { markedHighlight } from "marked-highlight";
|
|
5
|
+
import hljs from "highlight.js";
|
|
6
|
+
import { config as _cfg, format as _fmt } from "@shevky/base";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
TemplateRegistry,
|
|
10
|
+
TYPE_COMPONENT,
|
|
11
|
+
TYPE_LAYOUT,
|
|
12
|
+
TYPE_PARTIAL,
|
|
13
|
+
} from "../registries/templateRegistry.js";
|
|
14
|
+
import { Page } from "../lib/page.js";
|
|
15
|
+
import { PageRegistry } from "../registries/pageRegistry.js";
|
|
16
|
+
|
|
17
|
+
/** @typedef {import("../types/index.d.ts").Placeholder} Placeholder */
|
|
18
|
+
|
|
19
|
+
export class RenderEngine {
|
|
20
|
+
/**
|
|
21
|
+
* @type {TemplateRegistry}
|
|
22
|
+
*/
|
|
23
|
+
#_templateRegistry;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @type {{ buildContentUrl: (canonical: string | null | undefined, lang: string, slug: string) => string }}
|
|
27
|
+
*/
|
|
28
|
+
#_metaEngine;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @type {PageRegistry | null}
|
|
32
|
+
*/
|
|
33
|
+
#_pageRegistry;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @type {typeof _cfg}
|
|
37
|
+
*/
|
|
38
|
+
#_config;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @type {typeof _fmt}
|
|
42
|
+
*/
|
|
43
|
+
#_format;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @type {import("marked").Renderer}
|
|
47
|
+
*/
|
|
48
|
+
#_markdownRenderer;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{ templateRegistry: TemplateRegistry, metaEngine: { buildContentUrl: (canonical: string | null | undefined, lang: string, slug: string) => string }, pageRegistry?: PageRegistry | null, config?: typeof _cfg, format?: typeof _fmt }} options
|
|
52
|
+
*/
|
|
53
|
+
constructor(options) {
|
|
54
|
+
this.#_templateRegistry = options.templateRegistry;
|
|
55
|
+
this.#_metaEngine = options.metaEngine;
|
|
56
|
+
this.#_pageRegistry = options.pageRegistry ?? null;
|
|
57
|
+
this.#_config = options.config ?? _cfg;
|
|
58
|
+
this.#_format = options.format ?? _fmt;
|
|
59
|
+
|
|
60
|
+
this.#_markdownRenderer = new marked.Renderer();
|
|
61
|
+
this.#_markdownRenderer.code = (token) => this.#_renderMarkdownCode(token);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
*
|
|
66
|
+
* @param {string} layoutName
|
|
67
|
+
* @param {Record<string, any>} view
|
|
68
|
+
*/
|
|
69
|
+
renderLayout(layoutName, view) {
|
|
70
|
+
const layout = this.#_templateRegistry.getTemplate(TYPE_LAYOUT, layoutName);
|
|
71
|
+
return Mustache.render(layout.content, view, {
|
|
72
|
+
...this.#_templateRegistry.getFiles(TYPE_PARTIAL),
|
|
73
|
+
...this.#_templateRegistry.getFiles(TYPE_COMPONENT),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} html
|
|
79
|
+
* @param {{ versionToken: string, minifyHtml: (html: string, options: Record<string, any>) => Promise<string> }} options
|
|
80
|
+
*/
|
|
81
|
+
async transformHtml(html, options) {
|
|
82
|
+
let output = html
|
|
83
|
+
.replace(/\/output\.css(\?v=[^"']+)?/g, `/output.css?v=${options.versionToken}`)
|
|
84
|
+
.replace(/\/output\.js(\?v=[^"']+)?/g, `/output.js?v=${options.versionToken}`)
|
|
85
|
+
.replace(/\b(src|href)="~\//g, '$1="/');
|
|
86
|
+
|
|
87
|
+
if (!this.#_config.build.minify) {
|
|
88
|
+
return output;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
output = await options.minifyHtml(output, {
|
|
93
|
+
collapseWhitespace: true,
|
|
94
|
+
collapseBooleanAttributes: true,
|
|
95
|
+
decodeEntities: true,
|
|
96
|
+
removeComments: true,
|
|
97
|
+
removeRedundantAttributes: true,
|
|
98
|
+
removeScriptTypeAttributes: true,
|
|
99
|
+
removeStyleLinkTypeAttributes: true,
|
|
100
|
+
removeEmptyAttributes: false,
|
|
101
|
+
sortAttributes: true,
|
|
102
|
+
sortClassName: true,
|
|
103
|
+
minifyCSS: true,
|
|
104
|
+
minifyJS: true,
|
|
105
|
+
});
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
108
|
+
console.warn("[build] Failed to minify HTML:", msg);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return output;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {Record<string, any>} frontMatter
|
|
116
|
+
* @param {string} lang
|
|
117
|
+
* @param {Record<string, any>} dictionary
|
|
118
|
+
* @param {{ i18n: { default: string, flags: (lang: string) => any }, pages: Record<string, any> }} deps
|
|
119
|
+
*/
|
|
120
|
+
buildContentComponentContext(frontMatter, lang, dictionary, deps) {
|
|
121
|
+
const normalizedLang = lang ?? deps.i18n.default;
|
|
122
|
+
const languageFlags = deps.i18n.flags(normalizedLang);
|
|
123
|
+
return {
|
|
124
|
+
front: frontMatter ?? {},
|
|
125
|
+
lang: normalizedLang,
|
|
126
|
+
i18n: dictionary ?? {},
|
|
127
|
+
pages: deps.pages[normalizedLang] ?? {},
|
|
128
|
+
allPages: deps.pages,
|
|
129
|
+
locale: languageFlags.locale,
|
|
130
|
+
isEnglish: languageFlags.isEnglish,
|
|
131
|
+
isTurkish: languageFlags.isTurkish,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {{ lang: string, activeMenuKey: string | null, pageMeta: any, content: string, dictionary: Record<string, any> }} input
|
|
137
|
+
* @param {{
|
|
138
|
+
* pages: Record<string, any>,
|
|
139
|
+
* i18n: { flags: (lang: string) => any, serialize: () => string },
|
|
140
|
+
* metaEngine: { buildSiteData: (lang: string) => any },
|
|
141
|
+
* menuEngine: { getMenuData: (lang: string, activeMenuKey: string | null) => any },
|
|
142
|
+
* getFooterData: (lang: string) => any,
|
|
143
|
+
* analyticsSnippets: any,
|
|
144
|
+
* buildEasterEggPayload: (view: Record<string, any>) => any,
|
|
145
|
+
* }} deps
|
|
146
|
+
*/
|
|
147
|
+
buildViewPayload(input, deps) {
|
|
148
|
+
const { lang, activeMenuKey, pageMeta, content, dictionary } = input;
|
|
149
|
+
const languageFlags = deps.i18n.flags(lang);
|
|
150
|
+
const view = {
|
|
151
|
+
lang,
|
|
152
|
+
locale: languageFlags.locale,
|
|
153
|
+
isEnglish: languageFlags.isEnglish,
|
|
154
|
+
isTurkish: languageFlags.isTurkish,
|
|
155
|
+
theme: "light",
|
|
156
|
+
site: deps.metaEngine.buildSiteData(lang),
|
|
157
|
+
menu: deps.menuEngine.getMenuData(lang, activeMenuKey),
|
|
158
|
+
footer: deps.getFooterData(lang),
|
|
159
|
+
pages: deps.pages,
|
|
160
|
+
i18n: dictionary,
|
|
161
|
+
i18nInline: deps.i18n.serialize(),
|
|
162
|
+
page: pageMeta,
|
|
163
|
+
content,
|
|
164
|
+
scripts: {
|
|
165
|
+
analytics: deps.analyticsSnippets,
|
|
166
|
+
body: [],
|
|
167
|
+
},
|
|
168
|
+
easterEgg: "",
|
|
169
|
+
};
|
|
170
|
+
view.easterEgg = deps.buildEasterEggPayload(view);
|
|
171
|
+
return view;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {{
|
|
176
|
+
* kind?: string,
|
|
177
|
+
* type?: string,
|
|
178
|
+
* lang: string,
|
|
179
|
+
* slug: string,
|
|
180
|
+
* canonical?: string,
|
|
181
|
+
* layout: string,
|
|
182
|
+
* template?: string,
|
|
183
|
+
* front: Record<string, unknown>,
|
|
184
|
+
* view: Record<string, unknown>,
|
|
185
|
+
* html: string,
|
|
186
|
+
* sourcePath?: string,
|
|
187
|
+
* outputPath?: string,
|
|
188
|
+
* writeMeta?: Record<string, unknown>,
|
|
189
|
+
* }} input
|
|
190
|
+
*/
|
|
191
|
+
createPage(input) {
|
|
192
|
+
const isDynamic =
|
|
193
|
+
typeof input.writeMeta?.action === "string" &&
|
|
194
|
+
input.writeMeta.action.includes("DYNAMIC");
|
|
195
|
+
const isCollection =
|
|
196
|
+
typeof input.front?.collectionType === "string" &&
|
|
197
|
+
input.front.collectionType.length > 0;
|
|
198
|
+
const page = new Page({
|
|
199
|
+
...input,
|
|
200
|
+
isDynamic,
|
|
201
|
+
isCollection,
|
|
202
|
+
isStatic: !isDynamic,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (this.#_pageRegistry) {
|
|
206
|
+
this.#_pageRegistry.add(page);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return page;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {string} markdown
|
|
214
|
+
* @param {Record<string, any>} [context]
|
|
215
|
+
* @returns {{ markdown: string, placeholders: Placeholder[] }}
|
|
216
|
+
*/
|
|
217
|
+
renderMarkdownComponents(markdown, context = {}) {
|
|
218
|
+
if (!markdown || typeof markdown !== "string") {
|
|
219
|
+
return { markdown: markdown ?? "", placeholders: [] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @type {Placeholder[]} */
|
|
223
|
+
const placeholders = [];
|
|
224
|
+
const baseContext = context ?? {};
|
|
225
|
+
const writer = new Mustache.Writer();
|
|
226
|
+
const writerAny = /** @type {any} */ (writer);
|
|
227
|
+
const originalRenderPartial = writer.renderPartial;
|
|
228
|
+
|
|
229
|
+
const that = this;
|
|
230
|
+
/**
|
|
231
|
+
* @this {any}
|
|
232
|
+
* @param {any} token
|
|
233
|
+
* @param {any} tokenContext
|
|
234
|
+
* @param {any} partials
|
|
235
|
+
* @param {any} config
|
|
236
|
+
*/
|
|
237
|
+
writer.renderPartial = function renderPartial(
|
|
238
|
+
token,
|
|
239
|
+
tokenContext,
|
|
240
|
+
partials,
|
|
241
|
+
config,
|
|
242
|
+
) {
|
|
243
|
+
const name = token?.[1];
|
|
244
|
+
if (name?.startsWith("components/")) {
|
|
245
|
+
const template = that.#_templateRegistry.getTemplate(
|
|
246
|
+
TYPE_COMPONENT,
|
|
247
|
+
name,
|
|
248
|
+
).content;
|
|
249
|
+
if (!template) return "";
|
|
250
|
+
const tokenId = `COMPONENT_SLOT_${placeholders.length}_${name.replace(/[^A-Za-z0-9_-]/g, "_")}_${crypto
|
|
251
|
+
.randomBytes(4)
|
|
252
|
+
.toString("hex")}`;
|
|
253
|
+
const comment = `<!--${tokenId}-->`;
|
|
254
|
+
const marker = `\n${comment}\n`;
|
|
255
|
+
const tags =
|
|
256
|
+
typeof writerAny.getConfigTags === "function"
|
|
257
|
+
? writerAny.getConfigTags(config)
|
|
258
|
+
: undefined;
|
|
259
|
+
const tokens = writerAny.parse(template, tags);
|
|
260
|
+
const html = writerAny.renderTokens(
|
|
261
|
+
tokens,
|
|
262
|
+
tokenContext,
|
|
263
|
+
partials,
|
|
264
|
+
template,
|
|
265
|
+
/** @type {any} */ (config),
|
|
266
|
+
);
|
|
267
|
+
placeholders.push({ token: tokenId, marker, html });
|
|
268
|
+
return marker;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return originalRenderPartial.call(
|
|
272
|
+
this,
|
|
273
|
+
token,
|
|
274
|
+
tokenContext,
|
|
275
|
+
partials,
|
|
276
|
+
/** @type {any} */ (config),
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
let renderedMarkdown = writer.render(markdown, baseContext, {
|
|
281
|
+
...this.#_templateRegistry.getFiles(TYPE_PARTIAL),
|
|
282
|
+
...this.#_templateRegistry.getFiles(TYPE_COMPONENT),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
placeholders.forEach(({ token }) => {
|
|
286
|
+
const marker = `<!--${token}-->`;
|
|
287
|
+
const pattern = new RegExp(
|
|
288
|
+
`^[ \\t]*${this.#_escapeRegExp(marker)}[ \\t]*$`,
|
|
289
|
+
"gm",
|
|
290
|
+
);
|
|
291
|
+
renderedMarkdown = renderedMarkdown.replace(pattern, marker);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return { markdown: renderedMarkdown, placeholders };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @param {{
|
|
299
|
+
* frontMatter: Record<string, any>,
|
|
300
|
+
* lang: string,
|
|
301
|
+
* baseSlug: string,
|
|
302
|
+
* layoutName: string,
|
|
303
|
+
* templateName: string,
|
|
304
|
+
* contentHtml: string,
|
|
305
|
+
* dictionary: Record<string, any>,
|
|
306
|
+
* sourcePath: string,
|
|
307
|
+
* pages: Record<string, Record<string, any[]>>,
|
|
308
|
+
* renderContentTemplate: (templateName: string, contentHtml: string, front: Record<string, any>, lang: string, dictionary: Record<string, any>, listing?: Record<string, any>) => Promise<string>,
|
|
309
|
+
* buildViewPayload: (input: { lang: string, activeMenuKey: string | null, pageMeta: any, content: string, dictionary: Record<string, any> }) => Record<string, any>,
|
|
310
|
+
* renderPage: (input: { layoutName: string, view: Record<string, any>, front: Record<string, any>, lang: string, slug: string, writeMeta?: Record<string, any> }) => Promise<any>,
|
|
311
|
+
* metaEngine: { buildPageMeta: (front: Record<string, any>, lang: string, slug: string) => any },
|
|
312
|
+
* menuEngine: { resolveActiveMenuKey: (front: Record<string, any>) => string | null },
|
|
313
|
+
* resolveListingKey: (front: Record<string, any>) => string | null,
|
|
314
|
+
* resolveListingEmpty: (front: Record<string, any>, lang: string) => any,
|
|
315
|
+
* resolveCollectionType: (front: Record<string, any>, items?: any[], fallback?: string) => string,
|
|
316
|
+
* buildCollectionTypeFlags: (type: string) => Record<string, any>,
|
|
317
|
+
* resolvePaginationSegment: (lang: string) => string,
|
|
318
|
+
* dedupeCollectionItems: (items: any[]) => any[],
|
|
319
|
+
* byteLength: (input: unknown) => number,
|
|
320
|
+
* }} options
|
|
321
|
+
*/
|
|
322
|
+
async buildPaginatedCollectionPages(options) {
|
|
323
|
+
const {
|
|
324
|
+
frontMatter,
|
|
325
|
+
lang,
|
|
326
|
+
baseSlug,
|
|
327
|
+
layoutName,
|
|
328
|
+
templateName,
|
|
329
|
+
contentHtml,
|
|
330
|
+
dictionary,
|
|
331
|
+
sourcePath,
|
|
332
|
+
pages,
|
|
333
|
+
renderContentTemplate,
|
|
334
|
+
buildViewPayload,
|
|
335
|
+
renderPage,
|
|
336
|
+
metaEngine,
|
|
337
|
+
menuEngine,
|
|
338
|
+
resolveListingKey,
|
|
339
|
+
resolveListingEmpty,
|
|
340
|
+
resolveCollectionType,
|
|
341
|
+
buildCollectionTypeFlags,
|
|
342
|
+
resolvePaginationSegment,
|
|
343
|
+
dedupeCollectionItems,
|
|
344
|
+
byteLength,
|
|
345
|
+
} = options;
|
|
346
|
+
|
|
347
|
+
const normalizedFrontMatter = normalizeFrontMatter(frontMatter);
|
|
348
|
+
const langCollections = pages[lang] ?? {};
|
|
349
|
+
const key = resolveListingKey(normalizedFrontMatter);
|
|
350
|
+
const sourceItems =
|
|
351
|
+
key && Array.isArray(langCollections[key]) ? langCollections[key] : [];
|
|
352
|
+
const allItems = dedupeCollectionItems(sourceItems);
|
|
353
|
+
const pageSizeSetting = this.#_config.content.pagination.pageSize;
|
|
354
|
+
const pageSize = pageSizeSetting > 0 ? pageSizeSetting : 5;
|
|
355
|
+
const totalPages = Math.max(
|
|
356
|
+
1,
|
|
357
|
+
pageSize > 0 ? Math.ceil(allItems.length / pageSize) : 1,
|
|
358
|
+
);
|
|
359
|
+
const emptyMessage = resolveListingEmpty(normalizedFrontMatter, lang);
|
|
360
|
+
const collectionType = resolveCollectionType(normalizedFrontMatter, allItems);
|
|
361
|
+
const collectionFlags = buildCollectionTypeFlags(collectionType);
|
|
362
|
+
|
|
363
|
+
for (let pageIndex = 1; pageIndex <= totalPages; pageIndex += 1) {
|
|
364
|
+
const startIndex = (pageIndex - 1) * pageSize;
|
|
365
|
+
const endIndex = startIndex + pageSize;
|
|
366
|
+
const items = allItems.slice(startIndex, endIndex);
|
|
367
|
+
const hasItems = items.length > 0;
|
|
368
|
+
const hasPrev = pageIndex > 1;
|
|
369
|
+
const hasNext = pageIndex < totalPages;
|
|
370
|
+
|
|
371
|
+
const segment = resolvePaginationSegment(lang);
|
|
372
|
+
|
|
373
|
+
const base = baseSlug.replace(/\/+$/, "");
|
|
374
|
+
|
|
375
|
+
const pageSlug =
|
|
376
|
+
pageIndex === 1
|
|
377
|
+
? baseSlug
|
|
378
|
+
: base
|
|
379
|
+
? `${base}/${segment}-${pageIndex}`
|
|
380
|
+
: `${segment}-${pageIndex}`;
|
|
381
|
+
|
|
382
|
+
const prevSlug =
|
|
383
|
+
pageIndex > 2
|
|
384
|
+
? base
|
|
385
|
+
? `${base}/${segment}-${pageIndex - 1}`
|
|
386
|
+
: `${segment}-${pageIndex - 1}`
|
|
387
|
+
: baseSlug;
|
|
388
|
+
|
|
389
|
+
const nextSlug = base
|
|
390
|
+
? `${base}/${segment}-${pageIndex + 1}`
|
|
391
|
+
: `${segment}-${pageIndex + 1}`;
|
|
392
|
+
|
|
393
|
+
const listing = {
|
|
394
|
+
key,
|
|
395
|
+
lang,
|
|
396
|
+
items,
|
|
397
|
+
hasItems,
|
|
398
|
+
emptyMessage,
|
|
399
|
+
page: pageIndex,
|
|
400
|
+
totalPages,
|
|
401
|
+
hasPrev,
|
|
402
|
+
hasNext,
|
|
403
|
+
hasPagination: totalPages > 1,
|
|
404
|
+
prevUrl: hasPrev
|
|
405
|
+
? this.#_metaEngine.buildContentUrl(null, lang, prevSlug)
|
|
406
|
+
: "",
|
|
407
|
+
nextUrl: hasNext
|
|
408
|
+
? this.#_metaEngine.buildContentUrl(null, lang, nextSlug)
|
|
409
|
+
: "",
|
|
410
|
+
type: collectionType,
|
|
411
|
+
...collectionFlags,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
let canonical = normalizedFrontMatter.canonical;
|
|
415
|
+
if (pageIndex > 1) {
|
|
416
|
+
if (
|
|
417
|
+
typeof normalizedFrontMatter.canonical === "string" &&
|
|
418
|
+
normalizedFrontMatter.canonical.trim().length > 0
|
|
419
|
+
) {
|
|
420
|
+
const trimmed = normalizedFrontMatter.canonical
|
|
421
|
+
.trim()
|
|
422
|
+
.replace(/\/+$/, "");
|
|
423
|
+
canonical = `${trimmed}/${segment}-${pageIndex}/`;
|
|
424
|
+
} else {
|
|
425
|
+
canonical = undefined;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const frontForPage = /** @type {Record<string, any>} */ ({
|
|
430
|
+
...normalizedFrontMatter,
|
|
431
|
+
slug: pageSlug,
|
|
432
|
+
canonical,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
if (collectionType) {
|
|
436
|
+
frontForPage.collectionType = collectionType;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const renderedContent = await renderContentTemplate(
|
|
440
|
+
templateName,
|
|
441
|
+
contentHtml,
|
|
442
|
+
frontForPage,
|
|
443
|
+
lang,
|
|
444
|
+
dictionary,
|
|
445
|
+
listing,
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const pageMeta = metaEngine.buildPageMeta(frontForPage, lang, pageSlug);
|
|
449
|
+
const activeMenuKey = menuEngine.resolveActiveMenuKey(frontForPage);
|
|
450
|
+
const view = buildViewPayload({
|
|
451
|
+
lang,
|
|
452
|
+
activeMenuKey,
|
|
453
|
+
pageMeta,
|
|
454
|
+
content: renderedContent,
|
|
455
|
+
dictionary,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
await renderPage({
|
|
459
|
+
layoutName,
|
|
460
|
+
view,
|
|
461
|
+
front: frontForPage,
|
|
462
|
+
lang,
|
|
463
|
+
slug: pageSlug,
|
|
464
|
+
writeMeta: {
|
|
465
|
+
action: "BUILD_COLLECTION",
|
|
466
|
+
type: templateName,
|
|
467
|
+
source: sourcePath,
|
|
468
|
+
lang,
|
|
469
|
+
template: layoutName,
|
|
470
|
+
items: items.length,
|
|
471
|
+
page: `${pageIndex}/${totalPages}`,
|
|
472
|
+
inputBytes: byteLength(renderedContent),
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* @param {{
|
|
480
|
+
* collectionsConfig: Record<string, any> | null | undefined,
|
|
481
|
+
* pages: Record<string, Record<string, any[]>>,
|
|
482
|
+
* i18n: { supported: string[], get: (lang: string) => Record<string, any>, t: (lang: string, key: string, fallback?: string) => any },
|
|
483
|
+
* renderContentTemplate: (templateName: string, contentHtml: string, front: Record<string, any>, lang: string, dictionary: Record<string, any>) => Promise<string>,
|
|
484
|
+
* buildViewPayload: (input: { lang: string, activeMenuKey: string | null, pageMeta: any, content: string, dictionary: Record<string, any> }) => Record<string, any>,
|
|
485
|
+
* renderPage: (input: { layoutName: string, view: Record<string, any>, front: Record<string, any>, lang: string, slug: string, writeMeta?: Record<string, any> }) => Promise<any>,
|
|
486
|
+
* metaEngine: { buildPageMeta: (front: Record<string, any>, lang: string, slug: string) => any },
|
|
487
|
+
* menuEngine: { resolveActiveMenuKey: (front: Record<string, any>) => string | null },
|
|
488
|
+
* resolveCollectionType: (front: Record<string, any>, items?: any[], fallback?: string) => string,
|
|
489
|
+
* normalizeCollectionTypeValue: (value: unknown) => string,
|
|
490
|
+
* resolveCollectionDisplayKey: (configKey: string, defaultKey: string, items: any[]) => string,
|
|
491
|
+
* dedupeCollectionItems: (items: any[]) => any[],
|
|
492
|
+
* normalizeLogPath: (pathValue?: string | null) => string,
|
|
493
|
+
* io: { path: { combine: (...segments: string[]) => string } },
|
|
494
|
+
* byteLength: (input: unknown) => number,
|
|
495
|
+
* }} options
|
|
496
|
+
*/
|
|
497
|
+
async buildDynamicCollectionPages(options) {
|
|
498
|
+
const {
|
|
499
|
+
collectionsConfig,
|
|
500
|
+
pages,
|
|
501
|
+
i18n,
|
|
502
|
+
renderContentTemplate,
|
|
503
|
+
buildViewPayload,
|
|
504
|
+
renderPage,
|
|
505
|
+
metaEngine,
|
|
506
|
+
menuEngine,
|
|
507
|
+
resolveCollectionType,
|
|
508
|
+
normalizeCollectionTypeValue,
|
|
509
|
+
resolveCollectionDisplayKey,
|
|
510
|
+
dedupeCollectionItems,
|
|
511
|
+
normalizeLogPath,
|
|
512
|
+
io,
|
|
513
|
+
byteLength,
|
|
514
|
+
} = options;
|
|
515
|
+
|
|
516
|
+
if (!collectionsConfig || typeof collectionsConfig !== "object") {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const configKeys = Object.keys(collectionsConfig);
|
|
521
|
+
for (const configKey of configKeys) {
|
|
522
|
+
const config = collectionsConfig[configKey];
|
|
523
|
+
if (!config || typeof config !== "object") {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const templateName =
|
|
528
|
+
typeof config.template === "string" && config.template.trim().length > 0
|
|
529
|
+
? config.template.trim()
|
|
530
|
+
: "category";
|
|
531
|
+
|
|
532
|
+
const slugPattern =
|
|
533
|
+
config.slugPattern && typeof config.slugPattern === "object"
|
|
534
|
+
? /** @type {Record<string, string>} */ (config.slugPattern)
|
|
535
|
+
: {};
|
|
536
|
+
const pairs =
|
|
537
|
+
config.pairs && typeof config.pairs === "object"
|
|
538
|
+
? /** @type {Record<string, Record<string, string>>} */ (config.pairs)
|
|
539
|
+
: null;
|
|
540
|
+
|
|
541
|
+
const rawTypes =
|
|
542
|
+
Array.isArray(config.types) && config.types.length > 0
|
|
543
|
+
? /** @type {unknown[]} */ (config.types)
|
|
544
|
+
: null;
|
|
545
|
+
const types = rawTypes
|
|
546
|
+
? rawTypes
|
|
547
|
+
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
|
548
|
+
.filter((value) => value.length > 0)
|
|
549
|
+
: null;
|
|
550
|
+
|
|
551
|
+
if (!types || types.length === 0) {
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const languages = Object.keys(pages);
|
|
556
|
+
for (const lang of languages) {
|
|
557
|
+
const langCollections = pages[lang] ?? {};
|
|
558
|
+
const dictionary = i18n.get(lang);
|
|
559
|
+
const langSlugPattern =
|
|
560
|
+
typeof slugPattern[lang] === "string" ? slugPattern[lang] : null;
|
|
561
|
+
const titleSuffix = i18n.t(
|
|
562
|
+
lang,
|
|
563
|
+
`seo.collections.${configKey}.titleSuffix`,
|
|
564
|
+
"",
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
const collectionKeys = Object.keys(langCollections);
|
|
568
|
+
for (const key of collectionKeys) {
|
|
569
|
+
/** @type {any[]} */
|
|
570
|
+
const items = langCollections[key] ?? [];
|
|
571
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const typedItems = items.filter((entry) => {
|
|
576
|
+
if (!entry) {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
const entryType =
|
|
580
|
+
typeof entry.type === "string" ? entry.type.trim() : "";
|
|
581
|
+
if (!entryType) {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
return types.includes(entryType);
|
|
585
|
+
});
|
|
586
|
+
if (!typedItems.length) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const dedupedItems = dedupeCollectionItems(typedItems);
|
|
591
|
+
if (!dedupedItems.length) {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const slug =
|
|
596
|
+
langSlugPattern && langSlugPattern.includes("{{key}}")
|
|
597
|
+
? langSlugPattern.replace("{{key}}", key)
|
|
598
|
+
: (langSlugPattern ?? key);
|
|
599
|
+
|
|
600
|
+
let alternate;
|
|
601
|
+
if (pairs) {
|
|
602
|
+
const pairEntry = pairs[key];
|
|
603
|
+
if (pairEntry && typeof pairEntry === "object") {
|
|
604
|
+
/** @type {Record<string, string>} */
|
|
605
|
+
const altMap = {};
|
|
606
|
+
i18n.supported.forEach((altLang) => {
|
|
607
|
+
if (altLang === lang) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const altKey =
|
|
612
|
+
typeof pairEntry[altLang] === "string"
|
|
613
|
+
? pairEntry[altLang].trim()
|
|
614
|
+
: "";
|
|
615
|
+
if (!altKey) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const altSlugPattern =
|
|
620
|
+
typeof slugPattern[altLang] === "string"
|
|
621
|
+
? slugPattern[altLang]
|
|
622
|
+
: null;
|
|
623
|
+
const altSlug =
|
|
624
|
+
altSlugPattern && altSlugPattern.includes("{{key}}")
|
|
625
|
+
? altSlugPattern.replace("{{key}}", altKey)
|
|
626
|
+
: (altSlugPattern ?? altKey);
|
|
627
|
+
altMap[altLang] = this.#_metaEngine.buildContentUrl(
|
|
628
|
+
null,
|
|
629
|
+
altLang,
|
|
630
|
+
altSlug,
|
|
631
|
+
);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
if (Object.keys(altMap).length > 0) {
|
|
635
|
+
alternate = altMap;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const displayKey = resolveCollectionDisplayKey(
|
|
641
|
+
configKey,
|
|
642
|
+
key,
|
|
643
|
+
dedupedItems,
|
|
644
|
+
);
|
|
645
|
+
const baseTitle = displayKey;
|
|
646
|
+
const normalizedTitleSuffix =
|
|
647
|
+
typeof titleSuffix === "string" && titleSuffix.trim().length > 0
|
|
648
|
+
? titleSuffix.trim()
|
|
649
|
+
: "";
|
|
650
|
+
const effectiveTitle = normalizedTitleSuffix
|
|
651
|
+
? `${baseTitle} | ${normalizedTitleSuffix}`
|
|
652
|
+
: baseTitle;
|
|
653
|
+
const frontTitle = configKey === "series" ? displayKey : effectiveTitle;
|
|
654
|
+
const front = /** @type {Record<string, any>} */ ({
|
|
655
|
+
title: frontTitle,
|
|
656
|
+
metaTitle: effectiveTitle,
|
|
657
|
+
slug,
|
|
658
|
+
template: templateName,
|
|
659
|
+
listKey: key,
|
|
660
|
+
...(alternate ? { alternate } : {}),
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
front.listHeading = effectiveTitle;
|
|
664
|
+
if (configKey === "series") {
|
|
665
|
+
front.series = key;
|
|
666
|
+
front.seriesTitle = displayKey;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const fallbackType = normalizeCollectionTypeValue(
|
|
670
|
+
types.length === 1 ? types[0] : "",
|
|
671
|
+
);
|
|
672
|
+
const resolvedCollectionType = resolveCollectionType(
|
|
673
|
+
front,
|
|
674
|
+
dedupedItems,
|
|
675
|
+
fallbackType,
|
|
676
|
+
);
|
|
677
|
+
if (resolvedCollectionType) {
|
|
678
|
+
front.collectionType = resolvedCollectionType;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const contentHtml = await renderContentTemplate(
|
|
682
|
+
templateName,
|
|
683
|
+
"",
|
|
684
|
+
front,
|
|
685
|
+
lang,
|
|
686
|
+
dictionary,
|
|
687
|
+
);
|
|
688
|
+
const pageMeta = metaEngine.buildPageMeta(front, lang, slug);
|
|
689
|
+
const layoutName = "default";
|
|
690
|
+
const activeMenuKey = menuEngine.resolveActiveMenuKey(front);
|
|
691
|
+
const view = buildViewPayload({
|
|
692
|
+
lang,
|
|
693
|
+
activeMenuKey,
|
|
694
|
+
pageMeta,
|
|
695
|
+
content: contentHtml,
|
|
696
|
+
dictionary,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
await renderPage({
|
|
700
|
+
layoutName,
|
|
701
|
+
view,
|
|
702
|
+
front,
|
|
703
|
+
lang,
|
|
704
|
+
slug,
|
|
705
|
+
writeMeta: {
|
|
706
|
+
action: "BUILD_DYNAMIC_COLLECTION",
|
|
707
|
+
type: templateName,
|
|
708
|
+
source: normalizeLogPath(io.path.combine("collections", configKey)),
|
|
709
|
+
lang,
|
|
710
|
+
template: layoutName,
|
|
711
|
+
items: dedupedItems.length,
|
|
712
|
+
inputBytes: byteLength(contentHtml),
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* @param {string} html
|
|
722
|
+
* @param {Placeholder[]} placeholders
|
|
723
|
+
*/
|
|
724
|
+
injectMarkdownComponents(html, placeholders) {
|
|
725
|
+
if (!html || !placeholders || !placeholders.length) {
|
|
726
|
+
return html;
|
|
727
|
+
}
|
|
728
|
+
let output = html;
|
|
729
|
+
for (let i = placeholders.length - 1; i >= 0; i -= 1) {
|
|
730
|
+
const { token, marker, html: snippet } = placeholders[i];
|
|
731
|
+
const safeSnippet = snippet ?? "";
|
|
732
|
+
let nextOutput = output;
|
|
733
|
+
|
|
734
|
+
if (token) {
|
|
735
|
+
const pattern = new RegExp(
|
|
736
|
+
`(?:<p>)?\\s*<!--${this.#_escapeRegExp(token)}-->\\s*(?:</p>)?`,
|
|
737
|
+
"g",
|
|
738
|
+
);
|
|
739
|
+
const replaced = nextOutput.replace(pattern, safeSnippet);
|
|
740
|
+
nextOutput = replaced;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (nextOutput === output && marker) {
|
|
744
|
+
nextOutput = nextOutput.split(marker).join(safeSnippet);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
output = nextOutput;
|
|
748
|
+
}
|
|
749
|
+
return output;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* @param {string} markdown
|
|
754
|
+
*/
|
|
755
|
+
parseMarkdown(markdown) {
|
|
756
|
+
return /** @type {string} */ (marked.parse(markdown ?? ""));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
setupMarkdown() {
|
|
760
|
+
marked.setOptions(
|
|
761
|
+
/** @type {any} */ ({ mangle: false, headerIds: false, gfm: true }),
|
|
762
|
+
);
|
|
763
|
+
if (this.#_config.markdown.highlight) {
|
|
764
|
+
/**
|
|
765
|
+
* @param {string} code
|
|
766
|
+
* @param {string} lang
|
|
767
|
+
* @returns {string}
|
|
768
|
+
*/
|
|
769
|
+
function highlightCode(code, lang) {
|
|
770
|
+
const language = hljs.getLanguage(lang) ? lang : "plaintext";
|
|
771
|
+
return hljs.highlight(code, { language }).value;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
marked.use(
|
|
775
|
+
markedHighlight({
|
|
776
|
+
langPrefix: "hljs language-",
|
|
777
|
+
highlight: highlightCode,
|
|
778
|
+
}),
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
marked.use({ renderer: this.#_markdownRenderer });
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* @param {any} token
|
|
787
|
+
* @returns {string}
|
|
788
|
+
*/
|
|
789
|
+
#_renderMarkdownCode(token) {
|
|
790
|
+
const isTokenObject = token && typeof token === "object";
|
|
791
|
+
const languageSource =
|
|
792
|
+
isTokenObject && typeof token.lang === "string" ? token.lang : "";
|
|
793
|
+
const language =
|
|
794
|
+
(languageSource || "").trim().split(/\\s+/)[0]?.toLowerCase() || "text";
|
|
795
|
+
const langClass = language ? ` class="language-${language}"` : "";
|
|
796
|
+
const value =
|
|
797
|
+
isTokenObject && typeof token.text === "string"
|
|
798
|
+
? token.text
|
|
799
|
+
: (token ?? "");
|
|
800
|
+
const alreadyEscaped = Boolean(isTokenObject && token.escaped);
|
|
801
|
+
const content = alreadyEscaped ? value : this.#_format.escape(value);
|
|
802
|
+
return `<pre class="code-block" data-code-language="${language}"><code${langClass}>${content}</code></pre>`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** @param {string} [value] */
|
|
806
|
+
#_escapeRegExp(value = "") {
|
|
807
|
+
return value.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&");
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** @param {Record<string, any> | { raw?: unknown } | null | undefined} front */
|
|
812
|
+
function normalizeFrontMatter(front) {
|
|
813
|
+
if (!front || typeof front !== "object") {
|
|
814
|
+
return {};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const raw =
|
|
818
|
+
"raw" in front && front.raw && typeof front.raw === "object"
|
|
819
|
+
? front.raw
|
|
820
|
+
: front;
|
|
821
|
+
|
|
822
|
+
return typeof raw === "object" && raw !== null ? { ...raw } : {};
|
|
823
|
+
}
|