Skip to content

Commit 4599c94

Browse files
committed
refactor(ember): Extract template compiler plugin and refactor it.
1 parent 6ae05b1 commit 4599c94

File tree

2 files changed

+335
-250
lines changed

2 files changed

+335
-250
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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

Comments
 (0)