@eox/pages-theme-eox 2.0.0-alpha.5 → 2.0.0-alpha.7

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,980 @@
1
+ import { describe, it, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { resolveLlms, normalizeDomain } from "./index.js";
7
+ import {
8
+ cleanStory,
9
+ deriveStoryId,
10
+ normalizeWhitespace,
11
+ normalizeHeadings,
12
+ mapConcurrent,
13
+ detectManifestCandidates,
14
+ fetchManifest,
15
+ resolveAndFetchManifest,
16
+ fetchStoryMarkdown,
17
+ fetchStory,
18
+ fetchStories,
19
+ appendToLlmsTxt,
20
+ appendToLlmsFullTxt,
21
+ appendToStoryRoutePage,
22
+ routeFromRelativePath,
23
+ buildStoryUrl,
24
+ resolveStoryRouting,
25
+ writeIndividualStoryFiles,
26
+ } from "./storiesPlugin.js";
27
+
28
+ describe("normalizeDomain", () => {
29
+ it("strips trailing slashes correctly", () => {
30
+ assert.equal(
31
+ normalizeDomain("https://example.com/"),
32
+ "https://example.com",
33
+ );
34
+ assert.equal(
35
+ normalizeDomain("https://example.com///"),
36
+ "https://example.com",
37
+ );
38
+ assert.equal(normalizeDomain("https://example.com"), "https://example.com");
39
+ assert.equal(normalizeDomain(""), null);
40
+ assert.equal(normalizeDomain(null), null);
41
+ assert.equal(normalizeDomain(undefined), null);
42
+ });
43
+ });
44
+
45
+ describe("resolveLlms", () => {
46
+ it("returns empty plugins when option is false", () => {
47
+ const res = resolveLlms(false, "https://example.com");
48
+ assert.deepEqual(res.plugins, []);
49
+ });
50
+
51
+ it("enables default plugins when option is undefined or true", () => {
52
+ const res1 = resolveLlms(undefined, "https://example.com/");
53
+ assert.equal(res1.plugins.length, 3);
54
+
55
+ const res2 = resolveLlms(true, "https://example.com/");
56
+ assert.equal(res2.plugins.length, 3);
57
+ });
58
+
59
+ it("normalizes domain and strips trailing slashes", () => {
60
+ const res = resolveLlms(
61
+ { domain: "https://hub.eox.at///" },
62
+ "https://example.com",
63
+ );
64
+ assert.equal(res.plugins.length, 3);
65
+ assert.equal(res.domain, "https://hub.eox.at");
66
+ });
67
+
68
+ it("excludes stories plugin when stories is false", () => {
69
+ const res = resolveLlms({ stories: false }, "https://example.com");
70
+ assert.equal(res.plugins.length, 2);
71
+ assert.ok(res.plugins.some((p) => p.name === "vitepress-plugin-llms"));
72
+ assert.ok(
73
+ !res.plugins.some((p) => p.name === "@eox/pages-theme-eox:llms-stories"),
74
+ );
75
+ });
76
+
77
+ it("separates package-specific flags from upstream options", () => {
78
+ const res = resolveLlms(
79
+ {
80
+ copyButtons: true,
81
+ stories: { heading: "Featured Stories" },
82
+ ignorePaths: ["private/**"],
83
+ },
84
+ "https://example.com",
85
+ );
86
+ assert.equal(res.plugins.length, 3);
87
+ assert.equal(res.plugins[2].name, "@eox/pages-theme-eox:llms-stories");
88
+ assert.equal(res.copyButtons, true);
89
+ assert.deepEqual(res.stories, { heading: "Featured Stories" });
90
+ });
91
+ });
92
+
93
+ describe("deriveStoryId", () => {
94
+ it("extracts filename segment before the first dot", () => {
95
+ assert.equal(deriveStoryId("stories/air-quality.v1.md"), "air-quality");
96
+ assert.equal(deriveStoryId("./wildfire.md"), "wildfire");
97
+ assert.equal(
98
+ deriveStoryId("https://example.com/narratives/flood-monitoring.step2.md"),
99
+ "flood-monitoring",
100
+ );
101
+ assert.equal(deriveStoryId("simple-slug"), "simple-slug");
102
+ assert.equal(deriveStoryId(""), "");
103
+ assert.equal(deriveStoryId(null), "");
104
+ assert.equal(deriveStoryId(undefined), "");
105
+ });
106
+ });
107
+
108
+ describe("normalizeWhitespace", () => {
109
+ it("collapses multiple spaces, tabs, and newlines into a single space and trims", () => {
110
+ assert.equal(
111
+ normalizeWhitespace(" Hello \n\n world! \t"),
112
+ "Hello world!",
113
+ );
114
+ assert.equal(normalizeWhitespace("NoExtraSpaces"), "NoExtraSpaces");
115
+ assert.equal(normalizeWhitespace(null), "");
116
+ assert.equal(normalizeWhitespace(undefined), "");
117
+ });
118
+ });
119
+
120
+ describe("normalizeHeadings", () => {
121
+ it("demotes all headings by one level (# -> ##, ## -> ###)", () => {
122
+ const markdown = `# Title\n## Sub\n### Subsub\n#### Level 4\n##### Level 5`;
123
+ const res = normalizeHeadings(markdown, "Different Title");
124
+ assert.ok(res.includes("## Title"));
125
+ assert.ok(res.includes("### Sub"));
126
+ assert.ok(res.includes("#### Subsub"));
127
+ assert.ok(res.includes("##### Level 4"));
128
+ assert.ok(res.includes("###### Level 5"));
129
+ });
130
+
131
+ it("does not demote text lines starting with # but without space", () => {
132
+ const markdown = `#hashtag and #123 should remain`;
133
+ const res = normalizeHeadings(markdown, "Title");
134
+ assert.equal(res, `#hashtag and #123 should remain`);
135
+ });
136
+ });
137
+
138
+ describe("cleanStory", () => {
139
+ it("strips frontmatter, comments, storytelling directives and demotes headings", () => {
140
+ const raw = `---
141
+ title: Ignored Frontmatter Title
142
+ layout: story
143
+ ---
144
+ <!--{
145
+ "theme": "dark",
146
+ "steps": [1, 2]
147
+ }-->
148
+ # My Story Title
149
+
150
+ <!-- A normal HTML comment -->
151
+ Some story introductory text.
152
+
153
+ ## Background
154
+
155
+ ![Map Image](./images/map.png)
156
+ ![External Image](https://cdn.example.com/ext.png)
157
+ ![Absolute Image](/images/abs.png)
158
+
159
+ ### Details
160
+ More details here.
161
+ `;
162
+
163
+ const cleaned = cleanStory(
164
+ raw,
165
+ "My Story Title",
166
+ "https://cdn.example.com/stories",
167
+ );
168
+
169
+ assert.ok(!cleaned.includes("---"));
170
+ assert.ok(!cleaned.includes("Ignored Frontmatter Title"));
171
+ assert.ok(!cleaned.includes("theme"));
172
+ assert.ok(!cleaned.includes("steps"));
173
+ assert.ok(!cleaned.includes("A normal HTML comment"));
174
+ // Leading H1 matching title removed
175
+ assert.ok(!cleaned.includes("# My Story Title"));
176
+ // Headings demoted by one level: ## -> ###, ### -> ####
177
+ assert.ok(cleaned.includes("### Background"));
178
+ assert.ok(cleaned.includes("#### Details"));
179
+ // Relative image resolved
180
+ assert.ok(
181
+ cleaned.includes(
182
+ "![Map Image](https://cdn.example.com/stories/images/map.png)",
183
+ ),
184
+ );
185
+ // Absolute & external images kept untouched
186
+ assert.ok(
187
+ cleaned.includes("![External Image](https://cdn.example.com/ext.png)"),
188
+ );
189
+ assert.ok(cleaned.includes("![Absolute Image](/images/abs.png)"));
190
+ });
191
+
192
+ it("handles null or empty content safely", () => {
193
+ assert.equal(cleanStory(null, "Title"), "");
194
+ assert.equal(cleanStory("", "Title"), "");
195
+ });
196
+ });
197
+
198
+ describe("mapConcurrent", () => {
199
+ it("preserves ordering and processes all items within concurrency limit", async () => {
200
+ const items = [1, 2, 3, 4, 5, 6, 7];
201
+ const results = await mapConcurrent(items, 3, async (num) => num * 2);
202
+ assert.deepEqual(results, [2, 4, 6, 8, 10, 12, 14]);
203
+ });
204
+ });
205
+
206
+ describe("detectManifestCandidates", () => {
207
+ it("scans directory and ignores .vitepress and node_modules", async () => {
208
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "detect-test-"));
209
+ const mdPath = path.join(tmpDir, "page.md");
210
+ await fs.writeFile(
211
+ mdPath,
212
+ `<eox-storytelling src="https://cdn.eox.at/test/narratives.json"></eox-storytelling>`,
213
+ );
214
+
215
+ const ignoredDir = path.join(tmpDir, ".vitepress");
216
+ await fs.mkdir(ignoredDir);
217
+ await fs.writeFile(
218
+ path.join(ignoredDir, "config.js"),
219
+ `const url = "https://cdn.eox.at/ignored/narratives.json";`,
220
+ );
221
+
222
+ const candidates = await detectManifestCandidates(tmpDir);
223
+ assert.equal(candidates.length, 1);
224
+ assert.equal(candidates[0].url, "https://cdn.eox.at/test/narratives.json");
225
+ assert.equal(candidates[0].filePath, mdPath);
226
+
227
+ await fs.rm(tmpDir, { recursive: true, force: true });
228
+ });
229
+ });
230
+
231
+ describe("routeFromRelativePath", () => {
232
+ it("converts relative paths to clean route paths", () => {
233
+ assert.equal(routeFromRelativePath("story.md"), "/story");
234
+ assert.equal(
235
+ routeFromRelativePath("showcase/stories.md"),
236
+ "/showcase/stories",
237
+ );
238
+ assert.equal(routeFromRelativePath("story/index.md"), "/story");
239
+ assert.equal(routeFromRelativePath("index.md"), "/");
240
+ assert.equal(routeFromRelativePath(""), "/story");
241
+ assert.equal(routeFromRelativePath(null), "/story");
242
+ assert.equal(routeFromRelativePath(undefined), "/story");
243
+ });
244
+ });
245
+
246
+ describe("resolveStoryRouting", () => {
247
+ it("detects dynamic routes from VitePress paths and template files", () => {
248
+ assert.deepEqual(
249
+ resolveStoryRouting({
250
+ sourceRelPath: "narratives/[narrative].paths.js",
251
+ }),
252
+ {
253
+ isDynamic: true,
254
+ routeDir: "narratives",
255
+ routePath: "/narratives",
256
+ },
257
+ );
258
+
259
+ assert.deepEqual(
260
+ resolveStoryRouting({
261
+ sourceRelPath: "stories/[story].md",
262
+ }),
263
+ {
264
+ isDynamic: true,
265
+ routeDir: "stories",
266
+ routePath: "/stories",
267
+ },
268
+ );
269
+
270
+ assert.deepEqual(
271
+ resolveStoryRouting({
272
+ sourceRelPath: "narratives/[narrative].paths.ts",
273
+ }),
274
+ {
275
+ isDynamic: true,
276
+ routeDir: "narratives",
277
+ routePath: "/narratives",
278
+ },
279
+ );
280
+
281
+ assert.deepEqual(
282
+ resolveStoryRouting({
283
+ sourceRelPath: "stories/[story].paths.mjs",
284
+ }),
285
+ {
286
+ isDynamic: true,
287
+ routeDir: "stories",
288
+ routePath: "/stories",
289
+ },
290
+ );
291
+
292
+ assert.deepEqual(
293
+ resolveStoryRouting({
294
+ sourceRelPath: "[narrative].paths.js",
295
+ }),
296
+ {
297
+ isDynamic: true,
298
+ routeDir: "",
299
+ routePath: "/",
300
+ },
301
+ );
302
+ });
303
+
304
+ it("handles static routes from normal markdown files", () => {
305
+ assert.deepEqual(
306
+ resolveStoryRouting({
307
+ sourceRelPath: "stories.md",
308
+ }),
309
+ {
310
+ isDynamic: false,
311
+ routeDir: "stories",
312
+ routePath: "/stories",
313
+ },
314
+ );
315
+
316
+ assert.deepEqual(
317
+ resolveStoryRouting({
318
+ sourceRelPath: "showcase/index.md",
319
+ }),
320
+ {
321
+ isDynamic: false,
322
+ routeDir: "showcase",
323
+ routePath: "/showcase",
324
+ },
325
+ );
326
+ });
327
+
328
+ it("respects configuredPath overrides for both dynamic and static routes", () => {
329
+ assert.deepEqual(
330
+ resolveStoryRouting({
331
+ configuredPath: "narratives/[narrative]",
332
+ }),
333
+ {
334
+ isDynamic: true,
335
+ routeDir: "narratives",
336
+ routePath: "/narratives",
337
+ },
338
+ );
339
+
340
+ assert.deepEqual(
341
+ resolveStoryRouting({
342
+ configuredPath: "narratives/[narrative].md",
343
+ }),
344
+ {
345
+ isDynamic: true,
346
+ routeDir: "narratives",
347
+ routePath: "/narratives",
348
+ },
349
+ );
350
+
351
+ assert.deepEqual(
352
+ resolveStoryRouting({
353
+ configuredPath: "/custom-stories.md",
354
+ }),
355
+ {
356
+ isDynamic: false,
357
+ routeDir: "custom-stories",
358
+ routePath: "/custom-stories",
359
+ },
360
+ );
361
+
362
+ assert.deepEqual(
363
+ resolveStoryRouting({
364
+ configuredPath: "stories/index.md",
365
+ }),
366
+ {
367
+ isDynamic: false,
368
+ routeDir: "stories",
369
+ routePath: "/stories",
370
+ },
371
+ );
372
+ });
373
+
374
+ it("falls back to default /story route when no source or config provided", () => {
375
+ assert.deepEqual(resolveStoryRouting(), {
376
+ isDynamic: false,
377
+ routeDir: "story",
378
+ routePath: "/story",
379
+ });
380
+ });
381
+ });
382
+
383
+ describe("buildStoryUrl", () => {
384
+ it("constructs url with domain, base, storyPath, and storyId", () => {
385
+ assert.equal(
386
+ buildStoryUrl({
387
+ domain: "https://hub.eox.at",
388
+ base: "/portal/",
389
+ storyPath: "/story",
390
+ storyId: "clean-energy",
391
+ }),
392
+ "https://hub.eox.at/portal/story?id=clean-energy",
393
+ );
394
+ });
395
+
396
+ it("constructs dynamic route URLs for markdown docs and html pages", () => {
397
+ assert.equal(
398
+ buildStoryUrl({
399
+ domain: "https://gtif-austria.info",
400
+ base: "/",
401
+ routeDir: "narratives",
402
+ storyId: "air-heating-potential",
403
+ isDynamic: true,
404
+ extension: ".md",
405
+ }),
406
+ "https://gtif-austria.info/narratives/air-heating-potential.md",
407
+ );
408
+
409
+ assert.equal(
410
+ buildStoryUrl({
411
+ domain: "https://gtif-austria.info",
412
+ base: "/portal",
413
+ routeDir: "narratives",
414
+ storyId: "air-heating-potential",
415
+ isDynamic: true,
416
+ extension: "",
417
+ }),
418
+ "https://gtif-austria.info/portal/narratives/air-heating-potential",
419
+ );
420
+ });
421
+
422
+ it("handles empty domain and root base", () => {
423
+ assert.equal(
424
+ buildStoryUrl({
425
+ domain: null,
426
+ base: "/",
427
+ storyPath: "showcase/stories",
428
+ storyId: "wildfire",
429
+ }),
430
+ "/showcase/stories?id=wildfire",
431
+ );
432
+ });
433
+
434
+ it("handles base without slashes and no storyId", () => {
435
+ assert.equal(
436
+ buildStoryUrl({
437
+ domain: "https://example.com",
438
+ base: "subpath",
439
+ storyPath: "stories",
440
+ }),
441
+ "https://example.com/subpath/stories",
442
+ );
443
+ });
444
+ });
445
+
446
+ describe("resolveAndFetchManifest", () => {
447
+ it("resolves tier 1 explicit URL with highest precedence", async (t) => {
448
+ const mockManifest = [{ title: "Story 1", file: "story1.md" }];
449
+ t.mock.method(globalThis, "fetch", async (url) => ({
450
+ ok: true,
451
+ json: async () => mockManifest,
452
+ }));
453
+
454
+ const res = await resolveAndFetchManifest({
455
+ explicitUrl: "https://example.com/explicit/narratives.json",
456
+ brandStories: "https://example.com/brand/narratives.json",
457
+ srcDir: "/tmp",
458
+ });
459
+
460
+ assert.ok(res);
461
+ assert.equal(
462
+ res.manifestUrl,
463
+ "https://example.com/explicit/narratives.json",
464
+ );
465
+ assert.deepEqual(res.manifest, mockManifest);
466
+ assert.equal(res.sourceMarkdownPath, null);
467
+ });
468
+
469
+ it("resolves tier 2 brandStories when explicitUrl is absent", async (t) => {
470
+ const mockManifest = [{ title: "Story 2", file: "story2.md" }];
471
+ t.mock.method(globalThis, "fetch", async (url) => ({
472
+ ok: true,
473
+ json: async () => mockManifest,
474
+ }));
475
+
476
+ const res = await resolveAndFetchManifest({
477
+ explicitUrl: null,
478
+ brandStories: "https://example.com/brand/narratives.json",
479
+ srcDir: "/tmp",
480
+ });
481
+
482
+ assert.ok(res);
483
+ assert.equal(res.manifestUrl, "https://example.com/brand/narratives.json");
484
+ assert.deepEqual(res.manifest, mockManifest);
485
+ assert.equal(res.sourceMarkdownPath, null);
486
+ });
487
+
488
+ it("resolves tier 3 source scan when exactly one candidate found", async (t) => {
489
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tier3-test-"));
490
+ const storyMdPath = path.join(tmpDir, "story.md");
491
+ await fs.writeFile(
492
+ storyMdPath,
493
+ `<eox-storytelling src="https://cdn.eox.at/manifest/narratives.json"></eox-storytelling>`,
494
+ );
495
+
496
+ const mockManifest = [{ title: "Story 3", file: "story3.md" }];
497
+ t.mock.method(globalThis, "fetch", async (url) => ({
498
+ ok: true,
499
+ json: async () => mockManifest,
500
+ }));
501
+
502
+ const res = await resolveAndFetchManifest({
503
+ explicitUrl: null,
504
+ brandStories: null,
505
+ srcDir: tmpDir,
506
+ });
507
+
508
+ assert.ok(res);
509
+ assert.equal(
510
+ res.manifestUrl,
511
+ "https://cdn.eox.at/manifest/narratives.json",
512
+ );
513
+ assert.deepEqual(res.manifest, mockManifest);
514
+ assert.equal(res.sourceMarkdownPath, storyMdPath);
515
+
516
+ await fs.rm(tmpDir, { recursive: true, force: true });
517
+ });
518
+
519
+ it("skips and returns null when multiple candidate URLs are detected in Tier 3", async () => {
520
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "multi-cand-test-"));
521
+ await fs.writeFile(
522
+ path.join(tmpDir, "p1.md"),
523
+ `https://cdn.eox.at/first/narratives.json`,
524
+ );
525
+ await fs.writeFile(
526
+ path.join(tmpDir, "p2.md"),
527
+ `https://cdn.eox.at/second/narratives.json`,
528
+ );
529
+
530
+ const res = await resolveAndFetchManifest({
531
+ explicitUrl: null,
532
+ brandStories: null,
533
+ srcDir: tmpDir,
534
+ });
535
+
536
+ assert.equal(res, null);
537
+ await fs.rm(tmpDir, { recursive: true, force: true });
538
+ });
539
+
540
+ it("returns null gracefully if manifest fetch fails (fail-soft)", async (t) => {
541
+ t.mock.method(globalThis, "fetch", async () => ({
542
+ ok: false,
543
+ status: 404,
544
+ }));
545
+
546
+ const res = await resolveAndFetchManifest({
547
+ explicitUrl: "https://example.com/missing/narratives.json",
548
+ });
549
+
550
+ assert.equal(res, null);
551
+ });
552
+ });
553
+
554
+ describe("story fetching and processing", () => {
555
+ it("fetchStoryMarkdown returns cleaned markdown on success", async (t) => {
556
+ t.mock.method(globalThis, "fetch", async () => ({
557
+ ok: true,
558
+ text: async () => "# My Title\nBody text",
559
+ }));
560
+
561
+ const body = await fetchStoryMarkdown(
562
+ "https://example.com/story.md",
563
+ "My Title",
564
+ "https://example.com",
565
+ );
566
+ assert.equal(body, "Body text");
567
+ });
568
+
569
+ it("fetchStoryMarkdown returns null on HTTP error or timeout", async (t) => {
570
+ t.mock.method(globalThis, "fetch", async () => ({
571
+ ok: false,
572
+ status: 500,
573
+ }));
574
+
575
+ const body = await fetchStoryMarkdown(
576
+ "https://example.com/story.md",
577
+ "My Title",
578
+ "https://example.com",
579
+ );
580
+ assert.equal(body, null);
581
+ });
582
+
583
+ it("fetchStory skips invalid entries", async () => {
584
+ assert.equal(await fetchStory({ entry: null }), null);
585
+ assert.equal(await fetchStory({ entry: { title: "No file" } }), null);
586
+ assert.equal(await fetchStory({ entry: { file: "no-title.md" } }), null);
587
+ });
588
+
589
+ it("fetchStory correctly formats relative markdownUrl and storyUrl with domain", async (t) => {
590
+ t.mock.method(globalThis, "fetch", async () => ({
591
+ ok: true,
592
+ text: async () => "Story content",
593
+ }));
594
+
595
+ const story = await fetchStory({
596
+ entry: {
597
+ title: "Clean Energy",
598
+ subtitle: "Solar and wind",
599
+ file: "./clean-energy.v1.md",
600
+ },
601
+ index: 0,
602
+ manifestBaseUrl: "https://cdn.eox.at/manifest",
603
+ base: "/portal/",
604
+ storyPath: "/story",
605
+ domain: "https://hub.eox.at",
606
+ });
607
+
608
+ assert.ok(story);
609
+ assert.equal(story.storyId, "clean-energy");
610
+ assert.equal(
611
+ story.markdownUrl,
612
+ "https://cdn.eox.at/manifest/clean-energy.v1.md",
613
+ );
614
+ assert.equal(
615
+ story.storyUrl,
616
+ "https://hub.eox.at/portal/story?id=clean-energy",
617
+ );
618
+ assert.equal(story.cleanedBody, "Story content");
619
+ });
620
+
621
+ it("fetchStory supports dynamic routing with distinct docUrl and storyUrl", async (t) => {
622
+ t.mock.method(globalThis, "fetch", async () => ({
623
+ ok: true,
624
+ text: async () => "Story content",
625
+ }));
626
+
627
+ const story = await fetchStory({
628
+ entry: {
629
+ title: "Air Heating Potential",
630
+ file: "./air-heating.md",
631
+ },
632
+ index: 0,
633
+ manifestBaseUrl: "https://gtif-austria.info/data",
634
+ base: "/",
635
+ routing: {
636
+ isDynamic: true,
637
+ routeDir: "narratives",
638
+ routePath: "/narratives",
639
+ },
640
+ domain: "https://gtif-austria.info",
641
+ });
642
+
643
+ assert.ok(story);
644
+ assert.equal(story.storyId, "air-heating");
645
+ assert.equal(
646
+ story.docUrl,
647
+ "https://gtif-austria.info/narratives/air-heating.md",
648
+ );
649
+ assert.equal(
650
+ story.storyUrl,
651
+ "https://gtif-austria.info/narratives/air-heating",
652
+ );
653
+ });
654
+
655
+ it("fetchStory falls back to markdownUrl for docUrl when fetch fails", async (t) => {
656
+ t.mock.method(globalThis, "fetch", async () => ({
657
+ ok: false,
658
+ status: 404,
659
+ }));
660
+
661
+ const story = await fetchStory({
662
+ entry: {
663
+ title: "Air Heating",
664
+ file: "./air-heating.md",
665
+ },
666
+ index: 0,
667
+ manifestBaseUrl: "https://gtif-austria.info/data",
668
+ base: "/",
669
+ routing: {
670
+ isDynamic: true,
671
+ routeDir: "narratives",
672
+ routePath: "/narratives",
673
+ },
674
+ domain: "https://gtif-austria.info",
675
+ });
676
+
677
+ assert.ok(story);
678
+ assert.equal(story.cleanedBody, null);
679
+ assert.equal(story.docUrl, story.markdownUrl);
680
+ });
681
+
682
+ it("fetchStory falls back to markdownUrl when body is empty string", async (t) => {
683
+ t.mock.method(globalThis, "fetch", async () => ({
684
+ ok: true,
685
+ text: async () => "",
686
+ }));
687
+
688
+ const story = await fetchStory({
689
+ entry: {
690
+ title: "Empty Title",
691
+ file: "./empty.md",
692
+ },
693
+ index: 0,
694
+ manifestBaseUrl: "https://gtif-austria.info/data",
695
+ base: "/",
696
+ routing: {
697
+ isDynamic: true,
698
+ routeDir: "narratives",
699
+ routePath: "/narratives",
700
+ },
701
+ domain: "https://gtif-austria.info",
702
+ });
703
+
704
+ assert.ok(story);
705
+ assert.equal(story.cleanedBody, "");
706
+ assert.equal(story.docUrl, story.markdownUrl);
707
+ });
708
+
709
+ it("fetchStories filters invalid entries and processes list", async (t) => {
710
+ t.mock.method(globalThis, "fetch", async () => ({
711
+ ok: true,
712
+ text: async () => "Content",
713
+ }));
714
+
715
+ const manifest = [
716
+ { title: "Story A", file: "a.md" },
717
+ { invalid: true },
718
+ { title: "Story B", file: "b.md" },
719
+ ];
720
+
721
+ const results = await fetchStories({
722
+ manifest,
723
+ manifestBaseUrl: "https://cdn.example.com",
724
+ base: "/",
725
+ storyPath: "/story",
726
+ domain: null,
727
+ concurrency: 2,
728
+ });
729
+
730
+ assert.equal(results.length, 2);
731
+ assert.equal(results[0].title, "Story A");
732
+ assert.equal(results[1].title, "Story B");
733
+ });
734
+ });
735
+
736
+ describe("appending functions", () => {
737
+ it("appendToLlmsTxt creates or appends heading and stories index", async () => {
738
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "llms-test-"));
739
+ const llmsTxt = path.join(tmpDir, "llms.txt");
740
+ await fs.writeFile(llmsTxt, "# Main Docs\n\n- [Page 1](/page1)\n");
741
+
742
+ const stories = [
743
+ {
744
+ title: "Test Story",
745
+ subtitle: "A story for testing",
746
+ markdownUrl: "https://cdn.example.com/story.md",
747
+ storyUrl: "https://example.com/story?id=test",
748
+ },
749
+ ];
750
+
751
+ await appendToLlmsTxt({ outDir: tmpDir, heading: "Stories", stories });
752
+
753
+ const content = await fs.readFile(llmsTxt, "utf8");
754
+ assert.ok(content.includes("## Stories"));
755
+ assert.ok(
756
+ content.includes(
757
+ "- [Test Story](https://cdn.example.com/story.md): A story for testing. Story page: https://example.com/story?id=test",
758
+ ),
759
+ );
760
+
761
+ await fs.rm(tmpDir, { recursive: true, force: true });
762
+ });
763
+
764
+ it("appendToLlmsTxt avoids substring deduplication false-positives", async () => {
765
+ const tmpDir = await fs.mkdtemp(
766
+ path.join(os.tmpdir(), "llms-prefix-test-"),
767
+ );
768
+ const llmsTxt = path.join(tmpDir, "llms.txt");
769
+ await fs.writeFile(
770
+ llmsTxt,
771
+ "# Main Docs\n\n## Stories from our Partners\n\n- [Partner Story](/partner)\n",
772
+ );
773
+
774
+ const stories = [
775
+ {
776
+ title: "Test Story",
777
+ markdownUrl: "https://cdn.example.com/story.md",
778
+ storyUrl: "https://example.com/story?id=test",
779
+ },
780
+ ];
781
+
782
+ await appendToLlmsTxt({ outDir: tmpDir, heading: "Stories", stories });
783
+
784
+ const content = await fs.readFile(llmsTxt, "utf8");
785
+ const matches = content.match(/## Stories\b/g);
786
+ assert.equal(matches.length, 2);
787
+
788
+ await fs.rm(tmpDir, { recursive: true, force: true });
789
+ });
790
+
791
+ it("appendToLlmsFullTxt appends full cleaned content", async () => {
792
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "llms-full-test-"));
793
+ const llmsFull = path.join(tmpDir, "llms-full.txt");
794
+ await fs.writeFile(llmsFull, "# Full Documentation\n");
795
+
796
+ const stories = [
797
+ {
798
+ title: "Full Story",
799
+ markdownUrl: "https://cdn.example.com/full.md",
800
+ storyUrl: "https://example.com/story?id=full",
801
+ cleanedBody: "### Section 1\nClean body content",
802
+ },
803
+ ];
804
+
805
+ await appendToLlmsFullTxt({ outDir: tmpDir, heading: "Stories", stories });
806
+
807
+ const content = await fs.readFile(llmsFull, "utf8");
808
+ assert.ok(content.includes("# Stories"));
809
+ assert.ok(content.includes("## Full Story"));
810
+ assert.ok(content.includes("Source: https://cdn.example.com/full.md"));
811
+ assert.ok(content.includes("Clean body content"));
812
+
813
+ await fs.rm(tmpDir, { recursive: true, force: true });
814
+ });
815
+
816
+ it("appendToStoryRoutePage appends index to target story markdown page in outDir", async () => {
817
+ const srcDir = await fs.mkdtemp(path.join(os.tmpdir(), "src-test-"));
818
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "out-test-"));
819
+
820
+ const sourceMarkdownPath = path.join(srcDir, "story.md");
821
+ await fs.writeFile(sourceMarkdownPath, "# Story Page\n");
822
+
823
+ const targetStoryPath = path.join(outDir, "story.md");
824
+ await fs.writeFile(targetStoryPath, "# Story Page Output\n");
825
+
826
+ const stories = [
827
+ {
828
+ title: "Story One",
829
+ subtitle: "Subtitle One",
830
+ markdownUrl: "https://cdn.example.com/1.md",
831
+ storyUrl: "/story?id=1",
832
+ },
833
+ ];
834
+
835
+ await appendToStoryRoutePage({
836
+ outDir,
837
+ heading: "Stories",
838
+ stories,
839
+ sourceMarkdownPath,
840
+ srcDir,
841
+ });
842
+
843
+ const targetContent = await fs.readFile(targetStoryPath, "utf8");
844
+ assert.ok(targetContent.includes("## Stories"));
845
+ assert.ok(
846
+ targetContent.includes(
847
+ "- [Story One](https://cdn.example.com/1.md) — Subtitle One. [View story](/story?id=1)",
848
+ ),
849
+ );
850
+
851
+ await fs.rm(srcDir, { recursive: true, force: true });
852
+ await fs.rm(outDir, { recursive: true, force: true });
853
+ });
854
+
855
+ it("appendToStoryRoutePage supports dynamic routing targeting matching route markdown page", async () => {
856
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "dyn-route-test-"));
857
+ const targetPath = path.join(outDir, "narratives.md");
858
+ await fs.writeFile(targetPath, "# Narratives Gallery\n");
859
+
860
+ const stories = [
861
+ {
862
+ title: "Dynamic Story",
863
+ subtitle: "Dynamic Subtitle",
864
+ markdownUrl: "https://cdn.example.com/dyn.md",
865
+ docUrl: "https://example.com/narratives/dyn.md",
866
+ storyUrl: "https://example.com/narratives/dyn",
867
+ cleanedBody: "A dynamic story body",
868
+ },
869
+ ];
870
+
871
+ await appendToStoryRoutePage({
872
+ outDir,
873
+ heading: "Available Narratives",
874
+ stories,
875
+ routing: {
876
+ isDynamic: true,
877
+ routeDir: "narratives",
878
+ routePath: "/narratives",
879
+ },
880
+ });
881
+
882
+ const content = await fs.readFile(targetPath, "utf8");
883
+ assert.ok(content.includes("## Available Narratives"));
884
+ assert.ok(
885
+ content.includes(
886
+ "- [Dynamic Story](https://example.com/narratives/dyn.md) — Dynamic Subtitle. [View story](https://example.com/narratives/dyn)",
887
+ ),
888
+ );
889
+
890
+ await fs.rm(outDir, { recursive: true, force: true });
891
+ });
892
+
893
+ it("appendToStoryRoutePage does not throw if no candidate files exist", async () => {
894
+ await appendToStoryRoutePage({
895
+ outDir: "/nonexistent-dir",
896
+ heading: "Stories",
897
+ stories: [],
898
+ });
899
+ });
900
+ });
901
+
902
+ describe("writeIndividualStoryFiles", () => {
903
+ it("writes individual story markdown files in the routeDir output directory", async () => {
904
+ const outDir = await fs.mkdtemp(
905
+ path.join(os.tmpdir(), "individual-stories-"),
906
+ );
907
+ const stories = [
908
+ {
909
+ storyId: "austrian-air-heating",
910
+ title: "Austrian Air Heating Potential",
911
+ cleanedBody: "Cleaned story content for LLMs.",
912
+ },
913
+ {
914
+ storyId: "solar-rooftops",
915
+ title: "Solar Rooftops",
916
+ cleanedBody: "Solar energy potential breakdown.",
917
+ },
918
+ ];
919
+
920
+ await writeIndividualStoryFiles({
921
+ outDir,
922
+ routeDir: "narratives",
923
+ stories,
924
+ });
925
+
926
+ const story1Path = path.join(
927
+ outDir,
928
+ "narratives",
929
+ "austrian-air-heating.md",
930
+ );
931
+ const story2Path = path.join(outDir, "narratives", "solar-rooftops.md");
932
+
933
+ const story1Content = await fs.readFile(story1Path, "utf8");
934
+ assert.ok(story1Content.includes("# Austrian Air Heating Potential"));
935
+ assert.ok(story1Content.includes("Cleaned story content for LLMs."));
936
+
937
+ const story2Content = await fs.readFile(story2Path, "utf8");
938
+ assert.ok(story2Content.includes("# Solar Rooftops"));
939
+ assert.ok(story2Content.includes("Solar energy potential breakdown."));
940
+
941
+ await fs.rm(outDir, { recursive: true, force: true });
942
+ });
943
+
944
+ it("handles empty stories array gracefully without error", async () => {
945
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "empty-stories-"));
946
+ await writeIndividualStoryFiles({
947
+ outDir,
948
+ routeDir: "narratives",
949
+ stories: [],
950
+ });
951
+ await fs.rm(outDir, { recursive: true, force: true });
952
+ });
953
+
954
+ it("prevents path traversal outside outDir", async () => {
955
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "traversal-safe-"));
956
+ const outsideFile = path.resolve(outDir, "../escaped-story.md");
957
+
958
+ await writeIndividualStoryFiles({
959
+ outDir,
960
+ routeDir: "../",
961
+ stories: [
962
+ {
963
+ storyId: "escaped-story",
964
+ title: "Escaped Story",
965
+ cleanedBody: "Malicious content",
966
+ },
967
+ ],
968
+ });
969
+
970
+ let exists = true;
971
+ try {
972
+ await fs.access(outsideFile);
973
+ } catch {
974
+ exists = false;
975
+ }
976
+ assert.equal(exists, false);
977
+
978
+ await fs.rm(outDir, { recursive: true, force: true });
979
+ });
980
+ });