pict-docuserve 0.0.1

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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/build-site.js +172 -0
  3. package/css/docuserve.css +73 -0
  4. package/dist/css/docuserve.css +73 -0
  5. package/dist/index.html +32 -0
  6. package/dist/js/pict.compatible.js +7793 -0
  7. package/dist/js/pict.compatible.js.map +1 -0
  8. package/dist/js/pict.compatible.min.js +12 -0
  9. package/dist/js/pict.compatible.min.js.map +1 -0
  10. package/dist/js/pict.js +7792 -0
  11. package/dist/js/pict.js.map +1 -0
  12. package/dist/js/pict.min.js +12 -0
  13. package/dist/js/pict.min.js.map +1 -0
  14. package/dist/pict-docuserve.compatible.js +5148 -0
  15. package/dist/pict-docuserve.compatible.js.map +1 -0
  16. package/dist/pict-docuserve.compatible.min.js +2 -0
  17. package/dist/pict-docuserve.compatible.min.js.map +1 -0
  18. package/dist/pict-docuserve.js +4670 -0
  19. package/dist/pict-docuserve.js.map +1 -0
  20. package/dist/pict-docuserve.min.js +2 -0
  21. package/dist/pict-docuserve.min.js.map +1 -0
  22. package/html/index.html +32 -0
  23. package/package.json +52 -0
  24. package/source/Pict-Application-Docuserve-Configuration.json +15 -0
  25. package/source/Pict-Application-Docuserve.js +246 -0
  26. package/source/cli/Docuserve-CLI-Program.js +16 -0
  27. package/source/cli/Docuserve-CLI-Run.js +3 -0
  28. package/source/cli/commands/Docuserve-Command-Inject.js +127 -0
  29. package/source/cli/commands/Docuserve-Command-Serve.js +135 -0
  30. package/source/providers/Pict-Provider-Docuserve-Documentation.js +1156 -0
  31. package/source/providers/PictRouter-Docuserve-Configuration.json +26 -0
  32. package/source/views/PictView-Docuserve-Content.js +208 -0
  33. package/source/views/PictView-Docuserve-Layout.js +105 -0
  34. package/source/views/PictView-Docuserve-Sidebar.js +362 -0
  35. package/source/views/PictView-Docuserve-Splash.js +264 -0
  36. package/source/views/PictView-Docuserve-TopBar.js +213 -0
