import { createPrinter, parseJsonText, 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, MaybeSourceMappedHashedBundled, SourceMap } from '../common/bundle'; import { rollup, type RollupCache } from 'rollup'; import typescript from 'rollup-plugin-ts'; import terser from '@rollup/plugin-terser'; import nodeResolve from '@rollup/plugin-node-resolve'; import commonJs from '@rollup/plugin-commonjs'; function* assignProperties(pairs: Iterable<[string, MaybeSourceMappedHashedBundled]>, includeSourceMap: boolean): Generator { for (const [identifier, { bundled, hash, sourceMap }] 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) ), ...(includeSourceMap && sourceMap ? [factory.createPropertyAssignment( factory.createIdentifier("sourceMap"), parseJsonText(hash + ".map", JSON.stringify(sourceMap)).statements[0].expression, )] : []) ], true)); } } function declareObjectLiteral(identifier: string, pairs: Iterable<[string, MaybeSourceMappedHashedBundled]>, includeSourceMap: boolean): VariableDeclaration { return factory.createVariableDeclaration( factory.createIdentifier(identifier), undefined, undefined, factory.createSatisfiesExpression( factory.createAsExpression( factory.createObjectLiteralExpression(Array.from(assignProperties(pairs, includeSourceMap)), true), factory.createTypeReferenceNode(factory.createIdentifier('const'))), factory.createTypeReferenceNode( factory.createIdentifier("Record"), [ factory.createTypeReferenceNode(factory.createIdentifier("string")), factory.createTypeReferenceNode(factory.createIdentifier("MaybeSourceMappedHashedBundled"))]))); } function exportObjectLiteral(identifier: string, pairs: Iterable<[string, MaybeSourceMappedHashedBundled]>, includeSourceMap: boolean): VariableStatement { return factory.createVariableStatement( [factory.createToken(SyntaxKind.ExportKeyword)], factory.createVariableDeclarationList([declareObjectLiteral(identifier, pairs, includeSourceMap)], 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, } }); 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()), file: fileBase + ".css"} as SourceMap }; } async function processTypescript(atPath: string, inDir: string, cache?: RollupCache): Promise<{cache: RollupCache, bundle: SourceMappedBundled}> { const build = await rollup({ cache: cache ?? true, input: atPath, plugins: [ nodeResolve({ }), commonJs({ }), typescript({ transpiler: "babel", typescript: typescriptModule, tsconfig: join(inDir, 'tsconfig.json') }), terser({}) ] }) const {output: [chunk]} = await build.generate({ name: camelcase(basename(atPath.substring(0, atPath.length - TS_SUFFIX.length))), 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/bundles/client.generated.ts')) export async function writeBundle({ css, js }: {css: Map, js: Map}, outFile: string, includeSourceMap: true): Promise export async function writeBundle({ css, js }: {css: Map, js: Map}, outFile: string, includeSourceMap: false): Promise export async function writeBundle({ css, js }: {css: Map, js: Map}, outFile: string, includeSourceMap: boolean): Promise export async function writeBundle({ css, js }: {css: Map, js: Map}, outFile: string, includeSourceMap: boolean): 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( "MaybeSourceMappedHashedBundled")), ]) ), factory.createStringLiteral("../../common/bundle.js")), exportObjectLiteral('CSS', css, includeSourceMap), exportObjectLiteral('JS', js, includeSourceMap) ], factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None)), { encoding: 'utf-8', mode: 0o644 }); }