@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.
@@ -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
+ }