@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
JavaScript
#! /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