UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

654 lines (653 loc) 25.5 kB
/** * Static site generation module with enhanced customization capabilities * @module src/cli/static.js * @namespace UnderpostStatic */ import fs from 'fs-extra'; import path from 'path'; import express from 'express'; import { ssrFactory } from '../client-builder/ssr.js'; import { shellExec } from '../server/process.js'; import Underpost from '../index.js'; import { JSONweb } from '../client-builder/client-formatted.js'; import { loggerFactory, loggerMiddleware } from '../server/logger.js'; const logger = loggerFactory(import.meta); /** * @typedef {Object} MetadataOptions * @memberof UnderpostStatic * @property {string} [title='Home'] - Page title * @property {string} [description=''] - Page description for SEO * @property {string[]} [keywords=[]] - Keywords for SEO * @property {string} [author=''] - Page author * @property {string} [themeColor='#ffffff'] - Theme color for mobile browsers * @property {string} [canonicalURL=''] - Canonical URL for SEO * @property {string} [thumbnail=''] - Open Graph thumbnail image URL * @property {string} [locale='en-US'] - Page locale * @property {string} [siteName=''] - Site name for Open Graph * @property {Object} [openGraph={}] - Additional Open Graph metadata * @property {Object} [twitter={}] - Twitter card metadata */ /** * @typedef {Object} ScriptOptions * @memberof UnderpostStatic * @property {string} [src] - External script source URL * @property {string} [content] - Inline script content * @property {string} [type='text/javascript'] - Script type * @property {boolean} [async=false] - Async loading flag * @property {boolean} [defer=false] - Defer loading flag * @property {string} [integrity] - Subresource integrity hash * @property {string} [crossorigin] - CORS settings * @property {Object} [attributes={}] - Additional HTML attributes */ /** * @typedef {Object} StyleOptions * @memberof UnderpostStatic * @property {string} [href] - External stylesheet URL * @property {string} [content] - Inline style content * @property {string} [media='all'] - Media query * @property {string} [integrity] - Subresource integrity hash * @property {string} [crossorigin] - CORS settings */ /** * @typedef {Object} IconOptions * @memberof UnderpostStatic * @property {string} [favicon] - Favicon path * @property {string} [appleTouchIcon] - Apple touch icon path * @property {string} [manifest] - Web manifest path * @property {Object[]} [additional=[]] - Additional icon definitions */ /** * @typedef {Object} StaticGenerationOptions * @memberof UnderpostStatic * @property {string} [page=''] - SSR component path to render * @property {string} [title='Home'] - Page title (deprecated: use metadata.title) * @property {string} [outputPath='.'] - Output file path * @property {string} [buildPath='/'] - Build path * @property {string} [env='production'] - Environment (development/production) * @property {boolean} [dev=false] - Development mode flag * @property {boolean} [minify=true] - Minify HTML output * @property {MetadataOptions} [metadata={}] - Comprehensive metadata options * @property {Object} [scripts={}] - Script injection options * @property {ScriptOptions[]} [scripts.head=[]] - Scripts for head section * @property {ScriptOptions[]} [scripts.body=[]] - Scripts for body section * @property {StyleOptions[]} [styles=[]] - Stylesheet options * @property {string[]} [headComponents=[]] - Array of SSR head component paths * @property {string[]} [bodyComponents=[]] - Array of SSR body component paths * @property {IconOptions} [icons={}] - Icon configuration * @property {Object} [customPayload={}] - Custom data to inject into renderPayload * @property {Object} [templateHelpers={}] - Custom helper functions for templates * @property {string} [configFile=''] - Path to JSON config file * @property {string} [lang='en'] - HTML lang attribute * @property {string} [dir='ltr'] - HTML dir attribute * @property {Object} [microdata=[]] - Structured data (JSON-LD) */ class DefaultStaticGenerationOptions { static page = ''; static title = ''; static outputPath = ''; static buildPath = '/'; static env = 'production'; static dev = false; static minify = true; static metadata = {}; static scripts = {}; static styles = []; static headComponents = []; static bodyComponents = []; static icons = {}; static customPayload = {}; static templateHelpers = {}; static configFile = ''; static lang = 'en'; static dir = 'ltr'; static microdata = []; } /** * Template helper functions for common SSR patterns * @namespace TemplateHelpers */ class TemplateHelpers { /** * Generates a script tag from options * @param {ScriptOptions} options - Script options * @returns {string} HTML script tag * @memberof TemplateHelpers */ static createScriptTag(options) { const attrs = []; if (options.type && options.type !== 'text/javascript') { attrs.push(`type="${options.type}"`); } if (options.async) attrs.push('async'); if (options.defer) attrs.push('defer'); if (options.src) attrs.push(`src="${options.src}"`); if (options.integrity) attrs.push(`integrity="${options.integrity}"`); if (options.crossorigin) attrs.push(`crossorigin="${options.crossorigin}"`); // Add custom attributes if (options.attributes) { Object.entries(options.attributes).forEach(([key, value]) => { attrs.push(`${key}="${value}"`); }); } const attrString = attrs.length > 0 ? ` ${attrs.join(' ')}` : ''; const content = options.content || ''; return `<script${attrString}>${content}</script>`; } /** * Generates a link/style tag from options * @param {StyleOptions} options - Style options * @returns {string} HTML link or style tag * @memberof TemplateHelpers */ static createStyleTag(options) { if (options.content) { return `<style>${options.content}</style>`; } const attrs = [`rel="stylesheet"`]; if (options.href) attrs.push(`href="${options.href}"`); if (options.media) attrs.push(`media="${options.media}"`); if (options.integrity) attrs.push(`integrity="${options.integrity}"`); if (options.crossorigin) attrs.push(`crossorigin="${options.crossorigin}"`); return `<link ${attrs.join(' ')}>`; } /** * Generates icon link tags * @param {IconOptions} icons - Icon options * @returns {string} HTML icon link tags * @memberof TemplateHelpers */ static createIconTags(icons) { const tags = []; if (icons.favicon) { tags.push(`<link rel="icon" type="image/x-icon" href="${icons.favicon}">`); } if (icons.appleTouchIcon) { tags.push(`<link rel="apple-touch-icon" href="${icons.appleTouchIcon}">`); } if (icons.manifest) { tags.push(`<link rel="manifest" href="${icons.manifest}">`); } if (icons.additional && Array.isArray(icons.additional)) { icons.additional.forEach((icon) => { const attrs = Object.entries(icon) .map(([key, value]) => `${key}="${value}"`) .join(' '); tags.push(`<link ${attrs}>`); }); } return tags.join('\n'); } /** * Generates meta tags from metadata object * @param {MetadataOptions} metadata - Metadata options * @returns {string} HTML meta tags * @memberof TemplateHelpers */ static createMetaTags(metadata) { const tags = []; if (metadata.description) { tags.push(`<meta name="description" content="${metadata.description}">`); } if (metadata.keywords && metadata.keywords.length > 0) { tags.push(`<meta name="keywords" content="${metadata.keywords.join(', ')}">`); } if (metadata.author) { tags.push(`<meta name="author" content="${metadata.author}">`); } if (metadata.themeColor) { tags.push(`<meta name="theme-color" content="${metadata.themeColor}">`); } // Open Graph if (metadata.title) { tags.push(`<meta property="og:title" content="${metadata.title}">`); } if (metadata.description) { tags.push(`<meta property="og:description" content="${metadata.description}">`); } if (metadata.thumbnail) { tags.push(`<meta property="og:image" content="${metadata.thumbnail}">`); } if (metadata.canonicalURL) { tags.push(`<meta property="og:url" content="${metadata.canonicalURL}">`); tags.push(`<link rel="canonical" href="${metadata.canonicalURL}">`); } if (metadata.siteName) { tags.push(`<meta property="og:site_name" content="${metadata.siteName}">`); } if (metadata.locale) { tags.push(`<meta property="og:locale" content="${metadata.locale}">`); } // Twitter Card tags.push(`<meta name="twitter:card" content="summary_large_image">`); if (metadata.twitter) { Object.entries(metadata.twitter).forEach(([key, value]) => { tags.push(`<meta name="twitter:${key}" content="${value}">`); }); } // Additional Open Graph if (metadata.openGraph) { Object.entries(metadata.openGraph).forEach(([key, value]) => { tags.push(`<meta property="og:${key}" content="${value}">`); }); } return tags.join('\n'); } /** * Generates JSON-LD structured data script tags * @param {Object[]} microdata - Array of structured data objects * @returns {string} HTML script tags with JSON-LD * @memberof TemplateHelpers */ static createMicrodataTags(microdata) { if (!microdata || !Array.isArray(microdata) || microdata.length === 0) { return ''; } return microdata .map((data) => `<script type="application/ld+json">\n${JSON.stringify(data, null, 2)}\n</script>`) .join('\n'); } } /** * Configuration validator * @namespace ConfigValidator */ class ConfigValidator { /** * Validates static generation options * @param {StaticGenerationOptions} options - Options to validate * @returns {Object} Validation result with isValid flag and errors array * @memberof ConfigValidator */ static validate(options) { const errors = []; // Validate page path if (options.page && !fs.existsSync(options.page)) { errors.push(`Page component does not exist: ${options.page}`); } // Validate head components if (options.headComponents && Array.isArray(options.headComponents)) { options.headComponents.forEach((comp) => { if (!fs.existsSync(comp)) { errors.push(`Head component does not exist: ${comp}`); } }); } // Validate body components if (options.bodyComponents && Array.isArray(options.bodyComponents)) { options.bodyComponents.forEach((comp) => { if (!fs.existsSync(comp)) { errors.push(`Body component does not exist: ${comp}`); } }); } // Validate environment if (options.env && !['development', 'production'].includes(options.env)) { logger.warn(`Invalid environment: ${options.env}. Using 'production' as default.`); } return { isValid: errors.length === 0, errors, }; } } /** * Configuration file loader * @namespace ConfigLoader */ class ConfigLoader { /** * Loads configuration from a JSON file * @param {string} configPath - Path to config file * @returns {Object} Configuration object * @memberof ConfigLoader */ static load(configPath) { try { if (!fs.existsSync(configPath)) { logger.error(`Config file not found: ${configPath}`); return {}; } const content = fs.readFileSync(configPath, 'utf8'); return JSON.parse(content); } catch (error) { logger.error(`Error loading config file: ${error.message}`); return {}; } } /** * Saves configuration to a JSON file * @param {string} configPath - Path to save config * @param {Object} config - Configuration object * @memberof ConfigLoader */ static save(configPath, config) { try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); logger.info(`Config saved to: ${configPath}`); } catch (error) { logger.error(`Error saving config file: ${error.message}`); } } } /** * @class UnderpostStatic * @description Enhanced static site generation class with comprehensive customization * @memberof UnderpostStatic */ class UnderpostStatic { static API = { /** * Generate static HTML file with enhanced customization options * * @param {StaticGenerationOptions} options - Options for static generation * @param {string} [options.page] - Path to the SSR component to render * @param {string} [options.title] - Page title (deprecated: use metadata.title) * @param {string} [options.outputPath] - Output file path * @param {string} [options.buildPath='/'] - Build path * @param {string} [options.env='production'] - Environment (development/production) * @param {boolean} [options.minify=true] - Minify HTML output * @param {MetadataOptions} [options.metadata={}] - Comprehensive metadata options * @param {Object} [options.scripts={}] - Script injection options * @param {ScriptOptions[]} [options.scripts.head=[]] - Scripts for * head section * @param {ScriptOptions[]} [options.scripts.body=[]] - Scripts for body section * @param {StyleOptions[]} [options.styles=[]] - Stylesheet options * @param {string[]} [options.headComponents=[]] - Array of SSR head component paths * @param {string[]} [options.bodyComponents=[]] - Array of SSR body component paths * @param {IconOptions} [options.icons={}] - Icon configuration * @param {Object} [options.customPayload={}] - Custom data to inject into renderPayload * @param {string} [options.configFile=''] - Path to JSON config file * @param {string} [options.lang='en'] - HTML lang attribute * @param {string} [options.dir='ltr'] - HTML dir attribute * @param {Object} [options.microdata=[]] - Structured data (JSON-LD) * @returns {Promise<void>} * @memberof UnderpostStatic */ async callback(options = DefaultStaticGenerationOptions) { // Handle config template generation if (options.generateConfig) { const configPath = typeof options.generateConfig === 'string' ? options.generateConfig : './static-config.json'; return Underpost.static.generateConfigTemplate(configPath); } // Parse comma-separated options if (options.keywords) { options.keywords = options.keywords.split(',').map((k) => k.trim()); } if (options.headScripts) { options.scripts = options.scripts || {}; options.scripts.head = options.headScripts.split(',').map((s) => ({ src: s.trim() })); } if (options.bodyScripts) { options.scripts = options.scripts || {}; options.scripts.body = options.bodyScripts.split(',').map((s) => ({ src: s.trim() })); } if (options.styles) { options.styles = options.styles.split(',').map((s) => ({ href: s.trim() })); } if (options.headComponents) { options.headComponents = options.headComponents.split(',').map((c) => c.trim()); } if (options.bodyComponents) { options.bodyComponents = options.bodyComponents.split(',').map((c) => c.trim()); } // Build metadata object from individual options options.metadata = { ...(options.title && { title: options.title }), ...(options.description && { description: options.description }), ...(options.keywords && { keywords: options.keywords }), ...(options.author && { author: options.author }), ...(options.themeColor && { themeColor: options.themeColor }), ...(options.canonicalUrl && { canonicalURL: options.canonicalUrl }), ...(options.thumbnail && { thumbnail: options.thumbnail }), ...(options.locale && { locale: options.locale }), ...(options.siteName && { siteName: options.siteName }), }; // Build icons object if (options.favicon || options.appleTouchIcon || options.manifest) { options.icons = { ...(options.favicon && { favicon: options.favicon }), ...(options.appleTouchIcon && { appleTouchIcon: options.appleTouchIcon }), ...(options.manifest && { manifest: options.manifest }), }; } // Load config from file if specified if (options.configFile) { const fileConfig = ConfigLoader.load(options.configFile); options = { ...fileConfig, ...options }; // CLI options override file config } // Set defaults if (!options.outputPath) options.outputPath = '.'; if (!options.buildPath) options.buildPath = '/'; if (!options.env) options.env = 'production'; if (options.minify === undefined) options.minify = options.env === 'production'; if (!options.metadata) options.metadata = {}; if (!options.lang) options.lang = 'en'; if (!options.dir) options.dir = 'ltr'; // Merge title for backwards compatibility if (options.title && !options.metadata.title) { options.metadata.title = options.title; } if (!options.metadata.title) { options.metadata.title = 'Home'; } // Validate options const validation = ConfigValidator.validate(options); if (!validation.isValid) { logger.error('Validation errors:'); validation.errors.forEach((err) => logger.error(` - ${err}`)); if (validation.errors.some((err) => err.includes('does not exist'))) { return; // Exit if critical path errors } } // Generate page HTML if (options.page) { try { logger.info(`Generating static page: ${options.page}`); const Render = await ssrFactory(); const SsrComponent = await ssrFactory(options.page); // Build head components let ssrHeadComponents = ''; // Add custom meta tags if (options.metadata) { ssrHeadComponents += TemplateHelpers.createMetaTags(options.metadata); } // Add custom icons if (options.icons) { ssrHeadComponents += '\n' + TemplateHelpers.createIconTags(options.icons); } // Add custom styles if (options.styles && Array.isArray(options.styles)) { ssrHeadComponents += '\n' + options.styles.map((style) => TemplateHelpers.createStyleTag(style)).join('\n'); } // Add custom head scripts if (options.scripts?.head && Array.isArray(options.scripts.head)) { ssrHeadComponents += '\n' + options.scripts.head.map((script) => TemplateHelpers.createScriptTag(script)).join('\n'); } // Add microdata/structured data if (options.microdata && Array.isArray(options.microdata)) { ssrHeadComponents += '\n' + TemplateHelpers.createMicrodataTags(options.microdata); } // Load additional head components if (options.headComponents && Array.isArray(options.headComponents)) { for (const compPath of options.headComponents) { try { const HeadComponent = await ssrFactory(compPath); // Pass metadata and other options to component const componentData = { ...options.metadata, ssrPath: options.buildPath === '/' ? '/' : `${options.buildPath}/`, microdata: options.microdata || [], }; ssrHeadComponents += '\n' + HeadComponent(componentData); } catch (error) { logger.error(`Error loading head component ${compPath}: ${error.message}`); } } } // Build body components let ssrBodyComponents = SsrComponent(); // Load additional body components if (options.bodyComponents && Array.isArray(options.bodyComponents)) { for (const compPath of options.bodyComponents) { try { const BodyComponent = await ssrFactory(compPath); ssrBodyComponents += '\n' + BodyComponent(); } catch (error) { logger.error(`Error loading body component ${compPath}: ${error.message}`); } } } // Add custom body scripts if (options.scripts?.body && Array.isArray(options.scripts.body)) { ssrBodyComponents += '\n' + options.scripts.body.map((script) => TemplateHelpers.createScriptTag(script)).join('\n'); } // Build render payload const renderPayload = { version: Underpost.version, ...(options.env === 'development' ? { dev: true } : undefined), ...options.customPayload, }; // Generate HTML const htmlSrc = Render({ title: options.metadata.title, ssrPath: options.buildPath === '/' ? '/' : `${options.buildPath}/`, ssrHeadComponents, ssrBodyComponents, renderPayload, renderApi: { JSONweb, }, }); // Write output file const outputDir = path.dirname(options.outputPath); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } fs.writeFileSync(options.outputPath, htmlSrc, 'utf8'); logger.info(`Static page generated: ${options.outputPath}`); } catch (error) { logger.error(`Error generating static page: ${error.message}`); logger.error(error.stack); throw error; } } // Start standalone static file server if --run-sv is specified if (options.runSv !== undefined) { const port = typeof options.runSv === 'string' ? parseInt(options.runSv, 10) : 5000; const servePath = options.outputPath && options.outputPath !== '.' ? path.dirname(path.resolve(options.outputPath)) : path.resolve('.'); if (!fs.existsSync(servePath)) { logger.error(`Serve path does not exist: ${servePath}`); return; } const app = express(); app.use(loggerMiddleware(import.meta, 'debug', () => false)); app.use('/', express.static(servePath)); app.listen(port, () => { logger.info(`Static file server running at http://localhost:${port}`); logger.info(`Serving files from: ${servePath}`); }); } }, /** * Helper method to generate a config template file * * @param {string} outputPath - Where to save the template config * @returns {void} * @memberof UnderpostStatic */ generateConfigTemplate(outputPath = './static-config.json') { const template = { page: './src/client/ssr/body/DefaultSplashScreen.js', outputPath: './dist/index.html', env: 'production', minify: true, lang: 'en', dir: 'ltr', metadata: { title: 'My Application', description: 'A description of my application', keywords: ['keyword1', 'keyword2', 'keyword3'], author: 'Your Name', themeColor: '#ffffff', canonicalURL: 'https://example.com', thumbnail: 'https://example.com/images/thumbnail.png', locale: 'en-US', siteName: 'My Site', }, scripts: { head: [ { src: 'https://example.com/analytics.js', async: true, comment: 'Analytics script', }, { content: 'window.config = { apiUrl: "https://api.example.com" };', comment: 'App configuration', }, ], body: [ { src: '/app.js', type: 'module', defer: true, }, ], }, styles: [ { href: '/styles/main.css', }, { content: 'body { margin: 0; padding: 0; }', }, ], icons: { favicon: '/favicon.ico', appleTouchIcon: '/apple-touch-icon.png', manifest: '/manifest.json', }, headComponents: ['./src/client/ssr/head/Seo.js', './src/client/ssr/head/Pwa.js'], microdata: [ { '@context': 'https://schema.org', '@type': 'WebSite', name: 'My Site', url: 'https://example.com', }, ], customPayload: { apiEndpoint: 'https://api.example.com', customFeature: true, }, }; ConfigLoader.save(outputPath, template); logger.info(`Config template generated: ${outputPath}`); }, }; /** * Export template helpers for external use * @static * @memberof UnderpostStatic */ static TemplateHelpers = TemplateHelpers; /** * Export config validator for external use * @static * @memberof UnderpostStatic */ static ConfigValidator = ConfigValidator; /** * Export config loader for external use * @static * @memberof UnderpostStatic */ static ConfigLoader = ConfigLoader; } export default UnderpostStatic; export { TemplateHelpers, ConfigValidator, ConfigLoader };