Skip to content

Commit 39ba85c

Browse files
committed
chore(ci): enforce html custom data package contracts
Signed-off-by: John Yanarella <jyanarella@nvidia.com>
1 parent 090c9c3 commit 39ba85c

6 files changed

Lines changed: 372 additions & 18 deletions

File tree

‎projects/internals/eslint/src/configs/json.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
importjsonfrom'@eslint/json';
22
importnoUnpinnedDependencyRangesfrom'../local/no-unpinned-dependency-ranges.js';
3+
importrequireHtmlCustomDataContractfrom'../local/require-html-custom-data-contract.js';
34

45
constsource=['package.json'];
56
constignores=[
@@ -26,12 +27,14 @@ export const jsonConfig = [
2627
json,
2728
'local-json': {
2829
rules: {
29-
'no-unpinned-dependency-ranges': noUnpinnedDependencyRanges
30+
'no-unpinned-dependency-ranges': noUnpinnedDependencyRanges,
31+
'require-html-custom-data-contract': requireHtmlCustomDataContract
3032
}
3133
}
3234
},
3335
rules: {
34-
'local-json/no-unpinned-dependency-ranges': ['error']
36+
'local-json/no-unpinned-dependency-ranges': ['error'],
37+
'local-json/require-html-custom-data-contract': ['error']
3538
}
3639
}
3740
];
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
constPACKAGE_SCOPE='@nvidia-elements/';
2+
3+
functiongetMember(object,name){
4+
returnobject?.members.find(member=>member.name.value===name);
5+
}
6+
7+
functiongetContributions(root){
8+
constcontributes=getMember(root,'contributes')?.value;
9+
consthtml=contributes?.type==='Object' ? getMember(contributes,'html')?.value : undefined;
10+
11+
returnhtml?.type==='Object' ? getMember(html,'customData') : undefined;
12+
}
13+
14+
functionisPackageRelativePath(packagePath){
15+
returnpackagePath.startsWith('./')&&!packagePath.split('/').includes('..');
16+
}
17+
18+
functiongetExportTargetPattern(target){
19+
return`^${target
20+
.split('*')
21+
.map(segment=>segment.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'))
22+
.join('.*')}$`;
23+
}
24+
25+
functionmatchesExportTarget(target,path){
26+
if(target.type==='String'){
27+
returnnewRegExp(getExportTargetPattern(target.value)).test(path);
28+
}
29+
30+
if(target.type==='Array'){
31+
returntarget.elements.some(element=>matchesExportTarget(element.value,path));
32+
}
33+
34+
returntarget.type==='Object'&&target.members.some(member=>matchesExportTarget(member.value,path));
35+
}
36+
37+
/** @type {import('eslint').Rule.RuleModule} */
38+
exportdefault{
39+
meta: {
40+
type: 'problem',
41+
name: 'require-html-custom-data-contract',
42+
messages: {
43+
'invalid-contribution': 'contributes.html.customData must be a non-empty array of package-relative paths.',
44+
'missing-export': 'contributes.html.customData path "{{path}}" must be exposed by a package export.'
45+
}
46+
},
47+
create(context){
48+
return{
49+
Document(node){
50+
constroot=node.body;
51+
if(root.type!=='Object')return;
52+
53+
constpackageName=getMember(root,'name')?.value;
54+
if(packageName?.type!=='String'||!packageName.value.startsWith(PACKAGE_SCOPE))return;
55+
56+
constcontributions=getContributions(root);
57+
if(!contributions)return;
58+
59+
constexports=getMember(root,'exports')?.value;
60+
if(
61+
contributions.value.type!=='Array'||
62+
contributions.value.elements.length===0||
63+
contributions.value.elements.some(
64+
element=>element?.value?.type!=='String'||!isPackageRelativePath(element.value.value)
65+
)
66+
){
67+
context.report({node: contributions.value,messageId: 'invalid-contribution'});
68+
return;
69+
}
70+
71+
for(constelementofcontributions.value.elements){
72+
constisExported=
73+
exports?.type==='Object'&&
74+
exports.members.some(member=>matchesExportTarget(member.value,element.value.value));
75+
if(!isExported){
76+
context.report({
77+
node: element.value,
78+
messageId: 'missing-export',
79+
data: {path: element.value.value}
80+
});
81+
}
82+
}
83+
}
84+
};
85+
}
86+
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import{beforeEach,test}from'node:test';
2+
importassertfrom'node:assert/strict';
3+
import{RuleTester}from'eslint';
4+
importjsonfrom'@eslint/json';
5+
importrequireHtmlCustomDataContractfrom'./require-html-custom-data-contract.js';
6+
7+
lettester;
8+
9+
beforeEach(()=>{
10+
tester=newRuleTester({
11+
plugins: {
12+
json
13+
},
14+
language: 'json/json'
15+
});
16+
});
17+
18+
test('defines rule metadata',()=>{
19+
assert.equal(requireHtmlCustomDataContract.meta.type,'problem');
20+
assert.equal(requireHtmlCustomDataContract.meta.name,'require-html-custom-data-contract');
21+
assert.ok(requireHtmlCustomDataContract.meta.messages['missing-export']);
22+
});
23+
24+
test('requires HTML Custom Data contributions to be exported',()=>{
25+
tester.run('require-html-custom-data-contract',requireHtmlCustomDataContract,{
26+
valid: [
27+
{
28+
filename: 'package.json',
29+
code: `{
30+
"name": "@nvidia-elements/example",
31+
"contributes": { "html": { "customData": ["./dist/editor/custom-data.json"] } },
32+
"exports": { "./editor-data.json": "./dist/editor/custom-data.json" }
33+
}`
34+
},
35+
{
36+
filename: 'package.json',
37+
code: `{
38+
"name": "@nvidia-elements/example",
39+
"contributes": { "html": { "customData": ["./dist/editor/custom-data.json"] } },
40+
"exports": { "./editor-data.json": ["./dist/editor/custom-data.json"] }
41+
}`
42+
},
43+
{
44+
filename: 'package.json',
45+
code: '{ "name": "@nvidia-elements/example" }'
46+
},
47+
{
48+
filename: 'package.json',
49+
code: `{
50+
"name": "@internals/example",
51+
"contributes": { "html": { "customData": ["./dist/editor/custom-data.json"] } }
52+
}`
53+
}
54+
],
55+
invalid: [
56+
{
57+
filename: 'package.json',
58+
code: `{
59+
"name": "@nvidia-elements/example",
60+
"contributes": { "html": { "customData": ["./dist/data.html.json"] } }
61+
}`,
62+
errors: [{messageId: 'missing-export'}]
63+
},
64+
{
65+
filename: 'package.json',
66+
code: `{
67+
"name": "@nvidia-elements/example",
68+
"contributes": { "html": { "customData": [] } }
69+
}`,
70+
errors: [{messageId: 'invalid-contribution'}]
71+
},
72+
{
73+
filename: 'package.json',
74+
code: `{
75+
"name": "@nvidia-elements/example",
76+
"contributes": { "html": { "customData": ["./dist/data.html.json", 1] } }
77+
}`,
78+
errors: [{messageId: 'invalid-contribution'}]
79+
},
80+
{
81+
filename: 'package.json',
82+
code: `{
83+
"name": "@nvidia-elements/example",
84+
"contributes": { "html": { "customData": ["../dist/data.html.json"] } }
85+
}`,
86+
errors: [{messageId: 'invalid-contribution'}]
87+
},
88+
{
89+
filename: 'package.json',
90+
code: `{
91+
"name": "@nvidia-elements/example",
92+
"contributes": { "html": { "customData": ["/package/dist/data.html.json"] } }
93+
}`,
94+
errors: [{messageId: 'invalid-contribution'}]
95+
},
96+
{
97+
filename: 'package.json',
98+
code: `{
99+
"name": "@nvidia-elements/example",
100+
"contributes": { "html": { "customData": ["./dist/editor/custom-data.json"] } },
101+
"exports": { "./editor-data.json": "./dist/other-data.json" }
102+
}`,
103+
errors: [{messageId: 'missing-export'}]
104+
}
105+
]
106+
});
107+
});

‎projects/internals/vite/package.json‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"playwright-lock": "./src/playwright/index.js"
88
},
99
"scripts": {
10-
"ci": "wireit"
10+
"ci": "wireit",
11+
"test": "wireit"
1112
},
1213
"exports": {
1314
".": "./src/index.js",
@@ -46,10 +47,20 @@
4647
},
4748
"wireit": {
4849
"ci": {
50+
"dependencies": [
51+
"test"
52+
],
4953
"files": [
5054
"src",
5155
"!src/playwright/locks"
5256
]
57+
},
58+
"test": {
59+
"command": "node --test 'src/**/*.test.js'",
60+
"files": [
61+
"src/**/*.js"
62+
],
63+
"output": []
5364
}
5465
}
5566
}

