Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions cli/wca.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { spawn } from 'node:child_process';
import { glob as globFiles, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve, relative } from 'node:path';

const customElementsFileName = 'custom-elements.json';

function findObjectEnd(content, start) {
let depth = 0;
let mode = 'code';
let quote;

for (let index = start; index < content.length; index++) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yay, a lexer!

const character = content[index];
const nextCharacter = content[index + 1];

if (mode === 'line-comment') {
if (character === '\n') mode = 'code';
continue;
}
if (mode === 'block-comment') {
if (character === '*' && nextCharacter === '/') {
mode = 'code';
index++;

@EdwinACL831 EdwinACL831 Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why here you increment the index and not only do the continue;?
Oh nvm, it is because the next character is the known / which we want to skip I guess

}
continue;
}
if (mode === 'string' || mode === 'template') {
if (character === '\\') {
index++;
} else if (character === quote) {
mode = 'code';
}
continue;
}

if (character === '/' && nextCharacter === '/') {
mode = 'line-comment';
index++;
} else if (character === '/' && nextCharacter === '*') {
mode = 'block-comment';
index++;
} else if (character === '\'' || character === '"' || character === '`') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took forever to figure this part out, cause I didn't see the second ' and thought this was talking about the \ character 🤦‍♀️

mode = character === '`' ? 'template' : 'string';
quote = character;
} else if (character === '{') {
depth++;
} else if (character === '}' && --depth === 0) {
return index;
}
}

throw new Error('Could not find the end of a static properties object.');
}

function transformProperties(content) {
const propertyPattern = /\bstatic\s+properties\s*=/g;
let result = '';
let sourceIndex = 0;
let match;

while ((match = propertyPattern.exec(content)) !== null) {
const objectStart = content.indexOf('{', propertyPattern.lastIndex);
if (objectStart === -1) throw new Error('Could not find a static properties object.');

const objectEnd = findObjectEnd(content, objectStart);
const semicolon = content[objectEnd + 1] === ';' ? 1 : 0;
const declaration = content.slice(match.index, objectEnd + 1 + semicolon);
const object = content.slice(objectStart, objectEnd + 1);

result += content.slice(sourceIndex, match.index);
result += declaration.replace(/\bstatic\s+properties\s*=\s*/, 'static get properties() { return ')
.replace(object, `${object}; }`);
sourceIndex = objectEnd + 1 + semicolon;
propertyPattern.lastIndex = sourceIndex;
}

return result + content.slice(sourceIndex);
}

async function transformFiles(files, temporaryDirectory) {
for (const file of files) {
const sourcePath = resolve(file);
const relativePath = relative(process.cwd(), sourcePath);
const destinationPath = join(temporaryDirectory, relativePath);

await mkdir(dirname(destinationPath), { recursive: true });
const content = await readFile(sourcePath, 'utf8');
await writeFile(destinationPath, transformProperties(content));
}
}

function restorePaths(value, temporaryPath) {
if (Array.isArray(value)) {
value.forEach(item => restorePaths(item, temporaryPath));
} else if (value && typeof value === 'object') {
Object.entries(value).forEach(([key, item]) => {
if (key === 'path' && typeof item === 'string') {
value[key] = item.replace(temporaryPath, '.');
} else {
restorePaths(item, temporaryPath);
}
});
}
}

async function restoreCustomElementsPaths(temporaryDirectory) {
const customElements = JSON.parse(await readFile(customElementsFileName, 'utf8'));
const temporaryPath = `./${relative(process.cwd(), temporaryDirectory)}`;
restorePaths(customElements, temporaryPath);
await writeFile(customElementsFileName, `${JSON.stringify(customElements, null, 2)}\n`);
}

function runWca(src) {
return new Promise((resolve, reject) => {
const child = spawn('wca', [
'analyze',
src,
'--format', 'json',
'--outFile', customElementsFileName
], { stdio: 'inherit' });
child.once('error', reject);
child.once('close', (exitCode, signal) => {
if (exitCode === 0) {
resolve();
} else {
reject(new Error(`wca exited with ${signal ? `signal ${signal}` : `code ${exitCode}`}`));
}
});
});
}

async function main() {
const [glob] = process.argv.slice(2);
if (!glob) throw new Error('Usage: node ./cli/wca.js <glob>');

const temporaryDirectory = await mkdtemp(join(tmpdir(), 'wca-'));

try {
const files = await Array.fromAsync(globFiles(glob, { nodir: true }));

await transformFiles(files, temporaryDirectory);

await runWca(join(temporaryDirectory, '**/*.js'));

await restoreCustomElementsPaths(temporaryDirectory);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've done a side-by-side diff of custom-elements.json from before the static props stuff merged against this. Everything's identical with the exception of a few things now correctly being documented where we were already using static props (like <d2l-page-*> stuff).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Committing custom-elements.json doesn't make sense and will just be too noisy right? The smoke tests in the docs site did their job, and we've been fine up to this point, but also feels easy to miss a problem.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be very noisy, and I think we'd need to build an action similar to the translation formatter / vdiff that would PR into your PR with the changes? Otherwise we're relying on devs running build and committing it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That could all live in the docs site instead, rather than committing it here. But yeah, it would probably require a dev review almost every day 😬. Can't decide if that's worth it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I don't think we can fully move it to the docs site because component-usage-metrics also uses it.

} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}

main().catch(error => {
console.error(error);
process.exitCode = 1;
});
134 changes: 66 additions & 68 deletions components/button/button-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,72 @@ import { FocusMixin } from '../../mixins/focus/focus-mixin.js';

