kompendium
Version:
Documentation generator for Stencil components
303 lines (240 loc) • 8.22 kB
text/typescript
import { JsonDocs, Config, Logger } from '@stencil/core/internal';
import { defaultConfig } from './config';
import { addSources } from './source';
import lnk from 'lnk';
import { createMenu } from './menu';
import { copyFile, mkdir, readFile, stat, writeFile } from 'fs/promises';
import { exists } from './filesystem';
import { createWatcher } from './watch';
import { findGuides } from './guides';
import { KompendiumConfig, KompendiumData, TypeDescription } from '../types';
import { parseFile } from './typedoc';
import { createSchemas } from './schema';
import { createIndex } from './search';
export const kompendium = (config: Partial<KompendiumConfig> = {}) => {
if (!generateDocs()) {
return () => null;
}
return kompendiumGenerator(config);
};
let logger: Logger;
export function kompendiumGenerator(
config: Partial<KompendiumConfig>,
): (docs: JsonDocs, stencilConfig: Config) => Promise<void> {
config = {
...defaultConfig,
...config,
};
initialize(config);
return async (docs: JsonDocs, stencilConfig: Config) => {
logger = stencilConfig.logger;
const timeSpan = logger.createTimeSpan('kompendium started');
const [jsonDocs, title, readme, guides, types] = await Promise.all([
addSources(docs),
getProjectTitle(config),
getReadme(),
findGuides(config),
getTypes(config, stencilConfig.tsconfig),
]);
const data: KompendiumData = {
docs: jsonDocs,
title: title,
logo: config.logo,
menu: createMenu(docs, guides, types),
readme: readme,
guides: guides,
types: types,
schemas: createSchemas(docs.components, types),
index: null,
};
data.index = createIndex(data);
await writeData(config, data);
timeSpan.finish('kompendium finished');
};
}
async function initialize(config: Partial<KompendiumConfig>) {
const path = `${config.publicPath}/kompendium.json`;
if (isWatcher()) {
createWatcher(path, 'unlink', onUnlink(config));
}
await createOutputDirs(config);
}
const onUnlink = (config: Partial<KompendiumConfig>) => () => {
createSymlink(config);
};
async function createSymlink(config: Partial<KompendiumConfig>) {
const source = `${config.path}/kompendium.json`;
const target = `${config.publicPath}/kompendium.json`;
if (!(await exists(source))) {
return;
}
if (await exists(target)) {
return;
}
lnk([source], config.publicPath);
}
async function getProjectTitle(
config: Partial<KompendiumConfig>,
): Promise<string> {
if (config.title) {
return config.title;
}
const json = await readFile('./package.json', 'utf8');
const data = JSON.parse(json);
return data.name
.replace(/^@[^/]+?\//, '')
.split('-')
.join(' ');
}
async function writeData(
config: Partial<KompendiumConfig>,
data: KompendiumData,
) {
// Always write to the kompendium config folder (typically `.kompendium` in
// the root of the project) to avoid Stencil deleting the file during build.
const filePath = `${config.path}/kompendium.json`;
await writeFile(filePath, JSON.stringify(data), 'utf8');
if (isProd()) {
// In production, we used to write the kompendium.json file to the
// public path. We now copy the file to the public path for backwards
// compatibility with projects that do not have problems with Stencil
// deleting the file during build. For projects that do have this
// problem, they can always copy the file from the config folder.
const publicFilePath = `${config.publicPath}/kompendium.json`;
if (!(await exists(config.publicPath))) {
await mkdir(config.publicPath, { recursive: true });
}
await copyFile(filePath, publicFilePath);
}
if (isWatcher()) {
createSymlink(config);
}
}
async function createOutputDirs(config: Partial<KompendiumConfig>) {
let path = config.path;
if (!(await exists(path))) {
mkdir(path, { recursive: true });
}
path = config.publicPath;
if (!(await exists(path))) {
mkdir(path, { recursive: true });
}
}
async function getReadme(): Promise<string> {
const files = ['readme.md', 'README.md', 'README', 'readme'];
let data = null;
for (const file of files) {
if (data) {
continue;
}
if (!(await exists(file))) {
continue;
}
data = await readFile(file, 'utf8');
}
if (!data) {
logger.warn('README did not exist');
}
return data;
}
function generateDocs(): boolean {
return !!process.argv.includes('--docs');
}
function isWatcher(): boolean {
return !!process.argv.includes('--watch');
}
function isProd(): boolean {
return !(
process.argv.includes('--dev') ||
process.argv.includes('test') ||
process.argv.find((arg) => arg.includes('jest-worker'))
);
}
async function getTypes(
config: Partial<KompendiumConfig>,
tsconfig?: string,
): Promise<TypeDescription[]> {
logger.debug('Getting type information...');
let types = await readTypes(config);
const cache = await readCache(config);
if (types.length === 0 || (await isModified(types, cache))) {
logger.debug('Parsing types...');
const data = parseFile(config.typeRoot, tsconfig);
await saveData(config, data);
types = data;
}
return types;
}
async function isModified(types: any[], cache: Record<string, number>) {
if (Object.keys(cache).length === 0) {
return true;
}
const filenames = getUniqueSourceFilenames(types);
const stats = await Promise.all(filenames.map(tryStatFile));
return stats.some((fileStat, index) =>
hasFileChangedSinceCached(filenames[index], fileStat, cache),
);
}
function getUniqueSourceFilenames(types: any[]): string[] {
const filenames = types.map((t) => t.sources).flat();
return [...new Set(filenames)];
}
function tryStatFile(filename: string) {
return stat(filename).catch(() => null);
}
function hasFileChangedSinceCached(
filename: string,
fileStat: Awaited<ReturnType<typeof stat>> | null,
cache: Record<string, number>,
): boolean {
if (!fileStat) {
logger.debug(`${filename} cannot be accessed, marking as modified`);
return true;
}
const result = cache[filename] !== fileStat.mtimeMs;
logger.debug(`${filename} was ${result ? '' : 'not'} modified!`);
return result;
}
async function saveData(
config: Partial<KompendiumConfig>,
types: TypeDescription[],
) {
const filenames = getUniqueSourceFilenames(types);
const stats = await Promise.all(filenames.map(tryStatFile));
const cache = buildCacheFromFileStats(filenames, stats);
await Promise.all([writeCache(config, cache), writeTypes(config, types)]);
}
function buildCacheFromFileStats(
filenames: string[],
stats: Array<Awaited<ReturnType<typeof stat>> | null>,
): Record<string, number> {
const cache: Record<string, number> = {};
stats.forEach((fileStat, index) => {
if (fileStat) {
cache[filenames[index]] = fileStat.mtimeMs;
}
});
return cache;
}
async function readCache(config: Partial<KompendiumConfig>) {
try {
const data = await readFile(`${config.path}/cache.json`, 'utf8');
return JSON.parse(data);
} catch {
return {};
}
}
async function writeCache(config: Partial<KompendiumConfig>, data: any) {
await writeFile(`${config.path}/cache.json`, JSON.stringify(data), 'utf8');
}
async function readTypes(config: Partial<KompendiumConfig>) {
try {
const data = await readFile(`${config.path}/types.json`, 'utf8');
return JSON.parse(data);
} catch {
return [];
}
}
async function writeTypes(config: Partial<KompendiumConfig>, data: any) {
await writeFile(`${config.path}/types.json`, JSON.stringify(data), 'utf8');
}