@@ -0,0 +1,1156 @@
1
+ const libPictProvider = require('pict-provider');
2
+
3
+ /**
4
+ * Documentation Provider for Docuserve
5
+ *
6
+ * Loads the Indoctrinate-generated catalog and keyword index,
7
+ * fetches markdown documents from local paths or raw GitHub URLs,
8
+ * and parses them into HTML for rendering.
9
+ */
10
+ class DocuserveDocumentationProvider extends libPictProvider
11
+ {
12
+ constructor(pFable, pOptions, pServiceHash)
13
+ {
14
+ super(pFable, pOptions, pServiceHash);
15
+
16
+ this._Catalog = null;
17
+ this._ContentCache = {};
18
+ }
19
+
20
+ /**
21
+ * Load all documentation data sources: catalog, cover.md, _sidebar.md.
22
+ *
23
+ * Loads the catalog first (it provides the fallback data), then attempts
24
+ * to load cover.md and _sidebar.md in parallel. If those markdown files
25
+ * exist they drive the splash and sidebar views; otherwise the catalog
26
+ * data is used as a fallback.
27
+ *
28
+ * @param {Function} fCallback - Callback when all loading is complete
29
+ */
30
+ loadCatalog(fCallback)
31
+ {
32
+ let tmpCallback = (typeof(fCallback) === 'function') ? fCallback : () => {};
33
+
34
+ let tmpCatalogURL = this.pict.AppData.Docuserve.CatalogURL || 'retold-catalog.json';
35
+
36
+ fetch(tmpCatalogURL)
37
+ .then((pResponse) =>
38
+ {
39
+ if (!pResponse.ok)
40
+ {
41
+ this.log.warn(`Docuserve: Could not load catalog from [${tmpCatalogURL}].`);
42
+ return null;
43
+ }
44
+ return pResponse.json();
45
+ })
46
+ .then((pCatalog) =>
47
+ {
48
+ if (pCatalog)
49
+ {
50
+ this._Catalog = pCatalog;
51
+ this.pict.AppData.Docuserve.Catalog = pCatalog;
52
+ this.pict.AppData.Docuserve.CatalogLoaded = true;
53
+
54
+ // Build sidebar navigation data from the catalog as default
55
+ this.buildSidebarData(pCatalog);
56
+ }
57
+
58
+ // Now try to load cover.md, _sidebar.md, _topbar.md, and errorpage.md in parallel
59
+ let tmpPending = 4;
60
+ let tmpFinish = () =>
61
+ {
62
+ tmpPending--;
63
+ if (tmpPending <= 0)
64
+ {
65
+ return tmpCallback();
66
+ }
67
+ };
68
+
69
+ this.loadCover(tmpFinish);
70
+ this.loadSidebar(tmpFinish);
71
+ this.loadTopbar(tmpFinish);
72
+ this.loadErrorPage(tmpFinish);
73
+ })
74
+ .catch((pError) =>
75
+ {
76
+ this.log.warn(`Docuserve: Error loading catalog: ${pError}`);
77
+ return tmpCallback();
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Fetch and parse cover.md into structured data for the splash view.
83
+ *
84
+ * The expected cover.md format follows the docsify convention:
85
+ * # Title
86
+ * > Tagline
87
+ * Description paragraph text.
88
+ * - **Group** — description
89
+ * [Link Text](url)
90
+ *
91
+ * Parsed result stored in this.pict.AppData.Docuserve.Cover:
92
+ * { Title, Tagline, Description, Highlights: [{Label, Text}], Actions: [{Text, Href}] }
93
+ *
94
+ * @param {Function} fCallback - Callback when done
95
+ */
96
+ loadCover(fCallback)
97
+ {
98
+ let tmpCallback = (typeof(fCallback) === 'function') ? fCallback : () => {};
99
+ let tmpDocsBase = this.pict.AppData.Docuserve.DocsBaseURL || '';
100
+
101
+ fetch(tmpDocsBase + 'cover.md')
102
+ .then((pResponse) =>
103
+ {
104
+ if (!pResponse.ok)
105
+ {
106
+ return null;
107
+ }
108
+ return pResponse.text();
109
+ })
110
+ .then((pMarkdown) =>
111
+ {
112
+ if (!pMarkdown)
113
+ {
114
+ this.log.info('Docuserve: No cover.md found; splash will use catalog data.');
115
+ return tmpCallback();
116
+ }
117
+
118
+ this.pict.AppData.Docuserve.Cover = this.parseCover(pMarkdown);
119
+ this.pict.AppData.Docuserve.CoverLoaded = true;
120
+ return tmpCallback();
121
+ })
122
+ .catch((pError) =>
123
+ {
124
+ this.log.warn(`Docuserve: Error loading cover.md: ${pError}`);
125
+ return tmpCallback();
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Parse cover.md markdown text into a structured object.
131
+ *
132
+ * @param {string} pMarkdown - Raw cover.md content
133
+ * @returns {Object} Parsed cover data
134
+ */
135
+ parseCover(pMarkdown)
136
+ {
137
+ let tmpCover = {
138
+ Title: '',
139
+ Tagline: '',
140
+ Description: '',
141
+ Highlights: [],
142
+ Actions: []
143
+ };
144
+
145
+ let tmpLines = pMarkdown.split('\n');
146
+
147
+ for (let i = 0; i < tmpLines.length; i++)
148
+ {
149
+ let tmpLine = tmpLines[i].trim();
150
+
151
+ if (!tmpLine)
152
+ {
153
+ continue;
154
+ }
155
+
156
+ // Heading — the title
157
+ let tmpHeadingMatch = tmpLine.match(/^#+\s+(.+)/);
158
+ if (tmpHeadingMatch)
159
+ {
160
+ tmpCover.Title = tmpHeadingMatch[1].trim();
161
+ continue;
162
+ }
163
+
164
+ // Blockquote — the tagline
165
+ let tmpBlockquoteMatch = tmpLine.match(/^>\s*(.*)/);
166
+ if (tmpBlockquoteMatch)
167
+ {
168
+ tmpCover.Tagline = tmpBlockquoteMatch[1].trim();
169
+ continue;
170
+ }
171
+
172
+ // Bullet list — highlights (e.g. "- **Fable** — Core ecosystem, DI, config")
173
+ let tmpBulletMatch = tmpLine.match(/^[-*+]\s+(.*)/);
174
+ if (tmpBulletMatch)
175
+ {
176
+ let tmpBulletContent = tmpBulletMatch[1];
177
+ // Try to split on bold label: **Label** — rest
178
+ let tmpLabelMatch = tmpBulletContent.match(/^\*\*([^*]+)\*\*\s*[-—:]\s*(.*)/);
179
+ if (tmpLabelMatch)
180
+ {
181
+ tmpCover.Highlights.push({ Label: tmpLabelMatch[1].trim(), Text: tmpLabelMatch[2].trim() });
182
+ }
183
+ else
184
+ {
185
+ tmpCover.Highlights.push({ Label: '', Text: tmpBulletContent.trim() });
186
+ }
187
+ continue;
188
+ }
189
+
190
+ // Bare link — action button (e.g. "[Get Started](getting-started.md)")
191
+ let tmpLinkMatch = tmpLine.match(/^\[([^\]]+)\]\(([^)]+)\)\s*$/);
192
+ if (tmpLinkMatch)
193
+ {
194
+ tmpCover.Actions.push({ Text: tmpLinkMatch[1].trim(), Href: tmpLinkMatch[2].trim() });
195
+ continue;
196
+ }
197
+
198
+ // Otherwise it's description text
199
+ if (!tmpCover.Description)
200
+ {
201
+ tmpCover.Description = tmpLine;
202
+ }
203
+ else
204
+ {
205
+ tmpCover.Description += ' ' + tmpLine;
206
+ }
207
+ }
208
+
209
+ return tmpCover;
210
+ }
211
+
212
+ /**
213
+ * Fetch and parse _sidebar.md into structured navigation data.
214
+ *
215
+ * The expected _sidebar.md format follows the docsify convention:
216
+ * - [Home](/)
217
+ * - Group Title
218
+ * - [module-name](/group/module/)
219
+ * - [Group Title](group.md)
220
+ * - [module-name](/group/module/)
221
+ *
222
+ * If _sidebar.md is successfully loaded and parsed, its data replaces
223
+ * the catalog-inferred SidebarGroups in AppData.
224
+ *
225
+ * @param {Function} fCallback - Callback when done
226
+ */
227
+ loadSidebar(fCallback)
228
+ {
229
+ let tmpCallback = (typeof(fCallback) === 'function') ? fCallback : () => {};
230
+ let tmpDocsBase = this.pict.AppData.Docuserve.DocsBaseURL || '';
231
+
232
+ fetch(tmpDocsBase + '_sidebar.md')
233
+ .then((pResponse) =>
234
+ {
235
+ if (!pResponse.ok)
236
+ {
237
+ return null;
238
+ }
239
+ return pResponse.text();
240
+ })
241
+ .then((pMarkdown) =>
242
+ {
243
+ if (!pMarkdown)
244
+ {
245
+ this.log.info('Docuserve: No _sidebar.md found; sidebar will use catalog data.');
246
+ return tmpCallback();
247
+ }
248
+
249
+ let tmpSidebarData = this.parseSidebarMarkdown(pMarkdown);
250
+ if (tmpSidebarData && tmpSidebarData.length > 0)
251
+ {
252
+ this.pict.AppData.Docuserve.SidebarGroups = tmpSidebarData;
253
+ this.pict.AppData.Docuserve.SidebarLoaded = true;
254
+ }
255
+ return tmpCallback();
256
+ })
257
+ .catch((pError) =>
258
+ {
259
+ this.log.warn(`Docuserve: Error loading _sidebar.md: ${pError}`);
260
+ return tmpCallback();
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Fetch and parse _topbar.md into structured data for the top bar view.
266
+ *
267
+ * The expected _topbar.md format:
268
+ * # Brand Name
269
+ * - [Link Text](url)
270
+ * - [Link Text](url)
271
+ *
272
+ * The heading becomes the brand/title shown on the left. List items become
273
+ * navigation links. External links (starting with http) render on the
274
+ * right side; internal links render in the centre nav area.
275
+ *
276
+ * Parsed result stored in this.pict.AppData.Docuserve.TopBar:
277
+ * { Brand, NavLinks: [{Text, Href, External}], ExternalLinks: [{Text, Href}] }
278
+ *
279
+ * @param {Function} fCallback - Callback when done
280
+ */
281
+ loadTopbar(fCallback)
282
+ {
283
+ let tmpCallback = (typeof(fCallback) === 'function') ? fCallback : () => {};
284
+ let tmpDocsBase = this.pict.AppData.Docuserve.DocsBaseURL || '';
285
+
286
+ fetch(tmpDocsBase + '_topbar.md')
287
+ .then((pResponse) =>
288
+ {
289
+ if (!pResponse.ok)
290
+ {
291
+ return null;
292
+ }
293
+ return pResponse.text();
294
+ })
295
+ .then((pMarkdown) =>
296
+ {
297
+ if (!pMarkdown)
298
+ {
299
+ this.log.info('Docuserve: No _topbar.md found; top bar will use defaults.');
300
+ return tmpCallback();
301
+ }
302
+
303
+ this.pict.AppData.Docuserve.TopBar = this.parseTopbar(pMarkdown);
304
+ this.pict.AppData.Docuserve.TopBarLoaded = true;
305
+ return tmpCallback();
306
+ })
307
+ .catch((pError) =>
308
+ {
309
+ this.log.warn(`Docuserve: Error loading _topbar.md: ${pError}`);
310
+ return tmpCallback();
311
+ });
312
+ }
313
+
314
+ /**
315
+ * Parse _topbar.md markdown text into a structured object.
316
+ *
317
+ * @param {string} pMarkdown - Raw _topbar.md content
318
+ * @returns {Object} Parsed top bar data { Brand, NavLinks, ExternalLinks }
319
+ */
320
+ parseTopbar(pMarkdown)
321
+ {
322
+ let tmpTopBar = {
323
+ Brand: '',
324
+ NavLinks: [],
325
+ ExternalLinks: []
326
+ };
327
+
328
+ let tmpLines = pMarkdown.split('\n');
329
+
330
+ for (let i = 0; i < tmpLines.length; i++)
331
+ {
332
+ let tmpLine = tmpLines[i].trim();
333
+
334
+ if (!tmpLine)
335
+ {
336
+ continue;
337
+ }
338
+
339
+ // Heading — the brand name
340
+ let tmpHeadingMatch = tmpLine.match(/^#+\s+(.+)/);
341
+ if (tmpHeadingMatch)
342
+ {
343
+ tmpTopBar.Brand = tmpHeadingMatch[1].trim();
344
+ continue;
345
+ }
346
+
347
+ // Bullet list item with link
348
+ let tmpBulletMatch = tmpLine.match(/^[-*+]\s+(.*)/);
349
+ if (tmpBulletMatch)
350
+ {
351
+ let tmpContent = tmpBulletMatch[1].trim();
352
+ let tmpLinkMatch = tmpContent.match(/^\[([^\]]+)\]\(([^)]+)\)/);
353
+
354
+ if (tmpLinkMatch)
355
+ {
356
+ let tmpText = tmpLinkMatch[1].trim();
357
+ let tmpHref = tmpLinkMatch[2].trim();
358
+
359
+ // External links (http/https) go to the right side
360
+ if (tmpHref.match(/^https?:\/\//))
361
+ {
362
+ tmpTopBar.ExternalLinks.push({ Text: tmpText, Href: tmpHref });
363
+ }
364
+ else
365
+ {
366
+ // Internal link — convert to hash route
367
+ let tmpRoute = this.convertSidebarLink(tmpHref);
368
+ tmpTopBar.NavLinks.push({ Text: tmpText, Href: tmpRoute });
369
+ }
370
+ }
371
+ continue;
372
+ }
373
+ }
374
+
375
+ return tmpTopBar;
376
+ }
377
+
378
+ /**
379
+ * Fetch and parse errorpage.md into HTML for use as a custom error page.
380
+ *
381
+ * The errorpage.md is a standard markdown file. If it contains the
382
+ * placeholder `{{path}}` anywhere in its source, that token will be
383
+ * replaced with the actual requested path at display time (via
384
+ * getErrorPageHTML).
385
+ *
386
+ * @param {Function} fCallback - Callback when done
387
+ */
388
+ loadErrorPage(fCallback)
389
+ {
390
+ let tmpCallback = (typeof(fCallback) === 'function') ? fCallback : () => {};
391
+ let tmpDocsBase = this.pict.AppData.Docuserve.DocsBaseURL || '';
392
+
393
+ fetch(tmpDocsBase + 'errorpage.md')
394
+ .then((pResponse) =>
395
+ {
396
+ if (!pResponse.ok)
397
+ {
398
+ return null;
399
+ }
400
+ return pResponse.text();
401
+ })
402
+ .then((pMarkdown) =>
403
+ {
404
+ if (!pMarkdown)
405
+ {
406
+ this.log.info('Docuserve: No errorpage.md found; errors will use default page.');
407
+ return tmpCallback();
408
+ }
409
+
410
+ this.pict.AppData.Docuserve.ErrorPageHTML = this.parseMarkdown(pMarkdown);
411
+ this.pict.AppData.Docuserve.ErrorPageLoaded = true;
412
+ return tmpCallback();
413
+ })
414
+ .catch((pError) =>
415
+ {
416
+ this.log.warn(`Docuserve: Error loading errorpage.md: ${pError}`);
417
+ return tmpCallback();
418
+ });
419
+ }
420
+
421
+ /**
422
+ * Get the error page HTML for a given requested path.
423
+ *
424
+ * If a custom errorpage.md was loaded, its parsed HTML is returned with
425
+ * the `{{path}}` placeholder replaced by the actual requested path.
426
+ * Otherwise a default not-found HTML block is returned.
427
+ *
428
+ * @param {string} pRequestedPath - The path that was not found
429
+ * @returns {string} HTML to display
430
+ */
431
+ getErrorPageHTML(pRequestedPath)
432
+ {
433
+ let tmpPath = pRequestedPath || 'unknown';
434
+
435
+ if (this.pict.AppData.Docuserve.ErrorPageLoaded && this.pict.AppData.Docuserve.ErrorPageHTML)
436
+ {
437
+ // Replace the {{path}} placeholder with the actual requested path
438
+ return this.pict.AppData.Docuserve.ErrorPageHTML.replace(/\{\{path\}\}/g, this.escapeHTML(tmpPath));
439
+ }
440
+
441
+ // Default fallback
442
+ return '<div class="docuserve-not-found">'
443
+ + '<h2>Page Not Found</h2>'
444
+ + '<p>The document <code>' + this.escapeHTML(tmpPath) + '</code> could not be loaded.</p>'
445
+ + '<p><a href="#/Home">Return to the home page</a></p>'
446
+ + '</div>';
447
+ }
448
+
449
+ /**
450
+ * Parse _sidebar.md into the SidebarGroups format the sidebar view consumes.
451
+ *
452
+ * Returns an array of group objects:
453
+ * [{ Name, Key, Route, Modules: [{ Name, HasDocs, Group, Route }] }]
454
+ *
455
+ * Top-level items (no indent) become groups. Indented child items become
456
+ * modules within the preceding group. The special "Home" entry is stored
457
+ * as a group with no modules.
458
+ *
459
+ * @param {string} pMarkdown - Raw _sidebar.md content
460
+ * @returns {Array} Parsed sidebar groups
461
+ */
462
+ parseSidebarMarkdown(pMarkdown)
463
+ {
464
+ let tmpGroups = [];
465
+ let tmpCurrentGroup = null;
466
+ let tmpLines = pMarkdown.split('\n');
467
+
468
+ for (let i = 0; i < tmpLines.length; i++)
469
+ {
470
+ let tmpLine = tmpLines[i];
471
+
472
+ if (!tmpLine.trim())
473
+ {
474
+ continue;
475
+ }
476
+
477
+ // Detect indent level: child items have 2+ leading spaces
478
+ let tmpIndentMatch = tmpLine.match(/^(\s*)/);
479
+ let tmpIndent = tmpIndentMatch ? tmpIndentMatch[1].length : 0;
480
+ let tmpContent = tmpLine.trim();
481
+
482
+ // Must start with a list marker
483
+ let tmpListMatch = tmpContent.match(/^[-*+]\s+(.*)/);
484
+ if (!tmpListMatch)
485
+ {
486
+ continue;
487
+ }
488
+
489
+ let tmpItemContent = tmpListMatch[1].trim();
490
+
491
+ // Parse link if present: [Text](href)
492
+ let tmpLinkMatch = tmpItemContent.match(/^\[([^\]]+)\]\(([^)]+)\)/);
493
+
494
+ if (tmpIndent < 2)
495
+ {
496
+ // Top-level item — this is a group header or standalone link
497
+ if (tmpLinkMatch)
498
+ {
499
+ let tmpName = tmpLinkMatch[1].trim();
500
+ let tmpHref = tmpLinkMatch[2].trim();
501
+
502
+ // Derive a group key from the href or name
503
+ let tmpKey = this.deriveGroupKey(tmpName, tmpHref);
504
+ let tmpRoute = this.convertSidebarLink(tmpHref);
505
+
506
+ tmpCurrentGroup = {
507
+ Name: tmpName,
508
+ Key: tmpKey,
509
+ Route: tmpRoute,
510
+ Modules: []
511
+ };
512
+ tmpGroups.push(tmpCurrentGroup);
513
+ }
514
+ else
515
+ {
516
+ // Plain text group header (no link)
517
+ let tmpName = tmpItemContent;
518
+ let tmpKey = tmpName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
519
+
520
+ tmpCurrentGroup = {
521
+ Name: tmpName,
522
+ Key: tmpKey,
523
+ Route: '',
524
+ Modules: []
525
+ };
526
+ tmpGroups.push(tmpCurrentGroup);
527
+ }
528
+ }
529
+ else if (tmpCurrentGroup)
530
+ {
531
+ // Indented item — this is a module within the current group
532
+ if (tmpLinkMatch)
533
+ {
534
+ let tmpModuleName = tmpLinkMatch[1].trim();
535
+ let tmpModuleHref = tmpLinkMatch[2].trim();
536
+ let tmpModuleRoute = this.convertSidebarLink(tmpModuleHref);
537
+
538
+ tmpCurrentGroup.Modules.push({
539
+ Name: tmpModuleName,
540
+ HasDocs: true,
541
+ Group: tmpCurrentGroup.Key,
542
+ Route: tmpModuleRoute
543
+ });
544
+ }
545
+ else
546
+ {
547
+ // Plain text child entry (no docs link)
548
+ tmpCurrentGroup.Modules.push({
549
+ Name: tmpItemContent,
550
+ HasDocs: false,
551
+ Group: tmpCurrentGroup.Key,
552
+ Route: ''
553
+ });
554
+ }
555
+ }
556
+ }
557
+
558
+ return tmpGroups;
559
+ }
560
+
561
+ /**
562
+ * Convert a docsify-style sidebar link href into a docuserve hash route.
563
+ *
564
+ * Handles these forms:
565
+ * / -> #/Home
566
+ * /group/module/ -> #/doc/group/module
567
+ * /group/module/path.md -> #/doc/group/module/path.md
568
+ * something.md -> #/page/something
569
+ *
570
+ * @param {string} pHref - The original sidebar link href
571
+ * @returns {string} The converted hash route
572
+ */
573
+ convertSidebarLink(pHref)
574
+ {
575
+ if (!pHref)
576
+ {
577
+ return '';
578
+ }
579
+
580
+ // Root home link
581
+ if (pHref === '/')
582
+ {
583
+ return '#/Home';
584
+ }
585
+
586
+ // Strip leading/trailing slashes for parsing
587
+ let tmpPath = pHref.replace(/^\//, '').replace(/\/$/, '');
588
+
589
+ if (!tmpPath)
590
+ {
591
+ return '#/Home';
592
+ }
593
+
594
+ let tmpParts = tmpPath.split('/');
595
+
596
+ // Check if it's a module path (group/module)
597
+ if (tmpParts.length >= 2)
598
+ {
599
+ let tmpGroupKeys = ['fable', 'meadow', 'orator', 'pict', 'utility'];
600
+ if (tmpGroupKeys.indexOf(tmpParts[0]) >= 0)
601
+ {
602
+ return '#/doc/' + tmpPath;
603
+ }
604
+ }
605
+
606
+ // Local page reference
607
+ if (tmpPath.match(/\.md$/))
608
+ {
609
+ return '#/page/' + tmpPath.replace(/\.md$/, '');
610
+ }
611
+
612
+ return '#/page/' + tmpPath;
613
+ }
614
+
615
+ /**
616
+ * Derive a short group key from a sidebar group name or href.
617
+ *
618
+ * @param {string} pName - The display name (e.g. "Fable — Core Ecosystem")
619
+ * @param {string} pHref - The link href (e.g. "fable.md")
620
+ * @returns {string} A short key (e.g. "fable")
621
+ */
622
+ deriveGroupKey(pName, pHref)
623
+ {
624
+ // Try href first — "fable.md" -> "fable"
625
+ if (pHref && pHref !== '/')
626
+ {
627
+ let tmpFromHref = pHref.replace(/^\//, '').replace(/\.md$/, '').replace(/\/$/, '');
628
+ if (tmpFromHref && !tmpFromHref.includes('/'))
629
+ {
630
+ return tmpFromHref.toLowerCase();
631
+ }
632
+ }
633
+
634
+ // Fall back to first word of name lowercased
635
+ let tmpFirstWord = pName.split(/[\s—\-:]+/)[0];
636
+ return tmpFirstWord.toLowerCase().replace(/[^a-z0-9]/g, '');
637
+ }
638
+
639
+ /**
640
+ * Build structured sidebar data from the catalog for the sidebar view.
641
+ *
642
+ * @param {Object} pCatalog - The parsed retold-catalog.json
643
+ */
644
+ buildSidebarData(pCatalog)
645
+ {
646
+ let tmpSidebarGroups = [];
647
+
648
+ for (let i = 0; i < pCatalog.Groups.length; i++)
649
+ {
650
+ let tmpGroup = pCatalog.Groups[i];
651
+ let tmpGroupEntry = {
652
+ Name: tmpGroup.Name,
653
+ Key: tmpGroup.Key,
654
+ Description: tmpGroup.Description,
655
+ Modules: []
656
+ };
657
+
658
+ for (let j = 0; j < tmpGroup.Modules.length; j++)
659
+ {
660
+ let tmpModule = tmpGroup.Modules[j];
661
+ tmpGroupEntry.Modules.push({
662
+ Name: tmpModule.Name,
663
+ HasDocs: tmpModule.HasDocs,
664
+ Group: tmpGroup.Key,
665
+ Route: '#/doc/' + tmpGroup.Key + '/' + tmpModule.Name
666
+ });
667
+ }
668
+
669
+ tmpSidebarGroups.push(tmpGroupEntry);
670
+ }
671
+
672
+ this.pict.AppData.Docuserve.SidebarGroups = tmpSidebarGroups;
673
+ }
674
+
675
+ /**
676
+ * Resolve a document URL from group/module/path to a fetchable URL.
677
+ *
678
+ * @param {string} pGroup - The group key (e.g. 'fable')
679
+ * @param {string} pModule - The module name (e.g. 'fable')
680
+ * @param {string} pPath - The document path within the module docs (e.g. 'README.md')
681
+ * @returns {string} The resolved URL
682
+ */
683
+ resolveDocumentURL(pGroup, pModule, pPath)
684
+ {
685
+ if (!this._Catalog)
686
+ {
687
+ return null;
688
+ }
689
+
690
+ let tmpOrg = this._Catalog.GitHubOrg || 'stevenvelozo';
691
+ let tmpDefaultBranch = this._Catalog.DefaultBranch || 'master';
692
+
693
+ // Find the module in the catalog
694
+ for (let i = 0; i < this._Catalog.Groups.length; i++)
695
+ {
696
+ let tmpGroup = this._Catalog.Groups[i];
697
+ if (tmpGroup.Key !== pGroup)
698
+ {
699
+ continue;
700
+ }
701
+
702
+ for (let j = 0; j < tmpGroup.Modules.length; j++)
703
+ {
704
+ let tmpModule = tmpGroup.Modules[j];
705
+ if (tmpModule.Name !== pModule)
706
+ {
707
+ continue;
708
+ }
709
+
710
+ let tmpBranch = tmpModule.Branch || tmpDefaultBranch;
711
+ let tmpDocPath = pPath || 'README.md';
712
+ return 'https://raw.githubusercontent.com/' + tmpOrg + '/' + tmpModule.Repo + '/' + tmpBranch + '/docs/' + tmpDocPath;
713
+ }
714
+ }
715
+
716
+ return null;
717
+ }
718
+
719
+ /**
720
+ * Get the module-specific sidebar entries for a given group/module.
721
+ *
722
+ * @param {string} pGroup - The group key
723
+ * @param {string} pModule - The module name
724
+ * @returns {Array|null} The sidebar entries or null
725
+ */
726
+ getModuleSidebar(pGroup, pModule)
727
+ {
728
+ if (!this._Catalog)
729
+ {
730
+ return null;
731
+ }
732
+
733
+ for (let i = 0; i < this._Catalog.Groups.length; i++)
734
+ {
735
+ let tmpGroup = this._Catalog.Groups[i];
736
+ if (tmpGroup.Key !== pGroup)
737
+ {
738
+ continue;
739
+ }
740
+
741
+ for (let j = 0; j < tmpGroup.Modules.length; j++)
742
+ {
743
+ let tmpModule = tmpGroup.Modules[j];
744
+ if (tmpModule.Name !== pModule)
745
+ {
746
+ continue;
747
+ }
748
+
749
+ return tmpModule.Sidebar || null;
750
+ }
751
+ }
752
+
753
+ return null;
754
+ }
755
+
756
+ /**
757
+ * Fetch a markdown document and convert it to HTML.
758
+ *
759
+ * @param {string} pURL - The URL to fetch
760
+ * @param {Function} fCallback - Callback receiving (error, htmlContent)
761
+ */
762
+ fetchDocument(pURL, fCallback)
763
+ {
764
+ let tmpCallback = (typeof(fCallback) === 'function') ? fCallback : () => {};
765
+
766
+ if (!pURL)
767
+ {
768
+ return tmpCallback('No URL provided', '');
769
+ }
770
+
771
+ // Check cache
772
+ if (this._ContentCache[pURL])
773
+ {
774
+ return tmpCallback(null, this._ContentCache[pURL]);
775
+ }
776
+
777
+ fetch(pURL)
778
+ .then((pResponse) =>
779
+ {
780
+ if (!pResponse.ok)
781
+ {
782
+ return null;
783
+ }
784
+ return pResponse.text();
785
+ })
786
+ .then((pMarkdown) =>
787
+ {
788
+ if (!pMarkdown)
789
+ {
790
+ return tmpCallback('Document not found', this.getErrorPageHTML(pURL));
791
+ }
792
+
793
+ let tmpHTML = this.parseMarkdown(pMarkdown);
794
+ this._ContentCache[pURL] = tmpHTML;
795
+ return tmpCallback(null, tmpHTML);
796
+ })
797
+ .catch((pError) =>
798
+ {
799
+ this.log.warn(`Docuserve: Error fetching document [${pURL}]: ${pError}`);
800
+ return tmpCallback(pError, this.getErrorPageHTML(pURL));
801
+ });
802
+ }
803
+
804
+ /**
805
+ * Fetch a local document relative to the docs folder.
806
+ *
807
+ * @param {string} pPath - The relative path (e.g. 'architecture.md')
808
+ * @param {Function} fCallback - Callback receiving (error, htmlContent)
809
+ */
810
+ fetchLocalDocument(pPath, fCallback)
811
+ {
812
+ let tmpDocsBase = this.pict.AppData.Docuserve.DocsBaseURL || '';
813
+ let tmpURL = tmpDocsBase + pPath;
814
+ this.fetchDocument(tmpURL, fCallback);
815
+ }
816
+
817
+ /**
818
+ * Parse a markdown string into HTML.
819
+ *
820
+ * This is a basic markdown parser that handles the most common constructs:
821
+ * headings, paragraphs, code blocks, inline code, links, bold, italic,
822
+ * lists, blockquotes, and horizontal rules.
823
+ *
824
+ * @param {string} pMarkdown - The raw markdown text
825
+ * @returns {string} The parsed HTML
826
+ */
827
+ parseMarkdown(pMarkdown)
828
+ {
829
+ if (!pMarkdown)
830
+ {
831
+ return '';
832
+ }
833
+
834
+ let tmpLines = pMarkdown.split('\n');
835
+ let tmpHTML = [];
836
+ let tmpInCodeBlock = false;
837
+ let tmpCodeLang = '';
838
+ let tmpCodeLines = [];
839
+ let tmpInList = false;
840
+ let tmpListType = '';
841
+ let tmpInBlockquote = false;
842
+ let tmpBlockquoteLines = [];
843
+
844
+ for (let i = 0; i < tmpLines.length; i++)
845
+ {
846
+ let tmpLine = tmpLines[i];
847
+
848
+ // Code blocks (fenced)
849
+ if (tmpLine.match(/^```/))
850
+ {
851
+ if (tmpInCodeBlock)
852
+ {
853
+ // End code block
854
+ tmpHTML.push('<pre><code class="language-' + this.escapeHTML(tmpCodeLang) + '">' + this.escapeHTML(tmpCodeLines.join('\n')) + '</code></pre>');
855
+ tmpInCodeBlock = false;
856
+ tmpCodeLang = '';
857
+ tmpCodeLines = [];
858
+ }
859
+ else
860
+ {
861
+ // Close any open list or blockquote
862
+ if (tmpInList)
863
+ {
864
+ tmpHTML.push(tmpListType === 'ul' ? '</ul>' : '</ol>');
865
+ tmpInList = false;
866
+ }
867
+ if (tmpInBlockquote)
868
+ {
869
+ tmpHTML.push('<blockquote>' + this.parseMarkdown(tmpBlockquoteLines.join('\n')) + '</blockquote>');
870
+ tmpInBlockquote = false;
871
+ tmpBlockquoteLines = [];
872
+ }
873
+ // Start code block
874
+ tmpCodeLang = tmpLine.replace(/^```/, '').trim();
875
+ tmpInCodeBlock = true;
876
+ }
877
+ continue;
878
+ }
879
+
880
+ if (tmpInCodeBlock)
881
+ {
882
+ tmpCodeLines.push(tmpLine);
883
+ continue;
884
+ }
885
+
886
+ // Blockquotes
887
+ if (tmpLine.match(/^>\s?/))
888
+ {
889
+ if (!tmpInBlockquote)
890
+ {
891
+ // Close any open list
892
+ if (tmpInList)
893
+ {
894
+ tmpHTML.push(tmpListType === 'ul' ? '</ul>' : '</ol>');
895
+ tmpInList = false;
896
+ }
897
+ tmpInBlockquote = true;
898
+ tmpBlockquoteLines = [];
899
+ }
900
+ tmpBlockquoteLines.push(tmpLine.replace(/^>\s?/, ''));
901
+ continue;
902
+ }
903
+ else if (tmpInBlockquote)
904
+ {
905
+ tmpHTML.push('<blockquote>' + this.parseMarkdown(tmpBlockquoteLines.join('\n')) + '</blockquote>');
906
+ tmpInBlockquote = false;
907
+ tmpBlockquoteLines = [];
908
+ }
909
+
910
+ // Horizontal rule
911
+ if (tmpLine.match(/^(-{3,}|\*{3,}|_{3,})\s*$/))
912
+ {
913
+ if (tmpInList)
914
+ {
915
+ tmpHTML.push(tmpListType === 'ul' ? '</ul>' : '</ol>');
916
+ tmpInList = false;
917
+ }
918
+ tmpHTML.push('<hr>');
919
+ continue;
920
+ }
921
+
922
+ // Headings
923
+ let tmpHeadingMatch = tmpLine.match(/^(#{1,6})\s+(.+)/);
924
+ if (tmpHeadingMatch)
925
+ {
926
+ if (tmpInList)
927
+ {
928
+ tmpHTML.push(tmpListType === 'ul' ? '</ul>' : '</ol>');
929
+ tmpInList = false;
930
+ }
931
+ let tmpLevel = tmpHeadingMatch[1].length;
932
+ let tmpText = this.parseInline(tmpHeadingMatch[2]);
933
+ let tmpID = tmpHeadingMatch[2].toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-');
934
+ tmpHTML.push('<h' + tmpLevel + ' id="' + tmpID + '">' + tmpText + '</h' + tmpLevel + '>');
935
+ continue;
936
+ }
937
+
938
+ // Unordered list items
939
+ let tmpULMatch = tmpLine.match(/^(\s*)[-*+]\s+(.*)/);
940
+ if (tmpULMatch)
941
+ {
942
+ if (!tmpInList || tmpListType !== 'ul')
943
+ {
944
+ if (tmpInList)
945
+ {
946
+ tmpHTML.push(tmpListType === 'ul' ? '</ul>' : '</ol>');
947
+ }
948
+ tmpHTML.push('<ul>');
949
+ tmpInList = true;
950
+ tmpListType = 'ul';
951
+ }
952
+ tmpHTML.push('<li>' + this.parseInline(tmpULMatch[2]) + '</li>');
953
+ continue;
954
+ }
955
+
956
+ // Ordered list items
957
+ let tmpOLMatch = tmpLine.match(/^(\s*)\d+\.\s+(.*)/);
958
+ if (tmpOLMatch)
959
+ {
960
+ if (!tmpInList || tmpListType !== 'ol')
961
+ {
962
+ if (tmpInList)
963
+ {
964
+ tmpHTML.push(tmpListType === 'ul' ? '</ul>' : '</ol>');
965
+ }
966
+ tmpHTML.push('<ol>');
967
+ tmpInList = true;
968
+ tmpListType = 'ol';
969
+ }
970
+ tmpHTML.push('<li>' + this.parseInline(tmpOLMatch[2]) + '</li>');
971
+ continue;
972
+ }
973
+
974
+ // Close list if we've left list items
975
+ if (tmpInList && tmpLine.trim() !== '')
976
+ {
977
+ tmpHTML.push(tmpListType === 'ul' ? '</ul>' : '</ol>');
978
+ tmpInList = false;
979
+ }
980
+
981
+ // Empty line
982
+ if (tmpLine.trim() === '')
983
+ {
984
+ continue;
985
+ }
986
+
987
+ // Table detection
988
+ if (tmpLine.match(/^\|/) && i + 1 < tmpLines.length && tmpLines[i + 1].match(/^\|[\s-:|]+\|/))
989
+ {
990
+ // Close any open list
991
+ if (tmpInList)
992
+ {
993
+ tmpHTML.push(tmpListType === 'ul' ? '</ul>' : '</ol>');
994
+ tmpInList = false;
995
+ }
996
+
997
+ let tmpTableHTML = '<table>';
998
+
999
+ // Header row
1000
+ let tmpHeaders = tmpLine.split('|').filter((pCell) => { return pCell.trim() !== ''; });
1001
+ tmpTableHTML += '<thead><tr>';
1002
+ for (let h = 0; h < tmpHeaders.length; h++)
1003
+ {
1004
+ tmpTableHTML += '<th>' + this.parseInline(tmpHeaders[h].trim()) + '</th>';
1005
+ }
1006
+ tmpTableHTML += '</tr></thead>';
1007
+
1008
+ // Skip separator row
1009
+ i++;
1010
+
1011
+ // Body rows
1012
+ tmpTableHTML += '<tbody>';
1013
+ while (i + 1 < tmpLines.length && tmpLines[i + 1].match(/^\|/))
1014
+ {
1015
+ i++;
1016
+ let tmpCells = tmpLines[i].split('|').filter((pCell) => { return pCell.trim() !== ''; });
1017
+ tmpTableHTML += '<tr>';
1018
+ for (let c = 0; c < tmpCells.length; c++)
1019
+ {
1020
+ tmpTableHTML += '<td>' + this.parseInline(tmpCells[c].trim()) + '</td>';
1021
+ }
1022
+ tmpTableHTML += '</tr>';
1023
+ }
1024
+ tmpTableHTML += '</tbody></table>';
1025
+ tmpHTML.push(tmpTableHTML);
1026
+ continue;
1027
+ }
1028
+
1029
+ // Regular paragraph
1030
+ tmpHTML.push('<p>' + this.parseInline(tmpLine) + '</p>');
1031
+ }
1032
+
1033
+ // Close any trailing open elements
1034
+ if (tmpInList)
1035
+ {
1036
+ tmpHTML.push(tmpListType === 'ul' ? '</ul>' : '</ol>');
1037
+ }
1038
+ if (tmpInBlockquote)
1039
+ {
1040
+ tmpHTML.push('<blockquote>' + this.parseMarkdown(tmpBlockquoteLines.join('\n')) + '</blockquote>');
1041
+ }
1042
+ if (tmpInCodeBlock)
1043
+ {
1044
+ tmpHTML.push('<pre><code>' + this.escapeHTML(tmpCodeLines.join('\n')) + '</code></pre>');
1045
+ }
1046
+
1047
+ return tmpHTML.join('\n');
1048
+ }
1049
+
1050
+ /**
1051
+ * Parse inline markdown elements (bold, italic, code, links, images).
1052
+ *
1053
+ * @param {string} pText - The text to parse
1054
+ * @returns {string} HTML with inline elements
1055
+ */
1056
+ parseInline(pText)
1057
+ {
1058
+ if (!pText)
1059
+ {
1060
+ return '';
1061
+ }
1062
+
1063
+ let tmpResult = pText;
1064
+
1065
+ // Inline code (backticks) - handle first to avoid interfering with other patterns
1066
+ tmpResult = tmpResult.replace(/`([^`]+)`/g, '<code>$1</code>');
1067
+
1068
+ // Images
1069
+ tmpResult = tmpResult.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
1070
+
1071
+ // Links
1072
+ tmpResult = tmpResult.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (pMatch, pLinkText, pHref) =>
1073
+ {
1074
+ // Convert internal doc links to hash routes
1075
+ if (pHref.match(/^\//) || pHref.match(/^[^:]+\.md/))
1076
+ {
1077
+ let tmpRoute = this.convertDocLink(pHref);
1078
+ return '<a href="' + tmpRoute + '">' + pLinkText + '</a>';
1079
+ }
1080
+ return '<a href="' + pHref + '" target="_blank" rel="noopener">' + pLinkText + '</a>';
1081
+ });
1082
+
1083
+ // Bold
1084
+ tmpResult = tmpResult.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
1085
+ tmpResult = tmpResult.replace(/__([^_]+)__/g, '<strong>$1</strong>');
1086
+
1087
+ // Italic
1088
+ tmpResult = tmpResult.replace(/\*([^*]+)\*/g, '<em>$1</em>');
1089
+ tmpResult = tmpResult.replace(/_([^_]+)_/g, '<em>$1</em>');
1090
+
1091
+ return tmpResult;
1092
+ }
1093
+
1094
+ /**
1095
+ * Convert a docsify-style internal link to a hash route for docuserve.
1096
+ *
1097
+ * @param {string} pHref - The original link href
1098
+ * @returns {string} The converted hash route
1099
+ */
1100
+ convertDocLink(pHref)
1101
+ {
1102
+ // Remove leading slash
1103
+ let tmpPath = pHref.replace(/^\//, '');
1104
+
1105
+ // If it looks like a module path (group/module/), convert to our route format
1106
+ let tmpParts = tmpPath.split('/');
1107
+ if (tmpParts.length >= 2)
1108
+ {
1109
+ // Check if first segment matches a known group key
1110
+ let tmpGroupKeys = ['fable', 'meadow', 'orator', 'pict', 'utility'];
1111
+ if (tmpGroupKeys.indexOf(tmpParts[0]) >= 0)
1112
+ {
1113
+ return '#/doc/' + tmpPath;
1114
+ }
1115
+ }
1116
+
1117
+ // Local doc page
1118
+ if (tmpPath.match(/\.md$/))
1119
+ {
1120
+ let tmpPageKey = tmpPath.replace(/\.md$/, '');
1121
+ return '#/page/' + tmpPageKey;
1122
+ }
1123
+
1124
+ return '#/page/' + tmpPath;
1125
+ }
1126
+
1127
+ /**
1128
+ * Escape HTML special characters.
1129
+ *
1130
+ * @param {string} pText - The text to escape
1131
+ * @returns {string} The escaped text
1132
+ */
1133
+ escapeHTML(pText)
1134
+ {
1135
+ if (!pText)
1136
+ {
1137
+ return '';
1138
+ }
1139
+ return pText
1140
+ .replace(/&/g, '&amp;')
1141
+ .replace(/</g, '&lt;')
1142
+ .replace(/>/g, '&gt;')
1143
+ .replace(/"/g, '&quot;')
1144
+ .replace(/'/g, '&#39;');
1145
+ }
1146
+ }
1147
+
1148
+ module.exports = DocuserveDocumentationProvider;
1149
+
1150
+ module.exports.default_configuration =
1151
+ {
1152
+ ProviderIdentifier: "Docuserve-Documentation",
1153
+
1154
+ AutoInitialize: true,
1155
+ AutoInitializeOrdinal: 0
1156
+ };