301 lines
8.4 KiB
TypeScript
301 lines
8.4 KiB
TypeScript
import { DocumentData, DocumentMetadata } from '@nrwl/nx-dev/models-document';
|
|
import { MenuItem } from '@nrwl/nx-dev/models-menu';
|
|
import { parseMarkdown } from '@nrwl/nx-dev/ui-markdoc';
|
|
import { readFileSync } from 'fs';
|
|
import { load as yamlLoad } from 'js-yaml';
|
|
import { join } from 'path';
|
|
import { extractTitle } from './documents.utils';
|
|
|
|
export interface StaticDocumentPaths {
|
|
params: { segments: string[] };
|
|
}
|
|
|
|
export class DocumentsApi {
|
|
private documents: DocumentMetadata;
|
|
constructor(
|
|
private readonly options: {
|
|
publicDocsRoot: string;
|
|
documentSources: DocumentMetadata[];
|
|
addAncestor: { id: string; name: string } | null;
|
|
}
|
|
) {
|
|
if (!options.publicDocsRoot) {
|
|
throw new Error('public docs root cannot be undefined');
|
|
}
|
|
if (!options.documentSources) {
|
|
throw new Error('public document sources cannot be undefined');
|
|
}
|
|
|
|
const itemList: DocumentMetadata[] = options.documentSources.flatMap(
|
|
(x) => x.itemList
|
|
) as DocumentMetadata[];
|
|
|
|
this.documents = {
|
|
id: 'documents',
|
|
name: 'documents',
|
|
itemList: !!this.options.addAncestor
|
|
? [
|
|
{
|
|
id: this.options.addAncestor.id,
|
|
name: this.options.addAncestor.name,
|
|
itemList,
|
|
},
|
|
]
|
|
: itemList,
|
|
};
|
|
// this.allDocuments = options.allDocuments;
|
|
}
|
|
|
|
/**
|
|
* Generate the content of a "Category" or "Index" page, listing all its direct items.
|
|
* @param path
|
|
*/
|
|
getDocumentIndex(path: string[]): DocumentData | null {
|
|
let items = this.documents?.itemList;
|
|
let found: DocumentMetadata | null = null;
|
|
let itemPathToValidate: string[] = [];
|
|
|
|
for (const part of path) {
|
|
found = items?.find((item) => item.id === part) || null;
|
|
if (found) {
|
|
itemPathToValidate.push(found.id);
|
|
items = found.itemList;
|
|
}
|
|
}
|
|
|
|
// If the ids have found the item, check that the segment correspond to the id tree
|
|
if (found && path.join('/') !== itemPathToValidate.join('/')) {
|
|
found = null;
|
|
}
|
|
|
|
if (!found) return null;
|
|
|
|
const cardsTemplate = items
|
|
?.map((i) => ({
|
|
title: i.name,
|
|
description: i.description ?? '',
|
|
url: i.path ?? '/' + path.concat(i.id).join('/'),
|
|
}))
|
|
.map(
|
|
(card) =>
|
|
`{% card title="${card.title}" description="${card.description}" url="${card.url}" /%}\n`
|
|
)
|
|
.join('');
|
|
|
|
return {
|
|
filePath: '',
|
|
data: {
|
|
title: found?.name,
|
|
},
|
|
content: [
|
|
`# ${found?.name}\n\n ${found?.description ?? ''}\n\n`,
|
|
'{% cards %}\n',
|
|
cardsTemplate,
|
|
'{% /cards %}\n\n',
|
|
].join(''),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Retrieve content from an existing markdown file using the `file` property.
|
|
* @param path
|
|
*/
|
|
getDocument(path: string[]): DocumentData {
|
|
const { filePath, tags } = this.getDocumentInfo(path);
|
|
|
|
const originalContent = readFileSync(filePath, 'utf8');
|
|
const ast = parseMarkdown(originalContent);
|
|
const frontmatter = ast.attributes.frontmatter
|
|
? yamlLoad(ast.attributes.frontmatter)
|
|
: {};
|
|
|
|
// Set default title if not provided in front-matter section.
|
|
if (!frontmatter.title) {
|
|
frontmatter.title =
|
|
extractTitle(originalContent) ?? path[path.length - 1];
|
|
}
|
|
|
|
return {
|
|
filePath,
|
|
data: frontmatter,
|
|
content:
|
|
originalContent + '\n\n' + this.getRelatedDocumentsSection(tags, path),
|
|
};
|
|
}
|
|
|
|
getDocuments(): DocumentMetadata {
|
|
const docs = this.documents;
|
|
if (docs) return docs;
|
|
throw new Error(`Cannot find any documents`);
|
|
}
|
|
|
|
getStaticDocumentPaths(): StaticDocumentPaths[] {
|
|
const paths: StaticDocumentPaths[] = [];
|
|
|
|
function recur(curr: DocumentMetadata, acc: string[]): void {
|
|
if (curr.isExternal) return;
|
|
|
|
// Enable addressable category path
|
|
paths.push({
|
|
params: {
|
|
segments: curr.path
|
|
? curr.path.split('/').filter(Boolean).flat()
|
|
: [...acc, curr.id],
|
|
},
|
|
});
|
|
if (curr.itemList) {
|
|
curr.itemList.forEach((ii) => {
|
|
recur(ii, [...acc, curr.id]);
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!this.documents || !this.documents.itemList)
|
|
throw new Error(`Can't find any items`);
|
|
this.documents.itemList.forEach((item) => {
|
|
recur(item, []);
|
|
});
|
|
|
|
return paths;
|
|
}
|
|
|
|
/**
|
|
* Getting the document's filePath from the `file` property is done in 2 steps:
|
|
* - traversing the tree by path segments
|
|
* - if not found, try searching for it via the complete path string
|
|
* @param path
|
|
* @private
|
|
*/
|
|
private getDocumentInfo(path: string[]): {
|
|
filePath: string;
|
|
tags: string[];
|
|
} {
|
|
let items = this.documents?.itemList;
|
|
|
|
if (!items) {
|
|
throw new Error(`No document available for lookup`);
|
|
}
|
|
|
|
let found: DocumentMetadata | null = null;
|
|
let itemPathToValidate: string[] = [];
|
|
// Traversing the tree by matching item's ids with path's segments
|
|
for (const part of path) {
|
|
found = items?.find((item) => item.id === part) || null;
|
|
if (found) {
|
|
itemPathToValidate.push(found.id);
|
|
items = found.itemList;
|
|
}
|
|
}
|
|
// If the ids have found the item, check that the segment correspond to the id tree
|
|
if (found && path.join('/') !== itemPathToValidate.join('/')) {
|
|
found = null;
|
|
}
|
|
|
|
// If still not found, then attempt to match any item's id with the current path as a string
|
|
if (!found) {
|
|
function recur(curr, acc) {
|
|
if (curr.itemList) {
|
|
curr.itemList.forEach((ii) => {
|
|
recur(ii, [...acc, curr.id]);
|
|
});
|
|
} else {
|
|
if (curr.path === '/' + path.join('/')) {
|
|
found = curr;
|
|
}
|
|
}
|
|
}
|
|
this.documents.itemList!.forEach((item) => {
|
|
recur(item, []);
|
|
});
|
|
}
|
|
|
|
if (!found) throw new Error(`Document not found`);
|
|
const makeFilePath = (pathPart: string): string => {
|
|
return join(this.options.publicDocsRoot, `${pathPart}.md`);
|
|
};
|
|
const file = found.file
|
|
? { filePath: makeFilePath(found.file), tags: found.tags || [] }
|
|
: { filePath: makeFilePath(['generated', ...path].join('/')), tags: [] };
|
|
return file;
|
|
}
|
|
|
|
/**
|
|
* Displays a list of all concepts, recipes or reference documents that are tagged with the specified tag
|
|
* Tags are defined in map.json
|
|
* @returns
|
|
* @param tags
|
|
* @param path
|
|
*/
|
|
private getRelatedDocumentsSection(tags: string[], path: string[]): string {
|
|
let relatedConcepts: MenuItem[] = [];
|
|
let relatedRecipes: MenuItem[] = [];
|
|
let relatedReference: MenuItem[] = [];
|
|
function recur(curr, acc) {
|
|
if (curr.itemList) {
|
|
curr.itemList.forEach((ii) => {
|
|
recur(ii, [...acc, curr.id]);
|
|
});
|
|
} else if (path.join('/') === [...acc, curr.id].join('/')) {
|
|
return;
|
|
} else {
|
|
if (
|
|
curr.tags &&
|
|
tags.some((tag) => curr.tags.includes(tag)) &&
|
|
['concepts', 'more-concepts'].some((id) => acc.includes(id))
|
|
) {
|
|
curr.path = [...acc, curr.id].join('/');
|
|
relatedConcepts.push(curr);
|
|
}
|
|
if (
|
|
curr.tags &&
|
|
tags.some((tag) => curr.tags.includes(tag)) &&
|
|
acc.includes('recipe')
|
|
) {
|
|
curr.path = [...acc, curr.id].join('/');
|
|
relatedRecipes.push(curr);
|
|
}
|
|
if (
|
|
curr.tags &&
|
|
tags.some((tag) => curr.tags.includes(tag)) &&
|
|
['nx', 'workspace'].some((id) => acc.includes(id))
|
|
) {
|
|
curr.path = [...acc, curr.id].join('/');
|
|
relatedReference.push(curr);
|
|
}
|
|
}
|
|
}
|
|
this.documents.itemList!.forEach((item) => {
|
|
recur(item, []);
|
|
});
|
|
|
|
if (
|
|
relatedConcepts.length === 0 &&
|
|
relatedRecipes.length === 0 &&
|
|
relatedReference.length === 0
|
|
) {
|
|
return '';
|
|
}
|
|
|
|
let output = '## Related Documentation\n';
|
|
|
|
function listify(items: MenuItem[]): string {
|
|
return items
|
|
.map((item) => {
|
|
return `- [${item.name}](${item.path})`;
|
|
})
|
|
.join('\n');
|
|
}
|
|
if (relatedConcepts.length > 0) {
|
|
output += '### Concepts\n' + listify(relatedConcepts) + '\n';
|
|
}
|
|
if (relatedRecipes.length > 0) {
|
|
output += '### Recipes\n' + listify(relatedRecipes) + '\n';
|
|
}
|
|
if (relatedReference.length > 0) {
|
|
output += '### Reference\n' + listify(relatedReference) + '\n';
|
|
}
|
|
|
|
return output;
|
|
}
|
|
}
|