import { createPrinter, factory, NewLineKind, NodeFlags, type PropertyAssignment, SyntaxKind, type VariableDeclaration, type VariableStatement } from 'typescript'; import typescriptModule from 'typescript'; import { readFile, writeFile, readdir } from 'node:fs/promises'; import { basename, dirname, join, normalize } from 'node:path'; import {createHash} from 'node:crypto'; import camelcase from 'camelcase'; import { render as renderLess } from 'less'; import CleanCSS from 'clean-css'; import type { HashedBundled, SourceMappedHashedBundled, SourceMappedBundled, Bundled } from '../common/bundle.js'; import type { RawSourceMap } from 'source-map'; import { rollup, type RollupCache } from 'rollup'; import babel from '@rollup/plugin-babel'; import typescript from '@rollup/plugin-typescript'; import terser from '@rollup/plugin-terser'; function* assignProperties(pairs: Iterable<[string, HashedBundled]>): Generator { for (const [identifier, { bundled, hash }] of pairs) { yield factory.createPropertyAssignment( factory.createIdentifier(identifier), factory.createObjectLiteralExpression([ factory.createPropertyAssignment( factory.createIdentifier("bundled"), factory.createNoSubstitutionTemplateLiteral(bundled) ), factory.createPropertyAssignment( factory.createIdentifier("hash"), factory.createStringLiteral(hash) ), ], true)); } } function declareObjectLiteral(identifier: string, pairs: Iterable<[string, HashedBundled]>): VariableDeclaration { return factory.createVariableDeclaration( factory.createIdentifier(identifier), undefined, undefined, factory.createSatisfiesExpression( factory.createAsExpression( factory.createObjectLiteralExpression(Array.from(assignProperties(pairs)), true), factory.createTypeReferenceNode(factory.createIdentifier('const'))), factory.createTypeReferenceNode( factory.createIdentifier("Record"), [ factory.createTypeReferenceNode(factory.createIdentifier("string")), factory.createTypeReferenceNode(factory.createIdentifier("HashedBundled"))]))); } function exportObjectLiteral(identifier: string, pairs: Iterable<[string, HashedBundled]>): VariableStatement { return factory.createVariableStatement( [factory.createToken(SyntaxKind.ExportKeyword)], factory.createVariableDeclarationList([declareObjectLiteral(identifier, pairs)], NodeFlags.Const) ); } async function processLess(atPath: string): Promise { const fileBase = basename(atPath.substring(0, atPath.length - LESS_SUFFIX.length)); const { css: lessCss, map: lessMap } = await renderLess(await readFile(atPath, { encoding: 'utf-8' }), { paths: [dirname(atPath)], math: 'strict', strictUnits: true, filename: fileBase + '.less', strictImports: true, sourceMap: { outputSourceFiles: true, sourceMapFileInline: true } }); const { styles, sourceMap } = await new CleanCSS({ sourceMap: true, sourceMapInlineSources: true, returnPromise: true, level: 2, format: false, inline: ['all'], rebase: false, compatibility: '*', fetch(uri): never { throw Error(`external files are unexpected after less compilation, but found ${uri}`) }, }).minify({ [fileBase + '.css']: { styles: lessCss, sourceMap: lessMap } }) return { bundled: styles, sourceMap: JSON.parse(sourceMap!.toString()) as RawSourceMap }; } async function processTypescript(atPath: string, inDir: string, cache?: RollupCache): Promise<{cache: RollupCache, bundle: SourceMappedBundled}> { const build = await rollup({ cache: cache ?? true, input: atPath, plugins: [ typescript({ noEmitOnError: true, noForceEmit: true, emitDeclarationOnly: false, noEmit: true, include: [join(inDir, '**', '*.ts')], typescript: typescriptModule, tsconfig: join(inDir, 'tsconfig.json') }), babel({ babelHelpers: 'bundled', include: [join(inDir, '**', '*.ts'), join(inDir, '**', '*.js')], extensions: ['js', 'ts'], presets: ['@babel/preset-typescript'] }), terser({}) ] }) const {output: [chunk]} = await build.generate({ sourcemap: 'hidden', sourcemapFile: join(inDir, 'sourcemap.map'), format: 'iife', compact: true, }) return { cache: build.cache!, bundle: { bundled: chunk.code, sourceMap: chunk.map! } } } const LESS_SUFFIX = '-entrypoint.less'; const TS_SUFFIX = '-entrypoint.ts'; function hashBundled(value: T & {readonly hash?: never}): T & HashedBundled { const hash = createHash('sha256').update(value.bundled).digest('hex') return { ...value, hash, } } export async function getBundle(inDir: string): Promise<{ css: Map, js: Map }> { const css = new Map(); const js = new Map(); const dir = await readdir(inDir, { withFileTypes: true }); let cache: RollupCache|undefined = undefined for (const ent of dir) { if (!ent.isFile()) { continue; } if (ent.name.endsWith(LESS_SUFFIX)) { css.set(camelcase(ent.name.substring(0, ent.name.length - LESS_SUFFIX.length)), hashBundled(await processLess(join(inDir, ent.name)))); } else if (ent.name.endsWith(TS_SUFFIX)) { const {cache: newCache, bundle} = await processTypescript(join(inDir, ent.name), inDir, cache) cache = newCache js.set(camelcase(ent.name.substring(0, ent.name.length - TS_SUFFIX.length)), hashBundled(bundle)); } else { // continue; } } return { css, js }; } export const DEFAULT_IN_PATH = normalize(join(__dirname, '../../src/client/')) export const DEFAULT_OUT_PATH = normalize(join(__dirname, '../../src/server/web/client.generated.ts')) export async function writeBundle({ css, js }: {css: Map, js: Map}, outFile: string): Promise { const printer = createPrinter({ newLine: NewLineKind.LineFeed, omitTrailingSemicolon: true }); await writeFile(outFile, printer.printFile(factory.createSourceFile([ factory.createImportDeclaration( undefined, factory.createImportClause( false, undefined, factory.createNamedImports([ factory.createImportSpecifier( true, undefined, factory.createIdentifier("HashedBundled")) ]) ), factory.createStringLiteral("../../common/bundle.js")), exportObjectLiteral('CSS', css), exportObjectLiteral('JS', js) ], factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None)), { encoding: 'utf-8', mode: 0o644 }); }