import { DocumentMetadata, ProcessedDocument, RelatedDocument, } from '@nx/nx-dev/models-document'; import { readFileSync } from 'fs'; import { join } from 'path'; import { TagsApi } from './tags.api'; interface StaticDocumentPaths { params: { segments: string[] }; } export class DocumentsApi { private readonly manifest: Record; constructor( private readonly options: { id: string; manifest: Record; prefix: string; publicDocsRoot: string; tagsApi: TagsApi; } ) { if (!options.id) { throw new Error('id cannot be undefined'); } if (!options.prefix) { options.prefix = ''; } if (!options.publicDocsRoot) { throw new Error('public docs root cannot be undefined'); } if (!options.manifest) { throw new Error('public document sources cannot be undefined'); } this.manifest = structuredClone(this.options.manifest); } private getManifestKey(path: string): string { return '/' + path; } getFilePath(path: string): string { return join(this.options.publicDocsRoot, `${path}.md`); } getParamsStaticDocumentPaths(): StaticDocumentPaths[] { return Object.keys(this.manifest).map((path) => ({ params: { segments: !!this.options.prefix ? [this.options.prefix].concat(path.split('/').filter(Boolean).flat()) : path.split('/').filter(Boolean).flat(), }, })); } getSlugsStaticDocumentPaths(): string[] { if (this.options.prefix) return Object.keys(this.manifest).map( (path) => `/${this.options.prefix}` + path ); return Object.keys(this.manifest); } getDocument(path: string[]): ProcessedDocument { const document: DocumentMetadata | null = this.manifest[this.getManifestKey(path.join('/'))] || null; if (!document) { if ( path[0] === 'nx-api' && path[1] === 'devkit' && path[2] === 'documents' ) { const file = `generated/devkit/${path.slice(3).join('/')}`; return { content: readFileSync(this.getFilePath(file), 'utf8'), description: '', filePath: this.getFilePath(file), id: path.at(-1) || '', name: path.at(-1) || '', relatedDocuments: {}, tags: [], }; } throw new Error( `Document not found in manifest with: "${path.join('/')}"` ); } if (this.isDocumentIndex(document)) return this.getDocumentIndex(path); return { content: readFileSync(this.getFilePath(document.file), 'utf8'), description: document.description, filePath: this.getFilePath(document.file), id: document.id, name: document.name, relatedDocuments: this.getRelatedDocuments(document.tags), tags: document.tags, }; } getRelatedDocuments(tags: string[]): Record { const relatedDocuments = {}; tags.forEach( (tag) => (relatedDocuments[tag] = this.options.tagsApi.getAssociatedItems(tag)) ); return relatedDocuments; } isDocumentIndex(document: DocumentMetadata): boolean { return !!document.itemList.length; } generateDocumentIndexTemplate(document: DocumentMetadata): string { const cardsTemplate = document.itemList .map((i) => ({ title: i.name, description: i.description ?? '', url: i.path, })) .map( (card) => `{% card title="${card.title}" description="${ card.description }" url="${[this.options.prefix, card.url] .filter(Boolean) .join('/')}" /%}\n` ) .join(''); return [ `# ${document.name}\n\n ${document.description ?? ''}\n\n`, '{% cards %}\n', cardsTemplate, '{% /cards %}\n\n', ].join(''); } getDocumentIndex(path: string[]): ProcessedDocument { const document: DocumentMetadata | null = this.manifest[this.getManifestKey(path.join('/'))] || null; if (!document) throw new Error( `Document not found in manifest with: "${path.join('/')}"` ); if (!!document.file) return { content: readFileSync(this.getFilePath(document.file), 'utf8'), description: document.description, filePath: this.getFilePath(document.file), id: document.id, name: document.name, relatedDocuments: this.getRelatedDocuments(document.tags), tags: document.tags, }; return { content: this.generateDocumentIndexTemplate(document), description: document.description, filePath: '', id: document.id, name: document.name, relatedDocuments: this.getRelatedDocuments(document.tags), tags: document.tags, }; } generateRootDocumentIndex(options: { name: string; description: string; }): ProcessedDocument { const document = { id: 'root', name: options.name, description: options.description, file: '', path: '', isExternal: false, itemList: Object.keys(this.manifest) .filter((k) => k.split('/').length < 4) // Getting only top categories .map((k) => this.manifest[k]), tags: [], }; return { content: this.generateDocumentIndexTemplate(document), description: document.description, filePath: '', id: document.id, name: document.name, relatedDocuments: this.getRelatedDocuments(document.tags), tags: document.tags, }; } }