UNPKG

@nox-digital/compote

Version:

Single File Component (SFC) to Static Site Generation (SSG). Development in progress, not ready for production.

1,333 lines (1,124 loc) 99.6 kB
#! /usr/bin/env node import fsSync from 'fs' import fs from 'fs/promises' import { inspect } from 'util' import path from 'path' import { fileURLToPath } from 'url' import { exec, spawn } from 'child_process' import crypto from 'crypto' import * as readline from 'node:readline'; const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom') const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const c = console const cwd = process.cwd() const app = { dev: false, } let http, https, Path let compoteVersion const defaultOptions = { syntax: { // Trouver une expression opener: '{', // ma {variable} closer: '}', // Comment gérer l'expression bypass: '=', // hello {=world} => hello {world} bypass_space: true, // hello { world } => hello { world } bypass_double: true, // hello {{world}} => hello {{world}} unprotected:"*", // {*markdownToHTML} => <h1>C'est "OK"</h1> // Injection du slot slot: '…', // <MonComposant>le slot ici {…MonComposant}</MonComposant> hashed_filename: '___', // style___xLker.css }, behavior: { encode: { auto: true, }, attributes: { addMissingQuotes: "'", }, noGap: true, }, paths: { cache: './.compote/cache', public: './public', }, dev: { port: 8080, routes: [], }, options: { functions: false, pipe: false, hashed_filename: true, i18n_fallback: '', assets_sort: 'default', sitemap: { index: 'sitemap.xml' } } } const compiled = {} const code = {} const dependencies = {} const routes = {} const forStack = [] const config = {} const emptyElements = [ 'img', 'input', 'br', 'meta', 'link', 'source', 'base', 'area', 'wbr', 'hr', 'col', 'embed', 'param', 'track', ] class Expression { constructor(x, extraVars) { this.x = x; this.extraVars = extraVars || [] } [customInspectSymbol](depth, inspectOptions, inspect) { const vars = [] const x = this.x.trim() const isInterpolation = x[0] === '`' const locale = Object.keys(compiled.label)[0] for (const list of [ Object.keys(compiled.param), Object.keys(compiled.var), Object.keys(compiled.data), this.extraVars, Object.keys(compiled.label[locale] ?? {}) ]) { for (const k of list) { if (vars.includes(k)) continue let idx = -1 let add = false while ((idx = x.indexOf(k, idx + 1)) > -1) { if (idx === -1) break if (idx + k.length < x.length) { const nextCharacter = x.at(idx + k.length) if (nextCharacter.toLowerCase() !== nextCharacter.toUpperCase()) continue } if (idx > 0) { const previousCharacter = x.at(idx - 1) if (['.', '_', '$', '"', "'", '`'].includes(previousCharacter)) continue if (previousCharacter.toLowerCase() !== previousCharacter.toUpperCase()) continue } add = true break } if (add) vars.push(k) } } const with$ = (x.indexOf('$.') > -1 || x.indexOf('$[') > -1 || x.indexOf('($)') > -1) ? ', $' : '' if (!vars.length) return `(_${with$}) => ${x}` if (vars.length === 1 && x.startsWith(vars[0])) { if (vars[0].length === x.length || ['.', '['].includes(x.at(vars[0].length))) { return `(_${with$}) => _.${x}` } } return `(_${with$}) => { const {${vars.join(',')}} = _; return ${x} }` } } class Slot { constructor(s) { this.s = s } [customInspectSymbol](depth, inspectOptions, inspect) { return `(_) => _['…${this.s}']` } } const version = () => { if (compoteVersion) return compoteVersion const content = fsSync.readFileSync(new URL(`${__dirname}/package.json`, import.meta.url), { encoding: 'utf-8'}) let json try { json = JSON.parse(content) } catch (e) { console.error(`can't parse the compote package.json file`) } compoteVersion = json?.version ?? '?' return compoteVersion } const envValueInterpolation = (str, name) => { const chunks = [] for (let i=0; i < str.length; i++) { const opener = str.indexOf('{', i) let until = opener === -1 ? undefined : opener const before = str.slice(i, until) if (before.length) chunks.push(before) if (until === undefined) break i = until const closer = str.indexOf('}', i) until = closer === -1 ? undefined : closer if (until === undefined) { chunks.push('{') continue } const key = str.slice(opener + 1, closer) if (key in process.env) { chunks.push(`${process.env[key]}`) i = closer continue } console.error(`missing environment variable « ${key} » to construct the « ${name} » value « ${str} »`) process.exit(1) } return chunks.join('') } const configFile = async () => { const env = process.env Object.assign(config, defaultOptions) const configPath = env.COMPOTE_JSON || `${cwd}/compote.json` // Load the config file only if exists let content = null if (fsSync.existsSync(configPath)) { if (env.COMPOTE_JSON) console.log(`Custom configuration path « ${configPath} » `) try { content = fsSync.readFileSync(new URL(configPath, import.meta.url), { encoding: 'utf-8'}) if (!content) throw new Error() } catch (e) { console.error(`${configPath} format invalid`) console.log({ content: content }) process.exit(1) return false } } // Config file is optionnal if (content === null) return let conf = {} try { conf = JSON.parse(content) if ('paths' in conf) { for (const p in conf.paths) { conf.paths[p] = envValueInterpolation(conf.paths[p], p) } config.paths = conf.paths } if ('dev' in conf) { config.dev = conf.dev for (const r of config.dev.routes) { if (typeof r.match === 'string') r.match = new RegExp(r.match) } } if ('syntax' in conf) { config.syntax = conf.syntax } if ('behavior' in conf) { config.behavior = conf.behavior } if ('options' in conf) { config.options = conf.options } } catch (e) { console.error(`${configPath} format invalid`) process.exit(1) } // import custom functions if (config.options.functions) { try { console.log(`loading custom function ${config.options.functions}`) config.customFunctions = (await import(`${cwd}/${config.options.functions}`)) } catch (e) { console.error(`can't import your custom functions ${config.options.functions}`, e) process.exit(1) } } return } function exit(error, details, code = 1) { console.dir({ error, details }, { depth: Infinity}) if (app.dev) { console.log('________________________________________') return } process.exit(code) } const splitURL = (u) => { const url = u.startsWith('http') ? url : `http://127.0.0.1${u}` const querymark = url.indexOf('?') const query = querymark > -1 ? url.substring(querymark) : '' const hostmark = url.indexOf('/') + 2 const pathmark = url.indexOf('/', hostmark) const host = url.substring(hostmark, pathmark) const fullpath = querymark > -1 ? url.substring(pathmark, querymark) : url.substring(pathmark) const path = fullpath.substring(fullpath.at(0) === '/' ? 1 : 0, fullpath.at(-1) === '/' ? -1 : fullpath.length) const lastSlash = path.lastIndexOf('/') const file = lastSlash === -1 ? path : path.slice(lastSlash + 1) const paths = file === path ? path.split('/') : path.slice(0, lastSlash).split('/') return { url, host, fullpath, path, query, file, paths } } const router = (url) => { const u = splitURL(url) const args = {} for (const r of config.dev.routes) { if (Number.isInteger(r.match)) continue const match = u.path.match(r.match) if (!match) continue if (r.rewrite) { return { ...u, rewrited: r.rewrite(match, u.path), cache: r.cache } } if (r.args?.length) { r.args.map((key, i) => args[key] = match[i + 1]) } return { ...u, ...r, args } } return { ...u, match: false, page: '' } } async function serverHTTPS(request, response) { return server(app.serverHTTPS, request, response) } async function serverHTTP(request, response) { return server(app.serverHTTP, request, response) } async function server(serverType, request, response) { const [ urlWithoutParams, params ] = request.url.split('?', 1) const url = urlWithoutParams.at(-1) === '/' ? `${urlWithoutParams}index.html` : urlWithoutParams let filePath // Route demandée let ssr if (app.dev) { const origin = `:${serverType.address().port}` ssr = (route) => { // console.log(origin, request.url, route.page) // Vérifie que le chemin indiqué par la route existe puis l'importe const compiledFilePath = addPaths(config.paths?.compiled, `${route.page}.tpl.mjs`) if (!fsSync.existsSync(compiledFilePath)) { console.error(`Component « ${route.page} » not found`, { url, route, compiledFilePath }) response.writeHead(418) return response.end(``) } try { build(compiledFilePath, route.args, response, request) } catch (e) { console.error('\x1b[35m%s\x1b[0m', `BUILD ERROR:\n${e.toString()}`) response.writeHead(500) return response.end(``) } } const route = router(url) if (route.rewrited) { filePath = `${config.paths.cache}/${route.path}` console.log('Request', origin, request.url, route.rewrited) // S'il n'existe pas encore en cache if (!route.cache || !fsSync.existsSync(filePath)) { console.log('DOWNLOAD', filePath) // Créé les répertoires intérmédiaires await fs.mkdir(`${config.paths.cache}/${route.paths.join('/')}`, { recursive: true }) // Télécharge le fichier const file = fsSync.createWriteStream(filePath) const dl = https.get(route.rewrited, res => res.pipe(file)) await Promise.resolve(dl) .catch(err => { console.log({ DOWNLOAD_ERROR: err }) fsSync.unlink(filePath) }) await new Promise(resolve => setTimeout(resolve, 200)) } } else if (route.page) { console.log('Request', origin, request.url, route.page) return ssr(route) } else if (route.proxy) { console.log('Request', origin, request.url, route.proxy) let [ hostname, port ] = route.proxy.split(':') if (!hostname) hostname = '127.0.0.1' if (!port) port = 80 var options = { hostname, port, path: request.url, method: request.method, headers: request.headers } var proxy = http.request(options, (res) => { response.writeHead(res.statusCode, res.headers) res.pipe(response, { end: true }) }) .on('error', e => { console.error(`Request error to the reverse proxy`, e.toString()) response.writeHead(503) response.end(``) }) request.pipe(proxy, { end: true }) return } } // Communication spécifique pour le server de dév compote /.well-known/compote if (request.url.startsWith('/.well-known/compote')) { const POST = {} console.log(`-------------- ${request.url} --------------`) if (request.method.toUpperCase() === 'POST') { request.on('data', function(data) { try { console.dir(JSON.parse(data.toString()), { depth: Infinity }) } catch (e) { console.log(data) } }) } response.writeHead(200) return response.end('') } // Aucune route ne correspond, on renvoie le fichier demandé du dossier public const staticPath = app.dev ? config.paths.public : config.paths.dist filePath = filePath ? filePath : addPaths(staticPath, url) const extname = String(Path.extname(filePath)).toLowerCase() if (!extname) filePath += '/index.html' const mimeTypes = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.wav': 'audio/wav', '.mp4': 'video/mp4', '.woff': 'application/font-woff', '.ttf': 'application/font-ttf', '.eot': 'application/vnd.ms-fontobject', '.otf': 'application/font-otf', '.wasm': 'application/wasm', '.m3u8':`application/vnd.apple.mpegURL`, '.m4s': 'video/iso.segment', '.mp4': 'video/mp4', '.ts': 'video/MP2T', } const contentType = mimeTypes[extname || '.html'] || 'application/octet-stream' const exportedFiles = { scripts: 'text/javascript', styles: 'text/css', } for (const type in exportedFiles) { if (contentType !== exportedFiles[type]) continue // const typePath = config.paths[`dist_${type}`].slice(config.paths.dist) const typePath = config.paths[`dist_${type}`] const relativePath = filePath.slice(config.paths.public.length) if (!relativePath.startsWith(typePath)) continue const componentName = relativePath.split('/').at(-1).slice(0, -1 * extname.length)//.split('.').at(0) // TODO : vérifier qu'il s'agit d'un composant réel (pour éviter la colision avec un script externe commençant par une majuscule) if (componentName.at(0) < 'A' || componentName.at(0) > 'Z') continue filePath = `${config.paths.compiled}/${componentName}${extname}` // console.log('Re-routed', type, filePath) } fsSync.readFile(filePath, function(error, content) { if (error) { if (error.code == 'ENOENT') { response.writeHead(404) if (filePath.indexOf('.html') > -1) { const custom404 = config.dev.routes.find(r => r.match === 404) if (!custom404) return response.end(`Page Not Found\n`) if (ssr) return ssr(custom404) } else { return response.end(`File Not Found\n`) } } else { response.writeHead(500); return response.end(`error: ${error.code} ..\n`); } } else { response.writeHead(200, { 'Content-Type': contentType }) return response.end(content, 'utf-8') } }) } const envFile = async (filename) => { if (app.env) return app.env = {} const env = await fs.readFile(filename, { encoding: 'utf-8' }) .catch(e => exit(`\x1b[31mEnvironment file « ${filename} » not found\x1b[0m`)) const lines = env.trim().split("\n") for (const l of lines) { const line = l.trim() if (!line.length || line[0] === '#') continue const equals = line.indexOf('=') if (equals < 1) { console.error(`Environment file format invalid`, { line }) continue } const key = line.slice(0, equals).trim() const value = line.slice(equals + 1) process.env[key] = value app.env[key] = value } } const addPaths = (...paths) => { let all = [] for (let path of paths) { // Convert next paths starts if (all.length) { if (!path) continue if (path.startsWith('./')) path = path.slice(2) else if (path.at(0) === '/') path = path.slice(1) } // Missing first path fallback else if (!path) path = './' // Trailing slash if (path.at(-1) === '/') path = path.slice(0, -1) all.push(path) } return all.join('/') } function ifMetaComponent(filename) { const lastSlash = filename.lastIndexOf('/') const ext = filename.lastIndexOf('.tpl.mjs') const cpn = filename.slice(lastSlash === -1 ? 0 : lastSlash + 1, ext) if (cpn in config.options.merge) return filename.replace(cpn, `${cpn}@`) return filename } async function build(compiledFilePath, attributes, response, request) { const { Worker, MessageChannel, MessagePort, isMainThread, parentPort } = (await import('worker_threads')) const cpn = compiledFilePath.split('/').at(-1).replace('.tpl.mjs', '') // if (process.env.ENV) { // await envFile(process.env.ENV) // delete process.env.ENV // } let compiledFullPath = addPaths(cwd, ifMetaComponent(compiledFilePath)) const workerCode = ` const worker = async () => { const [ , , component, attributesJSON, configJSON ] = process.argv const attributes = JSON.parse(attributesJSON) const config = JSON.parse(configJSON) const Compote = (await import('${__dirname}/Compote.mjs')).default if (config.options.functions) { const fn = (await import(config.options.functions)) Object.assign(Compote.fn, fn) } const RequestedComponent = (await import(component)).default const {parentPort, workerData} = (await import('worker_threads')) const env = {} Object.keys(process.env).filter(k => k.startsWith('PUBLIC_')).map(k => env[k] = process.env[k]) const state = { dev: ${app.dev ? 'true' : 'false'}, env, locale: env.PUBLIC_LANG || 'fr', components: {}, allComponents: {}, _scripts: [], _styles: [], config } state.canonical = "${process.env.PUBLIC_DOMAIN ? `https://${process.env.PUBLIC_DOMAIN}${request.url}` : request.url ?? ''}" state.components[RequestedComponent.name] = RequestedComponent await Compote.loadDependencies(RequestedComponent, state.components, true, state, '${compiledFullPath}', config) const requestedComponent = new RequestedComponent(Compote, state, attributes) output = await Compote.build(state, requestedComponent, undefined, true) parentPort.postMessage(output) } worker() ` const argv = [ ifMetaComponent(compiledFilePath), JSON.stringify(attributes), JSON.stringify(config), ] const compote = new Worker(workerCode, { eval: true, argv }) compote.once('message', content => { const headers = { ...(config.dev.headers ?? {}), 'Content-Type': 'text/html' } // Si l'entête CSP est demandé, on extrait la balise meta concernée pour l'injecter dans le header HTTP const csp = 'Content-Security-Policy' if (csp in headers) { const idx = content.indexOf(csp) const quote = content.at(idx - 1) const after = `${(quote === '=' ? '' : quote)} content="` const until = idx + csp.length + after.length if (content.slice(idx + csp.length, until) === after) { const extract = content.slice(until, content.indexOf('"', until)) headers[csp] = headers[csp].replace('{CSP}', extract) } } response.writeHead(200, headers) response.end(content, 'utf-8') }) compote.once('error', content => { console.error(`Build error`, content) response.writeHead(500, { 'Content-Type': 'text/html' }) response.end('', 'utf-8') }) } const isFile = (path) => { if (path.at(-1) === '/') return false const parts = path.split('/') return (parts.at(-1).slice(1).indexOf('.') > -1) } async function initProject() { console.log('\nInitialize your project...\n') const configFile = 'compote.json' const exists = await fs.stat(configFile) .catch((e) => false) if (exists) { console.error(`a compote.json file already exists!`) process.exit(1) } const defaultConfig = { "dev": { "http": { "enable": true, "port": 1080 }, "https": { "enable": false, "port": 1443, "key": "./tls/localhost.key", "cert": "./tls/localhost.crt" }, "routes": [ { "match": "^index\\.html$", "page": "HomePage" }, { "match": 404, "page": "NotFoundPage", "args": [ "path", "query" ] } ], "headers": { "Content-Security-Policy": "report-uri /.well-known/compote; {CSP}" } }, "paths": { "src": "./src/components", "compiled": "./.compote/compiled", "public": "./public", "dist": "./dist", "dist_scripts": "./js", "dist_styles": "./css", "cache": "./.compote/cache" }, "options": { "functions": false, "pipe": false, "hashed_filenames": true, "sitemap": { "index": "sitemap.xml" } } } const defaultConfigString = JSON.stringify(defaultConfig, null, 2) for (const p in defaultConfig.paths) { console.log(`create directory ${p} => ${defaultConfig.paths[p]}`) await fs.mkdir(defaultConfig.paths[p], { recursive: true }) } const ignore = [ '.compote/', 'dist/', ] console.log(`\nIgnore theses files in your distributed version control (eg: gitignore) file:\n${ignore.join("\n")}`) console.log(`\n\nCreating compote.json config file...`) await fs.writeFile(configFile, defaultConfigString, { encoding: 'utf-8' }) .catch(e => console.error(`can't write ${configFile} file`)) console.log(`-------------------------------------------------------`) console.dir(defaultConfig, { depth: Infinity }) console.log(`-------------------------------------------------------`) console.log(`\n\n Start by running: npx compote --dev ( Details: npx compote --help )`) process.exit(0) } async function integrity(content, algo='sha256') { const b64 = crypto.createHash(algo).update(content).digest('base64') return { integrity: `${algo}-${b64}`, hash: simpleHash(b64), // b64.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '~'), } } function compoteHelpAndVersion(args=[]) { const options = args.filter(a => a.startsWith('--')) if (!args.length || options.includes('--help') || options.includes('--version')) { version() if (options.includes('--version')) { console.log(`COMPOTE version ${compoteVersion}`) process.exit(0) } console.log(` COMPOTE version ${compoteVersion} Compilation: ------------ By component: npx compote --compile ./src/Component.html ./compiled/ By folder: npx compote --compile ./src/ ./compiled/ Compile folder and watch changes to auto-compilation: npx compote --watch ./src/ ./compiled/ Build: ------ By file with optional JSON parameters: npx compote --build ./compiled/Component.tpl.mjs ./build/index.html {json} Build all pages, based on .env file or folder: npx compote --build-pages ./compiled/ ./build/ Distribution: ------------- Compilation and build all pages npx compote --dist ./src/ ./compiled/ ./build/ Options: --test Launch a static web server to test --mv-public Move public/ folder rather copying files To use only in temporary virtual machine Developement: ------------ Development web server with auto-compilation and building based on .env file or folder: npx compote --dev ./src/ ./compiled/ `) process.exit(1) } } async function compote(args=[]) { const startTime = new Date() const argsWithoutOptions = args.filter(a => !a.startsWith('--')) const options = args.filter(a => a.startsWith('--')) // Initialisation compiled.param = {} compiled.var = {} compiled.data = {} compiled.label = {} compiled.setup = {} compiled.template = [] compiled.style = '' compiled.script = '' compiled.scriptLabels = [] for (const k in code) delete code[k] for (const k in dependencies) delete dependencies[k] for (const k in routes) delete routes[k] forStack.length = 0 let srcPath, compiledPath, distPath, json let required = {} let errorMessage if (options.includes('--init')) { return await initProject() } if (options.includes('--dist')) { [ srcPath, compiledPath, distPath ] = argsWithoutOptions required = { srcPath, compiledPath, distPath } errorMessage = `missing path parameter\nnpx compote --dist <src path> <compiled path> <build path>` } else if (options.includes('--build')) { [ compiledPath, distPath, json ] = argsWithoutOptions required = { compiledPath, distPath } errorMessage = `missing path parameter\nnpx compote --build <compiled path> <build path> [json]` } else if (options.includes('--build-pages')) { [ compiledPath, distPath ] = argsWithoutOptions required = { compiledPath, distPath } errorMessage = `missing path parameter\nnpx compote --build-pages <compiled path> <build path>` } else if (options.includes('--compile') || options.includes('--compile-dev') || options.includes('--watch')) { [ srcPath, compiledPath ] = argsWithoutOptions required = { srcPath, compiledPath } errorMessage = `missing path parameter\nnpx compote --compile <src path> <compiled path>` } else if (options.includes('--dev')) { [ srcPath, compiledPath ] = argsWithoutOptions required = { srcPath, compiledPath } errorMessage = `missing path parameter\nnpx compote --dev <src path> <compiled path>` } if (('srcPath' in required && !required.srcPath) || ('compiledPath' in required && !required.compiledPath) || ('distPath' in required && !required.distPath)) { if (config.paths.src) srcPath = config.paths.src if (config.paths.compiled) compiledPath = config.paths.compiled if (config.paths.dist) distPath = config.paths.dist if (('srcPath' in required && !srcPath) || ('compiledPath' in required && !compiledPath) || ('distPath' in required && !distPath)) { console.error(errorMessage, Object.values(required)) process.exit(1) } } // ================================================================== // 1 - COMPILATION // ================================================================== // Fichier source => out const doCompile = options.includes('--compile') || options.includes('--compile-dev') || options.includes('--watch') const srcFolder = doCompile && srcPath && !isFile(srcPath) if (options.includes('--compile-dev')) app.dev = true // Compilation dossier/fichier spécifique if (doCompile && !('templates' in app)) { const srcRoot = srcFolder ? (srcPath.at(-1) === '/' ? srcPath.slice(0, -1) : srcPath) : srcPath.slice(0, srcPath.lastIndexOf('/')) console.log({ srcRoot, srcFolder }) app.templates = await findComponentFiles(srcRoot) } // Compilation d'un dossier if (doCompile && srcFolder) { console.log(`Compilation of directory ${srcPath}`) // app.templates = await findComponentFiles(srcPath.at(-1) === '/' ? srcPath.slice(0, -1) : srcPath) for (const name in app.templates) { if ('html' in app.templates[name]) { const html = `${app.templates[name].html}` app.multipleCompile = true await compote([ options.includes('--compile-dev') ? '--compile-dev' : '--compile', html, compiledPath ]) delete app.multipleCompile } } // Fusion des scripts/styles vers le fichier <composant>@.[js/css] for (const to in config.options.merge) { const t = { js: { tag: 'script', }, css: { tag: 'style', } } // Prend chaque fichiers source .css/.js pour les fusionner en un seul for (const ext in t) { const metaFilename = addPaths(compiledPath, `${to}@.${ext}`) // var toStream = await fsSync.createWriteStream(metaFilename) const exists = [] for (let cpn of [ to, ...config.options.merge[to] ]) { const filepath = addPaths(compiledPath, `${cpn}.${ext}`) if (await fsSync.existsSync(filepath)) exists.push(filepath) } let merged = [] for (let filepath of exists) { merged.push(await fs.readFile(filepath, { encoding: 'utf-8' })) // await fsSync.createReadStream(filepath).pipe(toStream) } merged = merged.join('') await fs.writeFile(metaFilename, merged) .catch(e => exit(`can't write the file ${metaFilename} !`, e)) // Calcul le checksum d'intégrité t[ext].checksum = await integrity(merged) t[ext].checksum.file_hash = `${to}${config.syntax.hashed_filename}${t[ext].checksum.hash}.${ext}` // Créer le lien hard vers la version de fichier incluant le hash await fs.link(metaFilename, addPaths(compiledPath, t[ext].checksum.file_hash)) .catch(e => { if (e.code === 'EEXIST') return exit(`can't write the asset hashed filename link ${compiledPath}/${t[ext].checksum.file_hash} !`, e) }) // console.log(`Merge ${to}@.${ext} [ ${exists.map(x => x.split('/').at(-1)).join(', ')} ]`) } // Clone le contenu actuel du composant pour changer les hash/integrity des fichiers scripts/styles fusionnés const toTpl = `${to}.tpl.mjs` const toPath = addPaths(compiledPath, toTpl) const tpl = await fs.readFile(toPath, { encoding: 'utf-8' }) .catch(e => exit(`can't read the file ${toPath} !`, e)) // Recherche la partie des scripts t.js.start = tpl.indexOf('script: [') if (t.js.start === -1) return exit(`can't find the « script: [] » section in template ${toTpl} !`) t.js.end = t.css.start = tpl.indexOf('style: [', t.js.start) if (t.css.start === -1) return exit(`can't find the « style: [] » section in template ${toTpl} !`) t.css.end = tpl.indexOf('template: [', t.css.start) if (t.css.end === -1) return exit(`can't find the « template: [] » section in template ${toTpl} !`) // Extrait la partie script/style for (const ext in t) { const codeJS = tpl.slice(tpl.indexOf('[', t[ext].start), tpl.lastIndexOf(',', t[ext].end)) t[ext].json = codeJS.replaceAll(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": ').replaceAll("'", '"') try { t[ext].data = JSON.parse(t[ext].json) } catch (e) { return exit(`Can't parse JS code of « ${t[ext].tag} » in template ${toTpl} for merging feature`, e) } t[ext].main = t[ext].data.find(s => s.file === `${to}.${ext}`) t[ext].main.file = `${to}@.${ext}` t[ext].main.integrity = t[ext].checksum.integrity t[ext].main.hash = t[ext].checksum.hash t[ext].main.file_hash = t[ext].checksum.file_hash } // Décline le fichier tpl.mjs en incluant les nouvelles données script/style const script = JSON.stringify(t.js.data) const style = JSON.stringify(t.css.data) const metaTpl = [ tpl.slice(0, t.js.start), `script: ${script}, \nstyle: ${style},\n`, tpl.slice(t.css.end), ].join('') const metaTplFilename = addPaths(compiledPath, `${to}@.tpl.mjs`) await fs.writeFile(metaTplFilename, metaTpl) .catch(e => exit(`can't write the file ${metaTplFilename} !`, e)) } } // Compilation d'un composant explicite if (doCompile && !srcFolder) { if (isFile(compiledPath)) { console.error(`compiled path need to be a directory`, { srcPath, compiledPath, args }) process.exit(1) } console.log(`Compilation of file ${srcPath}`) // Prépare les variables for (const d in dependencies) delete dependencies[d] const idx = { lastSlash: srcPath.lastIndexOf('/') } const name = idx.lastSlash > -1 ? srcPath.replace('.html', '').slice(idx.lastSlash + 1) : srcPath const path = idx.lastSlash > -1 ? srcPath.slice(0, idx.lastSlash) : './' // Lit le fichier HTML const file = await fs.readFile(srcPath.replace('.html', '') + '.html', { encoding: 'utf-8' }) .catch(e => exit(`can't read the file ${srcPath}.html !`, e)) // Sépare les sections await sections(path, name, file) .catch(e => exit(e)) if (code.style) compiled.style = code.style // compileStyle() code.style = null if (code.script) compiled.script = code.script // compileScript() code.script = null if (code.param) compiled.param = await compileData(code.param, 0) .catch(e => exit(`compile param ERROR`, e)) code.param = null if (code.var) compiled.var = await compileData(code.var, 0) .catch(e => exit(`compile data ERROR`, e)) code.var = null if (code.data) compiled.data = await compileData(code.data, 0) .catch(e => exit(`compile data ERROR`, e)) code.data = null if (code.setup) compiled.setup = await compileData(code.setup, 0) .catch(e => exit(`compile data ERROR`, e)) code.setup = null if (code.label) [compiled.label, compiled.scriptLabels] = await compileLabel(code.label, name) .catch(e => exit(`compile label ERROR`, e)) code.label = null if (code.template) compiled.template = await compileTemplate(0, code.template.length) .catch(e => exit(`compile data ERROR`, e)) code.template = null setContext(compiled.template, []) // Gestion des routes spécifiques dans <SETUP> if (compiled.setup?.route) { if (!config.dev.routes.find(r => r.self && r.page === name)) { config.dev.routes.push({ self: 1, match: new RegExp(`^${compiled.setup.route.replaceAll('.', '\\.')}$`), page: name }) } } // Gère les chemins d'accès à ses composants for (const dep in dependencies) { dependencies[dep] = `${dep}.tpl.mjs` // `#compiled/${dep}.tpl.mjs` // const exists = await fs.stat(dependencies[dep]) // .catch(e => console.warn(`\x1b[31mDependence ${dep} not found\x1b[0m `)) } // Vérifie s'il y a un fichier .mjs associé et l'inclus dans le code const mjs = await fs.readFile(srcPath.replace('.html', '.mjs'), { encoding: 'utf-8' }) .catch(e => null) const idxClass = mjs ? mjs.indexOf(`class ${name}`) : -1 const idxBracket = idxClass > -1 ? mjs.indexOf('{', idxClass) : -1 let imports = '' let extend = '' if (mjs) { if (idxClass === -1 || idxBracket === -1) { console.error(`${name}.mjs doesn't include "class ${name}" or his bracket "{`) } else { extend = mjs.slice(idxBracket + 1, mjs.lastIndexOf('}')) const idxClassLine = mjs.lastIndexOf('\n', idxClass) if (idxClassLine > -1) imports = mjs.slice(0, idxClassLine + 1) } } // Génère le source de sortie const opt = { depth: Infinity, colors: false } await version() // Destination const outputFile = addPaths(compiledPath, `${name}.tpl.mjs`) const outputPath = outputFile.slice(0, outputFile.lastIndexOf('/')) // Vérifie l'existence ou crée le chemin de destination if (!(await fsSync.existsSync(outputPath))) { console.log(`create path ${outputPath}`) await fs.mkdir(outputPath, { recursive: true }) } // Enregistre le script et les styles const assets = { js: compiled.script, css: compiled.style, } for (const ext in assets) { let i = 0 for (const a of assets[ext]) { if (a.content?.trim()) { let assetFile = a.file || `${name}${++i > 1 ? i : ''}.${ext}` // Si l'option "pipe" est activée const piped = app.pipe ? await app.pipe({ filename: assetFile, type: ext === 'css' ? 'style' : 'script', content: a.content, app, options, }) : a.content await fs.writeFile(`${outputPath}/${assetFile}`, piped) .catch(e => exit(`can't write the asset file ${outputPath}/${assetFile} !`, e)) a.file = assetFile Object.assign(a, await integrity(piped)) // Hard link pour la version hashée if (config.options.hashed_filenames) { const lastDot = a.file.lastIndexOf('.') a.file_hash = `${a.file.slice(0, lastDot)}${config.syntax.hashed_filename}${a.hash}.${a.file.slice(lastDot + 1)}` await fs.link(`${outputPath}/${assetFile}`,`${outputPath}/${a.file_hash}`) .catch(e => { if (e.code === 'EEXIST') return exit(`can't write the asset hashed filename link ${outputPath}/${a.hash_file} !`, e) }) } else a.file_hash = a.file delete a.content } } } // Enregistre le fichier template let output = `${imports}export default class ${name} { static ___ = { compote: '${compoteVersion}', component: ${name}, dependencies: ${inspect(dependencies, opt)}, prepared: 0, setup: ${inspect(compiled.setup, opt)}, param: ${inspect(compiled.param, opt)}, var: ${inspect(compiled.var, opt)}, data: ${inspect(compiled.data, opt)}, label: ${inspect(compiled.label, opt)}, scriptLabels: ${inspect(compiled.scriptLabels, opt)}, script: ${inspect(assets.js, opt)}, style: ${inspect(assets.css, opt)}, template: ${inspect(compiled.template, opt)}, } constructor(Compote, state, attributes, slot) { Compote.componentConstructor(this, ${name}, state, attributes, slot) }\n${extend}}` Object.keys(compiled).forEach((k,i) => compiled[i] = null) await fs.writeFile(outputFile, output) .catch(e => exit(`can't write the file ${outputFile} !`, e)) // Vérifie si ce composant fait parti d'un meta composant pour recompiler ce dernier également if (!app.multipleCompile) { const metaComponents = Object.keys(config.options.merge).filter(m => config.options.merge[m].includes(name)) for (const meta of metaComponents) { const html = `${app.templates[meta].html}` console.log(`${name} is a dependence of meta component ${html}, restart for full compilation...`) process.exit(2) // await compote([ options.includes('--compile-dev') ? '--compile-dev' : '--compile', html, compiledPath ]) } } } // ================================================================== // 2 - BUILD // ================================================================== // Construction d'un composant compilé à un fichier .html if (options.includes('--build')) { if (!compiledPath) exit(`Missing compiled component source to build`) if (!distPath || ['"', "'", '{'].includes(distPath.at(0))) exit(`Missing HTML file destination to build`) console.log(`construction du composant ${compiledPath} => ${distPath}`) let attributes = {} try { if (json) attributes = JSON.parse(json) } catch (e) { exit(`Error when parsing JSON parameters: ${e} « ${json} »`) } const html = await build(compiledPath, attributes, { writeHead: (code, message) => console.dir({code, message}), response: (response) => console.log({response}), end: (content, encoding) => fs.writeFile(distPath, content, { encoding }) .catch(e => console.log) }) // console.log(html) return } // Construction de toutes les pages if ((options.includes('--build-pages') || options.includes('--dist')) && !options.includes('--bypass-build')) { // Créer le dossier .compote/compiled si inexistant if (!(await fsSync.existsSync(config.paths.compiled))) { await fs.mkdir(config.paths.compiled, { recursive: true }) } else { // Supprime les anciens fichiers compilés (notamment pour les noms de fichiers avec hashage) if (!options.includes('--no-clean')) { await fs.rm(config.paths.compiled, { recursive: true }) await fs.mkdir(config.paths.compiled, { recursive: true }) } } // Compile l'ensemble des components if (options.includes('--dist') && !options.includes('--bypass-compile')) { await compote([ '--compile', srcPath, compiledPath ]) } // Recherche les composants terminant par « Page.tpl.mjs » const pages = (await fs.readdir(compiledPath)) .filter(f => f.indexOf('Page.tpl.mjs') > 0) const env = {} Object.keys(process.env).filter(k => k.startsWith('PUBLIC_')).map(k => env[k] = process.env[k]) const Compote = (await import(`${__dirname}/Compote.mjs`)).default if (config.options.functions) { Object.assign(Compote.fn, config.customFunctions) } const state = { env, locale: env.PUBLIC_LANG || 'fr', components: {}, allComponents: {}, _scripts: [], _styles: [], config } const mkdirCreated = [] let output = '' let nbCharacters = 0 let nbPages = 0 let compiledFullPath = addPaths(cwd, compiledPath) await fs.mkdir(distPath, { recursive: true }) const prefix = addPaths(distPath) let copyTime = 0 // Copy or move public/ folder if (options.includes('--dist')) { const startCopy = new Date() let moved = false const publicPath = config.paths.public if (options.includes('--mv-public') && options.includes('--dist')) { const exists = await fs.stat(prefix).catch((e) => false) if (!exists) { console.log('moving public folder', { from: publicPath, to: prefix }) moved = true await fs.rename(publicPath, prefix).catch(e => { moved = false console.error(`can't move the public folder ${publicPath} to ${prefix}`, e) process.exit(1) }) } } if (!moved) { await fs.mkdir(prefix, { recursive: true }) console.log('copying public folder') copy(publicPath, prefix) } copyTime = new Date() - startTime console.log(`\nfiles copied in ${copyTime / 1000}s`) } else await fs.mkdir(prefix, { recursive: true }) const assets = {} const asset_file = config.options.hashed_filenames ? 'file_hash' : 'file' for (const assetType of ['script', 'style']) { const distAsset = addPaths(prefix, config.paths[`dist_${assetType}s`]) if (!fsSync.existsSync(distAsset)) { await fs.mkdir(distAsset, { recursive: true }) } } // Sitemap const sm = config.options.sitemap.index ? { index: config.options.sitemap.index, nbFiles: 0, nbURLs: 0, maxURLs: 50000, size: 0, maxSize: 50 * 1000 * 1000, fh: null, fhN: null, now: new Date().toISOString(), nextSitemapFile: async (reason) => { const filename = sm.index.replace('.xml', `-${++sm.nbFiles}.xml`) if (sm.fhN !== null) { await sm.fhN.appendFile(`\n</urlset>`) await sm.fhN.close() } sm.fhN = await fs.open(addPaths(config.paths.dist, filename), 'a') await sm.fhN.appendFile(`<urlset>`) await sm.fh.appendFile(`\n<sitemap><loc>https://${process.env.PUBLIC_DOMAIN}/${filename}</loc><lastmod>${sm.now}</lastmod></sitemap>`) sm.size = Buffer.byteLength(`<urlset>\n</urlset>`, 'utf-8') sm.nbURLs = 0 c.log(`> new sitemap file ${filename} (reason: ${reason})`) }, } : false if (sm) { sm.fh = await fs.open(addPaths(config.paths.dist, sm.index), 'a') await sm.fh.appendFile(`<sitemapindex>`) await sm.nextSitemapFile('initialisation') } for (const page of pages) { const Component = (await import(ifMetaComponent(`${compiledFullPath}/${page}`))).default state.allComponents[Component.name] = Component const routes = 'routes' in Component ? await Component.routes() : [ { path: Component.___.setup.route, params: {}, } ] state._scripts = [] state._styles = [] state.components = await Compote.loadDependencies(Component, state.allComponents, true, state, compiledFullPath, config) console.log(`${page} ${routes.length}x... => ${prefix}`) // Copie les assets for (const cp in state.components) { for (const assetType of ['script', 'style']) { for (const a of state.components[cp].___[assetType]) { if (assets[a[asset_file]]) continue assets[a[asset_file]] = true const from = addPaths(config.paths.compiled, a[asset_file]) const to = addPaths(prefix, config.paths[`dist_${assetType}s`], a[asset_file]) fs.copyFile(from, to) } } } for (const route of routes) { delete state.page if (!route.path) { console.error(`ERROR: route missing for component ${Component.name}`) continue } let filepath = addPaths(prefix, route.path) const isDir = route.path.at(-1) === '/' || !isFile(filepath) if (isDir && !mkdirCreated.includes(filepath)) { await fs.mkdir(filepath, { recursive: true }) mkdirCreated.p