handoff-app
Version:
Automated documentation toolchain for building client side documentation from figma
337 lines (289 loc) • 9.8 kB
text/typescript
import fs from 'fs-extra';
import path from 'path';
import Handoff from '../index';
import { Logger } from '../utils/logger';
import { computeDirectoryState, computeFileState, directoryStatesMatch, FileState, statesMatch } from './file-state';
/** Current cache format version - bump when structure changes */
const CACHE_VERSION = '1.0.0';
/**
* Cache entry for a single component
*/
export interface ComponentCacheEntry {
/** File states for all source files of this component */
files: Record<string, FileState>;
/** States for template directory files (if templates is a directory) */
templateDirFiles?: Record<string, FileState>;
/** Timestamp when this component was last built */
buildTimestamp: number;
}
/**
* State of global dependencies that affect all components
*/
export interface GlobalDepsState {
/** tokens.json file state */
tokens?: FileState;
/** shared.scss or shared.css file state */
sharedStyles?: FileState;
/** Global SCSS entry file state */
globalScss?: FileState;
/** Global JS entry file state */
globalJs?: FileState;
}
/**
* Complete build cache structure
*/
export interface BuildCache {
/** Cache format version for invalidation on structure changes */
version: string;
/** State of global dependencies at last build */
globalDeps: GlobalDepsState;
/** Per-component cache entries: componentId -> entry */
components: Record<string, ComponentCacheEntry>;
}
/**
* Gets the path to the build cache file
*/
export function getCachePath(handoff: Handoff): string {
return path.resolve(handoff.modulePath, '.handoff', handoff.getProjectId(), '.cache', 'build-cache.json');
}
/**
* Loads the build cache from disk
* @returns The cached data or null if cache doesn't exist or is invalid
*/
export async function loadBuildCache(handoff: Handoff): Promise<BuildCache | null> {
const cachePath = getCachePath(handoff);
try {
if (!(await fs.pathExists(cachePath))) {
Logger.debug('No existing build cache found');
return null;
}
const data = await fs.readJson(cachePath);
// Validate cache version
if (data.version !== CACHE_VERSION) {
Logger.debug(`Build cache version mismatch (${data.version} vs ${CACHE_VERSION}), invalidating`);
return null;
}
return data as BuildCache;
} catch (error) {
Logger.debug('Failed to load build cache, will rebuild all components:', error);
return null;
}
}
/**
* Saves the build cache to disk
* Uses atomic write (temp file + rename) to prevent corruption
*/
export async function saveBuildCache(handoff: Handoff, cache: BuildCache): Promise<void> {
const cachePath = getCachePath(handoff);
const cacheDir = path.dirname(cachePath);
const tempPath = `${cachePath}.tmp`;
try {
await fs.ensureDir(cacheDir);
await fs.writeJson(tempPath, cache, { spaces: 2 });
await fs.rename(tempPath, cachePath);
Logger.debug('Build cache saved');
} catch (error) {
Logger.debug('Failed to save build cache:', error);
// Clean up temp file if it exists
try {
await fs.remove(tempPath);
} catch {
// Ignore cleanup errors
}
}
}
/**
* Computes the current state of global dependencies
*/
export async function computeGlobalDepsState(handoff: Handoff): Promise<GlobalDepsState> {
const result: GlobalDepsState = {};
// tokens.json
const tokensPath = handoff.getTokensFilePath();
result.tokens = (await computeFileState(tokensPath)) ?? undefined;
// shared.scss or shared.css
const sharedScssPath = path.resolve(handoff.workingPath, 'integration/components/shared.scss');
const sharedCssPath = path.resolve(handoff.workingPath, 'integration/components/shared.css');
const sharedScssState = await computeFileState(sharedScssPath);
const sharedCssState = await computeFileState(sharedCssPath);
result.sharedStyles = sharedScssState ?? sharedCssState ?? undefined;
// Global SCSS entry
if (handoff.runtimeConfig?.entries?.scss) {
result.globalScss = (await computeFileState(handoff.runtimeConfig.entries.scss)) ?? undefined;
}
// Global JS entry
if (handoff.runtimeConfig?.entries?.js) {
result.globalJs = (await computeFileState(handoff.runtimeConfig.entries.js)) ?? undefined;
}
return result;
}
/**
* Checks if global dependencies have changed
*/
export function haveGlobalDepsChanged(cached: GlobalDepsState | null | undefined, current: GlobalDepsState): boolean {
if (!cached) return true;
// Check each global dependency
if (!statesMatch(cached.tokens, current.tokens)) {
Logger.debug('Global dependency changed: tokens.json');
return true;
}
if (!statesMatch(cached.sharedStyles, current.sharedStyles)) {
Logger.debug('Global dependency changed: shared styles');
return true;
}
if (!statesMatch(cached.globalScss, current.globalScss)) {
Logger.debug('Global dependency changed: global SCSS entry');
return true;
}
if (!statesMatch(cached.globalJs, current.globalJs)) {
Logger.debug('Global dependency changed: global JS entry');
return true;
}
return false;
}
/**
* Gets all file paths that should be tracked for a component
*/
export function getComponentFilePaths(handoff: Handoff, componentId: string): { files: string[]; templateDir?: string } {
const runtimeComponent = handoff.runtimeConfig?.entries?.components?.[componentId];
if (!runtimeComponent) {
return { files: [] };
}
const files: string[] = [];
let templateDir: string | undefined;
// Find the config file path for this component
const configPaths = handoff.getConfigFilePaths();
for (const configPath of configPaths) {
// Check if this config path belongs to this component
if (configPath.includes(componentId)) {
files.push(configPath);
break;
}
}
// Add entry files
const entries = runtimeComponent.entries as Record<string, string | undefined> | undefined;
if (entries) {
if (entries.js) {
files.push(entries.js);
}
if (entries.scss) {
files.push(entries.scss);
}
// Handle both 'template' (singular) and 'templates' (plural) entry types
const templatePath = entries.template || entries.templates;
if (templatePath) {
try {
const stat = fs.statSync(templatePath);
if (stat.isDirectory()) {
templateDir = templatePath;
} else {
files.push(templatePath);
}
} catch {
// File doesn't exist, still add to track
files.push(templatePath);
}
}
}
return { files, templateDir };
}
/**
* Computes current file states for a component
*/
export async function computeComponentFileStates(
handoff: Handoff,
componentId: string
): Promise<{ files: Record<string, FileState>; templateDirFiles?: Record<string, FileState> }> {
const { files: filePaths, templateDir } = getComponentFilePaths(handoff, componentId);
const files: Record<string, FileState> = {};
for (const filePath of filePaths) {
const state = await computeFileState(filePath);
if (state) {
files[filePath] = state;
}
}
let templateDirFiles: Record<string, FileState> | undefined;
if (templateDir) {
templateDirFiles = await computeDirectoryState(templateDir, ['.hbs', '.html']);
}
return { files, templateDirFiles };
}
/**
* Checks if a component needs to be rebuilt based on file states
*/
export function hasComponentChanged(
cached: ComponentCacheEntry | null | undefined,
current: { files: Record<string, FileState>; templateDirFiles?: Record<string, FileState> }
): boolean {
if (!cached) {
return true; // No cache entry means new component
}
// Check regular files
const cachedFiles = Object.keys(cached.files);
const currentFiles = Object.keys(current.files);
// Check if file count changed
if (cachedFiles.length !== currentFiles.length) {
return true;
}
// Check if any files were added or removed
const cachedSet = new Set(cachedFiles);
for (const file of currentFiles) {
if (!cachedSet.has(file)) {
return true;
}
}
// Check if any file states changed
for (const file of cachedFiles) {
if (!statesMatch(cached.files[file], current.files[file])) {
return true;
}
}
// Check template directory files if applicable
if (!directoryStatesMatch(cached.templateDirFiles, current.templateDirFiles)) {
return true;
}
return false;
}
/**
* Checks if the component output files exist
*/
export async function checkOutputExists(handoff: Handoff, componentId: string): Promise<boolean> {
const outputPath = path.resolve(handoff.workingPath, 'public/api/component', `${componentId}.json`);
return fs.pathExists(outputPath);
}
/**
* Creates an empty cache structure
*/
export function createEmptyCache(): BuildCache {
return {
version: CACHE_VERSION,
globalDeps: {},
components: {},
};
}
/**
* Updates cache entry for a specific component
*/
export function updateComponentCacheEntry(
cache: BuildCache,
componentId: string,
fileStates: { files: Record<string, FileState>; templateDirFiles?: Record<string, FileState> }
): void {
cache.components[componentId] = {
files: fileStates.files,
templateDirFiles: fileStates.templateDirFiles,
buildTimestamp: Date.now(),
};
}
/**
* Removes components from cache that are no longer in runtime config
*/
export function pruneRemovedComponents(cache: BuildCache, currentComponentIds: string[]): void {
const currentSet = new Set(currentComponentIds);
const cachedIds = Object.keys(cache.components);
for (const cachedId of cachedIds) {
if (!currentSet.has(cachedId)) {
Logger.debug(`Pruning removed component from cache: ${cachedId}`);
delete cache.components[cachedId];
}
}
}