export const ButtonMixin = superclass => class extends FocusMixin(superclass) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had previously left these static getters in because our validate-wca.js script was complaining that they were missing descriptions... which should have set off alarms about the issue.

The good news is that we can now switch these to use the non-getter version.

static get properties() {
return {
/**
* @ignore
*/
ariaExpanded: { type: String, reflect: true, attribute: 'aria-expanded' },
/**
* @ignore
*/
ariaHaspopup: { type: String, reflect: true, attribute: 'aria-haspopup' },
/**
* @ignore
*/
ariaLabel: { type: String, reflect: true, attribute: 'aria-label' },
/**
* @ignore
*/
// eslint-disable-next-line lit/no-native-attributes
autofocus: { type: Boolean, reflect: true },
/**
* Wether the controlled element is expanded. Replaces 'aria-expanded'
* @type {'true' | 'false'}
*/
expanded: { type: String, reflect: true, attribute: 'expanded' },
/**
* Disables the button
* @type {boolean}
*/
disabled: { type: Boolean, reflect: true },
/**
* Tooltip text when disabled
* @type {string}
*/
disabledTooltip: { type: String, attribute: 'disabled-tooltip' },
/**
* @ignore
*/
form: { type: String, reflect: true },
/**
* @ignore
*/
formaction: { type: String, reflect: true },
/**
* @ignore
*/
formenctype: { type: String, reflect: true },
/**
* @ignore
*/
formmethod: { type: String, reflect: true },
/**
* @ignore
*/
formnovalidate: { type: String, reflect: true },
/**
* @ignore
*/
formtarget: { type: String, reflect: true },
/**
* @ignore
*/
name: { type: String, reflect: true },
/**
* @ignore
*/
type: { type: String, reflect: true }
};
}
static properties = {
/**
* @ignore
*/
ariaExpanded: { type: String, reflect: true, attribute: 'aria-expanded' },
/**
* @ignore
*/
ariaHaspopup: { type: String, reflect: true, attribute: 'aria-haspopup' },
/**
* @ignore
*/
ariaLabel: { type: String, reflect: true, attribute: 'aria-label' },
/**
* @ignore
*/
// eslint-disable-next-line lit/no-native-attributes
autofocus: { type: Boolean, reflect: true },
/**
* Wether the controlled element is expanded. Replaces 'aria-expanded'
* @type {'true' | 'false'}
*/
expanded: { type: String, reflect: true, attribute: 'expanded' },
/**
* Disables the button
* @type {boolean}
*/
disabled: { type: Boolean, reflect: true },
/**
* Tooltip text when disabled
* @type {string}
*/
disabledTooltip: { type: String, attribute: 'disabled-tooltip' },
/**
* @ignore
*/
form: { type: String, reflect: true },
/**
* @ignore
*/
formaction: { type: String, reflect: true },
/**
* @ignore
*/
formenctype: { type: String, reflect: true },
/**
* @ignore
*/
formmethod: { type: String, reflect: true },
/**
* @ignore
*/
formnovalidate: { type: String, reflect: true },
/**
* @ignore
*/
formtarget: { type: String, reflect: true },
/**
* @ignore
*/
name: { type: String, reflect: true },
/**
* @ignore
*/
type: { type: String, reflect: true }
};

constructor() {
super();
Expand Down
4 changes: 1 addition & 3 deletions components/demo/demo-passthrough-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ import { LitElement } from 'lit';
* @param { String } target The element name or other selector string of the element to pass properties to.
*/
export const DemoPassthroughMixin = (superclass, target) => class extends LitElement {
static get properties() {
return Object.fromEntries(superclass.elementProperties);
}
static properties = Object.fromEntries(superclass.elementProperties);

firstUpdated() {
this.target = this.shadowRoot.querySelector(target);
Expand Down
28 changes: 13 additions & 15 deletions components/selection/selection-action-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,19 @@ import { SelectionObserverMixin } from './selection-observer-mixin.js';

export const SelectionActionMixin = superclass => class extends LocalizeCoreElement(SelectionObserverMixin(superclass)) {

static get properties() {
return {
/**
* Disables bulk actions in the list or table controls once the selection limit is reached, but does not prevent further selection
* @type {number}
*/
maxSelectionCount: { type: Number, attribute: 'max-selection-count' },
/**
* Whether the action requires one or more selected items
* @type {boolean}
*/
requiresSelection: { type: Boolean, attribute: 'requires-selection', reflect: true },
_disabledTooltip: { state: true }
};
}
static properties = {
/**
* Disables bulk actions in the list or table controls once the selection limit is reached, but does not prevent further selection
* @type {number}
*/
maxSelectionCount: { type: Number, attribute: 'max-selection-count' },
/**
* Whether the action requires one or more selected items
* @type {boolean}
*/
requiresSelection: { type: Boolean, attribute: 'requires-selection', reflect: true },
_disabledTooltip: { state: true }
};

constructor() {
super();
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"build:icons": "node ./cli/icon-generator.js",
"build:illustrations": "node ./cli/empty-state-illustration-generator.js",
"build:sass": "sass ./test/sass.scss > ./test/sass.output.css",
"build:wca": "wca analyze \"{components,templates}/**/*.js\" --format json --outFile custom-elements.json",
"build:wca": "node ./cli/wca.js \"{components,mixins,templates}/**/*.js\"",
"build": "npm run build:clean && npm run build:icons && npm run build:illustrations && npm run build:sass && npm run build:wca",
"build-static": "rollup -c ./rollup/rollup.config.js",
"lint": "npm run lint:eslint && npm run lint:style",
Expand Down