From fc9335ece41680599ead3562aafe92abd84cbd19 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Thu, 21 Dec 2023 13:20:07 +0100 Subject: [PATCH 01/16] Remove unused import --- packages/alfa-cascade/src/selector-map.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/alfa-cascade/src/selector-map.ts b/packages/alfa-cascade/src/selector-map.ts index c6546b37c0..c95efba4af 100644 --- a/packages/alfa-cascade/src/selector-map.ts +++ b/packages/alfa-cascade/src/selector-map.ts @@ -12,7 +12,6 @@ import { import { Iterable } from "@siteimprove/alfa-iterable"; import { Serializable } from "@siteimprove/alfa-json"; import { Media } from "@siteimprove/alfa-media"; -import { Option } from "@siteimprove/alfa-option"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Refinement } from "@siteimprove/alfa-refinement"; import { Combinator, Complex, Context } from "@siteimprove/alfa-selector"; From f73a1391fdb17e93b6ad11258a715b0ce2234a13 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Thu, 21 Dec 2023 13:20:21 +0100 Subject: [PATCH 02/16] Typo in function name --- packages/alfa-dom/src/style/rule/supports.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/alfa-dom/src/style/rule/supports.ts b/packages/alfa-dom/src/style/rule/supports.ts index 59a5b7e9d3..dd1d5031a3 100644 --- a/packages/alfa-dom/src/style/rule/supports.ts +++ b/packages/alfa-dom/src/style/rule/supports.ts @@ -36,7 +36,7 @@ export class SupportsRule extends ConditionRule<"supports"> { export namespace SupportsRule { export interface JSON extends ConditionRule.JSON<"supports"> {} - export function isSupportsRue(value: unknown): value is SupportsRule { + export function isSupportsRule(value: unknown): value is SupportsRule { return value instanceof SupportsRule; } From 95b7ca85dae7198c9d48f59461d2f801e835acd2 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Thu, 21 Dec 2023 14:14:49 +0100 Subject: [PATCH 03/16] Break down huge file a bit --- .changeset/light-mice-beg.md | 21 + packages/alfa-media/src/feature/feature.ts | 649 ++++++++++++++ packages/alfa-media/src/feature/index.ts | 1 + packages/alfa-media/src/index.ts | 4 + packages/alfa-media/src/matchable.ts | 9 + packages/alfa-media/src/media.ts | 998 +-------------------- packages/alfa-media/src/modifier.ts | 24 + packages/alfa-media/src/type.ts | 96 ++ packages/alfa-media/src/value/index.ts | 1 + packages/alfa-media/src/value/value.ts | 252 ++++++ packages/alfa-media/tsconfig.json | 7 + packages/alfa-rules/src/sia-r44/rule.ts | 4 +- packages/alfa-rules/src/sia-r83/rule.ts | 17 +- 13 files changed, 1084 insertions(+), 999 deletions(-) create mode 100644 .changeset/light-mice-beg.md create mode 100644 packages/alfa-media/src/feature/feature.ts create mode 100644 packages/alfa-media/src/feature/index.ts create mode 100644 packages/alfa-media/src/matchable.ts create mode 100644 packages/alfa-media/src/modifier.ts create mode 100644 packages/alfa-media/src/type.ts create mode 100644 packages/alfa-media/src/value/index.ts create mode 100644 packages/alfa-media/src/value/value.ts diff --git a/.changeset/light-mice-beg.md b/.changeset/light-mice-beg.md new file mode 100644 index 0000000000..57f127a007 --- /dev/null +++ b/.changeset/light-mice-beg.md @@ -0,0 +1,21 @@ +--- +"@siteimprove/alfa-media": minor +--- + +**Breaking:** Names `Feature`, `Modifier`, `Type`, `Value` are now directly exported by the package. + +That is, replace + +```typescript +import { Media } from "@siteimprove/alfa-media"; +declare; +x: Media.Feature; +``` + +with + +```typescript +import { Feature as MediaFeature } from "@siteimprove/alfa-media"; +declare; +x: MediaFeature; +``` diff --git a/packages/alfa-media/src/feature/feature.ts b/packages/alfa-media/src/feature/feature.ts new file mode 100644 index 0000000000..39068499df --- /dev/null +++ b/packages/alfa-media/src/feature/feature.ts @@ -0,0 +1,649 @@ +import { + Keyword, + Length, + Number, + Percentage, + Token, +} from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { Equatable } from "@siteimprove/alfa-equatable"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import * as json from "@siteimprove/alfa-json"; +import { Serializable } from "@siteimprove/alfa-json"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Refinement } from "@siteimprove/alfa-refinement"; +import { Err, Ok, Result } from "@siteimprove/alfa-result"; + +import type { Matchable } from "../matchable"; +import { Value } from "../value"; +import { Resolver } from "../resolver"; + +const { + delimited, + either, + filter, + map, + mapResult, + option, + pair, + right, + separated, +} = Parser; +const { equals, property } = Predicate; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#mq-features} + */ +export abstract class Feature + implements + Matchable, + Iterable>, + Equatable, + Serializable +{ + protected readonly _value: Option>; + + protected constructor(value: Option>) { + this._value = value; + } + + public abstract get name(): string; + + public get value(): Option> { + return this._value; + } + + public abstract matches(device: Device): boolean; + + public equals(value: unknown): value is this { + return ( + value instanceof Feature && + value.name === this.name && + value._value.equals(this._value) + ); + } + + public *iterator(): Iterator> { + yield this; + } + + public [Symbol.iterator](): Iterator> { + return this.iterator(); + } + + public toJSON(): Feature.JSON { + return { + type: "feature", + name: this.name, + value: this._value.map((value) => value.toJSON()).getOr(null), + }; + } + + public toString(): string { + return `${this.name}${this._value.map((value) => `: ${value}`).getOr("")}`; + } +} + +export namespace Feature { + export interface JSON { + [key: string]: json.JSON; + type: "feature"; + name: string; + value: Value.JSON | null; + } + + export function tryFrom( + value: Option>, + name: string, + ): Result { + switch (name) { + case "width": + return Width.tryFrom(value); + + case "height": + return Height.tryFrom(value); + + case "orientation": + return Orientation.tryFrom(value); + + case "scripting": + return Scripting.tryFrom(value); + } + + return Err.of(`Unknown media feature ${name}`); + } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#width} + */ + class Width extends Feature { + public static of(value: Value): Width { + return new Width(Option.of(value)); + } + + private static _boolean = new Width(None); + + public static boolean(): Width { + return Width._boolean; + } + + public get name(): "width" { + return "width"; + } + + public matches(device: Device): boolean { + const { + viewport: { width }, + } = device; + + const value = this._value.map((value) => + value.map((length) => length.resolve(Resolver.length(device))), + ); + + return width > 0 + ? value.some((value) => value.matches(Length.of(width, "px"))) + : value.every((value) => value.matches(Length.of(0, "px"))); + } + } + + namespace Width { + export function tryFrom(value: Option): Result { + return value + .map((value) => (Value.Range.isRange(value) ? value.toLength() : value)) + .map((value) => { + if ( + value.hasValue(Length.isLength) && + value.hasValue( + (value): value is Length.Fixed => !value.hasCalculation(), + ) + ) { + return Ok.of(Width.of(value)); + } + + return Err.of(`Invalid value`); + }) + .getOrElse(() => Ok.of(Width.boolean())); + } + + export function isWidth(value: Feature): value is Width; + + export function isWidth(value: unknown): value is Width; + + export function isWidth(value: unknown): value is Width { + return value instanceof Width; + } + } + + export const { isWidth } = Width; + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#height} + */ + class Height extends Feature { + public static of(value: Value): Height { + return new Height(Option.of(value)); + } + + private static _boolean = new Height(None); + + public static boolean(): Height { + return Height._boolean; + } + + public get name(): "height" { + return "height"; + } + + public matches(device: Device): boolean { + const { + viewport: { height }, + } = device; + + const value = this._value.map((value) => + value.map((length) => length.resolve(Resolver.length(device))), + ); + + return height > 0 + ? value.some((value) => value.matches(Length.of(height, "px"))) + : value.every((value) => value.matches(Length.of(0, "px"))); + } + } + + namespace Height { + export function tryFrom(value: Option): Result { + return value + .map((value) => (Value.Range.isRange(value) ? value.toLength() : value)) + .map((value) => { + if ( + value.hasValue(Length.isLength) && + value.hasValue( + (value): value is Length.Fixed => !value.hasCalculation(), + ) + ) { + return Ok.of(Height.of(value)); + } + + return Err.of(`Invalid value`); + }) + .getOrElse(() => Ok.of(Height.boolean())); + } + + export function isHeight(value: Feature): value is Height; + + export function isHeight(value: unknown): value is Height; + + export function isHeight(value: unknown): value is Height { + return value instanceof Height; + } + } + + export const { isHeight } = Height; + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#orientation} + */ + class Orientation extends Feature { + public static of(value: Value): Orientation { + return new Orientation(Option.of(value)); + } + + private static _boolean = new Orientation(None); + + public static boolean(): Orientation { + return Orientation._boolean; + } + + public get name(): "orientation" { + return "orientation"; + } + + public matches(device: Device): boolean { + return this._value.every((value) => + value.matches(Keyword.of(device.viewport.orientation)), + ); + } + } + + namespace Orientation { + export function tryFrom( + value: Option>, + ): Result { + return value + .map((value) => { + if ( + Value.isDiscrete(value) && + value.hasValue( + Refinement.and( + Keyword.isKeyword, + property("value", equals("landscape", "portrait")), + ), + ) + ) { + return Ok.of(Orientation.of(value)); + } else { + return Err.of(`Invalid value`); + } + }) + .getOrElse(() => Ok.of(Orientation.boolean())); + } + } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#scripting} + */ + class Scripting extends Feature { + public static of(value: Value): Scripting { + return new Scripting(Option.of(value)); + } + + private static _boolean = new Scripting(None); + + public static boolean(): Scripting { + return Scripting._boolean; + } + + public get name(): "scripting" { + return "scripting"; + } + + public matches(device: Device): boolean { + return device.scripting.enabled + ? this._value.every((value) => value.matches(Keyword.of("enabled"))) + : this._value.some((value) => value.matches(Keyword.of("none"))); + } + } + + namespace Scripting { + export function tryFrom( + value: Option>, + ): Result { + return value + .map((value) => { + if ( + Value.isDiscrete(value) && + value.hasValue( + Refinement.and( + Keyword.isKeyword, + property("value", equals("none", "enabled", "initial-only")), + ), + ) + ) { + return Ok.of(Scripting.of(value)); + } else { + return Err.of(`Invalid value`); + } + }) + .getOrElse(() => Ok.of(Scripting.boolean())); + } + } + + export function isFeature(value: unknown): value is Feature { + return value instanceof Feature; + } +} + +export enum Comparison { + LessThan = "<", + LessThanOrEqual = "<=", + Equal = "=", + GreaterThan = ">", + GreaterThanOrEqual = ">=", +} + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-name} + */ +const parseFeatureName = map(Token.parseIdent(), (ident) => + ident.value.toLowerCase(), +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-value} + * + * @remarks + * We currently do not support calculations in media queries + */ +const parseFeatureValue = either( + either( + filter( + Number.parse, + (number) => !number.hasCalculation(), + () => "Calculations no supported in media queries", + ), + map(Token.parseIdent(), (ident) => Keyword.of(ident.value.toLowerCase())), + ), + either( + map( + separated( + Token.parseNumber((number) => number.isInteger), + delimited(option(Token.parseWhitespace), Token.parseDelim("/")), + Token.parseNumber((number) => number.isInteger), + ), + ([left, right]) => Percentage.of(left.value / right.value), + ), + filter( + Length.parse, + (length) => !length.hasCalculation(), + () => "Calculations no supported in media queries", + ), + ), +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-plain} + */ +const parseFeaturePlain = mapResult( + separated( + parseFeatureName, + delimited(option(Token.parseWhitespace), Token.parseColon), + parseFeatureValue, + ), + ([name, value]) => { + if (name.startsWith("min-") || name.startsWith("max-")) { + const range = name.startsWith("min-") + ? Value.minimumRange + : Value.maximumRange; + + name = name.slice(4); + + return Feature.tryFrom( + Option.of(range(Value.bound(value, /* isInclusive */ true))), + name, + ); + } else { + return Feature.tryFrom(Option.of(Value.discrete(value)), name); + } + }, +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-boolean} + */ +const parseFeatureBoolean = mapResult(parseFeatureName, (name) => + Feature.tryFrom(None, name), +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-lt} + */ +const parseFeatureLessThan = map( + right(Token.parseDelim("<"), option(Token.parseDelim("="))), + (equal) => + equal.isNone() ? Comparison.LessThan : Comparison.LessThanOrEqual, +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-gt} + */ +const parseFeatureGreaterThan = map( + right(Token.parseDelim(">"), option(Token.parseDelim("="))), + (equal) => + equal.isNone() ? Comparison.GreaterThan : Comparison.GreaterThanOrEqual, +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-eq} + */ +const parseFeatureEqual = map(Token.parseDelim("="), () => Comparison.Equal); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-comparison} + */ +const parseFeatureComparison = either( + parseFeatureEqual, + parseFeatureLessThan, + parseFeatureGreaterThan, +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-range} + */ +const parseFeatureRange = either( + // + mapResult( + pair( + map( + pair( + parseFeatureValue, + delimited(option(Token.parseWhitespace), parseFeatureLessThan), + ), + ([value, comparison]) => + Value.bound( + value, + /* isInclusive */ comparison === Comparison.LessThanOrEqual, + ), + ), + pair( + delimited(option(Token.parseWhitespace), parseFeatureName), + map( + pair( + delimited(option(Token.parseWhitespace), parseFeatureLessThan), + parseFeatureValue, + ), + ([comparison, value]) => + Value.bound( + value, + /* isInclusive */ comparison === Comparison.LessThanOrEqual, + ), + ), + ), + ), + ([minimum, [name, maximum]]) => + Feature.tryFrom(Option.of(Value.range(minimum, maximum)), name), + ), + + // + mapResult( + pair( + map( + pair( + parseFeatureValue, + delimited(option(Token.parseWhitespace), parseFeatureGreaterThan), + ), + ([value, comparison]) => + Value.bound( + value, + /* isInclusive */ comparison === Comparison.GreaterThanOrEqual, + ), + ), + pair( + delimited(option(Token.parseWhitespace), parseFeatureName), + map( + pair( + delimited(option(Token.parseWhitespace), parseFeatureGreaterThan), + parseFeatureValue, + ), + ([comparison, value]) => + Value.bound( + value, + /* isInclusive */ comparison === Comparison.GreaterThanOrEqual, + ), + ), + ), + ), + ([maximum, [name, minimum]]) => + Feature.tryFrom(Option.of(Value.range(minimum, maximum)), name), + ), + + // + mapResult( + pair( + parseFeatureName, + pair( + delimited(option(Token.parseWhitespace), parseFeatureComparison), + parseFeatureValue, + ), + ), + ([name, [comparison, value]]) => { + switch (comparison) { + case Comparison.Equal: + return Feature.tryFrom( + Option.of( + Value.range( + Value.bound(value, /* isInclude */ true), + Value.bound(value, /* isInclude */ true), + ), + ), + name, + ); + + case Comparison.LessThan: + case Comparison.LessThanOrEqual: + return Feature.tryFrom( + Option.of( + Value.maximumRange( + Value.bound( + value, + /* isInclusive */ comparison === Comparison.LessThanOrEqual, + ), + ), + ), + name, + ); + + case Comparison.GreaterThan: + case Comparison.GreaterThanOrEqual: + return Feature.tryFrom( + Option.of( + Value.minimumRange( + Value.bound( + value, + /* isInclusive */ comparison === + Comparison.GreaterThanOrEqual, + ), + ), + ), + name, + ); + } + }, + ), + + // + mapResult( + pair( + parseFeatureValue, + pair( + delimited(option(Token.parseWhitespace), parseFeatureComparison), + parseFeatureName, + ), + ), + ([value, [comparison, name]]) => { + switch (comparison) { + case Comparison.Equal: + return Feature.tryFrom( + Option.of( + Value.range( + Value.bound(value, /* isInclude */ true), + Value.bound(value, /* isInclude */ true), + ), + ), + name, + ); + + case Comparison.LessThan: + case Comparison.LessThanOrEqual: + return Feature.tryFrom( + Option.of( + Value.minimumRange( + Value.bound( + value, + /* isInclusive */ comparison === Comparison.LessThanOrEqual, + ), + ), + ), + name, + ); + + case Comparison.GreaterThan: + case Comparison.GreaterThanOrEqual: + return Feature.tryFrom( + Option.of( + Value.maximumRange( + Value.bound( + value, + /* isInclusive */ comparison === + Comparison.GreaterThanOrEqual, + ), + ), + ), + name, + ); + } + }, + ), +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-feature} + */ +export const parseMediaFeature = delimited( + Token.parseOpenParenthesis, + delimited( + option(Token.parseWhitespace), + either(parseFeatureRange, parseFeaturePlain, parseFeatureBoolean), + ), + Token.parseCloseParenthesis, +); diff --git a/packages/alfa-media/src/feature/index.ts b/packages/alfa-media/src/feature/index.ts new file mode 100644 index 0000000000..d0d5122433 --- /dev/null +++ b/packages/alfa-media/src/feature/index.ts @@ -0,0 +1 @@ +export * from "./feature"; diff --git a/packages/alfa-media/src/index.ts b/packages/alfa-media/src/index.ts index fa8208cd59..64149d2d25 100644 --- a/packages/alfa-media/src/index.ts +++ b/packages/alfa-media/src/index.ts @@ -1 +1,5 @@ +export * from "./feature"; export * from "./media"; +export * from "./modifier"; +export * from "./type"; +export * from "./value"; diff --git a/packages/alfa-media/src/matchable.ts b/packages/alfa-media/src/matchable.ts new file mode 100644 index 0000000000..83b3109f95 --- /dev/null +++ b/packages/alfa-media/src/matchable.ts @@ -0,0 +1,9 @@ +import { Device } from "@siteimprove/alfa-device"; +import { Predicate } from "@siteimprove/alfa-predicate"; + +/** + * @public + */ +export interface Matchable { + readonly matches: Predicate; +} diff --git a/packages/alfa-media/src/media.ts b/packages/alfa-media/src/media.ts index b79fa02f0f..97247049e9 100644 --- a/packages/alfa-media/src/media.ts +++ b/packages/alfa-media/src/media.ts @@ -1,1024 +1,42 @@ -import { Comparable } from "@siteimprove/alfa-comparable"; -import { - Length, - Keyword, - Number, - Percentage, - Token, - Component, -} from "@siteimprove/alfa-css"; +import { Token, Component } from "@siteimprove/alfa-css"; import { Device } from "@siteimprove/alfa-device"; import { Equatable } from "@siteimprove/alfa-equatable"; -import { Functor } from "@siteimprove/alfa-functor"; import { Iterable } from "@siteimprove/alfa-iterable"; import { Serializable } from "@siteimprove/alfa-json"; -import { Mapper } from "@siteimprove/alfa-mapper"; import { Option, None } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; -import { Predicate } from "@siteimprove/alfa-predicate"; -import { Refinement } from "@siteimprove/alfa-refinement"; -import { Result, Err, Ok } from "@siteimprove/alfa-result"; import { Slice } from "@siteimprove/alfa-slice"; import * as json from "@siteimprove/alfa-json"; -import { Resolver } from "./resolver"; +import { Feature, parseMediaFeature } from "./feature"; +import type { Matchable } from "./matchable"; +import { Modifier } from "./modifier"; +import { Type } from "./type"; const { delimited, either, end, - filter, left, map, - mapResult, oneOrMore, option, pair, right, - separated, separatedList, takeUntil, zeroOrMore, } = Parser; -const { property, equals } = Predicate; - /** * @public */ export namespace Media { - /** - * {@link https://drafts.csswg.org/mediaqueries/#media-query-modifier} - */ - export enum Modifier { - Only = "only", - Not = "not", - } - - const parseModifier = either( - map(Token.parseIdent("only"), () => Modifier.Only), - map(Token.parseIdent("not"), () => Modifier.Not), - ); - - interface Matchable { - readonly matches: Predicate; - } - - /** - * {@link https://drafts.csswg.org/mediaqueries/#media-type} - */ - export class Type implements Matchable, Equatable, Serializable { - public static of(name: string): Type { - return new Type(name); - } - - private readonly _name: string; - - private constructor(name: string) { - this._name = name; - } - - public get name(): string { - return this._name; - } - - public matches(device: Device): boolean { - switch (this._name) { - case "screen": - return device.type === Device.Type.Screen; - - case "print": - return device.type === Device.Type.Print; - - case "speech": - return device.type === Device.Type.Speech; - - case "all": - return true; - - default: - return false; - } - } - - public equals(value: unknown): value is this { - return value instanceof Type && value._name === this._name; - } - - public toJSON(): Type.JSON { - return { - name: this._name, - }; - } - - public toString(): string { - return this._name; - } - } - - export namespace Type { - export interface JSON { - [key: string]: json.JSON; - name: string; - } - - export function isType(value: unknown): value is Type { - return value instanceof Type; - } - } - export const { of: type, isType } = Type; - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-media-type} - */ - const parseType = map( - Token.parseIdent((ident) => { - switch (ident.value) { - // These values are not allowed as media types. - case "only": - case "not": - case "and": - case "or": - return false; - - default: - return true; - } - }), - (ident) => Type.of(ident.value), - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#media-feature} - */ - export abstract class Feature - implements - Matchable, - Iterable>, - Equatable, - Serializable - { - protected readonly _value: Option>; - - protected constructor(value: Option>) { - this._value = value; - } - - public abstract get name(): string; - - public get value(): Option> { - return this._value; - } - - public abstract matches(device: Device): boolean; - - public equals(value: unknown): value is this { - return ( - value instanceof Feature && - value.name === this.name && - value._value.equals(this._value) - ); - } - - public *iterator(): Iterator> { - yield this; - } - - public [Symbol.iterator](): Iterator> { - return this.iterator(); - } - - public toJSON(): Feature.JSON { - return { - type: "feature", - name: this.name, - value: this._value.map((value) => value.toJSON()).getOr(null), - }; - } - - public toString(): string { - return `${this.name}${this._value - .map((value) => `: ${value}`) - .getOr("")}`; - } - } - - export namespace Feature { - export interface JSON { - [key: string]: json.JSON; - type: "feature"; - name: string; - value: Value.JSON | null; - } - - export function tryFrom( - value: Option>, - name: string, - ): Result { - switch (name) { - case "width": - return Width.tryFrom(value); - - case "height": - return Height.tryFrom(value); - - case "orientation": - return Orientation.tryFrom(value); - - case "scripting": - return Scripting.tryFrom(value); - } - - return Err.of(`Unknown media feature ${name}`); - } - - /** - * {@link https://drafts.csswg.org/mediaqueries/#width} - */ - class Width extends Feature { - public static of(value: Value): Width { - return new Width(Option.of(value)); - } - - private static _boolean = new Width(None); - - public static boolean(): Width { - return Width._boolean; - } - - public get name(): "width" { - return "width"; - } - - public matches(device: Device): boolean { - const { - viewport: { width }, - } = device; - - const value = this._value.map((value) => - value.map((length) => length.resolve(Resolver.length(device))), - ); - - return width > 0 - ? value.some((value) => value.matches(Length.of(width, "px"))) - : value.every((value) => value.matches(Length.of(0, "px"))); - } - } - - namespace Width { - export function tryFrom(value: Option): Result { - return value - .map((value) => - Value.Range.isRange(value) ? value.toLength() : value, - ) - .map((value) => { - if ( - value.hasValue(Length.isLength) && - value.hasValue( - (value): value is Length.Fixed => !value.hasCalculation(), - ) - ) { - return Ok.of(Width.of(value)); - } - - return Err.of(`Invalid value`); - }) - .getOrElse(() => Ok.of(Width.boolean())); - } - - export function isWidth(value: Feature): value is Width; - - export function isWidth(value: unknown): value is Width; - - export function isWidth(value: unknown): value is Width { - return value instanceof Width; - } - } - - export const { isWidth } = Width; - - /** - * {@link https://drafts.csswg.org/mediaqueries/#height} - */ - class Height extends Feature { - public static of(value: Value): Height { - return new Height(Option.of(value)); - } - - private static _boolean = new Height(None); - - public static boolean(): Height { - return Height._boolean; - } - - public get name(): "height" { - return "height"; - } - - public matches(device: Device): boolean { - const { - viewport: { height }, - } = device; - - const value = this._value.map((value) => - value.map((length) => length.resolve(Resolver.length(device))), - ); - - return height > 0 - ? value.some((value) => value.matches(Length.of(height, "px"))) - : value.every((value) => value.matches(Length.of(0, "px"))); - } - } - - namespace Height { - export function tryFrom(value: Option): Result { - return value - .map((value) => - Value.Range.isRange(value) ? value.toLength() : value, - ) - .map((value) => { - if ( - value.hasValue(Length.isLength) && - value.hasValue( - (value): value is Length.Fixed => !value.hasCalculation(), - ) - ) { - return Ok.of(Height.of(value)); - } - - return Err.of(`Invalid value`); - }) - .getOrElse(() => Ok.of(Height.boolean())); - } - - export function isHeight(value: Feature): value is Height; - - export function isHeight(value: unknown): value is Height; - - export function isHeight(value: unknown): value is Height { - return value instanceof Height; - } - } - - export const { isHeight } = Height; - - /** - * {@link https://drafts.csswg.org/mediaqueries/#orientation} - */ - class Orientation extends Feature { - public static of(value: Value): Orientation { - return new Orientation(Option.of(value)); - } - - private static _boolean = new Orientation(None); - - public static boolean(): Orientation { - return Orientation._boolean; - } - - public get name(): "orientation" { - return "orientation"; - } - - public matches(device: Device): boolean { - return this._value.every((value) => - value.matches(Keyword.of(device.viewport.orientation)), - ); - } - } - - namespace Orientation { - export function tryFrom( - value: Option>, - ): Result { - return value - .map((value) => { - if ( - Value.isDiscrete(value) && - value.hasValue( - Refinement.and( - Keyword.isKeyword, - property("value", equals("landscape", "portrait")), - ), - ) - ) { - return Ok.of(Orientation.of(value)); - } else { - return Err.of(`Invalid value`); - } - }) - .getOrElse(() => Ok.of(Orientation.boolean())); - } - } - - /** - * {@link https://drafts.csswg.org/mediaqueries-5/#scripting} - */ - class Scripting extends Feature { - public static of(value: Value): Scripting { - return new Scripting(Option.of(value)); - } - - private static _boolean = new Scripting(None); - - public static boolean(): Scripting { - return Scripting._boolean; - } - - public get name(): "scripting" { - return "scripting"; - } - - public matches(device: Device): boolean { - return device.scripting.enabled - ? this._value.every((value) => value.matches(Keyword.of("enabled"))) - : this._value.some((value) => value.matches(Keyword.of("none"))); - } - } - - namespace Scripting { - export function tryFrom( - value: Option>, - ): Result { - return value - .map((value) => { - if ( - Value.isDiscrete(value) && - value.hasValue( - Refinement.and( - Keyword.isKeyword, - property("value", equals("none", "enabled", "initial-only")), - ), - ) - ) { - return Ok.of(Scripting.of(value)); - } else { - return Err.of(`Invalid value`); - } - }) - .getOrElse(() => Ok.of(Scripting.boolean())); - } - } - - export function isFeature(value: unknown): value is Feature { - return value instanceof Feature; - } - } - export const { isFeature } = Feature; - export enum Comparison { - LessThan = "<", - LessThanOrEqual = "<=", - Equal = "=", - GreaterThan = ">", - GreaterThanOrEqual = ">=", - } - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-mf-name} - */ - const parseFeatureName = map(Token.parseIdent(), (ident) => - ident.value.toLowerCase(), - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-mf-value} - * - * @remarks - * We currently do not support calculations in media queries - */ - const parseFeatureValue = either( - either( - filter( - Number.parse, - (number) => !number.hasCalculation(), - () => "Calculations no supported in media queries", - ), - map(Token.parseIdent(), (ident) => Keyword.of(ident.value.toLowerCase())), - ), - either( - map( - separated( - Token.parseNumber((number) => number.isInteger), - delimited(option(Token.parseWhitespace), Token.parseDelim("/")), - Token.parseNumber((number) => number.isInteger), - ), - ([left, right]) => Percentage.of(left.value / right.value), - ), - filter( - Length.parse, - (length) => !length.hasCalculation(), - () => "Calculations no supported in media queries", - ), - ), - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-mf-plain} - */ - const parseFeaturePlain = mapResult( - separated( - parseFeatureName, - delimited(option(Token.parseWhitespace), Token.parseColon), - parseFeatureValue, - ), - ([name, value]) => { - if (name.startsWith("min-") || name.startsWith("max-")) { - const range = name.startsWith("min-") - ? Value.minimumRange - : Value.maximumRange; - - name = name.slice(4); - - return Feature.tryFrom( - Option.of(range(Value.bound(value, /* isInclusive */ true))), - name, - ); - } else { - return Feature.tryFrom(Option.of(Value.discrete(value)), name); - } - }, - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-mf-boolean} - */ - const parseFeatureBoolean = mapResult(parseFeatureName, (name) => - Feature.tryFrom(None, name), - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-mf-lt} - */ - const parseFeatureLessThan = map( - right(Token.parseDelim("<"), option(Token.parseDelim("="))), - (equal) => - equal.isNone() ? Comparison.LessThan : Comparison.LessThanOrEqual, - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-mf-gt} - */ - const parseFeatureGreaterThan = map( - right(Token.parseDelim(">"), option(Token.parseDelim("="))), - (equal) => - equal.isNone() ? Comparison.GreaterThan : Comparison.GreaterThanOrEqual, - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-mf-eq} - */ - const parseFeatureEqual = map(Token.parseDelim("="), () => Comparison.Equal); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-mf-comparison} - */ - const parseFeatureComparison = either( - parseFeatureEqual, - parseFeatureLessThan, - parseFeatureGreaterThan, - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-mf-range} - */ - const parseFeatureRange = either( - // - mapResult( - pair( - map( - pair( - parseFeatureValue, - delimited(option(Token.parseWhitespace), parseFeatureLessThan), - ), - ([value, comparison]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.LessThanOrEqual, - ), - ), - pair( - delimited(option(Token.parseWhitespace), parseFeatureName), - map( - pair( - delimited(option(Token.parseWhitespace), parseFeatureLessThan), - parseFeatureValue, - ), - ([comparison, value]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.LessThanOrEqual, - ), - ), - ), - ), - ([minimum, [name, maximum]]) => - Feature.tryFrom(Option.of(Value.range(minimum, maximum)), name), - ), - - // - mapResult( - pair( - map( - pair( - parseFeatureValue, - delimited(option(Token.parseWhitespace), parseFeatureGreaterThan), - ), - ([value, comparison]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.GreaterThanOrEqual, - ), - ), - pair( - delimited(option(Token.parseWhitespace), parseFeatureName), - map( - pair( - delimited(option(Token.parseWhitespace), parseFeatureGreaterThan), - parseFeatureValue, - ), - ([comparison, value]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.GreaterThanOrEqual, - ), - ), - ), - ), - ([maximum, [name, minimum]]) => - Feature.tryFrom(Option.of(Value.range(minimum, maximum)), name), - ), - - // - mapResult( - pair( - parseFeatureName, - pair( - delimited(option(Token.parseWhitespace), parseFeatureComparison), - parseFeatureValue, - ), - ), - ([name, [comparison, value]]) => { - switch (comparison) { - case Comparison.Equal: - return Feature.tryFrom( - Option.of( - Value.range( - Value.bound(value, /* isInclude */ true), - Value.bound(value, /* isInclude */ true), - ), - ), - name, - ); - - case Comparison.LessThan: - case Comparison.LessThanOrEqual: - return Feature.tryFrom( - Option.of( - Value.maximumRange( - Value.bound( - value, - /* isInclusive */ comparison === Comparison.LessThanOrEqual, - ), - ), - ), - name, - ); - - case Comparison.GreaterThan: - case Comparison.GreaterThanOrEqual: - return Feature.tryFrom( - Option.of( - Value.minimumRange( - Value.bound( - value, - /* isInclusive */ comparison === - Comparison.GreaterThanOrEqual, - ), - ), - ), - name, - ); - } - }, - ), - - // - mapResult( - pair( - parseFeatureValue, - pair( - delimited(option(Token.parseWhitespace), parseFeatureComparison), - parseFeatureName, - ), - ), - ([value, [comparison, name]]) => { - switch (comparison) { - case Comparison.Equal: - return Feature.tryFrom( - Option.of( - Value.range( - Value.bound(value, /* isInclude */ true), - Value.bound(value, /* isInclude */ true), - ), - ), - name, - ); - - case Comparison.LessThan: - case Comparison.LessThanOrEqual: - return Feature.tryFrom( - Option.of( - Value.minimumRange( - Value.bound( - value, - /* isInclusive */ comparison === Comparison.LessThanOrEqual, - ), - ), - ), - name, - ); - - case Comparison.GreaterThan: - case Comparison.GreaterThanOrEqual: - return Feature.tryFrom( - Option.of( - Value.maximumRange( - Value.bound( - value, - /* isInclusive */ comparison === - Comparison.GreaterThanOrEqual, - ), - ), - ), - name, - ); - } - }, - ), - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-media-feature} - */ - const parseFeature = delimited( - Token.parseOpenParenthesis, - delimited( - option(Token.parseWhitespace), - either(parseFeatureRange, parseFeaturePlain, parseFeatureBoolean), - ), - Token.parseCloseParenthesis, - ); - - export interface Value - extends Functor, - Serializable { - map(mapper: Mapper): Value; - matches(value: T): boolean; - hasValue(refinement: Refinement): this is Value; - toJSON(): Value.JSON; - } - - export namespace Value { - export interface JSON { - [key: string]: json.JSON; - type: string; - } - - export class Discrete - implements Value, Serializable> - { - public static of(value: T): Discrete { - return new Discrete(value); - } - - private readonly _value: T; - - private constructor(value: T) { - this._value = value; - } - - public get value(): T { - return this._value; - } - - public map(mapper: Mapper): Discrete { - return new Discrete(mapper(this._value)); - } - - public matches(value: T): boolean { - return Equatable.equals(this._value, value); - } - - public hasValue( - refinement: Refinement, - ): this is Discrete { - return refinement(this._value); - } - - public toJSON(): Discrete.JSON { - return { - type: "discrete", - value: Serializable.toJSON(this._value), - }; - } - } - - export namespace Discrete { - export interface JSON { - [key: string]: json.JSON; - type: "discrete"; - value: Serializable.ToJSON; - } - - export function isDiscrete(value: unknown): value is Discrete { - return value instanceof Discrete; - } - } - - export const { of: discrete, isDiscrete } = Discrete; - - export class Range - implements Value, Serializable> - { - public static of(minimum: Bound, maximum: Bound): Range { - return new Range(Option.of(minimum), Option.of(maximum)); - } - - public static minimum(minimum: Bound): Range { - return new Range(Option.of(minimum), None); - } - - public static maximum(maximum: Bound): Range { - return new Range(None, Option.of(maximum)); - } - - private readonly _minimum: Option>; - private readonly _maximum: Option>; - - private constructor( - minimum: Option>, - maximum: Option>, - ) { - this._minimum = minimum; - this._maximum = maximum; - } - - public get minimum(): Option> { - return this._minimum; - } - - public get maximum(): Option> { - return this._maximum; - } - - public map(mapper: Mapper): Range { - return new Range( - this._minimum.map((bound) => bound.map(mapper)), - this._maximum.map((bound) => bound.map(mapper)), - ); - } - - public toLength(): Range> { - return this.map((bound) => - Refinement.and( - Number.isNumber, - (value) => !value.hasCalculation() && value.value === 0, - )(bound) - ? Length.of(0, "px") - : bound, - ); - } - - public matches(value: T): boolean { - if (!Comparable.isComparable(value)) { - return false; - } - - // Since we need to match both bounds, we return false if one is not - // matched and keep true for the default return at the end. - for (const minimum of this._minimum) { - if (minimum.isInclusive) { - // value is inclusively larger than the minimum if it is not - // strictly smaller than it. - if (value.compare(minimum.value) < 0) { - return false; - } - } else { - if (value.compare(minimum.value) <= 0) { - return false; - } - } - } - - for (const maximum of this._maximum) { - if (maximum.isInclusive) { - if (value.compare(maximum.value) > 0) { - return false; - } - } else { - if (value.compare(maximum.value) >= 0) { - return false; - } - } - } - - return true; - } - - public hasValue( - refinement: Refinement, - ): this is Discrete { - return ( - this._minimum.every((bound) => refinement(bound.value)) && - this._maximum.every((bound) => refinement(bound.value)) - ); - } - - public toJSON(): Range.JSON { - return { - type: "range", - minimum: this._minimum.map((bound) => bound.toJSON()).getOr(null), - maximum: this._maximum.map((bound) => bound.toJSON()).getOr(null), - }; - } - } - - export namespace Range { - export interface JSON { - [key: string]: json.JSON; - type: "range"; - minimum: Bound.JSON | null; - maximum: Bound.JSON | null; - } - - export function isRange(value: unknown): value is Range { - return value instanceof Range; - } - } - - export const { - of: range, - minimum: minimumRange, - maximum: maximumRange, - isRange, - } = Range; - - export class Bound - implements Functor, Serializable> - { - public static of(value: T, isInclusive: boolean): Bound { - return new Bound(value, isInclusive); - } - - private readonly _value: T; - private readonly _isInclusive: boolean; - - private constructor(value: T, isInclusive: boolean) { - this._value = value; - this._isInclusive = isInclusive; - } - - public get value(): T { - return this._value; - } - - public get isInclusive(): boolean { - return this._isInclusive; - } - - public map(mapper: Mapper): Bound { - return new Bound(mapper(this._value), this._isInclusive); - } - - public hasValue( - refinement: Refinement, - ): this is Bound { - return refinement(this._value); - } - - public toJSON(): Bound.JSON { - return { - value: Serializable.toJSON(this._value), - isInclusive: this._isInclusive, - }; - } - } - - export namespace Bound { - export interface JSON { - [key: string]: json.JSON; - value: Serializable.ToJSON; - isInclusive: boolean; - } - } - - export const { of: bound } = Bound; - } - export class And implements Matchable, Iterable, Equatable, Serializable { @@ -1263,7 +281,7 @@ export namespace Media { ), Token.parseCloseParenthesis, ), - parseFeature, + parseMediaFeature, ); /** @@ -1440,8 +458,8 @@ export namespace Media { map( pair( pair( - option(delimited(option(Token.parseWhitespace), parseModifier)), - parseType, + option(delimited(option(Token.parseWhitespace), Modifier.parse)), + Type.parse, ), option( right( diff --git a/packages/alfa-media/src/modifier.ts b/packages/alfa-media/src/modifier.ts new file mode 100644 index 0000000000..f27fbf5dab --- /dev/null +++ b/packages/alfa-media/src/modifier.ts @@ -0,0 +1,24 @@ +import { type Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; + +const { either, map } = Parser; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#mq-prefix} + * + * @public + */ +export enum Modifier { + Only = "only", + Not = "not", +} + +/** + * @public + */ +export namespace Modifier { + export const parse: CSSParser = either( + map(Token.parseIdent("only"), () => Modifier.Only), + map(Token.parseIdent("not"), () => Modifier.Not), + ); +} diff --git a/packages/alfa-media/src/type.ts b/packages/alfa-media/src/type.ts new file mode 100644 index 0000000000..33877a3060 --- /dev/null +++ b/packages/alfa-media/src/type.ts @@ -0,0 +1,96 @@ +import { Token } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import type { Equatable } from "@siteimprove/alfa-equatable"; +import type { Serializable } from "@siteimprove/alfa-json"; + +import * as json from "@siteimprove/alfa-json"; +import { Parser } from "@siteimprove/alfa-parser"; + +import type { Matchable } from "./matchable"; + +const { map } = Parser; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#media-types} + * + * @public + */ +export class Type implements Matchable, Equatable, Serializable { + public static of(name: string): Type { + return new Type(name); + } + + private readonly _name: string; + + private constructor(name: string) { + this._name = name; + } + + public get name(): string { + return this._name; + } + + public matches(device: Device): boolean { + switch (this._name) { + case "screen": + return device.type === Device.Type.Screen; + + case "print": + return device.type === Device.Type.Print; + + case "speech": + return device.type === Device.Type.Speech; + + case "all": + return true; + + default: + return false; + } + } + + public equals(value: unknown): value is this { + return value instanceof Type && value._name === this._name; + } + + public toJSON(): Type.JSON { + return { + name: this._name, + }; + } + + public toString(): string { + return this._name; + } +} + +export namespace Type { + export interface JSON { + [key: string]: json.JSON; + name: string; + } + + export function isType(value: unknown): value is Type { + return value instanceof Type; + } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-type} + */ + export const parse = map( + Token.parseIdent((ident) => { + switch (ident.value) { + // These values are not allowed as media types. + case "only": + case "not": + case "and": + case "or": + return false; + + default: + return true; + } + }), + (ident) => Type.of(ident.value), + ); +} diff --git a/packages/alfa-media/src/value/index.ts b/packages/alfa-media/src/value/index.ts new file mode 100644 index 0000000000..5261de5765 --- /dev/null +++ b/packages/alfa-media/src/value/index.ts @@ -0,0 +1 @@ +export * from "./value"; diff --git a/packages/alfa-media/src/value/value.ts b/packages/alfa-media/src/value/value.ts new file mode 100644 index 0000000000..f22e5f28f7 --- /dev/null +++ b/packages/alfa-media/src/value/value.ts @@ -0,0 +1,252 @@ +import { Comparable } from "@siteimprove/alfa-comparable"; +import { Length, Number } from "@siteimprove/alfa-css"; +import { Equatable } from "@siteimprove/alfa-equatable"; +import { Functor } from "@siteimprove/alfa-functor"; +import * as json from "@siteimprove/alfa-json"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Mapper } from "@siteimprove/alfa-mapper"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Refinement } from "@siteimprove/alfa-refinement"; + +export interface Value + extends Functor, + Serializable { + map(mapper: Mapper): Value; + matches(value: T): boolean; + hasValue(refinement: Refinement): this is Value; + toJSON(): Value.JSON; +} + +export namespace Value { + export interface JSON { + [key: string]: json.JSON; + type: string; + } + + export class Discrete + implements Value, Serializable> + { + public static of(value: T): Discrete { + return new Discrete(value); + } + + private readonly _value: T; + + private constructor(value: T) { + this._value = value; + } + + public get value(): T { + return this._value; + } + + public map(mapper: Mapper): Discrete { + return new Discrete(mapper(this._value)); + } + + public matches(value: T): boolean { + return Equatable.equals(this._value, value); + } + + public hasValue( + refinement: Refinement, + ): this is Discrete { + return refinement(this._value); + } + + public toJSON(): Discrete.JSON { + return { + type: "discrete", + value: Serializable.toJSON(this._value), + }; + } + } + + export namespace Discrete { + export interface JSON { + [key: string]: json.JSON; + type: "discrete"; + value: Serializable.ToJSON; + } + + export function isDiscrete(value: unknown): value is Discrete { + return value instanceof Discrete; + } + } + + export const { of: discrete, isDiscrete } = Discrete; + + export class Range + implements Value, Serializable> + { + public static of(minimum: Bound, maximum: Bound): Range { + return new Range(Option.of(minimum), Option.of(maximum)); + } + + public static minimum(minimum: Bound): Range { + return new Range(Option.of(minimum), None); + } + + public static maximum(maximum: Bound): Range { + return new Range(None, Option.of(maximum)); + } + + private readonly _minimum: Option>; + private readonly _maximum: Option>; + + private constructor(minimum: Option>, maximum: Option>) { + this._minimum = minimum; + this._maximum = maximum; + } + + public get minimum(): Option> { + return this._minimum; + } + + public get maximum(): Option> { + return this._maximum; + } + + public map(mapper: Mapper): Range { + return new Range( + this._minimum.map((bound) => bound.map(mapper)), + this._maximum.map((bound) => bound.map(mapper)), + ); + } + + public toLength(): Range> { + return this.map((bound) => + Refinement.and( + Number.isNumber, + (value) => !value.hasCalculation() && value.value === 0, + )(bound) + ? Length.of(0, "px") + : bound, + ); + } + + public matches(value: T): boolean { + if (!Comparable.isComparable(value)) { + return false; + } + + // Since we need to match both bounds, we return false if one is not + // matched and keep true for the default return at the end. + for (const minimum of this._minimum) { + if (minimum.isInclusive) { + // value is inclusively larger than the minimum if it is not + // strictly smaller than it. + if (value.compare(minimum.value) < 0) { + return false; + } + } else { + if (value.compare(minimum.value) <= 0) { + return false; + } + } + } + + for (const maximum of this._maximum) { + if (maximum.isInclusive) { + if (value.compare(maximum.value) > 0) { + return false; + } + } else { + if (value.compare(maximum.value) >= 0) { + return false; + } + } + } + + return true; + } + + public hasValue( + refinement: Refinement, + ): this is Discrete { + return ( + this._minimum.every((bound) => refinement(bound.value)) && + this._maximum.every((bound) => refinement(bound.value)) + ); + } + + public toJSON(): Range.JSON { + return { + type: "range", + minimum: this._minimum.map((bound) => bound.toJSON()).getOr(null), + maximum: this._maximum.map((bound) => bound.toJSON()).getOr(null), + }; + } + } + + export namespace Range { + export interface JSON { + [key: string]: json.JSON; + type: "range"; + minimum: Bound.JSON | null; + maximum: Bound.JSON | null; + } + + export function isRange(value: unknown): value is Range { + return value instanceof Range; + } + } + + export const { + of: range, + minimum: minimumRange, + maximum: maximumRange, + isRange, + } = Range; + + export class Bound + implements Functor, Serializable> + { + public static of(value: T, isInclusive: boolean): Bound { + return new Bound(value, isInclusive); + } + + private readonly _value: T; + private readonly _isInclusive: boolean; + + private constructor(value: T, isInclusive: boolean) { + this._value = value; + this._isInclusive = isInclusive; + } + + public get value(): T { + return this._value; + } + + public get isInclusive(): boolean { + return this._isInclusive; + } + + public map(mapper: Mapper): Bound { + return new Bound(mapper(this._value), this._isInclusive); + } + + public hasValue( + refinement: Refinement, + ): this is Bound { + return refinement(this._value); + } + + public toJSON(): Bound.JSON { + return { + value: Serializable.toJSON(this._value), + isInclusive: this._isInclusive, + }; + } + } + + export namespace Bound { + export interface JSON { + [key: string]: json.JSON; + value: Serializable.ToJSON; + isInclusive: boolean; + } + } + + export const { of: bound } = Bound; +} diff --git a/packages/alfa-media/tsconfig.json b/packages/alfa-media/tsconfig.json index 1eaf280e1a..6d4df31fd5 100644 --- a/packages/alfa-media/tsconfig.json +++ b/packages/alfa-media/tsconfig.json @@ -2,9 +2,16 @@ "$schema": "http://json.schemastore.org/tsconfig", "extends": "../tsconfig.json", "files": [ + "src/feature/feature.ts", + "src/feature/index.ts", "src/index.ts", + "src/matchable.ts", "src/media.ts", + "src/modifier.ts", "src/resolver.ts", + "src/type.ts", + "src/value/index.ts", + "src/value/value.ts", "test/media.spec.ts" ], "references": [ diff --git a/packages/alfa-rules/src/sia-r44/rule.ts b/packages/alfa-rules/src/sia-r44/rule.ts index 63eb9a7286..27b5d15dc4 100644 --- a/packages/alfa-rules/src/sia-r44/rule.ts +++ b/packages/alfa-rules/src/sia-r44/rule.ts @@ -11,7 +11,7 @@ import { } from "@siteimprove/alfa-dom"; import { Iterable } from "@siteimprove/alfa-iterable"; import { Real } from "@siteimprove/alfa-math"; -import { Media } from "@siteimprove/alfa-media"; +import { Feature as MediaFeature, Media } from "@siteimprove/alfa-media"; import { None, Option } from "@siteimprove/alfa-option"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Err, Ok } from "@siteimprove/alfa-result"; @@ -141,7 +141,7 @@ function isOrientationConditional(declaration: Declaration): boolean { } function hasOrientationCondition( - condition: Media.Feature | Media.Condition, + condition: MediaFeature | Media.Condition, ): boolean { for (const feature of condition) { if ( diff --git a/packages/alfa-rules/src/sia-r83/rule.ts b/packages/alfa-rules/src/sia-r83/rule.ts index 51e0bae3cb..53d0924596 100644 --- a/packages/alfa-rules/src/sia-r83/rule.ts +++ b/packages/alfa-rules/src/sia-r83/rule.ts @@ -14,7 +14,10 @@ import { } from "@siteimprove/alfa-dom"; import { Hash } from "@siteimprove/alfa-hash"; import { Iterable } from "@siteimprove/alfa-iterable"; -import { Media } from "@siteimprove/alfa-media"; +import { + Feature as MediaFeature, + Value as MediaValue, +} from "@siteimprove/alfa-media"; import { None, Option } from "@siteimprove/alfa-option"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Refinement } from "@siteimprove/alfa-refinement"; @@ -29,8 +32,8 @@ import { expectation } from "../common/act/expectation"; import { Scope, Stability } from "../tags"; -const { isHeight, isWidth } = Media.Feature; -const { Discrete, Range } = Media.Value; +const { isHeight, isWidth } = MediaFeature; +const { Discrete, Range } = MediaValue; const { or, not, equals } = Predicate; const { and, test } = Refinement; @@ -524,8 +527,8 @@ function usesMediaRule( * We currently do not support calculated media queries. But this is lost in the * typing of Media.Feature. Here, we simply consider them as "good" (font relative). */ -function isFontRelativeMediaRule( - refinement: Refinement, +function isFontRelativeMediaRule( + refinement: Refinement, ): Predicate { return (rule) => Iterable.some(rule.queries.queries, (query) => @@ -551,9 +554,9 @@ function isFontRelativeMediaRule( ); } -function usesFontRelativeMediaRule( +function usesFontRelativeMediaRule( device: Device, - refinement: Refinement, + refinement: Refinement, context: Context = Context.empty(), ): Predicate { return usesMediaRule(isFontRelativeMediaRule(refinement), device, context); From 5711972f35bd15723955a610104ae4a3d43d0bd3 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Thu, 21 Dec 2023 15:06:42 +0100 Subject: [PATCH 04/16] Break down huge file --- .changeset/light-mice-beg.md | 21 - .../alfa-media/src/condition/condition.ts | 302 ++++++++++ packages/alfa-media/src/condition/index.ts | 1 + packages/alfa-media/src/index.ts | 40 +- packages/alfa-media/src/list.ts | 100 +++ packages/alfa-media/src/media.ts | 570 ------------------ packages/alfa-media/src/query.ts | 160 +++++ packages/alfa-media/tsconfig.json | 5 +- packages/alfa-rules/src/sia-r44/rule.ts | 4 +- packages/alfa-rules/src/sia-r83/rule.ts | 17 +- 10 files changed, 611 insertions(+), 609 deletions(-) delete mode 100644 .changeset/light-mice-beg.md create mode 100644 packages/alfa-media/src/condition/condition.ts create mode 100644 packages/alfa-media/src/condition/index.ts create mode 100644 packages/alfa-media/src/list.ts delete mode 100644 packages/alfa-media/src/media.ts create mode 100644 packages/alfa-media/src/query.ts diff --git a/.changeset/light-mice-beg.md b/.changeset/light-mice-beg.md deleted file mode 100644 index 57f127a007..0000000000 --- a/.changeset/light-mice-beg.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@siteimprove/alfa-media": minor ---- - -**Breaking:** Names `Feature`, `Modifier`, `Type`, `Value` are now directly exported by the package. - -That is, replace - -```typescript -import { Media } from "@siteimprove/alfa-media"; -declare; -x: Media.Feature; -``` - -with - -```typescript -import { Feature as MediaFeature } from "@siteimprove/alfa-media"; -declare; -x: MediaFeature; -``` diff --git a/packages/alfa-media/src/condition/condition.ts b/packages/alfa-media/src/condition/condition.ts new file mode 100644 index 0000000000..7ec2e2f8f6 --- /dev/null +++ b/packages/alfa-media/src/condition/condition.ts @@ -0,0 +1,302 @@ +import { Token } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { Equatable } from "@siteimprove/alfa-equatable"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import * as json from "@siteimprove/alfa-json"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Slice } from "@siteimprove/alfa-slice"; +import { Feature, parseMediaFeature } from "../feature"; +import type { Matchable } from "../matchable"; + +const { delimited, either, map, oneOrMore, option, pair, right, zeroOrMore } = + Parser; + +export class And + implements Matchable, Iterable, Equatable, Serializable +{ + public static of(left: Feature | Condition, right: Feature | Condition): And { + return new And(left, right); + } + + private readonly _left: Feature | Condition; + private readonly _right: Feature | Condition; + + private constructor(left: Feature | Condition, right: Feature | Condition) { + this._left = left; + this._right = right; + } + + public get left(): Feature | Condition { + return this._left; + } + + public get right(): Feature | Condition { + return this._right; + } + + public matches(device: Device): boolean { + return this._left.matches(device) && this._right.matches(device); + } + + public equals(value: unknown): value is this { + return ( + value instanceof And && + value._left.equals(this._left) && + value._right.equals(this._right) + ); + } + + public *iterator(): Iterator { + for (const condition of [this._left, this._right]) { + yield* condition; + } + } + + public [Symbol.iterator](): Iterator { + return this.iterator(); + } + + public toJSON(): And.JSON { + return { + type: "and", + left: this._left.toJSON(), + right: this._right.toJSON(), + }; + } + + public toString(): string { + return `(${this._left}) and (${this._right})`; + } +} + +export namespace And { + export interface JSON { + [key: string]: json.JSON; + type: "and"; + left: Feature.JSON | Condition.JSON; + right: Feature.JSON | Condition.JSON; + } + + export function isAnd(value: unknown): value is And { + return value instanceof And; + } +} + +export class Or + implements Matchable, Iterable, Equatable, Serializable +{ + public static of(left: Feature | Condition, right: Feature | Condition): Or { + return new Or(left, right); + } + + private readonly _left: Feature | Condition; + private readonly _right: Feature | Condition; + + private constructor(left: Feature | Condition, right: Feature | Condition) { + this._left = left; + this._right = right; + } + + public get left(): Feature | Condition { + return this._left; + } + + public get right(): Feature | Condition { + return this._right; + } + + public matches(device: Device): boolean { + return this._left.matches(device) || this._right.matches(device); + } + + public equals(value: unknown): value is this { + return ( + value instanceof Or && + value._left.equals(this._left) && + value._right.equals(this._right) + ); + } + + public *iterator(): Iterator { + for (const condition of [this._left, this._right]) { + yield* condition; + } + } + + public [Symbol.iterator](): Iterator { + return this.iterator(); + } + + public toJSON(): Or.JSON { + return { + type: "or", + left: this._left.toJSON(), + right: this._right.toJSON(), + }; + } + + public toString(): string { + return `(${this._left}) or (${this._right})`; + } +} + +export namespace Or { + export interface JSON { + [key: string]: json.JSON; + type: "or"; + left: Feature.JSON | Condition.JSON; + right: Feature.JSON | Condition.JSON; + } + + export function isOr(value: unknown): value is Or { + return value instanceof Or; + } +} + +export class Not + implements Matchable, Iterable, Equatable, Serializable +{ + public static of(condition: Feature | Condition): Not { + return new Not(condition); + } + + private readonly _condition: Feature | Condition; + + private constructor(condition: Feature | Condition) { + this._condition = condition; + } + + public get condition(): Feature | Condition { + return this._condition; + } + + public matches(device: Device): boolean { + return !this._condition.matches(device); + } + + public equals(value: unknown): value is this { + return value instanceof Not && value._condition.equals(this._condition); + } + + public *iterator(): Iterator { + yield* this._condition; + } + + public [Symbol.iterator](): Iterator { + return this.iterator(); + } + + public toJSON(): Not.JSON { + return { + type: "not", + condition: this._condition.toJSON(), + }; + } + + public toString(): string { + return `not (${this._condition})`; + } +} + +export namespace Not { + export interface JSON { + [key: string]: json.JSON; + type: "not"; + condition: Condition.JSON | Feature.JSON; + } + + export function isNot(value: unknown): value is Not { + return value instanceof Not; + } +} + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#media-conditions} + */ +export type Condition = And | Or | Not; + +export namespace Condition { + export type JSON = And.JSON | Or.JSON | Not.JSON; + + export function isCondition(value: unknown): value is Condition { + return And.isAnd(value) || Or.isOr(value) || Not.isNot(value); + } +} + +/** + * @remarks + * The condition parser is forward-declared as it is needed within its + * subparsers. + */ +export let parseCondition: Parser, Feature | Condition, string>; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-in-parens} + */ +const parseInParens = either( + delimited( + Token.parseOpenParenthesis, + delimited(option(Token.parseWhitespace), (input) => parseCondition(input)), + Token.parseCloseParenthesis, + ), + parseMediaFeature, +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-not} + */ +const parseNot = map( + right( + delimited(option(Token.parseWhitespace), Token.parseIdent("not")), + parseInParens, + ), + Not.of, +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-and} + */ +const parseAnd = right( + delimited(option(Token.parseWhitespace), Token.parseIdent("and")), + parseInParens, +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-or} + */ +const parseOr = right( + delimited(option(Token.parseWhitespace), Token.parseIdent("or")), + parseInParens, +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-condition} + */ +parseCondition = either( + parseNot, + either( + map( + pair( + parseInParens, + either( + map(oneOrMore(parseAnd), (queries) => [And.of, queries] as const), + map(oneOrMore(parseOr), (queries) => [Or.of, queries] as const), + ), + ), + ([left, [constructor, right]]) => + Iterable.reduce(right, (left, right) => constructor(left, right), left), + ), + parseInParens, + ), +); + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-condition-without-or} + */ +export const parseConditionWithoutOr = either( + parseNot, + map(pair(parseInParens, zeroOrMore(parseAnd)), ([left, right]) => + [left, ...right].reduce((left, right) => And.of(left, right)), + ), +); diff --git a/packages/alfa-media/src/condition/index.ts b/packages/alfa-media/src/condition/index.ts new file mode 100644 index 0000000000..bc3dd77f79 --- /dev/null +++ b/packages/alfa-media/src/condition/index.ts @@ -0,0 +1 @@ +export * from "./condition"; diff --git a/packages/alfa-media/src/index.ts b/packages/alfa-media/src/index.ts index 64149d2d25..3d5c8dbc3e 100644 --- a/packages/alfa-media/src/index.ts +++ b/packages/alfa-media/src/index.ts @@ -1,5 +1,35 @@ -export * from "./feature"; -export * from "./media"; -export * from "./modifier"; -export * from "./type"; -export * from "./value"; +import * as condition from "./condition"; +import * as feature from "./feature"; +import * as mediaList from "./list"; +import * as modifier from "./modifier"; +import * as mediaQuery from "./query"; +import * as mediaType from "./type"; +import * as value from "./value"; + +/** + * @public + */ +export namespace Media { + export import Condition = condition.Condition; + export import And = condition.And; + export import Or = condition.Or; + export import Not = condition.Not; + + export import Feature = feature.Feature; + export import List = mediaList.List; + export import Modifier = modifier.Modifier; + export import Query = mediaQuery.Query; + export import Type = mediaType.Type; + export import Value = value.Value; + + export const { of: type, isType } = Type; + export const { isFeature } = Feature; + export const { of: and, isAnd } = And; + export const { of: or, isOr } = Or; + export const { of: not, isNot } = Not; + export const { isCondition } = Condition; + export const { of: query, isQuery } = Query; + export const { of: list, isList } = List; + + export const parse = List.parse; +} diff --git a/packages/alfa-media/src/list.ts b/packages/alfa-media/src/list.ts new file mode 100644 index 0000000000..9ce3f11199 --- /dev/null +++ b/packages/alfa-media/src/list.ts @@ -0,0 +1,100 @@ +import { Component, Token } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { Equatable } from "@siteimprove/alfa-equatable"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Slice } from "@siteimprove/alfa-slice"; + +import type { Matchable } from "./matchable"; +import { Query } from "./query"; + +const { either, end, map, separatedList, takeUntil } = Parser; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#media-query-list} + * + * @public + */ +export class List + implements Matchable, Iterable, Equatable, Serializable +{ + public static of(queries: Iterable): List { + return new List(queries); + } + + private readonly _queries: Array; + + private constructor(queries: Iterable) { + this._queries = Array.from(queries); + } + + public get queries(): Iterable { + return this._queries; + } + + public matches(device: Device): boolean { + return ( + this._queries.length === 0 || + this._queries.some((query) => query.matches(device)) + ); + } + + public equals(value: unknown): value is this { + return ( + value instanceof List && + value._queries.length === this._queries.length && + value._queries.every((query, i) => query.equals(this._queries[i])) + ); + } + + public *[Symbol.iterator](): Iterator { + yield* this._queries; + } + + public toJSON(): List.JSON { + return this._queries.map((query) => query.toJSON()); + } + + public toString(): string { + return this._queries.join(", "); + } +} + +/** + * @public + */ +export namespace List { + export type JSON = Array; + + export function isList(value: unknown): value is List { + return value instanceof List; + } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-query-list} + */ + export const parse = map( + separatedList( + map( + takeUntil( + Component.consume, + either( + Token.parseComma, + end(() => `Unexpected token`), + ), + ), + (components) => Iterable.flatten(components), + ), + Token.parseComma, + ), + (queries) => + List.of( + Iterable.map(queries, (tokens) => + Query.parse(Slice.from(tokens).trim(Token.isWhitespace)) + .map(([, query]) => query) + .getOr(Query.notAll), + ), + ), + ); +} diff --git a/packages/alfa-media/src/media.ts b/packages/alfa-media/src/media.ts deleted file mode 100644 index 97247049e9..0000000000 --- a/packages/alfa-media/src/media.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { Token, Component } from "@siteimprove/alfa-css"; -import { Device } from "@siteimprove/alfa-device"; -import { Equatable } from "@siteimprove/alfa-equatable"; -import { Iterable } from "@siteimprove/alfa-iterable"; -import { Serializable } from "@siteimprove/alfa-json"; -import { Option, None } from "@siteimprove/alfa-option"; -import { Parser } from "@siteimprove/alfa-parser"; -import { Slice } from "@siteimprove/alfa-slice"; - -import * as json from "@siteimprove/alfa-json"; - -import { Feature, parseMediaFeature } from "./feature"; -import type { Matchable } from "./matchable"; -import { Modifier } from "./modifier"; -import { Type } from "./type"; - -const { - delimited, - either, - end, - left, - map, - oneOrMore, - option, - pair, - right, - separatedList, - takeUntil, - zeroOrMore, -} = Parser; - -/** - * @public - */ -export namespace Media { - export const { of: type, isType } = Type; - - export const { isFeature } = Feature; - - export class And - implements Matchable, Iterable, Equatable, Serializable - { - public static of( - left: Feature | Condition, - right: Feature | Condition, - ): And { - return new And(left, right); - } - - private readonly _left: Feature | Condition; - private readonly _right: Feature | Condition; - - private constructor(left: Feature | Condition, right: Feature | Condition) { - this._left = left; - this._right = right; - } - - public get left(): Feature | Condition { - return this._left; - } - - public get right(): Feature | Condition { - return this._right; - } - - public matches(device: Device): boolean { - return this._left.matches(device) && this._right.matches(device); - } - - public equals(value: unknown): value is this { - return ( - value instanceof And && - value._left.equals(this._left) && - value._right.equals(this._right) - ); - } - - public *iterator(): Iterator { - for (const condition of [this._left, this._right]) { - yield* condition; - } - } - - public [Symbol.iterator](): Iterator { - return this.iterator(); - } - - public toJSON(): And.JSON { - return { - type: "and", - left: this._left.toJSON(), - right: this._right.toJSON(), - }; - } - - public toString(): string { - return `(${this._left}) and (${this._right})`; - } - } - - export namespace And { - export interface JSON { - [key: string]: json.JSON; - type: "and"; - left: Feature.JSON | Condition.JSON; - right: Feature.JSON | Condition.JSON; - } - - export function isAnd(value: unknown): value is And { - return value instanceof And; - } - } - - export const { of: and, isAnd } = And; - - export class Or - implements Matchable, Iterable, Equatable, Serializable - { - public static of( - left: Feature | Condition, - right: Feature | Condition, - ): Or { - return new Or(left, right); - } - - private readonly _left: Feature | Condition; - private readonly _right: Feature | Condition; - - private constructor(left: Feature | Condition, right: Feature | Condition) { - this._left = left; - this._right = right; - } - - public get left(): Feature | Condition { - return this._left; - } - - public get right(): Feature | Condition { - return this._right; - } - - public matches(device: Device): boolean { - return this._left.matches(device) || this._right.matches(device); - } - - public equals(value: unknown): value is this { - return ( - value instanceof Or && - value._left.equals(this._left) && - value._right.equals(this._right) - ); - } - - public *iterator(): Iterator { - for (const condition of [this._left, this._right]) { - yield* condition; - } - } - - public [Symbol.iterator](): Iterator { - return this.iterator(); - } - - public toJSON(): Or.JSON { - return { - type: "or", - left: this._left.toJSON(), - right: this._right.toJSON(), - }; - } - - public toString(): string { - return `(${this._left}) or (${this._right})`; - } - } - - export namespace Or { - export interface JSON { - [key: string]: json.JSON; - type: "or"; - left: Feature.JSON | Condition.JSON; - right: Feature.JSON | Condition.JSON; - } - - export function isOr(value: unknown): value is Or { - return value instanceof Or; - } - } - - export const { of: or, isOr } = Or; - - export class Not - implements Matchable, Iterable, Equatable, Serializable - { - public static of(condition: Feature | Condition): Not { - return new Not(condition); - } - - private readonly _condition: Feature | Condition; - - private constructor(condition: Feature | Condition) { - this._condition = condition; - } - - public get condition(): Feature | Condition { - return this._condition; - } - - public matches(device: Device): boolean { - return !this._condition.matches(device); - } - - public equals(value: unknown): value is this { - return value instanceof Not && value._condition.equals(this._condition); - } - - public *iterator(): Iterator { - yield* this._condition; - } - - public [Symbol.iterator](): Iterator { - return this.iterator(); - } - - public toJSON(): Not.JSON { - return { - type: "not", - condition: this._condition.toJSON(), - }; - } - - public toString(): string { - return `not (${this._condition})`; - } - } - - export namespace Not { - export interface JSON { - [key: string]: json.JSON; - type: "not"; - condition: Condition.JSON | Feature.JSON; - } - - export function isNot(value: unknown): value is Not { - return value instanceof Not; - } - } - - export const { of: not, isNot } = Not; - - /** - * {@link https://drafts.csswg.org/mediaqueries/#media-condition} - */ - export type Condition = And | Or | Not; - - export namespace Condition { - export type JSON = And.JSON | Or.JSON | Not.JSON; - - export function isCondition(value: unknown): value is Condition { - return isAnd(value) || isOr(value) || isNot(value); - } - } - - export const { isCondition } = Condition; - - /** - * @remarks - * The condition parser is forward-declared as it is needed within its - * subparsers. - */ - let parseCondition: Parser, Feature | Condition, string>; - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-media-in-parens} - */ - const parseInParens = either( - delimited( - Token.parseOpenParenthesis, - delimited(option(Token.parseWhitespace), (input) => - parseCondition(input), - ), - Token.parseCloseParenthesis, - ), - parseMediaFeature, - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-media-not} - */ - const parseNot = map( - right( - delimited(option(Token.parseWhitespace), Token.parseIdent("not")), - parseInParens, - ), - not, - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-media-and} - */ - const parseAnd = right( - delimited(option(Token.parseWhitespace), Token.parseIdent("and")), - parseInParens, - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-media-or} - */ - const parseOr = right( - delimited(option(Token.parseWhitespace), Token.parseIdent("or")), - parseInParens, - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-media-condition} - */ - parseCondition = either( - parseNot, - either( - map( - pair( - parseInParens, - either( - map(oneOrMore(parseAnd), (queries) => [and, queries] as const), - map(oneOrMore(parseOr), (queries) => [or, queries] as const), - ), - ), - ([left, [constructor, right]]) => - Iterable.reduce( - right, - (left, right) => constructor(left, right), - left, - ), - ), - parseInParens, - ), - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-media-condition-without-or} - */ - const parseConditionWithoutOr = either( - parseNot, - map(pair(parseInParens, zeroOrMore(parseAnd)), ([left, right]) => - [left, ...right].reduce((left, right) => and(left, right)), - ), - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#media-query} - */ - export class Query implements Matchable { - public static of( - modifier: Option, - type: Option, - condition: Option, - ): Query { - return new Query(modifier, type, condition); - } - - private readonly _modifier: Option; - private readonly _type: Option; - private readonly _condition: Option; - - private constructor( - modifier: Option, - type: Option, - condition: Option, - ) { - this._modifier = modifier; - this._type = type; - this._condition = condition; - } - - public get modifier(): Option { - return this._modifier; - } - - public get type(): Option { - return this._type; - } - - public get condition(): Option { - return this._condition; - } - - public matches(device: Device): boolean { - const negated = this._modifier.some( - (modifier) => modifier === Modifier.Not, - ); - - const type = this._type.every((type) => type.matches(device)); - - const condition = this.condition.every((condition) => - condition.matches(device), - ); - - return negated !== (type && condition); - } - - public equals(value: unknown): value is this { - return ( - value instanceof Query && - value._modifier.equals(this._modifier) && - value._type.equals(this._type) && - value._condition.equals(this._condition) - ); - } - - public toJSON(): Query.JSON { - return { - modifier: this._modifier.getOr(null), - type: this._type.map((type) => type.toJSON()).getOr(null), - condition: this._condition - .map((condition) => condition.toJSON()) - .getOr(null), - }; - } - - public toString(): string { - const modifier = this._modifier.getOr(""); - - const type = this._type - .map((type) => (modifier === "" ? `${type}` : `${modifier} ${type}`)) - .getOr(""); - - return this._condition - .map((condition) => - type === "" ? `${condition}` : `${type} and ${condition}`, - ) - .getOr(type); - } - } - - export namespace Query { - export interface JSON { - [key: string]: json.JSON; - modifier: string | null; - type: Type.JSON | null; - condition: Feature.JSON | Condition.JSON | Not.JSON | null; - } - - export function isQuery(value: unknown): value is Query { - return value instanceof Query; - } - } - - export const { of: query, isQuery } = Query; - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-media-query} - */ - const parseQuery = left( - either( - map(parseCondition, (condition) => - Query.of(None, None, Option.of(condition)), - ), - map( - pair( - pair( - option(delimited(option(Token.parseWhitespace), Modifier.parse)), - Type.parse, - ), - option( - right( - delimited(option(Token.parseWhitespace), Token.parseIdent("and")), - parseConditionWithoutOr, - ), - ), - ), - ([[modifier, type], condition]) => - Query.of(modifier, Option.of(type), condition), - ), - ), - end(() => `Unexpected token`), - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#media-query-list} - */ - export class List - implements Matchable, Iterable, Equatable, Serializable - { - public static of(queries: Iterable): List { - return new List(queries); - } - - private readonly _queries: Array; - - private constructor(queries: Iterable) { - this._queries = Array.from(queries); - } - - public get queries(): Iterable { - return this._queries; - } - - public matches(device: Device): boolean { - return ( - this._queries.length === 0 || - this._queries.some((query) => query.matches(device)) - ); - } - - public equals(value: unknown): value is this { - return ( - value instanceof List && - value._queries.length === this._queries.length && - value._queries.every((query, i) => query.equals(this._queries[i])) - ); - } - - public *[Symbol.iterator](): Iterator { - yield* this._queries; - } - - public toJSON(): List.JSON { - return this._queries.map((query) => query.toJSON()); - } - - public toString(): string { - return this._queries.join(", "); - } - } - - export namespace List { - export type JSON = Array; - - export function isList(value: unknown): value is List { - return value instanceof List; - } - } - - export const { of: list, isList } = List; - - const notAll = Query.of( - Option.of(Modifier.Not), - Option.of(Type.of("all")), - None, - ); - - /** - * {@link https://drafts.csswg.org/mediaqueries/#typedef-media-query-list} - */ - const parseList = map( - separatedList( - map( - takeUntil( - Component.consume, - either( - Token.parseComma, - end(() => `Unexpected token`), - ), - ), - (components) => Iterable.flatten(components), - ), - Token.parseComma, - ), - (queries) => - List.of( - Iterable.map(queries, (tokens) => - parseQuery(Slice.from(tokens).trim(Token.isWhitespace)) - .map(([, query]) => query) - .getOr(notAll), - ), - ), - ); - - export const parse = parseList; -} diff --git a/packages/alfa-media/src/query.ts b/packages/alfa-media/src/query.ts new file mode 100644 index 0000000000..bff1f0d190 --- /dev/null +++ b/packages/alfa-media/src/query.ts @@ -0,0 +1,160 @@ +import { Token } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; + +import * as json from "@siteimprove/alfa-json"; + +import { + type Condition, + Not, + parseCondition, + parseConditionWithoutOr, +} from "./condition"; +import { Feature } from "./feature"; +import type { Matchable } from "./matchable"; +import { Modifier } from "./modifier"; +import { Type } from "./type"; + +const { delimited, either, end, left, map, option, pair, right } = Parser; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#media-query} + * + * @public + */ +export class Query implements Matchable { + public static of( + modifier: Option, + type: Option, + condition: Option, + ): Query { + return new Query(modifier, type, condition); + } + + private readonly _modifier: Option; + private readonly _type: Option; + private readonly _condition: Option; + + private constructor( + modifier: Option, + type: Option, + condition: Option, + ) { + this._modifier = modifier; + this._type = type; + this._condition = condition; + } + + public get modifier(): Option { + return this._modifier; + } + + public get type(): Option { + return this._type; + } + + public get condition(): Option { + return this._condition; + } + + public matches(device: Device): boolean { + const negated = this._modifier.some( + (modifier) => modifier === Modifier.Not, + ); + + const type = this._type.every((type) => type.matches(device)); + + const condition = this.condition.every((condition) => + condition.matches(device), + ); + + return negated !== (type && condition); + } + + public equals(value: unknown): value is this { + return ( + value instanceof Query && + value._modifier.equals(this._modifier) && + value._type.equals(this._type) && + value._condition.equals(this._condition) + ); + } + + public toJSON(): Query.JSON { + return { + modifier: this._modifier.getOr(null), + type: this._type.map((type) => type.toJSON()).getOr(null), + condition: this._condition + .map((condition) => condition.toJSON()) + .getOr(null), + }; + } + + public toString(): string { + const modifier = this._modifier.getOr(""); + + const type = this._type + .map((type) => (modifier === "" ? `${type}` : `${modifier} ${type}`)) + .getOr(""); + + return this._condition + .map((condition) => + type === "" ? `${condition}` : `${type} and ${condition}`, + ) + .getOr(type); + } +} + +/** + * @public + */ +export namespace Query { + export interface JSON { + [key: string]: json.JSON; + modifier: string | null; + type: Type.JSON | null; + condition: Feature.JSON | Condition.JSON | Not.JSON | null; + } + + export function isQuery(value: unknown): value is Query { + return value instanceof Query; + } + + /** + * @internal + */ + export const notAll = Query.of( + Option.of(Modifier.Not), + Option.of(Type.of("all")), + None, + ); + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-query} + */ + export const parse = left( + either( + map(parseCondition, (condition) => + Query.of(None, None, Option.of(condition)), + ), + map( + pair( + pair( + option(delimited(option(Token.parseWhitespace), Modifier.parse)), + Type.parse, + ), + option( + right( + delimited(option(Token.parseWhitespace), Token.parseIdent("and")), + parseConditionWithoutOr, + ), + ), + ), + ([[modifier, type], condition]) => + Query.of(modifier, Option.of(type), condition), + ), + ), + end(() => `Unexpected token`), + ); +} diff --git a/packages/alfa-media/tsconfig.json b/packages/alfa-media/tsconfig.json index 6d4df31fd5..46e884c9ff 100644 --- a/packages/alfa-media/tsconfig.json +++ b/packages/alfa-media/tsconfig.json @@ -2,12 +2,15 @@ "$schema": "http://json.schemastore.org/tsconfig", "extends": "../tsconfig.json", "files": [ + "src/condition/condition.ts", + "src/condition/index.ts", "src/feature/feature.ts", "src/feature/index.ts", "src/index.ts", + "src/list.ts", "src/matchable.ts", - "src/media.ts", "src/modifier.ts", + "src/query.ts", "src/resolver.ts", "src/type.ts", "src/value/index.ts", diff --git a/packages/alfa-rules/src/sia-r44/rule.ts b/packages/alfa-rules/src/sia-r44/rule.ts index 27b5d15dc4..63eb9a7286 100644 --- a/packages/alfa-rules/src/sia-r44/rule.ts +++ b/packages/alfa-rules/src/sia-r44/rule.ts @@ -11,7 +11,7 @@ import { } from "@siteimprove/alfa-dom"; import { Iterable } from "@siteimprove/alfa-iterable"; import { Real } from "@siteimprove/alfa-math"; -import { Feature as MediaFeature, Media } from "@siteimprove/alfa-media"; +import { Media } from "@siteimprove/alfa-media"; import { None, Option } from "@siteimprove/alfa-option"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Err, Ok } from "@siteimprove/alfa-result"; @@ -141,7 +141,7 @@ function isOrientationConditional(declaration: Declaration): boolean { } function hasOrientationCondition( - condition: MediaFeature | Media.Condition, + condition: Media.Feature | Media.Condition, ): boolean { for (const feature of condition) { if ( diff --git a/packages/alfa-rules/src/sia-r83/rule.ts b/packages/alfa-rules/src/sia-r83/rule.ts index 53d0924596..51e0bae3cb 100644 --- a/packages/alfa-rules/src/sia-r83/rule.ts +++ b/packages/alfa-rules/src/sia-r83/rule.ts @@ -14,10 +14,7 @@ import { } from "@siteimprove/alfa-dom"; import { Hash } from "@siteimprove/alfa-hash"; import { Iterable } from "@siteimprove/alfa-iterable"; -import { - Feature as MediaFeature, - Value as MediaValue, -} from "@siteimprove/alfa-media"; +import { Media } from "@siteimprove/alfa-media"; import { None, Option } from "@siteimprove/alfa-option"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Refinement } from "@siteimprove/alfa-refinement"; @@ -32,8 +29,8 @@ import { expectation } from "../common/act/expectation"; import { Scope, Stability } from "../tags"; -const { isHeight, isWidth } = MediaFeature; -const { Discrete, Range } = MediaValue; +const { isHeight, isWidth } = Media.Feature; +const { Discrete, Range } = Media.Value; const { or, not, equals } = Predicate; const { and, test } = Refinement; @@ -527,8 +524,8 @@ function usesMediaRule( * We currently do not support calculated media queries. But this is lost in the * typing of Media.Feature. Here, we simply consider them as "good" (font relative). */ -function isFontRelativeMediaRule( - refinement: Refinement, +function isFontRelativeMediaRule( + refinement: Refinement, ): Predicate { return (rule) => Iterable.some(rule.queries.queries, (query) => @@ -554,9 +551,9 @@ function isFontRelativeMediaRule( ); } -function usesFontRelativeMediaRule( +function usesFontRelativeMediaRule( device: Device, - refinement: Refinement, + refinement: Refinement, context: Context = Context.empty(), ): Predicate { return usesMediaRule(isFontRelativeMediaRule(refinement), device, context); From 806f72d0e4ba1868f297af7badceafe261873487 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Thu, 21 Dec 2023 16:05:12 +0100 Subject: [PATCH 05/16] Break down value --- .../alfa-media/src/condition/condition.ts | 10 +- packages/alfa-media/src/resolver.ts | 3 +- packages/alfa-media/src/value/bound.ts | 61 +++++ packages/alfa-media/src/value/discrete.ts | 66 +++++ packages/alfa-media/src/value/range.ts | 137 ++++++++++ packages/alfa-media/src/value/value.ts | 235 ++---------------- packages/alfa-media/test/media.spec.ts | 125 ++-------- packages/alfa-media/tsconfig.json | 3 + 8 files changed, 313 insertions(+), 327 deletions(-) create mode 100644 packages/alfa-media/src/value/bound.ts create mode 100644 packages/alfa-media/src/value/discrete.ts create mode 100644 packages/alfa-media/src/value/range.ts diff --git a/packages/alfa-media/src/condition/condition.ts b/packages/alfa-media/src/condition/condition.ts index 7ec2e2f8f6..4c6c0f192e 100644 --- a/packages/alfa-media/src/condition/condition.ts +++ b/packages/alfa-media/src/condition/condition.ts @@ -1,11 +1,13 @@ -import { Token } from "@siteimprove/alfa-css"; +import { Parser as CSSParser, Token } from "@siteimprove/alfa-css"; import { Device } from "@siteimprove/alfa-device"; import { Equatable } from "@siteimprove/alfa-equatable"; import { Iterable } from "@siteimprove/alfa-iterable"; -import * as json from "@siteimprove/alfa-json"; import { Serializable } from "@siteimprove/alfa-json"; import { Parser } from "@siteimprove/alfa-parser"; import { Slice } from "@siteimprove/alfa-slice"; + +import * as json from "@siteimprove/alfa-json"; + import { Feature, parseMediaFeature } from "../feature"; import type { Matchable } from "../matchable"; @@ -229,12 +231,12 @@ export namespace Condition { * The condition parser is forward-declared as it is needed within its * subparsers. */ -export let parseCondition: Parser, Feature | Condition, string>; +export let parseCondition: CSSParser; /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-in-parens} */ -const parseInParens = either( +const parseInParens: CSSParser = either( delimited( Token.parseOpenParenthesis, delimited(option(Token.parseWhitespace), (input) => parseCondition(input)), diff --git a/packages/alfa-media/src/resolver.ts b/packages/alfa-media/src/resolver.ts index debd60997a..ebaea28463 100644 --- a/packages/alfa-media/src/resolver.ts +++ b/packages/alfa-media/src/resolver.ts @@ -15,7 +15,8 @@ export namespace Resolver { * * @remarks * Relative lengths in media queries are based on initial values of the - * associated properties. + * associated properties. They are hard-coded here to avoid circular dependcy + * to @siteimprove/alfa-style. */ export function length(device: Device): Length.Resolver { const { viewport } = device; diff --git a/packages/alfa-media/src/value/bound.ts b/packages/alfa-media/src/value/bound.ts new file mode 100644 index 0000000000..10fa2ca1fc --- /dev/null +++ b/packages/alfa-media/src/value/bound.ts @@ -0,0 +1,61 @@ +import { Functor } from "@siteimprove/alfa-functor"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Mapper } from "@siteimprove/alfa-mapper"; +import { Refinement } from "@siteimprove/alfa-refinement"; + +import * as json from "@siteimprove/alfa-json"; + +/** + * A bound, either inclusive or exclusive. + * + * @public + */ +export class Bound + implements Functor, Serializable> +{ + public static of(value: T, isInclusive: boolean): Bound { + return new Bound(value, isInclusive); + } + + private readonly _value: T; + private readonly _isInclusive: boolean; + + private constructor(value: T, isInclusive: boolean) { + this._value = value; + this._isInclusive = isInclusive; + } + + public get value(): T { + return this._value; + } + + public get isInclusive(): boolean { + return this._isInclusive; + } + + public map(mapper: Mapper): Bound { + return new Bound(mapper(this._value), this._isInclusive); + } + + public hasValue(refinement: Refinement): this is Bound { + return refinement(this._value); + } + + public toJSON(): Bound.JSON { + return { + value: Serializable.toJSON(this._value), + isInclusive: this._isInclusive, + }; + } +} + +/** + * @public + */ +export namespace Bound { + export interface JSON { + [key: string]: json.JSON; + value: Serializable.ToJSON; + isInclusive: boolean; + } +} diff --git a/packages/alfa-media/src/value/discrete.ts b/packages/alfa-media/src/value/discrete.ts new file mode 100644 index 0000000000..f05ff7e6bc --- /dev/null +++ b/packages/alfa-media/src/value/discrete.ts @@ -0,0 +1,66 @@ +import { Equatable } from "@siteimprove/alfa-equatable"; +import * as json from "@siteimprove/alfa-json"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Mapper } from "@siteimprove/alfa-mapper"; +import { Refinement } from "@siteimprove/alfa-refinement"; + +import type { Value } from "./value"; + +/** + * A non-numerical value, e.g., orientation. + * + * @public + */ +export class Discrete + implements Value, Serializable> +{ + public static of(value: T): Discrete { + return new Discrete(value); + } + + private readonly _value: T; + + private constructor(value: T) { + this._value = value; + } + + public get value(): T { + return this._value; + } + + public map(mapper: Mapper): Discrete { + return new Discrete(mapper(this._value)); + } + + public matches(value: T): boolean { + return Equatable.equals(this._value, value); + } + + public hasValue( + refinement: Refinement, + ): this is Discrete { + return refinement(this._value); + } + + public toJSON(): Discrete.JSON { + return { + type: "discrete", + value: Serializable.toJSON(this._value), + }; + } +} + +/** + * @public + */ +export namespace Discrete { + export interface JSON { + [key: string]: json.JSON; + type: "discrete"; + value: Serializable.ToJSON; + } + + export function isDiscrete(value: unknown): value is Discrete { + return value instanceof Discrete; + } +} diff --git a/packages/alfa-media/src/value/range.ts b/packages/alfa-media/src/value/range.ts new file mode 100644 index 0000000000..82f534a003 --- /dev/null +++ b/packages/alfa-media/src/value/range.ts @@ -0,0 +1,137 @@ +import { Comparable } from "@siteimprove/alfa-comparable"; +import { Length, Number } from "@siteimprove/alfa-css"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Mapper } from "@siteimprove/alfa-mapper"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Refinement } from "@siteimprove/alfa-refinement"; + +import * as json from "@siteimprove/alfa-json"; + +import { Bound } from "./bound"; +import { Discrete } from "./discrete"; +import type { Value } from "./value"; + +/** + * A range, with an optional minimum and maximum bound, both of which may be + * inclusive or exclusive. + * + * @public + */ +export class Range + implements Value, Serializable> +{ + public static of(minimum: Bound, maximum: Bound): Range { + return new Range(Option.of(minimum), Option.of(maximum)); + } + + public static minimum(minimum: Bound): Range { + return new Range(Option.of(minimum), None); + } + + public static maximum(maximum: Bound): Range { + return new Range(None, Option.of(maximum)); + } + + private readonly _minimum: Option>; + private readonly _maximum: Option>; + + private constructor(minimum: Option>, maximum: Option>) { + this._minimum = minimum; + this._maximum = maximum; + } + + public get minimum(): Option> { + return this._minimum; + } + + public get maximum(): Option> { + return this._maximum; + } + + public map(mapper: Mapper): Range { + return new Range( + this._minimum.map((bound) => bound.map(mapper)), + this._maximum.map((bound) => bound.map(mapper)), + ); + } + + public toLength(): Range> { + return this.map((bound) => + Refinement.and( + Number.isNumber, + (value) => !value.hasCalculation() && value.value === 0, + )(bound) + ? Length.of(0, "px") + : bound, + ); + } + + public matches(value: T): boolean { + if (!Comparable.isComparable(value)) { + return false; + } + + // Since we need to match both bounds, we return false if one is not + // matched and keep true for the default return at the end. + for (const minimum of this._minimum) { + if (minimum.isInclusive) { + // value is inclusively larger than the minimum if it is not + // strictly smaller than it. + if (value.compare(minimum.value) < 0) { + return false; + } + } else { + if (value.compare(minimum.value) <= 0) { + return false; + } + } + } + + for (const maximum of this._maximum) { + if (maximum.isInclusive) { + if (value.compare(maximum.value) > 0) { + return false; + } + } else { + if (value.compare(maximum.value) >= 0) { + return false; + } + } + } + + return true; + } + + public hasValue( + refinement: Refinement, + ): this is Discrete { + return ( + this._minimum.every((bound) => refinement(bound.value)) && + this._maximum.every((bound) => refinement(bound.value)) + ); + } + + public toJSON(): Range.JSON { + return { + type: "range", + minimum: this._minimum.map((bound) => bound.toJSON()).getOr(null), + maximum: this._maximum.map((bound) => bound.toJSON()).getOr(null), + }; + } +} + +/** + * @public + */ +export namespace Range { + export interface JSON { + [key: string]: json.JSON; + type: "range"; + minimum: Bound.JSON | null; + maximum: Bound.JSON | null; + } + + export function isRange(value: unknown): value is Range { + return value instanceof Range; + } +} diff --git a/packages/alfa-media/src/value/value.ts b/packages/alfa-media/src/value/value.ts index f22e5f28f7..5559f50094 100644 --- a/packages/alfa-media/src/value/value.ts +++ b/packages/alfa-media/src/value/value.ts @@ -1,13 +1,17 @@ -import { Comparable } from "@siteimprove/alfa-comparable"; -import { Length, Number } from "@siteimprove/alfa-css"; -import { Equatable } from "@siteimprove/alfa-equatable"; import { Functor } from "@siteimprove/alfa-functor"; -import * as json from "@siteimprove/alfa-json"; import { Serializable } from "@siteimprove/alfa-json"; import { Mapper } from "@siteimprove/alfa-mapper"; -import { None, Option } from "@siteimprove/alfa-option"; import { Refinement } from "@siteimprove/alfa-refinement"; +import * as json from "@siteimprove/alfa-json"; + +import * as boundValue from "./bound"; +import * as discreteValue from "./discrete"; +import * as rangeValue from "./range"; + +/** + * @public + */ export interface Value extends Functor, Serializable { @@ -17,181 +21,21 @@ export interface Value toJSON(): Value.JSON; } +/** + * @public + */ export namespace Value { export interface JSON { [key: string]: json.JSON; type: string; } - export class Discrete - implements Value, Serializable> - { - public static of(value: T): Discrete { - return new Discrete(value); - } - - private readonly _value: T; - - private constructor(value: T) { - this._value = value; - } - - public get value(): T { - return this._value; - } - - public map(mapper: Mapper): Discrete { - return new Discrete(mapper(this._value)); - } - - public matches(value: T): boolean { - return Equatable.equals(this._value, value); - } - - public hasValue( - refinement: Refinement, - ): this is Discrete { - return refinement(this._value); - } - - public toJSON(): Discrete.JSON { - return { - type: "discrete", - value: Serializable.toJSON(this._value), - }; - } - } - - export namespace Discrete { - export interface JSON { - [key: string]: json.JSON; - type: "discrete"; - value: Serializable.ToJSON; - } - - export function isDiscrete(value: unknown): value is Discrete { - return value instanceof Discrete; - } - } + export import Bound = boundValue.Bound; + export import Discrete = discreteValue.Discrete; + export import Range = rangeValue.Range; export const { of: discrete, isDiscrete } = Discrete; - export class Range - implements Value, Serializable> - { - public static of(minimum: Bound, maximum: Bound): Range { - return new Range(Option.of(minimum), Option.of(maximum)); - } - - public static minimum(minimum: Bound): Range { - return new Range(Option.of(minimum), None); - } - - public static maximum(maximum: Bound): Range { - return new Range(None, Option.of(maximum)); - } - - private readonly _minimum: Option>; - private readonly _maximum: Option>; - - private constructor(minimum: Option>, maximum: Option>) { - this._minimum = minimum; - this._maximum = maximum; - } - - public get minimum(): Option> { - return this._minimum; - } - - public get maximum(): Option> { - return this._maximum; - } - - public map(mapper: Mapper): Range { - return new Range( - this._minimum.map((bound) => bound.map(mapper)), - this._maximum.map((bound) => bound.map(mapper)), - ); - } - - public toLength(): Range> { - return this.map((bound) => - Refinement.and( - Number.isNumber, - (value) => !value.hasCalculation() && value.value === 0, - )(bound) - ? Length.of(0, "px") - : bound, - ); - } - - public matches(value: T): boolean { - if (!Comparable.isComparable(value)) { - return false; - } - - // Since we need to match both bounds, we return false if one is not - // matched and keep true for the default return at the end. - for (const minimum of this._minimum) { - if (minimum.isInclusive) { - // value is inclusively larger than the minimum if it is not - // strictly smaller than it. - if (value.compare(minimum.value) < 0) { - return false; - } - } else { - if (value.compare(minimum.value) <= 0) { - return false; - } - } - } - - for (const maximum of this._maximum) { - if (maximum.isInclusive) { - if (value.compare(maximum.value) > 0) { - return false; - } - } else { - if (value.compare(maximum.value) >= 0) { - return false; - } - } - } - - return true; - } - - public hasValue( - refinement: Refinement, - ): this is Discrete { - return ( - this._minimum.every((bound) => refinement(bound.value)) && - this._maximum.every((bound) => refinement(bound.value)) - ); - } - - public toJSON(): Range.JSON { - return { - type: "range", - minimum: this._minimum.map((bound) => bound.toJSON()).getOr(null), - maximum: this._maximum.map((bound) => bound.toJSON()).getOr(null), - }; - } - } - - export namespace Range { - export interface JSON { - [key: string]: json.JSON; - type: "range"; - minimum: Bound.JSON | null; - maximum: Bound.JSON | null; - } - - export function isRange(value: unknown): value is Range { - return value instanceof Range; - } - } - export const { of: range, minimum: minimumRange, @@ -199,54 +43,5 @@ export namespace Value { isRange, } = Range; - export class Bound - implements Functor, Serializable> - { - public static of(value: T, isInclusive: boolean): Bound { - return new Bound(value, isInclusive); - } - - private readonly _value: T; - private readonly _isInclusive: boolean; - - private constructor(value: T, isInclusive: boolean) { - this._value = value; - this._isInclusive = isInclusive; - } - - public get value(): T { - return this._value; - } - - public get isInclusive(): boolean { - return this._isInclusive; - } - - public map(mapper: Mapper): Bound { - return new Bound(mapper(this._value), this._isInclusive); - } - - public hasValue( - refinement: Refinement, - ): this is Bound { - return refinement(this._value); - } - - public toJSON(): Bound.JSON { - return { - value: Serializable.toJSON(this._value), - isInclusive: this._isInclusive, - }; - } - } - - export namespace Bound { - export interface JSON { - [key: string]: json.JSON; - value: Serializable.ToJSON; - isInclusive: boolean; - } - } - export const { of: bound } = Bound; } diff --git a/packages/alfa-media/test/media.spec.ts b/packages/alfa-media/test/media.spec.ts index bb906663c6..d08b0f57a5 100644 --- a/packages/alfa-media/test/media.spec.ts +++ b/packages/alfa-media/test/media.spec.ts @@ -19,10 +19,7 @@ test(".parse() parses a simple query for an orientation feature", (t) => { name: "orientation", value: { type: "discrete", - value: { - type: "keyword", - value: "portrait", - }, + value: { type: "keyword", value: "portrait" }, }, }, }, @@ -40,11 +37,7 @@ test(".parse() parses a simple query for a length feature", (t) => { value: { type: "range", minimum: { - value: { - type: "length", - value: 0, - unit: "px", - }, + value: { type: "length", value: 0, unit: "px" }, isInclusive: true, }, maximum: null, @@ -62,13 +55,7 @@ test(".parse() parses a list of queries", (t) => { .getUnsafe() .toJSON(), [ - { - modifier: null, - type: { - name: "screen", - }, - condition: null, - }, + { modifier: null, type: { name: "screen" }, condition: null }, { modifier: null, type: null, @@ -79,10 +66,7 @@ test(".parse() parses a list of queries", (t) => { name: "orientation", value: { type: "discrete", - value: { - type: "keyword", - value: "landscape", - }, + value: { type: "keyword", value: "landscape" }, }, }, right: { @@ -94,11 +78,7 @@ test(".parse() parses a list of queries", (t) => { type: "range", minimum: null, maximum: { - value: { - type: "length", - value: 640, - unit: "px", - }, + value: { type: "length", value: 640, unit: "px" }, isInclusive: true, }, }, @@ -111,11 +91,7 @@ test(".parse() parses a list of queries", (t) => { value: { type: "range", minimum: { - value: { - type: "length", - value: 100, - unit: "px", - }, + value: { type: "length", value: 100, unit: "px" }, isInclusive: true, }, maximum: null, @@ -137,9 +113,7 @@ test(".parse() parses a list of mixed type and feature queries", (t) => { [ { modifier: null, - type: { - name: "screen", - }, + type: { name: "screen" }, condition: { type: "and", left: { @@ -147,10 +121,7 @@ test(".parse() parses a list of mixed type and feature queries", (t) => { name: "orientation", value: { type: "discrete", - value: { - type: "keyword", - value: "portrait", - }, + value: { type: "keyword", value: "portrait" }, }, }, right: { @@ -159,11 +130,7 @@ test(".parse() parses a list of mixed type and feature queries", (t) => { value: { type: "range", minimum: { - value: { - type: "length", - value: 100, - unit: "px", - }, + value: { type: "length", value: 100, unit: "px" }, isInclusive: true, }, maximum: null, @@ -181,18 +148,13 @@ test(".parse() does not create a modifier in the absence of a type", (t) => { [ { modifier: "not", - type: { - name: "screen", - }, + type: { name: "screen" }, condition: { type: "feature", name: "orientation", value: { type: "discrete", - value: { - type: "keyword", - value: "landscape", - }, + value: { type: "keyword", value: "landscape" }, }, }, }, @@ -210,10 +172,7 @@ test(".parse() does not create a modifier in the absence of a type", (t) => { name: "orientation", value: { type: "discrete", - value: { - type: "keyword", - value: "landscape", - }, + value: { type: "keyword", value: "landscape" }, }, }, }, @@ -240,9 +199,7 @@ for (const input of [ t.deepEqual(parse(`${input}`).getUnsafe().toJSON(), [ { modifier: "not", - type: { - name: "all", - }, + type: { name: "all" }, condition: null, }, ]); @@ -253,9 +210,7 @@ test(`.parse() only drops invalid queries in a list, but leaves valid queries`, t.deepEqual(parse("(max-weight: 3px), (width: 100px)").getUnsafe().toJSON(), [ { modifier: "not", - type: { - name: "all", - }, + type: { name: "all" }, condition: null, }, { @@ -266,11 +221,7 @@ test(`.parse() only drops invalid queries in a list, but leaves valid queries`, name: "width", value: { type: "discrete", - value: { - type: "length", - value: 100, - unit: "px", - }, + value: { type: "length", value: 100, unit: "px" }, }, }, }, @@ -281,9 +232,7 @@ test(".parse() accepts unknown media types", (t) => { t.deepEqual(parse("unknown").getUnsafe().toJSON(), [ { modifier: null, - type: { - name: "unknown", - }, + type: { name: "unknown" }, condition: null, }, ]); @@ -300,11 +249,7 @@ test(".parse() parses a value < feature range", (t) => { value: { type: "range", minimum: { - value: { - type: "length", - value: 100, - unit: "px", - }, + value: { type: "length", value: 100, unit: "px" }, isInclusive: false, }, maximum: null, @@ -325,11 +270,7 @@ test(".parse() parses a feature > value range", (t) => { value: { type: "range", minimum: { - value: { - type: "length", - value: 100, - unit: "px", - }, + value: { type: "length", value: 100, unit: "px" }, isInclusive: false, }, maximum: null, @@ -350,11 +291,7 @@ test(".parse() parses a length feature > 0 as a dimensional bound", (t) => { value: { type: "range", minimum: { - value: { - type: "length", - value: 0, - unit: "px", - }, + value: { type: "length", value: 0, unit: "px" }, isInclusive: false, }, maximum: null, @@ -375,19 +312,11 @@ test(".parse() parses a value < feature < value range", (t) => { value: { type: "range", minimum: { - value: { - type: "length", - value: 100, - unit: "px", - }, + value: { type: "length", value: 100, unit: "px" }, isInclusive: false, }, maximum: { - value: { - type: "length", - value: 500, - unit: "px", - }, + value: { type: "length", value: 500, unit: "px" }, isInclusive: false, }, }, @@ -407,19 +336,11 @@ test(".parse() parses 0 in a length range as a dimensional bound", (t) => { value: { type: "range", minimum: { - value: { - type: "length", - value: 0, - unit: "px", - }, + value: { type: "length", value: 0, unit: "px" }, isInclusive: false, }, maximum: { - value: { - type: "length", - value: 500, - unit: "px", - }, + value: { type: "length", value: 500, unit: "px" }, isInclusive: false, }, }, diff --git a/packages/alfa-media/tsconfig.json b/packages/alfa-media/tsconfig.json index 46e884c9ff..137a20af0f 100644 --- a/packages/alfa-media/tsconfig.json +++ b/packages/alfa-media/tsconfig.json @@ -13,7 +13,10 @@ "src/query.ts", "src/resolver.ts", "src/type.ts", + "src/value/bound.ts", + "src/value/discrete.ts", "src/value/index.ts", + "src/value/range.ts", "src/value/value.ts", "test/media.spec.ts" ], From 3908fd64a5d8a799de63a92edbe72cafdaeaa0ca Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Thu, 21 Dec 2023 16:26:56 +0100 Subject: [PATCH 06/16] Break down parser circular references --- packages/alfa-media/package.json | 3 +- .../alfa-media/src/condition/condition.ts | 171 ++++++++++-------- packages/alfa-media/src/query.ts | 11 +- packages/alfa-media/tsconfig.json | 1 + 4 files changed, 103 insertions(+), 83 deletions(-) diff --git a/packages/alfa-media/package.json b/packages/alfa-media/package.json index 54f8067d03..3b0b51899c 100644 --- a/packages/alfa-media/package.json +++ b/packages/alfa-media/package.json @@ -31,7 +31,8 @@ "@siteimprove/alfa-predicate": "workspace:^0.71.1", "@siteimprove/alfa-refinement": "workspace:^0.71.1", "@siteimprove/alfa-result": "workspace:^0.71.1", - "@siteimprove/alfa-slice": "workspace:^0.71.1" + "@siteimprove/alfa-slice": "workspace:^0.71.1", + "@siteimprove/alfa-thunk": "workspace:^0.71.1" }, "devDependencies": { "@siteimprove/alfa-test": "workspace:^0.71.1" diff --git a/packages/alfa-media/src/condition/condition.ts b/packages/alfa-media/src/condition/condition.ts index 4c6c0f192e..9fbb06b45b 100644 --- a/packages/alfa-media/src/condition/condition.ts +++ b/packages/alfa-media/src/condition/condition.ts @@ -4,7 +4,7 @@ import { Equatable } from "@siteimprove/alfa-equatable"; import { Iterable } from "@siteimprove/alfa-iterable"; import { Serializable } from "@siteimprove/alfa-json"; import { Parser } from "@siteimprove/alfa-parser"; -import { Slice } from "@siteimprove/alfa-slice"; +import type { Thunk } from "@siteimprove/alfa-thunk"; import * as json from "@siteimprove/alfa-json"; @@ -83,6 +83,20 @@ export namespace And { export function isAnd(value: unknown): value is And { return value instanceof And; } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-and} + * + * @internal + */ + export function parse( + parseInParens: Thunk>, + ): CSSParser { + return right( + delimited(option(Token.parseWhitespace), Token.parseIdent("and")), + parseInParens(), + ); + } } export class Or @@ -154,6 +168,20 @@ export namespace Or { export function isOr(value: unknown): value is Or { return value instanceof Or; } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-or} + * + * @internal + */ + export function parse( + parseInParens: Thunk>, + ): CSSParser { + return right( + delimited(option(Token.parseWhitespace), Token.parseIdent("or")), + parseInParens(), + ); + } } export class Not @@ -211,6 +239,23 @@ export namespace Not { export function isNot(value: unknown): value is Not { return value instanceof Not; } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-not} + * + * @internal + */ + export function parse( + parseInParens: Thunk>, + ): CSSParser { + return map( + right( + delimited(option(Token.parseWhitespace), Token.parseIdent("not")), + parseInParens(), + ), + Not.of, + ); + } } /** @@ -224,81 +269,59 @@ export namespace Condition { export function isCondition(value: unknown): value is Condition { return And.isAnd(value) || Or.isOr(value) || Not.isNot(value); } -} - -/** - * @remarks - * The condition parser is forward-declared as it is needed within its - * subparsers. - */ -export let parseCondition: CSSParser; -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-in-parens} - */ -const parseInParens: CSSParser = either( - delimited( - Token.parseOpenParenthesis, - delimited(option(Token.parseWhitespace), (input) => parseCondition(input)), - Token.parseCloseParenthesis, - ), - parseMediaFeature, -); - -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-not} - */ -const parseNot = map( - right( - delimited(option(Token.parseWhitespace), Token.parseIdent("not")), - parseInParens, - ), - Not.of, -); - -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-and} - */ -const parseAnd = right( - delimited(option(Token.parseWhitespace), Token.parseIdent("and")), - parseInParens, -); - -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-or} - */ -const parseOr = right( - delimited(option(Token.parseWhitespace), Token.parseIdent("or")), - parseInParens, -); - -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-condition} - */ -parseCondition = either( - parseNot, - either( - map( - pair( - parseInParens, - either( - map(oneOrMore(parseAnd), (queries) => [And.of, queries] as const), - map(oneOrMore(parseOr), (queries) => [Or.of, queries] as const), + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-in-parens} + */ + const parseInParens = either( + delimited( + Token.parseOpenParenthesis, + delimited(option(Token.parseWhitespace), (input) => parse(input)), + Token.parseCloseParenthesis, + ), + parseMediaFeature, + ); + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-condition} + */ + export const parse: CSSParser = either( + Not.parse(() => parseInParens), + either( + map( + pair( + parseInParens, + either( + map( + oneOrMore(And.parse(() => parseInParens)), + (queries) => [And.of, queries] as const, + ), + map( + oneOrMore(Or.parse(() => parseInParens)), + (queries) => [Or.of, queries] as const, + ), + ), ), + ([left, [constructor, right]]) => + Iterable.reduce( + right, + (left, right) => constructor(left, right), + left, + ), ), - ([left, [constructor, right]]) => - Iterable.reduce(right, (left, right) => constructor(left, right), left), + parseInParens, ), - parseInParens, - ), -); + ); -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-condition-without-or} - */ -export const parseConditionWithoutOr = either( - parseNot, - map(pair(parseInParens, zeroOrMore(parseAnd)), ([left, right]) => - [left, ...right].reduce((left, right) => And.of(left, right)), - ), -); + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-condition-without-or} + */ + export const parseWithoutOr = either( + Not.parse(() => parseInParens), + map( + pair(parseInParens, zeroOrMore(And.parse(() => parseInParens))), + ([left, right]) => + [left, ...right].reduce((left, right) => And.of(left, right)), + ), + ); +} diff --git a/packages/alfa-media/src/query.ts b/packages/alfa-media/src/query.ts index bff1f0d190..2e0f9e6c1c 100644 --- a/packages/alfa-media/src/query.ts +++ b/packages/alfa-media/src/query.ts @@ -5,12 +5,7 @@ import { Parser } from "@siteimprove/alfa-parser"; import * as json from "@siteimprove/alfa-json"; -import { - type Condition, - Not, - parseCondition, - parseConditionWithoutOr, -} from "./condition"; +import { Condition, Not } from "./condition"; import { Feature } from "./feature"; import type { Matchable } from "./matchable"; import { Modifier } from "./modifier"; @@ -135,7 +130,7 @@ export namespace Query { */ export const parse = left( either( - map(parseCondition, (condition) => + map(Condition.parse, (condition) => Query.of(None, None, Option.of(condition)), ), map( @@ -147,7 +142,7 @@ export namespace Query { option( right( delimited(option(Token.parseWhitespace), Token.parseIdent("and")), - parseConditionWithoutOr, + Condition.parseWithoutOr, ), ), ), diff --git a/packages/alfa-media/tsconfig.json b/packages/alfa-media/tsconfig.json index 137a20af0f..02442526bc 100644 --- a/packages/alfa-media/tsconfig.json +++ b/packages/alfa-media/tsconfig.json @@ -35,6 +35,7 @@ { "path": "../alfa-refinement" }, { "path": "../alfa-result" }, { "path": "../alfa-slice" }, + { "path": "../alfa-thunk" }, { "path": "../alfa-test" } ] } From a3abdbff58eb019902956bd1a205193335b5add0 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Fri, 22 Dec 2023 10:39:59 +0100 Subject: [PATCH 07/16] Simplify parsers --- .../alfa-media/src/condition/condition.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/alfa-media/src/condition/condition.ts b/packages/alfa-media/src/condition/condition.ts index 9fbb06b45b..7d6f75cfe5 100644 --- a/packages/alfa-media/src/condition/condition.ts +++ b/packages/alfa-media/src/condition/condition.ts @@ -273,31 +273,32 @@ export namespace Condition { /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-in-parens} */ - const parseInParens = either( - delimited( - Token.parseOpenParenthesis, - delimited(option(Token.parseWhitespace), (input) => parse(input)), - Token.parseCloseParenthesis, - ), - parseMediaFeature, - ); + const parseInParens = () => + either( + delimited( + Token.parseOpenParenthesis, + delimited(option(Token.parseWhitespace), (input) => parse(input)), + Token.parseCloseParenthesis, + ), + parseMediaFeature, + ); /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-condition} */ export const parse: CSSParser = either( - Not.parse(() => parseInParens), + Not.parse(parseInParens), either( map( pair( - parseInParens, + parseInParens(), either( map( - oneOrMore(And.parse(() => parseInParens)), + oneOrMore(And.parse(parseInParens)), (queries) => [And.of, queries] as const, ), map( - oneOrMore(Or.parse(() => parseInParens)), + oneOrMore(Or.parse(parseInParens)), (queries) => [Or.of, queries] as const, ), ), @@ -309,7 +310,7 @@ export namespace Condition { left, ), ), - parseInParens, + parseInParens(), ), ); @@ -317,9 +318,9 @@ export namespace Condition { * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-condition-without-or} */ export const parseWithoutOr = either( - Not.parse(() => parseInParens), + Not.parse(parseInParens), map( - pair(parseInParens, zeroOrMore(And.parse(() => parseInParens))), + pair(parseInParens(), zeroOrMore(And.parse(parseInParens))), ([left, right]) => [left, ...right].reduce((left, right) => And.of(left, right)), ), From eed5a05728eddc59179c245746acf6369ec5912f Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Fri, 22 Dec 2023 11:01:00 +0100 Subject: [PATCH 08/16] Break down file --- packages/alfa-media/src/condition/and.ts | 100 +++++++ .../alfa-media/src/condition/condition.ts | 259 +----------------- packages/alfa-media/src/condition/index.ts | 3 + packages/alfa-media/src/condition/not.ts | 89 ++++++ packages/alfa-media/src/condition/or.ts | 100 +++++++ packages/alfa-media/tsconfig.json | 3 + 6 files changed, 302 insertions(+), 252 deletions(-) create mode 100644 packages/alfa-media/src/condition/and.ts create mode 100644 packages/alfa-media/src/condition/not.ts create mode 100644 packages/alfa-media/src/condition/or.ts diff --git a/packages/alfa-media/src/condition/and.ts b/packages/alfa-media/src/condition/and.ts new file mode 100644 index 0000000000..a25f01c38f --- /dev/null +++ b/packages/alfa-media/src/condition/and.ts @@ -0,0 +1,100 @@ +import { Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { Equatable } from "@siteimprove/alfa-equatable"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Parser } from "@siteimprove/alfa-parser"; +import type { Thunk } from "@siteimprove/alfa-thunk"; + +import * as json from "@siteimprove/alfa-json"; + +import type { Feature } from "../feature"; +import type { Matchable } from "../matchable"; +import type { Condition } from "./condition"; + +const { delimited, option, right } = Parser; + +export class And + implements Matchable, Iterable, Equatable, Serializable +{ + public static of(left: Feature | Condition, right: Feature | Condition): And { + return new And(left, right); + } + + private readonly _left: Feature | Condition; + private readonly _right: Feature | Condition; + + private constructor(left: Feature | Condition, right: Feature | Condition) { + this._left = left; + this._right = right; + } + + public get left(): Feature | Condition { + return this._left; + } + + public get right(): Feature | Condition { + return this._right; + } + + public matches(device: Device): boolean { + return this._left.matches(device) && this._right.matches(device); + } + + public equals(value: unknown): value is this { + return ( + value instanceof And && + value._left.equals(this._left) && + value._right.equals(this._right) + ); + } + + public *iterator(): Iterator { + for (const condition of [this._left, this._right]) { + yield* condition; + } + } + + public [Symbol.iterator](): Iterator { + return this.iterator(); + } + + public toJSON(): And.JSON { + return { + type: "and", + left: this._left.toJSON(), + right: this._right.toJSON(), + }; + } + + public toString(): string { + return `(${this._left}) and (${this._right})`; + } +} + +export namespace And { + export interface JSON { + [key: string]: json.JSON; + type: "and"; + left: Feature.JSON | Condition.JSON; + right: Feature.JSON | Condition.JSON; + } + + export function isAnd(value: unknown): value is And { + return value instanceof And; + } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-and} + * + * @internal + */ + export function parse( + parseInParens: Thunk>, + ): CSSParser { + return right( + delimited(option(Token.parseWhitespace), Token.parseIdent("and")), + parseInParens(), + ); + } +} diff --git a/packages/alfa-media/src/condition/condition.ts b/packages/alfa-media/src/condition/condition.ts index 7d6f75cfe5..fc15ac2638 100644 --- a/packages/alfa-media/src/condition/condition.ts +++ b/packages/alfa-media/src/condition/condition.ts @@ -1,262 +1,14 @@ import { Parser as CSSParser, Token } from "@siteimprove/alfa-css"; -import { Device } from "@siteimprove/alfa-device"; -import { Equatable } from "@siteimprove/alfa-equatable"; import { Iterable } from "@siteimprove/alfa-iterable"; -import { Serializable } from "@siteimprove/alfa-json"; import { Parser } from "@siteimprove/alfa-parser"; -import type { Thunk } from "@siteimprove/alfa-thunk"; - -import * as json from "@siteimprove/alfa-json"; import { Feature, parseMediaFeature } from "../feature"; -import type { Matchable } from "../matchable"; - -const { delimited, either, map, oneOrMore, option, pair, right, zeroOrMore } = - Parser; - -export class And - implements Matchable, Iterable, Equatable, Serializable -{ - public static of(left: Feature | Condition, right: Feature | Condition): And { - return new And(left, right); - } - - private readonly _left: Feature | Condition; - private readonly _right: Feature | Condition; - - private constructor(left: Feature | Condition, right: Feature | Condition) { - this._left = left; - this._right = right; - } - - public get left(): Feature | Condition { - return this._left; - } - - public get right(): Feature | Condition { - return this._right; - } - - public matches(device: Device): boolean { - return this._left.matches(device) && this._right.matches(device); - } - - public equals(value: unknown): value is this { - return ( - value instanceof And && - value._left.equals(this._left) && - value._right.equals(this._right) - ); - } - - public *iterator(): Iterator { - for (const condition of [this._left, this._right]) { - yield* condition; - } - } - - public [Symbol.iterator](): Iterator { - return this.iterator(); - } - - public toJSON(): And.JSON { - return { - type: "and", - left: this._left.toJSON(), - right: this._right.toJSON(), - }; - } - - public toString(): string { - return `(${this._left}) and (${this._right})`; - } -} - -export namespace And { - export interface JSON { - [key: string]: json.JSON; - type: "and"; - left: Feature.JSON | Condition.JSON; - right: Feature.JSON | Condition.JSON; - } - - export function isAnd(value: unknown): value is And { - return value instanceof And; - } - - /** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-and} - * - * @internal - */ - export function parse( - parseInParens: Thunk>, - ): CSSParser { - return right( - delimited(option(Token.parseWhitespace), Token.parseIdent("and")), - parseInParens(), - ); - } -} - -export class Or - implements Matchable, Iterable, Equatable, Serializable -{ - public static of(left: Feature | Condition, right: Feature | Condition): Or { - return new Or(left, right); - } - private readonly _left: Feature | Condition; - private readonly _right: Feature | Condition; +import { And } from "./and"; +import { Not } from "./not"; +import { Or } from "./or"; - private constructor(left: Feature | Condition, right: Feature | Condition) { - this._left = left; - this._right = right; - } - - public get left(): Feature | Condition { - return this._left; - } - - public get right(): Feature | Condition { - return this._right; - } - - public matches(device: Device): boolean { - return this._left.matches(device) || this._right.matches(device); - } - - public equals(value: unknown): value is this { - return ( - value instanceof Or && - value._left.equals(this._left) && - value._right.equals(this._right) - ); - } - - public *iterator(): Iterator { - for (const condition of [this._left, this._right]) { - yield* condition; - } - } - - public [Symbol.iterator](): Iterator { - return this.iterator(); - } - - public toJSON(): Or.JSON { - return { - type: "or", - left: this._left.toJSON(), - right: this._right.toJSON(), - }; - } - - public toString(): string { - return `(${this._left}) or (${this._right})`; - } -} - -export namespace Or { - export interface JSON { - [key: string]: json.JSON; - type: "or"; - left: Feature.JSON | Condition.JSON; - right: Feature.JSON | Condition.JSON; - } - - export function isOr(value: unknown): value is Or { - return value instanceof Or; - } - - /** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-or} - * - * @internal - */ - export function parse( - parseInParens: Thunk>, - ): CSSParser { - return right( - delimited(option(Token.parseWhitespace), Token.parseIdent("or")), - parseInParens(), - ); - } -} - -export class Not - implements Matchable, Iterable, Equatable, Serializable -{ - public static of(condition: Feature | Condition): Not { - return new Not(condition); - } - - private readonly _condition: Feature | Condition; - - private constructor(condition: Feature | Condition) { - this._condition = condition; - } - - public get condition(): Feature | Condition { - return this._condition; - } - - public matches(device: Device): boolean { - return !this._condition.matches(device); - } - - public equals(value: unknown): value is this { - return value instanceof Not && value._condition.equals(this._condition); - } - - public *iterator(): Iterator { - yield* this._condition; - } - - public [Symbol.iterator](): Iterator { - return this.iterator(); - } - - public toJSON(): Not.JSON { - return { - type: "not", - condition: this._condition.toJSON(), - }; - } - - public toString(): string { - return `not (${this._condition})`; - } -} - -export namespace Not { - export interface JSON { - [key: string]: json.JSON; - type: "not"; - condition: Condition.JSON | Feature.JSON; - } - - export function isNot(value: unknown): value is Not { - return value instanceof Not; - } - - /** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-not} - * - * @internal - */ - export function parse( - parseInParens: Thunk>, - ): CSSParser { - return map( - right( - delimited(option(Token.parseWhitespace), Token.parseIdent("not")), - parseInParens(), - ), - Not.of, - ); - } -} +const { delimited, either, map, oneOrMore, option, pair, zeroOrMore } = Parser; /** * {@link https://drafts.csswg.org/mediaqueries-5/#media-conditions} @@ -272,6 +24,9 @@ export namespace Condition { /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-in-parens} + * + * @privateRemarks + * This is a Thunk to allow dependency injection and break down circular dependencies. */ const parseInParens = () => either( diff --git a/packages/alfa-media/src/condition/index.ts b/packages/alfa-media/src/condition/index.ts index bc3dd77f79..a84e66bdbf 100644 --- a/packages/alfa-media/src/condition/index.ts +++ b/packages/alfa-media/src/condition/index.ts @@ -1 +1,4 @@ +export * from "./and"; export * from "./condition"; +export * from "./not"; +export * from "./or"; diff --git a/packages/alfa-media/src/condition/not.ts b/packages/alfa-media/src/condition/not.ts new file mode 100644 index 0000000000..fe0c124a9d --- /dev/null +++ b/packages/alfa-media/src/condition/not.ts @@ -0,0 +1,89 @@ +import { Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { Equatable } from "@siteimprove/alfa-equatable"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Parser } from "@siteimprove/alfa-parser"; +import type { Thunk } from "@siteimprove/alfa-thunk"; + +import * as json from "@siteimprove/alfa-json"; + +import type { Feature } from "../feature"; +import type { Matchable } from "../matchable"; +import type { Condition } from "./condition"; + +const { delimited, map, option, right } = Parser; + +export class Not + implements Matchable, Iterable, Equatable, Serializable +{ + public static of(condition: Feature | Condition): Not { + return new Not(condition); + } + + private readonly _condition: Feature | Condition; + + private constructor(condition: Feature | Condition) { + this._condition = condition; + } + + public get condition(): Feature | Condition { + return this._condition; + } + + public matches(device: Device): boolean { + return !this._condition.matches(device); + } + + public equals(value: unknown): value is this { + return value instanceof Not && value._condition.equals(this._condition); + } + + public *iterator(): Iterator { + yield* this._condition; + } + + public [Symbol.iterator](): Iterator { + return this.iterator(); + } + + public toJSON(): Not.JSON { + return { + type: "not", + condition: this._condition.toJSON(), + }; + } + + public toString(): string { + return `not (${this._condition})`; + } +} + +export namespace Not { + export interface JSON { + [key: string]: json.JSON; + type: "not"; + condition: Condition.JSON | Feature.JSON; + } + + export function isNot(value: unknown): value is Not { + return value instanceof Not; + } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-not} + * + * @internal + */ + export function parse( + parseInParens: Thunk>, + ): CSSParser { + return map( + right( + delimited(option(Token.parseWhitespace), Token.parseIdent("not")), + parseInParens(), + ), + Not.of, + ); + } +} diff --git a/packages/alfa-media/src/condition/or.ts b/packages/alfa-media/src/condition/or.ts new file mode 100644 index 0000000000..e05a3c479f --- /dev/null +++ b/packages/alfa-media/src/condition/or.ts @@ -0,0 +1,100 @@ +import { Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { Equatable } from "@siteimprove/alfa-equatable"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Parser } from "@siteimprove/alfa-parser"; +import type { Thunk } from "@siteimprove/alfa-thunk"; + +import * as json from "@siteimprove/alfa-json"; + +import type { Feature } from "../feature"; +import type { Matchable } from "../matchable"; +import type { Condition } from "./condition"; + +const { delimited, option, right } = Parser; + +export class Or + implements Matchable, Iterable, Equatable, Serializable +{ + public static of(left: Feature | Condition, right: Feature | Condition): Or { + return new Or(left, right); + } + + private readonly _left: Feature | Condition; + private readonly _right: Feature | Condition; + + private constructor(left: Feature | Condition, right: Feature | Condition) { + this._left = left; + this._right = right; + } + + public get left(): Feature | Condition { + return this._left; + } + + public get right(): Feature | Condition { + return this._right; + } + + public matches(device: Device): boolean { + return this._left.matches(device) || this._right.matches(device); + } + + public equals(value: unknown): value is this { + return ( + value instanceof Or && + value._left.equals(this._left) && + value._right.equals(this._right) + ); + } + + public *iterator(): Iterator { + for (const condition of [this._left, this._right]) { + yield* condition; + } + } + + public [Symbol.iterator](): Iterator { + return this.iterator(); + } + + public toJSON(): Or.JSON { + return { + type: "or", + left: this._left.toJSON(), + right: this._right.toJSON(), + }; + } + + public toString(): string { + return `(${this._left}) or (${this._right})`; + } +} + +export namespace Or { + export interface JSON { + [key: string]: json.JSON; + type: "or"; + left: Feature.JSON | Condition.JSON; + right: Feature.JSON | Condition.JSON; + } + + export function isOr(value: unknown): value is Or { + return value instanceof Or; + } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-or} + * + * @internal + */ + export function parse( + parseInParens: Thunk>, + ): CSSParser { + return right( + delimited(option(Token.parseWhitespace), Token.parseIdent("or")), + parseInParens(), + ); + } +} diff --git a/packages/alfa-media/tsconfig.json b/packages/alfa-media/tsconfig.json index 02442526bc..5efdcfdb7c 100644 --- a/packages/alfa-media/tsconfig.json +++ b/packages/alfa-media/tsconfig.json @@ -2,8 +2,11 @@ "$schema": "http://json.schemastore.org/tsconfig", "extends": "../tsconfig.json", "files": [ + "src/condition/and.ts", "src/condition/condition.ts", "src/condition/index.ts", + "src/condition/not.ts", + "src/condition/or.ts", "src/feature/feature.ts", "src/feature/index.ts", "src/index.ts", From 16cba951c9d9996d53d79fc4900a7b65047a8e16 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Fri, 22 Dec 2023 11:15:59 +0100 Subject: [PATCH 09/16] Extract Comparison --- packages/alfa-media/src/feature/comparison.ts | 50 +++++++++++++ packages/alfa-media/src/feature/feature.ts | 70 ++++--------------- packages/alfa-media/src/feature/height.ts | 0 .../alfa-media/src/feature/orientation.ts | 0 packages/alfa-media/src/feature/scripting.ts | 0 packages/alfa-media/src/feature/width.ts | 0 packages/alfa-media/tsconfig.json | 5 ++ 7 files changed, 68 insertions(+), 57 deletions(-) create mode 100644 packages/alfa-media/src/feature/comparison.ts create mode 100644 packages/alfa-media/src/feature/height.ts create mode 100644 packages/alfa-media/src/feature/orientation.ts create mode 100644 packages/alfa-media/src/feature/scripting.ts create mode 100644 packages/alfa-media/src/feature/width.ts diff --git a/packages/alfa-media/src/feature/comparison.ts b/packages/alfa-media/src/feature/comparison.ts new file mode 100644 index 0000000000..dab324f035 --- /dev/null +++ b/packages/alfa-media/src/feature/comparison.ts @@ -0,0 +1,50 @@ +import { type Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; + +const { either, map, option, right } = Parser; + +/** + * @internal + */ +export enum Comparison { + LessThan = "<", + LessThanOrEqual = "<=", + Equal = "=", + GreaterThan = ">", + GreaterThanOrEqual = ">=", +} + +/** + * @internal + */ +export namespace Comparison { + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-lt} + */ + export const parseLessThan: CSSParser< + Comparison.LessThan | Comparison.LessThanOrEqual + > = map( + right(Token.parseDelim("<"), option(Token.parseDelim("="))), + (equal) => + equal.isNone() ? Comparison.LessThan : Comparison.LessThanOrEqual, + ); + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-gt} + */ + export const parseGreaterThan = map( + right(Token.parseDelim(">"), option(Token.parseDelim("="))), + (equal) => + equal.isNone() ? Comparison.GreaterThan : Comparison.GreaterThanOrEqual, + ); + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-eq} + */ + export const parseEqual = map(Token.parseDelim("="), () => Comparison.Equal); + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-comparison} + */ + export const parse = either(parseEqual, parseLessThan, parseGreaterThan); +} diff --git a/packages/alfa-media/src/feature/feature.ts b/packages/alfa-media/src/feature/feature.ts index 39068499df..d883e67a90 100644 --- a/packages/alfa-media/src/feature/feature.ts +++ b/packages/alfa-media/src/feature/feature.ts @@ -20,17 +20,10 @@ import type { Matchable } from "../matchable"; import { Value } from "../value"; import { Resolver } from "../resolver"; -const { - delimited, - either, - filter, - map, - mapResult, - option, - pair, - right, - separated, -} = Parser; +import { Comparison } from "./comparison"; + +const { delimited, either, filter, map, mapResult, option, pair, separated } = + Parser; const { equals, property } = Predicate; /** @@ -344,14 +337,6 @@ export namespace Feature { } } -export enum Comparison { - LessThan = "<", - LessThanOrEqual = "<=", - Equal = "=", - GreaterThan = ">", - GreaterThanOrEqual = ">=", -} - /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-name} */ @@ -425,38 +410,6 @@ const parseFeatureBoolean = mapResult(parseFeatureName, (name) => Feature.tryFrom(None, name), ); -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-lt} - */ -const parseFeatureLessThan = map( - right(Token.parseDelim("<"), option(Token.parseDelim("="))), - (equal) => - equal.isNone() ? Comparison.LessThan : Comparison.LessThanOrEqual, -); - -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-gt} - */ -const parseFeatureGreaterThan = map( - right(Token.parseDelim(">"), option(Token.parseDelim("="))), - (equal) => - equal.isNone() ? Comparison.GreaterThan : Comparison.GreaterThanOrEqual, -); - -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-eq} - */ -const parseFeatureEqual = map(Token.parseDelim("="), () => Comparison.Equal); - -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-comparison} - */ -const parseFeatureComparison = either( - parseFeatureEqual, - parseFeatureLessThan, - parseFeatureGreaterThan, -); - /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-range} */ @@ -467,7 +420,7 @@ const parseFeatureRange = either( map( pair( parseFeatureValue, - delimited(option(Token.parseWhitespace), parseFeatureLessThan), + delimited(option(Token.parseWhitespace), Comparison.parseLessThan), ), ([value, comparison]) => Value.bound( @@ -479,7 +432,7 @@ const parseFeatureRange = either( delimited(option(Token.parseWhitespace), parseFeatureName), map( pair( - delimited(option(Token.parseWhitespace), parseFeatureLessThan), + delimited(option(Token.parseWhitespace), Comparison.parseLessThan), parseFeatureValue, ), ([comparison, value]) => @@ -500,7 +453,7 @@ const parseFeatureRange = either( map( pair( parseFeatureValue, - delimited(option(Token.parseWhitespace), parseFeatureGreaterThan), + delimited(option(Token.parseWhitespace), Comparison.parseGreaterThan), ), ([value, comparison]) => Value.bound( @@ -512,7 +465,10 @@ const parseFeatureRange = either( delimited(option(Token.parseWhitespace), parseFeatureName), map( pair( - delimited(option(Token.parseWhitespace), parseFeatureGreaterThan), + delimited( + option(Token.parseWhitespace), + Comparison.parseGreaterThan, + ), parseFeatureValue, ), ([comparison, value]) => @@ -532,7 +488,7 @@ const parseFeatureRange = either( pair( parseFeatureName, pair( - delimited(option(Token.parseWhitespace), parseFeatureComparison), + delimited(option(Token.parseWhitespace), Comparison.parse), parseFeatureValue, ), ), @@ -586,7 +542,7 @@ const parseFeatureRange = either( pair( parseFeatureValue, pair( - delimited(option(Token.parseWhitespace), parseFeatureComparison), + delimited(option(Token.parseWhitespace), Comparison.parse), parseFeatureName, ), ), diff --git a/packages/alfa-media/src/feature/height.ts b/packages/alfa-media/src/feature/height.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/alfa-media/src/feature/orientation.ts b/packages/alfa-media/src/feature/orientation.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/alfa-media/src/feature/scripting.ts b/packages/alfa-media/src/feature/scripting.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/alfa-media/src/feature/width.ts b/packages/alfa-media/src/feature/width.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/alfa-media/tsconfig.json b/packages/alfa-media/tsconfig.json index 5efdcfdb7c..eace73439b 100644 --- a/packages/alfa-media/tsconfig.json +++ b/packages/alfa-media/tsconfig.json @@ -7,8 +7,13 @@ "src/condition/index.ts", "src/condition/not.ts", "src/condition/or.ts", + "src/feature/comparison.ts", "src/feature/feature.ts", + "src/feature/height.ts", "src/feature/index.ts", + "src/feature/orientation.ts", + "src/feature/scripting.ts", + "src/feature/height.ts", "src/index.ts", "src/list.ts", "src/matchable.ts", From d7d5059226ecce0b54f71fbe39f949ca122834e9 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Fri, 22 Dec 2023 13:23:57 +0100 Subject: [PATCH 10/16] Break down big file --- .../alfa-media/src/condition/condition.ts | 4 +- packages/alfa-media/src/feature/feature.ts | 719 ++++++------------ packages/alfa-media/src/feature/height.ts | 77 ++ packages/alfa-media/src/feature/index.ts | 40 +- .../alfa-media/src/feature/orientation.ts | 65 ++ packages/alfa-media/src/feature/scripting.ts | 65 ++ packages/alfa-media/src/feature/width.ts | 77 ++ packages/alfa-media/tsconfig.json | 2 +- 8 files changed, 579 insertions(+), 470 deletions(-) diff --git a/packages/alfa-media/src/condition/condition.ts b/packages/alfa-media/src/condition/condition.ts index fc15ac2638..2c5ad5b5f5 100644 --- a/packages/alfa-media/src/condition/condition.ts +++ b/packages/alfa-media/src/condition/condition.ts @@ -2,7 +2,7 @@ import { Parser as CSSParser, Token } from "@siteimprove/alfa-css"; import { Iterable } from "@siteimprove/alfa-iterable"; import { Parser } from "@siteimprove/alfa-parser"; -import { Feature, parseMediaFeature } from "../feature"; +import { Feature } from "../feature"; import { And } from "./and"; import { Not } from "./not"; @@ -35,7 +35,7 @@ export namespace Condition { delimited(option(Token.parseWhitespace), (input) => parse(input)), Token.parseCloseParenthesis, ), - parseMediaFeature, + Feature.parse, ); /** diff --git a/packages/alfa-media/src/feature/feature.ts b/packages/alfa-media/src/feature/feature.ts index d883e67a90..3d2b0f1849 100644 --- a/packages/alfa-media/src/feature/feature.ts +++ b/packages/alfa-media/src/feature/feature.ts @@ -2,6 +2,7 @@ import { Keyword, Length, Number, + type Parser as CSSParser, Percentage, Token, } from "@siteimprove/alfa-css"; @@ -12,19 +13,16 @@ import * as json from "@siteimprove/alfa-json"; import { Serializable } from "@siteimprove/alfa-json"; import { None, Option } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; -import { Predicate } from "@siteimprove/alfa-predicate"; -import { Refinement } from "@siteimprove/alfa-refinement"; -import { Err, Ok, Result } from "@siteimprove/alfa-result"; +import { Err, Result } from "@siteimprove/alfa-result"; +import { Slice } from "@siteimprove/alfa-slice"; import type { Matchable } from "../matchable"; import { Value } from "../value"; -import { Resolver } from "../resolver"; import { Comparison } from "./comparison"; const { delimited, either, filter, map, mapResult, option, pair, separated } = Parser; -const { equals, property } = Predicate; /** * {@link https://drafts.csswg.org/mediaqueries-5/#mq-features} @@ -82,284 +80,49 @@ export abstract class Feature export namespace Feature { export interface JSON { [key: string]: json.JSON; + type: "feature"; name: string; value: Value.JSON | null; } - export function tryFrom( - value: Option>, - name: string, - ): Result { - switch (name) { - case "width": - return Width.tryFrom(value); - - case "height": - return Height.tryFrom(value); - - case "orientation": - return Orientation.tryFrom(value); - - case "scripting": - return Scripting.tryFrom(value); - } - - return Err.of(`Unknown media feature ${name}`); - } - - /** - * {@link https://drafts.csswg.org/mediaqueries-5/#width} - */ - class Width extends Feature { - public static of(value: Value): Width { - return new Width(Option.of(value)); - } - - private static _boolean = new Width(None); - - public static boolean(): Width { - return Width._boolean; - } - - public get name(): "width" { - return "width"; - } - - public matches(device: Device): boolean { - const { - viewport: { width }, - } = device; - - const value = this._value.map((value) => - value.map((length) => length.resolve(Resolver.length(device))), - ); - - return width > 0 - ? value.some((value) => value.matches(Length.of(width, "px"))) - : value.every((value) => value.matches(Length.of(0, "px"))); - } - } - - namespace Width { - export function tryFrom(value: Option): Result { - return value - .map((value) => (Value.Range.isRange(value) ? value.toLength() : value)) - .map((value) => { - if ( - value.hasValue(Length.isLength) && - value.hasValue( - (value): value is Length.Fixed => !value.hasCalculation(), - ) - ) { - return Ok.of(Width.of(value)); - } - - return Err.of(`Invalid value`); - }) - .getOrElse(() => Ok.of(Width.boolean())); - } - - export function isWidth(value: Feature): value is Width; - - export function isWidth(value: unknown): value is Width; - - export function isWidth(value: unknown): value is Width { - return value instanceof Width; - } - } - - export const { isWidth } = Width; - - /** - * {@link https://drafts.csswg.org/mediaqueries-5/#height} - */ - class Height extends Feature { - public static of(value: Value): Height { - return new Height(Option.of(value)); - } - - private static _boolean = new Height(None); - - public static boolean(): Height { - return Height._boolean; - } - - public get name(): "height" { - return "height"; - } - - public matches(device: Device): boolean { - const { - viewport: { height }, - } = device; - - const value = this._value.map((value) => - value.map((length) => length.resolve(Resolver.length(device))), - ); - - return height > 0 - ? value.some((value) => value.matches(Length.of(height, "px"))) - : value.every((value) => value.matches(Length.of(0, "px"))); - } - } - - namespace Height { - export function tryFrom(value: Option): Result { - return value - .map((value) => (Value.Range.isRange(value) ? value.toLength() : value)) - .map((value) => { - if ( - value.hasValue(Length.isLength) && - value.hasValue( - (value): value is Length.Fixed => !value.hasCalculation(), - ) - ) { - return Ok.of(Height.of(value)); - } - - return Err.of(`Invalid value`); - }) - .getOrElse(() => Ok.of(Height.boolean())); - } - - export function isHeight(value: Feature): value is Height; - - export function isHeight(value: unknown): value is Height; - - export function isHeight(value: unknown): value is Height { - return value instanceof Height; - } + export function isFeature(value: unknown): value is Feature { + return value instanceof Feature; } - export const { isHeight } = Height; - /** - * {@link https://drafts.csswg.org/mediaqueries-5/#orientation} + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-name} */ - class Orientation extends Feature { - public static of(value: Value): Orientation { - return new Orientation(Option.of(value)); - } - - private static _boolean = new Orientation(None); - - public static boolean(): Orientation { - return Orientation._boolean; - } - - public get name(): "orientation" { - return "orientation"; - } - - public matches(device: Device): boolean { - return this._value.every((value) => - value.matches(Keyword.of(device.viewport.orientation)), - ); - } - } - - namespace Orientation { - export function tryFrom( - value: Option>, - ): Result { - return value - .map((value) => { - if ( - Value.isDiscrete(value) && - value.hasValue( - Refinement.and( - Keyword.isKeyword, - property("value", equals("landscape", "portrait")), - ), - ) - ) { - return Ok.of(Orientation.of(value)); - } else { - return Err.of(`Invalid value`); - } - }) - .getOrElse(() => Ok.of(Orientation.boolean())); - } + function parseName( + name: string, + withRange: boolean = false, + ): CSSParser { + return filter( + map(Token.parseIdent(), (ident) => ident.value.toLowerCase()), + (parsed) => + parsed === name || + (withRange && (parsed === `min-${name}` || parsed === `max-${name}`)), + (parsed) => `Unknown feature ${parsed}`, + ); } /** - * {@link https://drafts.csswg.org/mediaqueries-5/#scripting} + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-value} + * + * @remarks + * We currently do not support calculations in media queries */ - class Scripting extends Feature { - public static of(value: Value): Scripting { - return new Scripting(Option.of(value)); - } - - private static _boolean = new Scripting(None); - - public static boolean(): Scripting { - return Scripting._boolean; - } - - public get name(): "scripting" { - return "scripting"; - } - - public matches(device: Device): boolean { - return device.scripting.enabled - ? this._value.every((value) => value.matches(Keyword.of("enabled"))) - : this._value.some((value) => value.matches(Keyword.of("none"))); - } - } - - namespace Scripting { - export function tryFrom( - value: Option>, - ): Result { - return value - .map((value) => { - if ( - Value.isDiscrete(value) && - value.hasValue( - Refinement.and( - Keyword.isKeyword, - property("value", equals("none", "enabled", "initial-only")), - ), - ) - ) { - return Ok.of(Scripting.of(value)); - } else { - return Err.of(`Invalid value`); - } - }) - .getOrElse(() => Ok.of(Scripting.boolean())); - } - } - - export function isFeature(value: unknown): value is Feature { - return value instanceof Feature; - } -} - -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-name} - */ -const parseFeatureName = map(Token.parseIdent(), (ident) => - ident.value.toLowerCase(), -); - -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-value} - * - * @remarks - * We currently do not support calculations in media queries - */ -const parseFeatureValue = either( - either( + const parseValue = either< + Slice, + Keyword | Length.Fixed | Number.Fixed | Percentage.Fixed, + string + >( filter( Number.parse, - (number) => !number.hasCalculation(), + (number): number is Number.Fixed => !number.hasCalculation(), () => "Calculations no supported in media queries", ), map(Token.parseIdent(), (ident) => Keyword.of(ident.value.toLowerCase())), - ), - either( map( separated( Token.parseNumber((number) => number.isInteger), @@ -370,236 +133,260 @@ const parseFeatureValue = either( ), filter( Length.parse, - (length) => !length.hasCalculation(), + (length): length is Length.Fixed => !length.hasCalculation(), () => "Calculations no supported in media queries", ), - ), -); + ); -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-plain} - */ -const parseFeaturePlain = mapResult( - separated( - parseFeatureName, - delimited(option(Token.parseWhitespace), Token.parseColon), - parseFeatureValue, - ), - ([name, value]) => { - if (name.startsWith("min-") || name.startsWith("max-")) { - const range = name.startsWith("min-") - ? Value.minimumRange - : Value.maximumRange; + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-plain} + */ + function parsePlain( + name: string, + withRange: boolean, + tryFrom: (value: Option>) => Result, + ): CSSParser { + return mapResult( + separated( + parseName(name, withRange), + delimited(option(Token.parseWhitespace), Token.parseColon), + parseValue, + ), + ([name, value]) => { + if (withRange && (name.startsWith("min-") || name.startsWith("max-"))) { + const range = name.startsWith("min-") + ? Value.minimumRange + : Value.maximumRange; - name = name.slice(4); + name = name.slice(4); - return Feature.tryFrom( - Option.of(range(Value.bound(value, /* isInclusive */ true))), - name, - ); - } else { - return Feature.tryFrom(Option.of(Value.discrete(value)), name); - } - }, -); + return tryFrom( + Option.of(range(Value.bound(value, /* isInclusive */ true))), + ); + } else { + return tryFrom(Option.of(Value.discrete(value))); + } + }, + ); + } -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-boolean} - */ -const parseFeatureBoolean = mapResult(parseFeatureName, (name) => - Feature.tryFrom(None, name), -); + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-boolean} + */ + function parseBoolean( + name: string, + tryFrom: (value: Option>) => Result, + ): CSSParser { + return mapResult(parseName(name), (name) => tryFrom(None)); + } -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-range} - */ -const parseFeatureRange = either( - // - mapResult( - pair( - map( + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-range} + */ + function parseRange( + name: string, + tryFrom: (value: Option>) => Result, + ): CSSParser { + return either( + // + mapResult( pair( - parseFeatureValue, - delimited(option(Token.parseWhitespace), Comparison.parseLessThan), - ), - ([value, comparison]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.LessThanOrEqual, - ), - ), - pair( - delimited(option(Token.parseWhitespace), parseFeatureName), - map( - pair( - delimited(option(Token.parseWhitespace), Comparison.parseLessThan), - parseFeatureValue, - ), - ([comparison, value]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.LessThanOrEqual, + map( + pair( + parseValue, + delimited( + option(Token.parseWhitespace), + Comparison.parseLessThan, + ), ), - ), - ), - ), - ([minimum, [name, maximum]]) => - Feature.tryFrom(Option.of(Value.range(minimum, maximum)), name), - ), - - // - mapResult( - pair( - map( - pair( - parseFeatureValue, - delimited(option(Token.parseWhitespace), Comparison.parseGreaterThan), - ), - ([value, comparison]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.GreaterThanOrEqual, + ([value, comparison]) => + Value.bound( + value, + /* isInclusive */ comparison === Comparison.LessThanOrEqual, + ), ), - ), - pair( - delimited(option(Token.parseWhitespace), parseFeatureName), - map( pair( - delimited( - option(Token.parseWhitespace), - Comparison.parseGreaterThan, + delimited(option(Token.parseWhitespace), parseName(name)), + map( + pair( + delimited( + option(Token.parseWhitespace), + Comparison.parseLessThan, + ), + parseValue, + ), + ([comparison, value]) => + Value.bound( + value, + /* isInclusive */ comparison === Comparison.LessThanOrEqual, + ), ), - parseFeatureValue, ), - ([comparison, value]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.GreaterThanOrEqual, - ), ), + ([minimum, [name, maximum]]) => + tryFrom(Option.of(Value.range(minimum, maximum))), ), - ), - ([maximum, [name, minimum]]) => - Feature.tryFrom(Option.of(Value.range(minimum, maximum)), name), - ), - // - mapResult( - pair( - parseFeatureName, - pair( - delimited(option(Token.parseWhitespace), Comparison.parse), - parseFeatureValue, - ), - ), - ([name, [comparison, value]]) => { - switch (comparison) { - case Comparison.Equal: - return Feature.tryFrom( - Option.of( - Value.range( - Value.bound(value, /* isInclude */ true), - Value.bound(value, /* isInclude */ true), + // + mapResult( + pair( + map( + pair( + parseValue, + delimited( + option(Token.parseWhitespace), + Comparison.parseGreaterThan, ), ), - name, - ); - - case Comparison.LessThan: - case Comparison.LessThanOrEqual: - return Feature.tryFrom( - Option.of( - Value.maximumRange( - Value.bound( - value, - /* isInclusive */ comparison === Comparison.LessThanOrEqual, + ([value, comparison]) => + Value.bound( + value, + /* isInclusive */ comparison === Comparison.GreaterThanOrEqual, + ), + ), + pair( + delimited(option(Token.parseWhitespace), parseName(name)), + map( + pair( + delimited( + option(Token.parseWhitespace), + Comparison.parseGreaterThan, ), + parseValue, ), - ), - name, - ); - - case Comparison.GreaterThan: - case Comparison.GreaterThanOrEqual: - return Feature.tryFrom( - Option.of( - Value.minimumRange( + ([comparison, value]) => Value.bound( value, /* isInclusive */ comparison === Comparison.GreaterThanOrEqual, ), - ), ), - name, - ); - } - }, - ), - - // - mapResult( - pair( - parseFeatureValue, - pair( - delimited(option(Token.parseWhitespace), Comparison.parse), - parseFeatureName, + ), + ), + ([maximum, [name, minimum]]) => + tryFrom(Option.of(Value.range(minimum, maximum))), ), - ), - ([value, [comparison, name]]) => { - switch (comparison) { - case Comparison.Equal: - return Feature.tryFrom( - Option.of( - Value.range( - Value.bound(value, /* isInclude */ true), - Value.bound(value, /* isInclude */ true), - ), - ), - name, - ); - case Comparison.LessThan: - case Comparison.LessThanOrEqual: - return Feature.tryFrom( - Option.of( - Value.minimumRange( - Value.bound( - value, - /* isInclusive */ comparison === Comparison.LessThanOrEqual, + // + mapResult( + pair( + parseName(name), + pair( + delimited(option(Token.parseWhitespace), Comparison.parse), + parseValue, + ), + ), + ([name, [comparison, value]]) => { + switch (comparison) { + case Comparison.Equal: + return tryFrom( + Option.of( + Value.range( + Value.bound(value, /* isInclude */ true), + Value.bound(value, /* isInclude */ true), + ), ), - ), - ), - name, - ); + ); + + case Comparison.LessThan: + case Comparison.LessThanOrEqual: + return tryFrom( + Option.of( + Value.maximumRange( + Value.bound( + value, + /* isInclusive */ comparison === + Comparison.LessThanOrEqual, + ), + ), + ), + ); + + case Comparison.GreaterThan: + case Comparison.GreaterThanOrEqual: + return tryFrom( + Option.of( + Value.minimumRange( + Value.bound( + value, + /* isInclusive */ comparison === + Comparison.GreaterThanOrEqual, + ), + ), + ), + ); + } + }, + ), - case Comparison.GreaterThan: - case Comparison.GreaterThanOrEqual: - return Feature.tryFrom( - Option.of( - Value.maximumRange( - Value.bound( - value, - /* isInclusive */ comparison === - Comparison.GreaterThanOrEqual, + // + mapResult( + pair( + parseValue, + pair( + delimited(option(Token.parseWhitespace), Comparison.parse), + parseName(name), + ), + ), + ([value, [comparison, name]]) => { + switch (comparison) { + case Comparison.Equal: + return tryFrom( + Option.of( + Value.range( + Value.bound(value, /* isInclude */ true), + Value.bound(value, /* isInclude */ true), + ), ), - ), - ), - name, - ); - } - }, - ), -); + ); + + case Comparison.LessThan: + case Comparison.LessThanOrEqual: + return tryFrom( + Option.of( + Value.minimumRange( + Value.bound( + value, + /* isInclusive */ comparison === + Comparison.LessThanOrEqual, + ), + ), + ), + ); + + case Comparison.GreaterThan: + case Comparison.GreaterThanOrEqual: + return tryFrom( + Option.of( + Value.maximumRange( + Value.bound( + value, + /* isInclusive */ comparison === + Comparison.GreaterThanOrEqual, + ), + ), + ), + ); + } + }, + ), + ); + } -/** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-feature} - */ -export const parseMediaFeature = delimited( - Token.parseOpenParenthesis, - delimited( - option(Token.parseWhitespace), - either(parseFeatureRange, parseFeaturePlain, parseFeatureBoolean), - ), - Token.parseCloseParenthesis, -); + /** + * @internal + */ + export function parseFeature( + name: string, + withRange: boolean, + tryFrom: (value: Option>) => Result, + ): CSSParser { + return either( + withRange + ? parseRange(name, tryFrom) + : () => Err.of(`${name} not allowed in range context`), + parsePlain(name, withRange, tryFrom), + parseBoolean(name, tryFrom), + ); + } +} diff --git a/packages/alfa-media/src/feature/height.ts b/packages/alfa-media/src/feature/height.ts index e69de29bb2..1725b45714 100644 --- a/packages/alfa-media/src/feature/height.ts +++ b/packages/alfa-media/src/feature/height.ts @@ -0,0 +1,77 @@ +import { Length } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Err, Ok, type Result } from "@siteimprove/alfa-result"; + +import { Resolver } from "../resolver"; +import { Value } from "../value"; + +import { Feature } from "./feature"; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#height} + * + * @internal + */ +export class Height extends Feature { + public static of(value: Value): Height { + return new Height(Option.of(value)); + } + + private static _boolean = new Height(None); + + public static boolean(): Height { + return Height._boolean; + } + + public get name(): "height" { + return "height"; + } + + public matches(device: Device): boolean { + const { + viewport: { height }, + } = device; + + const value = this._value.map((value) => + value.map((length) => length.resolve(Resolver.length(device))), + ); + + return height > 0 + ? value.some((value) => value.matches(Length.of(height, "px"))) + : value.every((value) => value.matches(Length.of(0, "px"))); + } +} + +/** + * @internal + */ +export namespace Height { + function tryFrom(value: Option): Result { + return value + .map((value) => (Value.Range.isRange(value) ? value.toLength() : value)) + .map((value) => { + if ( + value.hasValue(Length.isLength) && + value.hasValue( + (value): value is Length.Fixed => !value.hasCalculation(), + ) + ) { + return Ok.of(Height.of(value)); + } + + return Err.of(`Invalid value`); + }) + .getOrElse(() => Ok.of(Height.boolean())); + } + + export function isHeight(value: Feature): value is Height; + + export function isHeight(value: unknown): value is Height; + + export function isHeight(value: unknown): value is Height { + return value instanceof Height; + } + + export const parse = Feature.parseFeature("height", true, tryFrom); +} diff --git a/packages/alfa-media/src/feature/index.ts b/packages/alfa-media/src/feature/index.ts index d0d5122433..dc634f2e82 100644 --- a/packages/alfa-media/src/feature/index.ts +++ b/packages/alfa-media/src/feature/index.ts @@ -1 +1,39 @@ -export * from "./feature"; +import { Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; + +import * as feature from "./feature"; + +import * as height from "./height"; +import * as orientation from "./orientation"; +import * as scripting from "./scripting"; +import * as width from "./width"; + +const { delimited, either, option } = Parser; + +export type Feature = feature.Feature; + +export namespace Feature { + export type JSON = feature.Feature.JSON; + + export import Height = height.Height; + export import Orientation = orientation.Orientation; + export import Scripting = scripting.Scripting; + export import Width = width.Width; + + export const { isHeight } = Height; + export const { isWidth } = Width; + + export const { isFeature } = feature.Feature; + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-media-feature} + */ + export const parse = delimited( + Token.parseOpenParenthesis, + delimited( + option(Token.parseWhitespace), + either(Height.parse, Orientation.parse, Scripting.parse, Width.parse), + ), + Token.parseCloseParenthesis, + ); +} diff --git a/packages/alfa-media/src/feature/orientation.ts b/packages/alfa-media/src/feature/orientation.ts index e69de29bb2..a2f63dc1d8 100644 --- a/packages/alfa-media/src/feature/orientation.ts +++ b/packages/alfa-media/src/feature/orientation.ts @@ -0,0 +1,65 @@ +import { Keyword } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Refinement } from "@siteimprove/alfa-refinement"; +import { Err, Ok, type Result } from "@siteimprove/alfa-result"; + +import { Value } from "../value"; +import { Feature } from "./feature"; + +const { equals, property } = Predicate; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#orientation} + * + * @internal + */ +export class Orientation extends Feature { + public static of(value: Value): Orientation { + return new Orientation(Option.of(value)); + } + + private static _boolean = new Orientation(None); + + public static boolean(): Orientation { + return Orientation._boolean; + } + + public get name(): "orientation" { + return "orientation"; + } + + public matches(device: Device): boolean { + return this._value.every((value) => + value.matches(Keyword.of(device.viewport.orientation)), + ); + } +} + +/** + * @internal + */ +export namespace Orientation { + function tryFrom(value: Option>): Result { + return value + .map((value) => { + if ( + Value.isDiscrete(value) && + value.hasValue( + Refinement.and( + Keyword.isKeyword, + property("value", equals("landscape", "portrait")), + ), + ) + ) { + return Ok.of(Orientation.of(value)); + } else { + return Err.of(`Invalid value`); + } + }) + .getOrElse(() => Ok.of(Orientation.boolean())); + } + + export const parse = Feature.parseFeature("orientation", false, tryFrom); +} diff --git a/packages/alfa-media/src/feature/scripting.ts b/packages/alfa-media/src/feature/scripting.ts index e69de29bb2..9b291b976d 100644 --- a/packages/alfa-media/src/feature/scripting.ts +++ b/packages/alfa-media/src/feature/scripting.ts @@ -0,0 +1,65 @@ +import { Keyword } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Refinement } from "@siteimprove/alfa-refinement"; +import { Err, Ok, type Result } from "@siteimprove/alfa-result"; + +import { Value } from "../value"; +import { Feature } from "./feature"; + +const { property, equals } = Predicate; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#scripting} + * + * @internal + */ +export class Scripting extends Feature { + public static of(value: Value): Scripting { + return new Scripting(Option.of(value)); + } + + private static _boolean = new Scripting(None); + + public static boolean(): Scripting { + return Scripting._boolean; + } + + public get name(): "scripting" { + return "scripting"; + } + + public matches(device: Device): boolean { + return device.scripting.enabled + ? this._value.every((value) => value.matches(Keyword.of("enabled"))) + : this._value.some((value) => value.matches(Keyword.of("none"))); + } +} + +/** + * @internal + */ +export namespace Scripting { + function tryFrom(value: Option>): Result { + return value + .map((value) => { + if ( + Value.isDiscrete(value) && + value.hasValue( + Refinement.and( + Keyword.isKeyword, + property("value", equals("none", "enabled", "initial-only")), + ), + ) + ) { + return Ok.of(Scripting.of(value)); + } else { + return Err.of(`Invalid value`); + } + }) + .getOrElse(() => Ok.of(Scripting.boolean())); + } + + export const parse = Feature.parseFeature("scripting", false, tryFrom); +} diff --git a/packages/alfa-media/src/feature/width.ts b/packages/alfa-media/src/feature/width.ts index e69de29bb2..a6201e585e 100644 --- a/packages/alfa-media/src/feature/width.ts +++ b/packages/alfa-media/src/feature/width.ts @@ -0,0 +1,77 @@ +import { Length } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Err, Ok, type Result } from "@siteimprove/alfa-result"; + +import { Resolver } from "../resolver"; +import { Value } from "../value"; + +import { Feature } from "./feature"; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#width} + * + * @internal + */ +export class Width extends Feature { + public static of(value: Value): Width { + return new Width(Option.of(value)); + } + + private static _boolean = new Width(None); + + public static boolean(): Width { + return Width._boolean; + } + + public get name(): "width" { + return "width"; + } + + public matches(device: Device): boolean { + const { + viewport: { width }, + } = device; + + const value = this._value.map((value) => + value.map((length) => length.resolve(Resolver.length(device))), + ); + + return width > 0 + ? value.some((value) => value.matches(Length.of(width, "px"))) + : value.every((value) => value.matches(Length.of(0, "px"))); + } +} + +/** + * @internal + */ +export namespace Width { + function tryFrom(value: Option): Result { + return value + .map((value) => (Value.Range.isRange(value) ? value.toLength() : value)) + .map((value) => { + if ( + value.hasValue(Length.isLength) && + value.hasValue( + (value): value is Length.Fixed => !value.hasCalculation(), + ) + ) { + return Ok.of(Width.of(value)); + } + + return Err.of(`Invalid value`); + }) + .getOrElse(() => Ok.of(Width.boolean())); + } + + export function isWidth(value: Feature): value is Width; + + export function isWidth(value: unknown): value is Width; + + export function isWidth(value: unknown): value is Width { + return value instanceof Width; + } + + export const parse = Feature.parseFeature("width", true, tryFrom); +} diff --git a/packages/alfa-media/tsconfig.json b/packages/alfa-media/tsconfig.json index eace73439b..76c7daa852 100644 --- a/packages/alfa-media/tsconfig.json +++ b/packages/alfa-media/tsconfig.json @@ -13,7 +13,7 @@ "src/feature/index.ts", "src/feature/orientation.ts", "src/feature/scripting.ts", - "src/feature/height.ts", + "src/feature/width.ts", "src/index.ts", "src/list.ts", "src/matchable.ts", From 01a039d6b07bc5a4916f17ab6f6aa9c24f748679 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Fri, 22 Dec 2023 13:32:33 +0100 Subject: [PATCH 11/16] Simplify parsers --- packages/alfa-media/src/feature/comparison.ts | 17 +++-- packages/alfa-media/src/feature/feature.ts | 67 ++----------------- 2 files changed, 20 insertions(+), 64 deletions(-) diff --git a/packages/alfa-media/src/feature/comparison.ts b/packages/alfa-media/src/feature/comparison.ts index dab324f035..2e603b8bf1 100644 --- a/packages/alfa-media/src/feature/comparison.ts +++ b/packages/alfa-media/src/feature/comparison.ts @@ -1,7 +1,7 @@ import { type Parser as CSSParser, Token } from "@siteimprove/alfa-css"; import { Parser } from "@siteimprove/alfa-parser"; -const { either, map, option, right } = Parser; +const { delimited, either, map, option, right } = Parser; /** * @internal @@ -24,7 +24,10 @@ export namespace Comparison { export const parseLessThan: CSSParser< Comparison.LessThan | Comparison.LessThanOrEqual > = map( - right(Token.parseDelim("<"), option(Token.parseDelim("="))), + delimited( + option(Token.parseWhitespace), + right(Token.parseDelim("<"), option(Token.parseDelim("="))), + ), (equal) => equal.isNone() ? Comparison.LessThan : Comparison.LessThanOrEqual, ); @@ -33,7 +36,10 @@ export namespace Comparison { * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-gt} */ export const parseGreaterThan = map( - right(Token.parseDelim(">"), option(Token.parseDelim("="))), + delimited( + option(Token.parseWhitespace), + right(Token.parseDelim(">"), option(Token.parseDelim("="))), + ), (equal) => equal.isNone() ? Comparison.GreaterThan : Comparison.GreaterThanOrEqual, ); @@ -41,7 +47,10 @@ export namespace Comparison { /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-eq} */ - export const parseEqual = map(Token.parseDelim("="), () => Comparison.Equal); + export const parseEqual = map( + delimited(option(Token.parseWhitespace), Token.parseDelim("=")), + () => Comparison.Equal, + ); /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-comparison} diff --git a/packages/alfa-media/src/feature/feature.ts b/packages/alfa-media/src/feature/feature.ts index 3d2b0f1849..bd7656ce1a 100644 --- a/packages/alfa-media/src/feature/feature.ts +++ b/packages/alfa-media/src/feature/feature.ts @@ -112,25 +112,8 @@ export namespace Feature { * @remarks * We currently do not support calculations in media queries */ - const parseValue = either< - Slice, - Keyword | Length.Fixed | Number.Fixed | Percentage.Fixed, - string - >( - filter( - Number.parse, - (number): number is Number.Fixed => !number.hasCalculation(), - () => "Calculations no supported in media queries", - ), + const parseValue = either, Keyword | Length.Fixed, string>( map(Token.parseIdent(), (ident) => Keyword.of(ident.value.toLowerCase())), - map( - separated( - Token.parseNumber((number) => number.isInteger), - delimited(option(Token.parseWhitespace), Token.parseDelim("/")), - Token.parseNumber((number) => number.isInteger), - ), - ([left, right]) => Percentage.of(left.value / right.value), - ), filter( Length.parse, (length): length is Length.Fixed => !length.hasCalculation(), @@ -192,13 +175,7 @@ export namespace Feature { mapResult( pair( map( - pair( - parseValue, - delimited( - option(Token.parseWhitespace), - Comparison.parseLessThan, - ), - ), + pair(parseValue, Comparison.parseLessThan), ([value, comparison]) => Value.bound( value, @@ -208,13 +185,7 @@ export namespace Feature { pair( delimited(option(Token.parseWhitespace), parseName(name)), map( - pair( - delimited( - option(Token.parseWhitespace), - Comparison.parseLessThan, - ), - parseValue, - ), + pair(Comparison.parseLessThan, parseValue), ([comparison, value]) => Value.bound( value, @@ -231,13 +202,7 @@ export namespace Feature { mapResult( pair( map( - pair( - parseValue, - delimited( - option(Token.parseWhitespace), - Comparison.parseGreaterThan, - ), - ), + pair(parseValue, Comparison.parseGreaterThan), ([value, comparison]) => Value.bound( value, @@ -247,13 +212,7 @@ export namespace Feature { pair( delimited(option(Token.parseWhitespace), parseName(name)), map( - pair( - delimited( - option(Token.parseWhitespace), - Comparison.parseGreaterThan, - ), - parseValue, - ), + pair(Comparison.parseGreaterThan, parseValue), ([comparison, value]) => Value.bound( value, @@ -269,13 +228,7 @@ export namespace Feature { // mapResult( - pair( - parseName(name), - pair( - delimited(option(Token.parseWhitespace), Comparison.parse), - parseValue, - ), - ), + pair(parseName(name), pair(Comparison.parse, parseValue)), ([name, [comparison, value]]) => { switch (comparison) { case Comparison.Equal: @@ -321,13 +274,7 @@ export namespace Feature { // mapResult( - pair( - parseValue, - pair( - delimited(option(Token.parseWhitespace), Comparison.parse), - parseName(name), - ), - ), + pair(parseValue, pair(Comparison.parse, parseName(name))), ([value, [comparison, name]]) => { switch (comparison) { case Comparison.Equal: From a91a3aa95e125a53904dcc2ecd2a1536a9a7a93f Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Fri, 22 Dec 2023 13:45:35 +0100 Subject: [PATCH 12/16] Improve typing --- packages/alfa-media/src/feature/feature.ts | 60 +++++++++---------- packages/alfa-media/src/feature/height.ts | 10 ++-- .../alfa-media/src/feature/orientation.ts | 10 ++-- packages/alfa-media/src/feature/scripting.ts | 10 ++-- packages/alfa-media/src/feature/width.ts | 10 ++-- 5 files changed, 50 insertions(+), 50 deletions(-) diff --git a/packages/alfa-media/src/feature/feature.ts b/packages/alfa-media/src/feature/feature.ts index bd7656ce1a..68b542e8e6 100644 --- a/packages/alfa-media/src/feature/feature.ts +++ b/packages/alfa-media/src/feature/feature.ts @@ -1,9 +1,7 @@ import { Keyword, Length, - Number, type Parser as CSSParser, - Percentage, Token, } from "@siteimprove/alfa-css"; import { Device } from "@siteimprove/alfa-device"; @@ -27,20 +25,24 @@ const { delimited, either, filter, map, mapResult, option, pair, separated } = /** * {@link https://drafts.csswg.org/mediaqueries-5/#mq-features} */ -export abstract class Feature +export abstract class Feature implements Matchable, - Iterable>, + Iterable>, Equatable, Serializable { + private readonly _name: N; protected readonly _value: Option>; - protected constructor(value: Option>) { + protected constructor(name: N, value: Option>) { + this._name = name; this._value = value; } - public abstract get name(): string; + public get name(): string { + return this._name; + } public get value(): Option> { return this._value; @@ -56,18 +58,18 @@ export abstract class Feature ); } - public *iterator(): Iterator> { + public *iterator(): Iterator> { yield this; } - public [Symbol.iterator](): Iterator> { + public [Symbol.iterator](): Iterator> { return this.iterator(); } - public toJSON(): Feature.JSON { + public toJSON(): Feature.JSON { return { type: "feature", - name: this.name, + name: this._name, value: this._value.map((value) => value.toJSON()).getOr(null), }; } @@ -78,11 +80,11 @@ export abstract class Feature } export namespace Feature { - export interface JSON { + export interface JSON { [key: string]: json.JSON; type: "feature"; - name: string; + name: N; value: Value.JSON | null; } @@ -93,13 +95,13 @@ export namespace Feature { /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-name} */ - function parseName( - name: string, + function parseName( + name: N, withRange: boolean = false, - ): CSSParser { + ): CSSParser { return filter( map(Token.parseIdent(), (ident) => ident.value.toLowerCase()), - (parsed) => + (parsed): parsed is N | `min-${N}` | `max-${N}` => parsed === name || (withRange && (parsed === `min-${name}` || parsed === `max-${name}`)), (parsed) => `Unknown feature ${parsed}`, @@ -124,10 +126,10 @@ export namespace Feature { /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-plain} */ - function parsePlain( - name: string, + function parsePlain( + name: N, withRange: boolean, - tryFrom: (value: Option>) => Result, + tryFrom: (value: Option>) => Result, string>, ): CSSParser { return mapResult( separated( @@ -141,8 +143,6 @@ export namespace Feature { ? Value.minimumRange : Value.maximumRange; - name = name.slice(4); - return tryFrom( Option.of(range(Value.bound(value, /* isInclusive */ true))), ); @@ -156,9 +156,9 @@ export namespace Feature { /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-boolean} */ - function parseBoolean( - name: string, - tryFrom: (value: Option>) => Result, + function parseBoolean( + name: N, + tryFrom: (value: Option>) => Result, string>, ): CSSParser { return mapResult(parseName(name), (name) => tryFrom(None)); } @@ -166,9 +166,9 @@ export namespace Feature { /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-range} */ - function parseRange( - name: string, - tryFrom: (value: Option>) => Result, + function parseRange( + name: N, + tryFrom: (value: Option>) => Result, string>, ): CSSParser { return either( // @@ -323,10 +323,10 @@ export namespace Feature { /** * @internal */ - export function parseFeature( - name: string, + export function parseFeature( + name: N, withRange: boolean, - tryFrom: (value: Option>) => Result, + tryFrom: (value: Option>) => Result, string>, ): CSSParser { return either( withRange diff --git a/packages/alfa-media/src/feature/height.ts b/packages/alfa-media/src/feature/height.ts index 1725b45714..65cc152519 100644 --- a/packages/alfa-media/src/feature/height.ts +++ b/packages/alfa-media/src/feature/height.ts @@ -13,19 +13,19 @@ import { Feature } from "./feature"; * * @internal */ -export class Height extends Feature { +export class Height extends Feature<"height", Length.Fixed> { public static of(value: Value): Height { return new Height(Option.of(value)); } private static _boolean = new Height(None); - public static boolean(): Height { - return Height._boolean; + private constructor(value: Option>) { + super("height", value); } - public get name(): "height" { - return "height"; + public static boolean(): Height { + return Height._boolean; } public matches(device: Device): boolean { diff --git a/packages/alfa-media/src/feature/orientation.ts b/packages/alfa-media/src/feature/orientation.ts index a2f63dc1d8..a7978899cd 100644 --- a/packages/alfa-media/src/feature/orientation.ts +++ b/packages/alfa-media/src/feature/orientation.ts @@ -15,19 +15,19 @@ const { equals, property } = Predicate; * * @internal */ -export class Orientation extends Feature { +export class Orientation extends Feature<"orientation", Keyword> { public static of(value: Value): Orientation { return new Orientation(Option.of(value)); } private static _boolean = new Orientation(None); - public static boolean(): Orientation { - return Orientation._boolean; + private constructor(value: Option>) { + super("orientation", value); } - public get name(): "orientation" { - return "orientation"; + public static boolean(): Orientation { + return Orientation._boolean; } public matches(device: Device): boolean { diff --git a/packages/alfa-media/src/feature/scripting.ts b/packages/alfa-media/src/feature/scripting.ts index 9b291b976d..33187aa491 100644 --- a/packages/alfa-media/src/feature/scripting.ts +++ b/packages/alfa-media/src/feature/scripting.ts @@ -15,19 +15,19 @@ const { property, equals } = Predicate; * * @internal */ -export class Scripting extends Feature { +export class Scripting extends Feature<"scripting", Keyword> { public static of(value: Value): Scripting { return new Scripting(Option.of(value)); } private static _boolean = new Scripting(None); - public static boolean(): Scripting { - return Scripting._boolean; + private constructor(value: Option>) { + super("scripting", value); } - public get name(): "scripting" { - return "scripting"; + public static boolean(): Scripting { + return Scripting._boolean; } public matches(device: Device): boolean { diff --git a/packages/alfa-media/src/feature/width.ts b/packages/alfa-media/src/feature/width.ts index a6201e585e..6b47504387 100644 --- a/packages/alfa-media/src/feature/width.ts +++ b/packages/alfa-media/src/feature/width.ts @@ -13,19 +13,19 @@ import { Feature } from "./feature"; * * @internal */ -export class Width extends Feature { +export class Width extends Feature<"width", Length.Fixed> { public static of(value: Value): Width { return new Width(Option.of(value)); } private static _boolean = new Width(None); - public static boolean(): Width { - return Width._boolean; + private constructor(value: Option>) { + super("width", value); } - public get name(): "width" { - return "width"; + public static boolean(): Width { + return Width._boolean; } public matches(device: Device): boolean { From 1b40528f82cf38cbf214369411109f92226344d4 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Fri, 22 Dec 2023 14:27:24 +0100 Subject: [PATCH 13/16] Umprove parsers and typing --- packages/alfa-media/src/feature/feature.ts | 111 ++++++++++-------- packages/alfa-media/src/feature/height.ts | 21 +--- packages/alfa-media/src/feature/index.ts | 8 +- .../alfa-media/src/feature/orientation.ts | 32 ++--- packages/alfa-media/src/feature/scripting.ts | 33 ++---- packages/alfa-media/src/feature/width.ts | 21 +--- 6 files changed, 91 insertions(+), 135 deletions(-) diff --git a/packages/alfa-media/src/feature/feature.ts b/packages/alfa-media/src/feature/feature.ts index 68b542e8e6..eeaddf46db 100644 --- a/packages/alfa-media/src/feature/feature.ts +++ b/packages/alfa-media/src/feature/feature.ts @@ -11,16 +11,13 @@ import * as json from "@siteimprove/alfa-json"; import { Serializable } from "@siteimprove/alfa-json"; import { None, Option } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; -import { Err, Result } from "@siteimprove/alfa-result"; -import { Slice } from "@siteimprove/alfa-slice"; import type { Matchable } from "../matchable"; import { Value } from "../value"; import { Comparison } from "./comparison"; -const { delimited, either, filter, map, mapResult, option, pair, separated } = - Parser; +const { delimited, either, filter, map, option, pair, separated } = Parser; /** * {@link https://drafts.csswg.org/mediaqueries-5/#mq-features} @@ -113,25 +110,28 @@ export namespace Feature { * * @remarks * We currently do not support calculations in media queries + * We currently only support media features whose value is keyword + * or length, keyword parsing uses the `alfa-css` parser. */ - const parseValue = either, Keyword | Length.Fixed, string>( - map(Token.parseIdent(), (ident) => Keyword.of(ident.value.toLowerCase())), - filter( - Length.parse, - (length): length is Length.Fixed => !length.hasCalculation(), - () => "Calculations no supported in media queries", - ), + const parseLength = filter( + Length.parse, + (length): length is Length.Fixed => !length.hasCalculation(), + () => "Calculations no supported in media queries", ); /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-plain} */ - function parsePlain( + function parsePlain< + N extends string = string, + T extends Keyword | Length.Fixed = Keyword | Length.Fixed, + >( name: N, + parseValue: CSSParser, withRange: boolean, - tryFrom: (value: Option>) => Result, string>, - ): CSSParser { - return mapResult( + from: (value: Option>) => Feature, + ): CSSParser> { + return map( separated( parseName(name, withRange), delimited(option(Token.parseWhitespace), Token.parseColon), @@ -143,11 +143,11 @@ export namespace Feature { ? Value.minimumRange : Value.maximumRange; - return tryFrom( + return from( Option.of(range(Value.bound(value, /* isInclusive */ true))), ); } else { - return tryFrom(Option.of(Value.discrete(value))); + return from(Option.of(Value.discrete(value))); } }, ); @@ -156,11 +156,11 @@ export namespace Feature { /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-boolean} */ - function parseBoolean( + function parseBoolean( name: N, - tryFrom: (value: Option>) => Result, string>, - ): CSSParser { - return mapResult(parseName(name), (name) => tryFrom(None)); + from: (value: None) => Feature, + ): CSSParser> { + return map(parseName(name), () => from(None)); } /** @@ -168,14 +168,14 @@ export namespace Feature { */ function parseRange( name: N, - tryFrom: (value: Option>) => Result, string>, - ): CSSParser { + from: (value: Option>) => Feature, + ): CSSParser> { return either( // - mapResult( + map( pair( map( - pair(parseValue, Comparison.parseLessThan), + pair(parseLength, Comparison.parseLessThan), ([value, comparison]) => Value.bound( value, @@ -185,7 +185,7 @@ export namespace Feature { pair( delimited(option(Token.parseWhitespace), parseName(name)), map( - pair(Comparison.parseLessThan, parseValue), + pair(Comparison.parseLessThan, parseLength), ([comparison, value]) => Value.bound( value, @@ -195,14 +195,14 @@ export namespace Feature { ), ), ([minimum, [name, maximum]]) => - tryFrom(Option.of(Value.range(minimum, maximum))), + from(Option.of(Value.range(minimum, maximum))), ), // - mapResult( + map( pair( map( - pair(parseValue, Comparison.parseGreaterThan), + pair(parseLength, Comparison.parseGreaterThan), ([value, comparison]) => Value.bound( value, @@ -212,7 +212,7 @@ export namespace Feature { pair( delimited(option(Token.parseWhitespace), parseName(name)), map( - pair(Comparison.parseGreaterThan, parseValue), + pair(Comparison.parseGreaterThan, parseLength), ([comparison, value]) => Value.bound( value, @@ -223,16 +223,16 @@ export namespace Feature { ), ), ([maximum, [name, minimum]]) => - tryFrom(Option.of(Value.range(minimum, maximum))), + from(Option.of(Value.range(minimum, maximum))), ), // - mapResult( - pair(parseName(name), pair(Comparison.parse, parseValue)), + map( + pair(parseName(name), pair(Comparison.parse, parseLength)), ([name, [comparison, value]]) => { switch (comparison) { case Comparison.Equal: - return tryFrom( + return from( Option.of( Value.range( Value.bound(value, /* isInclude */ true), @@ -243,7 +243,7 @@ export namespace Feature { case Comparison.LessThan: case Comparison.LessThanOrEqual: - return tryFrom( + return from( Option.of( Value.maximumRange( Value.bound( @@ -257,7 +257,7 @@ export namespace Feature { case Comparison.GreaterThan: case Comparison.GreaterThanOrEqual: - return tryFrom( + return from( Option.of( Value.minimumRange( Value.bound( @@ -273,12 +273,12 @@ export namespace Feature { ), // - mapResult( - pair(parseValue, pair(Comparison.parse, parseName(name))), + map( + pair(parseLength, pair(Comparison.parse, parseName(name))), ([value, [comparison, name]]) => { switch (comparison) { case Comparison.Equal: - return tryFrom( + return from( Option.of( Value.range( Value.bound(value, /* isInclude */ true), @@ -289,7 +289,7 @@ export namespace Feature { case Comparison.LessThan: case Comparison.LessThanOrEqual: - return tryFrom( + return from( Option.of( Value.minimumRange( Value.bound( @@ -303,7 +303,7 @@ export namespace Feature { case Comparison.GreaterThan: case Comparison.GreaterThanOrEqual: - return tryFrom( + return from( Option.of( Value.maximumRange( Value.bound( @@ -323,17 +323,28 @@ export namespace Feature { /** * @internal */ - export function parseFeature( + export function parseContinuous( name: N, - withRange: boolean, - tryFrom: (value: Option>) => Result, string>, - ): CSSParser { + from: (value: Option>) => Feature, + ): CSSParser> { + return either( + parseRange(name, from), + parsePlain(name, parseLength, true, from), + parseBoolean(name, from), + ); + } + + /** + * @internal + */ + export function parseDiscrete( + name: N, + from: (value: Option>) => Feature, + ...values: Array + ): CSSParser> { return either( - withRange - ? parseRange(name, tryFrom) - : () => Err.of(`${name} not allowed in range context`), - parsePlain(name, withRange, tryFrom), - parseBoolean(name, tryFrom), + parsePlain(name, Keyword.parse(...values), false, from), + parseBoolean(name, from), ); } } diff --git a/packages/alfa-media/src/feature/height.ts b/packages/alfa-media/src/feature/height.ts index 65cc152519..d56a760448 100644 --- a/packages/alfa-media/src/feature/height.ts +++ b/packages/alfa-media/src/feature/height.ts @@ -1,7 +1,6 @@ import { Length } from "@siteimprove/alfa-css"; import { Device } from "@siteimprove/alfa-device"; import { None, Option } from "@siteimprove/alfa-option"; -import { Err, Ok, type Result } from "@siteimprove/alfa-result"; import { Resolver } from "../resolver"; import { Value } from "../value"; @@ -47,22 +46,8 @@ export class Height extends Feature<"height", Length.Fixed> { * @internal */ export namespace Height { - function tryFrom(value: Option): Result { - return value - .map((value) => (Value.Range.isRange(value) ? value.toLength() : value)) - .map((value) => { - if ( - value.hasValue(Length.isLength) && - value.hasValue( - (value): value is Length.Fixed => !value.hasCalculation(), - ) - ) { - return Ok.of(Height.of(value)); - } - - return Err.of(`Invalid value`); - }) - .getOrElse(() => Ok.of(Height.boolean())); + function from(value: Option>): Height { + return value.map(Height.of).getOrElse(Height.boolean); } export function isHeight(value: Feature): value is Height; @@ -73,5 +58,5 @@ export namespace Height { return value instanceof Height; } - export const parse = Feature.parseFeature("height", true, tryFrom); + export const parse = Feature.parseContinuous("height", from); } diff --git a/packages/alfa-media/src/feature/index.ts b/packages/alfa-media/src/feature/index.ts index dc634f2e82..d34988480b 100644 --- a/packages/alfa-media/src/feature/index.ts +++ b/packages/alfa-media/src/feature/index.ts @@ -1,5 +1,6 @@ import { Token } from "@siteimprove/alfa-css"; import { Parser } from "@siteimprove/alfa-parser"; +import { Slice } from "@siteimprove/alfa-slice"; import * as feature from "./feature"; @@ -32,7 +33,12 @@ export namespace Feature { Token.parseOpenParenthesis, delimited( option(Token.parseWhitespace), - either(Height.parse, Orientation.parse, Scripting.parse, Width.parse), + either, Feature, string>( + Height.parse, + Orientation.parse, + Scripting.parse, + Width.parse, + ), ), Token.parseCloseParenthesis, ); diff --git a/packages/alfa-media/src/feature/orientation.ts b/packages/alfa-media/src/feature/orientation.ts index a7978899cd..f562fde967 100644 --- a/packages/alfa-media/src/feature/orientation.ts +++ b/packages/alfa-media/src/feature/orientation.ts @@ -1,15 +1,10 @@ import { Keyword } from "@siteimprove/alfa-css"; import { Device } from "@siteimprove/alfa-device"; import { None, Option } from "@siteimprove/alfa-option"; -import { Predicate } from "@siteimprove/alfa-predicate"; -import { Refinement } from "@siteimprove/alfa-refinement"; -import { Err, Ok, type Result } from "@siteimprove/alfa-result"; import { Value } from "../value"; import { Feature } from "./feature"; -const { equals, property } = Predicate; - /** * {@link https://drafts.csswg.org/mediaqueries-5/#orientation} * @@ -41,25 +36,14 @@ export class Orientation extends Feature<"orientation", Keyword> { * @internal */ export namespace Orientation { - function tryFrom(value: Option>): Result { - return value - .map((value) => { - if ( - Value.isDiscrete(value) && - value.hasValue( - Refinement.and( - Keyword.isKeyword, - property("value", equals("landscape", "portrait")), - ), - ) - ) { - return Ok.of(Orientation.of(value)); - } else { - return Err.of(`Invalid value`); - } - }) - .getOrElse(() => Ok.of(Orientation.boolean())); + function from(value: Option>): Orientation { + return value.map(Orientation.of).getOrElse(Orientation.boolean); } - export const parse = Feature.parseFeature("orientation", false, tryFrom); + export const parse = Feature.parseDiscrete( + "orientation", + from, + "portrait", + "landscape", + ); } diff --git a/packages/alfa-media/src/feature/scripting.ts b/packages/alfa-media/src/feature/scripting.ts index 33187aa491..07e5d3705a 100644 --- a/packages/alfa-media/src/feature/scripting.ts +++ b/packages/alfa-media/src/feature/scripting.ts @@ -1,15 +1,10 @@ import { Keyword } from "@siteimprove/alfa-css"; import { Device } from "@siteimprove/alfa-device"; import { None, Option } from "@siteimprove/alfa-option"; -import { Predicate } from "@siteimprove/alfa-predicate"; -import { Refinement } from "@siteimprove/alfa-refinement"; -import { Err, Ok, type Result } from "@siteimprove/alfa-result"; import { Value } from "../value"; import { Feature } from "./feature"; -const { property, equals } = Predicate; - /** * {@link https://drafts.csswg.org/mediaqueries-5/#scripting} * @@ -41,25 +36,15 @@ export class Scripting extends Feature<"scripting", Keyword> { * @internal */ export namespace Scripting { - function tryFrom(value: Option>): Result { - return value - .map((value) => { - if ( - Value.isDiscrete(value) && - value.hasValue( - Refinement.and( - Keyword.isKeyword, - property("value", equals("none", "enabled", "initial-only")), - ), - ) - ) { - return Ok.of(Scripting.of(value)); - } else { - return Err.of(`Invalid value`); - } - }) - .getOrElse(() => Ok.of(Scripting.boolean())); + function from(value: Option>): Scripting { + return value.map(Scripting.of).getOrElse(Scripting.boolean); } - export const parse = Feature.parseFeature("scripting", false, tryFrom); + export const parse = Feature.parseDiscrete( + "scripting", + from, + "none", + "initial-only", + "enabled", + ); } diff --git a/packages/alfa-media/src/feature/width.ts b/packages/alfa-media/src/feature/width.ts index 6b47504387..dda420d7bb 100644 --- a/packages/alfa-media/src/feature/width.ts +++ b/packages/alfa-media/src/feature/width.ts @@ -1,7 +1,6 @@ import { Length } from "@siteimprove/alfa-css"; import { Device } from "@siteimprove/alfa-device"; import { None, Option } from "@siteimprove/alfa-option"; -import { Err, Ok, type Result } from "@siteimprove/alfa-result"; import { Resolver } from "../resolver"; import { Value } from "../value"; @@ -47,22 +46,8 @@ export class Width extends Feature<"width", Length.Fixed> { * @internal */ export namespace Width { - function tryFrom(value: Option): Result { - return value - .map((value) => (Value.Range.isRange(value) ? value.toLength() : value)) - .map((value) => { - if ( - value.hasValue(Length.isLength) && - value.hasValue( - (value): value is Length.Fixed => !value.hasCalculation(), - ) - ) { - return Ok.of(Width.of(value)); - } - - return Err.of(`Invalid value`); - }) - .getOrElse(() => Ok.of(Width.boolean())); + function from(value: Option>): Width { + return value.map(Width.of).getOrElse(Width.boolean); } export function isWidth(value: Feature): value is Width; @@ -73,5 +58,5 @@ export namespace Width { return value instanceof Width; } - export const parse = Feature.parseFeature("width", true, tryFrom); + export const parse = Feature.parseContinuous("width", from); } From 60d083f9ecf42252a5b7756fbb9929e6985eaef5 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Fri, 22 Dec 2023 14:50:44 +0100 Subject: [PATCH 14/16] Refactor and streamline parser --- packages/alfa-media/src/feature/comparison.ts | 8 + packages/alfa-media/src/feature/feature.ts | 168 ++++++------------ 2 files changed, 60 insertions(+), 116 deletions(-) diff --git a/packages/alfa-media/src/feature/comparison.ts b/packages/alfa-media/src/feature/comparison.ts index 2e603b8bf1..5d5544dac5 100644 --- a/packages/alfa-media/src/feature/comparison.ts +++ b/packages/alfa-media/src/feature/comparison.ts @@ -18,6 +18,14 @@ export enum Comparison { * @internal */ export namespace Comparison { + export function isInclusive(comparison: Comparison): boolean { + return ( + comparison === Comparison.LessThanOrEqual || + comparison === Comparison.GreaterThanOrEqual || + comparison === Comparison.Equal + ); + } + /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-lt} */ diff --git a/packages/alfa-media/src/feature/feature.ts b/packages/alfa-media/src/feature/feature.ts index eeaddf46db..51d80f9d5c 100644 --- a/packages/alfa-media/src/feature/feature.ts +++ b/packages/alfa-media/src/feature/feature.ts @@ -17,7 +17,8 @@ import { Value } from "../value"; import { Comparison } from "./comparison"; -const { delimited, either, filter, map, option, pair, separated } = Parser; +const { delimited, either, filter, left, map, option, pair, right, separated } = + Parser; /** * {@link https://drafts.csswg.org/mediaqueries-5/#mq-features} @@ -105,20 +106,6 @@ export namespace Feature { ); } - /** - * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-value} - * - * @remarks - * We currently do not support calculations in media queries - * We currently only support media features whose value is keyword - * or length, keyword parsing uses the `alfa-css` parser. - */ - const parseLength = filter( - Length.parse, - (length): length is Length.Fixed => !length.hasCalculation(), - () => "Calculations no supported in media queries", - ); - /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-plain} */ @@ -163,6 +150,38 @@ export namespace Feature { return map(parseName(name), () => from(None)); } + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-value} + * + * @remarks + * We currently do not support calculations in media queries + * We currently only support media features whose value is keyword + * or length, keyword parsing uses the `alfa-css` parser. + */ + const parseLength = filter( + Length.parse, + (length): length is Length.Fixed => !length.hasCalculation(), + () => "Calculations no supported in media queries", + ); + + function parseComparisonLengthBound( + parseComparison: CSSParser, + ): CSSParser<[Value.Bound, C]> { + return map(pair(parseComparison, parseLength), ([comparison, value]) => [ + Value.bound(value, Comparison.isInclusive(comparison)), + comparison, + ]); + } + + function parseLengthComparisonBound( + parseComparison: CSSParser, + ): CSSParser<[Value.Bound, C]> { + return map(pair(parseLength, parseComparison), ([value, comparison]) => [ + Value.bound(value, Comparison.isInclusive(comparison)), + comparison, + ]); + } + /** * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-range} */ @@ -174,146 +193,63 @@ export namespace Feature { // map( pair( - map( - pair(parseLength, Comparison.parseLessThan), - ([value, comparison]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.LessThanOrEqual, - ), - ), - pair( + parseLengthComparisonBound(Comparison.parseLessThan), + right( delimited(option(Token.parseWhitespace), parseName(name)), - map( - pair(Comparison.parseLessThan, parseLength), - ([comparison, value]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.LessThanOrEqual, - ), - ), + parseComparisonLengthBound(Comparison.parseLessThan), ), ), - ([minimum, [name, maximum]]) => + ([[minimum], [maximum]]) => from(Option.of(Value.range(minimum, maximum))), ), // map( pair( - map( - pair(parseLength, Comparison.parseGreaterThan), - ([value, comparison]) => - Value.bound( - value, - /* isInclusive */ comparison === Comparison.GreaterThanOrEqual, - ), - ), - pair( + parseLengthComparisonBound(Comparison.parseGreaterThan), + right( delimited(option(Token.parseWhitespace), parseName(name)), - map( - pair(Comparison.parseGreaterThan, parseLength), - ([comparison, value]) => - Value.bound( - value, - /* isInclusive */ comparison === - Comparison.GreaterThanOrEqual, - ), - ), + parseComparisonLengthBound(Comparison.parseGreaterThan), ), ), - ([maximum, [name, minimum]]) => + ([[maximum], [minimum]]) => from(Option.of(Value.range(minimum, maximum))), ), // map( - pair(parseName(name), pair(Comparison.parse, parseLength)), - ([name, [comparison, value]]) => { + right(parseName(name), parseComparisonLengthBound(Comparison.parse)), + ([bound, comparison]) => { switch (comparison) { case Comparison.Equal: - return from( - Option.of( - Value.range( - Value.bound(value, /* isInclude */ true), - Value.bound(value, /* isInclude */ true), - ), - ), - ); + return from(Option.of(Value.range(bound, bound))); case Comparison.LessThan: case Comparison.LessThanOrEqual: - return from( - Option.of( - Value.maximumRange( - Value.bound( - value, - /* isInclusive */ comparison === - Comparison.LessThanOrEqual, - ), - ), - ), - ); + return from(Option.of(Value.maximumRange(bound))); case Comparison.GreaterThan: case Comparison.GreaterThanOrEqual: - return from( - Option.of( - Value.minimumRange( - Value.bound( - value, - /* isInclusive */ comparison === - Comparison.GreaterThanOrEqual, - ), - ), - ), - ); + return from(Option.of(Value.minimumRange(bound))); } }, ), // map( - pair(parseLength, pair(Comparison.parse, parseName(name))), - ([value, [comparison, name]]) => { + left(parseLengthComparisonBound(Comparison.parse), parseName(name)), + ([bound, comparison]) => { switch (comparison) { case Comparison.Equal: - return from( - Option.of( - Value.range( - Value.bound(value, /* isInclude */ true), - Value.bound(value, /* isInclude */ true), - ), - ), - ); + return from(Option.of(Value.range(bound, bound))); case Comparison.LessThan: case Comparison.LessThanOrEqual: - return from( - Option.of( - Value.minimumRange( - Value.bound( - value, - /* isInclusive */ comparison === - Comparison.LessThanOrEqual, - ), - ), - ), - ); + return from(Option.of(Value.minimumRange(bound))); case Comparison.GreaterThan: case Comparison.GreaterThanOrEqual: - return from( - Option.of( - Value.maximumRange( - Value.bound( - value, - /* isInclusive */ comparison === - Comparison.GreaterThanOrEqual, - ), - ), - ), - ); + return from(Option.of(Value.maximumRange(bound))); } }, ), From 6b1759c0afbe75712ef4a4ef674d0cd7be94ee51 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Fri, 22 Dec 2023 15:08:41 +0100 Subject: [PATCH 15/16] Clean up --- docs/review/api/alfa-media.api.md | 472 ++------------------- packages/alfa-media/package.json | 1 - packages/alfa-media/src/condition/and.ts | 5 +- packages/alfa-media/src/condition/not.ts | 4 +- packages/alfa-media/src/condition/or.ts | 5 +- packages/alfa-media/src/feature/feature.ts | 7 +- packages/alfa-media/tsconfig.json | 1 - yarn.lock | 2 +- 8 files changed, 48 insertions(+), 449 deletions(-) diff --git a/docs/review/api/alfa-media.api.md b/docs/review/api/alfa-media.api.md index dc3a78f992..e9db74fa6d 100644 --- a/docs/review/api/alfa-media.api.md +++ b/docs/review/api/alfa-media.api.md @@ -9,482 +9,72 @@ import { Equatable } from '@siteimprove/alfa-equatable'; import { Functor } from '@siteimprove/alfa-functor'; import { Iterable as Iterable_2 } from '@siteimprove/alfa-iterable'; import * as json from '@siteimprove/alfa-json'; +import { Keyword } from '@siteimprove/alfa-css'; import { Length } from '@siteimprove/alfa-css'; import { Mapper } from '@siteimprove/alfa-mapper'; import { Option } from '@siteimprove/alfa-option'; import { Parser } from '@siteimprove/alfa-parser'; +import { Parser as Parser_2 } from '@siteimprove/alfa-css'; import { Predicate } from '@siteimprove/alfa-predicate'; import { Refinement } from '@siteimprove/alfa-refinement'; -import { Result } from '@siteimprove/alfa-result'; import { Serializable } from '@siteimprove/alfa-json'; import { Slice } from '@siteimprove/alfa-slice'; +import type { Thunk } from '@siteimprove/alfa-thunk'; import { Token } from '@siteimprove/alfa-css'; +import { Unit } from '@siteimprove/alfa-css'; // @public (undocumented) export namespace Media { + import Condition = condition.Condition; + import And = condition.And; + import Or = condition.Or; + import Not = condition.Not; + import Feature = feature.Feature; + import List = mediaList.List; + import Modifier = modifier.Modifier; + import Query = mediaQuery.Query; + import Type = mediaType.Type; + import Value = value.Value; + const // Warning: (ae-forgotten-export) The symbol "Type" needs to be exported by the entry point index.d.ts + // // (undocumented) - export class And implements Matchable, Iterable_2, Equatable, Serializable { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - iterator(): Iterator; - // (undocumented) - get left(): Feature | Condition; - // (undocumented) - matches(device: Device): boolean; - // (undocumented) - static of(left: Feature | Condition, right: Feature | Condition): And; - // (undocumented) - get right(): Feature | Condition; - // (undocumented) - toJSON(): And.JSON; - // (undocumented) - toString(): string; - } - // (undocumented) - export namespace And { - // (undocumented) - export function isAnd(value: unknown): value is And; - // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - left: Feature.JSON | Condition.JSON; - // (undocumented) - right: Feature.JSON | Condition.JSON; - // (undocumented) - type: "and"; - } - } - // (undocumented) - export enum Comparison { - // (undocumented) - Equal = "=", - // (undocumented) - GreaterThan = ">", - // (undocumented) - GreaterThanOrEqual = ">=", - // (undocumented) - LessThan = "<", - // (undocumented) - LessThanOrEqual = "<=" - } - // (undocumented) - export type Condition = And | Or | Not; - const // (undocumented) type: typeof Type.of, // (undocumented) isType: typeof Type.isType; + const // Warning: (ae-forgotten-export) The symbol "Feature_2" needs to be exported by the entry point index.d.ts + // // (undocumented) - export namespace Condition { - // (undocumented) - export function isCondition(value: unknown): value is Condition; - // (undocumented) - export type JSON = And.JSON | Or.JSON | Not.JSON; - } - // (undocumented) - export abstract class Feature implements Matchable, Iterable_2>, Equatable, Serializable { - // (undocumented) - [Symbol.iterator](): Iterator>; - protected constructor(value: Option>); - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - iterator(): Iterator>; - // (undocumented) - abstract matches(device: Device): boolean; - // (undocumented) - abstract get name(): string; - // (undocumented) - toJSON(): Feature.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get value(): Option>; - // (undocumented) - protected readonly _value: Option>; - } - const // (undocumented) - isFeature: typeof Feature.isFeature; + isFeature: typeof import("./feature/feature").Feature.isFeature; + const // Warning: (ae-forgotten-export) The symbol "And" needs to be exported by the entry point index.d.ts + // // (undocumented) - export namespace Feature { - // (undocumented) - export class Height extends Feature { - // (undocumented) - static boolean(): Height; - // (undocumented) - matches(device: Device): boolean; - // (undocumented) - get name(): "height"; - // (undocumented) - static of(value: Value): Height; - } - // (undocumented) - export namespace Height { - // (undocumented) - export function isHeight(value: Feature): value is Height; - // (undocumented) - export function isHeight(value: unknown): value is Height; - // (undocumented) - export function tryFrom(value: Option): Result; - } - // (undocumented) - export function isFeature(value: unknown): value is Feature; - // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - name: string; - // (undocumented) - type: "feature"; - // (undocumented) - value: Value.JSON | null; - } - const // (undocumented) - isWidth: typeof Width.isWidth; - // (undocumented) - export function tryFrom(value: Option>, name: string): Result; - // (undocumented) - export class Width extends Feature { - // (undocumented) - static boolean(): Width; - // (undocumented) - matches(device: Device): boolean; - // (undocumented) - get name(): "width"; - // (undocumented) - static of(value: Value): Width; - } - const // (undocumented) - isHeight: typeof Height.isHeight; - // (undocumented) - export namespace Width { - // (undocumented) - export function isWidth(value: Feature): value is Width; - // (undocumented) - export function isWidth(value: unknown): value is Width; - // (undocumented) - export function tryFrom(value: Option): Result; - } - {}; - } - // (undocumented) - export class List implements Matchable, Iterable_2, Equatable, Serializable { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(device: Device): boolean; - // (undocumented) - static of(queries: Iterable_2): List; - // (undocumented) - get queries(): Iterable_2; - // (undocumented) - toJSON(): List.JSON; - // (undocumented) - toString(): string; - } - // (undocumented) - export namespace List { - // (undocumented) - export function isList(value: unknown): value is List; - // (undocumented) - export type JSON = Array; - } - // (undocumented) - export interface Matchable { - // (undocumented) - readonly matches: Predicate; - } - // (undocumented) - export enum Modifier { - // (undocumented) - Not = "not", - // (undocumented) - Only = "only" - } - const // (undocumented) and: typeof And.of, // (undocumented) isAnd: typeof And.isAnd; + const // Warning: (ae-forgotten-export) The symbol "Or" needs to be exported by the entry point index.d.ts + // // (undocumented) - export class Not implements Matchable, Iterable_2, Equatable, Serializable { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - get condition(): Feature | Condition; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - iterator(): Iterator; - // (undocumented) - matches(device: Device): boolean; - // (undocumented) - static of(condition: Feature | Condition): Not; - // (undocumented) - toJSON(): Not.JSON; - // (undocumented) - toString(): string; - } - // (undocumented) - export namespace Not { - // (undocumented) - export function isNot(value: unknown): value is Not; - // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - condition: Condition.JSON | Feature.JSON; - // (undocumented) - type: "not"; - } - } - const // (undocumented) or: typeof Or.of, // (undocumented) isOr: typeof Or.isOr; + const // Warning: (ae-forgotten-export) The symbol "Not" needs to be exported by the entry point index.d.ts + // // (undocumented) - export class Or implements Matchable, Iterable_2, Equatable, Serializable { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - iterator(): Iterator; - // (undocumented) - get left(): Feature | Condition; - // (undocumented) - matches(device: Device): boolean; - // (undocumented) - static of(left: Feature | Condition, right: Feature | Condition): Or; - // (undocumented) - get right(): Feature | Condition; - // (undocumented) - toJSON(): Or.JSON; - // (undocumented) - toString(): string; - } - // (undocumented) - export namespace Or { - // (undocumented) - export function isOr(value: unknown): value is Or; - // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - left: Feature.JSON | Condition.JSON; - // (undocumented) - right: Feature.JSON | Condition.JSON; - // (undocumented) - type: "or"; - } - } - const // (undocumented) not: typeof Not.of, // (undocumented) isNot: typeof Not.isNot; + const // Warning: (ae-forgotten-export) The symbol "Condition" needs to be exported by the entry point index.d.ts + // // (undocumented) - export class Query implements Matchable { - // (undocumented) - get condition(): Option; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(device: Device): boolean; - // (undocumented) - get modifier(): Option; - // (undocumented) - static of(modifier: Option, type: Option, condition: Option): Query; - // (undocumented) - toJSON(): Query.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): Option; - } - // (undocumented) - export namespace Query { - // (undocumented) - export function isQuery(value: unknown): value is Query; - // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - condition: Feature.JSON | Condition.JSON | Not.JSON | null; - // (undocumented) - modifier: string | null; - // (undocumented) - type: Type.JSON | null; - } - } - const // (undocumented) isCondition: typeof Condition.isCondition; + const // Warning: (ae-forgotten-export) The symbol "Query" needs to be exported by the entry point index.d.ts + // // (undocumented) - export class Type implements Matchable, Equatable, Serializable { - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(device: Device): boolean; - // (undocumented) - get name(): string; - // (undocumented) - static of(name: string): Type; - // (undocumented) - toJSON(): Type.JSON; - // (undocumented) - toString(): string; - } - // (undocumented) - export namespace Type { - // (undocumented) - export function isType(value: unknown): value is Type; - // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - name: string; - } - } - const // (undocumented) query: typeof Query.of, // (undocumented) isQuery: typeof Query.isQuery; + const // Warning: (ae-forgotten-export) The symbol "List" needs to be exported by the entry point index.d.ts + // // (undocumented) - export interface Value extends Functor, Serializable { - // (undocumented) - hasValue(refinement: Refinement): this is Value; - // (undocumented) - map(mapper: Mapper): Value; - // (undocumented) - matches(value: T): boolean; - // (undocumented) - toJSON(): Value.JSON; - } - // (undocumented) - export namespace Value { - // (undocumented) - export class Bound implements Functor, Serializable> { - // (undocumented) - hasValue(refinement: Refinement): this is Bound; - // (undocumented) - get isInclusive(): boolean; - // (undocumented) - map(mapper: Mapper): Bound; - // (undocumented) - static of(value: T, isInclusive: boolean): Bound; - // (undocumented) - toJSON(): Bound.JSON; - // (undocumented) - get value(): T; - } - // (undocumented) - export namespace Bound { - // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - isInclusive: boolean; - // (undocumented) - value: Serializable.ToJSON; - } - } - // (undocumented) - export class Discrete implements Value, Serializable> { - // (undocumented) - hasValue(refinement: Refinement): this is Discrete; - // (undocumented) - map(mapper: Mapper): Discrete; - // (undocumented) - matches(value: T): boolean; - // (undocumented) - static of(value: T): Discrete; - // (undocumented) - toJSON(): Discrete.JSON; - // (undocumented) - get value(): T; - } - const // (undocumented) - discrete: typeof Discrete.of, // (undocumented) - isDiscrete: typeof Discrete.isDiscrete; - // (undocumented) - export namespace Discrete { - // (undocumented) - export function isDiscrete(value: unknown): value is Discrete; - // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - type: "discrete"; - // (undocumented) - value: Serializable.ToJSON; - } - } - // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - type: string; - } - const // (undocumented) - range: typeof Range.of, // (undocumented) - minimumRange: typeof Range.minimum, // (undocumented) - maximumRange: typeof Range.maximum, // (undocumented) - isRange: typeof Range.isRange; - // (undocumented) - export class Range implements Value, Serializable> { - // (undocumented) - hasValue(refinement: Refinement): this is Discrete; - // (undocumented) - map(mapper: Mapper): Range; - // (undocumented) - matches(value: T): boolean; - // (undocumented) - static maximum(maximum: Bound): Range; - // (undocumented) - get maximum(): Option>; - // (undocumented) - static minimum(minimum: Bound): Range; - // (undocumented) - get minimum(): Option>; - // (undocumented) - static of(minimum: Bound, maximum: Bound): Range; - // (undocumented) - toJSON(): Range.JSON; - // (undocumented) - toLength(): Range>; - } - // (undocumented) - export namespace Range { - // (undocumented) - export function isRange(value: unknown): value is Range; - // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - maximum: Bound.JSON | null; - // (undocumented) - minimum: Bound.JSON | null; - // (undocumented) - type: "range"; - } - } - const // (undocumented) - bound: typeof Bound.of; - } - const // (undocumented) list: typeof List.of, // (undocumented) isList: typeof List.isList; const // (undocumented) parse: Parser, List, string, []>; - {}; } // (No @packageDocumentation comment for this package) diff --git a/packages/alfa-media/package.json b/packages/alfa-media/package.json index 3b0b51899c..6a7393d5b7 100644 --- a/packages/alfa-media/package.json +++ b/packages/alfa-media/package.json @@ -30,7 +30,6 @@ "@siteimprove/alfa-parser": "workspace:^0.71.1", "@siteimprove/alfa-predicate": "workspace:^0.71.1", "@siteimprove/alfa-refinement": "workspace:^0.71.1", - "@siteimprove/alfa-result": "workspace:^0.71.1", "@siteimprove/alfa-slice": "workspace:^0.71.1", "@siteimprove/alfa-thunk": "workspace:^0.71.1" }, diff --git a/packages/alfa-media/src/condition/and.ts b/packages/alfa-media/src/condition/and.ts index a25f01c38f..4887880511 100644 --- a/packages/alfa-media/src/condition/and.ts +++ b/packages/alfa-media/src/condition/and.ts @@ -29,10 +29,12 @@ export class And this._right = right; } + /** @public (knip) */ public get left(): Feature | Condition { return this._left; } + /** @public (knip) */ public get right(): Feature | Condition { return this._right; } @@ -49,12 +51,13 @@ export class And ); } - public *iterator(): Iterator { + private *iterator(): Iterator { for (const condition of [this._left, this._right]) { yield* condition; } } + /** @public (knip) */ public [Symbol.iterator](): Iterator { return this.iterator(); } diff --git a/packages/alfa-media/src/condition/not.ts b/packages/alfa-media/src/condition/not.ts index fe0c124a9d..5a6663c470 100644 --- a/packages/alfa-media/src/condition/not.ts +++ b/packages/alfa-media/src/condition/not.ts @@ -27,6 +27,7 @@ export class Not this._condition = condition; } + /** @public (knip) */ public get condition(): Feature | Condition { return this._condition; } @@ -39,10 +40,11 @@ export class Not return value instanceof Not && value._condition.equals(this._condition); } - public *iterator(): Iterator { + private *iterator(): Iterator { yield* this._condition; } + /** @public (knip) */ public [Symbol.iterator](): Iterator { return this.iterator(); } diff --git a/packages/alfa-media/src/condition/or.ts b/packages/alfa-media/src/condition/or.ts index e05a3c479f..b03d8e32e8 100644 --- a/packages/alfa-media/src/condition/or.ts +++ b/packages/alfa-media/src/condition/or.ts @@ -29,10 +29,12 @@ export class Or this._right = right; } + /** @public (knip) */ public get left(): Feature | Condition { return this._left; } + /** @public (knip) */ public get right(): Feature | Condition { return this._right; } @@ -49,12 +51,13 @@ export class Or ); } - public *iterator(): Iterator { + private *iterator(): Iterator { for (const condition of [this._left, this._right]) { yield* condition; } } + /** @public (knip) */ public [Symbol.iterator](): Iterator { return this.iterator(); } diff --git a/packages/alfa-media/src/feature/feature.ts b/packages/alfa-media/src/feature/feature.ts index 51d80f9d5c..445a63a50e 100644 --- a/packages/alfa-media/src/feature/feature.ts +++ b/packages/alfa-media/src/feature/feature.ts @@ -22,6 +22,8 @@ const { delimited, either, filter, left, map, option, pair, right, separated } = /** * {@link https://drafts.csswg.org/mediaqueries-5/#mq-features} + * + * @public */ export abstract class Feature implements @@ -56,10 +58,11 @@ export abstract class Feature ); } - public *iterator(): Iterator> { + private *iterator(): Iterator> { yield this; } + /** @public (knip) */ public [Symbol.iterator](): Iterator> { return this.iterator(); } @@ -156,7 +159,7 @@ export namespace Feature { * @remarks * We currently do not support calculations in media queries * We currently only support media features whose value is keyword - * or length, keyword parsing uses the `alfa-css` parser. + * or length, keyword parsing uses the `@siteimprove/alfa-css` parser. */ const parseLength = filter( Length.parse, diff --git a/packages/alfa-media/tsconfig.json b/packages/alfa-media/tsconfig.json index 76c7daa852..388b2f1858 100644 --- a/packages/alfa-media/tsconfig.json +++ b/packages/alfa-media/tsconfig.json @@ -41,7 +41,6 @@ { "path": "../alfa-parser" }, { "path": "../alfa-predicate" }, { "path": "../alfa-refinement" }, - { "path": "../alfa-result" }, { "path": "../alfa-slice" }, { "path": "../alfa-thunk" }, { "path": "../alfa-test" } diff --git a/yarn.lock b/yarn.lock index e02262266c..5d7eee771a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1391,9 +1391,9 @@ __metadata: "@siteimprove/alfa-parser": "workspace:^0.71.1" "@siteimprove/alfa-predicate": "workspace:^0.71.1" "@siteimprove/alfa-refinement": "workspace:^0.71.1" - "@siteimprove/alfa-result": "workspace:^0.71.1" "@siteimprove/alfa-slice": "workspace:^0.71.1" "@siteimprove/alfa-test": "workspace:^0.71.1" + "@siteimprove/alfa-thunk": "workspace:^0.71.1" languageName: unknown linkType: soft From e650369beb58c2ffc60b6f4646bd805c1fcae541 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 22 Dec 2023 14:14:44 +0000 Subject: [PATCH 16/16] Extract API --- docs/review/api/alfa-dom.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/review/api/alfa-dom.api.md b/docs/review/api/alfa-dom.api.md index dfbb9e0a8e..33fc49242c 100644 --- a/docs/review/api/alfa-dom.api.md +++ b/docs/review/api/alfa-dom.api.md @@ -1128,7 +1128,7 @@ export namespace SupportsRule { // @internal (undocumented) export function fromSupportsRule(json: JSON): Trampoline; // (undocumented) - export function isSupportsRue(value: unknown): value is SupportsRule; + export function isSupportsRule(value: unknown): value is SupportsRule; // (undocumented) export interface JSON extends ConditionRule.JSON<"supports"> { }