handoff-app
Version:
Automated documentation toolchain for building client side documentation from figma
727 lines (657 loc) • 22 kB
text/typescript
import { ChangelogRecord } from '@handoff/changelog';
import { ComponentListObject, ComponentType } from '@handoff/transformers/preview/types';
import { ComponentDocumentationOptions, PreviewObject } from '@handoff/types';
import { ClientConfig, IntegrationObject } from '@handoff/types/config';
import { findFilesByExtension } from '@handoff/utils/fs';
import * as fs from 'fs-extra';
import matter from 'gray-matter';
import { Types as CoreTypes } from 'handoff-core';
import { groupBy, merge, startCase, uniq } from 'lodash';
import path from 'path';
import { ParsedUrlQuery } from 'querystring';
import semver from 'semver';
import { SubPageType } from '../../pages/[level1]/[level2]';
// Get the parsed url string type
export interface IParams extends ParsedUrlQuery {
slug: string;
}
// Type for the metadata from frontmatter
export interface Metadata {
title: string;
description: string;
metaTitle: string;
metaDescription: string;
}
// Define what a section link looks like
export interface SectionLink {
title: string;
weight: number;
path: string;
subSections: {
title: string;
path: string;
image: string;
menu?: {
title: string;
path: string;
image: string;
}[];
}[];
}
// Documentation Page Properties
export interface DocumentationProps {
metadata: Metadata;
content?: string;
options?: ComponentDocumentationOptions;
menu: SectionLink[];
current: SectionLink;
config: ClientConfig;
}
export interface DocumentationWithTokensProps extends DocumentationProps {
css: string;
scss: string;
styleDictionary: string;
types: string;
}
export interface ChangelogDocumentationProps extends DocumentationProps {
changelog: ChangelogRecord[];
}
export interface FontDocumentationProps extends DocumentationProps {
customFonts: string[];
design: CoreTypes.IDocumentationObject['localStyles'];
}
export interface AssetDocumentationProps extends DocumentationProps {
assets: CoreTypes.IDocumentationObject['assets'];
}
export interface ComponentDocumentationProps extends DocumentationWithTokensProps {
id: string;
component: CoreTypes.IFileComponentObject;
legacyDefinition: CoreTypes.ILegacyComponentDefinition;
// definitions: DocumentComponentDefinitions;
previews: PreviewObject[];
componentOptions: CoreTypes.IHandoffConfigurationComponentOptions;
}
export interface FoundationDocumentationProps extends DocumentationWithTokensProps {
design: CoreTypes.IDocumentationObject['localStyles'];
}
/**
* List the default paths
*/
export const knownPaths = [
'assets',
'assets/fonts',
'assets/icons',
'assets/logos',
'foundations',
'foundations/colors',
'foundations/effects',
'foundations/logos',
'foundations/typography',
'system',
'system/component',
'changelog',
];
/**
* Get the plural name of a component
* @param singular
* @returns
*/
export const pluralizeComponent = (singular: string): string => {
return (
{
button: 'buttons',
select: 'selects',
checkbox: 'checkboxes',
radio: 'radios',
input: 'inputs',
tooltip: 'tooltips',
alert: 'alerts',
switch: 'switches',
pagination: 'pagination',
modal: 'modal',
}[singular] ?? singular
);
};
/**
* Build level 1 static path parameters
* @returns
*/
export const buildL1StaticPaths = () => {
const docRoot = path.resolve(process.env.HANDOFF_MODULE_PATH ?? '', 'config/docs');
const files = fs.readdirSync(docRoot);
const pageRoot = path.resolve(process.env.HANDOFF_WORKING_PATH ?? '', 'pages');
let list = files;
if (fs.existsSync(pageRoot)) {
const pages = fs.readdirSync(pageRoot);
list = files.concat(pages);
}
const paths = list
.filter((fileName) => {
if (fs.existsSync(path.join(docRoot, fileName))) return !fs.lstatSync(path.join(docRoot, fileName)).isDirectory();
if (fs.existsSync(path.join(pageRoot, fileName))) return !fs.lstatSync(path.join(pageRoot, fileName)).isDirectory();
return false;
})
.filter((fileName) => fileName.endsWith('.md'))
.map((fileName) => {
const path = fileName.replace('.md', '');
if (knownPaths.indexOf(path) < 0) {
return {
params: {
level1: path,
},
};
}
})
.filter(filterOutUndefined);
return paths;
};
/**
* Build static paths for level 2
* @returns SubPathType[]
*/
export const buildL2StaticPaths = () => {
const docRoot = path.resolve(process.env.HANDOFF_MODULE_PATH ?? '', 'config/docs');
const files = fs.readdirSync(docRoot);
const pageRoot = path.resolve(process.env.HANDOFF_WORKING_PATH ?? '', 'pages');
let list = files;
if (fs.existsSync(pageRoot)) {
const pages = fs.readdirSync(pageRoot);
list = files.concat(pages);
}
const paths: SubPageType[] = list
.flatMap((fileName) => {
let calculatePath;
if (fs.existsSync(path.join(pageRoot, fileName))) {
calculatePath = path.join(pageRoot, fileName);
} else if (fs.existsSync(path.join(docRoot, fileName))) {
calculatePath = path.join(docRoot, fileName);
} else {
return undefined;
}
if (fs.lstatSync(calculatePath).isDirectory()) {
const subFiles = fs.readdirSync(calculatePath);
return subFiles
.filter((subFile) => subFile.endsWith('.md'))
.flatMap((subFile) => {
const childPath = fileName.replace('.md', '');
if (knownPaths.indexOf(childPath) < 0) {
return {
params: {
level1: fileName,
level2: subFile.replace('.md', ''),
},
};
}
})
.filter(filterOutUndefined);
}
})
.filter(filterOutUndefined);
return paths;
};
/**
* Build the static menu for rendeirng pages
* @returns SectionLink[]
*/
export const staticBuildMenu = () => {
// // Contents of docs
const docRoot = path.join(process.env.HANDOFF_MODULE_PATH ?? '', 'config/docs');
// Get the file list
const files = fs.readdirSync(docRoot);
let list = files;
const workingPages = path.resolve(process.env.HANDOFF_WORKING_PATH ?? '', 'pages');
let pages: string[] = [];
if (fs.existsSync(workingPages)) {
pages = fs.readdirSync(workingPages);
list = list.concat(pages);
}
const sections: SectionLink[] = [];
// Build path tree
const custom = uniq(list)
.map((fileName) => {
let search = '';
if (pages.includes(fileName)) {
search = path.resolve(workingPages, fileName);
} else {
search = path.resolve(docRoot, fileName);
}
if (
!fs.lstatSync(search).isDirectory() &&
search !== path.resolve(docRoot, 'index.md') &&
search !== path.resolve(workingPages, 'index.md') &&
(fileName.endsWith('md') || fileName.endsWith('mdx'))
) {
const contents = fs.readFileSync(search, 'utf-8');
const { data: metadata } = matter(contents);
if (metadata.enabled === false) {
return undefined;
}
const filepath = `/${fileName.replace('.mdx', '').replace('.md', '')}`;
let subSections = [];
if (metadata.menu) {
// Build the submenu
subSections = Object.keys(metadata.menu)
.map((key) => {
const sub = metadata.menu[key];
if (sub.components) {
// The user wants to inject the component menu here
return {
title: sub.title,
menu: staticBuildComponentMenu(sub.components),
};
}
if (sub.tokens) {
// The user wants to inject the component menu here
return {
title: 'Tokens',
menu: staticBuildTokensMenu(),
};
}
if (sub.enabled !== false) {
return sub;
}
})
.filter(filterOutUndefined);
}
return {
title: metadata.menuTitle ?? metadata.title,
weight: metadata.weight,
path: filepath,
subSections,
};
}
})
.filter(filterOutUndefined);
return sections.concat(custom).sort((a: SectionLink, b: SectionLink) => a.weight - b.weight);
};
const staticBuildComponentMenu = (type?: string) => {
let menu = [];
let components = fetchComponents();
if (type) {
components = components.filter((c) => c.type === type);
}
// Build the submenu of exportables (components)
const groupedComponents = groupBy(components, (e) => e.group ?? '');
Object.keys(groupedComponents).forEach((group) => {
const menuGroup = { title: group, menu: [] };
groupedComponents[group].forEach((component) => {
const docs = fetchDocPageMetadataAndContent('docs/components/', component.id);
let title = startCase(component.id);
if (docs.metadata.title) {
title = docs.metadata.title;
}
if (component.name) {
title = component.name;
}
menuGroup.menu.push({ path: `system/component/${component.id}`, title });
});
// sort the menu group by name alphabetical
menuGroup.menu = menuGroup.menu.sort((a, b) => a.title.localeCompare(b.title));
menu.push(menuGroup);
});
// sort the menu by name alphabetical
menu = menu.sort((a, b) => a.title.localeCompare(b.title));
return menu;
};
const staticBuildTokensMenu = () => {
const menu = [
{
title: `Foundations`,
path: `system/tokens/foundations`,
menu: [
{
title: `Colors`,
path: `system/tokens/foundations/colors`,
},
{
title: `Effects`,
path: `system/tokens/foundations/effects`,
},
{
title: `Typography`,
path: `system/tokens/foundations/typography`,
},
],
},
];
const componentMenuItems = [];
const components = fetchComponents(false);
// Build the submenu of exportables (components)
const groupedComponents = groupBy(components, (e) => e.group ?? '');
Object.keys(groupedComponents).forEach((group) => {
groupedComponents[group].forEach((component) => {
const docs = fetchDocPageMetadataAndContent('docs/components/', component.id);
let title = startCase(component.id);
if (docs.metadata.title) {
title = docs.metadata.title;
}
if (component.name) {
title = component.name;
}
componentMenuItems.push({ path: `system/tokens/components/${component.id}`, title });
});
});
if (componentMenuItems.length > 0) {
menu.push({
title: `Components`,
path: `system/tokens/components`,
menu: componentMenuItems,
});
}
return menu;
};
const staticBuildTokenMenu = () => {
let subSections = {
title: 'Tokens',
path: 'system/tokens',
menu: [],
};
const tokens = getTokens();
return subSections;
};
/**
* Filter the menus by the current path
* @param menu
* @param path
* @returns SectionLink | null
*/
export const getCurrentSection = (menu: SectionLink[], path: string): SectionLink | null =>
menu.filter((section) => section.path === path)[0];
/**
* Build a static object for rending markdown pages
* @param path
* @param slug
* @returns
*/
export const fetchDocPageMarkdown = (path: string, slug: string | undefined, id: string, integrationObject?: IntegrationObject) => {
const menu = staticBuildMenu();
const { metadata, content, options } = fetchDocPageMetadataAndContent(path, slug, integrationObject);
// Return props
return {
props: {
metadata,
content,
options,
menu,
current: getCurrentSection(menu, `${id}`) ?? null,
},
};
};
export const fetchMdxPageMarkdown = () => {
//const menu = staticBuildMenu();
// Return props
return {
props: {
menu: [],
current: [],
},
};
};
/**
* Fetch Component Doc Page Markdown
* @param path
* @param slug
* @param id
* @returns
*/
export const fetchCompDocPageMarkdown = (path: string, slug: string | undefined, id: string, integrationObject?: IntegrationObject) => {
return {
props: {
...fetchDocPageMarkdown(path, slug, id, integrationObject).props,
scss: slug ? fetchTokensString(slug, 'scss') : '',
css: slug ? fetchTokensString(slug, 'css') : '',
styleDictionary: slug ? fetchTokensString(slug, 'styleDictionary') : '',
types: slug ? fetchTokensString(slug, 'types') : '',
},
};
};
/**
* Fetch exportables id's from the JSON files in the exportables directory
* @returns {string[]}
*/
export const fetchComponents = (fetchAll: boolean = true) => {
let components: Record<
string,
Omit<CoreTypes.IFileComponentObject, 'instances'> & { type?: ComponentType; group?: string; description?: string; name?: string }
> = getTokens().components;
if (fetchAll) {
const componentIds = Array.from(
new Set<string>(
(
JSON.parse(
fs.readFileSync(
path.resolve(
process.env.HANDOFF_MODULE_PATH ?? '',
'.handoff',
`${process.env.HANDOFF_PROJECT_ID}`,
'public',
'api',
'components.json'
),
'utf-8'
)
) as ComponentListObject[]
).map((c) => c.id)
)
);
for (const componentId of componentIds) {
const metadata = getLatestComponentMetadata(componentId);
if (metadata) {
components[componentId] = {
type: metadata.type as ComponentType,
group: metadata.group || '',
description: metadata.description || '',
name: metadata.title || '',
};
}
}
}
const items =
Object.entries(components).map(([id, obj]) => ({
id,
type: obj.type || 'Components',
group: obj.group || '',
name: obj.name || '',
description: obj.description || '',
})) ?? [];
try {
return items;
} catch (e) {
return null;
}
};
type RuntimeCache = IntegrationObject & { config: ClientConfig };
let cachedRuntimeCache: RuntimeCache | null = null;
const loadRuntimeCache = (): RuntimeCache => {
if (cachedRuntimeCache) {
return cachedRuntimeCache;
}
const runtimeCachePath = path.resolve(process.env.HANDOFF_EXPORT_PATH, 'runtime.cache.json');
if (!fs.existsSync(runtimeCachePath)) {
throw new Error(`Runtime cache not found at: ${runtimeCachePath}`);
}
try {
const cacheContent = fs.readFileSync(runtimeCachePath, 'utf-8');
cachedRuntimeCache = JSON.parse(cacheContent) as RuntimeCache;
return cachedRuntimeCache;
} catch (e) {
throw new Error(`Error reading runtime cache: ${runtimeCachePath}`);
}
};
export const getLatestComponentMetadata = (id: string) => {
const runtimeCache = loadRuntimeCache();
const components = runtimeCache.entries?.components;
if (!components || !components[id]) {
return false;
}
const versions = Object.keys(components[id]);
if (!versions.length) {
return false;
}
// Use natural version sorting (optional improvement below!)
const latestVersion = semver.rsort(versions).shift();
if (!latestVersion) {
return false;
}
const latestComponent = components[id][latestVersion];
return latestComponent || false;
};
/**
* Returns the legacy component definition for component with the given name.
* @deprecated Will be removed before 1.0.0 release.
*/
export const getLegacyDefinition = (name: string) => {
const config = getClientRuntimeConfig();
const sourcePath = path.resolve(process.env.HANDOFF_WORKING_PATH, 'exportables');
if (!fs.existsSync(sourcePath)) {
return null;
}
const definitionPaths = (findFilesByExtension(sourcePath, '.json') ?? []).filter((path) => path.split('/').pop() === name);
if (definitionPaths.length === 0) {
return null;
}
const data = fs.readFileSync(definitionPaths[0], 'utf-8');
const exportable = JSON.parse(data.toString()) as CoreTypes.ILegacyComponentDefinition;
const exportableOptions = {};
merge(exportableOptions, exportable.options);
exportable.options = exportableOptions as CoreTypes.ILegacyComponentDefinitionOptions;
return exportable;
};
/**
* Fetch Component Doc Page Markdown
* @param path
* @param slug
* @param id
* @returns
*/
export const fetchFoundationDocPageMarkdown = (path: string, slug: string | undefined, id: string) => {
return {
props: {
...fetchDocPageMarkdown(path, slug, id).props,
scss: slug ? fetchTokensString(pluralizeComponent(slug), 'scss') : '',
css: slug ? fetchTokensString(pluralizeComponent(slug), 'css') : '',
styleDictionary: slug ? fetchTokensString(pluralizeComponent(slug), 'styleDictionary') : '',
types: slug ? fetchTokensString(pluralizeComponent(slug), 'types') : '',
},
};
};
export const getClientRuntimeConfig = (): ClientConfig => {
const runtimeCache = loadRuntimeCache();
return runtimeCache.config;
};
export const getTokens = (): CoreTypes.IDocumentationObject => {
const exportedFilePath = process.env.HANDOFF_EXPORT_PATH
? path.resolve(process.env.HANDOFF_EXPORT_PATH, 'tokens.json')
: path.resolve(process.cwd(), process.env.HANDOFF_OUTPUT_DIR ?? 'exported', 'tokens.json');
if (!fs.existsSync(exportedFilePath)) return {} as CoreTypes.IDocumentationObject;
const data = fs.readFileSync(exportedFilePath, 'utf-8');
return JSON.parse(data.toString()) as CoreTypes.IDocumentationObject;
};
export const getChangelog = () => {
const exportedFilePath = process.env.HANDOFF_EXPORT_PATH
? path.resolve(process.env.HANDOFF_EXPORT_PATH, 'changelog.json')
: path.resolve(process.cwd(), process.env.HANDOFF_OUTPUT_DIR ?? 'exported', 'changelog.json');
if (!fs.existsSync(exportedFilePath)) return [];
const data = fs.readFileSync(exportedFilePath, 'utf-8');
return JSON.parse(data.toString()) as ChangelogRecord[];
};
/**
* Reduce a slug which can be either an array or string, to just a string by
* plucking the first element
* @param slug
* @returns
*/
export const reduceSlugToString = (slug: string | string[] | undefined): string | undefined => {
let prop: string | undefined;
if (Array.isArray(slug)) {
if (slug[0]) {
prop = slug[0];
}
} else {
prop = slug;
}
return prop;
};
/**
* Get doc meta and content from markdown
* @param path
* @param slug
* @returns
*/
export const fetchDocPageMetadataAndContent = (
localPath: string,
slug: string | string[] | undefined,
integrationObject?: IntegrationObject
) => {
const pagePath = localPath.replace('docs/', 'pages/');
const handoffModulePath = process.env.HANDOFF_MODULE_PATH ?? '';
const handoffWorkingPath = process.env.HANDOFF_WORKING_PATH ?? '';
let currentContents = '';
let options = {} as ComponentDocumentationOptions;
const contentModuleFilePath = path.resolve(handoffModulePath, 'config', `${localPath}${slug}.md`);
const contentWorkingFilePath = path.resolve(handoffWorkingPath, `${pagePath}${slug}.md`);
if (fs.existsSync(contentWorkingFilePath)) {
currentContents = fs.readFileSync(contentWorkingFilePath, 'utf-8');
} else if (!fs.existsSync(contentModuleFilePath)) {
return { metadata: {}, content: currentContents, options: {} };
} else {
currentContents = fs.readFileSync(contentModuleFilePath, 'utf-8');
}
const { data: metadata, content } = matter(currentContents);
if (typeof slug === 'string' && integrationObject?.entries?.templates) {
const viewConfigFilePath = path.resolve(integrationObject.entries.templates, slug, 'view.config.json');
if (fs.existsSync(viewConfigFilePath)) {
options = JSON.parse(fs.readFileSync(viewConfigFilePath, 'utf-8').toString()) as ComponentDocumentationOptions;
}
}
return { metadata, content, options };
};
/**
* Filter out undefined elements
* @param value
* @returns
*/
export const filterOutUndefined = <T>(value: T): value is NonNullable<T> => value !== undefined;
/**
* Create a title string from a prefix
* @param prefix
* @returns
*/
export const titleString = (prefix: string | null): string => {
const config = getClientRuntimeConfig();
const prepend = prefix ? `${prefix} | ` : '';
return `${prefix}${config?.app?.client} Design System`;
};
/**
* Get the tokens for a component
* @param component
* @param type
* @returns
*/
export const fetchTokensString = (component: string, type: 'css' | 'scss' | 'styleDictionary' | 'types'): string => {
let tokens = '';
const baseSearchPath = process.env.HANDOFF_EXPORT_PATH
? path.resolve(process.env.HANDOFF_EXPORT_PATH, 'tokens')
: path.resolve(process.cwd(), process.env.HANDOFF_OUTPUT_DIR ?? 'exported', 'tokens');
const scssSearchPath = path.resolve(baseSearchPath, 'sass', `${component}.scss`);
const typeSearchPath = path.resolve(baseSearchPath, 'types', `${component}.scss`);
const sdSearchPath = path.resolve(baseSearchPath, 'sd', 'tokens', `${component}.tokens.json`);
const sdAltSearchPath = path.resolve(baseSearchPath, 'sd', 'tokens', component, `${component}.tokens.json`);
const cssSearchPath = path.resolve(baseSearchPath, 'css', `${component}.css`);
if (type === 'scss' && fs.existsSync(scssSearchPath)) {
tokens = fs.readFileSync(scssSearchPath).toString();
} else if (type === 'types' && fs.existsSync(typeSearchPath)) {
tokens = fs.readFileSync(typeSearchPath).toString();
} else if (type === 'styleDictionary') {
if (fs.existsSync(sdSearchPath)) {
// Foundations
tokens = fs.readFileSync(sdSearchPath).toString();
} else if (fs.existsSync(sdAltSearchPath)) {
// Components
tokens = fs.readFileSync(sdAltSearchPath).toString();
}
} else if (fs.existsSync(cssSearchPath)) {
tokens = fs.readFileSync(cssSearchPath).toString();
}
return tokens;
};