Skip to content

Commit b2dab2b

Browse files
committed
test: added linter to verify correct use of <MatIcon />
1 parent 81f0578 commit b2dab2b

2 files changed

Lines changed: 176 additions & 79 deletions

File tree

packages/web-component/b64Fonts.js

Lines changed: 171 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -3,123 +3,178 @@ const path = require("path");
33
const woff2base64 = require("woff2base64");
44
const css = require("css");
55

6+
const componentPath = "src/";
7+
const bcFont = {
8+
font: {
9+
"BCSans-Regular.woff2": fs.readFileSync(
10+
"src/scss/fonts/BCSans-Regular.woff2",
11+
),
12+
},
13+
options: {
14+
fontFamily: "BCSans",
15+
style: "normal",
16+
},
17+
};
18+
619
async function* walk(dir) {
7-
for await (const d of await fs.promises.opendir(dir)) {
20+
const allowedExtensions = [".tsx", ".jsx", ".ts", ".js", ".html"];
21+
22+
const test = await fs.promises.opendir(dir);
23+
for await (const d of test) {
824
const entry = path.join(dir, d.name);
25+
const fileName = d.name.toLowerCase();
926
switch (true) {
1027
case d.isDirectory():
1128
yield* walk(entry);
1229
break;
13-
case d.isFile() && d.name.toLowerCase().endsWith(".tsx"):
30+
case d.isFile() &&
31+
allowedExtensions.some((ext) => fileName.endsWith(ext)):
1432
yield entry;
1533
break;
1634
}
1735
}
1836
}
1937

20-
async function extractIconList(dir) {
38+
// Refuses instances of material-icons and material-icons-outlined.
39+
async function invalidateUsesOfMaterialIconClass(srcDir) {
40+
const errors = [];
41+
for await (const p of walk(srcDir)) {
42+
if (p.indexOf("mat-icon") >= 0) {
43+
continue;
44+
}
45+
46+
const fileErrors = Array.from(
47+
fs.readFileSync(p).toString().split("\n").entries(),
48+
)
49+
.filter(([row, line]) => line.indexOf("material-icons") >= 0)
50+
.map(([row, line]) => {
51+
return { file: p, lineNumber: row + 1, line: line };
52+
});
53+
errors.push(...fileErrors);
54+
}
55+
56+
return errors;
57+
}
58+
59+
// Extracts the material icon symbols used by the read along component. Returns
60+
// a sorted list of icon names.
61+
//
62+
// Throws an exception if JSX expressions are used in <MatIcon /> elements.
63+
async function extractIconList(srcDir) {
2164
const matIconRe = /<MatIcon.*?>((.|\n)+?)<\/MatIcon>/gm;
2265
const iconNames = new Set();
23-
for await (const p of walk(dir)) {
66+
for await (const p of walk(srcDir)) {
2467
const tsx = fs.readFileSync(p).toString();
25-
for (const result of tsx.matchAll(matIconRe)) {
26-
if (!result || result.length < 2) {
68+
for (const icon of tsx.matchAll(matIconRe)) {
69+
if (!icon || icon.length < 2) {
2770
continue;
2871
}
2972

30-
const iconName = result[1].trim();
73+
const iconName = icon[1].trim();
3174
if (!iconName.startsWith("{") && !iconName.endsWith("}")) {
3275
iconNames.add(iconName);
3376
continue;
3477
}
3578

3679
throw (
37-
"MatIcon does not support JSX expressions, please \n" +
38-
"rewrite your expression in the following format: \n\n" +
39-
"{cond ? <MatIcon>true</MatIcon> : <MatIcon>false</MatIcon>\n"
80+
"MatIcon does not support JSX expressions, please " +
81+
`rewrite your expression: (file: ${p}) \n\n` +
82+
` ${icon[0]}\n\n` +
83+
"using the following format: \n" +
84+
" {cond ? (<MatIcon>true</MatIcon>) : (<MatIcon>false</MatIcon>)\n"
4085
);
4186
}
4287
}
4388

4489
return Array.from(iconNames.values()).sort();
4590
}
4691

47-
async function extractFontsFromGoogle() {
92+
/**
93+
* Uses Google's font v2 API to fetch the CSS font-face declarations.
94+
*
95+
* Returns an array of woff2base64 font definitions.
96+
*/
97+
async function fontsFromGoogle(srcDir) {
4898
// load the list of used material-icon.
49-
const knownIcons = await extractIconList("src/components/");
99+
const knownIcons = await extractIconList(srcDir);
50100

51101
// fetch and parse CSS definition from Google.
52-
const parsedCss = await fetch(
102+
const iconUrl =
53103
"https://fonts.googleapis.com/css2?family=Material+Icons&family=Material+Icons+Outlined&display=swap&icon_names=" +
54-
knownIcons.join(","),
55-
)
56-
.then((resp) => resp.text())
57-
.then((text) => css.parse(text));
104+
knownIcons.join(",");
105+
106+
const parsedCss = await fetch(iconUrl)
107+
.then((resp) => {
108+
if (resp.status === 200) {
109+
return resp.text();
110+
}
111+
throw `${resp.statusText}(${resp.status}): could not fetch font information from Google`;
112+
})
113+
.then((text) => css.parse(text))
114+
.catch((err) => console.log(err));
115+
if (!parsedCss) {
116+
return null;
117+
}
58118

59119
const urlExtract = /url\((.+?)\)/;
60120

61121
// extract the woff2base64 information from the parsed CSS
62-
return Promise.all(
63-
parsedCss.stylesheet.rules
64-
.filter((r) => r.type === "font-face")
65-
.map((fontRule) => {
66-
// flatten array of declarations to a single object.
67-
return Object.values(fontRule.declarations)
68-
.filter((decl) => decl.type === "declaration")
69-
.reduce((acc, decl) => {
70-
acc[decl.property] = decl.value;
71-
return acc;
72-
}, {});
73-
})
74-
.map((font) => {
75-
// extract url from src field.
76-
const fontUrl = urlExtract.exec(font.src);
77-
if (!fontUrl) {
78-
throw `error: could not find font URL for ${font["font-family"]}`;
79-
}
80-
font.src = fontUrl[1];
81-
return font;
82-
})
83-
.map(async (font) => {
84-
// convert the object to the woff2base62 format by fetching the
85-
// font from google.
86-
let fontFamily = font["font-family"];
87-
if (fontFamily.startsWith("'") && fontFamily.endsWith("'")) {
88-
fontFamily = fontFamily.slice(1, fontFamily.length - 1);
89-
}
90-
91-
const fontFilename = fontFamily.replaceAll(" ", "") + ".woff2";
92-
const fontContent = {};
93-
const resp = await fetch(font.src);
94-
fontContent[fontFilename] = Buffer.from(await resp.arrayBuffer());
95-
96-
return {
97-
font: fontContent,
98-
options: {
99-
fontFamily: fontFamily,
100-
style: font["font-style"] ?? "normal",
101-
weight: parseInt(font["font-weight"] ?? "400"),
102-
},
103-
};
104-
}),
105-
);
122+
const fonts = parsedCss.stylesheet.rules
123+
.filter((r) => r.type === "font-face")
124+
.map((fontRule) => {
125+
// flatten array of declarations to a single object.
126+
return Object.values(fontRule.declarations)
127+
.filter((decl) => decl.type === "declaration")
128+
.reduce((acc, decl) => {
129+
acc[decl.property] = decl.value;
130+
return acc;
131+
}, {});
132+
})
133+
.map((font) => {
134+
// extract url from src field.
135+
const fontUrl = urlExtract.exec(font.src);
136+
if (!fontUrl) {
137+
throw `error: could not find font URL for ${font["font-family"]}`;
138+
}
139+
font.src = fontUrl[1];
140+
return font;
141+
})
142+
.map(async (font) => {
143+
// convert the object to the woff2base62 format by fetching the
144+
// font from google.
145+
let fontFamily = font["font-family"];
146+
if (fontFamily.startsWith("'") && fontFamily.endsWith("'")) {
147+
fontFamily = fontFamily.slice(1, fontFamily.length - 1);
148+
}
149+
150+
const fontFilename = fontFamily.replaceAll(" ", "") + ".woff2";
151+
const fontContent = {};
152+
const resp = await fetch(font.src);
153+
fontContent[fontFilename] = Buffer.from(await resp.arrayBuffer());
154+
155+
return {
156+
font: fontContent,
157+
options: {
158+
fontFamily: fontFamily,
159+
style: font["font-style"] ?? "normal",
160+
weight: parseInt(font["font-weight"] ?? "400"),
161+
},
162+
};
163+
});
164+
165+
return Promise.all(fonts);
106166
}
107167

108-
const bcFont = {
109-
font: {
110-
"BCSans-Regular.woff2": fs.readFileSync(
111-
"src/scss/fonts/BCSans-Regular.woff2",
112-
),
113-
},
114-
options: {
115-
fontFamily: "BCSans",
116-
style: "normal",
117-
},
118-
};
168+
/**
169+
* Generate embeddable font files for use with Studio Web.
170+
*/
171+
async function main() {
172+
const fonts = await fontsFromGoogle(componentPath);
173+
if (!fonts) {
174+
process.exit(1);
175+
}
119176

120-
extractFontsFromGoogle().then((fonts) => {
121177
fonts.push(bcFont);
122-
123178
const b64Css = fonts
124179
.map((x) => woff2base64(x.font, x.options).woff2)
125180
.join("\n");
@@ -128,5 +183,46 @@ extractFontsFromGoogle().then((fonts) => {
128183
"../../dist/packages/web-component/dist/fonts.b64.css",
129184
b64Css,
130185
);
186+
131187
fs.writeFileSync("../studio-web/src/assets/fonts.b64.css", b64Css);
132-
});
188+
}
189+
190+
/**
191+
* Validate the use of <MatIcon /> type. We currently don't support
192+
* JSX expression.
193+
*
194+
* Verify there are no additional uses of class="material-icon".
195+
*/
196+
async function validate() {
197+
let exitCode = 0;
198+
199+
// Verify there are no JSX expression inside of <MatIcon /> elements.
200+
try {
201+
await extractIconList(componentPath);
202+
} catch (err) {
203+
console.log(err);
204+
exitCode = 1;
205+
}
206+
207+
// refuse all instances of material-icons or material-icons-outlined.
208+
const errors = await invalidateUsesOfMaterialIconClass(componentPath);
209+
if (errors.length > 0) {
210+
exitCode = 1;
211+
console.log(
212+
"error: detected usage of material-icons or material-icons-outlined. Please" +
213+
" replace these with the <MatIcon /> component:\n",
214+
);
215+
errors.forEach((err) => {
216+
console.log(`${err.file}:${err.lineNumber} - ${err.line.trim()}`);
217+
});
218+
}
219+
220+
process.exit(exitCode);
221+
}
222+
223+
const isValidate = process.argv.some((arg) => arg === "--validate");
224+
if (isValidate) {
225+
validate();
226+
} else {
227+
main();
228+
}

packages/web-component/package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
],
1717
"scripts": {
1818
"bundle": "bash bundle.sh",
19-
"cy:run": "cypress run",
20-
"test:full-pipeline": "npm run serve-test-data & nx run serve & npm run wait-for-test-server && npm run test:once",
21-
"test:once": "cypress run",
22-
"test:open": "cypress open",
19+
"linter:maticon": "node b64Fonts.js --validate",
20+
"cy:run": "npm run linter:maticon && cypress run",
21+
"test:full-pipeline": "npm run linter:maticon && npm run serve-test-data & nx run serve & npm run wait-for-test-server && npm run test:once",
22+
"test:once": "npm run linter:maticon && cypress run",
23+
"test:open": "npm run linter:maticon && cypress open",
2324
"serve-test-data": "sirv --dev --cors --port 8941 test-data/",
2425
"wait-for-test-server": "wait-on -i 2000 -v -t 30000 http://localhost:3333/build/web-component.esm.js"
2526
},

0 commit comments

Comments
 (0)