pict-docuserve 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pict-docuserve",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "Pict Documentation Server - A single-page documentation viewer built on Pict",
5
5
  "main": "source/Pict-Application-Docuserve.js",
6
6
  "bin": {
@@ -30,20 +30,20 @@
30
30
  "dependencies": {
31
31
  "fable-serviceproviderbase": "^3.0.19",
32
32
  "lunr": "^2.3.9",
33
- "pict": "^1.0.368",
33
+ "pict": "^1.0.372",
34
34
  "pict-application": "^1.0.34",
35
35
  "pict-provider": "^1.0.13",
36
36
  "pict-section-code": "^1.0.11",
37
- "pict-section-content": "^1.0.2",
37
+ "pict-section-content": "^1.0.4",
38
38
  "pict-section-histogram": "^1.0.1",
39
39
  "pict-section-modal": "^1.1.1",
40
- "pict-section-theme": "^1.0.2",
40
+ "pict-section-theme": "^1.0.5",
41
41
  "pict-service-commandlineutility": "^1.0.19",
42
42
  "pict-view": "^1.0.68"
43
43
  },
44
44
  "devDependencies": {
45
- "pict-docuserve": "^1.3.1",
46
- "quackage": "^1.2.3"
45
+ "pict-docuserve": "^1.3.3",
46
+ "quackage": "^1.2.4"
47
47
  },
48
48
  "copyFilesSettings": {
49
49
  "whenFileExists": "overwrite"
@@ -573,10 +573,23 @@ class DocuserveApplication extends libPictApplication
573
573
 
574
574
  tmpDocProvider.fetchLocalDocument(tmpPath, (pError, pHTML) =>
575
575
  {
576
+ // On a miss the path may be a directory reference — retry
577
+ // <path>/README.md so directory-style links resolve.
578
+ if (pError && !tmpPath.match(/(^|\/)README\.md$/i))
579
+ {
580
+ let tmpReadmePath = tmpPath.replace(/\.md$/i, '') + '/README.md';
581
+ tmpDocProvider.fetchLocalDocument(tmpReadmePath, (pReadmeError, pReadmeHTML) =>
582
+ {
583
+ // Show the README on success, else the original
584
+ // not-found page (it names the path the user asked for).
585
+ tmpContentView.displayContent(pReadmeError ? pHTML : pReadmeHTML);
586
+ }, '', '', tmpReadmePath);
587
+ return;
588
+ }
576
589
  // fetchDocument always provides displayable HTML in pHTML,
577
590
  // even on error, so we can use it directly.
578
591
  tmpContentView.displayContent(pHTML);
579
- });
592
+ }, '', '', tmpPath);
580
593
  }
581
594
 
