Skip to content

Commit

Permalink
Merge pull request #15 from Foxy/beta
Browse files Browse the repository at this point in the history
feat: Core.BooleanSelector
  • Loading branch information
brettflorio committed Apr 20, 2021
2 parents 893fb04 + 3ddc981 commit 9e79afe
Show file tree
Hide file tree
Showing 3 changed files with 452 additions and 0 deletions.
293 changes: 293 additions & 0 deletions src/core/BooleanSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
const enum Entity {
List,
Set,
}

type Tree = { only?: Record<string, Tree>; not?: string[] };
type Output = { entity: Entity; buffer: string; tree: Tree; branch: Tree | string[] };
type Processor = (output: Output, character: string) => void;

/**
* Boolean selector is an HTML boolean attribute value format that allows
* developers to write configurations for elements deep in a shadow DOM. Here's
* what it looks like:
*
* ```text
* direct-child-one:nested-child:not=descendant-one,descendant-two direct-child-two
* ```
*
* When used with the "disabled" attribute, the code above could translate to: "Disable
* everything except for the descendant-one and descendant-two in the nested-child that
* belongs to direct-child-one; disable direct-child-two entirely."
*
* Boolean selector is always a list, where items are separated by whitespace (as much
* as you need, including line breaks):
*
* ```text
* item-one item-two item-three
* ```
*
* Each item is a path that consists of identifiers (lowercase characters from a to z or a dash)
* separated by a colon:
*
* ```text
* parent:child:nested-child
* ```
*
* By default, only specified paths will be selected. To select everything except for certain paths,
* add the `not=` modifier to the end of the path (or at the top level):
*
* ```text
* parent:child:not=exception
* ```
*
* You can specify multiple values by separating them with a comma and optionally a whitespace:
*
* ```text
* parent:child:not=exception-one, exception-two
* ```
*
* Only lowercase a-z letters, colon, comma, dash and whitespace are allowed in the selectors. An attempt
* to use a character outside of this set will result in a `SyntaxError`.
*/
export class BooleanSelector {
/**
* Helper selector that matches any identifier on any level.
*
* @example
* BooleanSelector.True.matches('anything') // => true
* BooleanSelector.True.zoom('thing').matches('stuff') // => true
*
* @returns `BooleanSelector` singleton
*/
static get True(): BooleanSelector {
return trueBooleanSelectorSingleton;
}

/**
* Helper selector that doesn't match any identifier on any level.
*
* @example
* BooleanSelector.False.matches('anything') // => false
* BooleanSelector.False.zoom('thing').matches('stuff') // => false
*
* @returns `BooleanSelector` singleton
*/
static get False(): BooleanSelector {
return falseBooleanSelectorSingleton;
}

/**
* Creates a `BooleanSelector` instance from an attribute value according to the following rules:
*
* - boolean selector constructed from a `null` value will always return `false` from `.allows()`;
* - if attribute value is empty or matches `truthyValue`, a boolean selector will always return `true` from `.allows()`;
* - in every other case attribute value will be parsed as boolean selector.
*
* @example
* const value = element.getAttribute('disabled');
* BooleanSelector.fromAttribute(value) // => [object BooleanSelector]
*
* @param value attribite value
* @param truthyValue additional attribute value that must be treated as truthy (use attribute name here to be spec-compliant)
* @returns `BooleanSelector` instance constructed from the given attribite value
*/
static fromAttribute(value: string | null, truthyValue?: string): BooleanSelector {
if (value === null) return BooleanSelector.False;
if (value === '' || value === truthyValue) return BooleanSelector.True;
return new BooleanSelector(value);
}


private static __processors: Record<Entity, Processor> = {
[Entity.List](output, character) {
/* istanbul ignore next */
if (Array.isArray(output.branch)) throw new SyntaxError('Paths are not allowed in sets.');

if (character === '=') {
if (output.buffer === 'not') {
const newBranch = output.branch[output.buffer] ?? [];

output.branch[output.buffer] = newBranch;
output.entity = Entity.Set;
output.branch = newBranch;
output.buffer = '';

return;
} else {
throw new SyntaxError(`Unknown modifier "${output.buffer}".`);
}
}

if (/^\s$/.test(character) || character === ':') {
const selector = output.buffer;
const newBranch = output.branch.only?.[selector] ?? {};

output.branch.only = { ...output.branch.only, [selector]: newBranch };
output.branch = /^\s$/.test(character) ? output.tree : newBranch;
output.buffer = '';
return;
}

if (/^[a-z]|-$/.test(character)) {
output.buffer += character;
return;
}

throw new SyntaxError(`Expected [a-z], "-", ":" or a whitespace, but got "${character}" instead.`);
},

[Entity.Set](output, character) {
/* istanbul ignore next */
if (!Array.isArray(output.branch)) throw new SyntaxError('Unexpected set item.');

if (output.buffer.length === 0 && /^\s$/.test(character)) return;

if (character === ',' || /^\s$/.test(character)) {
output.entity = character === ',' ? Entity.Set : Entity.List;
output.branch.push(output.buffer);
output.branch = character === ',' ? output.branch : output.tree;
output.buffer = '';
return;
}

if (/^[a-z]|-$/.test(character)) {
output.buffer += character;
return;
}

throw new SyntaxError(`Expected [a-z], "-", "," or a whitespace, but got "${character}" instead.`);
},
};

private __value: string;

private __tree: Tree;

/**
* Parses the boolean selector value and creates an instance
* of the `BooleanSelector` class.
*
* @param value boolean selector value, e.g. `foo:bar baz:not=qux`
*/
constructor(value: string) {
this.__value = value;
this.__tree = BooleanSelector.__parse(value);
}

/**
* Checks if current selector includes rules for the given top-level identifier.
*
* @example
* new BooleanSelector('foo:bar').matches('foo') // => true
* new BooleanSelector('foo:bar').matches('bar') // => false
*
* @param id identifier to look for
* @returns `true` is current selector includes rules for the given identifier
*/
matches(id: string): boolean {
return !!this.__tree.only?.[id] || this.__tree.not?.includes(id) === false;
}

/**
* Zooms on the given top-level identifier.
*
* @example
* new BooleanSelector('foo:bar:baz').zoom('foo').toString() // => "bar:baz"
*
* @param id identifier to look for
* @returns `true` is current selector includes rules for the given identifier
*/
zoom(id: string): BooleanSelector {
const subtree = this.__tree.only?.[id] ?? {};
return new BooleanSelector(BooleanSelector.__stringify(subtree));
}

/**
* Converts this selector to string.
*
* @example
* new BooleanSelector('foo:bar').toString() // => "foo:bar"
*
* @returns serialized representation of this selector
*/
toString(): string {
return this.__value;
}

/**
* Converts this selector to an attribute value.
*
* @example
* new BooleanSelector('foo:bar').toAttribute() // => "foo:bar"
* new BooleanSelector('').toAttribute() // => null
*
* @returns attribute value representing this selector.
*/
toAttribute(): string | null {
return this.__value.trim().length === 0 ? null : this.toString();
}

private static __stringify(tree: Tree, path = ''): string {
if (tree.only) {
return Object.entries(tree.only).reduce((output, [key, subtree]) => {
const result = BooleanSelector.__stringify(subtree, path.length === 0 ? key : `${path}:${key}`);
return output.length === 0 ? result : `${output} ${result}`;
}, '');
}

if (tree.not) return `${path.length === 0 ? '' : `${path}:`}not=${tree.not.join(',')}`;

return path;
}

private static __parse(value: string): Tree {
const tree = {};
const output: Output = { branch: tree, buffer: '', entity: Entity.List, tree };

Array.from(`${value} `).forEach((character, position) => {
try {
BooleanSelector.__processors[output.entity](output, character);
} catch (err) {
const hint = 'This error occured at: ';
const preview = value.substring(position - 30, position + 30);
const pointer = `${' '.repeat(hint.length + Math.min(preview.length, 29))}^`;

throw new SyntaxError([err.message, `${hint}${preview}`, pointer].join('\n'));
}
});

return tree;
}
}

class TrueBooleanSelector extends BooleanSelector {
matches(): boolean {
return true;
}

zoom(): this {
return this;
}

toAttribute(): string | null {
return '';
}
}

class FalseBooleanSelector extends BooleanSelector {
matches(): boolean {
return false;
}

zoom(): this {
return this;
}

toAttribute(): string | null {
return null;
}
}

const falseBooleanSelectorSingleton = new FalseBooleanSelector('');
const trueBooleanSelectorSingleton = new TrueBooleanSelector('');
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export type { Graph } from './Graph';

export * as Nucleon from './Nucleon/index.js';

export { BooleanSelector } from './BooleanSelector.js';
export { Rumour } from './Rumour/index.js';
export { API } from './API/index.js';
Loading

0 comments on commit 9e79afe

Please sign in to comment.