From b334aba7a918d66c33c5ed20e2426b010bcecc00 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Tue, 2 Jan 2024 10:13:25 +0100 Subject: [PATCH] Refactor `@siteimprove/alfa-media` (#1542) * Remove unused import * Typo in function name * Break down huge file a bit * Break down huge file * Break down value * Break down parser circular references * Simplify parsers * Break down file * Extract Comparison * Break down big file * Simplify parsers * Improve typing * Umprove parsers and typing * Refactor and streamline parser * Clean up * Extract API --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- docs/review/api/alfa-dom.api.md | 2 +- docs/review/api/alfa-media.api.md | 472 +---- packages/alfa-cascade/src/selector-map.ts | 1 - packages/alfa-dom/src/style/rule/supports.ts | 2 +- packages/alfa-media/package.json | 4 +- packages/alfa-media/src/condition/and.ts | 103 ++ .../alfa-media/src/condition/condition.ts | 83 + packages/alfa-media/src/condition/index.ts | 4 + packages/alfa-media/src/condition/not.ts | 91 + packages/alfa-media/src/condition/or.ts | 103 ++ packages/alfa-media/src/feature/comparison.ts | 67 + packages/alfa-media/src/feature/feature.ts | 289 +++ packages/alfa-media/src/feature/height.ts | 62 + packages/alfa-media/src/feature/index.ts | 45 + .../alfa-media/src/feature/orientation.ts | 49 + packages/alfa-media/src/feature/scripting.ts | 50 + packages/alfa-media/src/feature/width.ts | 62 + packages/alfa-media/src/index.ts | 36 +- packages/alfa-media/src/list.ts | 100 ++ packages/alfa-media/src/matchable.ts | 9 + packages/alfa-media/src/media.ts | 1552 ----------------- packages/alfa-media/src/modifier.ts | 24 + packages/alfa-media/src/query.ts | 155 ++ packages/alfa-media/src/resolver.ts | 3 +- packages/alfa-media/src/type.ts | 96 + packages/alfa-media/src/value/bound.ts | 61 + packages/alfa-media/src/value/discrete.ts | 66 + packages/alfa-media/src/value/index.ts | 1 + packages/alfa-media/src/value/range.ts | 137 ++ packages/alfa-media/src/value/value.ts | 47 + packages/alfa-media/test/media.spec.ts | 125 +- packages/alfa-media/tsconfig.json | 25 +- yarn.lock | 2 +- 33 files changed, 1823 insertions(+), 2105 deletions(-) create mode 100644 packages/alfa-media/src/condition/and.ts 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/condition/not.ts create mode 100644 packages/alfa-media/src/condition/or.ts create mode 100644 packages/alfa-media/src/feature/comparison.ts create mode 100644 packages/alfa-media/src/feature/feature.ts create mode 100644 packages/alfa-media/src/feature/height.ts create mode 100644 packages/alfa-media/src/feature/index.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 create mode 100644 packages/alfa-media/src/list.ts create mode 100644 packages/alfa-media/src/matchable.ts delete mode 100644 packages/alfa-media/src/media.ts create mode 100644 packages/alfa-media/src/modifier.ts create mode 100644 packages/alfa-media/src/query.ts create mode 100644 packages/alfa-media/src/type.ts 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/index.ts create mode 100644 packages/alfa-media/src/value/range.ts create mode 100644 packages/alfa-media/src/value/value.ts 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"> { } 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-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"; 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; } diff --git a/packages/alfa-media/package.json b/packages/alfa-media/package.json index 54f8067d03..6a7393d5b7 100644 --- a/packages/alfa-media/package.json +++ b/packages/alfa-media/package.json @@ -30,8 +30,8 @@ "@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-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/and.ts b/packages/alfa-media/src/condition/and.ts new file mode 100644 index 0000000000..4887880511 --- /dev/null +++ b/packages/alfa-media/src/condition/and.ts @@ -0,0 +1,103 @@ +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 (knip) */ + public get left(): Feature | Condition { + return this._left; + } + + /** @public (knip) */ + 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) + ); + } + + private *iterator(): Iterator { + for (const condition of [this._left, this._right]) { + yield* condition; + } + } + + /** @public (knip) */ + 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 new file mode 100644 index 0000000000..2c5ad5b5f5 --- /dev/null +++ b/packages/alfa-media/src/condition/condition.ts @@ -0,0 +1,83 @@ +import { Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Parser } from "@siteimprove/alfa-parser"; + +import { Feature } from "../feature"; + +import { And } from "./and"; +import { Not } from "./not"; +import { Or } from "./or"; + +const { delimited, either, map, oneOrMore, option, pair, zeroOrMore } = Parser; + +/** + * {@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); + } + + /** + * {@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( + delimited( + Token.parseOpenParenthesis, + delimited(option(Token.parseWhitespace), (input) => parse(input)), + Token.parseCloseParenthesis, + ), + Feature.parse, + ); + + /** + * {@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, + ), + ), + parseInParens(), + ), + ); + + /** + * {@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/condition/index.ts b/packages/alfa-media/src/condition/index.ts new file mode 100644 index 0000000000..a84e66bdbf --- /dev/null +++ b/packages/alfa-media/src/condition/index.ts @@ -0,0 +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..5a6663c470 --- /dev/null +++ b/packages/alfa-media/src/condition/not.ts @@ -0,0 +1,91 @@ +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 (knip) */ + 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); + } + + private *iterator(): Iterator { + yield* this._condition; + } + + /** @public (knip) */ + 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..b03d8e32e8 --- /dev/null +++ b/packages/alfa-media/src/condition/or.ts @@ -0,0 +1,103 @@ +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 (knip) */ + public get left(): Feature | Condition { + return this._left; + } + + /** @public (knip) */ + 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) + ); + } + + private *iterator(): Iterator { + for (const condition of [this._left, this._right]) { + yield* condition; + } + } + + /** @public (knip) */ + 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/src/feature/comparison.ts b/packages/alfa-media/src/feature/comparison.ts new file mode 100644 index 0000000000..5d5544dac5 --- /dev/null +++ b/packages/alfa-media/src/feature/comparison.ts @@ -0,0 +1,67 @@ +import { type Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; + +const { delimited, either, map, option, right } = Parser; + +/** + * @internal + */ +export enum Comparison { + LessThan = "<", + LessThanOrEqual = "<=", + Equal = "=", + GreaterThan = ">", + GreaterThanOrEqual = ">=", +} + +/** + * @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} + */ + export const parseLessThan: CSSParser< + Comparison.LessThan | Comparison.LessThanOrEqual + > = map( + delimited( + option(Token.parseWhitespace), + 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( + delimited( + option(Token.parseWhitespace), + 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( + delimited(option(Token.parseWhitespace), 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 new file mode 100644 index 0000000000..445a63a50e --- /dev/null +++ b/packages/alfa-media/src/feature/feature.ts @@ -0,0 +1,289 @@ +import { + Keyword, + Length, + type 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 { None, Option } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; + +import type { Matchable } from "../matchable"; +import { Value } from "../value"; + +import { Comparison } from "./comparison"; + +const { delimited, either, filter, left, map, option, pair, right, separated } = + Parser; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#mq-features} + * + * @public + */ +export abstract class Feature + implements + Matchable, + Iterable>, + Equatable, + Serializable +{ + private readonly _name: N; + protected readonly _value: Option>; + + protected constructor(name: N, value: Option>) { + this._name = name; + this._value = value; + } + + public get name(): string { + return this._name; + } + + 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) + ); + } + + private *iterator(): Iterator> { + yield this; + } + + /** @public (knip) */ + 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: N; + value: Value.JSON | null; + } + + export function isFeature(value: unknown): value is Feature { + return value instanceof Feature; + } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-name} + */ + function parseName( + name: N, + withRange: boolean = false, + ): CSSParser { + return filter( + map(Token.parseIdent(), (ident) => ident.value.toLowerCase()), + (parsed): parsed is N | `min-${N}` | `max-${N}` => + parsed === name || + (withRange && (parsed === `min-${name}` || parsed === `max-${name}`)), + (parsed) => `Unknown feature ${parsed}`, + ); + } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-plain} + */ + function parsePlain< + N extends string = string, + T extends Keyword | Length.Fixed = Keyword | Length.Fixed, + >( + name: N, + parseValue: CSSParser, + withRange: boolean, + from: (value: Option>) => Feature, + ): CSSParser> { + return map( + 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; + + return from( + Option.of(range(Value.bound(value, /* isInclusive */ true))), + ); + } else { + return from(Option.of(Value.discrete(value))); + } + }, + ); + } + + /** + * {@link https://drafts.csswg.org/mediaqueries-5/#typedef-mf-boolean} + */ + function parseBoolean( + name: N, + from: (value: None) => Feature, + ): CSSParser> { + 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 `@siteimprove/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} + */ + function parseRange( + name: N, + from: (value: Option>) => Feature, + ): CSSParser> { + return either( + // + map( + pair( + parseLengthComparisonBound(Comparison.parseLessThan), + right( + delimited(option(Token.parseWhitespace), parseName(name)), + parseComparisonLengthBound(Comparison.parseLessThan), + ), + ), + ([[minimum], [maximum]]) => + from(Option.of(Value.range(minimum, maximum))), + ), + + // + map( + pair( + parseLengthComparisonBound(Comparison.parseGreaterThan), + right( + delimited(option(Token.parseWhitespace), parseName(name)), + parseComparisonLengthBound(Comparison.parseGreaterThan), + ), + ), + ([[maximum], [minimum]]) => + from(Option.of(Value.range(minimum, maximum))), + ), + + // + map( + right(parseName(name), parseComparisonLengthBound(Comparison.parse)), + ([bound, comparison]) => { + switch (comparison) { + case Comparison.Equal: + return from(Option.of(Value.range(bound, bound))); + + case Comparison.LessThan: + case Comparison.LessThanOrEqual: + return from(Option.of(Value.maximumRange(bound))); + + case Comparison.GreaterThan: + case Comparison.GreaterThanOrEqual: + return from(Option.of(Value.minimumRange(bound))); + } + }, + ), + + // + map( + left(parseLengthComparisonBound(Comparison.parse), parseName(name)), + ([bound, comparison]) => { + switch (comparison) { + case Comparison.Equal: + return from(Option.of(Value.range(bound, bound))); + + case Comparison.LessThan: + case Comparison.LessThanOrEqual: + return from(Option.of(Value.minimumRange(bound))); + + case Comparison.GreaterThan: + case Comparison.GreaterThanOrEqual: + return from(Option.of(Value.maximumRange(bound))); + } + }, + ), + ); + } + + /** + * @internal + */ + export function parseContinuous( + name: N, + 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( + 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 new file mode 100644 index 0000000000..d56a760448 --- /dev/null +++ b/packages/alfa-media/src/feature/height.ts @@ -0,0 +1,62 @@ +import { Length } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { None, Option } from "@siteimprove/alfa-option"; + +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<"height", Length.Fixed> { + public static of(value: Value): Height { + return new Height(Option.of(value)); + } + + private static _boolean = new Height(None); + + private constructor(value: Option>) { + super("height", value); + } + + public static boolean(): Height { + return Height._boolean; + } + + 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 from(value: Option>): Height { + return value.map(Height.of).getOrElse(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.parseContinuous("height", from); +} diff --git a/packages/alfa-media/src/feature/index.ts b/packages/alfa-media/src/feature/index.ts new file mode 100644 index 0000000000..d34988480b --- /dev/null +++ b/packages/alfa-media/src/feature/index.ts @@ -0,0 +1,45 @@ +import { Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Slice } from "@siteimprove/alfa-slice"; + +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, 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 new file mode 100644 index 0000000000..f562fde967 --- /dev/null +++ b/packages/alfa-media/src/feature/orientation.ts @@ -0,0 +1,49 @@ +import { Keyword } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { None, Option } from "@siteimprove/alfa-option"; + +import { Value } from "../value"; +import { Feature } from "./feature"; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#orientation} + * + * @internal + */ +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); + + private constructor(value: Option>) { + super("orientation", value); + } + + public static boolean(): Orientation { + return Orientation._boolean; + } + + public matches(device: Device): boolean { + return this._value.every((value) => + value.matches(Keyword.of(device.viewport.orientation)), + ); + } +} + +/** + * @internal + */ +export namespace Orientation { + function from(value: Option>): Orientation { + return value.map(Orientation.of).getOrElse(Orientation.boolean); + } + + 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 new file mode 100644 index 0000000000..07e5d3705a --- /dev/null +++ b/packages/alfa-media/src/feature/scripting.ts @@ -0,0 +1,50 @@ +import { Keyword } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { None, Option } from "@siteimprove/alfa-option"; + +import { Value } from "../value"; +import { Feature } from "./feature"; + +/** + * {@link https://drafts.csswg.org/mediaqueries-5/#scripting} + * + * @internal + */ +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); + + private constructor(value: Option>) { + super("scripting", value); + } + + public static boolean(): Scripting { + return Scripting._boolean; + } + + 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 from(value: Option>): Scripting { + return value.map(Scripting.of).getOrElse(Scripting.boolean); + } + + 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 new file mode 100644 index 0000000000..dda420d7bb --- /dev/null +++ b/packages/alfa-media/src/feature/width.ts @@ -0,0 +1,62 @@ +import { Length } from "@siteimprove/alfa-css"; +import { Device } from "@siteimprove/alfa-device"; +import { None, Option } from "@siteimprove/alfa-option"; + +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<"width", Length.Fixed> { + public static of(value: Value): Width { + return new Width(Option.of(value)); + } + + private static _boolean = new Width(None); + + private constructor(value: Option>) { + super("width", value); + } + + public static boolean(): Width { + return Width._boolean; + } + + 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 from(value: Option>): Width { + return value.map(Width.of).getOrElse(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.parseContinuous("width", from); +} diff --git a/packages/alfa-media/src/index.ts b/packages/alfa-media/src/index.ts index fa8208cd59..3d5c8dbc3e 100644 --- a/packages/alfa-media/src/index.ts +++ b/packages/alfa-media/src/index.ts @@ -1 +1,35 @@ -export * from "./media"; +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/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 deleted file mode 100644 index b79fa02f0f..0000000000 --- a/packages/alfa-media/src/media.ts +++ /dev/null @@ -1,1552 +0,0 @@ -import { Comparable } from "@siteimprove/alfa-comparable"; -import { - Length, - Keyword, - Number, - Percentage, - 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"; - -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 - { - 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, - ), - parseFeature, - ); - - /** - * {@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), parseModifier)), - parseType, - ), - 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/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/query.ts b/packages/alfa-media/src/query.ts new file mode 100644 index 0000000000..2e0f9e6c1c --- /dev/null +++ b/packages/alfa-media/src/query.ts @@ -0,0 +1,155 @@ +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 { Condition, Not } 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(Condition.parse, (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")), + Condition.parseWithoutOr, + ), + ), + ), + ([[modifier, type], condition]) => + Query.of(modifier, Option.of(type), condition), + ), + ), + end(() => `Unexpected token`), + ); +} 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/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/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/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/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 new file mode 100644 index 0000000000..5559f50094 --- /dev/null +++ b/packages/alfa-media/src/value/value.ts @@ -0,0 +1,47 @@ +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"; + +import * as boundValue from "./bound"; +import * as discreteValue from "./discrete"; +import * as rangeValue from "./range"; + +/** + * @public + */ +export interface Value + extends Functor, + Serializable { + map(mapper: Mapper): Value; + matches(value: T): boolean; + hasValue(refinement: Refinement): this is Value; + toJSON(): Value.JSON; +} + +/** + * @public + */ +export namespace Value { + export interface JSON { + [key: string]: json.JSON; + type: string; + } + + export import Bound = boundValue.Bound; + export import Discrete = discreteValue.Discrete; + export import Range = rangeValue.Range; + + export const { of: discrete, isDiscrete } = Discrete; + + export const { + of: range, + minimum: minimumRange, + maximum: maximumRange, + isRange, + } = Range; + + 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 1eaf280e1a..388b2f1858 100644 --- a/packages/alfa-media/tsconfig.json +++ b/packages/alfa-media/tsconfig.json @@ -2,9 +2,30 @@ "$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/comparison.ts", + "src/feature/feature.ts", + "src/feature/height.ts", + "src/feature/index.ts", + "src/feature/orientation.ts", + "src/feature/scripting.ts", + "src/feature/width.ts", "src/index.ts", - "src/media.ts", + "src/list.ts", + "src/matchable.ts", + "src/modifier.ts", + "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" ], "references": [ @@ -20,8 +41,8 @@ { "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 978123cc02..611f41b13b 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