|
| 1 | +import { AnalysisOptions, Block, BlockCompiler, BlockDefinitionCompiler, BlockFactory, Configuration, INLINE_DEFINITION_FILE, Options as ParserOptions, resolveConfiguration } from "@css-blocks/core"; |
| 2 | +import type { ASTPluginEnvironment } from "@glimmer/syntax"; |
| 3 | +import { MultiMap, ObjectDictionary } from "@opticss/util"; |
| 4 | +import type { InputNode } from "broccoli-node-api"; |
| 5 | +import outputWrapper = require("broccoli-output-wrapper"); |
| 6 | +import md5Sum = require("broccoli-persistent-filter/lib/md5-hex"); |
| 7 | +import persistentStrategy = require("broccoli-persistent-filter/lib/strategies/persistent"); |
| 8 | +import debugGenerator from "debug"; |
| 9 | +import TemplateCompilerPlugin = require("ember-cli-htmlbars/lib/template-compiler-plugin"); |
| 10 | +import FSMerger = require("fs-merger"); |
| 11 | +import * as FSTree from "fs-tree-diff"; |
| 12 | +import { OptiCSSOptions, postcss } from "opticss"; |
| 13 | +import * as path from "path"; |
| 14 | + |
| 15 | +import { AnalyzingRewriteManager } from "./AnalyzingRewriteManager"; |
| 16 | +import { BroccoliFileLocator } from "./BroccoliFileLocator"; |
| 17 | +import { BroccoliTreeImporter, identToPath, isBroccoliTreeIdentifier } from "./BroccoliTreeImporter"; |
| 18 | +import { EmberAnalysis } from "./EmberAnalysis"; |
| 19 | +import { ASTPluginWithDeps } from "./TemplateAnalyzingRewriter"; |
| 20 | + |
| 21 | +type PersistentStrategy = typeof persistentStrategy; |
| 22 | + |
| 23 | +interface AdditionalFile { |
| 24 | + outputPath: string; |
| 25 | + contents: string; |
| 26 | +} |
| 27 | +export const BLOCK_GLOB = "**/*.block.{css,scss,sass,less,styl}"; |
| 28 | + |
| 29 | +type Writeable<T> = { -readonly [P in keyof T]: T[P] }; |
| 30 | + |
| 31 | +const debug = debugGenerator("css-blocks:ember"); |
| 32 | + |
| 33 | +export interface EmberASTPluginEnvironment extends ASTPluginEnvironment { |
| 34 | + meta?: { |
| 35 | + moduleName?: string; |
| 36 | + }; |
| 37 | +} |
| 38 | + |
| 39 | +export interface CSSBlocksEmberOptions { |
| 40 | + output?: string; |
| 41 | + aliases?: ObjectDictionary<string>; |
| 42 | + analysisOpts?: AnalysisOptions; |
| 43 | + parserOpts?: Writeable<ParserOptions>; |
| 44 | + optimization?: Partial<OptiCSSOptions>; |
| 45 | +} |
| 46 | + |
| 47 | +/** |
| 48 | + * This class extends ember-cli-htmlbars's template compiler (which is built on |
| 49 | + * top of broccoli-persistent-filter). In the ember-cli addon for this package |
| 50 | + * we monkey patch ember-cli-htmlbars' ember-cli addon to return an instance of |
| 51 | + * this class instead. |
| 52 | + * |
| 53 | + * The reason we must extend the template compiler is so that we can write |
| 54 | + * and manage the cache for the additional output files that are associated with |
| 55 | + * each template. We produce compiled css-blocks files for the parent as well |
| 56 | + * as template analysis metadata for each template. |
| 57 | + * |
| 58 | + * For each template that uses CSS Blocks we create a cache entry that contains |
| 59 | + * the cache keys for each additional file that is output with the template. |
| 60 | + * the cached data for each additional file includes the output path as well as |
| 61 | + * the contents for that file. |
| 62 | + * |
| 63 | + * Note: It is possible for several templates to depend on the same CSS Block |
| 64 | + * file and so their caches will point to the same additional file cache |
| 65 | + * entries. This produces a little extra work when the cache is warm but |
| 66 | + * ensures consistency if one of the templates is removed. |
| 67 | + * |
| 68 | + * In the case where just one of the templates is invalidated, the css block file |
| 69 | + * will get recompiled after it is retrieved from cache, but this is ok because |
| 70 | + * the output from css-blocks will be identical. |
| 71 | + */ |
| 72 | +export class CSSBlocksTemplateCompilerPlugin extends TemplateCompilerPlugin { |
| 73 | + previousSourceTree: FSTree; |
| 74 | + cssBlocksOptions: CSSBlocksEmberOptions; |
| 75 | + parserOpts: Readonly<Configuration>; |
| 76 | + analyzingRewriter: AnalyzingRewriteManager | undefined; |
| 77 | + input!: FSMerger.FS; |
| 78 | + output!: outputWrapper.FSOutput; |
| 79 | + persist: boolean; |
| 80 | + treeName: string; |
| 81 | + debug: debugGenerator.Debugger; |
| 82 | + constructor(inputTree: InputNode, treeName: string, htmlbarsOptions: TemplateCompilerPlugin.HtmlBarsOptions, cssBlocksOptions: CSSBlocksEmberOptions) { |
| 83 | + super(inputTree, htmlbarsOptions); |
| 84 | + this.cssBlocksOptions = cssBlocksOptions; |
| 85 | + this.parserOpts = resolveConfiguration(cssBlocksOptions.parserOpts); |
| 86 | + this.previousSourceTree = new FSTree(); |
| 87 | + this.treeName = treeName; |
| 88 | + let persist = htmlbarsOptions.persist; |
| 89 | + if (persist === undefined) persist = true; |
| 90 | + this.persist = TemplateCompilerPlugin.shouldPersist(process.env, persist); |
| 91 | + this.debug = debug.extend(treeName); |
| 92 | + } |
| 93 | + astPluginBuilder(env: EmberASTPluginEnvironment): ASTPluginWithDeps { |
| 94 | + let moduleName = env.meta?.["moduleName"]; |
| 95 | + if (!moduleName) { |
| 96 | + this.debug("No module name. Returning noop ast plugin"); |
| 97 | + return { |
| 98 | + name: "css-blocks-noop", |
| 99 | + visitor: {}, |
| 100 | + }; |
| 101 | + } |
| 102 | + this.debug(`Returning template analyzer and rewriter for ${moduleName}`); |
| 103 | + if (!this.analyzingRewriter) { |
| 104 | + throw new Error("[internal error] analyzing rewriter expected."); |
| 105 | + } |
| 106 | + // The analyzing rewriter gets swapped out at the beginning of build() with |
| 107 | + // a new instance. that instance tracks all the analyses that are produced |
| 108 | + // for each ast plugin that is created for each template once super.build() |
| 109 | + // is done, the analyses for all of the templates is complete and we can |
| 110 | + // write additional output files to the output tree. |
| 111 | + return this.analyzingRewriter.templateAnalyzerAndRewriter(moduleName, env.syntax); |
| 112 | + } |
| 113 | + |
| 114 | + async build() { |
| 115 | + let cssBlockEntries = this.input.entries(".", {globs: [BLOCK_GLOB]}); |
| 116 | + let currentFSTree = FSTree.fromEntries(cssBlockEntries); |
| 117 | + let patch = this.previousSourceTree.calculatePatch(currentFSTree); |
| 118 | + let removedFiles = patch.filter((change) => change[0] === "unlink"); |
| 119 | + this.previousSourceTree = currentFSTree; |
| 120 | + if (removedFiles.length > 0) { |
| 121 | + console.warn(`[WARN] ${removedFiles[0][1]} was just removed and the output directory was not cleaned up.`); |
| 122 | + } |
| 123 | + let importer = new BroccoliTreeImporter(this.input, this.parserOpts.importer); |
| 124 | + let config = resolveConfiguration({importer}, this.parserOpts); |
| 125 | + let factory = new BlockFactory(config, postcss); |
| 126 | + let fileLocator = new BroccoliFileLocator(this.input); |
| 127 | + this.debug(`Looking for templates using css blocks.`); |
| 128 | + this.analyzingRewriter = new AnalyzingRewriteManager(factory, fileLocator, this.cssBlocksOptions.analysisOpts || {}, this.parserOpts); |
| 129 | + // The astPluginBuilder interface isn't async so we have to first load all |
| 130 | + // the blocks and associate them to their corresponding templates. |
| 131 | + await this.analyzingRewriter.discoverTemplatesWithBlocks(); |
| 132 | + this.debug(`Discovered ${Object.keys(this.analyzingRewriter.templateBlocks).length} templates with corresponding block files.`); |
| 133 | + |
| 134 | + // Compiles the handlebars files, runs our plugin for each file |
| 135 | + // we have to wrap this RSVP Promise that's returned in a native promise or |
| 136 | + // else await won't work. |
| 137 | + await nativePromise(() => super.build()); |
| 138 | + this.debug(`Template rewriting complete.`); |
| 139 | + |
| 140 | + let blocks = new Set<Block>(); // these blocks must be compiled |
| 141 | + let blockOutputPaths = new Map<Block, string>(); // this mapping is needed by the template analysis serializer. |
| 142 | + let analyses = new Array<EmberAnalysis>(); // Analyses to serialize. |
| 143 | + let templateBlocks = new MultiMap<EmberAnalysis, Block>(); // Tracks the blocks associated with each template (there's a 1-1 relationship beteen analyses and templates). |
| 144 | + let additionalFileCacheKeys = new MultiMap<EmberAnalysis, string>(); // tracks the cache keys we create for each additional output file. |
| 145 | + // first pass discovers the set of all blocks & associate them to their corresponding analyses. |
| 146 | + for (let analyzedTemplate of this.analyzingRewriter.analyzedTemplates()) { |
| 147 | + let { block, analysis } = analyzedTemplate; |
| 148 | + analyses.push(analysis); |
| 149 | + blocks.add(block); |
| 150 | + templateBlocks.set(analysis, block); |
| 151 | + let blockDependencies = block.transitiveBlockDependencies(); |
| 152 | + templateBlocks.set(analysis, ...blockDependencies); |
| 153 | + for (let depBlock of blockDependencies) { |
| 154 | + blocks.add(depBlock); |
| 155 | + } |
| 156 | + } |
| 157 | + this.debug(`Analyzed ${analyses.length} templates.`); |
| 158 | + this.debug(`Discovered ${blocks.size} blocks in use.`); |
| 159 | + |
| 160 | + await this.buildCompiledBlocks(blocks, config, blockOutputPaths, additionalFileCacheKeys, templateBlocks); |
| 161 | + |
| 162 | + await this.buildSerializedAnalyses(analyses, blockOutputPaths, additionalFileCacheKeys); |
| 163 | + |
| 164 | + if (this.persist) { |
| 165 | + for (let analysis of additionalFileCacheKeys.keys()) { |
| 166 | + let cacheKey = this.additionalFilesCacheKey(this.inputFileCacheKey(analysis.template.relativePath)); |
| 167 | + let additionalCacheKeys = additionalFileCacheKeys.get(analysis); |
| 168 | + await (<PersistentStrategy>this.processor.processor)._cache?.set(cacheKey, JSON.stringify(additionalCacheKeys)); |
| 169 | + this.debug(`Stored ${additionalCacheKeys.length} additional output files for ${analysis.template.relativePath} to cache.`); |
| 170 | + this.debug(`Cache keys are: ${additionalCacheKeys.join(", ")}`); |
| 171 | + } |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + async buildCompiledBlocks( |
| 176 | + blocks: Set<Block>, |
| 177 | + config: Readonly<Configuration>, |
| 178 | + blockOutputPaths: Map<Block, string>, |
| 179 | + additionalFileCacheKeys: MultiMap<EmberAnalysis, string>, |
| 180 | + templateBlocks: MultiMap<EmberAnalysis, Block>, |
| 181 | + ): Promise<void> { |
| 182 | + let compiler = new BlockCompiler(postcss, this.parserOpts); |
| 183 | + compiler.setDefinitionCompiler(new BlockDefinitionCompiler(postcss, (_b, p) => { return p.replace(".block.css", ".compiledblock.css"); }, this.parserOpts)); |
| 184 | + for (let block of blocks) { |
| 185 | + this.debug(`compiling: ${config.importer.debugIdentifier(block.identifier, config)}`); |
| 186 | + let outputPath = getOutputPath(block); |
| 187 | + // Skip processing if we don't get an output path. This happens for files that |
| 188 | + // get referenced in @block from node_modules. |
| 189 | + if (outputPath === null) { |
| 190 | + continue; |
| 191 | + } |
| 192 | + blockOutputPaths.set(block, outputPath); |
| 193 | + if (!block.stylesheet) { |
| 194 | + throw new Error("[internal error] block stylesheet expected."); |
| 195 | + } |
| 196 | + // TODO - allow for inline definitions or files, by user option |
| 197 | + let { css: compiledAST } = compiler.compileWithDefinition(block, block.stylesheet, this.analyzingRewriter!.reservedClassNames(), INLINE_DEFINITION_FILE); |
| 198 | + // TODO disable source maps in production? |
| 199 | + let result = compiledAST.toResult({ to: outputPath, map: { inline: true } }); |
| 200 | + let contents = result.css; |
| 201 | + if (this.persist) { |
| 202 | + // We only compile and output each block once, but a block might be consumed |
| 203 | + // by several of the templates that we have processed. So we have to figure out |
| 204 | + // which template(s) depend on the block we're writing. |
| 205 | + for (let {analysis} of this.analyzingRewriter!.analyzedTemplates()) { |
| 206 | + if (templateBlocks.hasValue(analysis, block)) { |
| 207 | + await this.cacheAdditionalFile(additionalFileCacheKeys, analysis, {outputPath, contents}); |
| 208 | + } |
| 209 | + } |
| 210 | + } |
| 211 | + this.output.writeFileSync(outputPath, contents, "utf8"); |
| 212 | + this.debug(`compiled: ${outputPath}`); |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + async buildSerializedAnalyses( |
| 217 | + analyses: Array<EmberAnalysis>, |
| 218 | + blockOutputPaths: Map<Block, string>, |
| 219 | + additionalFileCacheKeys: MultiMap<EmberAnalysis, string>, |
| 220 | + ): Promise<void> { |
| 221 | + for (let analysis of analyses) { |
| 222 | + let outputPath = analysisPath(analysis.template.relativePath); |
| 223 | + let contents = JSON.stringify(analysis.serialize(blockOutputPaths)); |
| 224 | + await this.cacheAdditionalFile(additionalFileCacheKeys, analysis, {outputPath, contents}); |
| 225 | + this.output.mkdirSync(path.dirname(outputPath), { recursive: true }); |
| 226 | + this.output.writeFileSync( |
| 227 | + outputPath, |
| 228 | + contents, |
| 229 | + "utf8", |
| 230 | + ); |
| 231 | + this.debug(`Analyzed ${analysis.template.relativePath} => ${outputPath}`); |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + inputFileCacheKey(relativePath): string { |
| 236 | + // it would be nice if we could avoid this double read. |
| 237 | + let contents = this.input.readFileSync(relativePath, this.inputEncoding || "utf8"); |
| 238 | + return this.cacheKeyProcessString(contents, relativePath); |
| 239 | + } |
| 240 | + |
| 241 | + additionalFilesCacheKey(mainFileCacheKey: string): string { |
| 242 | + return `${mainFileCacheKey}-additional-files`; |
| 243 | + } |
| 244 | + |
| 245 | + async cacheAdditionalFile(additionalFileCacheKeys: MultiMap<EmberAnalysis, string>, analysis: EmberAnalysis, additionalFile: AdditionalFile) { |
| 246 | + if (this.persist) { |
| 247 | + let cacheKey = md5Sum([additionalFile.outputPath, additionalFile.contents]); |
| 248 | + additionalFileCacheKeys.set(analysis, cacheKey); |
| 249 | + await (<PersistentStrategy>this.processor.processor)._cache!.set(cacheKey, JSON.stringify(additionalFile)); |
| 250 | + this.debug(`Wrote cache key ${cacheKey} for ${additionalFile.outputPath}`); |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + // We override broccoli-persistent-filter's _handleFile implementation |
| 255 | + // in order to extract the additional output files from cache when the file is cached. |
| 256 | + // ideally this would be a capability provided by broccoli-persistent-filter, because |
| 257 | + // in those cases, it would be able to recover from a missing cache entry correctly. |
| 258 | + async _handleFile(relativePath: string, srcDir: string, destDir: string, entry: Parameters<TemplateCompilerPlugin["_handleFile"]>[3], outputPath: string, forceInvalidation: boolean, isChange: boolean, stats: Parameters<TemplateCompilerPlugin["_handleFile"]>[7]) { |
| 259 | + let cached = false; |
| 260 | + let mainFileCacheKey: string | undefined; |
| 261 | + if (this.persist) { |
| 262 | + // check if the persistent cache is warm for the main file being handled. |
| 263 | + mainFileCacheKey = this.inputFileCacheKey(relativePath); |
| 264 | + let result = await (<PersistentStrategy>this.processor.processor)._cache!.get(mainFileCacheKey); |
| 265 | + cached = result.isCached; |
| 266 | + } |
| 267 | + let additionalFiles = new Array<AdditionalFile>(); |
| 268 | + let consistentCache = true; |
| 269 | + |
| 270 | + if (cached && !forceInvalidation) { |
| 271 | + // first we read the list of additional cache keys for other output files. |
| 272 | + let additionalFilesCacheKey = this.additionalFilesCacheKey(mainFileCacheKey!); |
| 273 | + let cacheKeysCacheResult = await (<PersistentStrategy>this.processor.processor)._cache!.get<string>(additionalFilesCacheKey); |
| 274 | + if (cacheKeysCacheResult.isCached) { |
| 275 | + let additionalCacheKeys: Array<string> = JSON.parse(cacheKeysCacheResult.value); |
| 276 | + // for each cache key we read out the additional file metadata that is cached and write the additional files to the output tree. |
| 277 | + for (let cacheKey of additionalCacheKeys) { |
| 278 | + let additionalFileCacheResult = await (<PersistentStrategy>this.processor.processor)._cache!.get<string>(cacheKey); |
| 279 | + if (!additionalFileCacheResult.isCached) { |
| 280 | + this.debug(`The cache is inconsistent (missing: ${cacheKey}). Force invalidating the template.`); |
| 281 | + forceInvalidation = true; |
| 282 | + consistentCache = false; |
| 283 | + } |
| 284 | + additionalFiles.push(JSON.parse(additionalFileCacheResult.value)); |
| 285 | + } |
| 286 | + this.debug(`Wrote ${additionalCacheKeys.length} additional cached files for ${relativePath}`); |
| 287 | + } else { |
| 288 | + // this happens when the file isn't a css-blocks based template. |
| 289 | + this.debug(`No additional cached files for ${relativePath}`); |
| 290 | + } |
| 291 | + } |
| 292 | + |
| 293 | + let result = await super._handleFile(relativePath, srcDir, destDir, entry, outputPath, forceInvalidation, isChange, stats); |
| 294 | + |
| 295 | + if (cached && consistentCache) { |
| 296 | + for (let additionalFile of additionalFiles) { |
| 297 | + this.output.mkdirSync(path.dirname(additionalFile.outputPath), { recursive: true }); |
| 298 | + this.output.writeFileSync(additionalFile.outputPath, additionalFile.contents, this.outputEncoding || "utf8"); |
| 299 | + } |
| 300 | + } |
| 301 | + return result; |
| 302 | + } |
| 303 | +} |
| 304 | + |
| 305 | +function analysisPath(templatePath: string): string { |
| 306 | + let analysisPath = path.parse(templatePath); |
| 307 | + delete analysisPath.base; |
| 308 | + analysisPath.ext = ".block-analysis.json"; |
| 309 | + return path.format(analysisPath); |
| 310 | +} |
| 311 | + |
| 312 | +function getOutputPath(block: Block): string | null { |
| 313 | + if (isBroccoliTreeIdentifier(block.identifier)) { |
| 314 | + return identToPath(block.identifier).replace(".block", ".compiledblock"); |
| 315 | + } else { |
| 316 | + return null; |
| 317 | + } |
| 318 | +} |
| 319 | + |
| 320 | +function nativePromise(work: () => void | PromiseLike<void>): Promise<void> { |
| 321 | + return new Promise((resolve, reject) => { |
| 322 | + try { |
| 323 | + let buildResult = work() || Promise.resolve(); |
| 324 | + buildResult.then(resolve, reject); |
| 325 | + } catch (e) { |
| 326 | + reject(e); |
| 327 | + } |
| 328 | + }); |
| 329 | +} |
0 commit comments