chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# documentation-api
|
||||
|
||||
This library provides the data necessary to display the documentation.
|
||||
|
||||
The [`data`](./src/data) folder contains the version mapping as well as a snapshot of the docs.
|
||||
|
||||
## Testing
|
||||
|
||||
```
|
||||
nx test documentation-api
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
import { baseConfig } from '../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
@@ -0,0 +1,24 @@
|
||||
/* eslint-disable */
|
||||
module.exports = {
|
||||
displayName: 'nx-dev-data-access-documents',
|
||||
globals: {},
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../coverage/nx-dev/data-access-documents',
|
||||
preset: '../../jest.preset.js',
|
||||
moduleNameMapper: {
|
||||
// Override for nx-dev packages - point to packages directory
|
||||
'^@nx/devkit$': '<rootDir>/../../packages/devkit/index.ts',
|
||||
'^@nx/devkit/testing$': '<rootDir>/../../packages/devkit/testing.ts',
|
||||
'^@nx/devkit/internal-testing-utils$':
|
||||
'<rootDir>/../../packages/devkit/internal-testing-utils.ts',
|
||||
'^@nx/devkit/src/(.*)$': '<rootDir>/../../packages/devkit/src/$1',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from '../src/node.index';
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@nx/nx-dev-data-access-documents",
|
||||
"version": "0.0.1",
|
||||
"type": "commonjs",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./node-only": {
|
||||
"import": "./src/node.index.ts",
|
||||
"default": "./src/node.index.ts"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nx/devkit": "workspace:*",
|
||||
"@nx/nx-dev-models-document": "workspace:*",
|
||||
"@nx/nx-dev-models-package": "workspace:*",
|
||||
"@nx/nx-dev-ui-markdoc": "workspace:*"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "nx-dev-data-access-documents",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "nx-dev/data-access-documents/src",
|
||||
"projectType": "library",
|
||||
"targets": {},
|
||||
"tags": ["scope:nx-dev", "type:data-access"]
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { readFileSync, accessSync, constants } from 'fs';
|
||||
import { join, basename, parse, resolve } from 'path';
|
||||
import { extractFrontmatter } from '@nx/nx-dev-ui-markdoc';
|
||||
import { sortPosts } from './blog.util';
|
||||
import { BlogPostDataEntry } from './blog.model';
|
||||
import { readFile, readdir } from 'fs/promises';
|
||||
|
||||
export class BlogApi {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
id: string;
|
||||
blogRoot: string;
|
||||
}
|
||||
) {
|
||||
if (!options.id) {
|
||||
throw new Error('id cannot be undefined');
|
||||
}
|
||||
if (!options.blogRoot) {
|
||||
throw new Error('public blog root cannot be undefined');
|
||||
}
|
||||
}
|
||||
async getBlogTags(): Promise<string[]> {
|
||||
const blogs = await this.getBlogs();
|
||||
const tags = new Set<string>();
|
||||
blogs.forEach((blog) => {
|
||||
blog.tags.forEach((tag) => tags.add(tag));
|
||||
});
|
||||
return Array.from(tags);
|
||||
}
|
||||
async getBlogs(
|
||||
filterFn?: (post: BlogPostDataEntry) => boolean
|
||||
): Promise<BlogPostDataEntry[]> {
|
||||
return await this.getAllBlogs(filterFn);
|
||||
}
|
||||
|
||||
async getAllBlogs(
|
||||
filterFn?: (post: BlogPostDataEntry) => boolean
|
||||
): Promise<BlogPostDataEntry[]> {
|
||||
const files: string[] = await readdir(this.options.blogRoot);
|
||||
const authors = JSON.parse(
|
||||
readFileSync(join(this.options.blogRoot, 'authors.json'), 'utf8')
|
||||
);
|
||||
const allPosts: BlogPostDataEntry[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(this.options.blogRoot, file);
|
||||
if (!filePath.endsWith('.md')) continue;
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const frontmatter = extractFrontmatter(content);
|
||||
const slug = this.calculateSlug(filePath, frontmatter);
|
||||
const { image, type } = this.determineOgImage(frontmatter.cover_image);
|
||||
|
||||
const post = {
|
||||
content,
|
||||
title: frontmatter.title ?? null,
|
||||
description: frontmatter.description ?? null,
|
||||
authors: authors.filter((author) =>
|
||||
frontmatter.authors?.includes(author.name)
|
||||
),
|
||||
eventDate: this.dateFromFileName(file),
|
||||
date: this.calculateDate(file, frontmatter),
|
||||
time: frontmatter.time,
|
||||
status: frontmatter.status,
|
||||
cover_image: frontmatter.cover_image
|
||||
? `/documentation${frontmatter.cover_image}` // Match the prefix used by markdown parser
|
||||
: null,
|
||||
tags: frontmatter.tags ?? [],
|
||||
reposts: frontmatter.reposts ?? [],
|
||||
// Do not default to 'false' so you can 'unpin' a blog post
|
||||
pinned: frontmatter.pinned ?? null,
|
||||
ogImage: image,
|
||||
ogImageType: type,
|
||||
filePath,
|
||||
slug,
|
||||
youtubeUrl: frontmatter.youtubeUrl,
|
||||
registrationUrl: frontmatter.registrationUrl,
|
||||
podcastYoutubeId: frontmatter.podcastYoutubeId,
|
||||
podcastSpotifyId: frontmatter.podcastSpotifyId,
|
||||
podcastIHeartUrl: frontmatter.podcastIHeartUrl,
|
||||
podcastAppleUrl: frontmatter.podcastAppleUrl,
|
||||
podcastAmazonUrl: frontmatter.podcastAmazonUrl,
|
||||
published: frontmatter.published ?? true,
|
||||
metrics: frontmatter.metrics,
|
||||
hideCoverImage: frontmatter.hideCoverImage ?? false,
|
||||
};
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
const shouldIncludePost = !frontmatter.draft || isDevelopment;
|
||||
|
||||
if (shouldIncludePost && (!filterFn || filterFn(post))) {
|
||||
allPosts.push(post);
|
||||
}
|
||||
}
|
||||
return sortPosts(allPosts);
|
||||
}
|
||||
|
||||
// Optimize this so we don't read the FS multiple times
|
||||
async getBlogPostBySlug(
|
||||
slug: string | null
|
||||
): Promise<BlogPostDataEntry | undefined> {
|
||||
if (!slug) throw new Error(`Could not find blog post with slug: ${slug}`);
|
||||
return (await this.getBlogs()).find((post) => post.slug === slug);
|
||||
}
|
||||
|
||||
private calculateSlug(filePath: string, frontmatter: any): string {
|
||||
const baseName = basename(filePath, '.md');
|
||||
return frontmatter.slug || baseName;
|
||||
}
|
||||
|
||||
private dateFromFileName(filename: string): string {
|
||||
const timeString = new Date().toISOString().split('T')[1];
|
||||
const regexp = /^(\d\d\d\d-\d\d-\d\d).+$/;
|
||||
const match = filename.match(regexp);
|
||||
if (match) {
|
||||
return new Date(match[1] + ' ' + timeString).toISOString();
|
||||
} else {
|
||||
throw new Error(`Could not parse date from filename: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateDate(filename: string, frontmatter: any): string {
|
||||
const date: Date = new Date();
|
||||
const timeString = date.toISOString().split('T')[1];
|
||||
if (frontmatter.date) {
|
||||
return new Date(
|
||||
frontmatter.date.toISOString().split('T')[0] + 'T' + timeString
|
||||
).toISOString();
|
||||
} else {
|
||||
return this.dateFromFileName(filename);
|
||||
}
|
||||
}
|
||||
|
||||
private fileExists(filePath: string): boolean {
|
||||
try {
|
||||
accessSync(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private determineOgImage(imagePath: string): {
|
||||
image: string;
|
||||
type: string;
|
||||
} {
|
||||
const allowedExtensions = ['.png', '.webp', '.jpg', '.jpeg'];
|
||||
const defaultImage = 'https://nx.dev/socials/nx-media.png';
|
||||
const defaultType = 'png';
|
||||
|
||||
if (!imagePath) {
|
||||
return { image: defaultImage, type: defaultType };
|
||||
}
|
||||
|
||||
const { ext } = parse(imagePath);
|
||||
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
const foundExt = allowedExtensions.find((allowedExt) => {
|
||||
const ogImagePath = imagePath.replace(ext, allowedExt);
|
||||
return this.fileExists(
|
||||
join(
|
||||
'public',
|
||||
'documentation',
|
||||
resolve(this.options.blogRoot, ogImagePath)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
if (!foundExt) {
|
||||
return { image: defaultImage, type: defaultType };
|
||||
}
|
||||
|
||||
return {
|
||||
image: join('documentation', imagePath.replace(ext, foundExt)),
|
||||
type: foundExt.replace('.', ''),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
image: join('documentation', imagePath),
|
||||
type: ext.replace('.', ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type BlogPostDataEntry = {
|
||||
title: string;
|
||||
content: string;
|
||||
description: string;
|
||||
authors: BlogAuthor[];
|
||||
date: string;
|
||||
cover_image: string | null;
|
||||
tags: string[];
|
||||
reposts: string[];
|
||||
updated?: string;
|
||||
pinned?: boolean;
|
||||
filePath: string;
|
||||
slug: string;
|
||||
youtubeUrl?: string;
|
||||
podcastYoutubeId?: string;
|
||||
podcastSpotifyId?: string;
|
||||
podcastAmazonUrl?: string;
|
||||
podcastAppleUrl?: string;
|
||||
podcastIHeartUrl?: string;
|
||||
published?: boolean;
|
||||
ogImage?: string;
|
||||
ogImageType?: string;
|
||||
metrics?: Array<{ value: string; label: string }>;
|
||||
hideCoverImage?: boolean;
|
||||
};
|
||||
|
||||
export type BlogAuthor = {
|
||||
name: string;
|
||||
image: string;
|
||||
twitter: string;
|
||||
github: string;
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { sortPosts } from './blog.util';
|
||||
|
||||
describe('sortPosts', () => {
|
||||
test('sort from latest to earliest', () => {
|
||||
const posts = [
|
||||
createPost({ date: '2022-01-01', title: 'Post 1', slug: 'post-1' }),
|
||||
createPost({ date: '2023-01-01', title: 'Post 2', slug: 'post-2' }),
|
||||
];
|
||||
|
||||
const results = sortPosts(posts);
|
||||
|
||||
expect(results.map((p) => p.slug)).toEqual(['post-2', 'post-1']);
|
||||
});
|
||||
|
||||
test('latest pinned posts are presented first', () => {
|
||||
const posts = [
|
||||
createPost({
|
||||
date: '2023-01-01',
|
||||
title: 'Post 1',
|
||||
slug: 'post-1',
|
||||
pinned: true,
|
||||
}),
|
||||
createPost({ date: '2023-02-01', title: 'Post 2', slug: 'post-2' }),
|
||||
createPost({
|
||||
date: '2023-03-01',
|
||||
title: 'Post 3',
|
||||
slug: 'post-3',
|
||||
pinned: true,
|
||||
}),
|
||||
createPost({
|
||||
date: '2023-04-01',
|
||||
title: 'Post 4',
|
||||
slug: 'post-4',
|
||||
pinned: true,
|
||||
}),
|
||||
createPost({ date: '2023-05-01', title: 'Post 5', slug: 'post-5' }),
|
||||
];
|
||||
|
||||
const results = sortPosts(posts);
|
||||
|
||||
expect(results.map((p) => p.slug)).toEqual([
|
||||
'post-4',
|
||||
'post-3',
|
||||
'post-1',
|
||||
'post-5',
|
||||
'post-2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function createPost(data: {
|
||||
date: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
pinned?: boolean;
|
||||
}) {
|
||||
return {
|
||||
content: '',
|
||||
filePath: `${data.slug}.md`,
|
||||
slug: data.slug,
|
||||
date: data.date,
|
||||
title: data.title,
|
||||
description: '',
|
||||
authors: [],
|
||||
ogImage: '',
|
||||
ogImageType: '',
|
||||
cover_image: '',
|
||||
tags: [],
|
||||
reposts: [],
|
||||
pinned: data.pinned ?? undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { BlogPostDataEntry } from './blog.model';
|
||||
|
||||
export function sortPosts(posts: BlogPostDataEntry[]): BlogPostDataEntry[] {
|
||||
return posts.sort((a, b) => {
|
||||
if (a.pinned === true && b.pinned !== true) return -1;
|
||||
if (b.pinned === true && a.pinned !== true) return 1;
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface ChangelogRawContentEntry {
|
||||
version: string;
|
||||
content: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export class ChangelogApi {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
id: string;
|
||||
changelogRoot: string;
|
||||
}
|
||||
) {
|
||||
if (!options.id) {
|
||||
throw new Error('id cannot be undefined');
|
||||
}
|
||||
if (!options.changelogRoot) {
|
||||
throw new Error('public docs root cannot be undefined');
|
||||
}
|
||||
}
|
||||
|
||||
getChangelogEntries(): ChangelogRawContentEntry[] {
|
||||
const files = readdirSync(this.options.changelogRoot);
|
||||
|
||||
return files.map((file) => {
|
||||
const filePath = join(this.options.changelogRoot, file);
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const version = file.replace('.md', '').replace(/_/g, '.');
|
||||
return {
|
||||
content,
|
||||
version,
|
||||
filePath,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import {
|
||||
pkgToGeneratedApiDocs,
|
||||
type DocumentMetadata,
|
||||
type ProcessedDocument,
|
||||
type RelatedDocument,
|
||||
} from '@nx/nx-dev-models-document';
|
||||
import { type ProcessedPackageMetadata } from '@nx/nx-dev-models-package';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { type TagsApi } from './tags.api';
|
||||
import { extractFrontmatter } from '@nx/nx-dev-ui-markdoc';
|
||||
|
||||
interface StaticDocumentPaths {
|
||||
params: { segments: string[] };
|
||||
}
|
||||
|
||||
export class DocumentsApi {
|
||||
private readonly manifest: Record<string, DocumentMetadata>;
|
||||
private readonly packagesManifest?: Record<string, ProcessedPackageMetadata>;
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
id: string;
|
||||
manifest: Record<string, DocumentMetadata>;
|
||||
packagesManifest?: Record<string, ProcessedPackageMetadata>;
|
||||
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);
|
||||
if (this.options.packagesManifest) {
|
||||
this.packagesManifest = structuredClone(this.options.packagesManifest);
|
||||
}
|
||||
if (
|
||||
options.id === 'angular-rspack-documents' ||
|
||||
options.id === 'angular-rsbuild-documents'
|
||||
) {
|
||||
this.manifest = Object.assign(
|
||||
this.manifest,
|
||||
this.getAngularRspackPackage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getManifestKey(path: string): string {
|
||||
return '/' + path;
|
||||
}
|
||||
|
||||
// TODO(colum): Remove this once we move angular rspack into main repo (when stable).
|
||||
getAngularRspackPackage(): Record<string, DocumentMetadata> {
|
||||
return {
|
||||
'/nx-api/angular-rspack/documents/create-config': {
|
||||
id: 'create-config',
|
||||
name: 'createConfig',
|
||||
description: 'createConfig for @nx/angular-rspack',
|
||||
path: '/nx-api/angular-rspack/documents/create-config',
|
||||
file: 'shared/guides/angular-rspack/api/nx-angular-rspack/create-config',
|
||||
isExternal: false,
|
||||
itemList: [],
|
||||
tags: [],
|
||||
},
|
||||
'/nx-api/angular-rspack/documents/create-server': {
|
||||
id: 'create-server',
|
||||
name: 'createServer',
|
||||
description: 'createServer for @nx/angular-rspack',
|
||||
path: '/nx-api/angular-rspack/documents/create-server',
|
||||
file: 'shared/guides/angular-rspack/api/nx-angular-rspack/create-server',
|
||||
isExternal: false,
|
||||
itemList: [],
|
||||
tags: [],
|
||||
},
|
||||
'/nx-api/angular-rsbuild/documents/create-config': {
|
||||
id: 'create-config',
|
||||
name: 'createConfig',
|
||||
description: 'createConfig for @nx/angular-rsbuild',
|
||||
path: '/nx-api/angular-rspack/documents/create-config',
|
||||
file: 'shared/guides/angular-rspack/api/nx-angular-rsbuild/create-config',
|
||||
isExternal: false,
|
||||
itemList: [],
|
||||
tags: [],
|
||||
},
|
||||
'/nx-api/angular-rsbuild/documents/create-server': {
|
||||
id: 'create-server',
|
||||
name: 'createServer',
|
||||
description: 'createServer for @nx/angular-rsbuild',
|
||||
path: '/nx-api/angular-rspack/documents/create-server',
|
||||
file: 'shared/guides/angular-rspack/api/nx-angular-rsbuild/create-server',
|
||||
isExternal: false,
|
||||
itemList: [],
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getFilePath(path: string): string {
|
||||
return join(this.options.publicDocsRoot, path);
|
||||
}
|
||||
|
||||
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[] {
|
||||
let paths: string[] = [];
|
||||
|
||||
// Add regular document paths
|
||||
if (this.options.prefix) {
|
||||
paths = Object.keys(this.manifest).map(
|
||||
(path) => `/${this.options.prefix}` + path
|
||||
);
|
||||
} else {
|
||||
paths = Object.keys(this.manifest);
|
||||
}
|
||||
|
||||
// API Docs
|
||||
if (this.packagesManifest) {
|
||||
const packages = Object.values(this.packagesManifest);
|
||||
|
||||
packages.forEach((pkg) => {
|
||||
const apiDocData = pkgToGeneratedApiDocs[pkg.name];
|
||||
// Packages are not mapped, skip creating URLs for them.
|
||||
if (!apiDocData) return;
|
||||
const apiPagePath = apiDocData.pagePath;
|
||||
paths.push(apiPagePath);
|
||||
|
||||
if (apiDocData.includeDocuments) {
|
||||
paths.push(`${apiPagePath}/documents`);
|
||||
Object.keys(pkg.documents).forEach((path) => {
|
||||
paths.push(path);
|
||||
});
|
||||
|
||||
// Legacy devkit API documents
|
||||
if (pkg.name === 'devkit') {
|
||||
readdirSync('../../docs/generated/devkit').forEach((fileName) => {
|
||||
// Private files
|
||||
if (fileName.startsWith('.')) return;
|
||||
if (fileName.endsWith('.md')) {
|
||||
const apiDocPath = `${
|
||||
pkgToGeneratedApiDocs['devkit'].pagePath
|
||||
}/documents/${fileName.replace('.md', '')}`;
|
||||
paths.push(apiDocPath);
|
||||
} else {
|
||||
readdirSync('../../docs/generated/devkit/' + fileName).forEach(
|
||||
(subFileName) => {
|
||||
const apiDocPath = `${
|
||||
pkgToGeneratedApiDocs['devkit'].pagePath
|
||||
}/documents/${fileName}/${subFileName.replace('.md', '')}`;
|
||||
paths.push(apiDocPath);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
paths.push(`${apiPagePath}/executors`);
|
||||
Object.keys(pkg.executors).forEach((path) => {
|
||||
paths.push(path);
|
||||
});
|
||||
|
||||
paths.push(`${apiPagePath}/generators`);
|
||||
Object.keys(pkg.generators).forEach((path) => {
|
||||
paths.push(path);
|
||||
});
|
||||
|
||||
paths.push(`${apiPagePath}/migrations`);
|
||||
});
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
getDocument(path: string[]): ProcessedDocument {
|
||||
const document: DocumentMetadata | null =
|
||||
this.manifest[this.getManifestKey(path.join('/'))] || null;
|
||||
|
||||
if (!document) {
|
||||
// Legacy handler for devkit docs
|
||||
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}.md`), 'utf8'),
|
||||
description: '',
|
||||
filePath: this.getFilePath(file),
|
||||
id: path.at(-1) || '',
|
||||
name: path.at(-1) || '',
|
||||
relatedDocuments: {},
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
// NEW Legacy handler for devkit docs
|
||||
if (
|
||||
path[0] === 'reference' &&
|
||||
path[1] === 'core-api' &&
|
||||
path[2] === 'devkit' &&
|
||||
path[3] === 'documents'
|
||||
) {
|
||||
const file = `generated/devkit/${path.slice(4).join('/')}`;
|
||||
return {
|
||||
content: readFileSync(this.getFilePath(`${file}.md`), '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}.md`), 'utf8'),
|
||||
description: document.description,
|
||||
filePath: this.getFilePath(`${document.file}.md`),
|
||||
id: document.id,
|
||||
name: document.name,
|
||||
mediaImage: document.mediaImage || '',
|
||||
relatedDocuments: this.getRelatedDocuments(document.tags),
|
||||
parentDocuments: path.map((segment, index): RelatedDocument => {
|
||||
const parentPath = path.slice(0, index + 1).join('/');
|
||||
const parentDocument =
|
||||
this.manifest[this.getManifestKey(parentPath)] || null;
|
||||
if (!parentDocument) {
|
||||
return {
|
||||
id: segment,
|
||||
name: '',
|
||||
description: '',
|
||||
file: '',
|
||||
path: '/' + path.slice(0, index + 1).join('/'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: parentDocument.id,
|
||||
name: parentDocument.name,
|
||||
description: parentDocument.description,
|
||||
file: parentDocument.file,
|
||||
path: parentDocument.path,
|
||||
};
|
||||
}),
|
||||
tags: document.tags,
|
||||
};
|
||||
}
|
||||
|
||||
getRelatedDocuments(tags: string[]): Record<string, RelatedDocument[]> {
|
||||
const relatedDocuments = {};
|
||||
tags.forEach(
|
||||
(tag) =>
|
||||
(relatedDocuments[tag] = this.options.tagsApi.getAssociatedItems(tag))
|
||||
);
|
||||
|
||||
return relatedDocuments;
|
||||
}
|
||||
|
||||
isDocumentIndex(document: DocumentMetadata): boolean {
|
||||
return !!document.itemList.length || !document.file;
|
||||
}
|
||||
|
||||
generateDocumentIndexTemplate(document: DocumentMetadata): string {
|
||||
const cardsTemplate = document.itemList
|
||||
.map((i) => {
|
||||
// Try to get description from frontmatter first
|
||||
let description = i.description ?? '';
|
||||
|
||||
// Get the document metadata to find the file path
|
||||
// i.path might already have a leading slash or might not, so we need to check both
|
||||
const itemDocument =
|
||||
this.manifest[i.path] || this.manifest[this.getManifestKey(i.path)];
|
||||
|
||||
if (itemDocument && itemDocument.file) {
|
||||
const filePath = this.getFilePath(`${itemDocument.file}.md`);
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const frontmatter = extractFrontmatter(content);
|
||||
// Use frontmatter description if available, otherwise fall back to map.json description
|
||||
description = frontmatter.description || description;
|
||||
} catch (e) {
|
||||
// If there's an error reading the file, fall back to the original description
|
||||
console.warn(`Could not read frontmatter from ${filePath}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: i.name,
|
||||
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`,
|
||||
...(!document.itemList.length
|
||||
? ['No items found.']
|
||||
: ['{% 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}.md`), 'utf8'),
|
||||
description: document.description,
|
||||
filePath: this.getFilePath(`${document.file}.md`),
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { BlogApi } from './blog.api';
|
||||
import { PodcastDataEntry } from './podcast.model';
|
||||
|
||||
export class PodcastApi {
|
||||
_blogApi: BlogApi;
|
||||
|
||||
constructor(options: { blogApi: BlogApi }) {
|
||||
this._blogApi = options.blogApi;
|
||||
}
|
||||
|
||||
async getPodcastBlogs(): Promise<PodcastDataEntry[]> {
|
||||
return await this._blogApi.getBlogs((post) =>
|
||||
post.tags.map((t) => t.toLowerCase()).includes('podcast')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BlogPostDataEntry } from './blog.model';
|
||||
|
||||
export interface PodcastDataEntry extends BlogPostDataEntry {
|
||||
duration?: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { RelatedDocument } from '@nx/nx-dev-models-document';
|
||||
|
||||
export class TagsApi {
|
||||
private readonly manifest: Record<string, RelatedDocument[]>;
|
||||
constructor(private readonly tags: Record<string, RelatedDocument[]>) {
|
||||
if (!tags) {
|
||||
throw new Error('tags property cannot be undefined');
|
||||
}
|
||||
|
||||
this.manifest = structuredClone(this.tags);
|
||||
}
|
||||
|
||||
getAssociatedItems(tag: string): RelatedDocument[] {
|
||||
const items: RelatedDocument[] | null = this.manifest[tag] || null;
|
||||
|
||||
if (!items) throw new Error(`No associated items found for tag: "${tag}"`);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
getAssociatedItemsFromTags(tags: string[]): RelatedDocument[] {
|
||||
return this.sortAndDeduplicateItems(
|
||||
tags.map((t) => this.getAssociatedItems(t))
|
||||
);
|
||||
}
|
||||
|
||||
sortAndDeduplicateItems(tags: RelatedDocument[][]): RelatedDocument[] {
|
||||
return tags.flat().reduce((acc, item: RelatedDocument) => {
|
||||
if (acc.findIndex((curr) => curr.file === item.file) === -1) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, [] as RelatedDocument[]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { BlogApi } from './blog.api';
|
||||
import { WebinarDataEntry } from './webinar.model';
|
||||
|
||||
export class WebinarApi {
|
||||
_blogApi: BlogApi;
|
||||
|
||||
constructor(options: { blogApi: BlogApi }) {
|
||||
this._blogApi = options.blogApi;
|
||||
}
|
||||
|
||||
async getWebinarBlogs(): Promise<WebinarDataEntry[]> {
|
||||
return await this._blogApi.getBlogs((post) =>
|
||||
post.tags.map((t) => t.toLowerCase()).includes('webinar')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { BlogPostDataEntry } from './blog.model';
|
||||
|
||||
export interface WebinarDataEntry extends BlogPostDataEntry {
|
||||
status?: 'Upcoming' | 'Past - Gated' | 'Past - Ungated';
|
||||
eventDate?: string;
|
||||
time?: string;
|
||||
registrationUrl?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './lib/documents.api';
|
||||
export * from './lib/changelog.api';
|
||||
export * from './lib/blog.util';
|
||||
export * from './lib/blog.api';
|
||||
export * from './lib/blog.model';
|
||||
export * from './lib/tags.api';
|
||||
export * from './lib/podcast.model';
|
||||
export * from './lib/podcast.api';
|
||||
export * from './lib/webinar.model';
|
||||
export * from './lib/webinar.api';
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "dist",
|
||||
"types": ["node"],
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../ui-markdoc/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../models-package/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../models-document/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/devkit/tsconfig.lib.json"
|
||||
}
|
||||
],
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "dist/spec",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx",
|
||||
"**/*.d.ts",
|
||||
"jest.config.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user