Skip to content

Commit bec10d2

Browse files
Timothy Lindvalltimlindvall
Timothy Lindvall
authored andcommittedMay 5, 2020
feat: Utilities for compiled CSS parsing.
- Add utility methods to BaseImporter to handle compiled CSS files. - Create new CompiledImportedFile type that can be now returned by Importer.import(). - Update tests to reflect changes to ImportedFile and Importer.import(). - Add stub to BlockFactory for handling CompiledImportedFiles. - Add test coverage for isDefinitionUrlValid().
1 parent ab9443f commit bec10d2

File tree

9 files changed

+254
-21
lines changed

9 files changed

+254
-21
lines changed
 

‎packages/@css-blocks/core/src/BlockParser/BlockFactory.ts

+19-14
Original file line numberDiff line numberDiff line change
@@ -183,21 +183,26 @@ export class BlockFactory {
183183
private async _getBlockPromiseAsync(identifier: FileIdentifier): Promise<Block> {
184184
try {
185185
let file = await this.importer.import(identifier, this.configuration);
186-
let block = await this._importAndPreprocessBlock(file);
187-
debug(`Finalizing Block object for "${block.identifier}"`);
188-
189-
// last check to make sure we don't return a new instance
190-
if (this.blocks[block.identifier]) {
191-
return this.blocks[block.identifier];
186+
if (file.type === 'ImportedFile') {
187+
let block = await this._importAndPreprocessBlock(file);
188+
debug(`Finalizing Block object for "${block.identifier}"`);
189+
190+
// last check to make sure we don't return a new instance
191+
if (this.blocks[block.identifier]) {
192+
return this.blocks[block.identifier];
193+
}
194+
195+
// Ensure this block name is unique.
196+
block.setName(this.getUniqueBlockName(block.name));
197+
198+
// if the block has any errors, surface them here unless we're in fault tolerant mode.
199+
this._surfaceBlockErrors(block);
200+
this.blocks[block.identifier] = block;
201+
return block;
202+
} else {
203+
// TODO: Process CompiledImportedFile type.
204+
return new Block('foo', 'bar');
192205
}
193-
194-
// Ensure this block name is unique.
195-
block.setName(this.getUniqueBlockName(block.name));
196-
197-
// if the block has any errors, surface them here unless we're in fault tolerant mode.
198-
this._surfaceBlockErrors(block);
199-
this.blocks[block.identifier] = block;
200-
return block;
201206
} catch (error) {
202207
if (this.preprocessQueue.activeJobCount > 0) {
203208
debug(`Block error. Currently there are ${this.preprocessQueue.activeJobCount} preprocessing jobs. waiting.`);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as path from "path";
2+
import * as url from "url";
3+
4+
/**
5+
* A regular expression that can be used to test for the header comment in a compiled CSS file.
6+
*/
7+
export const REGEXP_COMMENT_HEADER = /\/\*#css-blocks (?!end)([A-Za-z0-9]+)\*\//m;
8+
9+
/**
10+
* A regular expression that can be used to test for the definition URL in a compiled CSS file.
11+
*/
12+
export const REGEXP_COMMENT_DEFINITION_REF = /\/\*#blockDefinitionURL=([\S]+?)\*\//m;
13+
14+
/**
15+
* A regular expression that can be used to test for the footer comment in a compiled CSS file.
16+
*/
17+
export const REGEXP_COMMENT_FOOTER = /\/\*#css-blocks end*\\/m;
18+
19+
/**
20+
* Determines if the given URL for a definition is valid.
21+
*
22+
* The following are valid:
23+
* - Path relative to the current file on the filesystem.
24+
* - Embedded base64 data.
25+
*
26+
* The following are invalid:
27+
* - Absolute paths.
28+
* - URLs with a protocol other than data.
29+
*
30+
* @param urlOrPath - The definition URL to check.
31+
* @returns True if valid given the above rules, false otherwise.
32+
*/
33+
export function isDefinitionUrlValid(urlOrPath: string): boolean {
34+
// Try to parse as a URL first.
35+
try {
36+
const parsedUrl = url.parse(urlOrPath);
37+
if (parsedUrl.protocol) {
38+
return parsedUrl.protocol === 'data:';
39+
}
40+
} catch (e) {}
41+
42+
// If we can't parse as a URL with a protocol, it's a path.
43+
return !path.isAbsolute(urlOrPath);
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export {
2+
REGEXP_COMMENT_HEADER,
3+
REGEXP_COMMENT_DEFINITION_REF,
4+
REGEXP_COMMENT_FOOTER
5+
} from './compiled-comments';

‎packages/@css-blocks/core/src/importing/BaseImporter.ts

+74-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Syntax } from '../BlockParser';
22
import { ResolvedConfiguration } from '../configuration';
3-
import { Importer, FileIdentifier, ImportedFile } from './Importer';
3+
import { Importer, FileIdentifier, ImportedFile, CompiledImportedFileCssContents, CompiledImportedFile } from './Importer';
4+
import { REGEXP_COMMENT_HEADER, REGEXP_COMMENT_DEFINITION_REF, REGEXP_COMMENT_FOOTER } from '../PrecompiledDefinitions/compiled-comments';
45

56
/**
67
* The BaseImporter is an abstract class that Importer implementations may extend from.
@@ -17,7 +18,7 @@ export abstract class BaseImporter implements Importer {
1718
/**
1819
* Import the file with the given metadata and return a string and meta data for it.
1920
*/
20-
abstract import(identifier: FileIdentifier, config: ResolvedConfiguration): Promise<ImportedFile>;
21+
abstract import(identifier: FileIdentifier, config: ResolvedConfiguration): Promise<ImportedFile | CompiledImportedFile>;
2122
/**
2223
* The default name of the block used unless the block specifies one itself.
2324
*/
@@ -36,4 +37,75 @@ export abstract class BaseImporter implements Importer {
3637
* Returns the syntax the contents are written in.
3738
*/
3839
abstract syntax(identifier: FileIdentifier, config: ResolvedConfiguration): Syntax;
40+
41+
/**
42+
* Determines if given file contents is a compiled CSS Blocks file.
43+
* We determine this by looking for special auto-generated CSS Blocks
44+
* comments in the file. We don't validate at this point that the data
45+
* included in these comments is valid.
46+
*
47+
* @param contents - A string of the imported file contents to check.
48+
* @returns True if this represents previously-compiled CSS, false otheerwise.
49+
*/
50+
protected isCompiledBlockCSS(contents: string): boolean {
51+
return REGEXP_COMMENT_HEADER.test(contents) &&
52+
REGEXP_COMMENT_DEFINITION_REF.test(contents) &&
53+
REGEXP_COMMENT_FOOTER.test(contents);
54+
}
55+
56+
/**
57+
* Break apart a compiled CSS file that was previously generated from a block file
58+
* into segments, based on the CSS Blocks comments that are present. If given a file
59+
* that's missing expected auto-generated CSS Blocks comments, this will error.
60+
* You should call isCompiledBlockCSS() first to determine if the file should be
61+
* processed as a compiled CSS Block file.
62+
*
63+
* @param contents - A string of the imported file contents to check.
64+
* @returns The segmented information from the compiled CSS file.
65+
*/
66+
protected segmentizeCompiledBlockCSS(contents: string): CompiledImportedFileCssContents {
67+
// Use our regexps to find the start and end of the compiled content in the CSS file.
68+
const headerRegexpResult = contents.match(REGEXP_COMMENT_HEADER);
69+
const footerRegexpResult = contents.match(REGEXP_COMMENT_FOOTER);
70+
if (!headerRegexpResult || !footerRegexpResult) {
71+
throw new Error("Unable to parse compiled CSS file into segments. Expected comments are missing.");
72+
}
73+
74+
// Determine start/end indexes based on the regexp results above.
75+
const [headerFullMatch, blockIdFromComment] = headerRegexpResult;
76+
const { index: headerStartIndex } = headerRegexpResult;
77+
if (!headerStartIndex) {
78+
throw new Error("Unable to determine start location of regexp result.");
79+
}
80+
const headerEndIndex = headerStartIndex + headerFullMatch.length;
81+
const [footerFullMatch] = footerRegexpResult;
82+
const { index: footerStartIndex } = footerRegexpResult;
83+
if (!footerStartIndex) {
84+
throw new Error("Unable to determine start location of regexp result.");
85+
}
86+
const footerEndIndex = footerStartIndex + footerFullMatch.length;
87+
88+
// Break the file into segments.
89+
const pre = contents.slice(0, headerStartIndex);
90+
const post = contents.slice(footerEndIndex + 1);
91+
const fullBlockContents = contents.slice(headerEndIndex + 1, footerStartIndex);
92+
93+
// Parse out the URL, or embedded data, for the block definition.
94+
// The definition comment should be removed from the block's CSS contents.
95+
const definitionRegexpResult = fullBlockContents.match(REGEXP_COMMENT_DEFINITION_REF);
96+
if (!definitionRegexpResult) {
97+
throw new Error("Unable to find definition URL in compiled CSS. This comment must be declared between the header and footer CSS Blocks comments.");
98+
}
99+
const [definitionFullMatch, definitionUrl] = definitionRegexpResult;
100+
const blockCssContents = fullBlockContents.replace(definitionFullMatch, '');
101+
102+
return {
103+
type: 'CompiledImportedFileCssContents',
104+
pre,
105+
blockIdFromComment,
106+
blockCssContents,
107+
definitionUrl,
108+
post
109+
};
110+
}
39111
}

‎packages/@css-blocks/core/src/importing/Importer.ts

+72-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export type FileIdentifier = string;
2929
* Structure that CSS Blocks uses to represent a single file.
3030
*/
3131
export interface ImportedFile {
32+
type: "ImportedFile";
3233
/**
3334
* A unique identifier (probably an absolute filesystem path) that describes
3435
* the block and can be used for caching.
@@ -48,6 +49,76 @@ export interface ImportedFile {
4849
contents: string;
4950
}
5051

52+
/**
53+
* Represents the parsed contents from an imported pre-compiled CSS file.
54+
*/
55+
export interface CompiledImportedFileCssContents {
56+
type: "CompiledImportedFileCssContents";
57+
58+
/**
59+
* File contents prior to the CSS Blocks header comment.
60+
*/
61+
pre: string;
62+
63+
/**
64+
* The Block ID as declared in the header comment. This is expected
65+
* to match the `block-id` declaration for the `:scope` selector
66+
* in the definition.
67+
*/
68+
blockIdFromComment: string;
69+
70+
/**
71+
* The CSS rules that are present in this file. Only captures any CSS
72+
* output between the header and footer comment. The comment that
73+
* contains the definition url is omitted.
74+
*/
75+
blockCssContents: string;
76+
77+
/**
78+
* The definition URL as declared in the definition comment. This can
79+
* be a relative path or embedded Base64 data.
80+
*/
81+
definitionUrl: string;
82+
83+
/**
84+
* File contents after the CSS Blocks footer comment.
85+
*/
86+
post: string;
87+
}
88+
89+
/**
90+
* Represents an aggregate pre-compiled CSS file and the associated block
91+
* definitions for that file. The definitions may be a separate file
92+
* altogether or inlined with the compiled contents.
93+
*/
94+
export interface CompiledImportedFile {
95+
type: "CompiledImportedFile";
96+
97+
/**
98+
* A unique identifier (probably an absolute filesystem path) that describes
99+
* the block and can be used for caching.
100+
*/
101+
identifier: FileIdentifier;
102+
103+
/**
104+
* The syntax of the source contents. For pre-compiled files, this is always CSS.
105+
*/
106+
syntax: Syntax.css;
107+
108+
/**
109+
* The contents of the imported pre-compiled CSS file, sliced into segments based
110+
* on the presence and location of CSS Blocks comments.
111+
*/
112+
cssContents: CompiledImportedFileCssContents;
113+
114+
/**
115+
* The contents of the block definition. If this was embedded base64 data, it will
116+
* have been decoded into a string. If this was an external file, the file's
117+
* contents will be included here.
118+
*/
119+
definitionContents: string;
120+
}
121+
51122
/**
52123
* Importer provides an API that enables css-blocks to resolve a
53124
* @block directive into a string that is a css-block stylesheet and
@@ -65,7 +136,7 @@ export interface Importer {
65136
/**
66137
* import the file with the given metadata and return a string and meta data for it.
67138
*/
68-
import(identifier: FileIdentifier, config: ResolvedConfiguration): Promise<ImportedFile>;
139+
import(identifier: FileIdentifier, config: ResolvedConfiguration): Promise<ImportedFile | CompiledImportedFile>;
69140
/**
70141
* the default name of the block used unless the block specifies one itself.
71142
*/

‎packages/@css-blocks/core/src/importing/NodeJsImporter.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as path from "path";
66
import { Syntax } from "../BlockParser";
77
import { ResolvedConfiguration } from "../configuration";
88

9-
import { FileIdentifier, ImportedFile } from "./Importer";
9+
import { FileIdentifier, ImportedFile, CompiledImportedFile } from "./Importer";
1010
import { BaseImporter } from "./BaseImporter";
1111

1212
const debug = debugGenerator("css-blocks:importer");
@@ -134,9 +134,10 @@ export class NodeJsImporter extends BaseImporter {
134134
return path.relative(config.rootDir, identifier);
135135
}
136136

137-
async import(identifier: FileIdentifier, config: ResolvedConfiguration): Promise<ImportedFile> {
137+
async import(identifier: FileIdentifier, config: ResolvedConfiguration): Promise<ImportedFile | CompiledImportedFile> {
138138
let contents = await readFile(identifier, "utf-8");
139139
return {
140+
type: 'ImportedFile',
140141
syntax: this.syntax(identifier, config),
141142
identifier,
142143
defaultName: this.defaultName(identifier, config),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { assert } from "chai";
2+
import { suite, test } from "mocha-typescript";
3+
import { isDefinitionUrlValid } from "../../src/PrecompiledDefinitions/compiled-comments";
4+
5+
@suite("PrecompiledDefinitions/compiled-comments")
6+
export class CompiledCommentsTests {
7+
8+
@test "isDefinitionUrlValid > Reports encoded base64 data is ok"() {
9+
const testInput = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==";
10+
assert.ok(isDefinitionUrlValid(testInput));
11+
}
12+
13+
@test "isDefinitionUrlValid > Reports relative path is ok"() {
14+
const testInput = "../../path/to/definition.block.dfn";
15+
assert.ok(isDefinitionUrlValid(testInput));
16+
}
17+
18+
@test "isDefinitionUrlValid > Reports absolute Unix path is invalid"() {
19+
const testInput = "/path/to/definition.blockdfn.css";
20+
assert.notOk(isDefinitionUrlValid(testInput));
21+
}
22+
23+
@test "isDefinitionUrlValid > Reports absolute Windows path is invalid"() {
24+
const testInput = "C:\\path\\to\\definition.blockdfn.css";
25+
assert.notOk(isDefinitionUrlValid(testInput));
26+
}
27+
28+
@test "isDefinitionUrlValid > Reports URL with non-data protocol is invalid"() {
29+
const testInput = "https://css-blocks.com/";
30+
assert.notOk(isDefinitionUrlValid(testInput));
31+
}
32+
33+
}

‎packages/@css-blocks/core/test/importing-test.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Importer,
1414
NodeJsImporter,
1515
defaultImporter,
16+
ImportedFile,
1617
} from "../src/importing";
1718

1819
const FIXTURES = path.resolve(__dirname, "..", "..", "test", "fixtures");
@@ -82,7 +83,7 @@ function testFSImporter(name: string, importer: Importer) {
8283
it("imports a file", async () => {
8384
let options = getConfiguration(FSI_FIXTURES);
8485
let ident = importer.identifier(null, "a.block.css", options);
85-
let importedFile = await importer.import(ident, options);
86+
let importedFile = await importer.import(ident, options) as ImportedFile;
8687
assert.deepEqual(importedFile.contents, fs.readFileSync(path.join(FSI_FIXTURES, "a.block.css"), "utf-8"));
8788
assert.equal(importedFile.defaultName, "a");
8889
assert.equal(importedFile.identifier, ident);
@@ -196,7 +197,7 @@ describe("PathAliasImporter", () => {
196197
let options = getConfiguration(FSI_FIXTURES);
197198
let importer: Importer = this.importer;
198199
let ident = importer.identifier(null, "sub/sub.block.css", options);
199-
let importedFile = await importer.import(ident, options);
200+
let importedFile = await importer.import(ident, options) as ImportedFile;
200201
let expectedContents = fs.readFileSync(path.join(ALIAS_FIXTURES, "alias_subdirectory", "sub.block.css"), "utf-8");
201202
assert.deepEqual(importedFile.contents, expectedContents);
202203
assert.equal(importedFile.defaultName, "sub");

‎packages/@css-blocks/core/test/util/MockImportRegistry.ts

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export class MockImporter extends NodeJsImporter {
4040
}
4141
this.registry.imported[resolvedPath] = true;
4242
return {
43+
type: "ImportedFile",
4344
syntax: source.syntax,
4445
identifier: resolvedPath,
4546
defaultName: this.defaultName(resolvedPath, configuration),

0 commit comments

Comments
 (0)
Please sign in to comment.