UNPKG

shipmast

Version:

Automated License & Metadata applicators for Codebases.

293 lines (225 loc) 8.66 kB
import fs from 'fs/promises'; import path from 'path' import { glob } from 'glob'; import { createHash, randomUUID } from 'crypto'; /** * Assigns path-related metadata. * * @param {object} obj - Object to mutate with path data. * @param {string} rootPath - The base directory for relative path calculation. * @param {string} filePath - The full path of the file. */ export function assignPathData(obj, rootPath, filePath) { obj["$file_path_relative"] = path.relative(rootPath, filePath).replace(/\\/g, '/'); } /** * Assigns file statistics to an object. * * @param {object} obj - Object to mutate with file stats. * @param {fs.Stats} stats - File statistics from fs.stat. */ export function assignStatsData(obj, stats) { obj["$file_size"] = stats.size; obj["$file_size_bytes"] = `${stats.size} bytes`; } /** * Creates a structured data object with `pre` and `post` fields. * * @returns {object} An empty structured data object. */ export function makeData() { return { pre: {}, post: {} }; } /** * Merges two data objects together. * * @param {object} dataA - First data object. * @param {object} dataB - Second data object. * @returns {object} - Merged data object. */ export function mergeData(dataA, dataB) { return { pre: { ...dataA.pre, ...dataB.pre }, post: { ...dataA.post, ...dataB.post }, }; } function isValidUUID(uuid) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( uuid, ); } export function extractLicenseFields(matter) { const fields = {}; const lines = matter.split('\n'); for (const line of lines) { const trimmedLine = line.trim(); if (!trimmedLine) continue; const match = trimmedLine.match(/(?:.*\s)?([a-zA-Z0-9_]+)\s*:\s*(.*)$/); if (match) { const key = match[1].trim(); const value = match[2].trim(); fields[key] = value; } } return fields; } function hash(input) { return createHash('sha256').update(input, 'utf8').digest('hex'); } function templateLicense(license, data) { for (const [key, value] of Object.entries(data)) { license = license.replaceAll(`{{${key}}}`, value); } return license; } /** * Extracts a shipmast block using `--˹ ... ˺--` as a signature. * Ensures the block is properly removed and can be reinserted later. */ function extractDecorator(content) { const blockCommentRegex = /\/\*˹[\s\S]*?˼\*\//g; const shebangRegex = /^#![^\n]+\n/; // Extract shebang first before processing anything else let shebangMatch = content.match(shebangRegex); let shebang = shebangMatch ? shebangMatch[0] : null; // Remove the shebang from content before extracting the decorator let newContent = shebang ? content.replace(shebangRegex, '').trimStart() : content; // Extract block comment using the `/*˹ ... ˺*/` format let matches = newContent.match(blockCommentRegex); let decorator = matches ? matches[0] : null; if (!decorator) { console.error(`⚠️ Warning: No shipmast block found using /*˹ ... ˺*/.`); return { decorator: null, content: newContent, shebang }; } // Remove the shipmast block newContent = newContent.replace(decorator, '').trimStart(); return { decorator, content: newContent, shebang }; } /** * Extracts the base directory from the given glob pattern. */ function getGlobBaseDir(globPattern) { // Find the first part of the glob pattern before any wildcards const parts = globPattern.split(/[*?\[\]]/); // Split at any glob pattern characters return path.resolve(parts[0]); // Resolve the first segment as the base directory } export async function applyTemplateGlob(globPattern, template, options = {}) { const { data = {}, dryRun = false } = options; if (!globPattern || !template) { throw new Error('❌ applyTemplateGlob requires a glob pattern and a template.'); } // Determine where the search started const baseDir = getGlobBaseDir(globPattern); const files = await glob(globPattern, { nodir: true }); if (files.length === 0) { console.warn(`No files matched pattern: ${globPattern}`); return; } console.log(`Processing ${files.length} files...`); for (const filePath of files) { const content = await fs.readFile(filePath, 'utf8'); const fileStats = await fs.stat(filePath); let fileData = makeData(); assignPathData(fileData.pre, baseDir, filePath); assignStatsData(fileData.post, fileStats); fileData = mergeData(fileData, { pre: data.pre || {}, post: data.post || {} }); const result = await applyTemplate(content, template, { data: fileData }); if (result.updated) { if (dryRun) { console.log(`🛑 [Dry Run] Would update: ${filePath}`); } else { await fs.writeFile(filePath, result.result, 'utf8'); console.log(`✅ Updated: ${filePath}`); } } else { console.log(`No changes: ${filePath}`); } } } export async function applyTemplate(content, template, options={}) { const { decorator: previousLicense, content: strippedContent, shebang, } = extractDecorator(content); let DATA = {pre:{},post:{}}; if (previousLicense) { Object.assign(DATA.pre, extractLicenseFields(previousLicense)); } Object.assign(DATA.pre,options.data.pre) Object.assign(DATA.post,options.data.post) const date = new Date(); DATA.pre["$year_full"] = date.getFullYear(); if (!isValidUUID(DATA.pre.file_uuid)) { DATA.post["$file_uuid"] = randomUUID(); } else { DATA.post["$file_uuid"] = DATA.pre.file_uuid; } const oldDocHash = DATA.pre.file_hash; const oldBlockHash = DATA.pre.mast_hash; const newDocHash = hash(strippedContent); let docUpdated = newDocHash !== oldDocHash; template = templateLicense(template, DATA.pre); const newBlockHash = hash(template); DATA.post["$generated_on_iso"] = new Date().toISOString(); DATA.post["$file_hash"] = newDocHash; DATA.post["$generated_by"] = 'shipmast on npm'; template = templateLicense(template, DATA.post); let blockUpdated = newBlockHash !== oldBlockHash; DATA.post["$mast_hash"] = newBlockHash; const finalBlock = templateLicense(template, DATA.post); let updated = docUpdated || blockUpdated; if (updated) { console.log('Result: Changes.'); } else { console.log('Result: No Changes.'); } let finalContent = `${shebang ? shebang : ''}/*˹${finalBlock}˼*/\n${strippedContent}`; return { result: finalContent, updated }; } const shipmastRegex = /\/\*˹[\s\S]*?˼\*\/\n?/g; /** * Removes the shipmast block from a given content string. * Detects the `/-*˹ ... ˼*-/` signature and removes it. * * @param {string} content - The file content. * @returns {string} - The cleaned content without the shipmast. */ export function remove(content) { return content.replace(shipmastRegex, '').trim(); } /** * Removes the shipmast block from all files matching a glob pattern. * * @param {string} globPattern - The glob pattern to match files. * @param {object} options - Optional parameters. * @param {boolean} options.dryRun - If true, will only log changes without modifying files. */ export async function removeGlob(globPattern, options = {}) { const { dryRun = false } = options; if (!globPattern) { throw new Error('❌ removeGlob requires a glob pattern.'); } const files = await glob(globPattern, { nodir: true }); if (files.length === 0) { console.warn(`No files matched pattern: ${globPattern}`); return; } console.log(`🔍 Removing shipmast from ${files.length} files...`); for (const filePath of files) { const content = await fs.readFile(filePath, 'utf8'); const cleanedContent = remove(content); if (cleanedContent !== content) { if (dryRun) { console.log(`🛑 [Dry Run] Would remove shipmast from: ${filePath}`); } else { await fs.writeFile(filePath, cleanedContent, 'utf8'); console.log(`✅ Removed shipmast from: ${filePath}`); } } else { console.log(`ℹ️ No shipmast found in: ${filePath}`); } } } export default {applyTemplate,applyTemplateGlob,remove,removeGlob,assignPathData,assignStatsData,mergeData,makeData}