582
595
  /**
@@ -11,7 +11,9 @@ let _PictCLIProgram = new libCLIProgram(
11
11
  [
12
12
  require('./commands/Docuserve-Command-Serve.js'),
13
13
  require('./commands/Docuserve-Command-Inject.js'),
14
- require('./commands/Docuserve-Command-PrepareLocal.js')
14
+ require('./commands/Docuserve-Command-PrepareLocal.js'),
15
+ require('./commands/Docuserve-Command-StageExamples.js'),
16
+ require('./commands/Docuserve-Command-CheckLinks.js')
15
17
  ]);
16
18
 
17
19
  module.exports = _PictCLIProgram;
@@ -0,0 +1,428 @@
1
+ const libCommandLineCommand = require('pict-service-commandlineutility').ServiceCommandLineCommand;
2
+
3
+ const libFS = require('fs');
4
+ const libPath = require('path');
5
+
6
+ /**
7
+ * Recursively collect every .md file beneath a folder.
8
+ *
9
+ * @param {string} pRoot - The folder to walk.
10
+ * @returns {string[]} Absolute paths of every markdown file found, sorted.
11
+ */
12
+ function collectMarkdownFiles(pRoot)
13
+ {
14
+ let tmpResults = [];
15
+ let tmpStack = [pRoot];
16
+
17
+ while (tmpStack.length > 0)
18
+ {
19
+ let tmpDir = tmpStack.pop();
20
+ let tmpEntries;
21
+ try
22
+ {
23
+ tmpEntries = libFS.readdirSync(tmpDir, { withFileTypes: true });
24
+ }
25
+ catch (pError)
26
+ {
27
+ continue;
28
+ }
29
+
30
+ for (let i = 0; i < tmpEntries.length; i++)
31
+ {
32
+ let tmpEntry = tmpEntries[i];
33
+ let tmpFullPath = libPath.join(tmpDir, tmpEntry.name);
34
+ if (tmpEntry.isDirectory())
35
+ {
36
+ tmpStack.push(tmpFullPath);
37
+ }
38
+ else if (tmpEntry.isFile() && /\.md$/i.test(tmpEntry.name))
39
+ {
40
+ tmpResults.push(tmpFullPath);
41
+ }
42
+ }
43
+ }
44
+
45
+ tmpResults.sort();
46
+ return tmpResults;
47
+ }
48
+
49
+ /**
50
+ * True when a file exists on disk and is a regular file.
51
+ */
52
+ function fileExists(pPath)
53
+ {
54
+ try
55
+ {
56
+ return libFS.statSync(pPath).isFile();
57
+ }
58
+ catch (pError)
59
+ {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * True when a path exists on disk and is a directory.
66
+ */
67
+ function directoryExists(pPath)
68
+ {
69
+ try
70
+ {
71
+ return libFS.statSync(pPath).isDirectory();
72
+ }
73
+ catch (pError)
74
+ {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Resolve a relative href against a base directory, collapsing "." and ".."
81
+ * segments. ".." is clamped at the docs root. Mirrors docuserve module-mode
82
+ * resolution (Pict-Provider-Docuserve-Documentation._resolveRelativeDocPath)
83
+ * so the checker resolves .md links exactly the way the viewer routes them.
84
+ *
85
+ * @param {string} pBaseDir - The directory the href is relative to (POSIX, docs-root-relative).
86
+ * @param {string} pHref - The href to resolve.
87
+ * @returns {string} The resolved docs-root-relative path (no leading slash).
88
+ */
89
+ function resolveRelativeDocPath(pBaseDir, pHref)
90
+ {
91
+ let tmpSegments = [];
92
+ let tmpCombined = (pBaseDir ? pBaseDir + '/' : '') + String(pHref || '');
93
+ let tmpParts = tmpCombined.split('/');
94
+ for (let i = 0; i < tmpParts.length; i++)
95
+ {
96
+ let tmpPart = tmpParts[i];
97
+ if ((tmpPart === '') || (tmpPart === '.'))
98
+ {
99
+ continue;
100
+ }
101
+ if (tmpPart === '..')
102
+ {
103
+ if (tmpSegments.length > 0)
104
+ {
105
+ tmpSegments.pop();
106
+ }
107
+ continue;
108
+ }
109
+ tmpSegments.push(tmpPart);
110
+ }
111
+ return tmpSegments.join('/');
112
+ }
113
+
114
+ /**
115
+ * True when a link target points outside the docs site and must never be
116
+ * resolved against the filesystem: an absolute or protocol-relative URL, a
117
+ * scheme such as mailto:/data:/tel:, or a pure #anchor.
118
+ */
119
+ function isExternalOrAnchor(pTarget)
120
+ {
121
+ if (!pTarget)
122
+ {
123
+ return true;
124
+ }
125
+ if (pTarget.charAt(0) === '#')
126
+ {
127
+ return true;
128
+ }
129
+ if (pTarget.indexOf('//') === 0)
130
+ {
131
+ return true;
132
+ }
133
+ if (/^[a-z][a-z0-9+.-]*:/i.test(pTarget))
134
+ {
135
+ return true;
136
+ }
137
+ return false;
138
+ }
139
+
140
+ /**
141
+ * Normalize a raw link target extracted from markdown: drop a surrounding
142
+ * <...>, drop a link title (` "Title"`), drop a #fragment / ?query, and
143
+ * decode the %5F underscore escape that docuserve's mdSafeUrl introduces.
144
+ *
145
+ * @param {string} pRawTarget - The target captured between the link parens.
146
+ * @returns {string} The cleaned target, ready for filesystem resolution.
147
+ */
148
+ function cleanTarget(pRawTarget)
149
+ {
150
+ let tmpTarget = String(pRawTarget || '').trim();
151
+
152
+ // Drop an angle-bracket wrapper: [text](<url>) -> url
153
+ tmpTarget = tmpTarget.replace(/^<+/, '').replace(/>+$/, '');
154
+
155
+ // Drop a link title: (url "Title") -> url
156
+ let tmpSpaceIndex = tmpTarget.search(/\s/);
157
+ if (tmpSpaceIndex >= 0)
158
+ {
159
+ tmpTarget = tmpTarget.substring(0, tmpSpaceIndex);
160
+ }
161
+
162
+ // Drop a #fragment and/or ?query
163
+ tmpTarget = tmpTarget.split('#')[0].split('?')[0];
164
+
165
+ // docuserve's mdSafeUrl percent-encodes underscores; the HTTP layer
166
+ // decodes them back on fetch, so resolve against the decoded path.
167
+ tmpTarget = tmpTarget.replace(/%5[fF]/g, '_');
168
+
169
+ return tmpTarget.trim();
170
+ }
171
+
172
+ class DocuserveCommandCheckLinks extends libCommandLineCommand
173
+ {
174
+ constructor(pFable, pManifest, pServiceHash)
175
+ {
176
+ super(pFable, pManifest, pServiceHash);
177
+
178
+ this.options.CommandKeyword = 'check-links';
179
+ this.options.Description = 'Scan a docs folder for unresolvable local links and image/media references.';
180
+
181
+ this.options.CommandArguments.push({ Name: '[docs-path]', Description: 'Documentation folder to check (defaults to ./docs/).' });
182
+
183
+ this.addCommand();
184
+ }
185
+
186
+ onRun()
187
+ {
188
+ let tmpDocsRoot = libPath.resolve(this.ArgumentString || './docs/');
189
+
190
+ if (!directoryExists(tmpDocsRoot))
191
+ {
192
+ this.log.error(`Docs folder not found at [${tmpDocsRoot}].`);
193
+ process.exit(1);
194
+ return;
195
+ }
196
+
197
+ this.log.info(`Checking local links + media references under [${tmpDocsRoot}]...`);
198
+
199
+ let tmpFiles = collectMarkdownFiles(tmpDocsRoot);
200
+ if (tmpFiles.length < 1)
201
+ {
202
+ this.log.info(`No markdown files found; nothing to check.`);
203
+ return;
204
+ }
205
+
206
+ let tmpLinkCount = 0;
207
+ let tmpImageCount = 0;
208
+ let tmpBroken = [];
209
+
210
+ for (let f = 0; f < tmpFiles.length; f++)
211
+ {
212
+ let tmpFilePath = tmpFiles[f];
213
+ let tmpContent;
214
+ try
215
+ {
216
+ tmpContent = libFS.readFileSync(tmpFilePath, 'utf8');
217
+ }
218
+ catch (pError)
219
+ {
220
+ this.log.warn(`Could not read [${tmpFilePath}]: ${pError.message}`);
221
+ continue;
222
+ }
223
+
224
+ let tmpReferences = this._extractReferences(tmpContent);
225
+ for (let r = 0; r < tmpReferences.length; r++)
226
+ {
227
+ let tmpReference = tmpReferences[r];
228
+
229
+ if (tmpReference.IsImage)
230
+ {
231
+ tmpImageCount++;
232
+ }
233
+ else
234
+ {
235
+ tmpLinkCount++;
236
+ }
237
+
238
+ let tmpTarget = cleanTarget(tmpReference.Target);
239
+ if (isExternalOrAnchor(tmpTarget) || (tmpTarget === ''))
240
+ {
241
+ continue;
242
+ }
243
+
244
+ let tmpResolution = tmpReference.IsImage
245
+ ? this._resolveImageReference(tmpTarget, tmpFilePath, tmpDocsRoot)
246
+ : this._resolveDocumentLink(tmpTarget, tmpFilePath, tmpDocsRoot);
247
+
248
+ if (!tmpResolution.OK)
249
+ {
250
+ tmpBroken.push(
251
+ {
252
+ File: libPath.relative(tmpDocsRoot, tmpFilePath),
253
+ Line: tmpReference.Line,
254
+ Snippet: tmpReference.Snippet,
255
+ Reason: tmpResolution.Reason
256
+ });
257
+ }
258
+ }
259
+ }
260
+
261
+ this.log.info(`Checked ${tmpLinkCount} link(s) + ${tmpImageCount} image reference(s) across ${tmpFiles.length} file(s).`);
262
+
263
+ if (tmpBroken.length > 0)
264
+ {
265
+ this.log.error(`Found ${tmpBroken.length} broken reference(s):`);
266
+ for (let i = 0; i < tmpBroken.length; i++)
267
+ {
268
+ let tmpEntry = tmpBroken[i];
269
+ this.log.error(` ${tmpEntry.File}:${tmpEntry.Line} ${tmpEntry.Snippet} -> ${tmpEntry.Reason}`);
270
+ }
271
+ process.exit(1);
272
+ return;
273
+ }
274
+
275
+ this.log.info(`All local links + media references resolve.`);
276
+ }
277
+
278
+ /**
279
+ * Extract every inline link and image reference from a markdown document.
280
+ *
281
+ * Fenced code blocks and inline code spans are skipped so that example
282
+ * code containing `[text](target)` is never treated as a real link.
283
+ *
284
+ * @param {string} pContent - The raw markdown text.
285
+ * @returns {object[]} { Target, IsImage, Line, Snippet } per reference.
286
+ */
287
+ _extractReferences(pContent)
288
+ {
289
+ let tmpReferences = [];
290
+ let tmpLines = String(pContent).split(/\r?\n/);
291
+ let tmpInFence = false;
292
+
293
+ for (let i = 0; i < tmpLines.length; i++)
294
+ {
295
+ let tmpLine = tmpLines[i];
296
+
297
+ // Toggle on fenced code block boundaries (backtick fences, to
298
+ // match the content provider's markdown parser).
299
+ if (/^\s*`{3,}/.test(tmpLine))
300
+ {
301
+ tmpInFence = !tmpInFence;
302
+ continue;
303
+ }
304
+ if (tmpInFence)
305
+ {
306
+ continue;
307
+ }
308
+
309
+ // Drop inline code spans so `[x](y)` in backticks is not a link.
310
+ let tmpScrubbed = tmpLine.replace(/`[^`]*`/g, ' ');
311
+
312
+ // Images: ![alt](target)
313
+ let tmpImageRegEx = /!\[[^\]]*\]\(([^)]+)\)/g;
314
+ let tmpImageMatch;
315
+ while ((tmpImageMatch = tmpImageRegEx.exec(tmpScrubbed)) !== null)
316
+ {
317
+ tmpReferences.push(
318
+ {
319
+ Target: tmpImageMatch[1],
320
+ IsImage: true,
321
+ Line: i + 1,
322
+ Snippet: tmpImageMatch[0]
323
+ });
324
+ }
325
+
326
+ // Links: [text](target) — the [^!] guard excludes image links.
327
+ let tmpLinkRegEx = /(^|[^!])(\[[^\]]*\]\(([^)]+)\))/g;
328
+ let tmpLinkMatch;
329
+ while ((tmpLinkMatch = tmpLinkRegEx.exec(tmpScrubbed)) !== null)
330
+ {
331
+ tmpReferences.push(
332
+ {
333
+ Target: tmpLinkMatch[3],
334
+ IsImage: false,
335
+ Line: i + 1,
336
+ Snippet: tmpLinkMatch[2]
337
+ });
338
+ }
339
+ }
340
+
341
+ return tmpReferences;
342
+ }
343
+
344
+ /**
345
+ * Resolve a documentation link the way docuserve module-mode renders it,
346
+ * and report whether the target exists on disk.
347
+ *
348
+ * Every relative link — a .md page, a .html app, a directory, a media
349
+ * file — is resolved against the directory of the document that contains
350
+ * it, with ../ clamped at the docs root. A /-rooted link is resolved
351
+ * against the docs root.
352
+ *
353
+ * @param {string} pTarget - The cleaned link target.
354
+ * @param {string} pCurrentFile - Absolute path of the file the link is in.
355
+ * @param {string} pDocsRoot - The absolute docs root folder.
356
+ * @returns {object} { OK, Reason }
357
+ */
358
+ _resolveDocumentLink(pTarget, pCurrentFile, pDocsRoot)
359
+ {
360
+ let tmpBaseDir = '';
361
+ let tmpHref = pTarget;
362
+ if (pTarget.charAt(0) === '/')
363
+ {
364
+ tmpHref = pTarget.replace(/^\/+/, '');
365
+ }
366
+ else
367
+ {
368
+ tmpBaseDir = libPath.relative(pDocsRoot, libPath.dirname(pCurrentFile)).split(libPath.sep).join('/');
369
+ }
370
+ let tmpResolved = libPath.join(pDocsRoot, resolveRelativeDocPath(tmpBaseDir, tmpHref));
371
+
372
+ if (fileExists(tmpResolved) || directoryExists(tmpResolved))
373
+ {
374
+ return { OK: true };
375
+ }
376
+
377
+ // An extensionless target may be a directory or an extensionless page
378
+ // route — docuserve's page route also tries <target>.md and
379
+ // <target>/README.md, so accept those too.
380
+ let tmpBareTarget = pTarget.replace(/[#?].*$/, '').replace(/\/+$/, '');
381
+ if (/\.md$/i.test(pTarget) || !/\.[a-z0-9]+$/i.test(tmpBareTarget))
382
+ {
383
+ if (fileExists(tmpResolved + '.md') || fileExists(libPath.join(tmpResolved, 'README.md')))
384
+ {
385
+ return { OK: true };
386
+ }
387
+ }
388
+
389
+ return { OK: false, Reason: `no file at ${libPath.relative(pDocsRoot, tmpResolved) || '.'}` };
390
+ }
391
+
392
+ /**
393
+ * Resolve an image/media reference the way docuserve's image resolver does
394
+ * — relative to the containing document's directory, or docs-root-relative
395
+ * when the target is /-rooted — and report whether it exists on disk.
396
+ *
397
+ * @param {string} pTarget - The cleaned image target.
398
+ * @param {string} pCurrentFile - Absolute path of the file the ref is in.
399
+ * @param {string} pDocsRoot - The absolute docs root folder.
400
+ * @returns {object} { OK, Reason }
401
+ */
402
+ _resolveImageReference(pTarget, pCurrentFile, pDocsRoot)
403
+ {
404
+ let tmpResolved;
405
+ if (pTarget.charAt(0) === '/')
406
+ {
407
+ tmpResolved = libPath.normalize(libPath.join(pDocsRoot, pTarget.replace(/^\/+/, '')));
408
+ }
409
+ else
410
+ {
411
+ tmpResolved = libPath.normalize(libPath.join(libPath.dirname(pCurrentFile), pTarget));
412
+ }
413
+
414
+ if ((tmpResolved !== pDocsRoot) && (tmpResolved.indexOf(pDocsRoot + libPath.sep) !== 0))
415
+ {
416
+ return { OK: false, Reason: 'resolves outside the docs root' };
417
+ }
418
+
419
+ if (fileExists(tmpResolved))
420
+ {
421
+ return { OK: true };
422
+ }
423
+
424
+ return { OK: false, Reason: `no file at ${libPath.relative(pDocsRoot, tmpResolved) || '.'}` };
425
+ }
426
+ }
427
+
428
+ module.exports = DocuserveCommandCheckLinks;