@fwdslsh/unify
Version:
A lightweight, framework-free static site generator with Bun native APIs
249 lines (218 loc) • 9.29 kB
JavaScript
/**
* Get output path for a source file, supporting pretty URLs
* @param {string} sourcePath - Source file path
* @param {string} sourceRoot - Source root directory
* @param {string} outputRoot - Output root directory
* @param {boolean} prettyUrls - Whether to use pretty URLs
* @returns {string} Output file path
*/
export function getOutputPathWithPrettyUrls(sourcePath, sourceRoot, outputRoot, prettyUrls = false) {
const relativePath = path.relative(sourceRoot, sourcePath);
const ext = path.extname(sourcePath).toLowerCase();
// Only apply pretty URLs to HTML/Markdown files not starting with '_'
const fileName = path.basename(sourcePath);
const isPage = !fileName.startsWith('_') && (ext === '.html' || ext === '.htm' || ext === '.md');
if (prettyUrls && isPage) {
// Remove extension and create subdirectory with index.html
const withoutExt = relativePath.replace(/\.[^.]+$/, '');
return path.resolve(outputRoot, withoutExt, 'index.html');
}
// Standard output: preserve relative path
return path.resolve(outputRoot, relativePath);
}
/**
* Path resolution utilities for unify
* Handles secure path resolution and validation
*/
import path from 'path';
import { fileURLToPath } from 'url';
import { PathTraversalError } from './errors.js';
/**
* Resolve include path based on type (file vs virtual)
* @param {string} type - 'file' or 'virtual'
* @param {string} includePath - Path from include directive
* @param {string} currentFilePath - Path of file containing the include
* @param {string} sourceRoot - Root source directory
* @returns {string} Resolved absolute path
*/
export function resolveIncludePath(type, includePath, currentFilePath, sourceRoot) {
// Validate input
if (!includePath || typeof includePath !== 'string') {
throw new Error('Include path must be a non-empty string');
}
let resolvedPath;
if (type === 'file') {
// Relative to current file's directory
const currentDir = path.dirname(currentFilePath);
resolvedPath = path.resolve(currentDir, includePath);
} else if (type === 'virtual') {
// Relative to source root, remove all leading slashes and normalize
let cleanPath = includePath.replace(/^\/+/, '');
// Normalize multiple slashes
cleanPath = cleanPath.replace(/\/+/g, '/');
resolvedPath = path.resolve(sourceRoot, cleanPath);
} else {
throw new Error(`Invalid include type: ${type}`);
}
// Security check: ensure resolved path is within source root
if (!isPathWithinDirectory(resolvedPath, sourceRoot)) {
throw new PathTraversalError(includePath, sourceRoot);
}
return resolvedPath;
}
/**
* Check if a path is within a directory (prevents path traversal)
* @param {string} filePath - Path to check
* @param {string} directory - Directory that should contain the path
* @returns {boolean} True if path is within directory
*/
export function isPathWithinDirectory(filePath, directory) {
const resolvedFilePath = path.resolve(filePath);
const resolvedDirectory = path.resolve(directory);
return resolvedFilePath.startsWith(resolvedDirectory + path.sep) ||
resolvedFilePath === resolvedDirectory;
}
/**
* Get file extension from path
* @param {string} filePath - File path
* @returns {string} Extension including dot (e.g., '.html')
*/
export function getFileExtension(filePath) {
return path.extname(filePath).toLowerCase();
}
/**
* Check if file is an HTML file
* @param {string} filePath - File path
* @returns {boolean} True if HTML file
*/
export function isHtmlFile(filePath) {
return getFileExtension(filePath) === '.html';
}
/**
* Check if file is a partial (should not be output as a page)
* @param {string} filePath - File path to check
* @param {string|Object} config - Components directory name or full config object
* @returns {boolean} True if file is a partial/component/layout file
*/
export function isPartialFile(filePath, config = '.components') {
const fileName = path.basename(filePath);
// Handle both string and object config for backward compatibility
let componentsDir, layoutsDir;
if (typeof config === 'string') {
componentsDir = config;
layoutsDir = '.layouts'; // default
} else {
componentsDir = config.components || '.components';
layoutsDir = config.layouts || '.layouts';
}
// Check if filename starts with underscore (traditional partial marker)
if (fileName.startsWith('_')) {
return true;
}
// Check if in configured components directory
if (isFileInDirectory(filePath, componentsDir)) {
return true;
}
// Check if in configured layouts directory
if (isFileInDirectory(filePath, layoutsDir)) {
return true;
}
// Also check for common standard directory names that should be treated as partials
const commonPartialDirs = [
'layouts', 'components', '.components', '.layouts',
'includes', 'partials', 'templates',
'custom_components', 'site_layouts' // Support custom naming conventions
];
for (const dirName of commonPartialDirs) {
if (isFileInDirectory(filePath, dirName)) {
return true;
}
}
return false;
}
/**
* Check if a file is within a specific directory
* @param {string} filePath - File path to check
* @param {string} dirPattern - Directory name or absolute path to check against
* @returns {boolean} True if file is in the directory
*/
function isFileInDirectory(filePath, dirPattern) {
const normalizedFilePath = path.resolve(filePath);
// If dirPattern is an absolute path, check if file is within that directory
if (path.isAbsolute(dirPattern)) {
const normalizedDirPattern = path.resolve(dirPattern);
return normalizedFilePath.startsWith(normalizedDirPattern + path.sep) ||
normalizedFilePath === normalizedDirPattern;
}
// If dirPattern is a relative directory name, check if any part of the path matches
const normalizedPath = path.normalize(filePath);
const pathParts = normalizedPath.split(path.sep);
// Check if any part of the path matches the directory name
return pathParts.includes(dirPattern);
}
/**
* Get output path for a source file
* @param {string} sourcePath - Source file path
* @param {string} sourceRoot - Source root directory
* @param {string} outputRoot - Output root directory
* @returns {string} Output file path
*/
export function getOutputPath(sourcePath, sourceRoot, outputRoot) {
const relativePath = path.relative(sourceRoot, sourcePath);
return path.resolve(outputRoot, relativePath);
}
/**
* Get current file's directory (for ES modules)
* @param {string} importMetaUrl - import.meta.url
* @returns {string} Directory path
*/
export function getCurrentDirectory(importMetaUrl) {
return path.dirname(fileURLToPath(importMetaUrl));
}
/**
* Resolve layout or component path with security checks
* @param {string} resourcePath - The layout/component path from attribute
* @param {string} sourceRoot - Source root directory
* @param {string} resourceDir - Base directory for the resource (layouts/components)
* @param {string} resourceType - Type of resource for error messages ('layout' or 'component')
* @param {boolean} allowDirectoryPaths - Whether to treat paths with slashes as relative to sourceRoot
* @returns {string} Resolved absolute path
* @throws {Error} If path is outside allowed directories
*/
export function resolveResourcePath(resourcePath, sourceRoot, resourceDir, resourceType = 'resource', allowDirectoryPaths = false) {
let resolvedPath;
if (resourcePath.startsWith('/')) {
// Absolute path relative to source root
const relativePath = resourcePath.substring(1); // Remove leading slash
resolvedPath = path.join(sourceRoot, relativePath);
// Security check - must be within source root for absolute paths
if (!isPathWithinDirectory(resolvedPath, sourceRoot)) {
throw new Error(`${resourceType} path outside source directory: ${resourcePath}`);
}
} else if (allowDirectoryPaths && resourcePath.includes('/')) {
// Path with directory structure, relative to source root
resolvedPath = path.join(sourceRoot, resourcePath);
// Security check - must be within source root
if (!isPathWithinDirectory(resolvedPath, sourceRoot)) {
throw new Error(`${resourceType} path outside source directory: ${resourcePath}`);
}
} else {
// Relative path within resource directory
if (path.isAbsolute(resourceDir)) {
// If resourceDir is an absolute path (from CLI), use it directly
resolvedPath = path.join(resourceDir, resourcePath);
// Security check - must be within the configured resource directory
if (!isPathWithinDirectory(resolvedPath, resourceDir)) {
throw new Error(`${resourceType} path outside ${resourceType} directory: ${resourcePath}`);
}
} else {
// If resourceDir is relative, join with sourceRoot
resolvedPath = path.join(sourceRoot, resourceDir, resourcePath);
// Security check - must be within source root for relative paths
if (!isPathWithinDirectory(resolvedPath, sourceRoot)) {
throw new Error(`${resourceType} path outside source directory: ${resourcePath}`);
}
}
}
return resolvedPath;
}