@xyd-js/plugin-docs 0.1.0-xyd.2 → 0.1.0-xyd.3

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,751 @@
1
+ import path from "path";
2
+ import { promises as fs } from "fs";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import matterStringify from "gray-matter/lib/stringify";
6
+ import { Plugin as VitePlugin } from "vite"
7
+ import { route } from "@react-router/dev/routes";
8
+
9
+ import {
10
+ Settings,
11
+ APIFile,
12
+ Sidebar,
13
+ SidebarRoute,
14
+ Metadata
15
+ } from "@xyd-js/core";
16
+ import uniform, {
17
+ pluginNavigation,
18
+ Reference,
19
+ ReferenceType,
20
+ OpenAPIReferenceContext,
21
+ GraphQLReferenceContext
22
+ } from "@xyd-js/uniform";
23
+ import { uniformPluginXDocsSidebar } from "@xyd-js/openapi";
24
+
25
+ import { Preset, PresetData } from "../../types";
26
+
27
+ import { createRequire } from 'module';
28
+ import { VIRTUAL_CONTENT_FOLDER } from "../../const";
29
+ import { getHostPath } from "../../utils";
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const matter = require('gray-matter'); // TODO: !!! BETTER SOLUTION !!!
33
+
34
+ export async function ensureAndCleanupVirtualFolder() {
35
+ try {
36
+ // Create directory recursively if it doesn't exist
37
+ await fs.mkdir(VIRTUAL_CONTENT_FOLDER, { recursive: true });
38
+
39
+ // Read all files and directories in the folder
40
+ const entries = await fs.readdir(VIRTUAL_CONTENT_FOLDER, { withFileTypes: true });
41
+
42
+ // Delete each entry recursively
43
+ for (const entry of entries) {
44
+ const fullPath = path.join(VIRTUAL_CONTENT_FOLDER, entry.name);
45
+ if (entry.isDirectory()) {
46
+ await fs.rm(fullPath, { recursive: true, force: true });
47
+ } else {
48
+ await fs.unlink(fullPath);
49
+ }
50
+ }
51
+ } catch (error) {
52
+ console.error('Error managing virtual folder:', error);
53
+ }
54
+ }
55
+
56
+ // TODO: !!!!! REFACTOR PLUGIN-ZERO AND ITS DEPS FOR MORE READABLE CODE AND BETTER API !!!!
57
+
58
+ export interface uniformPresetOptions {
59
+ urlPrefix?: string
60
+ sourceTheme?: boolean
61
+ disableFSWrite?: boolean
62
+ fileRouting?: { [key: string]: string }
63
+ }
64
+
65
+ function flatPages(
66
+ sidebar: (SidebarRoute | Sidebar)[],
67
+ groups: { [key: string]: string },
68
+ resp: string[] = [],
69
+ ) {
70
+ sidebar.map(async side => {
71
+ if ("route" in side) {
72
+ side?.pages.map(item => {
73
+ return flatPages([item], groups, resp)
74
+ })
75
+
76
+ return
77
+ }
78
+
79
+ if (groups[side.group || ""]) {
80
+ const link = groups[side.group || ""]
81
+
82
+ resp.push(link)
83
+ }
84
+
85
+ side?.pages?.map(async page => {
86
+ if (typeof page === "string") {
87
+ resp.push(page)
88
+ return
89
+ }
90
+
91
+ if ("virtual" in page) {
92
+ resp.push(page.virtual)
93
+ return
94
+ }
95
+
96
+ return flatPages([page], groups, resp)
97
+ })
98
+ })
99
+
100
+ return resp
101
+ }
102
+
103
+ function flatGroups(
104
+ sidebar: (SidebarRoute | Sidebar)[],
105
+ resp: { [key: string]: string } = {}
106
+ ) {
107
+ sidebar.map(async side => {
108
+ if ("route" in side) {
109
+ side?.pages.map(item => {
110
+ return flatGroups([item], resp)
111
+ })
112
+
113
+ return
114
+ }
115
+
116
+ if (side.group) {
117
+ if (resp[side.group]) {
118
+ console.error('group already exists', side.group)
119
+ }
120
+
121
+ const first = side?.pages?.[0]
122
+ if (first && typeof first === "string") {
123
+ const chunks = first.split("/")
124
+ chunks[chunks.length - 1] = side.group || ""
125
+ const groupLink = chunks.join("/")
126
+
127
+ resp[side.group] = groupLink
128
+ }
129
+ }
130
+
131
+ side?.pages?.map(async page => {
132
+ if (typeof page === "string") {
133
+ return
134
+ }
135
+
136
+ if ("virtual" in page) {
137
+ return
138
+ }
139
+
140
+ return flatGroups([page], resp)
141
+ })
142
+ })
143
+
144
+ return resp
145
+ }
146
+
147
+ function uniformSidebarLevelMap(pages: string[]) {
148
+ const out = {};
149
+ let level = 0;
150
+
151
+ function recursive(items: string[]) {
152
+ for (const item of items) {
153
+ out[item] = level++;
154
+ }
155
+ }
156
+
157
+ recursive(pages);
158
+ return out;
159
+ }
160
+
161
+ // Helper function to read markdown files with support for both .mdx and .md extensions
162
+ async function readMarkdownFile(root: string, page: string): Promise<string> {
163
+ try {
164
+ // Try .mdx first
165
+ return await fs.readFile(path.join(root, page + '.mdx'), "utf-8");
166
+ } catch (e) {
167
+ // If .mdx fails, try .md
168
+ try {
169
+ return await fs.readFile(path.join(root, page + '.md'), "utf-8");
170
+ } catch (e) {
171
+ }
172
+ }
173
+
174
+ return ""
175
+ }
176
+
177
+ async function uniformResolver(
178
+ settings: Settings,
179
+ root: string,
180
+ matchRoute: string,
181
+ apiFile: string,
182
+ uniformApiResolver: (filePath: string) => Promise<Reference[]>,
183
+ sidebar?: (SidebarRoute | Sidebar)[],
184
+ options?: uniformPresetOptions,
185
+ uniformType?: UniformType,
186
+ disableFSWrite?: boolean
187
+ ) {
188
+ let urlPrefix = ""
189
+
190
+ if (matchRoute && sidebar) {
191
+ sidebar.forEach((sidebar) => {
192
+ if ("route" in sidebar) {
193
+ if (sidebar.route === matchRoute) {
194
+ if (urlPrefix) {
195
+ throw new Error('multiple sidebars found for apiFile match')
196
+ }
197
+ urlPrefix = sidebar.route
198
+ }
199
+ }
200
+ })
201
+ }
202
+
203
+ const resolvedApiFile = path.relative(process.cwd(), path.resolve(process.cwd(), apiFile))
204
+ const uniformRefs = await uniformApiResolver(resolvedApiFile)
205
+ const plugins = globalThis.__xydUserUniformVitePlugins || []
206
+
207
+ if (!urlPrefix && options?.fileRouting?.[resolvedApiFile]) {
208
+ matchRoute = options.fileRouting[resolvedApiFile]
209
+ }
210
+
211
+ if (!urlPrefix && matchRoute) {
212
+ sidebar?.push({
213
+ route: matchRoute,
214
+ pages: []
215
+ })
216
+ urlPrefix = matchRoute
217
+ }
218
+ if (!urlPrefix && options?.urlPrefix) {
219
+ urlPrefix = options.urlPrefix
220
+ }
221
+ if (!urlPrefix) {
222
+ throw new Error('(uniformResolver): urlPrefix not found')
223
+ }
224
+
225
+ if (uniformType === "openapi") {
226
+ plugins.push(uniformPluginXDocsSidebar)
227
+ }
228
+ const uniformWithNavigation = uniform(uniformRefs, {
229
+ plugins: [
230
+ ...plugins,
231
+ pluginNavigation(settings, {
232
+ urlPrefix,
233
+ }),
234
+ ]
235
+ })
236
+
237
+ let pageLevels = {}
238
+
239
+ const uniformData = {
240
+ slugs: {},
241
+ data: [] as any[], // TODO: fix any
242
+ i: 0,
243
+ set: (slug, content: string, options = {}) => {
244
+ if (uniformData.slugs[slug]) {
245
+ console.error('slug already exists', slug)
246
+ }
247
+ // TODO: in the future custom sort
248
+ const level = pageLevels[slug]
249
+
250
+ uniformData.data[level] = {
251
+ slug,
252
+ content,
253
+ options
254
+ }
255
+ }
256
+ }
257
+
258
+ const uniformSidebars: SidebarRoute[] = []
259
+
260
+ if (sidebar && matchRoute) {
261
+ // TODO: DRY
262
+ sidebar.forEach((sidebar) => {
263
+ if ("route" in sidebar) {
264
+ if (sidebar.route === matchRoute) {
265
+ uniformSidebars.push(sidebar)
266
+ }
267
+ }
268
+ })
269
+
270
+ if (uniformSidebars.length > 1) {
271
+ throw new Error('multiple sidebars found for uniform match')
272
+ }
273
+ }
274
+
275
+ {
276
+ const otherUniformPages = flatPages(uniformSidebars, {})
277
+ const groups = flatGroups(uniformWithNavigation.out.sidebar)
278
+ const flatUniformPages = [
279
+ ...otherUniformPages,
280
+ ...flatPages(
281
+ uniformWithNavigation.out.sidebar,
282
+ groups // TODO: we dont need groups - because it comes to structured page levels
283
+ ),
284
+ ]
285
+
286
+ pageLevels = uniformSidebarLevelMap(flatUniformPages)
287
+
288
+ {
289
+ // TODO: below should be inside uniform?
290
+ // TODO: custom `fn` logic?
291
+ await Promise.all(otherUniformPages.map(async (page) => {
292
+ const content = await readMarkdownFile(root, page);
293
+ uniformData.set(page, content + "\n");
294
+ }))
295
+
296
+ await Promise.all(Object.keys(groups).map(async (group) => {
297
+ try {
298
+ // TODO: only if `group_index`
299
+ const page = groups[group]
300
+ const content = await readMarkdownFile(root, page);
301
+ uniformData.set(page, content + "\n");
302
+ } catch (e) {
303
+ // Silently continue if file not found
304
+ }
305
+ }))
306
+ }
307
+ }
308
+
309
+ {
310
+ const routeFolder = path.join(root, matchRoute)
311
+ try {
312
+ await fs.access(routeFolder);
313
+ } catch {
314
+ await fs.mkdir(routeFolder, { recursive: true });
315
+ }
316
+ }
317
+
318
+ let composedFileMap: Record<string, string> = {}
319
+ if (!settings.engine?.uniform?.store) {
320
+ composedFileMap = await composeFileMap(root, matchRoute)
321
+ }
322
+
323
+ const basePath = settings.engine?.uniform?.store
324
+ ? root
325
+ : path.join(root, VIRTUAL_CONTENT_FOLDER)
326
+
327
+ await Promise.all(
328
+ uniformWithNavigation.references.map(async (ref) => {
329
+ const byCanonical = path.join(urlPrefix, ref.canonical)
330
+ const mdPath = path.join(basePath, byCanonical + '.md')
331
+
332
+ const frontmatter = uniformWithNavigation.out.pageFrontMatter[byCanonical]
333
+
334
+ if (!frontmatter) {
335
+ console.error('frontmatter not found', byCanonical)
336
+ return
337
+ }
338
+
339
+ let meta: Metadata = {
340
+ title: frontmatter.title,
341
+ layout: "wide"
342
+ }
343
+
344
+
345
+ // const mdFilePath = path.join(basePath, byCanonical)
346
+ const absoluteApiFile = path.join(
347
+ process.cwd(),
348
+ apiFile,
349
+ )
350
+ // const relativeApiFile = path.relative(
351
+ // mdFilePath,
352
+ // absoluteApiFile
353
+ // )
354
+ const resolvedApiFile = absoluteApiFile // TODO: leave absolute or relative?
355
+ let region = ""
356
+ // TODO: in the future more advanced composition? - not only like `GET /users/{id}`
357
+ switch (uniformType) {
358
+ case "graphql": {
359
+ const ctx = ref.context as GraphQLReferenceContext;
360
+ region = `${ctx.graphqlTypeShort}.${ctx?.graphqlName}`
361
+
362
+ meta.graphql = `${resolvedApiFile}#${region}`
363
+
364
+ break
365
+ }
366
+ case "openapi": {
367
+ const ctx = ref.context as OpenAPIReferenceContext;
368
+ const method = (ctx?.method || "").toUpperCase()
369
+ if (method && ctx?.path) {
370
+ region = `${method} ${ctx?.path}`
371
+ } else if (ctx.componentSchema) {
372
+ region = "/components/schemas/" + ctx.componentSchema
373
+ }
374
+ meta.openapi = `${resolvedApiFile}#${region}`
375
+ break
376
+ }
377
+ }
378
+
379
+ let composedContent = ""
380
+ if (region && composedFileMap[region]) {
381
+ const content = await fs.readFile(composedFileMap[region], 'utf-8');
382
+ const resp = matter(content);
383
+
384
+ meta = {
385
+ ...meta,
386
+ ...composyingMetaProps(resp.data, "title", "description", "layout")
387
+ }
388
+
389
+ composedContent = resp.content
390
+ }
391
+
392
+ const content = matterStringify({ content: composedContent }, meta);
393
+
394
+ if (!disableFSWrite) {
395
+ try {
396
+ await fs.access(path.dirname(mdPath));
397
+ } catch {
398
+ await fs.mkdir(path.dirname(mdPath), { recursive: true });
399
+ }
400
+
401
+ await fs.writeFile(mdPath, content)
402
+ }
403
+ })
404
+ )
405
+
406
+ if (!sidebar) {
407
+ return {
408
+ sidebar: [
409
+ {
410
+ route: matchRoute,
411
+ pages: uniformWithNavigation.out.sidebar
412
+ }
413
+ ] as SidebarRoute[],
414
+ data: uniformData.data
415
+ }
416
+ }
417
+
418
+ if (matchRoute) {
419
+ // TODO: in the future custom position - before / after
420
+ uniformSidebars[0].pages.unshift(...uniformWithNavigation.out.sidebar)
421
+
422
+ return {
423
+ data: uniformData.data,
424
+ }
425
+ }
426
+
427
+ sidebar.unshift({
428
+ route: matchRoute,
429
+ pages: uniformWithNavigation.out.sidebar
430
+ })
431
+
432
+ return {
433
+ data: uniformData.data,
434
+ composedFileMap
435
+ }
436
+ }
437
+
438
+ const allowedMetaProps = ['title', 'description', 'layout'] as const;
439
+
440
+ type AllowedMetaProps = Pick<Metadata, typeof allowedMetaProps[number]>;
441
+
442
+ function composyingMetaProps(meta: Metadata, ...props: (keyof AllowedMetaProps)[]) {
443
+ let newProps = {}
444
+ props.forEach(prop => {
445
+ if (allowedMetaProps.includes(prop as typeof allowedMetaProps[number]) && typeof meta[prop] === "string") {
446
+ newProps[prop] = meta[prop] as string;
447
+ }
448
+ });
449
+
450
+ return newProps;
451
+ }
452
+
453
+ async function composeFileMap(basePath: string, matchRoute: string) {
454
+ const routeMap: Record<string, string> = {};
455
+
456
+ async function processDirectory(dirPath: string) {
457
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
458
+
459
+ for (const entry of entries) {
460
+ const fullPath = path.join(dirPath, entry.name);
461
+
462
+ if (entry.isDirectory()) {
463
+ await processDirectory(fullPath);
464
+ } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.mdx'))) {
465
+ try {
466
+ const content = await fs.readFile(fullPath, 'utf-8');
467
+ const { data: frontmatter } = matter(content);
468
+
469
+ if (frontmatter && frontmatter.openapi) {
470
+ const route = frontmatter.openapi;
471
+ routeMap[route] = path.join(matchRoute, entry.name);
472
+ }
473
+ } catch (error) {
474
+ console.error(`Error processing file ${fullPath}:`, error);
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ await processDirectory(path.join(basePath, matchRoute));
481
+ return routeMap;
482
+ }
483
+
484
+ // preinstall adds uniform navigation to settings
485
+ function preinstall(
486
+ id: string,
487
+ uniformApiResolver: (filePath: string) => Promise<Reference[]>,
488
+ apiFile: APIFile,
489
+ uniformType: UniformType,
490
+ disableFSWrite?: boolean,
491
+ options?: uniformPresetOptions,
492
+ ) {
493
+ return function preinstallInner(innerOptions: any) {
494
+ return async function uniformPluginInner(settings: Settings, data: PresetData) {
495
+ const root = process.cwd()
496
+
497
+ if (!apiFile) {
498
+ return
499
+ }
500
+
501
+ const resp: any[] = []
502
+
503
+ // TODO: support NOT ROUTE MATCH
504
+
505
+ if (typeof apiFile === "string") {
506
+ const routeMatch = id
507
+
508
+ const resolved = await uniformResolver(
509
+ settings,
510
+ root,
511
+ routeMatch,
512
+ apiFile,
513
+ uniformApiResolver,
514
+ settings?.navigation?.sidebar,
515
+ {
516
+ ...options,
517
+ ...innerOptions,
518
+ },
519
+ uniformType,
520
+ disableFSWrite
521
+ )
522
+
523
+ if (resolved.sidebar) {
524
+ settings.navigation = {
525
+ ...settings?.navigation,
526
+ sidebar: !settings.navigation?.sidebar
527
+ ? resolved.sidebar
528
+ : [
529
+ ...resolved.sidebar,
530
+ ...!settings.navigation?.sidebar || [],
531
+ ]
532
+ }
533
+ }
534
+
535
+ resp.push({
536
+ urlPrefix: routeMatch.startsWith("/") ? routeMatch : `/${routeMatch}`,
537
+ data: resolved.data,
538
+ })
539
+ } else {
540
+ async function resolve(
541
+ routeMatch: string,
542
+ uniform: string,
543
+ ) {
544
+ const resolved = await uniformResolver(
545
+ settings,
546
+ root,
547
+ routeMatch,
548
+ uniform,
549
+ uniformApiResolver,
550
+ settings?.navigation?.sidebar,
551
+ {
552
+ ...options,
553
+ ...innerOptions,
554
+ },
555
+ uniformType,
556
+ disableFSWrite
557
+ )
558
+
559
+ if (resolved.sidebar) {
560
+ settings.navigation = {
561
+ ...settings?.navigation,
562
+ sidebar: !settings.navigation?.sidebar
563
+ ? resolved.sidebar
564
+ : [
565
+ ...resolved.sidebar,
566
+ ...!settings.navigation?.sidebar || [],
567
+ ]
568
+ }
569
+ }
570
+
571
+ resp.push({
572
+ urlPrefix: routeMatch.startsWith("/") ? routeMatch : `/${routeMatch}`,
573
+ data: resolved.data,
574
+ })
575
+ }
576
+
577
+ if (apiFile["source"]) {
578
+ await resolve(apiFile["route"], apiFile["source"])
579
+ } else {
580
+ for (const apiKey in apiFile) {
581
+ const uniform = apiFile?.[apiKey]?.source || apiFile?.[apiKey] || ""
582
+ const routeMatch = settings.api?.[id]?.[apiKey]?.route || ""
583
+
584
+ if (!uniform) {
585
+ throw new Error(`uniform not found for ${apiKey}`)
586
+ }
587
+
588
+ await resolve(routeMatch, uniform)
589
+ }
590
+ }
591
+ }
592
+
593
+ return resp
594
+ }
595
+ }
596
+ }
597
+
598
+ function vitePluginUniformContent(pluginId: string) {
599
+ return function vitePluginUniformContentInner() {
600
+ return async function ({
601
+ preinstall
602
+ }): Promise<VitePlugin> {
603
+ return {
604
+ name: `virtual:xyd-plugin-docs/${pluginId}`, // TODO: unique name per plugin ?
605
+ resolveId(id) {
606
+ if (id == `virtual:xyd-plugin-docs/${pluginId}`) {
607
+ return id;
608
+ }
609
+ },
610
+ async load(id) {
611
+ if (id === `virtual:xyd-plugin-docs/${pluginId}`) {
612
+ if (!preinstall.data) {
613
+ return `export default ${JSON.stringify(preinstall)}`;
614
+ }
615
+
616
+ return `export default ${JSON.stringify(preinstall.data)}`;
617
+ }
618
+ }
619
+ };
620
+ }
621
+ }
622
+ }
623
+
624
+ type UniformType = "graphql" | "openapi" | "sources"
625
+
626
+ function uniformPreset(
627
+ id: string,
628
+ apiFile: APIFile,
629
+ sidebar: (SidebarRoute | Sidebar)[],
630
+ options: uniformPresetOptions,
631
+ uniformApiResolver: (filePath: string) => Promise<Reference[]>,
632
+ disableFSWrite?: boolean
633
+ ) {
634
+ return function (settings: Settings, uniformType: UniformType) {
635
+ const routeMatches: string[] = []
636
+
637
+ if (apiFile) {
638
+ sidebar.forEach((sidebar) => {
639
+ if ("route" in sidebar) {
640
+ if (typeof apiFile === "string") {
641
+ const routeMatch = id
642
+
643
+ if (sidebar.route === routeMatch) {
644
+ routeMatches.push(routeMatch)
645
+ }
646
+
647
+ return
648
+ }
649
+
650
+ if (typeof apiFile === "object" && !Array.isArray(apiFile)) {
651
+ for (const routeMatchKey in apiFile) {
652
+ // TODO: is 'id' a good idea here?
653
+ const routeMatch = settings?.api?.[id]?.[routeMatchKey]?.route || ""
654
+ if (sidebar.route === routeMatch) {
655
+ routeMatches.push(routeMatch)
656
+ }
657
+ }
658
+ }
659
+
660
+ return
661
+ }
662
+ // TODO: support NOT match sidebar
663
+ })
664
+ } else {
665
+ if (!options.urlPrefix) {
666
+ throw new Error('(uniformPreset): urlPrefix not found')
667
+ }
668
+
669
+ routeMatches.push(`${options.urlPrefix}/*`)
670
+ }
671
+
672
+ const basePath = path.join(getHostPath(), "./plugins/xyd-plugin-docs")
673
+ const pageTheme = "src/pages/docs.tsx"
674
+
675
+ return {
676
+ preinstall: [
677
+ preinstall(
678
+ id,
679
+ uniformApiResolver,
680
+ apiFile,
681
+ uniformType,
682
+ disableFSWrite,
683
+ options
684
+ )
685
+ ],
686
+ routes: routeMatches.map((routeMatch, i) => route(
687
+ `${routeMatch}/*`,
688
+ path.join(basePath, pageTheme), {
689
+ id: `xyd-plugin-docs/${id}-${i}`,
690
+ }
691
+ ),
692
+ ),
693
+ vitePlugins: [
694
+ vitePluginUniformContent(id),
695
+ ]
696
+ }
697
+ } satisfies Preset<unknown>
698
+ }
699
+
700
+
701
+ // TODO: refactor to use class methods + separate functions if needed?
702
+ export abstract class UniformPreset {
703
+ private _urlPrefix: string;
704
+ private _sourceTheme: boolean;
705
+ private _fileRouting: { [key: string]: string } = {};
706
+
707
+ protected constructor(
708
+ private presetId: string,
709
+ private apiFile: APIFile,
710
+ private sidebar: (SidebarRoute | Sidebar)[],
711
+ private disableFSWrite?: boolean
712
+ ) {
713
+ }
714
+
715
+ protected abstract uniformRefResolver(filePath: string): Promise<Reference[]>
716
+
717
+ protected urlPrefix(urlPrefix: string): this {
718
+ this._urlPrefix = urlPrefix
719
+
720
+ return this
721
+ }
722
+
723
+ protected sourceTheme(v: boolean): this {
724
+ this._sourceTheme = v
725
+
726
+ return this
727
+ }
728
+
729
+ protected fileRouting(file: string, route: string): this {
730
+ this._fileRouting[file] = route
731
+
732
+ return this
733
+ }
734
+
735
+ protected newUniformPreset() {
736
+ return uniformPreset(
737
+ this.presetId,
738
+ this.apiFile,
739
+ this.sidebar,
740
+ {
741
+ urlPrefix: this._urlPrefix,
742
+ sourceTheme: this._sourceTheme,
743
+ fileRouting: this._fileRouting,
744
+ },
745
+ this.uniformRefResolver,
746
+ this.disableFSWrite
747
+ )
748
+ }
749
+ }
750
+
751
+