@shevky/core 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,56 @@ npm run build
23
23
  npm run dev
24
24
  ```
25
25
 
26
+ ## Build Memory Setting
27
+
28
+ For large projects you can limit in-memory page buffering during build:
29
+
30
+ ```json
31
+ {
32
+ "build": {
33
+ "pageBufferLimit": 20
34
+ }
35
+ }
36
+ ```
37
+
38
+ - `pageBufferLimit`: Number of rendered pages kept in memory before flushing to disk.
39
+ - Default: `20`
40
+ - Environment override: `SHEVKY_PAGE_BUFFER_LIMIT`
41
+
42
+ ## Output Aliases
43
+
44
+ If you want to keep markdown generation (plugins/layout/meta) but publish an
45
+ additional file path, you can define build aliases:
46
+
47
+ ```json
48
+ {
49
+ "build": {
50
+ "outputAliases": [
51
+ { "from": "~/404/", "to": "~/404.html" },
52
+ { "from": "~/en/404/", "to": "~/en/404.html" }
53
+ ]
54
+ }
55
+ }
56
+ ```
57
+
58
+ - `from`: Generated page URL/path.
59
+ - `to`: Extra copied output URL/path.
60
+
61
+ ## Content Root Directories
62
+
63
+ You can copy selected `src/content` directories directly to dist root:
64
+
65
+ ```json
66
+ {
67
+ "build": {
68
+ "contentRootDirectories": [".well-known"]
69
+ }
70
+ }
71
+ ```
72
+
73
+ - Default: `[".well-known"]`
74
+ - Example: `src/content/.well-known/apple-app-site-association` -> `dist/.well-known/apple-app-site-association`
75
+
26
76
  ## Technical Documentation
27
77
 
28
78
  The tech documentation is preparing for Shevky with Shevky. You will find it under [Shevky Project](https://tatoglu.net/project/shevky) page.
@@ -1,4 +1,4 @@
1
- import { log as _log, plugin as _plugin } from "@shevky/base";
1
+ import { i18n as _i18n, log as _log, plugin as _plugin } from "@shevky/base";
2
2
  import { PluginRegistry } from "../registries/pluginRegistry.js";
3
3
  import { ContentRegistry } from "../registries/contentRegistry.js";
4
4
  import { Project } from "../lib/project.js";
@@ -27,6 +27,11 @@ export class PluginEngine {
27
27
  */
28
28
  #_metaEngine;
29
29
 
30
+ /**
31
+ * @type {Record<string, any>}
32
+ */
33
+ #_runtimeContext = {};
34
+
30
35
  /**
31
36
  * @param {PluginRegistry} pluginRegistry
32
37
  * @param {ContentRegistry} contentRegistry
@@ -38,6 +43,22 @@ export class PluginEngine {
38
43
  this.#_metaEngine = metaEngine;
39
44
  }
40
45
 
46
+ /**
47
+ * @param {Record<string, any>} runtimeContext
48
+ * @returns {void}
49
+ */
50
+ setRuntimeContext(runtimeContext) {
51
+ this.#_runtimeContext =
52
+ runtimeContext && typeof runtimeContext === "object" ? runtimeContext : {};
53
+ }
54
+
55
+ /**
56
+ * @returns {void}
57
+ */
58
+ clearRuntimeContext() {
59
+ this.#_runtimeContext = {};
60
+ }
61
+
41
62
  /**
42
63
  * @param {string} hook
43
64
  * @returns {Promise<void>}
@@ -82,6 +103,7 @@ export class PluginEngine {
82
103
  return {
83
104
  ...baseContext,
84
105
  paths: this.#_project.toObject(),
106
+ i18n: _i18n,
85
107
 
86
108
  // content:load
87
109
  ...(hook === _plugin.hooks.CONTENT_LOAD
@@ -98,6 +120,7 @@ export class PluginEngine {
98
120
  contentIndex: this.#_contentRegistry.buildContentIndex(),
99
121
  }
100
122
  : {}),
123
+ ...this.#_runtimeContext,
101
124
  };
102
125
  }
103
126
  }
@@ -16,6 +16,33 @@ import { PageRegistry } from "../registries/pageRegistry.js";
16
16
 
17
17
  /** @typedef {import("../types/index.d.ts").Placeholder} Placeholder */
18
18
 
19
+ const PAGINATED_SCHEMA_TYPE_RULES = {
20
+ home: "collection",
21
+ };
22
+
23
+ /** @param {unknown} value */
24
+ function normalizeSchemaType(value) {
25
+ if (typeof value !== "string") {
26
+ return "";
27
+ }
28
+
29
+ return value.trim().toLowerCase();
30
+ }
31
+
32
+ /** @param {unknown} schemaType @param {number} pageIndex */
33
+ function resolvePaginatedSchemaType(schemaType, pageIndex) {
34
+ const normalizedType = normalizeSchemaType(schemaType);
35
+ if (!normalizedType) {
36
+ return "";
37
+ }
38
+
39
+ if (pageIndex <= 1) {
40
+ return normalizedType;
41
+ }
42
+
43
+ return PAGINATED_SCHEMA_TYPE_RULES[normalizedType] ?? normalizedType;
44
+ }
45
+
19
46
  export class RenderEngine {
20
47
  /**
21
48
  * @type {TemplateRegistry}
@@ -181,7 +208,7 @@ export class RenderEngine {
181
208
  * layout: string,
182
209
  * template?: string,
183
210
  * front: Record<string, unknown>,
184
- * view: Record<string, unknown>,
211
+ * view?: Record<string, unknown>,
185
212
  * html: string,
186
213
  * sourcePath?: string,
187
214
  * outputPath?: string,
@@ -436,6 +463,14 @@ export class RenderEngine {
436
463
  frontForPage.collectionType = collectionType;
437
464
  }
438
465
 
466
+ const paginatedSchemaType = resolvePaginatedSchemaType(
467
+ frontForPage.schemaType,
468
+ pageIndex,
469
+ );
470
+ if (paginatedSchemaType) {
471
+ frontForPage.schemaType = paginatedSchemaType;
472
+ }
473
+
439
474
  const renderedContent = await renderContentTemplate(
440
475
  templateName,
441
476
  contentHtml,
@@ -445,7 +480,11 @@ export class RenderEngine {
445
480
  listing,
446
481
  );
447
482
 
448
- const pageMeta = metaEngine.buildPageMeta(frontForPage, lang, pageSlug);
483
+ const pageMeta = await metaEngine.buildPageMeta(
484
+ frontForPage,
485
+ lang,
486
+ pageSlug,
487
+ );
449
488
  const activeMenuKey = menuEngine.resolveActiveMenuKey(frontForPage);
450
489
  const view = buildViewPayload({
451
490
  lang,
@@ -687,7 +726,7 @@ export class RenderEngine {
687
726
  lang,
688
727
  dictionary,
689
728
  );
690
- const pageMeta = metaEngine.buildPageMeta(front, lang, slug);
729
+ const pageMeta = await metaEngine.buildPageMeta(front, lang, slug);
691
730
  const layoutName = "default";
692
731
  const activeMenuKey = menuEngine.resolveActiveMenuKey(front);
693
732
  const view = buildViewPayload({
@@ -812,14 +851,7 @@ export class RenderEngine {
812
851
 
813
852
  /** @param {Record<string, any> | { raw?: unknown } | null | undefined} front */
814
853
  function normalizeFrontMatter(front) {
815
- if (!front || typeof front !== "object") {
816
- return {};
817
- }
818
-
819
- const raw =
820
- "raw" in front && front.raw && typeof front.raw === "object"
821
- ? front.raw
822
- : front;
823
-
824
- return typeof raw === "object" && raw !== null ? { ...raw } : {};
854
+ const frontRecord = _fmt.toRecord(front);
855
+ const rawRecord = _fmt.pickFirstRecord(frontRecord?.raw, frontRecord);
856
+ return rawRecord ? { ...rawRecord } : {};
825
857
  }
@@ -68,6 +68,10 @@ export class ContentFile {
68
68
  return this._header.template;
69
69
  }
70
70
 
71
+ get schemaType() {
72
+ return this._header.schemaType;
73
+ }
74
+
71
75
  get isFeatured() {
72
76
  return this._header.isFeatured;
73
77
  }
@@ -104,6 +104,12 @@ export class ContentHeader {
104
104
  : "";
105
105
  }
106
106
 
107
+ get schemaType() {
108
+ return typeof this._frontMatter.schemaType === "string"
109
+ ? this._frontMatter.schemaType.trim().toLowerCase()
110
+ : "";
111
+ }
112
+
107
113
  get related() {
108
114
  return Array.isArray(this._frontMatter.related)
109
115
  ? this._frontMatter.related
@@ -124,9 +130,10 @@ export class ContentHeader {
124
130
 
125
131
  get tags() {
126
132
  const tags = _fmt.normalizeStringArray(this._frontMatter.tags);
127
- return tags.map((t) => {
128
- return _fmt.slugify(t);
129
- });
133
+ const normalized = tags
134
+ .map((tag) => _fmt.slugify(tag))
135
+ .filter((tag) => tag.length > 0);
136
+ return [...new Set(normalized)];
130
137
  }
131
138
 
132
139
  get keywords() {
@@ -154,6 +161,10 @@ export class ContentHeader {
154
161
  }
155
162
 
156
163
  get isPolicy() {
164
+ if (this.schemaType) {
165
+ return _fmt.boolean(this.schemaType === "policy");
166
+ }
167
+
157
168
  const category =
158
169
  typeof this._frontMatter.category === "string"
159
170
  ? this._frontMatter.category.trim()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shevky/core",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "A minimal, dependency-light static site generator.",
5
5
  "type": "module",
6
6
  "main": "shevky.js",
@@ -36,12 +36,15 @@
36
36
  },
37
37
  "homepage": "https://github.com/shevky/core#readme",
38
38
  "dependencies": {
39
- "@shevky/base": "^0.0.3",
40
- "@types/node": "^20.11.30",
41
- "@types/mustache": "^4.2.6",
42
- "@types/html-minifier-terser": "^7.0.2",
39
+ "@shevky/base": "^0.0.5",
43
40
  "@tailwindcss/typography": "^0.5.19",
41
+ "@types/html-minifier-terser": "^7.0.2",
42
+ "@types/mustache": "^4.2.6",
43
+ "@types/node": "^20.11.30",
44
44
  "autoprefixer": "^10.4.21",
45
+ "command-line-args": "^6.0.1",
46
+ "command-line-usage": "^7.0.3",
47
+ "degit": "^2.8.4",
45
48
  "gray-matter": "^4.0.3",
46
49
  "highlight.js": "^11.11.1",
47
50
  "html-minifier-terser": "^6.1.0",
@@ -49,10 +52,7 @@
49
52
  "marked-highlight": "^2.2.3",
50
53
  "mustache": "^4.2.0",
51
54
  "postcss": "^8.5.6",
52
- "tailwindcss": "^4.1.14",
53
- "command-line-args": "^6.0.1",
54
- "command-line-usage": "^7.0.3",
55
- "degit": "^2.8.4"
55
+ "tailwindcss": "^4.1.14"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=18"
@@ -1,4 +1,4 @@
1
- import { io as _io, config as _cfg } from "@shevky/base";
1
+ import { io as _io, config as _cfg, log as _log } from "@shevky/base";
2
2
  import matter from "gray-matter";
3
3
 
4
4
  import { ContentFile } from "../lib/contentFile.js";
@@ -37,6 +37,7 @@ export class ContentRegistry {
37
37
  }
38
38
 
39
39
  const files = await _io.directory.read(path);
40
+ let hasChanges = false;
40
41
  for (const entry of files) {
41
42
  if (!entry.endsWith(".md")) {
42
43
  continue;
@@ -49,10 +50,14 @@ export class ContentRegistry {
49
50
  }
50
51
 
51
52
  const contentFile = await this.#_loadFromFile(filePath);
52
- this.#_cache.push(contentFile);
53
+ if (this.#_addUniqueContent(contentFile)) {
54
+ hasChanges = true;
55
+ }
53
56
  }
54
57
 
55
- this.#_resetCaches();
58
+ if (hasChanges) {
59
+ this.#_resetCaches();
60
+ }
56
61
  }
57
62
 
58
63
  get count() {
@@ -72,8 +77,9 @@ export class ContentRegistry {
72
77
  }
73
78
 
74
79
  if (input instanceof ContentFile) {
75
- this.#_cache.push(input);
76
- this.#_resetCaches();
80
+ if (this.#_addUniqueContent(input)) {
81
+ this.#_resetCaches();
82
+ }
77
83
  return;
78
84
  }
79
85
 
@@ -92,8 +98,9 @@ export class ContentRegistry {
92
98
  const isValid = typeof input.isValid === "boolean" ? input.isValid : true;
93
99
 
94
100
  const contentFile = new ContentFile(header, content, sourcePath, isValid);
95
- this.#_cache.push(contentFile);
96
- this.#_resetCaches();
101
+ if (this.#_addUniqueContent(contentFile)) {
102
+ this.#_resetCaches();
103
+ }
97
104
  }
98
105
 
99
106
  /**
@@ -117,7 +124,7 @@ export class ContentRegistry {
117
124
  !file.isValid ||
118
125
  file.isDraft ||
119
126
  !file.isPublished ||
120
- file.category !== "policy"
127
+ file.schemaType !== "policy"
121
128
  ) {
122
129
  continue;
123
130
  }
@@ -325,4 +332,48 @@ export class ContentRegistry {
325
332
  });
326
333
  return sorted;
327
334
  }
335
+
336
+ /**
337
+ * Adds content only if required fields exist and id+lang is unique.
338
+ * @param {ContentFile} contentFile
339
+ */
340
+ #_addUniqueContent(contentFile) {
341
+ if (!(contentFile instanceof ContentFile)) {
342
+ return false;
343
+ }
344
+
345
+ const id = typeof contentFile.id === "string" ? contentFile.id.trim() : "";
346
+ const lang =
347
+ typeof contentFile.lang === "string" ? contentFile.lang.trim() : "";
348
+ const sourcePath =
349
+ typeof contentFile.sourcePath === "string" &&
350
+ contentFile.sourcePath.trim().length > 0
351
+ ? contentFile.sourcePath
352
+ : "unknown source";
353
+
354
+ /** @type {string[]} */
355
+ const missingFields = [];
356
+ if (!id) missingFields.push("id");
357
+ if (!lang) missingFields.push("lang");
358
+ if (missingFields.length > 0) {
359
+ _log.warn(
360
+ `[content] Skipped content: missing required field(s): ${missingFields.join(", ")} (${sourcePath})`,
361
+ );
362
+ return false;
363
+ }
364
+
365
+ const hasExisting = this.#_cache.some((entry) => {
366
+ const existingId = typeof entry.id === "string" ? entry.id.trim() : "";
367
+ const existingLang =
368
+ typeof entry.lang === "string" ? entry.lang.trim() : "";
369
+ return existingId === id && existingLang === lang;
370
+ });
371
+
372
+ if (hasExisting) {
373
+ return false;
374
+ }
375
+
376
+ this.#_cache.push(contentFile);
377
+ return true;
378
+ }
328
379
  }
package/scripts/build.js CHANGED
@@ -86,6 +86,7 @@ const DEFAULT_IMAGE = _cfg.seo.defaultImage;
86
86
  const FALLBACK_TAGLINES = { tr: "-", en: "-" };
87
87
  /** @type {Record<string, any>} */
88
88
  const COLLECTION_CONFIG = _cfg.content.collections;
89
+ const PAGE_BUFFER_LIMIT = resolvePageBufferLimit();
89
90
 
90
91
  const GENERATED_PAGES = new Set();
91
92
 
@@ -145,16 +146,9 @@ function normalizeLogPath(pathValue) {
145
146
 
146
147
  /** @param {FrontMatter | { raw?: unknown } | null | undefined} front */
147
148
  function normalizeFrontMatter(front) {
148
- if (!front || typeof front !== "object") {
149
- return {};
150
- }
151
-
152
- const raw =
153
- "raw" in front && front.raw && typeof front.raw === "object"
154
- ? front.raw
155
- : front;
156
-
157
- return typeof raw === "object" && raw !== null ? { ...raw } : {};
149
+ const frontRecord = _fmt.toRecord(front);
150
+ const rawRecord = _fmt.pickFirstRecord(frontRecord?.raw, frontRecord);
151
+ return rawRecord ? { ...rawRecord } : {};
158
152
  }
159
153
 
160
154
  async function ensureDist() {
@@ -196,16 +190,14 @@ function injectAlternateLocaleMeta(html, locales) {
196
190
  function resolvePaginationSegment(lang) {
197
191
  /** @type {Record<string, string>} */
198
192
  const segmentConfig = _cfg?.content?.pagination?.segment ?? {};
199
- if (
200
- typeof segmentConfig[lang] === "string" &&
201
- segmentConfig[lang].trim().length > 0
202
- ) {
203
- return segmentConfig[lang].trim();
193
+ const langSegment = _fmt.text(segmentConfig[lang]);
194
+ if (langSegment) {
195
+ return langSegment;
204
196
  }
205
197
 
206
- const defaultSegment = segmentConfig[_i18n.default];
207
- if (typeof defaultSegment === "string" && defaultSegment.trim().length > 0) {
208
- return defaultSegment.trim();
198
+ const defaultSegment = _fmt.text(segmentConfig[_i18n.default]);
199
+ if (defaultSegment) {
200
+ return defaultSegment;
209
201
  }
210
202
 
211
203
  return "page";
@@ -252,7 +244,17 @@ async function writeHtmlFile(relativePath, html, meta = {}) {
252
244
  });
253
245
  }
254
246
 
255
- async function flushPages() {
247
+ /** @param {{ force?: boolean }} [options] */
248
+ async function flushPages(options = {}) {
249
+ const force = _fmt.boolean(options.force);
250
+ if (pageRegistry.count === 0) {
251
+ return;
252
+ }
253
+
254
+ if (!force && pageRegistry.count < PAGE_BUFFER_LIMIT) {
255
+ return;
256
+ }
257
+
256
258
  const pages = pageRegistry
257
259
  .list()
258
260
  .sort((a, b) => (a.outputPath || "").localeCompare(b.outputPath || ""));
@@ -262,6 +264,7 @@ async function flushPages() {
262
264
  }
263
265
  await writeHtmlFile(page.outputPath, page.html, page.writeMeta ?? {});
264
266
  }
267
+ pageRegistry.clear();
265
268
  }
266
269
 
267
270
  /** @param {string} html @param {string} langKey */
@@ -508,12 +511,12 @@ async function renderFragmentTemplate(
508
511
  templateName,
509
512
  listingOverride,
510
513
  ) {
511
- const template =
512
- typeof templateName === "string" && templateName.trim().length > 0
513
- ? templateRegistry.getTemplate(TYPE_TEMPLATE, templateName.trim())
514
- : null;
515
- if (templateName && !template) {
516
- _log.warn(`[build] Fragment template not found: ${templateName}`);
514
+ const resolvedTemplateName = _fmt.text(templateName);
515
+ const template = resolvedTemplateName
516
+ ? templateRegistry.getTemplate(TYPE_TEMPLATE, resolvedTemplateName)
517
+ : null;
518
+ if (resolvedTemplateName && !template) {
519
+ _log.warn(`[build] Fragment template not found: ${resolvedTemplateName}`);
517
520
  }
518
521
  const {
519
522
  normalizedFront,
@@ -523,7 +526,7 @@ async function renderFragmentTemplate(
523
526
  languageFlags,
524
527
  resolvedDictionary,
525
528
  } = buildContentRenderContext(front, lang, dictionary, listingOverride);
526
- const decorated = decorateHtml(contentHtml, templateName ?? "");
529
+ const decorated = decorateHtml(contentHtml, resolvedTemplateName);
527
530
  const templateContent = template?.content ?? "{{{content.html}}}";
528
531
 
529
532
  return Mustache.render(
@@ -559,10 +562,7 @@ function buildContentRenderContext(front, lang, dictionary, listingOverride) {
559
562
  const baseFront = normalizeFrontMatter(front);
560
563
  /** @type {string[]} */
561
564
  const normalizedTags = Array.isArray(front.tags)
562
- ? front.tags.filter(
563
- (/** @type {string} */ tag) =>
564
- typeof tag === "string" && tag.trim().length > 0,
565
- )
565
+ ? front.tags.filter((/** @type {string} */ tag) => _fmt.hasText(tag))
566
566
  : [];
567
567
  const tagLinks = normalizedTags
568
568
  .map((/** @type {string} */ tag) => {
@@ -570,10 +570,8 @@ function buildContentRenderContext(front, lang, dictionary, listingOverride) {
570
570
  return url ? { label: tag, url } : null;
571
571
  })
572
572
  .filter(Boolean);
573
- const categorySlug =
574
- typeof front.category === "string" && front.category.trim().length > 0
575
- ? _fmt.slugify(front.category)
576
- : "";
573
+ const categoryLabel = _fmt.text(front.category);
574
+ const categorySlug = categoryLabel ? _fmt.slugify(categoryLabel) : "";
577
575
  const categoryUrl = categorySlug
578
576
  ? metaEngine.buildContentUrl(null, lang, categorySlug)
579
577
  : null;
@@ -584,10 +582,7 @@ function buildContentRenderContext(front, lang, dictionary, listingOverride) {
584
582
  tagLinks,
585
583
  hasTags: normalizedTags.length > 0,
586
584
  categoryUrl,
587
- categoryLabel:
588
- typeof front.category === "string" && front.category.trim().length > 0
589
- ? front.category.trim()
590
- : "",
585
+ categoryLabel,
591
586
  dateDisplay: _fmt.date(front.date, lang),
592
587
  updatedDisplay: _fmt.date(front.updated, lang),
593
588
  cover: front.cover ?? DEFAULT_IMAGE,
@@ -633,28 +628,29 @@ async function renderPage({ layoutName, view, front, lang, slug, writeMeta }) {
633
628
  minifyHtml,
634
629
  });
635
630
  const relativePath = buildOutputPath(front, lang, slug);
631
+ const templateName = _fmt.text(front?.template);
632
+ const pageType = _fmt.text(writeMeta?.type) || templateName;
633
+ const collectionType = normalizeCollectionTypeValue(front?.collectionType);
634
+ const slimFront = collectionType
635
+ ? /** @type {FrontMatter} */ ({ collectionType })
636
+ : /** @type {FrontMatter} */ ({});
636
637
  const page = renderEngine.createPage({
637
638
  kind: "page",
638
- type:
639
- typeof writeMeta?.type === "string" && writeMeta.type.length > 0
640
- ? writeMeta.type
641
- : typeof front?.template === "string"
642
- ? front.template
643
- : "",
639
+ type: pageType,
644
640
  lang,
645
641
  slug,
646
642
  canonical: metaEngine.buildContentUrl(front?.canonical, lang, slug),
647
643
  layout: layoutName,
648
- template: typeof front?.template === "string" ? front.template : "",
649
- front,
650
- view,
644
+ template: templateName,
645
+ front: slimFront,
651
646
  html: finalHtml,
652
- sourcePath: typeof writeMeta?.source === "string" ? writeMeta.source : "",
647
+ sourcePath: _fmt.text(writeMeta?.source),
653
648
  outputPath: relativePath,
654
649
  writeMeta,
655
650
  });
656
651
  GENERATED_PAGES.add(toPosixPath(relativePath));
657
652
  registerLegacyPaths(lang, slug);
653
+ await flushPages();
658
654
  return page;
659
655
  }
660
656
 
@@ -698,6 +694,150 @@ function toPosixPath(value) {
698
694
  return value.split(_io.path.separator).join("/");
699
695
  }
700
696
 
697
+ /** @param {FrontMatter | { header?: FrontMatter } | null | undefined} input */
698
+ function resolveFrontMatterInput(input) {
699
+ const inputRecord = _fmt.toRecord(input);
700
+ const resolved = _fmt.pickFirstRecord(
701
+ inputRecord?.raw,
702
+ inputRecord?.header,
703
+ inputRecord,
704
+ );
705
+ return /** @type {FrontMatter} */ (resolved ?? {});
706
+ }
707
+
708
+ /** @param {unknown} value */
709
+ function normalizeSchemaTypeValue(value) {
710
+ if (typeof value !== "string") {
711
+ return "";
712
+ }
713
+
714
+ return value.trim().toLowerCase();
715
+ }
716
+
717
+ /**
718
+ * @param {Record<string, any> | null | undefined} front
719
+ * @param {Record<string, any> | null | undefined} [derived]
720
+ */
721
+ function resolveSchemaTypeForGeneration(front, derived) {
722
+ const frontSchemaType = normalizeSchemaTypeValue(front?.schemaType);
723
+ if (_plugin.isSchemaType(frontSchemaType)) {
724
+ return frontSchemaType;
725
+ }
726
+
727
+ const derivedSchemaType = normalizeSchemaTypeValue(derived?.schemaType);
728
+ if (_plugin.isSchemaType(derivedSchemaType)) {
729
+ return derivedSchemaType;
730
+ }
731
+
732
+ const collectionType = normalizeCollectionTypeValue(
733
+ front?.collectionType ?? derived?.collectionType,
734
+ );
735
+ if (collectionType) {
736
+ return "collection";
737
+ }
738
+
739
+ return "page";
740
+ }
741
+
742
+ /**
743
+ * @param {Record<string, any> | null | undefined} front
744
+ * @param {Record<string, any> | null | undefined} [derived]
745
+ */
746
+ function injectSchemaTypeForGeneration(front, derived) {
747
+ if (!front || typeof front !== "object") {
748
+ return;
749
+ }
750
+
751
+ const resolvedType = resolveSchemaTypeForGeneration(front, derived);
752
+ const currentFrontType = normalizeSchemaTypeValue(front.schemaType);
753
+
754
+ if (!_plugin.isSchemaType(currentFrontType)) {
755
+ try {
756
+ front.schemaType = resolvedType;
757
+ } catch {
758
+ // Ignore read-only objects.
759
+ }
760
+ }
761
+
762
+ if (derived && typeof derived === "object") {
763
+ const currentDerivedType = normalizeSchemaTypeValue(derived.schemaType);
764
+ if (!_plugin.isSchemaType(currentDerivedType)) {
765
+ try {
766
+ derived.schemaType = resolvedType;
767
+ } catch {
768
+ // Ignore read-only objects.
769
+ }
770
+ }
771
+ }
772
+ }
773
+
774
+ /** @param {FrontMatter} front @param {string} lang @param {string} slug */
775
+ function buildMinimalPageMeta(front, lang, slug) {
776
+ const canonical = metaEngine.resolveUrl(
777
+ _fmt.text(front?.canonical) || metaEngine.buildContentUrl(null, lang, slug),
778
+ );
779
+ const alternates = metaEngine.buildAlternateUrlMap(front, lang, canonical);
780
+ const alternateLinks = metaEngine.buildAlternateLinkList(alternates);
781
+
782
+ return {
783
+ title: _fmt.text(front?.metaTitle) || _fmt.text(front?.title),
784
+ description: _fmt.text(front?.description),
785
+ robots: _fmt.text(front?.robots) || "index,follow",
786
+ canonical,
787
+ alternates,
788
+ alternateLinks,
789
+ og: _fmt.toRecord(front?.og, {}),
790
+ twitter: _fmt.toRecord(front?.twitter, {}),
791
+ structuredData: front?.structuredData ?? "",
792
+ };
793
+ }
794
+
795
+ /** @param {FrontMatter | { header?: FrontMatter } | null | undefined} input @param {string} lang @param {string} slug @param {Record<string, any> | null | undefined} [derived] */
796
+ async function buildPageMetaWithPlugins(input, lang, slug, derived) {
797
+ const front = resolveFrontMatterInput(input);
798
+ const derivedFront =
799
+ derived && typeof derived === "object"
800
+ ? /** @type {Record<string, any>} */ (derived)
801
+ : front;
802
+ injectSchemaTypeForGeneration(front, derivedFront);
803
+ let pluginPageMeta = null;
804
+
805
+ pluginEngine.setRuntimeContext({
806
+ frontMatter: front,
807
+ derivedFrontMatter: derivedFront,
808
+ lang,
809
+ slug,
810
+ setPageMeta: (/** @type {Record<string, any>} */ meta) => {
811
+ pluginPageMeta = meta;
812
+ },
813
+ });
814
+
815
+ try {
816
+ await pluginEngine.execute(_plugin.hooks.PAGE_META);
817
+ } finally {
818
+ pluginEngine.clearRuntimeContext();
819
+ }
820
+
821
+ if (
822
+ pluginPageMeta &&
823
+ typeof pluginPageMeta === "object" &&
824
+ !Array.isArray(pluginPageMeta)
825
+ ) {
826
+ return /** @type {Record<string, any>} */ (pluginPageMeta);
827
+ }
828
+
829
+ const existingPageMeta = _fmt.toRecord(front?.pageMeta);
830
+ if (existingPageMeta) {
831
+ return existingPageMeta;
832
+ }
833
+
834
+ _log.debug(
835
+ `[build] Missing page meta from '${_plugin.hooks.PAGE_META}' hook for lang='${lang}' slug='${slug}'. Using front matter as page meta.`,
836
+ );
837
+
838
+ return buildMinimalPageMeta(front, lang, slug);
839
+ }
840
+
701
841
  /** @param {FrontMatter} front @param {string} lang */
702
842
  function buildCollectionListing(front, lang) {
703
843
  const normalizedLang = lang ?? _i18n.default;
@@ -724,19 +864,13 @@ function buildCollectionListing(front, lang) {
724
864
  function buildSeriesListing(front, lang) {
725
865
  /** @type {string[]} */
726
866
  const relatedSource = Array.isArray(front?.related) ? front.related : [];
727
- const seriesName =
728
- typeof front?.seriesTitle === "string" &&
729
- front.seriesTitle.trim().length > 0
730
- ? front.seriesTitle.trim()
731
- : typeof front?.series === "string"
732
- ? front.series.trim()
733
- : "";
734
- const currentId = typeof front?.id === "string" ? front.id.trim() : "";
867
+ const seriesName = _fmt.text(front?.seriesTitle) || _fmt.text(front?.series);
868
+ const currentId = _fmt.text(front?.id);
735
869
  /** @type {Array<{ id: string, label: string, url: string, hasUrl?: boolean, isCurrent: boolean, isPlaceholder: boolean }>} */
736
870
  const items = [];
737
871
 
738
872
  relatedSource.forEach((/** @type {string} */ entry) => {
739
- const value = typeof entry === "string" ? entry.trim() : "";
873
+ const value = _fmt.text(entry);
740
874
  if (!value) {
741
875
  items.push({
742
876
  id: "",
@@ -804,19 +938,16 @@ function resolveCollectionType(front, items, fallback) {
804
938
  }
805
939
 
806
940
  if (Array.isArray(items)) {
807
- const entryWithType = items.find(
808
- (entry) =>
809
- typeof entry?.type === "string" && entry.type.trim().length > 0,
810
- );
811
- const entryType =
812
- typeof entryWithType?.type === "string" ? entryWithType.type.trim() : "";
941
+ const entryWithType = items.find((entry) => _fmt.hasText(entry?.type));
942
+ const entryType = _fmt.text(entryWithType?.type);
813
943
  if (entryType) {
814
944
  return entryType.toLowerCase();
815
945
  }
816
946
  }
817
947
 
818
- if (typeof fallback === "string" && fallback.trim().length > 0) {
819
- return fallback.trim().toLowerCase();
948
+ const normalizedFallback = _fmt.text(fallback);
949
+ if (normalizedFallback) {
950
+ return normalizedFallback.toLowerCase();
820
951
  }
821
952
 
822
953
  return "";
@@ -858,20 +989,23 @@ function resolveListingKey(front) {
858
989
  function resolveListingEmpty(front, lang) {
859
990
  if (!front) return "";
860
991
  const { listingEmpty } = front;
861
- if (typeof listingEmpty === "string" && listingEmpty.trim().length > 0) {
862
- return listingEmpty.trim();
992
+ const direct = _fmt.text(listingEmpty);
993
+ if (direct) {
994
+ return direct;
863
995
  }
864
996
  if (listingEmpty && typeof listingEmpty === "object") {
865
997
  const listingEmptyMap = /** @type {Record<string, string>} */ (
866
998
  listingEmpty
867
999
  );
868
1000
  const localized = listingEmptyMap[lang];
869
- if (typeof localized === "string" && localized.trim().length > 0) {
870
- return localized.trim();
1001
+ const localizedValue = _fmt.text(localized);
1002
+ if (localizedValue) {
1003
+ return localizedValue;
871
1004
  }
872
1005
  const fallback = listingEmptyMap[_i18n.default];
873
- if (typeof fallback === "string" && fallback.trim().length > 0) {
874
- return fallback.trim();
1006
+ const fallbackValue = _fmt.text(fallback);
1007
+ if (fallbackValue) {
1008
+ return fallbackValue;
875
1009
  }
876
1010
  }
877
1011
  return "";
@@ -880,16 +1014,95 @@ function resolveListingEmpty(front, lang) {
880
1014
  /** @param {FrontMatter} front */
881
1015
  function resolveListingHeading(front) {
882
1016
  if (!front) return "";
1017
+ return _fmt.text(front.listHeading) || _fmt.text(front.title);
1018
+ }
1019
+
1020
+ function resolvePageBufferLimit() {
1021
+ const envValue = Number.parseInt(
1022
+ process.env.SHEVKY_PAGE_BUFFER_LIMIT ?? "",
1023
+ 10,
1024
+ );
1025
+
1026
+ if (Number.isFinite(envValue) && envValue > 0) {
1027
+ return envValue;
1028
+ }
1029
+
1030
+ const configValue = Number.parseInt(
1031
+ String(_cfg?.build?.pageBufferLimit ?? ""),
1032
+ 10,
1033
+ );
1034
+
1035
+ if (Number.isFinite(configValue) && configValue > 0) {
1036
+ return configValue;
1037
+ }
1038
+
1039
+ return 20;
1040
+ }
1041
+
1042
+ /** @param {unknown} value */
1043
+ function resolveAliasOutputPath(value) {
1044
+ const raw = _fmt.text(value);
1045
+ if (!raw) {
1046
+ return null;
1047
+ }
1048
+
1049
+ const relative = metaEngine.canonicalToRelativePath(raw);
1050
+ if (relative) {
1051
+ const lastSegment = relative.split("/").pop()?.trim() ?? "";
1052
+ if (lastSegment.includes(".")) {
1053
+ return relative;
1054
+ }
1055
+ return _io.path.combine(relative, "index.html");
1056
+ }
1057
+
1058
+ const normalizedRaw = raw.trim();
883
1059
  if (
884
- typeof front.listHeading === "string" &&
885
- front.listHeading.trim().length > 0
1060
+ normalizedRaw === "/" ||
1061
+ normalizedRaw === "~/" ||
1062
+ /^https?:\/\/[^/]+\/?$/i.test(normalizedRaw)
886
1063
  ) {
887
- return front.listHeading.trim();
1064
+ return "index.html";
888
1065
  }
889
- if (typeof front.title === "string" && front.title.trim().length > 0) {
890
- return front.title.trim();
1066
+
1067
+ return null;
1068
+ }
1069
+
1070
+ async function applyOutputAliases() {
1071
+ const aliases = Array.isArray(_cfg?.build?.outputAliases)
1072
+ ? _cfg.build.outputAliases
1073
+ : [];
1074
+
1075
+ for (const entry of aliases) {
1076
+ const alias = _fmt.toRecord(entry);
1077
+ if (!alias) {
1078
+ continue;
1079
+ }
1080
+
1081
+ const sourceRelative = resolveAliasOutputPath(alias.from);
1082
+ const targetRelative = resolveAliasOutputPath(alias.to);
1083
+ if (!sourceRelative || !targetRelative || sourceRelative === targetRelative) {
1084
+ continue;
1085
+ }
1086
+
1087
+ const sourcePath = _io.path.combine(DIST_DIR, sourceRelative);
1088
+ if (!(await _io.file.exists(sourcePath))) {
1089
+ _log.warn(
1090
+ `[build] Output alias source not found: ${normalizeLogPath(sourcePath)}`,
1091
+ );
1092
+ continue;
1093
+ }
1094
+
1095
+ const targetPath = _io.path.combine(DIST_DIR, targetRelative);
1096
+ await _io.directory.create(_io.path.name(targetPath));
1097
+ await _io.file.copy(sourcePath, targetPath);
1098
+ GENERATED_PAGES.add(toPosixPath(targetRelative));
1099
+
1100
+ _log.step("COPY_ALIAS", {
1101
+ source: normalizeLogPath(sourcePath),
1102
+ target: normalizeLogPath(targetPath),
1103
+ type: "alias",
1104
+ });
891
1105
  }
892
- return "";
893
1106
  }
894
1107
 
895
1108
  async function buildContentPages() {
@@ -932,10 +1145,7 @@ async function buildContentPages() {
932
1145
  const rawFront = normalizeFrontMatter(file.header);
933
1146
  const shouldBuildFragment = _fmt.boolean(rawFront.fragment);
934
1147
  const fragmentTemplateName =
935
- typeof rawFront.fragmentTemplate === "string" &&
936
- rawFront.fragmentTemplate.trim().length > 0
937
- ? rawFront.fragmentTemplate.trim()
938
- : file.template;
1148
+ _fmt.text(rawFront.fragmentTemplate) || file.template;
939
1149
 
940
1150
  if (file.template === "collection" || file.template === "home") {
941
1151
  await renderEngine.buildPaginatedCollectionPages({
@@ -960,7 +1170,15 @@ async function buildContentPages() {
960
1170
  buildEasterEggPayload,
961
1171
  }),
962
1172
  renderPage,
963
- metaEngine,
1173
+ metaEngine: {
1174
+ buildPageMeta: async (frontForPage, pageLang, pageSlug) =>
1175
+ buildPageMetaWithPlugins(
1176
+ frontForPage,
1177
+ pageLang,
1178
+ pageSlug,
1179
+ frontForPage,
1180
+ ),
1181
+ },
964
1182
  menuEngine,
965
1183
  resolveListingKey,
966
1184
  resolveListingEmpty,
@@ -981,10 +1199,11 @@ async function buildContentPages() {
981
1199
  file.lang,
982
1200
  dictionary,
983
1201
  );
984
- const pageMeta = metaEngine.buildPageMeta(
1202
+ const pageMeta = await buildPageMetaWithPlugins(
985
1203
  file.header,
986
1204
  file.lang,
987
1205
  file.slug,
1206
+ file,
988
1207
  );
989
1208
  const activeMenuKey = menuEngine.resolveActiveMenuKey(file.header);
990
1209
  const view = renderEngine.buildViewPayload(
@@ -1043,10 +1262,7 @@ async function buildContentPages() {
1043
1262
  minifyHtml,
1044
1263
  },
1045
1264
  );
1046
- const fragmentLang =
1047
- typeof file.lang === "string" && file.lang.trim().length > 0
1048
- ? file.lang.trim()
1049
- : _i18n.default;
1265
+ const fragmentLang = _fmt.text(file.lang) || _i18n.default;
1050
1266
  const fragmentPath = _io.path.combine(
1051
1267
  FRAGMENTS_DIR,
1052
1268
  fragmentLang,
@@ -1081,7 +1297,15 @@ async function buildContentPages() {
1081
1297
  buildEasterEggPayload,
1082
1298
  }),
1083
1299
  renderPage,
1084
- metaEngine,
1300
+ metaEngine: {
1301
+ buildPageMeta: async (frontForPage, pageLang, pageSlug) =>
1302
+ buildPageMetaWithPlugins(
1303
+ frontForPage,
1304
+ pageLang,
1305
+ pageSlug,
1306
+ frontForPage,
1307
+ ),
1308
+ },
1085
1309
  menuEngine,
1086
1310
  resolveCollectionType,
1087
1311
  normalizeCollectionTypeValue,
@@ -1097,15 +1321,9 @@ async function buildContentPages() {
1097
1321
  function resolveCollectionDisplayKey(configKey, defaultKey, items) {
1098
1322
  if (configKey === "series" && Array.isArray(items)) {
1099
1323
  const entryWithTitle = items.find(
1100
- (entry) =>
1101
- entry &&
1102
- typeof entry.seriesTitle === "string" &&
1103
- entry.seriesTitle.trim().length > 0,
1324
+ (entry) => entry && _fmt.hasText(entry.seriesTitle),
1104
1325
  );
1105
- const seriesTitle =
1106
- typeof entryWithTitle?.seriesTitle === "string"
1107
- ? entryWithTitle.seriesTitle.trim()
1108
- : "";
1326
+ const seriesTitle = _fmt.text(entryWithTitle?.seriesTitle);
1109
1327
  if (seriesTitle) {
1110
1328
  return seriesTitle;
1111
1329
  }
@@ -1162,6 +1380,14 @@ async function copyHtmlRecursive(currentDir = SRC_DIR, relative = "") {
1162
1380
  for (const entry of entries) {
1163
1381
  const fullPath = _io.path.combine(currentDir, entry);
1164
1382
  const relPath = relative ? _io.path.combine(relative, entry) : entry;
1383
+ const normalizedRelPath = toPosixPath(relPath);
1384
+
1385
+ if (
1386
+ normalizedRelPath === "content" ||
1387
+ normalizedRelPath.startsWith("content/")
1388
+ ) {
1389
+ continue;
1390
+ }
1165
1391
 
1166
1392
  if (!entry.endsWith(".html")) {
1167
1393
  continue;
@@ -1209,6 +1435,83 @@ async function copyHtmlRecursive(currentDir = SRC_DIR, relative = "") {
1209
1435
  }
1210
1436
  }
1211
1437
 
1438
+ /** @param {string} currentDir */
1439
+ async function copyContentStaticRecursive(currentDir = CONTENT_DIR) {
1440
+ if (!(await _io.directory.exists(currentDir))) {
1441
+ return;
1442
+ }
1443
+
1444
+ const contentRootDirectories = resolveContentRootDirectories();
1445
+ const entries = await _io.directory.read(currentDir);
1446
+ for (const entry of entries) {
1447
+ const fullPath = _io.path.combine(currentDir, entry);
1448
+ const relPath = entry;
1449
+ const normalizedRelPath = toPosixPath(relPath);
1450
+ const isXml = entry.endsWith(".xml");
1451
+
1452
+ if (
1453
+ contentRootDirectories.some(
1454
+ (directory) =>
1455
+ normalizedRelPath === directory ||
1456
+ normalizedRelPath.startsWith(`${directory}/`),
1457
+ )
1458
+ ) {
1459
+ continue;
1460
+ }
1461
+
1462
+ if (!(entry.endsWith(".html") || entry.endsWith(".xml"))) {
1463
+ continue;
1464
+ }
1465
+
1466
+ if (GENERATED_PAGES.has(toPosixPath(relPath))) {
1467
+ continue;
1468
+ }
1469
+
1470
+ const raw = await _io.file.read(fullPath);
1471
+ const transformed = isXml
1472
+ ? raw
1473
+ : await renderEngine.transformHtml(raw, {
1474
+ versionToken,
1475
+ minifyHtml,
1476
+ });
1477
+
1478
+ await writeHtmlFile(relPath, transformed, {
1479
+ action: "COPY_HTML",
1480
+ type: isXml ? "content-xml" : "content-static",
1481
+ source: fullPath,
1482
+ lang: _i18n.default,
1483
+ inputBytes: byteLength(transformed),
1484
+ });
1485
+ }
1486
+ }
1487
+
1488
+ function resolveContentRootDirectories() {
1489
+ const configured = Array.isArray(_cfg?.build?.contentRootDirectories)
1490
+ ? _cfg.build.contentRootDirectories
1491
+ : [".well-known"];
1492
+
1493
+ return [...new Set(configured.map((entry) => _fmt.text(entry)).filter(Boolean))];
1494
+ }
1495
+
1496
+ async function copyContentRootDirectories() {
1497
+ const directories = resolveContentRootDirectories();
1498
+ for (const directory of directories) {
1499
+ const sourceDir = _io.path.combine(CONTENT_DIR, directory);
1500
+ if (!(await _io.directory.exists(sourceDir))) {
1501
+ continue;
1502
+ }
1503
+
1504
+ const targetDir = _io.path.combine(DIST_DIR, directory);
1505
+ await _io.directory.copy(sourceDir, targetDir);
1506
+
1507
+ _log.step("COPY_DIR", {
1508
+ source: normalizeLogPath(sourceDir),
1509
+ target: normalizeLogPath(targetDir),
1510
+ type: "content-root-directory",
1511
+ });
1512
+ }
1513
+ }
1514
+
1212
1515
  async function copyStaticAssets() {
1213
1516
  if (!(await _io.directory.exists(ASSETS_DIR))) {
1214
1517
  return;
@@ -1243,7 +1546,10 @@ async function main() {
1243
1546
 
1244
1547
  await buildContentPages();
1245
1548
  await copyHtmlRecursive();
1246
- await flushPages();
1549
+ await copyContentStaticRecursive();
1550
+ await copyContentRootDirectories();
1551
+ await flushPages({ force: true });
1552
+ await applyOutputAliases();
1247
1553
  }
1248
1554
 
1249
1555
  const API = {
package/scripts/main.js CHANGED
@@ -4,7 +4,7 @@ import _cli from "./cli.js";
4
4
  import _build from "./build.js";
5
5
  import _init from "./init.js";
6
6
 
7
- const VERSION = "0.0.6";
7
+ const VERSION = "0.0.8";
8
8
 
9
9
  const __filename = _io.url.toPath(import.meta.url);
10
10
  const __dirname = _io.path.name(__filename);
package/types/index.d.ts CHANGED
@@ -55,6 +55,13 @@ export interface PluginExecutionContext extends BasePluginContext {
55
55
  Record<string, { id: string; lang: string; title: string; canonical: string }>
56
56
  >;
57
57
  footerPolicies?: Record<string, FooterPolicy[]>;
58
+ i18n?: Record<string, any>;
59
+ frontMatter?: FrontMatter;
60
+ derivedFrontMatter?: FrontMatter | Record<string, any>;
61
+ lang?: string;
62
+ slug?: string;
63
+ pageMeta?: Record<string, any> | null;
64
+ setPageMeta?: (meta: Record<string, any>) => void;
58
65
  }
59
66
 
60
67
  export type Placeholder = {