‎projects/internals/vite/src/plugins/cem.js‎

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,65 @@ import { generateVsCodeCustomElementData } from 'custom-element-vs-code-integrat
55

66
constresolve=rel=>path.join(process.cwd(),rel);
77

8+
functiongetCustomDataPaths(packageJson){
9+
constcustomDataPaths=packageJson.contributes?.html?.customData;
10+
if(customDataPaths===undefined)return[];
11+
12+
if(
13+
!Array.isArray(customDataPaths)||
14+
customDataPaths.length===0||
15+
customDataPaths.some(customDataPath=>typeofcustomDataPath!=='string')
16+
){
17+
thrownewError(
18+
`${packageJson.name}: contributes.html.customData must be a non-empty array of package-relative paths.`
19+
);
20+
}
21+
22+
returncustomDataPaths;
23+
}
24+
25+
functionisPackageRelativePath(packagePath){
26+
returnpackagePath.startsWith('./')&&!packagePath.split('/').includes('..');
27+
}
28+
29+
functiongetPackagePath(packageDirectory,packagePath){
30+
if(!isPackageRelativePath(packagePath))returnundefined;
31+
32+
constresolvedPath=path.resolve(packageDirectory,packagePath);
33+
constpathFromPackage=path.relative(packageDirectory,resolvedPath);
34+
returnpathFromPackage.startsWith('..')||pathFromPackage==='' ? undefined : resolvedPath;
35+
}
36+
37+
functionhasManifestTags(manifest){
38+
return(manifest.modules??[]).some(module=>(module.declarations??[]).some(declaration=>declaration.tagName));
39+
}
40+
41+
exportfunctiongetCustomDataOutputs(packageJson,manifest,packageDirectory){
42+
constcustomDataPaths=getCustomDataPaths(packageJson);
43+
consthasComponents=hasManifestTags(manifest);
44+
constcustomDataOutputs=customDataPaths.map(customDataPath=>{
45+
constoutputPath=getPackagePath(packageDirectory,customDataPath);
46+
if(!outputPath){
47+
thrownewError(
48+
`${packageJson.name}: contributes.html.customData path "${customDataPath}" must be package-relative.`
49+
);
50+
}
51+
52+
return{
53+
outdir: path.dirname(outputPath),
54+
htmlFileName: path.basename(outputPath)
55+
};
56+
});
57+
58+
if(hasComponents&&customDataOutputs.length===0){
59+
thrownewError(
60+
`${packageJson.name}: Custom Elements Manifest declares tags but contributes.html.customData is missing.`
61+
);
62+
}
63+
64+
returnhasComponents ? customDataOutputs : [];
65+
}
66+
867
functionnormalizeURL(value){
968
if(typeofvalue!=='string'){
1069
returnnull;
@@ -45,9 +104,10 @@ export function cem() {
45104
: newURL('cem.config.mjs',import.meta.url).toString().replace('file://','');
46105

47106
constmanifest=awaitcli({argv: ['analyze','--config',configPath,'--outdir','./dist']});
48-
consthasComponents=manifest.modules.flatMap(module=>module.declarations).find(d=>d.tagName);
107+
constpackageJson=JSON.parse(fs.readFileSync(resolve('./package.json'),'utf8'));
108+
constcustomDataOutputs=getCustomDataOutputs(packageJson,manifest,process.cwd());
49109

50-
if(hasComponents){
110+
if(customDataOutputs.length>0){
51111
// deep clone
52112
constvsCodeManifest=structuredClone(manifest);
53113
vsCodeManifest.modules.forEach(module=>{
@@ -58,19 +118,20 @@ export function cem() {
58118
});
59119
});
60120

61-
generateVsCodeCustomElementData(vsCodeManifest,{
62-
outdir: resolve('./dist'),
63-
htmlFileName: 'data.html.json',
64-
cssFileName: null,
65-
referencesTemplate: (_name,tag)=>{
66-
constdeclaration=vsCodeManifest.modules
67-
.flatMap(module=>module.declarations)
68-
.find(d=>d.tagName===tag);
69-
returnObject.entries(declaration?.metadata??{}).flatMap(([name,value])=>{
70-
constreference=generateVsCodeCustomElementDataReference(name,value);
71-
returnreference ? [reference] : [];
72-
});
73-
}
121+
customDataOutputs.forEach(output=>{
122+
generateVsCodeCustomElementData(vsCodeManifest,{
123+
...output,
124+
cssFileName: null,
125+
referencesTemplate: (_name,tag)=>{
126+
constdeclaration=vsCodeManifest.modules
127+
.flatMap(module=>module.declarations)
128+
.find(d=>d.tagName===tag);
129+
returnObject.entries(declaration?.metadata??{}).flatMap(([name,value])=>{
130+
constreference=generateVsCodeCustomElementDataReference(name,value);
131+
returnreference ? [reference] : [];
132+
});
133+
}
134+
});
74135
});
75136
}
76137
}

0 commit comments

Comments
 (0)