radiant-docs 0.1.34 → 0.1.37

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.
@@ -125,9 +125,18 @@ export type NavPage = {
125
125
  tag?: string;
126
126
  title?: string;
127
127
  };
128
+ export type NavOpenApiPageRef = {
129
+ source: string;
130
+ endpoint: string;
131
+ };
132
+ export type NavOpenApiPage = {
133
+ openapi: NavOpenApiPageRef;
134
+ title?: string;
135
+ tag?: string;
136
+ };
128
137
  export type NavGroup = {
129
138
  group: string;
130
- pages: (string | NavPage | NavGroup)[];
139
+ pages: (string | NavPage | NavGroup | NavOpenApiPage)[];
131
140
  icon?: string | null;
132
141
  expanded?: boolean; // need to add this logic
133
142
  tag?: string;
@@ -138,7 +147,7 @@ export type NavOpenApi = {
138
147
  exclude?: string[];
139
148
  };
140
149
  export type NavigationItem = {
141
- pages?: (string | NavPage | NavGroup)[];
150
+ pages?: (string | NavPage | NavGroup | NavOpenApiPage)[];
142
151
  menu?: NavMenu;
143
152
  openapi?: string | NavOpenApi;
144
153
  };
@@ -553,11 +562,53 @@ function parseEndpointString(
553
562
  return { method, path: normalizedPath };
554
563
  }
555
564
 
556
- function validateNavigationNode(
565
+ async function validateNavOpenApiPage(
566
+ navOpenApiPage: any,
567
+ currentPath: Path,
568
+ ): Promise<void> {
569
+ checkType(navOpenApiPage, "object", currentPath, "Open API page");
570
+
571
+ if (typeof navOpenApiPage.source !== "string") {
572
+ throwConfigError(
573
+ "Open API page must include a 'source' property that is a string.",
574
+ [...currentPath, "source"],
575
+ );
576
+ }
577
+
578
+ if (typeof navOpenApiPage.endpoint !== "string") {
579
+ throwConfigError(
580
+ "Open API page must include an 'endpoint' property that is a string in the format \"METHOD /path\".",
581
+ [...currentPath, "endpoint"],
582
+ );
583
+ }
584
+
585
+ const parsedEndpoint = parseEndpointString(navOpenApiPage.endpoint);
586
+ if (!parsedEndpoint) {
587
+ throwConfigError(
588
+ `Open API page endpoint must be in the format "METHOD /path". Found: ${navOpenApiPage.endpoint}`,
589
+ [...currentPath, "endpoint"],
590
+ );
591
+ }
592
+
593
+ await validateOpenApiFile(navOpenApiPage.source, [...currentPath, "source"]);
594
+
595
+ const openApiDoc = await loadOpenApiSpec(navOpenApiPage.source);
596
+ const availableEndpoints = extractAvailableEndpoints(openApiDoc);
597
+ const endpointKey = `${parsedEndpoint!.method} ${parsedEndpoint!.path}`;
598
+
599
+ if (!availableEndpoints.has(endpointKey)) {
600
+ throwConfigError(
601
+ `Open API page endpoint does not match any endpoint in the OpenAPI spec. Found: ${navOpenApiPage.endpoint}. Expected format: "METHOD /path".`,
602
+ [...currentPath, "endpoint"],
603
+ );
604
+ }
605
+ }
606
+
607
+ async function validateNavigationNode(
557
608
  item: any,
558
609
  currentPath: Path,
559
610
  groupDepth: number = 0,
560
- ): void {
611
+ ): Promise<void> {
561
612
  // A) Base Case: Simple string path
562
613
  if (typeof item === "string") {
563
614
  const normalizedPath = normalizeDocsPagePath(item, currentPath);
@@ -571,11 +622,12 @@ function validateNavigationNode(
571
622
  // Determine item type by key presence (Strict XOR enforcement)
572
623
  const isGroup = "group" in item;
573
624
  const isPage = "page" in item;
625
+ const isOpenApiPage = "openapi" in item;
574
626
 
575
- const typeCount = [isGroup, isPage].filter(Boolean).length;
627
+ const typeCount = [isGroup, isPage, isOpenApiPage].filter(Boolean).length;
576
628
  if (typeCount !== 1) {
577
629
  throwConfigError(
578
- "Object must contain exactly one key: 'page' or 'group'.",
630
+ "Object must contain exactly one key: 'page', 'group', or 'openapi'.",
579
631
  currentPath,
580
632
  );
581
633
  }
@@ -601,17 +653,17 @@ function validateNavigationNode(
601
653
  throwConfigError("Group must have a 'pages' array.", [...path, "pages"]);
602
654
  checkType(item.pages, "array", [...path, "pages"], "Group pages");
603
655
 
604
- item.pages.forEach((child: any, i: number) => {
656
+ for (const [i, child] of item.pages.entries()) {
605
657
  if (typeof child === "string") {
606
658
  const childPath = [...path, "pages", i];
607
659
  const normalizedPagePath = normalizeDocsPagePath(child, childPath);
608
660
  item.pages[i] = normalizedPagePath;
609
661
  validateFileExistence(normalizedPagePath, childPath);
610
- return;
662
+ continue;
611
663
  }
612
664
 
613
- validateNavigationNode(child, [...path, "pages", i], groupDepth + 1);
614
- });
665
+ await validateNavigationNode(child, [...path, "pages", i], groupDepth + 1);
666
+ }
615
667
  return;
616
668
  }
617
669
 
@@ -640,10 +692,47 @@ function validateNavigationNode(
640
692
  throwConfigError("Page items cannot have children.", [...path, "pages"]);
641
693
  return;
642
694
  }
695
+
696
+ if (isOpenApiPage) {
697
+ const path = [...currentPath];
698
+
699
+ if ("icon" in item) {
700
+ throwConfigError(
701
+ "Open API page items cannot have an 'icon'. Method badges are displayed automatically.",
702
+ [...path, "icon"],
703
+ );
704
+ }
705
+
706
+ await validateNavOpenApiPage(item.openapi, [...path, "openapi"]);
707
+ checkType(item.title, "string", [...path, "title"], "Open API page title");
708
+ checkType(item.tag, "string", [...path, "tag"], "Open API page tag");
709
+
710
+ if ("expanded" in item)
711
+ throwConfigError("Open API page items cannot have 'expanded'.", [
712
+ ...path,
713
+ "expanded",
714
+ ]);
715
+ if ("pages" in item)
716
+ throwConfigError("Open API page items cannot have children.", [
717
+ ...path,
718
+ "pages",
719
+ ]);
720
+ if ("group" in item)
721
+ throwConfigError("Open API page items cannot have 'group'.", [
722
+ ...path,
723
+ "group",
724
+ ]);
725
+ if ("page" in item)
726
+ throwConfigError("Open API page items cannot have 'page'.", [
727
+ ...path,
728
+ "page",
729
+ ]);
730
+ return;
731
+ }
643
732
  }
644
733
 
645
734
  function getFirstPagePathFromPageItems(
646
- items: (string | NavPage | NavGroup)[],
735
+ items: (string | NavPage | NavGroup | NavOpenApiPage)[],
647
736
  ): string | undefined {
648
737
  for (const item of items) {
649
738
  if (typeof item === "string") {
@@ -890,19 +979,18 @@ async function validateNavMenuItem(item: NavMenuItem, currentPath: Path) {
890
979
  [...currentPath, "submenu", "pages"],
891
980
  "Submenu pages",
892
981
  );
893
- (submenuValue as NavigationItem["pages"])?.forEach(
894
- (item: string | NavPage | NavGroup, i: number) => {
895
- const itemPath = [...currentPath, "submenu", "pages", i];
896
- if (typeof item === "string") {
897
- const normalizedPagePath = normalizeDocsPagePath(item, itemPath);
898
- (submenuValue as (string | NavPage | NavGroup)[])[i] =
899
- normalizedPagePath;
900
- validateFileExistence(normalizedPagePath, itemPath);
901
- } else {
902
- validateNavigationNode(item, itemPath);
903
- }
904
- },
905
- );
982
+ const pages = (submenuValue as NavigationItem["pages"]) ?? [];
983
+ for (const [i, item] of pages.entries()) {
984
+ const itemPath = [...currentPath, "submenu", "pages", i];
985
+ if (typeof item === "string") {
986
+ const normalizedPagePath = normalizeDocsPagePath(item, itemPath);
987
+ (submenuValue as (string | NavPage | NavGroup | NavOpenApiPage)[])[i] =
988
+ normalizedPagePath;
989
+ validateFileExistence(normalizedPagePath, itemPath);
990
+ } else {
991
+ await validateNavigationNode(item, itemPath);
992
+ }
993
+ }
906
994
  }
907
995
 
908
996
  // Validate openapi - can be string or NavOpenApi object
@@ -1326,16 +1414,16 @@ async function validateNavigation(navigation: DocsConfig["navigation"]) {
1326
1414
  );
1327
1415
 
1328
1416
  // Route to Recursive Structural Validation
1329
- navValue.forEach((item: NavigationItem, i: number) => {
1417
+ for (const [i, item] of navValue.entries()) {
1330
1418
  const itemPath = ["navigation", navKey, i] as Path;
1331
1419
  if (typeof item === "string") {
1332
1420
  const normalizedPagePath = normalizeDocsPagePath(item, itemPath);
1333
1421
  navValue[i] = normalizedPagePath;
1334
1422
  validateFileExistence(normalizedPagePath, itemPath);
1335
1423
  } else {
1336
- validateNavigationNode(item, itemPath);
1424
+ await validateNavigationNode(item, itemPath);
1337
1425
  }
1338
- });
1426
+ }
1339
1427
  }
1340
1428
  }
1341
1429
 
@@ -1,408 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- const CWD = process.cwd();
5
- const DIST_DIR = path.join(CWD, "dist");
6
- const LOCAL_ORIGIN = "https://radiant.invalid";
7
-
8
- const STATIC_EXTENSIONS = new Set([
9
- ".avif",
10
- ".css",
11
- ".eot",
12
- ".gif",
13
- ".ico",
14
- ".jpeg",
15
- ".jpg",
16
- ".js",
17
- ".json",
18
- ".mjs",
19
- ".mp4",
20
- ".otf",
21
- ".pdf",
22
- ".png",
23
- ".svg",
24
- ".ttf",
25
- ".txt",
26
- ".wasm",
27
- ".webmanifest",
28
- ".webp",
29
- ".woff",
30
- ".woff2",
31
- ".xml",
32
- ]);
33
-
34
- const STATIC_PATH_PREFIXES = ["/_astro/", "/_og/", "/pagefind/"];
35
-
36
- function normalizeHostUrl(input) {
37
- const withScheme = /^https?:\/\//i.test(input) ? input : `https://${input}`;
38
- return new URL(withScheme);
39
- }
40
-
41
- function normalizePrefix(input) {
42
- return input.replace(/^\/+/, "").replace(/\/+$/, "");
43
- }
44
-
45
- function findFilesByExtension(dir, extension, files = []) {
46
- if (!fs.existsSync(dir)) return files;
47
-
48
- const entries = fs.readdirSync(dir, { withFileTypes: true });
49
- for (const entry of entries) {
50
- const fullPath = path.join(dir, entry.name);
51
-
52
- if (entry.isDirectory()) {
53
- findFilesByExtension(fullPath, extension, files);
54
- continue;
55
- }
56
-
57
- if (entry.isFile() && entry.name.endsWith(extension)) {
58
- files.push(fullPath);
59
- }
60
- }
61
-
62
- return files;
63
- }
64
-
65
- function htmlBasePathname(filePath) {
66
- const relative = path.relative(DIST_DIR, filePath).replace(/\\/g, "/");
67
- if (relative === "index.html") return "/";
68
-
69
- if (relative.endsWith("/index.html")) {
70
- return `/${relative.slice(0, -"index.html".length)}`;
71
- }
72
-
73
- const slashIndex = relative.lastIndexOf("/");
74
- if (slashIndex === -1) return "/";
75
- return `/${relative.slice(0, slashIndex + 1)}`;
76
- }
77
-
78
- function decodeValue(value) {
79
- return value.trim().replace(/&amp;/g, "&");
80
- }
81
-
82
- function isSkippableUrl(value) {
83
- return (
84
- !value ||
85
- value.startsWith("data:") ||
86
- value.startsWith("blob:") ||
87
- value.startsWith("mailto:") ||
88
- value.startsWith("tel:") ||
89
- value.startsWith("javascript:") ||
90
- value.startsWith("//")
91
- );
92
- }
93
-
94
- function isStaticAssetPath(pathname) {
95
- const normalizedPath = pathname.toLowerCase();
96
- if (STATIC_PATH_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))) {
97
- return true;
98
- }
99
-
100
- const extension = path.extname(normalizedPath);
101
- return STATIC_EXTENSIONS.has(extension);
102
- }
103
-
104
- function withPrefix(pathname, prefix) {
105
- const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
106
- if (
107
- normalizedPathname === `/${prefix}` ||
108
- normalizedPathname.startsWith(`/${prefix}/`)
109
- ) {
110
- return normalizedPathname;
111
- }
112
-
113
- return `/${prefix}${normalizedPathname}`.replace(/\/+/g, "/");
114
- }
115
-
116
- function rewriteSingleUrl(value, filePath, hostUrl, prefix) {
117
- const decoded = decodeValue(value);
118
- if (isSkippableUrl(decoded)) {
119
- return { value, changed: false };
120
- }
121
-
122
- let parsed;
123
- try {
124
- parsed = new URL(decoded, `${LOCAL_ORIGIN}${htmlBasePathname(filePath)}`);
125
- } catch {
126
- return { value, changed: false };
127
- }
128
-
129
- if (parsed.origin === hostUrl.origin) {
130
- const prefixedPathname = withPrefix(parsed.pathname, prefix);
131
- if (prefixedPathname === parsed.pathname) {
132
- return { value, changed: false };
133
- }
134
-
135
- const updated = `${hostUrl.origin}${prefixedPathname}${parsed.search}${parsed.hash}`;
136
- return {
137
- value: updated.replace(/&/g, "&amp;"),
138
- changed: true,
139
- };
140
- }
141
-
142
- if (parsed.origin !== LOCAL_ORIGIN) {
143
- return { value, changed: false };
144
- }
145
-
146
- if (!isStaticAssetPath(parsed.pathname)) {
147
- return { value, changed: false };
148
- }
149
-
150
- const prefixedPathname = withPrefix(parsed.pathname, prefix);
151
- const updated = `${hostUrl.origin}${prefixedPathname}${parsed.search}${parsed.hash}`;
152
-
153
- return {
154
- value: updated.replace(/&/g, "&amp;"),
155
- changed: updated !== decoded,
156
- };
157
- }
158
-
159
- function rewriteSrcset(value, filePath, hostUrl, prefix) {
160
- const candidates = value.split(",");
161
- let changed = false;
162
-
163
- const rewritten = candidates.map((candidate) => {
164
- const trimmed = candidate.trim();
165
- if (!trimmed) return candidate;
166
-
167
- const whitespaceIndex = trimmed.search(/\s/);
168
- const urlPart =
169
- whitespaceIndex === -1 ? trimmed : trimmed.slice(0, whitespaceIndex);
170
- const descriptor = whitespaceIndex === -1 ? "" : trimmed.slice(whitespaceIndex);
171
-
172
- const rewrittenCandidate = rewriteSingleUrl(urlPart, filePath, hostUrl, prefix);
173
- if (rewrittenCandidate.changed) changed = true;
174
-
175
- return `${rewrittenCandidate.value}${descriptor}`;
176
- });
177
-
178
- return {
179
- value: changed ? rewritten.join(", ") : value,
180
- changed,
181
- };
182
- }
183
-
184
- function rewriteAttribute(html, filePath, tagName, attribute, hostUrl, prefix) {
185
- const pattern = new RegExp(
186
- `(<${tagName}\\b[^>]*\\b${attribute}\\s*=\\s*["'])([^"']*)(["'][^>]*>)`,
187
- "gi",
188
- );
189
-
190
- let changed = false;
191
-
192
- const rewritten = html.replace(pattern, (full, before, value, after) => {
193
- const result =
194
- attribute === "srcset"
195
- ? rewriteSrcset(value, filePath, hostUrl, prefix)
196
- : rewriteSingleUrl(value, filePath, hostUrl, prefix);
197
-
198
- if (!result.changed) return full;
199
- changed = true;
200
- return `${before}${result.value}${after}`;
201
- });
202
-
203
- return { html: rewritten, changed };
204
- }
205
-
206
- function rewriteMetaImageContent(html, propertyName, hostUrl, prefix) {
207
- const patterns = [
208
- new RegExp(
209
- `(<meta\\s+[^>]*property\\s*=\\s*["']${propertyName}["'][^>]*\\bcontent\\s*=\\s*["'])([^"']*)(["'][^>]*>)`,
210
- "gi",
211
- ),
212
- new RegExp(
213
- `(<meta\\s+[^>]*\\bcontent\\s*=\\s*["'])([^"']*)(["'][^>]*property\\s*=\\s*["']${propertyName}["'][^>]*>)`,
214
- "gi",
215
- ),
216
- ];
217
-
218
- let changed = false;
219
- let nextHtml = html;
220
-
221
- for (const pattern of patterns) {
222
- nextHtml = nextHtml.replace(pattern, (full, before, value, after) => {
223
- const result = rewriteSingleUrl(value, DIST_DIR, hostUrl, prefix);
224
- if (!result.changed) return full;
225
- changed = true;
226
- return `${before}${result.value}${after}`;
227
- });
228
- }
229
-
230
- return { html: nextHtml, changed };
231
- }
232
-
233
- function rewriteMetaNameContent(html, name, hostUrl, prefix) {
234
- const patterns = [
235
- new RegExp(
236
- `(<meta\\s+[^>]*name\\s*=\\s*["']${name}["'][^>]*\\bcontent\\s*=\\s*["'])([^"']*)(["'][^>]*>)`,
237
- "gi",
238
- ),
239
- new RegExp(
240
- `(<meta\\s+[^>]*\\bcontent\\s*=\\s*["'])([^"']*)(["'][^>]*name\\s*=\\s*["']${name}["'][^>]*>)`,
241
- "gi",
242
- ),
243
- ];
244
-
245
- let changed = false;
246
- let nextHtml = html;
247
-
248
- for (const pattern of patterns) {
249
- nextHtml = nextHtml.replace(pattern, (full, before, value, after) => {
250
- const result = rewriteSingleUrl(value, DIST_DIR, hostUrl, prefix);
251
- if (!result.changed) return full;
252
- changed = true;
253
- return `${before}${result.value}${after}`;
254
- });
255
- }
256
-
257
- return { html: nextHtml, changed };
258
- }
259
-
260
- function rewriteInlinePagefindRuntimeImports(html, filePath, hostUrl, prefix) {
261
- const pattern =
262
- /(["'])(https?:\/\/[^"'`)]*\/pagefind\/pagefind\.js(?:\?[^"'`)]*)?|\/pagefind\/pagefind\.js(?:\?[^"'`)]*)?)\1/g;
263
- let changed = false;
264
-
265
- const rewritten = html.replace(pattern, (full, quote, urlValue) => {
266
- const result = rewriteSingleUrl(urlValue, filePath, hostUrl, prefix);
267
- if (!result.changed) return full;
268
-
269
- changed = true;
270
- return `${quote}${result.value}${quote}`;
271
- });
272
-
273
- return { html: rewritten, changed };
274
- }
275
-
276
- function rewriteCssUrls(css, filePath, hostUrl, prefix) {
277
- const pattern = /url\(\s*(['"]?)([^"')]+)\1\s*\)/gi;
278
- let changed = false;
279
-
280
- const rewritten = css.replace(pattern, (full, quote, value) => {
281
- const result = rewriteSingleUrl(value, filePath, hostUrl, prefix);
282
- if (!result.changed) return full;
283
-
284
- changed = true;
285
- return `url(${quote}${result.value}${quote})`;
286
- });
287
-
288
- return { css: rewritten, changed };
289
- }
290
-
291
- function main() {
292
- const staticAssetHostInput = process.env.STATIC_ASSET_HOST?.trim();
293
- const staticAssetPrefixInput =
294
- process.env.R2_BUCKET_PREFIX?.trim() ??
295
- process.env.STATIC_ASSET_PREFIX?.trim();
296
-
297
- if (!staticAssetHostInput) {
298
- console.log(
299
- "Skipping static asset host rewrite: STATIC_ASSET_HOST is not configured.",
300
- );
301
- return;
302
- }
303
-
304
- if (!staticAssetPrefixInput) {
305
- console.log(
306
- "Skipping static asset host rewrite: R2_BUCKET_PREFIX or STATIC_ASSET_PREFIX is not configured.",
307
- );
308
- return;
309
- }
310
-
311
- if (!fs.existsSync(DIST_DIR)) {
312
- console.warn("Skipping static asset host rewrite: dist directory not found.");
313
- return;
314
- }
315
-
316
- const hostUrl = normalizeHostUrl(staticAssetHostInput);
317
- const prefix = normalizePrefix(staticAssetPrefixInput);
318
- const htmlFiles = findFilesByExtension(DIST_DIR, ".html").sort();
319
-
320
- if (htmlFiles.length === 0) {
321
- console.warn("Skipping static asset host rewrite: no HTML files found in dist.");
322
- return;
323
- }
324
-
325
- let updatedHtmlCount = 0;
326
- let updatedCssCount = 0;
327
-
328
- for (const htmlFile of htmlFiles) {
329
- const sourceHtml = fs.readFileSync(htmlFile, "utf8");
330
- let nextHtml = sourceHtml;
331
- let fileChanged = false;
332
-
333
- const rewrites = [
334
- ["link", "href"],
335
- ["script", "src"],
336
- ["img", "src"],
337
- ["img", "srcset"],
338
- ["source", "src"],
339
- ["source", "srcset"],
340
- ["video", "src"],
341
- ["video", "poster"],
342
- ["audio", "src"],
343
- ];
344
-
345
- for (const [tagName, attribute] of rewrites) {
346
- const result = rewriteAttribute(
347
- nextHtml,
348
- htmlFile,
349
- tagName,
350
- attribute,
351
- hostUrl,
352
- prefix,
353
- );
354
- nextHtml = result.html;
355
- fileChanged = fileChanged || result.changed;
356
- }
357
-
358
- const ogResult = rewriteMetaImageContent(
359
- nextHtml,
360
- "og:image",
361
- hostUrl,
362
- prefix,
363
- );
364
- nextHtml = ogResult.html;
365
- fileChanged = fileChanged || ogResult.changed;
366
-
367
- const twitterResult = rewriteMetaNameContent(
368
- nextHtml,
369
- "twitter:image",
370
- hostUrl,
371
- prefix,
372
- );
373
- nextHtml = twitterResult.html;
374
- fileChanged = fileChanged || twitterResult.changed;
375
-
376
- const pagefindImportResult = rewriteInlinePagefindRuntimeImports(
377
- nextHtml,
378
- htmlFile,
379
- hostUrl,
380
- prefix,
381
- );
382
- nextHtml = pagefindImportResult.html;
383
- fileChanged = fileChanged || pagefindImportResult.changed;
384
-
385
- if (fileChanged) {
386
- fs.writeFileSync(htmlFile, nextHtml, "utf8");
387
- updatedHtmlCount += 1;
388
- }
389
- }
390
-
391
- const cssFiles = findFilesByExtension(DIST_DIR, ".css").sort();
392
-
393
- for (const cssFile of cssFiles) {
394
- const sourceCss = fs.readFileSync(cssFile, "utf8");
395
- const result = rewriteCssUrls(sourceCss, cssFile, hostUrl, prefix);
396
-
397
- if (!result.changed) continue;
398
-
399
- fs.writeFileSync(cssFile, result.css, "utf8");
400
- updatedCssCount += 1;
401
- }
402
-
403
- console.log(
404
- `✅ Static asset host rewrite complete. Updated HTML ${updatedHtmlCount}/${htmlFiles.length}, CSS ${updatedCssCount}/${cssFiles.length}.`,
405
- );
406
- }
407
-
408
- main();