@design.estate/wcctools 6.0.0 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +33 -0
- package/dist_shell/bundle.js +405 -368
- package/dist_shell/bundle.js.map +1 -1
- package/dist_shell/bundle.js.third-party-notices.json +52 -0
- package/dist_shell/bundle.js.third-party-notices.txt +107 -1
- package/dist_shell/index.html +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.bundlestatus.d.ts +36 -0
- package/dist_ts/classes.bundlestatus.js +110 -0
- package/dist_ts/classes.devapi.d.ts +3 -0
- package/dist_ts/classes.devapi.js +8 -1
- package/dist_ts/classes.devserver.d.ts +45 -10
- package/dist_ts/classes.devserver.js +123 -49
- package/dist_ts/cli.js +12 -3
- package/dist_ts/index.d.ts +0 -2
- package/dist_ts/index.js +1 -3
- package/dist_ts/plugins.d.ts +7 -2
- package/dist_ts/plugins.js +9 -3
- package/dist_ts/standards/baselines.d.ts +27 -0
- package/dist_ts/standards/baselines.js +83 -0
- package/dist_ts/standards/checker.d.ts +21 -0
- package/dist_ts/standards/checker.js +88 -0
- package/dist_ts/standards/classes.gitrepo.d.ts +13 -0
- package/dist_ts/standards/classes.gitrepo.js +43 -0
- package/dist_ts/standards/classes.sourceedits.d.ts +15 -0
- package/dist_ts/standards/classes.sourceedits.js +54 -0
- package/dist_ts/standards/classes.standardfixer.d.ts +40 -0
- package/dist_ts/standards/classes.standardfixer.js +210 -0
- package/dist_ts/standards/classes.standardproject.d.ts +80 -0
- package/dist_ts/standards/classes.standardproject.js +195 -0
- package/dist_ts/standards/classes.standardtree.d.ts +56 -0
- package/dist_ts/standards/classes.standardtree.js +207 -0
- package/dist_ts/standards/cli.d.ts +7 -0
- package/dist_ts/standards/cli.js +89 -0
- package/dist_ts/standards/config.d.ts +33 -0
- package/dist_ts/standards/config.js +139 -0
- package/dist_ts/standards/errors.d.ts +9 -0
- package/dist_ts/standards/errors.js +17 -0
- package/dist_ts/standards/helpers.ast.d.ts +56 -0
- package/dist_ts/standards/helpers.ast.js +172 -0
- package/dist_ts/standards/helpers.paths.d.ts +36 -0
- package/dist_ts/standards/helpers.paths.js +86 -0
- package/dist_ts/standards/index.d.ts +14 -0
- package/dist_ts/standards/index.js +15 -0
- package/dist_ts/standards/output.d.ts +7 -0
- package/dist_ts/standards/output.js +57 -0
- package/dist_ts/standards/ratchet.d.ts +15 -0
- package/dist_ts/standards/ratchet.js +45 -0
- package/dist_ts/standards/rule.d.ts +50 -0
- package/dist_ts/standards/rule.js +9 -0
- package/dist_ts/standards/rules/barrel.element-index.d.ts +7 -0
- package/dist_ts/standards/rules/barrel.element-index.js +108 -0
- package/dist_ts/standards/rules/barrel.no-demo-export.d.ts +3 -0
- package/dist_ts/standards/rules/barrel.no-demo-export.js +29 -0
- package/dist_ts/standards/rules/demo.fixture-not-exported.d.ts +3 -0
- package/dist_ts/standards/rules/demo.fixture-not-exported.js +29 -0
- package/dist_ts/standards/rules/demo.groups.d.ts +6 -0
- package/dist_ts/standards/rules/demo.groups.js +72 -0
- package/dist_ts/standards/rules/demo.required.d.ts +3 -0
- package/dist_ts/standards/rules/demo.required.js +48 -0
- package/dist_ts/standards/rules/demo.separate-file.d.ts +3 -0
- package/dist_ts/standards/rules/demo.separate-file.js +38 -0
- package/dist_ts/standards/rules/demo.shape.d.ts +3 -0
- package/dist_ts/standards/rules/demo.shape.js +38 -0
- package/dist_ts/standards/rules/docs.component-index.d.ts +8 -0
- package/dist_ts/standards/rules/docs.component-index.js +84 -0
- package/dist_ts/standards/rules/element.exported.d.ts +3 -0
- package/dist_ts/standards/rules/element.exported.js +33 -0
- package/dist_ts/standards/rules/element.literal-tag.d.ts +3 -0
- package/dist_ts/standards/rules/element.literal-tag.js +30 -0
- package/dist_ts/standards/rules/element.reachable.d.ts +3 -0
- package/dist_ts/standards/rules/element.reachable.js +48 -0
- package/dist_ts/standards/rules/element.tag-map.d.ts +3 -0
- package/dist_ts/standards/rules/element.tag-map.js +83 -0
- package/dist_ts/standards/rules/i18n.literals.d.ts +3 -0
- package/dist_ts/standards/rules/i18n.literals.js +52 -0
- package/dist_ts/standards/rules/index.d.ts +12 -0
- package/dist_ts/standards/rules/index.js +83 -0
- package/dist_ts/standards/rules/layout.element-folder.d.ts +3 -0
- package/dist_ts/standards/rules/layout.element-folder.js +47 -0
- package/dist_ts/standards/rules/layout.entry-file-name.d.ts +3 -0
- package/dist_ts/standards/rules/layout.entry-file-name.js +71 -0
- package/dist_ts/standards/rules/layout.entry.d.ts +3 -0
- package/dist_ts/standards/rules/layout.entry.js +38 -0
- package/dist_ts/standards/rules/layout.group-folder.d.ts +3 -0
- package/dist_ts/standards/rules/layout.group-folder.js +22 -0
- package/dist_ts/standards/rules/layout.shared-00.d.ts +3 -0
- package/dist_ts/standards/rules/layout.shared-00.js +36 -0
- package/dist_ts/standards/rules/naming.class-matches-tag.d.ts +3 -0
- package/dist_ts/standards/rules/naming.class-matches-tag.js +26 -0
- package/dist_ts/standards/rules/naming.lowercase-files.d.ts +3 -0
- package/dist_ts/standards/rules/naming.lowercase-files.js +26 -0
- package/dist_ts/standards/rules/naming.tag-prefix.d.ts +3 -0
- package/dist_ts/standards/rules/naming.tag-prefix.js +23 -0
- package/dist_ts/standards/rules/props.accessor.d.ts +3 -0
- package/dist_ts/standards/rules/props.accessor.js +35 -0
- package/dist_ts/standards/rules/props.object-no-attribute.d.ts +3 -0
- package/dist_ts/standards/rules/props.object-no-attribute.js +42 -0
- package/dist_ts/standards/rules/props.reflect-primitive.d.ts +5 -0
- package/dist_ts/standards/rules/props.reflect-primitive.js +41 -0
- package/dist_ts/standards/rules/props.typed.d.ts +6 -0
- package/dist_ts/standards/rules/props.typed.js +64 -0
- package/dist_ts/standards/rules/shared.d.ts +38 -0
- package/dist_ts/standards/rules/shared.js +103 -0
- package/dist_ts/standards/rules/style.bdtheme.d.ts +5 -0
- package/dist_ts/standards/rules/style.bdtheme.js +28 -0
- package/dist_ts_interfaces/index.d.ts +1 -0
- package/dist_ts_interfaces/index.js +2 -1
- package/dist_ts_interfaces/requests.d.ts +35 -0
- package/dist_ts_interfaces/standard.d.ts +93 -0
- package/dist_ts_interfaces/standard.js +6 -0
- package/dist_ts_shell/bundlestatus.d.ts +15 -0
- package/dist_ts_shell/bundlestatus.js +64 -0
- package/dist_ts_shell/elements/wcc-contextmenu.d.ts +3 -0
- package/dist_ts_shell/elements/wcc-contextmenu.js +18 -3
- package/dist_ts_shell/elements/wcc-preview-frame.d.ts +6 -0
- package/dist_ts_shell/elements/wcc-preview-frame.js +50 -2
- package/dist_ts_shell/elements/wcc-shell.d.ts +15 -0
- package/dist_ts_shell/elements/wcc-shell.js +45 -3
- package/dist_ts_shell/plugins.d.ts +2 -1
- package/dist_ts_shell/plugins.js +3 -2
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +8 -3
- package/readme.md +40 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.bundlestatus.ts +123 -0
- package/ts/classes.devapi.ts +9 -0
- package/ts/classes.devserver.ts +127 -47
- package/ts/cli.ts +12 -2
- package/ts/index.ts +0 -2
- package/ts/plugins.ts +10 -2
- package/ts/standards/baselines.ts +100 -0
- package/ts/standards/checker.ts +114 -0
- package/ts/standards/classes.gitrepo.ts +48 -0
- package/ts/standards/classes.sourceedits.ts +58 -0
- package/ts/standards/classes.standardfixer.ts +234 -0
- package/ts/standards/classes.standardproject.ts +250 -0
- package/ts/standards/classes.standardtree.ts +220 -0
- package/ts/standards/cli.ts +86 -0
- package/ts/standards/config.ts +160 -0
- package/ts/standards/errors.ts +18 -0
- package/ts/standards/helpers.ast.ts +208 -0
- package/ts/standards/helpers.paths.ts +119 -0
- package/ts/standards/index.ts +22 -0
- package/ts/standards/output.ts +65 -0
- package/ts/standards/ratchet.ts +63 -0
- package/ts/standards/rule.ts +58 -0
- package/ts/standards/rules/barrel.element-index.ts +112 -0
- package/ts/standards/rules/barrel.no-demo-export.ts +30 -0
- package/ts/standards/rules/demo.fixture-not-exported.ts +30 -0
- package/ts/standards/rules/demo.groups.ts +74 -0
- package/ts/standards/rules/demo.required.ts +49 -0
- package/ts/standards/rules/demo.separate-file.ts +40 -0
- package/ts/standards/rules/demo.shape.ts +42 -0
- package/ts/standards/rules/docs.component-index.ts +88 -0
- package/ts/standards/rules/element.exported.ts +35 -0
- package/ts/standards/rules/element.literal-tag.ts +30 -0
- package/ts/standards/rules/element.reachable.ts +49 -0
- package/ts/standards/rules/element.tag-map.ts +87 -0
- package/ts/standards/rules/i18n.literals.ts +58 -0
- package/ts/standards/rules/index.ts +87 -0
- package/ts/standards/rules/layout.element-folder.ts +49 -0
- package/ts/standards/rules/layout.entry-file-name.ts +73 -0
- package/ts/standards/rules/layout.entry.ts +40 -0
- package/ts/standards/rules/layout.group-folder.ts +23 -0
- package/ts/standards/rules/layout.shared-00.ts +37 -0
- package/ts/standards/rules/naming.class-matches-tag.ts +27 -0
- package/ts/standards/rules/naming.lowercase-files.ts +27 -0
- package/ts/standards/rules/naming.tag-prefix.ts +24 -0
- package/ts/standards/rules/props.accessor.ts +37 -0
- package/ts/standards/rules/props.object-no-attribute.ts +45 -0
- package/ts/standards/rules/props.reflect-primitive.ts +44 -0
- package/ts/standards/rules/props.typed.ts +70 -0
- package/ts/standards/rules/shared.ts +127 -0
- package/ts/standards/rules/style.bdtheme.ts +31 -0
- package/ts_interfaces/bridge.ts +95 -0
- package/ts_interfaces/catalog.ts +37 -0
- package/ts_interfaces/index.ts +4 -0
- package/ts_interfaces/plugins.ts +3 -0
- package/ts_interfaces/requests.ts +65 -0
- package/ts_interfaces/standard.ts +103 -0
- package/ts_shared/index.ts +1 -0
- package/ts_shared/plugins.ts +3 -0
- package/ts_shared/previewroute.ts +58 -0
- package/ts_shell/bundlestatus.ts +68 -0
- package/ts_shell/elements/wcc-contextmenu.ts +306 -0
- package/ts_shell/elements/wcc-preview-frame.ts +167 -0
- package/ts_shell/elements/wcc-properties.ts +998 -0
- package/ts_shell/elements/wcc-record-button.ts +108 -0
- package/ts_shell/elements/wcc-recording-panel.ts +1005 -0
- package/ts_shell/elements/wcc-shell.ts +485 -0
- package/ts_shell/elements/wcc-sidebar.ts +1419 -0
- package/ts_shell/index.html +21 -0
- package/ts_shell/index.ts +4 -0
- package/ts_shell/plugins.ts +12 -0
- package/ts_shell/previewconnection.ts +103 -0
- package/ts_shell/services/recorder.service.ts +451 -0
- package/ts_shell/types/dom-mediacapture-stub/index.d.ts +12 -0
- package/ts_shell/types/dom-mediacapture-stub/package.json +6 -0
- package/ts_shell/types/dom-webcodecs-stub/index.d.ts +2 -0
- package/ts_shell/types/dom-webcodecs-stub/package.json +6 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { baseOf, isDemoFile } from '../helpers.paths.js';
|
|
2
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
3
|
+
import { removeStatement } from './shared.js';
|
|
4
|
+
|
|
5
|
+
/** No barrel exports a demo file. */
|
|
6
|
+
export const barrelNoDemoExportRule: IStandardRule = {
|
|
7
|
+
id: 'barrel/no-demo-export',
|
|
8
|
+
severity: { base: 'error' },
|
|
9
|
+
fixMode: 'auto',
|
|
10
|
+
detect: ({ project }) => {
|
|
11
|
+
const findings: IRuleFinding[] = [];
|
|
12
|
+
for (const file of project.webFiles) {
|
|
13
|
+
if (baseOf(file) !== 'index.ts') {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
for (const reexport of project.reexports(file)) {
|
|
17
|
+
if (reexport.target && isDemoFile(reexport.target)) {
|
|
18
|
+
findings.push({
|
|
19
|
+
file,
|
|
20
|
+
position: reexport.statement.getStart(),
|
|
21
|
+
key: file,
|
|
22
|
+
message: `${file} exports the demo file ${reexport.target}`,
|
|
23
|
+
fix: (builderArg) => removeStatement(builderArg, file, reexport.statement),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return findings;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { elementsBarrel } from '../classes.standardproject.js';
|
|
2
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
3
|
+
import { elementKey, removeStatement } from './shared.js';
|
|
4
|
+
|
|
5
|
+
/** Elements declared in demo files are not reachable from `ts_web/elements/index.ts`. */
|
|
6
|
+
export const demoFixtureNotExportedRule: IStandardRule = {
|
|
7
|
+
id: 'demo/fixture-not-exported',
|
|
8
|
+
severity: { base: 'warn' },
|
|
9
|
+
fixMode: 'auto',
|
|
10
|
+
detect: ({ project }) => {
|
|
11
|
+
const findings: IRuleFinding[] = [];
|
|
12
|
+
const reached = project.reachableFrom(elementsBarrel);
|
|
13
|
+
for (const element of project.elements) {
|
|
14
|
+
if (!element.inDemoFile || !reached.has(element.file)) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const exporters = [...reached].flatMap((fileArg) => project.reexports(fileArg)).filter((reexportArg) => reexportArg.target === element.file);
|
|
18
|
+
findings.push({
|
|
19
|
+
file: element.file,
|
|
20
|
+
position: element.decoratorCall.getStart(),
|
|
21
|
+
key: elementKey(element),
|
|
22
|
+
message: `fixture element <${element.tag ?? '?'}> in a demo file is reachable from ${elementsBarrel}`,
|
|
23
|
+
fix: exporters.length > 0
|
|
24
|
+
? (builderArg) => exporters.forEach((reexportArg) => removeStatement(builderArg, reexportArg.file, reexportArg.statement))
|
|
25
|
+
: undefined,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return findings;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import * as ast from '../helpers.ast.js';
|
|
3
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
4
|
+
import { catalogElements, elementKey, groupAreaOf, nextLineStart } from './shared.js';
|
|
5
|
+
|
|
6
|
+
const ts = plugins.typescript;
|
|
7
|
+
|
|
8
|
+
const quote = (textArg: string): string => `'${textArg.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An element with a demo declares `static demoGroups`, a non-empty string or array of non-empty strings.
|
|
12
|
+
* dees: the first entry is `standard.groupNames[<area>]` of the element's `00group-<area>`.
|
|
13
|
+
*/
|
|
14
|
+
export const demoGroupsRule: IStandardRule = {
|
|
15
|
+
id: 'demo/groups',
|
|
16
|
+
severity: { base: 'warn' },
|
|
17
|
+
fixMode: 'auto',
|
|
18
|
+
detect: ({ project, config, profile }) => {
|
|
19
|
+
const findings: IRuleFinding[] = [];
|
|
20
|
+
for (const element of catalogElements(project)) {
|
|
21
|
+
const demo = ast.findStaticProperty(element.classNode, 'demo');
|
|
22
|
+
if (!demo) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const area = profile === 'dees' ? groupAreaOf(element.file) : null;
|
|
26
|
+
const expected = area ? config.groupNames?.[area] ?? null : null;
|
|
27
|
+
const base = { file: element.file, key: elementKey(element) };
|
|
28
|
+
const member = ast.findStaticProperty(element.classNode, 'demoGroups');
|
|
29
|
+
const text = element.sourceFile.text;
|
|
30
|
+
|
|
31
|
+
if (!member) {
|
|
32
|
+
const finding: IRuleFinding = { ...base, position: demo.getStart(), message: `${element.className ?? '?'} has a demo but no static demoGroups` };
|
|
33
|
+
if (expected) {
|
|
34
|
+
const at = nextLineStart(text, demo.end);
|
|
35
|
+
finding.fix = (builderArg) => builderArg.edit(element.file, at, at, `${ast.indentationAt(text, demo.getStart())}public static demoGroups = [${quote(expected)}];\n`);
|
|
36
|
+
}
|
|
37
|
+
findings.push(finding);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const initializer = member.initializer;
|
|
41
|
+
let entries: string[] | null = null;
|
|
42
|
+
if (initializer && ast.isStringLiteralLike(initializer)) {
|
|
43
|
+
entries = [initializer.text];
|
|
44
|
+
} else if (initializer && ts.isArrayLiteralExpression(initializer) && initializer.elements.every(ast.isStringLiteralLike)) {
|
|
45
|
+
entries = initializer.elements.map((entryArg) => (entryArg as ast.TStringLiteralLike).text);
|
|
46
|
+
} else if (!initializer || ts.isArrayLiteralExpression(initializer) || ts.isLiteralExpression(initializer)) {
|
|
47
|
+
findings.push({ ...base, position: member.getStart(), message: `static demoGroups of ${element.className ?? '?'} is not a string or an array of strings` });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (entries && (entries.length === 0 || entries.some((entryArg) => entryArg.length === 0))) {
|
|
51
|
+
findings.push({ ...base, position: member.getStart(), message: `static demoGroups of ${element.className ?? '?'} is empty or has an empty entry` });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (profile !== 'dees' || !area || !entries) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!expected) {
|
|
58
|
+
findings.push({ ...base, position: member.getStart(), message: `standard.groupNames has no display name for the area '${area}'` });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (entries[0] !== expected) {
|
|
62
|
+
const target = initializer!;
|
|
63
|
+
const reordered = [expected, ...entries.filter((entryArg) => entryArg !== expected)];
|
|
64
|
+
findings.push({
|
|
65
|
+
...base,
|
|
66
|
+
position: member.getStart(),
|
|
67
|
+
message: `the first demo group of ${element.className ?? '?'} is ${quote(entries[0])}; expected ${quote(expected)} (00group-${area})`,
|
|
68
|
+
fix: (builderArg) => builderArg.edit(element.file, target.getStart(), target.end, `[${reordered.map(quote).join(', ')}]`),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return findings;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as ast from '../helpers.ast.js';
|
|
2
|
+
import { dirOf } from '../helpers.paths.js';
|
|
3
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
4
|
+
import { catalogElements, elementKey, isCustomElementName } from './shared.js';
|
|
5
|
+
|
|
6
|
+
/** Every exported element has `static demo`. The opt-in fix writes a stub that renders the bare tag. */
|
|
7
|
+
export const demoRequiredRule: IStandardRule = {
|
|
8
|
+
id: 'demo/required',
|
|
9
|
+
severity: { base: 'warn' },
|
|
10
|
+
fixMode: 'opt-in',
|
|
11
|
+
allowlistCategories: ['subcomponent', 'base'],
|
|
12
|
+
detect: ({ project }) => {
|
|
13
|
+
const findings: IRuleFinding[] = [];
|
|
14
|
+
const elements = catalogElements(project);
|
|
15
|
+
for (const element of elements) {
|
|
16
|
+
if (!element.exported || ast.findStaticProperty(element.classNode, 'demo')) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const finding: IRuleFinding = {
|
|
20
|
+
file: element.file,
|
|
21
|
+
position: element.decoratorCall.getStart(),
|
|
22
|
+
key: elementKey(element),
|
|
23
|
+
message: `${element.className ?? '?'} (<${element.tag ?? '?'}>) has no static demo`,
|
|
24
|
+
};
|
|
25
|
+
const sourceFile = element.sourceFile;
|
|
26
|
+
// The stub's file name and markup come from the tag: only a valid custom element name is usable.
|
|
27
|
+
const usableTag = element.tag && isCustomElementName(element.tag) ? element.tag : null;
|
|
28
|
+
const demoFile = usableTag ? `${dirOf(element.file)}/${usableTag}.demo.ts` : null;
|
|
29
|
+
const firstMember = element.classNode.members[0];
|
|
30
|
+
const alone = elements.filter((otherArg) => otherArg.file === element.file).length === 1;
|
|
31
|
+
if (usableTag && demoFile && element.className && firstMember && alone && !project.exists(demoFile) && !/\bdemoFunc\b/.test(sourceFile.text)) {
|
|
32
|
+
const tag = usableTag;
|
|
33
|
+
const customElementSource = ast.importSourceOf(sourceFile, 'customElement');
|
|
34
|
+
const htmlSource = ast.importSourceOf(sourceFile, 'html')
|
|
35
|
+
?? (customElementSource && customElementSource !== 'lit/decorators.js' ? customElementSource : 'lit');
|
|
36
|
+
const text = sourceFile.text;
|
|
37
|
+
const importAt = ast.afterLastImport(sourceFile);
|
|
38
|
+
finding.fix = (builderArg) => {
|
|
39
|
+
builderArg.create(demoFile, `import { html } from '${htmlSource}';\n\nexport const demoFunc = () => html\`<${tag}></${tag}>\`;\n`);
|
|
40
|
+
builderArg.edit(element.file, importAt, importAt, `import { demoFunc } from './${tag}.demo.js';\n`);
|
|
41
|
+
const lineStart = text.lastIndexOf('\n', firstMember.getStart() - 1) + 1;
|
|
42
|
+
builderArg.edit(element.file, lineStart, lineStart, `${ast.indentationAt(text, firstMember.getStart())}public static demo = demoFunc;\n`);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
findings.push(finding);
|
|
46
|
+
}
|
|
47
|
+
return findings;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import * as ast from '../helpers.ast.js';
|
|
3
|
+
import { isDemoFile, resolveSpecifier } from '../helpers.paths.js';
|
|
4
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
5
|
+
import { catalogElements, elementKey, rootIdentifier } from './shared.js';
|
|
6
|
+
|
|
7
|
+
const ts = plugins.typescript;
|
|
8
|
+
|
|
9
|
+
/** `static demo` refers to an export of the element's demo file. Report-only in 6.1. */
|
|
10
|
+
export const demoSeparateFileRule: IStandardRule = {
|
|
11
|
+
id: 'demo/separate-file',
|
|
12
|
+
severity: { base: 'warn', dees: 'error' },
|
|
13
|
+
fixMode: 'none',
|
|
14
|
+
detect: ({ project }) => {
|
|
15
|
+
const findings: IRuleFinding[] = [];
|
|
16
|
+
for (const element of catalogElements(project)) {
|
|
17
|
+
const member = ast.findStaticProperty(element.classNode, 'demo');
|
|
18
|
+
if (!member?.initializer) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const initializer = member.initializer;
|
|
22
|
+
const references = ts.isArrayLiteralExpression(initializer) ? [...initializer.elements] : [initializer];
|
|
23
|
+
const separate = references.length > 0 && references.every((referenceArg) => {
|
|
24
|
+
const identifier = rootIdentifier(referenceArg);
|
|
25
|
+
const source = identifier ? ast.importSourceOf(element.sourceFile, identifier) : null;
|
|
26
|
+
const resolved = source ? resolveSpecifier(element.file, source, (fileArg) => project.files.has(fileArg)) : null;
|
|
27
|
+
return !!resolved && isDemoFile(resolved.target);
|
|
28
|
+
});
|
|
29
|
+
if (!separate) {
|
|
30
|
+
findings.push({
|
|
31
|
+
file: element.file,
|
|
32
|
+
position: member.getStart(),
|
|
33
|
+
key: elementKey(element),
|
|
34
|
+
message: `static demo of ${element.className ?? '?'} is not imported from its demo file (${element.tag ?? '<tag>'}.demo.ts)`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return findings;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import * as ast from '../helpers.ast.js';
|
|
3
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
4
|
+
import { catalogElements, elementKey } from './shared.js';
|
|
5
|
+
|
|
6
|
+
const ts = plugins.typescript;
|
|
7
|
+
|
|
8
|
+
/** A demo factory: a function, or a reference to one (identifier or property access). */
|
|
9
|
+
const isFactory = (expressionArg: ast.TExpression): boolean =>
|
|
10
|
+
ts.isArrowFunction(expressionArg)
|
|
11
|
+
|| ts.isFunctionExpression(expressionArg)
|
|
12
|
+
|| ts.isIdentifier(expressionArg)
|
|
13
|
+
|| ts.isPropertyAccessExpression(expressionArg);
|
|
14
|
+
|
|
15
|
+
/** `static demo` is a function or a non-empty array of functions. */
|
|
16
|
+
export const demoShapeRule: IStandardRule = {
|
|
17
|
+
id: 'demo/shape',
|
|
18
|
+
severity: { base: 'error' },
|
|
19
|
+
fixMode: 'none',
|
|
20
|
+
detect: ({ project }) => {
|
|
21
|
+
const findings: IRuleFinding[] = [];
|
|
22
|
+
for (const element of catalogElements(project)) {
|
|
23
|
+
const member = ast.findStaticProperty(element.classNode, 'demo');
|
|
24
|
+
if (!member) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const initializer = member.initializer;
|
|
28
|
+
const valid = !!initializer && (ts.isArrayLiteralExpression(initializer)
|
|
29
|
+
? initializer.elements.length > 0 && initializer.elements.every(isFactory)
|
|
30
|
+
: isFactory(initializer));
|
|
31
|
+
if (!valid) {
|
|
32
|
+
findings.push({
|
|
33
|
+
file: element.file,
|
|
34
|
+
position: member.getStart(),
|
|
35
|
+
key: elementKey(element),
|
|
36
|
+
message: `static demo of ${element.className ?? '?'} is not a function or a non-empty array of functions`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return findings;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { StandardProject } from '../classes.standardproject.js';
|
|
2
|
+
import { joinPath } from '../helpers.paths.js';
|
|
3
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
4
|
+
import { catalogElements } from './shared.js';
|
|
5
|
+
|
|
6
|
+
export const componentIndexStart = '<!-- component-index:start -->';
|
|
7
|
+
export const componentIndexEnd = '<!-- component-index:end -->';
|
|
8
|
+
const readme = 'readme.md';
|
|
9
|
+
|
|
10
|
+
/** GitHub-style heading anchor. */
|
|
11
|
+
const slug = (headingArg: string): string => headingArg.toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/ /g, '-');
|
|
12
|
+
|
|
13
|
+
const headingsOf = (textArg: string): Set<string> => new Set([...textArg.matchAll(/^#{1,6}\s+(.+)$/gm)].map((matchArg) => slug(matchArg[1])));
|
|
14
|
+
|
|
15
|
+
/** The generated component index block: one row per element under `ts_web/elements`, sorted by tag. */
|
|
16
|
+
export const renderComponentIndex = (projectArg: StandardProject, readmeTextArg: string): string => {
|
|
17
|
+
const headings = headingsOf(readmeTextArg);
|
|
18
|
+
const rows = catalogElements(projectArg)
|
|
19
|
+
.filter((elementArg) => elementArg.tag && elementArg.className && elementArg.file.startsWith('ts_web/elements/'))
|
|
20
|
+
.map((elementArg) => {
|
|
21
|
+
const family = elementArg.file.split('/')[2].replace(/^00group-/, '').replace(/-/g, ' ');
|
|
22
|
+
const name = elementArg.className!;
|
|
23
|
+
const api = headings.has(name.toLowerCase()) ? `[${name}](#${name.toLowerCase()})` : '—';
|
|
24
|
+
return { tag: elementArg.tag!, row: `| \`${elementArg.tag}\` | ${family} | ${api} | [Source](${elementArg.file}) |` };
|
|
25
|
+
})
|
|
26
|
+
.sort((a, b) => a.tag.localeCompare(b.tag, 'en'));
|
|
27
|
+
return `${componentIndexStart}\n| Element | Family | API | Source |\n| --- | --- | --- | --- |\n${rows.map((rowArg) => rowArg.row).join('\n')}\n${componentIndexEnd}`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** dees: the readme's generated component index is current and its relative links resolve. */
|
|
31
|
+
export const docsComponentIndexRule: IStandardRule = {
|
|
32
|
+
id: 'docs/component-index',
|
|
33
|
+
severity: { dees: 'error' },
|
|
34
|
+
fixMode: 'auto',
|
|
35
|
+
detect: ({ project }) => {
|
|
36
|
+
const findings: IRuleFinding[] = [];
|
|
37
|
+
const text = project.files.get(readme);
|
|
38
|
+
if (text === undefined) {
|
|
39
|
+
return [{ file: readme, position: 0, key: readme, message: 'readme.md is missing' }];
|
|
40
|
+
}
|
|
41
|
+
const first = text.indexOf(componentIndexStart);
|
|
42
|
+
const last = text.indexOf(componentIndexEnd);
|
|
43
|
+
if (first < 0 || last < first) {
|
|
44
|
+
findings.push({ file: readme, position: 0, key: readme, message: `readme.md has no ${componentIndexStart} … ${componentIndexEnd} block` });
|
|
45
|
+
} else {
|
|
46
|
+
const generated = renderComponentIndex(project, text);
|
|
47
|
+
const end = last + componentIndexEnd.length;
|
|
48
|
+
if (text.slice(first, end) !== generated) {
|
|
49
|
+
findings.push({
|
|
50
|
+
file: readme,
|
|
51
|
+
position: first,
|
|
52
|
+
key: readme,
|
|
53
|
+
message: 'the component index is stale',
|
|
54
|
+
fix: (builderArg) => builderArg.edit(readme, first, end, generated),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const headings = headingsOf(text);
|
|
59
|
+
const withoutCode = text.replace(/^```[^\n]*\n[\s\S]*?^```/gm, (blockArg) => blockArg.replace(/[^\n]/g, ' '));
|
|
60
|
+
for (const match of withoutCode.matchAll(/\[[^\]]*\]\(([^\s)]+)\)/g)) {
|
|
61
|
+
const target = match[1];
|
|
62
|
+
if (/^[a-z]+:/i.test(target)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (target.startsWith('#')) {
|
|
66
|
+
if (!headings.has(target.slice(1))) {
|
|
67
|
+
findings.push({ file: readme, position: match.index, key: readme, message: `link to a missing section: ${target}` });
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const file = target.split('#')[0];
|
|
72
|
+
if (!file) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let decoded: string;
|
|
76
|
+
try {
|
|
77
|
+
decoded = decodeURIComponent(file);
|
|
78
|
+
} catch {
|
|
79
|
+
findings.push({ file: readme, position: match.index, key: readme, message: `malformed link: ${target}` });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!project.exists(joinPath(decoded))) {
|
|
83
|
+
findings.push({ file: readme, position: match.index, key: readme, message: `link to a missing file: ${target}` });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return findings;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
3
|
+
import { catalogElements, elementKey } from './shared.js';
|
|
4
|
+
|
|
5
|
+
const ts = plugins.typescript;
|
|
6
|
+
|
|
7
|
+
/** The element class is exported. */
|
|
8
|
+
export const elementExportedRule: IStandardRule = {
|
|
9
|
+
id: 'element/exported',
|
|
10
|
+
severity: { base: 'error' },
|
|
11
|
+
fixMode: 'auto',
|
|
12
|
+
detect: ({ project }) => {
|
|
13
|
+
const findings: IRuleFinding[] = [];
|
|
14
|
+
for (const element of catalogElements(project)) {
|
|
15
|
+
if (element.exported) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const finding: IRuleFinding = {
|
|
19
|
+
file: element.file,
|
|
20
|
+
position: element.decoratorCall.getStart(),
|
|
21
|
+
key: elementKey(element),
|
|
22
|
+
message: `class ${element.className ?? '?'} (<${element.tag ?? '?'}>) is not exported`,
|
|
23
|
+
};
|
|
24
|
+
// `export` goes before the first keyword modifier (e.g. `abstract`) or the `class` keyword, after the decorators.
|
|
25
|
+
const firstModifier = (ts.getModifiers(element.classNode) ?? [])[0];
|
|
26
|
+
const classKeyword = element.classNode.getChildren().find((childArg) => childArg.kind === ts.SyntaxKind.ClassKeyword);
|
|
27
|
+
const position = firstModifier?.getStart() ?? classKeyword?.getStart();
|
|
28
|
+
if (position !== undefined && element.classNode.parent === element.sourceFile) {
|
|
29
|
+
finding.fix = (builderArg) => builderArg.edit(element.file, position, position, 'export ');
|
|
30
|
+
}
|
|
31
|
+
findings.push(finding);
|
|
32
|
+
}
|
|
33
|
+
return findings;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
2
|
+
import { elementKey, isCustomElementName } from './shared.js';
|
|
3
|
+
|
|
4
|
+
/** `@customElement` takes a single string literal that is a valid custom element name. */
|
|
5
|
+
export const elementLiteralTagRule: IStandardRule = {
|
|
6
|
+
id: 'element/literal-tag',
|
|
7
|
+
severity: { base: 'error' },
|
|
8
|
+
fixMode: 'none',
|
|
9
|
+
detect: ({ project }) => {
|
|
10
|
+
const findings: IRuleFinding[] = [];
|
|
11
|
+
for (const element of project.elements) {
|
|
12
|
+
if (element.tag === null) {
|
|
13
|
+
findings.push({
|
|
14
|
+
file: element.file,
|
|
15
|
+
position: element.decoratorCall.getStart(),
|
|
16
|
+
key: elementKey(element),
|
|
17
|
+
message: `@customElement of ${element.className ?? 'an anonymous class'} does not take a single string literal`,
|
|
18
|
+
});
|
|
19
|
+
} else if (!isCustomElementName(element.tag)) {
|
|
20
|
+
findings.push({
|
|
21
|
+
file: element.file,
|
|
22
|
+
position: element.decoratorCall.getStart(),
|
|
23
|
+
key: elementKey(element),
|
|
24
|
+
message: `'${element.tag}' is not a valid custom element name (a lowercase letter, then lowercase letters, digits, '-', '.' or '_', with at least one '-')`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return findings;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { rootBarrel } from '../classes.standardproject.js';
|
|
2
|
+
import { dirOf } from '../helpers.paths.js';
|
|
3
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
4
|
+
import { addReexport, catalogElements, elementKey } from './shared.js';
|
|
5
|
+
|
|
6
|
+
/** Every entry file outside demo files is reachable from `ts_web/index.ts`. */
|
|
7
|
+
export const elementReachableRule: IStandardRule = {
|
|
8
|
+
id: 'element/reachable',
|
|
9
|
+
severity: { base: 'error' },
|
|
10
|
+
fixMode: 'auto',
|
|
11
|
+
allowlistCategories: ['internal'],
|
|
12
|
+
detect: ({ project }) => {
|
|
13
|
+
const findings: IRuleFinding[] = [];
|
|
14
|
+
const reached = project.reachableFrom(rootBarrel);
|
|
15
|
+
for (const element of catalogElements(project)) {
|
|
16
|
+
if (reached.has(element.file)) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const finding: IRuleFinding = {
|
|
20
|
+
file: element.file,
|
|
21
|
+
position: element.decoratorCall.getStart(),
|
|
22
|
+
key: elementKey(element),
|
|
23
|
+
message: `<${element.tag ?? '?'}> in ${element.file} is not reachable from ${rootBarrel}`,
|
|
24
|
+
};
|
|
25
|
+
// Walk up to the nearest reachable barrel; an unreachable barrel that already re-exports the file is exported instead.
|
|
26
|
+
let candidate = element.file;
|
|
27
|
+
let dir = dirOf(element.file);
|
|
28
|
+
while (dir.startsWith('ts_web')) {
|
|
29
|
+
const barrel = `${dir}/index.ts`;
|
|
30
|
+
if (barrel !== candidate && project.files.has(barrel)) {
|
|
31
|
+
if (reached.has(barrel)) {
|
|
32
|
+
const target = candidate;
|
|
33
|
+
finding.fix = (builderArg) => addReexport(builderArg, project, barrel, target);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
if (project.reexports(barrel).some((reexportArg) => reexportArg.target === candidate)) {
|
|
37
|
+
candidate = barrel;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (dir === 'ts_web') {
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
dir = dirOf(dir);
|
|
44
|
+
}
|
|
45
|
+
findings.push(finding);
|
|
46
|
+
}
|
|
47
|
+
return findings;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import * as ast from '../helpers.ast.js';
|
|
3
|
+
import type { IStandardElement } from '../classes.standardproject.js';
|
|
4
|
+
import type { IRuleFinding, IStandardRule, TFixAction } from '../rule.js';
|
|
5
|
+
import { catalogElements, elementKey, isCustomElementName } from './shared.js';
|
|
6
|
+
|
|
7
|
+
const ts = plugins.typescript;
|
|
8
|
+
|
|
9
|
+
/** The tags of a file's `declare global { interface HTMLElementTagNameMap { … } }` and the first such interface. */
|
|
10
|
+
const tagMapOf = (sourceFileArg: ast.TSourceFile): { tags: Set<string>; declaration: plugins.typescript.InterfaceDeclaration | null } => {
|
|
11
|
+
const tags = new Set<string>();
|
|
12
|
+
let declaration: plugins.typescript.InterfaceDeclaration | null = null;
|
|
13
|
+
for (const statement of sourceFileArg.statements) {
|
|
14
|
+
if (!ts.isModuleDeclaration(statement) || !ts.isIdentifier(statement.name) || statement.name.text !== 'global'
|
|
15
|
+
|| !statement.body || !ts.isModuleBlock(statement.body)) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
for (const inner of statement.body.statements) {
|
|
19
|
+
if (ts.isInterfaceDeclaration(inner) && inner.name.text === 'HTMLElementTagNameMap') {
|
|
20
|
+
declaration ??= inner;
|
|
21
|
+
for (const member of inner.members) {
|
|
22
|
+
if (ts.isPropertySignature(member) && member.name && (ts.isStringLiteral(member.name) || ts.isIdentifier(member.name))) {
|
|
23
|
+
tags.add(member.name.text);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { tags, declaration };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Adds every missing element of one file to its tag map, or appends a tag map block to the file. */
|
|
33
|
+
const tagMapFix = (missingArg: IStandardElement[]): TFixAction => (builderArg) => {
|
|
34
|
+
const sourceFile = missingArg[0].sourceFile;
|
|
35
|
+
const file = missingArg[0].file;
|
|
36
|
+
const text = sourceFile.text;
|
|
37
|
+
const { declaration } = tagMapOf(sourceFile);
|
|
38
|
+
if (declaration) {
|
|
39
|
+
const closeBrace = declaration.end - 1;
|
|
40
|
+
const lineStart = text.lastIndexOf('\n', closeBrace - 1) + 1;
|
|
41
|
+
const firstMember = declaration.members[0];
|
|
42
|
+
const indentation = firstMember
|
|
43
|
+
? ast.indentationAt(text, firstMember.getStart())
|
|
44
|
+
: `${ast.indentationAt(text, declaration.getStart())} `;
|
|
45
|
+
const lines = missingArg.map((elementArg) => `${indentation}'${elementArg.tag}': ${elementArg.className};\n`).join('');
|
|
46
|
+
if (/^[ \t]*$/.test(text.slice(lineStart, closeBrace))) {
|
|
47
|
+
builderArg.edit(file, lineStart, lineStart, lines);
|
|
48
|
+
} else {
|
|
49
|
+
builderArg.edit(file, closeBrace, closeBrace, `\n${lines}`);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const members = missingArg.map((elementArg) => ` '${elementArg.tag}': ${elementArg.className};\n`).join('');
|
|
54
|
+
const prefix = text.length === 0 || text.endsWith('\n') ? '' : '\n';
|
|
55
|
+
builderArg.edit(file, text.length, text.length, `${prefix}\ndeclare global {\n interface HTMLElementTagNameMap {\n${members} }\n}\n`);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The entry file declares `declare global { interface HTMLElementTagNameMap { '<tag>': <Class> } }`. */
|
|
59
|
+
export const elementTagMapRule: IStandardRule = {
|
|
60
|
+
id: 'element/tag-map',
|
|
61
|
+
severity: { base: 'warn' },
|
|
62
|
+
fixMode: 'auto',
|
|
63
|
+
detect: ({ project }) => {
|
|
64
|
+
const findings: IRuleFinding[] = [];
|
|
65
|
+
const byFile = new Map<string, IStandardElement[]>();
|
|
66
|
+
for (const element of catalogElements(project)) {
|
|
67
|
+
if (element.tag && !tagMapOf(element.sourceFile).tags.has(element.tag)) {
|
|
68
|
+
byFile.set(element.file, [...(byFile.get(element.file) ?? []), element]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const missing of byFile.values()) {
|
|
72
|
+
// The tag is written into the source: only valid custom element names are.
|
|
73
|
+
const fixable = missing.every((elementArg) => elementArg.className && isCustomElementName(elementArg.tag!));
|
|
74
|
+
const fix = fixable ? tagMapFix(missing) : undefined;
|
|
75
|
+
for (const element of missing) {
|
|
76
|
+
findings.push({
|
|
77
|
+
file: element.file,
|
|
78
|
+
position: element.decoratorCall.getStart(),
|
|
79
|
+
key: elementKey(element),
|
|
80
|
+
message: `HTMLElementTagNameMap of ${element.file} does not declare '${element.tag}'`,
|
|
81
|
+
fix,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return findings;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import * as ast from '../helpers.ast.js';
|
|
3
|
+
import { isDemoFile } from '../helpers.paths.js';
|
|
4
|
+
import type { IRuleFinding, IStandardRule } from '../rule.js';
|
|
5
|
+
|
|
6
|
+
const ts = plugins.typescript;
|
|
7
|
+
|
|
8
|
+
/** The words belong to the catalog; demos, demo data, the i18n catalog and the demo pages show sample content. */
|
|
9
|
+
const isExempt = (fileArg: string): boolean =>
|
|
10
|
+
isDemoFile(fileArg)
|
|
11
|
+
|| fileArg.startsWith('ts_web/i18n/')
|
|
12
|
+
|| fileArg.startsWith('ts_web/demos/')
|
|
13
|
+
|| fileArg.startsWith('ts_web/pages/');
|
|
14
|
+
|
|
15
|
+
const wordAttributes = 'aria-label|aria-description|aria-valuetext|title|placeholder|label|text|heading|subheading|alt';
|
|
16
|
+
const wordProperties = new Set(['name', 'label', 'text', 'heading', 'subheading', 'title', 'placeholder', 'message', 'description', 'confirmLabel', 'cancelLabel', 'emptyText', 'tooltip', 'hint']);
|
|
17
|
+
const word = /^[A-Z][a-z]+(?:[ '’,.?!…-]+[A-Za-z]+)*[.?!…]?$/;
|
|
18
|
+
|
|
19
|
+
/** The patterns of dees-catalog's scripts/check-literal-ratchet.cjs that apply to template and string text. */
|
|
20
|
+
const textPatterns = (): RegExp[] => [
|
|
21
|
+
// aria-label="Progress"
|
|
22
|
+
new RegExp(`\\s(?:${wordAttributes})="([A-Za-z][^"$]*[a-z][^"$]*)"`, 'g'),
|
|
23
|
+
// >Cancel< and > Back <
|
|
24
|
+
/(?<![=-])>\s*([A-Z][a-z]+(?:[ '’,.?!…-]+[A-Za-z]+)*[.?!…]?)\s*</g,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** dees: no English literal a reader sees outside demo files, `ts_web/i18n/`, `ts_web/demos/` and `ts_web/pages/`. A ratchet. */
|
|
28
|
+
export const i18nLiteralsRule: IStandardRule = {
|
|
29
|
+
id: 'i18n/literals',
|
|
30
|
+
severity: { dees: 'error' },
|
|
31
|
+
fixMode: 'none',
|
|
32
|
+
detect: ({ project }) => {
|
|
33
|
+
const findings: IRuleFinding[] = [];
|
|
34
|
+
for (const file of project.webFiles) {
|
|
35
|
+
if (isExempt(file)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const visit = (nodeArg: ast.TNode): void => {
|
|
39
|
+
if (ts.isStringLiteral(nodeArg) || ts.isNoSubstitutionTemplateLiteral(nodeArg)
|
|
40
|
+
|| ts.isTemplateHead(nodeArg) || ts.isTemplateMiddle(nodeArg) || ts.isTemplateTail(nodeArg)) {
|
|
41
|
+
const text = nodeArg.getText();
|
|
42
|
+
for (const pattern of textPatterns()) {
|
|
43
|
+
for (const match of text.matchAll(pattern)) {
|
|
44
|
+
findings.push({ file, position: nodeArg.getStart() + match.index, key: file, message: `English literal ${match[0].trim()}` });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (ts.isPropertyAssignment(nodeArg) && (ts.isIdentifier(nodeArg.name) || ts.isStringLiteral(nodeArg.name))
|
|
49
|
+
&& wordProperties.has(nodeArg.name.text) && ts.isStringLiteral(nodeArg.initializer) && word.test(nodeArg.initializer.text)) {
|
|
50
|
+
findings.push({ file, position: nodeArg.getStart(), key: file, message: `English literal ${nodeArg.getText()}` });
|
|
51
|
+
}
|
|
52
|
+
ts.forEachChild(nodeArg, visit);
|
|
53
|
+
};
|
|
54
|
+
visit(project.sourceFile(file));
|
|
55
|
+
}
|
|
56
|
+
return findings;
|
|
57
|
+
},
|
|
58
|
+
};
|