├── .editorconfig ├── .github └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── deno.json ├── deno.lock ├── examples └── deno.ts ├── jest.config.js ├── mod.ts ├── package-lock.json ├── package.json ├── src ├── index.test.ts ├── index.ts ├── math.test.ts ├── media-query.test.ts ├── modules.test.ts ├── natural-dates.test.ts ├── routing.test.ts ├── tailwindcss.test.ts ├── test-deps.ts └── types.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{json,yml,md}] 12 | indent_style = space 13 | 14 | [Makefile] 15 | indent_size = 2 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['16.x', '17.x', '18.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Build 26 | run: npm run build 27 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | .parcel-cache/ 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": false 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Patrick Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | deno_test: 2 | deno run --no-check examples/deno.ts 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

👑 🌿 yieldparser

3 |

Parse using composable generator functions. It’s like components for parsing.

4 | 5 | minified and gzipped size 6 | minified size 7 | zero dependencies 8 | 9 |
10 | 11 | ## Installation 12 | 13 | ```console 14 | npm add yieldparser 15 | ``` 16 | 17 | ## Overview 18 | 19 | Yieldparser parses a source chunk-by-chunk. You define a generator function that 20 | yields each chunk to be found. This chunk can be a `string`, a `RexExp`, or 21 | another generator function. Your generator function receives replies from 22 | parsing that chunk, for example a regular expression would receive a reply with 23 | the matches that were found. You then use this information to build a result: 24 | the value that your generator function returns. This could be a simple value, or 25 | it could be an entire AST (abstract syntax tree). 26 | 27 | If you yield an array of choices, then each choice is tested and the first one 28 | that matches is used. 29 | 30 | If your chunks don’t match the input string, then an error result is returned 31 | with the remaining string and the chunk that it failed on. If it succeeds, then 32 | a success result is returned with the return value of the generator function, 33 | and the remaining string (if there is anything remaining). 34 | 35 | Run `parse(input, yourGeneratorIterable)` to take an input string and parse into 36 | a result. 37 | 38 | Run `invert(output, yourGeneratorIterable)` to take an expected result and map 39 | it back to a source string. 40 | 41 | ## Examples 42 | 43 | - [Routes](#routes-parser) 44 | - [IP Address](#ip-address-parser) 45 | - [Maths expressions: `5 * 6 + 3`](src/math.test.ts) 46 | - [Basic CSS](#basic-css-parser) 47 | - Semver parser 48 | - Emoticons to Emoji 49 | - CSV 50 | - JSON 51 | - Cron 52 | - Markdown subset 53 | 54 | ### Routes parser 55 | 56 | Define a generator function for each route you have, and then define a top level 57 | `Routes` generator function. Then parse your path using `parse()`. 58 | 59 | You can also map from a route object back to a path string using `invert()`. 60 | 61 | ```typescript 62 | import { invert, mustEnd, parse } from "yieldparser"; 63 | 64 | type Route = 65 | | { type: "home" } 66 | | { type: "about" } 67 | | { type: "terms" } 68 | | { type: "blog" } 69 | | { type: "blogArticle"; slug: string }; 70 | 71 | function* Home() { 72 | yield "/"; 73 | yield mustEnd; 74 | return { type: "home" } as Route; 75 | } 76 | 77 | function* About() { 78 | yield "/about"; 79 | yield mustEnd; 80 | return { type: "about" } as Route; 81 | } 82 | 83 | function* Terms() { 84 | yield "/legal"; 85 | yield "/terms"; 86 | yield mustEnd; 87 | return { type: "terms" } as Route; 88 | } 89 | 90 | function* blogPrefix() { 91 | yield "/blog"; 92 | } 93 | 94 | function* BlogHome() { 95 | yield blogPrefix; 96 | yield mustEnd; 97 | return { type: "blog" }; 98 | } 99 | 100 | function* BlogArticle() { 101 | yield blogPrefix; 102 | yield "/"; 103 | const [slug]: [string] = yield /^.+/; 104 | return { type: "blogArticle", slug }; 105 | } 106 | 107 | function* BlogRoutes() { 108 | return yield [BlogHome, BlogArticle]; 109 | } 110 | 111 | function* Routes() { 112 | return yield [Home, About, Terms, BlogRoutes]; 113 | } 114 | 115 | parse("/", Routes()); // result: { type: "home" }, success: true, remaining: "" } 116 | parse("/about", Routes()); // result: { type: "about" }, success: true, remaining: "" } 117 | parse("/legal/terms", Routes()); // result: { type: "terms" }, success: true, remaining: "" } 118 | parse("/blog", Routes()); // result: { type: "blog" }, success: true, remaining: "" } 119 | parse("/blog/happy-new-year", Routes()); // result: { type: "blogArticle", slug: "happy-new-year" }, success: true, remaining: "" } 120 | 121 | invert({ type: "home" }, Routes()); // "/" 122 | invert({ type: "about" }, Routes()); // "/about" 123 | invert({ type: "terms" }, Routes()); // "/legal/terms" 124 | invert({ type: "blog" }, Routes()); // "/blog" 125 | invert({ type: "blogArticle", slug: "happy-new-year" }, Routes()); // "/blog/happy-new-year" 126 | ``` 127 | 128 | ### IP Address parser 129 | 130 | ```typescript 131 | import { mustEnd, parse } from "yieldparser"; 132 | 133 | function* Digit() { 134 | const [digit]: [string] = yield /^\d+/; 135 | const value = parseInt(digit, 10); 136 | if (value < 0 || value > 255) { 137 | return new Error(`Digit must be between 0 and 255, was ${value}`); 138 | } 139 | return value; 140 | } 141 | 142 | function* IPAddress() { 143 | const first = yield Digit; 144 | yield "."; 145 | const second = yield Digit; 146 | yield "."; 147 | const third = yield Digit; 148 | yield "."; 149 | const fourth = yield Digit; 150 | yield mustEnd; 151 | return [first, second, third, fourth]; 152 | } 153 | 154 | parse("1.2.3.4", IPAddress()); 155 | /* 156 | { 157 | success: true, 158 | result: [1, 2, 3, 4], 159 | remaining: '', 160 | } 161 | */ 162 | 163 | parse("1.2.3.256", IPAddress()); 164 | /* 165 | { 166 | success: false, 167 | failedOn: { 168 | nested: [ 169 | { 170 | yielded: new Error('Digit must be between 0 and 255, was 256'), 171 | }, 172 | ], 173 | }, 174 | remaining: '256', 175 | } 176 | */ 177 | ``` 178 | 179 | ### Basic CSS parser 180 | 181 | ```typescript 182 | import { has, hasMore, parse } from "yieldparser"; 183 | 184 | type Selector = string; 185 | interface Declaraction { 186 | property: string; 187 | value: string; 188 | } 189 | interface Rule { 190 | selectors: Array; 191 | declarations: Array; 192 | } 193 | 194 | const whitespaceMay = /^\s*/; 195 | 196 | function* PropertyParser() { 197 | const [name]: [string] = yield /[-a-z]+/; 198 | return name; 199 | } 200 | 201 | function* ValueParser() { 202 | const [rawValue]: [string] = yield /(-?\d+(rem|em|%|px|)|[-a-z]+)/; 203 | return rawValue; 204 | } 205 | 206 | function* DeclarationParser() { 207 | const name = yield PropertyParser; 208 | yield whitespaceMay; 209 | yield ":"; 210 | yield whitespaceMay; 211 | const rawValue = yield ValueParser; 212 | yield whitespaceMay; 213 | yield ";"; 214 | return { name, rawValue }; 215 | } 216 | 217 | function* RuleParser() { 218 | const declarations: Array = []; 219 | 220 | const [selector]: [string] = yield /(:root|[*]|[a-z][\w]*)/; 221 | 222 | yield whitespaceMay; 223 | yield "{"; 224 | yield whitespaceMay; 225 | while ((yield has("}")) === false) { 226 | yield whitespaceMay; 227 | declarations.push(yield DeclarationParser); 228 | yield whitespaceMay; 229 | } 230 | 231 | return { selector, declarations }; 232 | } 233 | 234 | function* RulesParser() { 235 | const rules = []; 236 | 237 | yield whitespaceMay; 238 | while (yield hasMore) { 239 | rules.push(yield RuleParser); 240 | yield whitespaceMay; 241 | } 242 | return rules; 243 | } 244 | 245 | const code = ` 246 | :root { 247 | --first-var: 42rem; 248 | --second-var: 15%; 249 | } 250 | 251 | * { 252 | font: inherit; 253 | box-sizing: border-box; 254 | } 255 | 256 | h1 { 257 | margin-bottom: 1em; 258 | } 259 | `; 260 | 261 | parse(code, RulesParser()); 262 | 263 | /* 264 | { 265 | success: true, 266 | result: [ 267 | { 268 | selector: ':root', 269 | declarations: [ 270 | { 271 | name: '--first-var', 272 | rawValue: '42rem', 273 | }, 274 | { 275 | name: '--second-var', 276 | rawValue: '15%', 277 | }, 278 | ], 279 | }, 280 | { 281 | selector: '*', 282 | declarations: [ 283 | { 284 | name: 'font', 285 | rawValue: 'inherit', 286 | }, 287 | { 288 | name: 'box-sizing', 289 | rawValue: 'border-box', 290 | }, 291 | ], 292 | }, 293 | { 294 | selector: 'h1', 295 | declarations: [ 296 | { 297 | name: 'margin-bottom', 298 | rawValue: '1em', 299 | }, 300 | ], 301 | }, 302 | ], 303 | remaining: '', 304 | } 305 | */ 306 | ``` 307 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "std/": "https://deno.land/std@0.207.0/" 4 | }, 5 | "tasks": { 6 | "dev": "deno run --watch main.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3", 3 | "redirects": { 4 | "https://deno.land/x/expect/mod.ts": "https://deno.land/x/expect@v0.4.0/mod.ts" 5 | }, 6 | "remote": { 7 | "https://deno.land/std@0.207.0/testing/_test_suite.ts": "30f018feeb3835f12ab198d8a518f9089b1bcb2e8c838a8b615ab10d5005465c", 8 | "https://deno.land/std@0.207.0/testing/bdd.ts": "3f446df5ef8e856a869e8eec54c8482590415741ff0b6358a00c43486cc15769", 9 | "https://deno.land/std@0.97.0/fmt/colors.ts": "db22b314a2ae9430ae7460ce005e0a7130e23ae1c999157e3bb77cf55800f7e4", 10 | "https://deno.land/std@0.97.0/testing/_diff.ts": "961eaf6d9f5b0a8556c9d835bbc6fa74f5addd7d3b02728ba7936ff93364f7a3", 11 | "https://deno.land/std@0.97.0/testing/asserts.ts": "341292d12eebc44be4c3c2ca101ba8b6b5859cef2fa69d50c217f9d0bfbcfd1f", 12 | "https://deno.land/x/expect@v0.4.0/expect.ts": "1d1856758a750f440d0b65d74f19e5d4829bb76d8e576d05546abd8e7b1dfb9e", 13 | "https://deno.land/x/expect@v0.4.0/matchers.ts": "55acf74a3c4a308d079798930f05ab11da2080ec7acd53517193ca90d1296bf7", 14 | "https://deno.land/x/expect@v0.4.0/mock.ts": "562d4b1d735d15b0b8e935f342679096b64fe452f86e96714fe8616c0c884914", 15 | "https://deno.land/x/expect@v0.4.0/mod.ts": "0304d2430e1e96ba669a8495e24ba606dcc3d152e1f81aaa8da898cea24e36c2" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/deno.ts: -------------------------------------------------------------------------------- 1 | // import { parse, mustEnd } from 'https://unpkg.com/yieldparser@0.4.0?module'; 2 | import { mustEnd, parse } from "../src/index.ts"; 3 | 4 | function* Digit() { 5 | const [digit]: [string] = yield /^\d+/; 6 | const value = parseInt(digit, 10); 7 | if (value < 0 || value > 255) { 8 | return new Error(`Digit must be between 0 and 255, was ${value}`); 9 | } 10 | return value; 11 | } 12 | 13 | function* IPAddress() { 14 | const first = yield Digit; 15 | yield "."; 16 | const second = yield Digit; 17 | yield "."; 18 | const third = yield Digit; 19 | yield "."; 20 | const fourth = yield Digit; 21 | yield mustEnd; 22 | return [first, second, third, fourth]; 23 | } 24 | 25 | console.log(parse("1.2.3.4", IPAddress())); 26 | console.log(parse("1.2.3.256", IPAddress())); 27 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "node", 3 | transform: { 4 | "^.+\\.(t|j)sx?$": "@swc/jest", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/index.ts"; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yieldparser", 3 | "version": "0.4.1", 4 | "license": "MIT", 5 | "source": "src/index.ts", 6 | "main": "dist/yieldparser.js", 7 | "module": "dist/yieldparser.module.js", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "browser": "./dist/yieldparser.module.js", 12 | "import": "./dist/yieldparser.module.js", 13 | "require": "./dist/yieldparser.js" 14 | } 15 | }, 16 | "targets": { 17 | "main": { 18 | "optimize": true 19 | }, 20 | "module": { 21 | "optimize": true 22 | } 23 | }, 24 | "files": [ 25 | "dist", 26 | "src" 27 | ], 28 | "engines": { 29 | "node": ">=16" 30 | }, 31 | "scripts": { 32 | "prepack": "tsc --noEmit && jest && npm run build", 33 | "dev": "parcel watch", 34 | "build": "parcel build", 35 | "test": "jest --watch" 36 | }, 37 | "prettier": { 38 | "printWidth": 80, 39 | "semi": true, 40 | "singleQuote": true, 41 | "trailingComma": "es5" 42 | }, 43 | "author": "Patrick Smith", 44 | "devDependencies": { 45 | "@parcel/packager-ts": "^2.10.3", 46 | "@parcel/transformer-typescript-types": "^2.10.3", 47 | "@swc/jest": "^0.2.29", 48 | "@types/jest": "^26.0.24", 49 | "jest": "^26.6.3", 50 | "parcel": "^2.10.3", 51 | "prettier": "^2.8.8", 52 | "typescript": "^4.9.5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./test-deps.ts"; 2 | import { has, hasMore, mustEnd, parse } from "./index.ts"; 3 | import type { ParsedType, ParseGenerator } from "./index.ts"; 4 | 5 | const test = Deno.test; 6 | 7 | describe("parse()", () => { 8 | describe("failing", () => { 9 | test("array of wrong substrings", () => { 10 | expect(parse("abcdef", ["abc", "wrong"])).toEqual({ 11 | remaining: "def", 12 | success: false, 13 | failedOn: { iterationCount: 1, yielded: "wrong" }, 14 | }); 15 | }); 16 | 17 | test("yielding string after start", () => { 18 | expect( 19 | parse( 20 | "abc", 21 | (function* () { 22 | yield "bc"; 23 | })(), 24 | ), 25 | ).toEqual({ 26 | success: false, 27 | remaining: "abc", 28 | failedOn: { iterationCount: 0, yielded: "bc" }, 29 | }); 30 | }); 31 | 32 | test("yielding wrong string", () => { 33 | expect( 34 | parse( 35 | "abcDEF", 36 | (function* () { 37 | yield "abc"; 38 | yield "def"; 39 | })(), 40 | ), 41 | ).toEqual({ 42 | success: false, 43 | remaining: "DEF", 44 | failedOn: { iterationCount: 1, yielded: "def" }, 45 | }); 46 | }); 47 | }); 48 | 49 | describe("succeeding iterables", () => { 50 | it("accepts substrings", () => { 51 | expect(parse("abcdef", ["abc", "def"])).toEqual({ 52 | remaining: "", 53 | success: true, 54 | }); 55 | }); 56 | 57 | it("accepts array of substrings", () => { 58 | expect(parse("abcdef", [["123", "abc"], "def"])).toEqual({ 59 | remaining: "", 60 | success: true, 61 | }); 62 | }); 63 | 64 | it("only replaces first match", () => { 65 | expect(parse("abc123abc", ["abc", "123", "abc"])).toEqual({ 66 | remaining: "", 67 | success: true, 68 | }); 69 | }); 70 | }); 71 | 72 | describe("succeeding generator functions", () => { 73 | it("accepts substrings", () => { 74 | expect( 75 | parse( 76 | "abcdef", 77 | (function* () { 78 | yield "abc"; 79 | yield "def"; 80 | })(), 81 | ), 82 | ).toEqual({ 83 | remaining: "", 84 | success: true, 85 | }); 86 | }); 87 | 88 | it("accepts empty string", () => { 89 | expect( 90 | parse( 91 | "abcdef", 92 | (function* () { 93 | yield ""; 94 | yield "abc"; 95 | yield ""; 96 | yield "def"; 97 | yield ""; 98 | })(), 99 | ), 100 | ).toEqual({ 101 | remaining: "", 102 | success: true, 103 | }); 104 | }); 105 | 106 | it("accepts array of substrings", () => { 107 | expect( 108 | parse( 109 | "abcdef", 110 | (function* () { 111 | const found: string = yield ["abc", "123"]; 112 | yield "def"; 113 | return { found }; 114 | })(), 115 | ), 116 | ).toEqual({ 117 | remaining: "", 118 | success: true, 119 | result: { 120 | found: "abc", 121 | }, 122 | }); 123 | }); 124 | 125 | it("accepts array of substrings", () => { 126 | expect( 127 | parse( 128 | "abcdef", 129 | (function* () { 130 | const found: string = yield ["123", "abc"]; 131 | yield "def"; 132 | return { found }; 133 | })(), 134 | ), 135 | ).toEqual({ 136 | remaining: "", 137 | success: true, 138 | result: { 139 | found: "abc", 140 | }, 141 | }); 142 | }); 143 | 144 | it("accepts Set of substrings", () => { 145 | expect( 146 | parse( 147 | "abcdef", 148 | (function* () { 149 | const found: string = yield new Set(["123", "abc"]); 150 | yield "def"; 151 | return { found }; 152 | })(), 153 | ), 154 | ).toEqual({ 155 | remaining: "", 156 | success: true, 157 | result: { 158 | found: "abc", 159 | }, 160 | }); 161 | }); 162 | it("accepts Set of substrings", () => { 163 | expect( 164 | parse( 165 | "abcdef", 166 | (function* () { 167 | const found: string = yield "abc"; 168 | yield "def"; 169 | return { found }; 170 | })(), 171 | ), 172 | ).toEqual({ 173 | remaining: "", 174 | success: true, 175 | result: { 176 | found: "abc", 177 | }, 178 | }); 179 | }); 180 | 181 | it("accepts regex", () => { 182 | expect( 183 | parse( 184 | "abcdef", 185 | (function* () { 186 | yield /^abc/; 187 | yield /^def$/; 188 | })(), 189 | ), 190 | ).toEqual({ 191 | remaining: "", 192 | success: true, 193 | }); 194 | }); 195 | 196 | it("accepts newlines as string and regex", () => { 197 | expect( 198 | parse( 199 | "\n\n", 200 | (function* () { 201 | yield "\n"; 202 | yield /^\n/; 203 | })(), 204 | ), 205 | ).toEqual({ 206 | remaining: "", 207 | success: true, 208 | }); 209 | }); 210 | 211 | it("yields result from regex", () => { 212 | expect( 213 | parse( 214 | "abcdef", 215 | (function* () { 216 | const [found1]: [string] = yield /^abc/; 217 | const [found2]: [string] = yield /^def/; 218 | return { found1, found2 }; 219 | })(), 220 | ), 221 | ).toEqual({ 222 | remaining: "", 223 | success: true, 224 | result: { 225 | found1: "abc", 226 | found2: "def", 227 | }, 228 | }); 229 | }); 230 | 231 | it("accepts regex with capture groups", () => { 232 | expect( 233 | parse( 234 | "abcdef", 235 | (function* () { 236 | const [whole, first, second]: [ 237 | string, 238 | string, 239 | string, 240 | ] = yield /^a(b)(c)/; 241 | const [found2]: [string] = yield /^def/; 242 | return { whole, first, second, found2 }; 243 | })(), 244 | ), 245 | ).toEqual({ 246 | remaining: "", 247 | success: true, 248 | result: { 249 | whole: "abc", 250 | first: "b", 251 | second: "c", 252 | found2: "def", 253 | }, 254 | }); 255 | }); 256 | 257 | it("accepts yield delegating to other generator function", () => { 258 | function* BCD() { 259 | yield "b"; 260 | yield "c"; 261 | yield "d"; 262 | return { bcd: true }; 263 | } 264 | 265 | expect( 266 | parse( 267 | "abcdef", 268 | (function* () { 269 | yield "a"; 270 | const result = yield* BCD(); 271 | yield "ef"; 272 | return result; 273 | })(), 274 | ), 275 | ).toEqual({ 276 | remaining: "", 277 | success: true, 278 | result: { 279 | bcd: true, 280 | }, 281 | }); 282 | }); 283 | 284 | it("accepts yielding array of other generator functions", () => { 285 | function* BCD() { 286 | yield "b"; 287 | yield "c"; 288 | yield "d"; 289 | return { bcd: true }; 290 | } 291 | 292 | function* BAD() { 293 | yield "b"; 294 | yield "a"; 295 | yield "d"; 296 | return { bad: true }; 297 | } 298 | 299 | expect( 300 | parse( 301 | "abcdef", 302 | (function* () { 303 | yield "a"; 304 | const result = yield [BAD, BCD]; 305 | yield "ef"; 306 | return result; 307 | })(), 308 | ), 309 | ).toEqual({ 310 | remaining: "", 311 | success: true, 312 | result: { 313 | bcd: true, 314 | }, 315 | }); 316 | }); 317 | }); 318 | 319 | describe("IP Address", () => { 320 | function* Digit() { 321 | const [digit]: [string] = yield /^\d+/; 322 | const value = parseInt(digit, 10); 323 | if (value < 0 || value > 255) { 324 | return new Error(`Digit must be between 0 and 255, was ${value}`); 325 | } 326 | return value; 327 | } 328 | 329 | function* IPAddress() { 330 | const first: number = yield Digit; 331 | yield "."; 332 | const second: number = yield Digit; 333 | yield "."; 334 | const third: number = yield Digit; 335 | yield "."; 336 | const fourth: number = yield Digit; 337 | yield mustEnd; 338 | return [first, second, third, fourth]; 339 | } 340 | 341 | it("accepts valid IP addresses", () => { 342 | expect(parse("1.2.3.4", IPAddress())).toEqual({ 343 | success: true, 344 | result: [1, 2, 3, 4], 345 | remaining: "", 346 | }); 347 | 348 | expect(parse("255.255.255.255", IPAddress())).toEqual({ 349 | success: true, 350 | result: [255, 255, 255, 255], 351 | remaining: "", 352 | }); 353 | }); 354 | 355 | it("rejects invalid 1.2.3.256", () => { 356 | const result = parse("1.2.3.256", IPAddress()); 357 | expect(result.success).toBe(false); 358 | expect(result.remaining).toBe("256"); 359 | expect((result as any).failedOn.nested.yield).toEqual( 360 | new Error("Digit must be between 0 and 255, was 256"), 361 | ); 362 | }); 363 | 364 | it("rejects invalid 1.2.3.4.5", () => { 365 | const result = parse("1.2.3.4.5", IPAddress()); 366 | expect(result.success).toBe(false); 367 | expect(result.remaining).toBe(".5"); 368 | expect((result as any).failedOn.nested.yield).toEqual(mustEnd); 369 | }); 370 | }); 371 | 372 | describe("CSS", () => { 373 | type Selector = string; 374 | interface Declaraction { 375 | property: string; 376 | value: string; 377 | } 378 | interface Rule { 379 | selectors: Array; 380 | declarations: Array; 381 | } 382 | 383 | const whitespaceMay = /^\s*/; 384 | 385 | function* PropertyParser() { 386 | const [name]: [string] = yield /^[-a-z]+/; 387 | return name; 388 | } 389 | 390 | function* ValueParser() { 391 | const [rawValue]: [string] = yield /^(-?\d+(rem|em|%|px|)|[-a-z]+)/; 392 | return rawValue; 393 | } 394 | 395 | function* DeclarationParser() { 396 | const name: string = yield PropertyParser; 397 | yield whitespaceMay; 398 | yield ":"; 399 | yield whitespaceMay; 400 | const rawValue: string = yield ValueParser; 401 | yield whitespaceMay; 402 | yield ";"; 403 | return { name, rawValue }; 404 | } 405 | 406 | function* RuleParser(): 407 | | Generator> 408 | | Generator<() => ParseGenerator, Rule, boolean> 409 | | Generator< 410 | () => typeof DeclarationParser, 411 | Rule, 412 | ParsedType 413 | > 414 | | Generator { 415 | const declarations: Array = []; 416 | 417 | const [selector]: [string] = yield /^(:root|[*]|[a-z][\w]*)/; 418 | 419 | yield whitespaceMay; 420 | yield "{"; 421 | yield whitespaceMay; 422 | while ((yield has("}")) === false) { 423 | yield whitespaceMay; 424 | declarations.push((yield DeclarationParser) as unknown as Declaraction); 425 | yield whitespaceMay; 426 | } 427 | 428 | return { selectors: [selector], declarations } as Rule; 429 | } 430 | 431 | function* RulesParser(): ParseGenerator> { 432 | const rules: Array = []; 433 | 434 | yield whitespaceMay; 435 | while (yield hasMore) { 436 | rules.push(yield RuleParser); 437 | yield whitespaceMay; 438 | } 439 | return rules; 440 | } 441 | 442 | const code = ` 443 | :root { 444 | --first-var: 42rem; 445 | --second-var: 15%; 446 | } 447 | 448 | * { 449 | font: inherit; 450 | box-sizing: border-box; 451 | } 452 | 453 | h1 { 454 | margin-bottom: 1em; 455 | } 456 | `; 457 | 458 | it("parses", () => { 459 | expect(parse(code, RulesParser())).toEqual({ 460 | success: true, 461 | result: [ 462 | { 463 | selectors: [":root"], 464 | declarations: [ 465 | { 466 | name: "--first-var", 467 | rawValue: "42rem", 468 | }, 469 | { 470 | name: "--second-var", 471 | rawValue: "15%", 472 | }, 473 | ], 474 | }, 475 | { 476 | selectors: ["*"], 477 | declarations: [ 478 | { 479 | name: "font", 480 | rawValue: "inherit", 481 | }, 482 | { 483 | name: "box-sizing", 484 | rawValue: "border-box", 485 | }, 486 | ], 487 | }, 488 | { 489 | selectors: ["h1"], 490 | declarations: [ 491 | { 492 | name: "margin-bottom", 493 | rawValue: "1em", 494 | }, 495 | ], 496 | }, 497 | ], 498 | remaining: "", 499 | }); 500 | }); 501 | }); 502 | }); 503 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type ParsedType = A extends { Parser: () => Generator } 2 | ? ParsedTypeForClass 3 | : A extends (...args: unknown[]) => unknown ? ParsedTypeForFunction 4 | : never; 5 | type ParsedTypeForFunction unknown> = 6 | ReturnType extends Generator ? Y : never; 7 | type ParsedTypeForClass Generator }> = ReturnType< 8 | C["Parser"] 9 | > extends Generator ? Y 10 | : never; 11 | 12 | export type ParseItem = 13 | | string 14 | | RegExp 15 | | Iterable 16 | | (() => Generator); 17 | export type ParseYieldable = ParseItem; 18 | 19 | export interface ParseError { 20 | iterationCount: number; 21 | yielded: ParseItem | Error; 22 | nested?: Array; 23 | } 24 | 25 | export type ParseResult = 26 | | { 27 | success: false; 28 | remaining: string; 29 | failedOn: ParseError; 30 | } 31 | | { 32 | success: true; 33 | remaining: string; 34 | result: Result; 35 | }; 36 | 37 | export type ParseYieldedValue = Input extends RegExp 38 | ? RegExpMatchArray 39 | : string; 40 | 41 | export type ParseGenerator = 42 | | Generator, Result, string | RegExpMatchArray> 43 | | Generator, Result, unknown> 44 | | Generator 45 | | Iterable; 46 | 47 | export function parse( 48 | input: string, 49 | iterable: ParseGenerator, 50 | ): ParseResult { 51 | let lastResult: ParseYieldedValue | undefined; 52 | 53 | let iterationCount = -1; 54 | const iterator = iterable[Symbol.iterator](); 55 | 56 | main: while (true) { 57 | const nestedErrors: Array = []; 58 | 59 | iterationCount += 1; 60 | const next = iterator.next(lastResult as any); 61 | if (next.done) { 62 | if (next.value instanceof Error) { 63 | return { 64 | success: false, 65 | remaining: input, 66 | failedOn: { 67 | iterationCount, 68 | yielded: next.value, 69 | }, 70 | }; 71 | } 72 | 73 | return { 74 | success: true, 75 | remaining: input, 76 | result: next.value, 77 | }; 78 | } 79 | 80 | const yielded = next.value as ParseItem; 81 | const choices = 82 | typeof yielded !== "string" && (yielded as any)[Symbol.iterator] 83 | ? (yielded as Iterable) 84 | : [yielded]; 85 | 86 | for (const choice of choices) { 87 | if (typeof choice === "string") { 88 | let found = false; 89 | const newInput = input.replace(choice, (_1, offset: number) => { 90 | found = offset === 0; 91 | return ""; 92 | }); 93 | if (found) { 94 | input = newInput; 95 | lastResult = choice; 96 | continue main; 97 | } 98 | } else if (choice instanceof RegExp) { 99 | if (["^", "$"].includes(choice.source[0]) === false) { 100 | throw new Error(`Regex must be from start: ${choice}`); 101 | } 102 | const match = input.match(choice); 103 | if (match) { 104 | lastResult = match; 105 | // input = input.replace(item, ''); 106 | input = input.slice(match[0].length); 107 | continue main; 108 | } 109 | } else if (choice instanceof Function) { 110 | const choiceResult = parse(input, choice()); 111 | if (choiceResult.success) { 112 | lastResult = choiceResult.result as any; 113 | input = choiceResult.remaining; 114 | continue main; 115 | } else if (choiceResult.failedOn) { 116 | nestedErrors.push(choiceResult.failedOn); 117 | // if (choiceResult.failedOn.iterationCount > 0) { 118 | // return { 119 | // success: false, 120 | // remaining: input, 121 | // failedOn: { 122 | // iterationCount, 123 | // yielded: choice, 124 | // nested: nestedErrors.length === 0 ? undefined : nestedErrors, 125 | // }, 126 | // }; 127 | // } 128 | } 129 | } 130 | } 131 | 132 | return { 133 | success: false, 134 | remaining: input, 135 | failedOn: { 136 | iterationCount, 137 | yielded, 138 | nested: nestedErrors.length === 0 ? undefined : nestedErrors, 139 | }, 140 | }; 141 | } 142 | } 143 | 144 | export function* mustEnd() { 145 | yield /^$/; 146 | } 147 | 148 | export function* isEnd() { 149 | const { index }: { index: number } = yield /$/; 150 | return index === 0; 151 | } 152 | 153 | export function* hasMore() { 154 | const { index }: { index: number } = yield /$/; 155 | return index > 0; 156 | // return !(yield isEnd); 157 | } 158 | 159 | export function has(prefix: ParseYieldable): () => ParseGenerator { 160 | return function* () { 161 | return (yield [prefix, ""]) !== ""; 162 | }; 163 | } 164 | 165 | export function optional( 166 | ...potentials: Array 167 | ): () => ParseGenerator { 168 | return function* () { 169 | const result = yield [...potentials, ""]; 170 | return result === "" ? undefined : result; 171 | }; 172 | } 173 | 174 | export function lookAhead( 175 | regex: RegExp, 176 | ): () => Generator { 177 | const lookAheadRegex = new RegExp(`^(?=${regex.source})`); 178 | return function* () { 179 | return yield lookAheadRegex; 180 | }; 181 | } 182 | 183 | //////// 184 | 185 | export function invert( 186 | needle: {}, 187 | iterable: ParseGenerator, 188 | ): string | null { 189 | const result = invertInner(needle, iterable); 190 | if (result !== null && result.type === "done") { 191 | return result.components.join(""); 192 | } 193 | 194 | return null; 195 | } 196 | 197 | function invertInner( 198 | needle: Record, 199 | iterable: ParseGenerator, 200 | ): { type: "done" | "prefix"; components: ReadonlyArray } | null { 201 | let reply: unknown | undefined; 202 | 203 | const expectedKeys = Object.keys(needle); 204 | if (expectedKeys.length === 0) { 205 | throw new Error("Expected object must have keys."); 206 | } 207 | const iterator = iterable[Symbol.iterator](); 208 | const components: Array = []; 209 | const regexpMap = new Map(); 210 | 211 | while (true) { 212 | const next = iterator.next(reply as any); 213 | if (next.done) { 214 | if (next.value instanceof Error) { 215 | return null; 216 | } 217 | 218 | const result = next.value; 219 | if (result == null) { 220 | return { type: "prefix", components: Object.freeze(components) }; 221 | } 222 | 223 | const resultKeys = new Set(Object.keys(result)); 224 | if ( 225 | expectedKeys.length === resultKeys.size && 226 | expectedKeys.every((key) => { 227 | if (!resultKeys.has(key)) { 228 | return false; 229 | } 230 | 231 | if (typeof result[key] === "symbol") { 232 | const entry = regexpMap.get(result[key]); 233 | if (entry !== undefined) { 234 | if ( 235 | entry.regexp.test(needle[key]) 236 | ) { 237 | components[entry.index] = needle[key]; 238 | return true; 239 | } 240 | } 241 | } 242 | 243 | return result[key] === needle[key]; 244 | }) 245 | ) { 246 | return { type: "done", components: Object.freeze(components) }; 247 | } else { 248 | return null; 249 | } 250 | } 251 | 252 | const yielded = next.value; 253 | const choices = 254 | typeof yielded !== "string" && (yielded as any)[Symbol.iterator] 255 | ? (yielded as Iterable) 256 | : [yielded]; 257 | 258 | for (const choice of choices) { 259 | reply = undefined; 260 | 261 | if (typeof choice === "string") { 262 | components.push(choice); 263 | reply = choice; 264 | break; // Assume first string is the canonical version. 265 | } else if (choice instanceof RegExp) { 266 | const index = components.length; 267 | components.push(""); // This will be replaced later using the index. 268 | // components.push('???'); // This will be replaced later using the index. 269 | const s = Symbol(); 270 | regexpMap.set(s, { regexp: choice, index }); 271 | reply = [s]; 272 | } else if (choice instanceof Function) { 273 | const result = invertInner(needle, choice()); 274 | if (result != null) { 275 | if (result.type === "done") { 276 | return { 277 | type: "done", 278 | components: Object.freeze(components.concat(result.components)), 279 | }; 280 | } else { 281 | components.push(...result.components); 282 | } 283 | } 284 | } 285 | } 286 | } 287 | } 288 | 289 | 290 | // type CustomFunc = (p: Parser) => T; 291 | 292 | // interface MatcherFunc { 293 | // (s: string): string; 294 | // (r: RegExp): [string]; 295 | // (c: CustomFunc): T; 296 | // } 297 | 298 | // type Parser = MatcherFunc & { 299 | // peek: MatcherFunc; 300 | // error(description: string): void; 301 | // }; 302 | 303 | // function Digit(this: Parser): number { 304 | // const [digits] = this(/^\d+$/); 305 | // const value = parseInt(digits, 10); 306 | 307 | // if (value < 0 || value > 255) { 308 | // this.error(`value must be between 0 and 255, was ${value}`); 309 | // } 310 | 311 | // return value; 312 | // } 313 | 314 | // function IPAddress(this: Parser): [number, number, number, number] { 315 | // const first = this(Digit); 316 | // this("."); 317 | // const second = this(Digit); 318 | // this("."); 319 | // const third = this(Digit); 320 | // this("."); 321 | // const fourth = this(Digit); 322 | 323 | // return [first, second, third, fourth]; 324 | // } 325 | -------------------------------------------------------------------------------- /src/math.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from "./test-deps.ts"; 2 | import { has, hasMore, parse, ParseGenerator } from "./index.ts"; 3 | 4 | describe("math parser", () => { 5 | const whitespaceMay = /^\s*/; 6 | 7 | function* ParseInt() { 8 | const isNegative: boolean = yield has("-"); 9 | const [stringValue]: [string] = yield /^\d+/; 10 | return parseInt(stringValue, 10) * (isNegative ? -1 : 1); 11 | } 12 | 13 | type Operator = "+" | "-" | "*" | "/"; 14 | 15 | function* ParseOperator() { 16 | const operator: Operator = yield ["+", "-", "*", "/"]; 17 | return operator; 18 | } 19 | 20 | function applyOperator(a: number, b: number, operator: Operator): number { 21 | switch (operator) { 22 | case "+": 23 | return a + b; 24 | case "-": 25 | return a - b; 26 | case "*": 27 | return a * b; 28 | case "/": 29 | return a / b; 30 | } 31 | } 32 | 33 | function* MathExpression(): ParseGenerator { 34 | yield whitespaceMay; 35 | let current: number = yield ParseInt; 36 | 37 | while (yield hasMore) { 38 | yield whitespaceMay; 39 | const operator: Operator = yield ParseOperator; 40 | yield whitespaceMay; 41 | const other = yield ParseInt; 42 | 43 | current = applyOperator(current, other, operator); 44 | } 45 | 46 | return current; 47 | } 48 | 49 | Deno.test("many", () => { 50 | ([ 51 | ["1 + 1", 2], 52 | ["1 + 2", 3], 53 | ["2 + 2", 4], 54 | ["21 + 19", 40], 55 | ["21 + -19", 2], 56 | ["-21 + 19", -2], 57 | ["-21 + -19", -40], 58 | ["0 - 10", -10], 59 | ["21 - 19", 2], 60 | ["-21 - 19", -40], 61 | ["1 * 1", 1], 62 | ["2 * 2", 4], 63 | ["12 * 12", 144], 64 | ["1 / 2", 0.5], 65 | ["10 / 2", 5], 66 | ["10 / 20", 0.5], 67 | ] as const).forEach(([input, output]) => { 68 | expect(parse(input, MathExpression())).toEqual({ 69 | success: true, 70 | result: output, 71 | remaining: "", 72 | }); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/media-query.test.ts: -------------------------------------------------------------------------------- 1 | // https://www.w3.org/TR/mediaqueries-5/ 2 | import { afterEach, beforeEach, describe, expect, it } from './test-deps.ts'; 3 | import { mustEnd, optional, parse, ParseResult } from './index.ts'; 4 | import { ParserGenerator, YieldedValue } from './types.ts'; 5 | 6 | const optionalWhitespace = /^\s*/; 7 | const requiredWhitespace = /^\s+/; 8 | 9 | export function has(prefix: string | RegExp): () => ParserGenerator { 10 | return function* (): ParserGenerator { 11 | const [match] = yield [prefix, '']; 12 | return match !== ''; 13 | }; 14 | } 15 | 16 | export function* hasMore(): ParserGenerator { 17 | const { index }: { index: number } = yield /$/; 18 | return index > 0; 19 | // return !(yield isEnd); 20 | } 21 | 22 | function* ParseInt(): ParserGenerator { 23 | const isNegative = Boolean(yield has('-')); 24 | const [stringValue] = yield /^\d+/; 25 | return parseInt(stringValue, 10) * (isNegative ? -1 : 1); 26 | } 27 | 28 | interface MatchMediaContext { 29 | mediaType: 'screen' | 'print'; 30 | viewportWidth: number; 31 | viewportHeight: number; 32 | viewportZoom: number; 33 | rootFontSizePx: number; 34 | primaryPointingDevice?: 'touchscreen' | 'mouse'; 35 | secondaryPointingDevice?: 'touchscreen' | 'mouse'; 36 | } 37 | 38 | class ParsedMediaType { 39 | constructor(public readonly mediaType: 'screen' | 'print' | 'all') {} 40 | 41 | matches(context: { mediaType: 'screen' | 'print' }) { 42 | if (this.mediaType === 'all') return true; 43 | return this.mediaType === context.mediaType; 44 | } 45 | 46 | static *Parser(): ParserGenerator { 47 | yield optionalWhitespace; 48 | yield /^only\s+/; 49 | const [mediaType] = yield ['screen', 'print']; 50 | return new ParsedMediaType(mediaType as 'screen' | 'print'); 51 | } 52 | } 53 | 54 | class ParsedNotMediaType { 55 | constructor(public readonly mediaType: 'screen' | 'print' | 'all') {} 56 | 57 | matches(context: { mediaType: 'screen' | 'print' }) { 58 | if (this.mediaType === 'all') return false; 59 | return this.mediaType !== context.mediaType; 60 | } 61 | 62 | static *Parser(): ParserGenerator { 63 | yield optionalWhitespace; 64 | yield 'not'; 65 | yield requiredWhitespace; 66 | const [mediaType] = yield ['screen', 'print']; 67 | return new ParsedNotMediaType(mediaType as ParsedNotMediaType['mediaType']); 68 | } 69 | } 70 | 71 | /** 72 | * https://www.w3.org/TR/mediaqueries-5/#width 73 | */ 74 | class ParsedMinWidth { 75 | constructor( 76 | public readonly value: number, 77 | public readonly unit: 'px' | 'em' | 'rem' 78 | ) {} 79 | 80 | private valueInPx(context: MatchMediaContext): number { 81 | switch (this.unit) { 82 | case 'px': 83 | return this.value; 84 | case 'rem': 85 | case 'em': 86 | return this.value * context.rootFontSizePx; 87 | } 88 | } 89 | 90 | matches(context: MatchMediaContext) { 91 | return this.valueInPx(context) <= context.viewportWidth; 92 | } 93 | 94 | static *Parser(): ParserGenerator { 95 | yield optionalWhitespace; 96 | yield '('; 97 | yield optionalWhitespace; 98 | yield 'min-width:'; 99 | yield optionalWhitespace; 100 | const { value } = yield ParseInt; 101 | const [unit] = yield ['px', 'em', 'rem']; 102 | yield optionalWhitespace; 103 | yield ')'; 104 | return new ParsedMinWidth(value.valueOf(), unit as 'px' | 'em' | 'rem'); 105 | } 106 | } 107 | 108 | /** 109 | * https://www.w3.org/TR/mediaqueries-5/#orientation 110 | */ 111 | class ParsedOrientation { 112 | constructor(public readonly orientation: 'portrait' | 'landscape') {} 113 | 114 | matches(context: { viewportWidth: number; viewportHeight: number }) { 115 | const calculated = 116 | context.viewportHeight >= context.viewportWidth 117 | ? 'portrait' 118 | : 'landscape'; 119 | return this.orientation === calculated; 120 | } 121 | 122 | static *Parser(): ParserGenerator { 123 | yield optionalWhitespace; 124 | yield '('; 125 | yield optionalWhitespace; 126 | yield 'orientation:'; 127 | yield optionalWhitespace; 128 | const [orientation] = yield ['portrait', 'landscape']; 129 | yield optionalWhitespace; 130 | yield ')'; 131 | return new ParsedOrientation(orientation as 'portrait' | 'landscape'); 132 | } 133 | } 134 | 135 | /** 136 | https://www.w3.org/TR/mediaqueries-5/#hover 137 | */ 138 | const PointerAccuracy = Object.freeze({ 139 | none: 0, 140 | coarse: 1, 141 | fine: 2, 142 | 143 | fromDevice(device: 'touchscreen' | 'mouse' | undefined) { 144 | switch (device) { 145 | case 'mouse': 146 | return PointerAccuracy.fine; 147 | case 'touchscreen': 148 | return PointerAccuracy.coarse; 149 | default: 150 | return PointerAccuracy.none; 151 | } 152 | }, 153 | }); 154 | type PointerLevels = (typeof PointerAccuracy)['none' | 'coarse' | 'fine']; 155 | class ParsedPointer { 156 | constructor( 157 | public readonly accuracy: 'none' | 'coarse' | 'fine', 158 | public readonly any?: 'any' 159 | ) {} 160 | 161 | private get minLevel() { 162 | return PointerAccuracy[this.accuracy]; 163 | } 164 | 165 | private primaryAccuracy(context: MatchMediaContext) { 166 | return PointerAccuracy.fromDevice(context.primaryPointingDevice); 167 | } 168 | 169 | private bestAccuracy(context: MatchMediaContext) { 170 | return Math.max( 171 | PointerAccuracy.fromDevice(context.primaryPointingDevice), 172 | PointerAccuracy.fromDevice(context.secondaryPointingDevice) 173 | ) as PointerLevels; 174 | } 175 | 176 | matches(context: MatchMediaContext) { 177 | const minLevel = this.minLevel; 178 | const deviceLevel = 179 | this.any === 'any' 180 | ? this.bestAccuracy(context) 181 | : this.primaryAccuracy(context); 182 | 183 | if (minLevel === PointerAccuracy.none) { 184 | return deviceLevel === PointerAccuracy.none; 185 | } 186 | 187 | return deviceLevel >= minLevel; 188 | } 189 | 190 | static *Parser(): ParserGenerator { 191 | yield optionalWhitespace; 192 | yield '('; 193 | yield optionalWhitespace; 194 | const any = Boolean(yield has('any-')); 195 | yield 'pointer:'; 196 | yield optionalWhitespace; 197 | const [hover] = yield ['none', 'coarse', 'fine']; 198 | // const [hover] = yield* oneOf('none', 'coarse', 'fine'); 199 | yield optionalWhitespace; 200 | yield ')'; 201 | return new ParsedPointer( 202 | hover as 'none' | 'coarse' | 'fine', 203 | any ? 'any' : undefined 204 | ); 205 | } 206 | } 207 | 208 | /** 209 | https://www.w3.org/TR/mediaqueries-5/#hover 210 | */ 211 | class ParsedHover { 212 | constructor( 213 | public readonly hover: 'none' | 'hover', 214 | public readonly any?: 'any' 215 | ) {} 216 | 217 | private canPrimaryHover(context: MatchMediaContext) { 218 | switch (context.primaryPointingDevice) { 219 | case 'mouse': 220 | return true; 221 | default: 222 | return false; 223 | } 224 | } 225 | 226 | private canAnyHover(context: MatchMediaContext) { 227 | switch (context.secondaryPointingDevice) { 228 | case 'mouse': 229 | return true; 230 | default: 231 | return this.canPrimaryHover(context); 232 | } 233 | } 234 | 235 | matches(context: MatchMediaContext) { 236 | const canHover = 237 | this.any === 'any' 238 | ? this.canAnyHover(context) 239 | : this.canPrimaryHover(context); 240 | 241 | if (canHover) { 242 | return this.hover === 'hover'; 243 | } else { 244 | return this.hover === 'none'; 245 | } 246 | } 247 | 248 | static *Parser(): ParserGenerator { 249 | yield optionalWhitespace; 250 | yield '('; 251 | yield optionalWhitespace; 252 | const any = Boolean(yield has('any-')); 253 | yield 'hover:'; 254 | yield optionalWhitespace; 255 | const [hover] = yield ['none', 'hover']; 256 | yield optionalWhitespace; 257 | yield ')'; 258 | return new ParsedHover(hover as 'none' | 'hover', any ? 'any' : undefined); 259 | } 260 | } 261 | 262 | // See https://www.w3.org/TR/mediaqueries-5/#mq-syntax 263 | const parsedMediaFeature = [ 264 | ParsedMinWidth.Parser, 265 | ParsedOrientation.Parser, 266 | ParsedHover.Parser, 267 | ParsedPointer.Parser, 268 | ]; 269 | const parsedMediaInParens = [...parsedMediaFeature]; 270 | // type ParsedMediaFeature = ParsedType<(typeof parsedMediaFeature)[-1]>; 271 | type ParsedMediaFeature = 272 | | ParsedMinWidth 273 | | ParsedOrientation 274 | | ParsedHover 275 | | ParsedPointer; 276 | type ParsedMediaInParens = ParsedMediaFeature; 277 | 278 | class ParsedMediaCondition { 279 | constructor( 280 | public readonly first: ParsedMediaFeature, 281 | public readonly conditions?: ParsedMediaAnds | ParsedMediaOrs 282 | ) {} 283 | 284 | matches(context: MatchMediaContext) { 285 | const base = this.first.matches(context); 286 | if (this.conditions instanceof ParsedMediaAnds) { 287 | return base && this.conditions.matches(context); 288 | } else if (this.conditions instanceof ParsedMediaOrs) { 289 | return base || this.conditions.matches(context); 290 | } else { 291 | return base; 292 | } 293 | } 294 | 295 | static *Parser() { 296 | yield optionalWhitespace; 297 | const first: ParsedMediaInParens = yield parsedMediaInParens; 298 | const conditions: ParsedMediaAnds | ParsedMediaOrs | '' = yield [ 299 | ParsedMediaAnds.Parser, 300 | ParsedMediaOrs.Parser, 301 | '', 302 | ]; 303 | if (conditions === '') { 304 | return first; 305 | } else { 306 | return new ParsedMediaCondition(first, conditions); 307 | } 308 | } 309 | } 310 | 311 | class ParsedMediaAnds { 312 | constructor(public readonly list: ReadonlyArray) {} 313 | 314 | matches(context: MatchMediaContext) { 315 | return this.list.every((m) => m.matches(context)); 316 | } 317 | 318 | static *Parser(): ParserGenerator { 319 | const list: Array = []; 320 | 321 | do { 322 | const [a, c] = yield requiredWhitespace; 323 | const [b] = yield 'and'; 324 | yield requiredWhitespace; 325 | const { value: item } = yield parsedMediaInParens; 326 | list.push(item); 327 | } while (yield hasMore); 328 | 329 | return new ParsedMediaAnds(list); 330 | } 331 | } 332 | 333 | class ParsedMediaOrs { 334 | constructor(public readonly list: ReadonlyArray) {} 335 | 336 | matches(context: MatchMediaContext) { 337 | return this.list.some((m) => m.matches(context)); 338 | } 339 | 340 | static *Parser(): ParserGenerator { 341 | const list: Array = []; 342 | 343 | do { 344 | yield requiredWhitespace; 345 | yield 'or'; 346 | yield requiredWhitespace; 347 | list.push((yield parsedMediaInParens).value); 348 | } while (yield hasMore); 349 | 350 | return new ParsedMediaOrs(list); 351 | } 352 | } 353 | 354 | class ParsedMediaTypeThenConditionWithoutOr { 355 | constructor( 356 | public readonly mediaType: ParsedMediaType | ParsedNotMediaType, 357 | public readonly and: ReadonlyArray 358 | ) {} 359 | 360 | matches(context: MatchMediaContext) { 361 | return ( 362 | this.mediaType.matches(context) && 363 | this.and.every((m) => m.matches(context)) 364 | ); 365 | } 366 | 367 | static *ParserA(): ParserGenerator< 368 | | ParsedMediaType 369 | | ParsedNotMediaType 370 | | ParsedMediaTypeThenConditionWithoutOr, 371 | ParsedMediaType | ParsedNotMediaType 372 | > { 373 | const mediaType = yield [ParsedMediaType.Parser, ParsedNotMediaType.Parser]; 374 | 375 | const list: Array = []; 376 | 377 | if (list.length === 0) { 378 | return mediaType.value; 379 | } else { 380 | return new ParsedMediaTypeThenConditionWithoutOr(mediaType.value, list); 381 | } 382 | } 383 | 384 | static *Parser(): ParserGenerator< 385 | | ParsedMediaType 386 | | ParsedNotMediaType 387 | | ParsedMediaTypeThenConditionWithoutOr, 388 | ParsedMediaType | ParsedNotMediaType | ParsedMediaInParens 389 | > { 390 | const mediaType = (yield [ 391 | ParsedMediaType.Parser, 392 | ParsedNotMediaType.Parser, 393 | ]) as YieldedValue; 394 | 395 | const list: Array = []; 396 | 397 | while (yield has(/^\s+and\s/)) { 398 | list.push( 399 | ((yield parsedMediaInParens) as YieldedValue).value 400 | ); 401 | } 402 | 403 | if (list.length === 0) { 404 | return mediaType.value; 405 | } else { 406 | return new ParsedMediaTypeThenConditionWithoutOr(mediaType.value, list); 407 | } 408 | } 409 | } 410 | 411 | class ParsedMediaQuery { 412 | constructor( 413 | public readonly main: 414 | | ParsedMediaTypeThenConditionWithoutOr 415 | | ParsedMediaType 416 | ) {} 417 | 418 | static *Parser() { 419 | const main: ParsedMediaQuery['main'] = yield [ 420 | ParsedMediaTypeThenConditionWithoutOr.Parser, 421 | ParsedMediaCondition.Parser, 422 | ]; 423 | yield optionalWhitespace; 424 | yield mustEnd; 425 | return main; 426 | } 427 | } 428 | 429 | function matchMedia(context: MatchMediaContext, mediaQuery: string) { 430 | const parsed: ParseResult = parse( 431 | mediaQuery, 432 | ParsedMediaQuery.Parser() as any 433 | ); 434 | if (!parsed.success) { 435 | throw Error(`Invalid media query: ${mediaQuery}`); 436 | } 437 | 438 | let matches = false; 439 | if ( 440 | 'matches' in parsed.result && 441 | typeof parsed.result.matches === 'function' 442 | ) { 443 | matches = parsed.result.matches(context); 444 | } 445 | 446 | return { 447 | matches, 448 | }; 449 | } 450 | 451 | it('can parse "screen"', () => { 452 | const result = parse('screen', ParsedMediaQuery.Parser() as any); 453 | expect(result).toEqual({ 454 | success: true, 455 | result: new ParsedMediaType('screen'), 456 | remaining: '', 457 | }); 458 | }); 459 | 460 | it('can parse (min-width: 480px)', () => { 461 | const result = parse('(min-width: 480px)', ParsedMediaQuery.Parser() as any); 462 | expect(result).toEqual({ 463 | success: true, 464 | result: new ParsedMinWidth(480, 'px'), 465 | remaining: '', 466 | }); 467 | }); 468 | 469 | it('can parse (orientation: landscape)', () => { 470 | const result = parse( 471 | '(orientation: landscape)', 472 | ParsedMediaQuery.Parser() as any 473 | ); 474 | expect(result).toEqual({ 475 | success: true, 476 | result: new ParsedOrientation('landscape'), 477 | remaining: '', 478 | }); 479 | }); 480 | 481 | it('can parse "screen and (min-width: 480px)"', () => { 482 | const result = parse( 483 | 'screen and (min-width: 480px)', 484 | ParsedMediaQuery.Parser() as any 485 | ); 486 | expect(result).toEqual({ 487 | success: true, 488 | result: new ParsedMediaTypeThenConditionWithoutOr( 489 | new ParsedMediaType('screen'), 490 | [new ParsedMinWidth(480, 'px')] 491 | ), 492 | remaining: '', 493 | }); 494 | }); 495 | 496 | it('can run matchMedia()', () => { 497 | const defaultRootFontSizePx = 16; 498 | const viewport = (width: number, height: number, zoom: number = 1) => 499 | ({ 500 | viewportWidth: width / zoom, 501 | viewportHeight: height / zoom, 502 | viewportZoom: zoom, 503 | } as const); 504 | 505 | const screen = ( 506 | viewport: Pick< 507 | MatchMediaContext, 508 | 'viewportWidth' | 'viewportHeight' | 'viewportZoom' 509 | >, 510 | primaryPointingDevice: 'touchscreen' | 'mouse' | undefined = 'touchscreen', 511 | secondaryPointingDevice?: 'touchscreen' | 'mouse' 512 | ) => 513 | ({ 514 | mediaType: 'screen', 515 | ...viewport, 516 | rootFontSizePx: defaultRootFontSizePx, 517 | primaryPointingDevice, 518 | secondaryPointingDevice, 519 | } as const); 520 | 521 | const screenSized = ( 522 | viewportWidth: number, 523 | viewportHeight: number, 524 | primaryPointingDevice: 'touchscreen' | 'mouse' | null = 'touchscreen', 525 | secondaryPointingDevice?: 'touchscreen' | 'mouse' 526 | ) => 527 | ({ 528 | mediaType: 'screen', 529 | viewportWidth, 530 | viewportHeight, 531 | viewportZoom: 1, 532 | rootFontSizePx: defaultRootFontSizePx, 533 | primaryPointingDevice: primaryPointingDevice ?? undefined, 534 | secondaryPointingDevice, 535 | } as const); 536 | 537 | const printSized = (viewportWidth: number, viewportHeight: number) => 538 | ({ 539 | mediaType: 'print', 540 | viewportWidth, 541 | viewportHeight, 542 | viewportZoom: 1, 543 | rootFontSizePx: defaultRootFontSizePx, 544 | } as const); 545 | 546 | expect(matchMedia(screenSized(100, 100), 'screen').matches).toBe(true); 547 | expect(matchMedia(screenSized(100, 100), 'only screen').matches).toBe(true); 548 | expect(matchMedia(screenSized(100, 100), 'not screen').matches).toBe(false); 549 | expect(matchMedia(screenSized(100, 100), 'print').matches).toBe(false); 550 | expect(matchMedia(screenSized(100, 100), 'only print').matches).toBe(false); 551 | 552 | expect(matchMedia(printSized(100, 100), 'screen').matches).toBe(false); 553 | expect(matchMedia(printSized(100, 100), 'only screen').matches).toBe(false); 554 | expect(matchMedia(printSized(100, 100), 'print').matches).toBe(true); 555 | expect(matchMedia(printSized(100, 100), 'only print').matches).toBe(true); 556 | 557 | expect(matchMedia(screenSized(478, 100), '(min-width: 480px)').matches).toBe( 558 | false 559 | ); 560 | expect(matchMedia(screenSized(479, 100), '(min-width: 480px)').matches).toBe( 561 | false 562 | ); 563 | expect(matchMedia(screenSized(480, 100), '(min-width: 480px)').matches).toBe( 564 | true 565 | ); 566 | expect(matchMedia(screenSized(481, 100), '(min-width: 480px)').matches).toBe( 567 | true 568 | ); 569 | 570 | expect( 571 | matchMedia(screen(viewport(479, 100)), '(min-width: 30em)').matches 572 | ).toBe(false); 573 | expect( 574 | matchMedia(screen(viewport(480, 100)), '(min-width: 30em)').matches 575 | ).toBe(true); 576 | expect( 577 | matchMedia(screen(viewport(481, 100)), '(min-width: 30em)').matches 578 | ).toBe(true); 579 | 580 | expect( 581 | matchMedia(screen(viewport(480, 100, 0.5)), '(min-width: 15em)').matches 582 | ).toBe(true); 583 | expect( 584 | matchMedia(screen(viewport(480, 100, 2.0)), '(min-width: 15em)').matches 585 | ).toBe(true); 586 | expect( 587 | matchMedia(screen(viewport(480, 100, 2.1)), '(min-width: 15em)').matches 588 | ).toBe(false); 589 | 590 | expect( 591 | matchMedia(screen(viewport(480, 100, 0.5)), '(min-width: 60em)').matches 592 | ).toBe(true); 593 | expect( 594 | matchMedia(screen(viewport(480, 100, 0.55)), '(min-width: 60em)').matches 595 | ).toBe(false); 596 | expect( 597 | matchMedia(screen(viewport(480, 100, 2.0)), '(min-width: 60em)').matches 598 | ).toBe(false); 599 | 600 | expect( 601 | matchMedia(screen(viewport(479, 100)), '(min-width: 30rem)').matches 602 | ).toBe(false); 603 | expect( 604 | matchMedia(screen(viewport(480, 100)), '(min-width: 30rem)').matches 605 | ).toBe(true); 606 | expect( 607 | matchMedia(screen(viewport(481, 100)), '(min-width: 30rem)').matches 608 | ).toBe(true); 609 | 610 | expect( 611 | matchMedia(screenSized(200, 100), '(orientation: landscape)').matches 612 | ).toBe(true); 613 | expect( 614 | matchMedia(screenSized(200, 100), '(orientation: portrait)').matches 615 | ).toBe(false); 616 | 617 | expect( 618 | matchMedia(screenSized(100, 200), '(orientation: landscape)').matches 619 | ).toBe(false); 620 | expect( 621 | matchMedia(screenSized(100, 200), '(orientation: portrait)').matches 622 | ).toBe(true); 623 | 624 | expect( 625 | matchMedia(screenSized(100, 100), '(orientation: landscape)').matches 626 | ).toBe(false); 627 | expect( 628 | matchMedia(screenSized(100, 100), '(orientation: portrait)').matches 629 | ).toBe(true); 630 | 631 | expect( 632 | matchMedia(screenSized(100, 100, 'touchscreen'), '(hover: none)').matches 633 | ).toBe(true); 634 | expect( 635 | matchMedia(screenSized(100, 100, 'touchscreen'), '(hover: hover)').matches 636 | ).toBe(false); 637 | expect( 638 | matchMedia(screenSized(100, 100, 'touchscreen'), '(any-hover: none)') 639 | .matches 640 | ).toBe(true); 641 | expect( 642 | matchMedia(screenSized(100, 100, 'touchscreen'), '(any-hover: hover)') 643 | .matches 644 | ).toBe(false); 645 | expect( 646 | matchMedia(screenSized(100, 100, 'touchscreen'), '(pointer: none)').matches 647 | ).toBe(false); 648 | expect( 649 | matchMedia(screenSized(100, 100, 'touchscreen'), '(pointer: coarse)') 650 | .matches 651 | ).toBe(true); 652 | expect( 653 | matchMedia(screenSized(100, 100, 'touchscreen'), '(pointer: fine)').matches 654 | ).toBe(false); 655 | expect( 656 | matchMedia(screenSized(100, 100, 'touchscreen'), '(any-pointer: none)') 657 | .matches 658 | ).toBe(false); 659 | expect( 660 | matchMedia(screenSized(100, 100, 'touchscreen'), '(any-pointer: coarse)') 661 | .matches 662 | ).toBe(true); 663 | expect( 664 | matchMedia(screenSized(100, 100, 'touchscreen'), '(any-pointer: fine)') 665 | .matches 666 | ).toBe(false); 667 | 668 | expect( 669 | matchMedia(screenSized(100, 100, 'touchscreen', 'mouse'), '(hover: none)') 670 | .matches 671 | ).toBe(true); 672 | expect( 673 | matchMedia(screenSized(100, 100, 'touchscreen', 'mouse'), '(hover: hover)') 674 | .matches 675 | ).toBe(false); 676 | expect( 677 | matchMedia( 678 | screenSized(100, 100, 'touchscreen', 'mouse'), 679 | '(any-hover: none)' 680 | ).matches 681 | ).toBe(false); 682 | expect( 683 | matchMedia( 684 | screenSized(100, 100, 'touchscreen', 'mouse'), 685 | '(any-hover: hover)' 686 | ).matches 687 | ).toBe(true); 688 | expect( 689 | matchMedia( 690 | screenSized(100, 100, 'touchscreen', 'mouse'), 691 | '(any-pointer: none)' 692 | ).matches 693 | ).toBe(false); 694 | expect( 695 | matchMedia( 696 | screenSized(100, 100, 'touchscreen', 'mouse'), 697 | '(any-pointer: coarse)' 698 | ).matches 699 | ).toBe(true); 700 | expect( 701 | matchMedia( 702 | screenSized(100, 100, 'touchscreen', 'mouse'), 703 | '(any-pointer: fine)' 704 | ).matches 705 | ).toBe(true); 706 | 707 | expect( 708 | matchMedia(screenSized(100, 100, 'mouse'), '(hover: none)').matches 709 | ).toBe(false); 710 | expect( 711 | matchMedia(screenSized(100, 100, 'mouse'), '(hover: hover)').matches 712 | ).toBe(true); 713 | expect( 714 | matchMedia(screenSized(100, 100, 'mouse'), '(any-hover: none)').matches 715 | ).toBe(false); 716 | expect( 717 | matchMedia(screenSized(100, 100, 'mouse'), '(any-hover: hover)').matches 718 | ).toBe(true); 719 | expect( 720 | matchMedia(screenSized(100, 100, 'mouse'), '(pointer: none)').matches 721 | ).toBe(false); 722 | expect( 723 | matchMedia(screenSized(100, 100, 'mouse'), '(pointer: coarse)').matches 724 | ).toBe(true); 725 | expect( 726 | matchMedia(screenSized(100, 100, 'mouse'), '(pointer: fine)').matches 727 | ).toBe(true); 728 | expect( 729 | matchMedia(screenSized(100, 100, 'mouse'), '(any-pointer: none)').matches 730 | ).toBe(false); 731 | expect( 732 | matchMedia(screenSized(100, 100, 'mouse'), '(any-pointer: coarse)').matches 733 | ).toBe(true); 734 | expect( 735 | matchMedia(screenSized(100, 100, 'mouse'), '(any-pointer: fine)').matches 736 | ).toBe(true); 737 | 738 | expect( 739 | matchMedia(screenSized(100, 100, 'mouse', 'touchscreen'), '(hover: none)') 740 | .matches 741 | ).toBe(false); 742 | expect( 743 | matchMedia(screenSized(100, 100, 'mouse', 'touchscreen'), '(hover: hover)') 744 | .matches 745 | ).toBe(true); 746 | expect( 747 | matchMedia( 748 | screenSized(100, 100, 'mouse', 'touchscreen'), 749 | '(any-hover: none)' 750 | ).matches 751 | ).toBe(false); 752 | expect( 753 | matchMedia( 754 | screenSized(100, 100, 'mouse', 'touchscreen'), 755 | '(any-hover: hover)' 756 | ).matches 757 | ).toBe(true); 758 | 759 | expect(matchMedia(screenSized(100, 100, null), '(hover: none)').matches).toBe( 760 | true 761 | ); 762 | expect( 763 | matchMedia(screenSized(100, 100, null), '(hover: hover)').matches 764 | ).toBe(false); 765 | expect( 766 | matchMedia(screenSized(100, 100, null), '(any-hover: none)').matches 767 | ).toBe(true); 768 | expect( 769 | matchMedia(screenSized(100, 100, null), '(any-hover: hover)').matches 770 | ).toBe(false); 771 | expect( 772 | matchMedia(screenSized(100, 100, null), '(pointer: none)').matches 773 | ).toBe(true); 774 | expect( 775 | matchMedia(screenSized(100, 100, null), '(pointer: coarse)').matches 776 | ).toBe(false); 777 | expect( 778 | matchMedia(screenSized(100, 100, null), '(pointer: fine)').matches 779 | ).toBe(false); 780 | expect( 781 | matchMedia(screenSized(100, 100, null), '(any-pointer: none)').matches 782 | ).toBe(true); 783 | expect( 784 | matchMedia(screenSized(100, 100, null), '(any-pointer: coarse)').matches 785 | ).toBe(false); 786 | expect( 787 | matchMedia(screenSized(100, 100, null), '(any-pointer: fine)').matches 788 | ).toBe(false); 789 | 790 | expect( 791 | matchMedia(screenSized(480, 100), 'screen and (min-width: 480px)').matches 792 | ).toBe(true); 793 | expect( 794 | matchMedia(screenSized(480, 100), 'only screen and (min-width: 480px)') 795 | .matches 796 | ).toBe(true); 797 | expect( 798 | matchMedia( 799 | screenSized(480, 100), 800 | 'only screen and (min-width: 480px) and (orientation: landscape)' 801 | ).matches 802 | ).toBe(true); 803 | expect( 804 | matchMedia( 805 | screenSized(480, 100, 'touchscreen'), 806 | 'only screen and (min-width: 480px) and (orientation: landscape) and (any-hover: hover)' 807 | ).matches 808 | ).toBe(false); 809 | expect( 810 | matchMedia( 811 | screenSized(480, 100, 'touchscreen', 'mouse'), 812 | 'only screen and (min-width: 480px) and (orientation: landscape) and (any-hover: hover)' 813 | ).matches 814 | ).toBe(true); 815 | expect( 816 | matchMedia( 817 | screenSized(480, 100, 'touchscreen', 'mouse'), 818 | 'not print and (min-width: 480px) and (orientation: landscape) and (any-hover: hover)' 819 | ).matches 820 | ).toBe(true); 821 | 822 | expect( 823 | matchMedia( 824 | screenSized(480, 100), 825 | '(orientation: landscape) or (orientation: portrait)' 826 | ).matches 827 | ).toBe(true); 828 | }); 829 | -------------------------------------------------------------------------------- /src/modules.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./test-deps.ts"; 2 | import { has, hasMore, optional, parse } from "./index.ts"; 3 | 4 | describe("ES modules", () => { 5 | const code = `import first from 'first-module'; 6 | 7 | import second from 'second-module'; 8 | 9 | const a = 'hello!'; 10 | const pi = 3.14159; 11 | const symbolA = Symbol('a'); 12 | 13 | function noop() {} 14 | 15 | function whoami() { 16 | return 'admin'; 17 | } 18 | 19 | function* oneTwoThree() { 20 | yield 1; 21 | yield 'some string'; 22 | yield 3; 23 | } 24 | 25 | function closure() { 26 | function inner() {} 27 | 28 | return inner; 29 | } 30 | 31 | ;; ;; ;; 32 | 33 | export const b = 'some exported'; 34 | 35 | export function double() { 36 | return 'double'; 37 | } 38 | `; 39 | 40 | const whitespaceMust = /^\s+/; 41 | const whitespaceMay = /^\s*/; 42 | const semicolonOptional = /^;*/; 43 | // See: https://stackoverflow.com/questions/2008279/validate-a-javascript-function-name 44 | const identifierRegex = /^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*/; 45 | const stringRegex = 46 | /^('(?([^']|\\['\\bfnrt\/])*)'|"(?([^"]|\\['\\bfnrt\/])*)")/; 47 | 48 | function* Identifier() { 49 | const [name]: [string] = yield identifierRegex; 50 | return { type: "identifier", name }; 51 | } 52 | 53 | function* StringLiteral() { 54 | const { 55 | groups, 56 | }: { 57 | groups: Record<"contentsSingle" | "contentsDouble", string>; 58 | } = yield stringRegex; 59 | return groups.contentsSingle || groups.contentsDouble || ""; 60 | } 61 | 62 | function* NumberLiteral() { 63 | const [stringValue]: [ 64 | string, 65 | ] = yield /^(([\d]+[.][\d]*)|([\d]*[.][\d]+)|([\d]+))/; 66 | return parseFloat(stringValue); 67 | } 68 | 69 | function* ValueLiteral() { 70 | return yield [StringLiteral, NumberLiteral]; 71 | } 72 | 73 | function* SymbolDeclaration() { 74 | yield "Symbol("; 75 | const name = yield StringLiteral; 76 | yield ")"; 77 | return { type: "symbol", name }; 78 | } 79 | 80 | function* Expression() { 81 | return yield [ValueLiteral, SymbolDeclaration, Identifier]; 82 | } 83 | 84 | function* ConstStatement() { 85 | yield "const"; 86 | yield whitespaceMust; 87 | const { name }: { name: string } = yield Identifier; 88 | yield whitespaceMay; 89 | yield "="; 90 | yield whitespaceMay; 91 | const value = yield Expression; 92 | yield semicolonOptional; 93 | return { type: "const", name, value }; 94 | } 95 | 96 | function* ReturnStatement() { 97 | yield "return"; 98 | yield whitespaceMust; 99 | const value = yield Expression; 100 | yield semicolonOptional; 101 | return { type: "return", value }; 102 | } 103 | 104 | function* YieldStatement() { 105 | yield "yield"; 106 | yield whitespaceMust; 107 | const value = yield Expression; 108 | yield semicolonOptional; 109 | return { type: "yield", value }; 110 | } 111 | 112 | function* FunctionParser() { 113 | yield "function"; 114 | yield whitespaceMay; 115 | const isGenerator: boolean = yield has("*"); 116 | yield whitespaceMay; 117 | const { name }: { name: string } = yield Identifier; 118 | yield whitespaceMay; 119 | yield "("; 120 | yield ")"; 121 | yield whitespaceMay; 122 | yield "{"; 123 | yield whitespaceMay; 124 | let statements: Array = []; 125 | while ((yield has("}")) === false) { 126 | yield whitespaceMay; 127 | const statement = yield [ 128 | ConstStatement, 129 | ReturnStatement, 130 | YieldStatement, 131 | FunctionParser, 132 | ]; 133 | statements.push(statement); 134 | yield whitespaceMay; 135 | } 136 | // yield '}'; 137 | return { type: "function", name, isGenerator, statements }; 138 | } 139 | 140 | function* ImportStatement() { 141 | yield "import"; 142 | yield whitespaceMust; 143 | const { name: defaultBinding }: { name: string } = yield Identifier; 144 | yield whitespaceMust; 145 | yield "from"; 146 | yield whitespaceMay; 147 | const moduleSpecifier = yield StringLiteral; 148 | yield semicolonOptional; 149 | return { 150 | type: "import", 151 | defaultBinding, 152 | moduleSpecifier, 153 | }; 154 | } 155 | 156 | function* ExportStatement() { 157 | yield "export"; 158 | yield whitespaceMust; 159 | const exported = yield [ConstStatement, FunctionParser]; 160 | return { type: "export", exported }; 161 | } 162 | 163 | // function* ExportNamed() { 164 | // yield 'export'; 165 | // return { bad: true }; 166 | // } 167 | 168 | function* ESModuleParser() { 169 | const lines: Array = []; 170 | while (yield hasMore) { 171 | yield /^[\s;]*/; 172 | lines.push( 173 | yield [ 174 | ConstStatement, 175 | ImportStatement, 176 | ExportStatement, 177 | FunctionParser, 178 | ], 179 | ); 180 | yield /^[\s;]*/; 181 | } 182 | return lines; 183 | } 184 | 185 | it("accepts empty string", () => { 186 | expect(parse("", ESModuleParser())).toEqual({ 187 | remaining: "", 188 | success: true, 189 | result: [], 190 | }); 191 | }); 192 | 193 | describe("valid ES module", () => { 194 | const expected = { 195 | remaining: "", 196 | success: true, 197 | result: [ 198 | { 199 | type: "import", 200 | defaultBinding: "first", 201 | moduleSpecifier: "first-module", 202 | }, 203 | { 204 | type: "import", 205 | defaultBinding: "second", 206 | moduleSpecifier: "second-module", 207 | }, 208 | { 209 | type: "const", 210 | name: "a", 211 | value: "hello!", 212 | }, 213 | { 214 | type: "const", 215 | name: "pi", 216 | value: 3.14159, 217 | }, 218 | { 219 | type: "const", 220 | name: "symbolA", 221 | value: { 222 | type: "symbol", 223 | name: "a", 224 | }, 225 | }, 226 | { 227 | type: "function", 228 | name: "noop", 229 | isGenerator: false, 230 | statements: [], 231 | }, 232 | { 233 | type: "function", 234 | name: "whoami", 235 | isGenerator: false, 236 | statements: [ 237 | { 238 | type: "return", 239 | value: "admin", 240 | }, 241 | ], 242 | }, 243 | { 244 | type: "function", 245 | name: "oneTwoThree", 246 | isGenerator: true, 247 | statements: [ 248 | { 249 | type: "yield", 250 | value: 1, 251 | }, 252 | { 253 | type: "yield", 254 | value: "some string", 255 | }, 256 | { 257 | type: "yield", 258 | value: 3, 259 | }, 260 | ], 261 | }, 262 | { 263 | type: "function", 264 | name: "closure", 265 | isGenerator: false, 266 | statements: [ 267 | { 268 | type: "function", 269 | name: "inner", 270 | isGenerator: false, 271 | statements: [], 272 | }, 273 | { 274 | type: "return", 275 | value: { 276 | type: "identifier", 277 | name: "inner", 278 | }, 279 | }, 280 | ], 281 | }, 282 | { 283 | type: "export", 284 | exported: { 285 | type: "const", 286 | name: "b", 287 | value: "some exported", 288 | }, 289 | }, 290 | { 291 | type: "export", 292 | exported: { 293 | type: "function", 294 | name: "double", 295 | isGenerator: false, 296 | statements: [ 297 | { 298 | type: "return", 299 | value: "double", 300 | }, 301 | ], 302 | }, 303 | }, 304 | ], 305 | }; 306 | 307 | it("can parse an ES module", () => { 308 | expect(parse(code, ESModuleParser())).toEqual(expected); 309 | }); 310 | 311 | it("can parse with leading and trailing whitespace", () => { 312 | expect(parse("\n \n " + code + " \n \n", ESModuleParser())).toEqual( 313 | expected, 314 | ); 315 | }); 316 | 317 | describe("exports", () => { 318 | function* exports() { 319 | const result = parse(code, ESModuleParser()); 320 | if (!result.success) { 321 | return; 322 | } 323 | 324 | for (const item of result.result as any[]) { 325 | if (item.type === "export") { 326 | yield item.exported; 327 | } 328 | } 329 | } 330 | 331 | it("exports b", () => { 332 | expect(Array.from(exports())).toEqual([ 333 | { name: "b", type: "const", value: "some exported" }, 334 | { 335 | type: "function", 336 | name: "double", 337 | isGenerator: false, 338 | statements: [ 339 | { 340 | type: "return", 341 | value: "double", 342 | }, 343 | ], 344 | }, 345 | ]); 346 | }); 347 | }); 348 | 349 | describe("lookup", () => { 350 | function lookup(identifier: string) { 351 | const result = parse(code, ESModuleParser()); 352 | if (!result.success) { 353 | return; 354 | } 355 | 356 | for (const item of result.result as any[]) { 357 | if (item.type === "const") { 358 | if (item.name === identifier) { 359 | return item; 360 | } 361 | } else if (item.type === "function") { 362 | if (item.name === identifier) { 363 | return item; 364 | } 365 | } else if (item.type === "export") { 366 | if (item.exported.name === identifier) { 367 | return item.exported; 368 | } 369 | } 370 | } 371 | } 372 | 373 | it("can lookup const", () => { 374 | expect(lookup("a")).toEqual({ 375 | type: "const", 376 | name: "a", 377 | value: "hello!", 378 | }); 379 | }); 380 | 381 | it("can lookup function", () => { 382 | expect(lookup("whoami")).toEqual({ 383 | type: "function", 384 | name: "whoami", 385 | isGenerator: false, 386 | statements: [ 387 | { 388 | type: "return", 389 | value: "admin", 390 | }, 391 | ], 392 | }); 393 | }); 394 | 395 | it("can lookup exported const", () => { 396 | expect(lookup("b")).toEqual({ 397 | name: "b", 398 | type: "const", 399 | value: "some exported", 400 | }); 401 | }); 402 | 403 | it("can lookup exported function", () => { 404 | expect(lookup("double")).toEqual({ 405 | type: "function", 406 | name: "double", 407 | isGenerator: false, 408 | statements: [ 409 | { 410 | type: "return", 411 | value: "double", 412 | }, 413 | ], 414 | }); 415 | }); 416 | }); 417 | }); 418 | }); 419 | -------------------------------------------------------------------------------- /src/natural-dates.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect } from "./test-deps.ts"; 2 | import { 3 | has, 4 | optional, 5 | parse, 6 | ParseGenerator, 7 | ParseResult, 8 | ParseYieldable, 9 | } from "./index.ts"; 10 | 11 | describe("natural date parser", () => { 12 | const whitespaceOptional = /^\s*/; 13 | 14 | function* ParseInt() { 15 | const [stringValue]: [string] = yield /^\d+/; 16 | return parseInt(stringValue, 10); 17 | } 18 | 19 | const weekdayChoices = Object.freeze( 20 | [ 21 | "monday", 22 | "tuesday", 23 | "wednesday", 24 | "thursday", 25 | "friday", 26 | "saturday", 27 | "sunday", 28 | ] as const, 29 | ); 30 | type Weekday = (typeof weekdayChoices)[0 | 1 | 2 | 3 | 4 | 5 | 6]; 31 | 32 | function* WeekdayParser() { 33 | let repeats: boolean = yield has(/^every\b/); 34 | yield optional(/^next\b/); 35 | 36 | yield whitespaceOptional; 37 | 38 | const weekday: Weekday = yield weekdayChoices; 39 | repeats = repeats || (yield has(/^[s]\b/)); 40 | 41 | return { weekday, repeats }; 42 | } 43 | 44 | function* AnotherWeekdayParser() { 45 | yield whitespaceOptional; 46 | yield optional("and", "or"); 47 | yield whitespaceOptional; 48 | return yield WeekdayParser; 49 | } 50 | 51 | function* WeekdaysParser() { 52 | let repeats = false; 53 | 54 | const weekdays = new Set(); 55 | 56 | let result: { weekday: Weekday; repeats: boolean }; 57 | result = yield WeekdayParser; 58 | 59 | weekdays.add(result.weekday); 60 | repeats = repeats || result.repeats; 61 | 62 | while (result = yield optional(AnotherWeekdayParser)) { 63 | weekdays.add(result.weekday); 64 | repeats = repeats || result.repeats; 65 | } 66 | 67 | return { weekdays, repeats }; 68 | } 69 | 70 | function* MinutesSuffixParser() { 71 | yield ":"; 72 | const minutes = yield ParseInt; 73 | return minutes; 74 | } 75 | 76 | function* TimeOfDayParser() { 77 | let hours = yield ParseInt; 78 | const minutes = yield optional(MinutesSuffixParser); 79 | const amOrPm = yield optional("am", "pm"); 80 | if (amOrPm === "pm" && hours <= 11) { 81 | hours += 12; 82 | } else if (amOrPm === "am" && hours === 12) { 83 | hours = 24; 84 | } 85 | return { hours, minutes }; 86 | } 87 | 88 | function* TimespanSuffixParser() { 89 | const started = yield optional("to", "-", "–", "—", "until"); 90 | if (started === undefined) return undefined; 91 | yield whitespaceOptional; 92 | return yield TimeOfDayParser; 93 | } 94 | 95 | function* TimespanParser() { 96 | yield ["from", "at", ""]; 97 | yield whitespaceOptional; 98 | const startTime = yield TimeOfDayParser; 99 | yield whitespaceOptional; 100 | const endTime = yield optional(TimespanSuffixParser); 101 | return { startTime, endTime }; 102 | } 103 | 104 | interface Result { 105 | weekdays: Set; 106 | repeats: undefined | "weekly"; 107 | startTime: { hours: number; minutes?: number }; 108 | endTime: { hours: number; minutes?: number }; 109 | } 110 | 111 | function* NaturalDateParser(): ParseGenerator { 112 | yield whitespaceOptional; 113 | const { weekdays, repeats } = yield WeekdaysParser; 114 | yield whitespaceOptional; 115 | 116 | yield whitespaceOptional; 117 | const timespan = yield optional(TimespanParser); 118 | yield whitespaceOptional; 119 | 120 | return { 121 | repeats: repeats ? "weekly" : undefined, 122 | weekdays, 123 | ...(timespan as any), 124 | }; 125 | } 126 | 127 | function parseNaturalDate(input: string) { 128 | input = input.toLowerCase(); 129 | input = input.replace(/[,]/g, ""); 130 | return parse(input, NaturalDateParser()); 131 | } 132 | 133 | Deno.test.each([ 134 | ["Monday", { weekdays: new Set(["monday"]) }], 135 | ["Wednesday", { weekdays: new Set(["wednesday"]) }], 136 | [" Wednesday ", { weekdays: new Set(["wednesday"]) }], 137 | ["Wednesday and Saturday", { 138 | weekdays: new Set(["wednesday", "saturday"]), 139 | }], 140 | ["Wednesday or Saturday", { weekdays: new Set(["wednesday", "saturday"]) }], 141 | ["Wednesday, Saturday", { weekdays: new Set(["wednesday", "saturday"]) }], 142 | ["Wednesday and, Saturday", { 143 | weekdays: new Set(["wednesday", "saturday"]), 144 | }], 145 | ["Every Wednesday", { 146 | repeats: "weekly", 147 | weekdays: new Set(["wednesday"]), 148 | }], 149 | [" Every Wednesday ", { 150 | repeats: "weekly", 151 | weekdays: new Set(["wednesday"]), 152 | }], 153 | ["Every Wednesday or Saturday", { 154 | repeats: "weekly", 155 | weekdays: new Set(["wednesday", "saturday"]), 156 | }], 157 | ["Wednesdays", { repeats: "weekly", weekdays: new Set(["wednesday"]) }], 158 | [" Wednesdays ", { repeats: "weekly", weekdays: new Set(["wednesday"]) }], 159 | ["Wednesdays and Tuesdays", { 160 | repeats: "weekly", 161 | weekdays: new Set(["wednesday", "tuesday"]), 162 | }], 163 | [" Wednesdays and Tuesdays ", { 164 | repeats: "weekly", 165 | weekdays: new Set(["wednesday", "tuesday"]), 166 | }], 167 | ["Wednesdays and Tuesdays and Fridays and Wednesdays", { 168 | repeats: "weekly", 169 | weekdays: new Set(["wednesday", "tuesday", "friday"]), 170 | }], 171 | ["Wednesdays at 9", { 172 | repeats: "weekly", 173 | weekdays: new Set(["wednesday"]), 174 | startTime: { hours: 9 }, 175 | }], 176 | [" Wednesdays at 9 ", { 177 | repeats: "weekly", 178 | weekdays: new Set(["wednesday"]), 179 | startTime: { hours: 9 }, 180 | }], 181 | ["Wednesdays at 9:30", { 182 | repeats: "weekly", 183 | weekdays: new Set(["wednesday"]), 184 | startTime: { hours: 9, minutes: 30 }, 185 | }], 186 | ["Wednesdays at 9:59", { 187 | repeats: "weekly", 188 | weekdays: new Set(["wednesday"]), 189 | startTime: { hours: 9, minutes: 59 }, 190 | }], 191 | ["Wednesdays at 9:30am", { 192 | repeats: "weekly", 193 | weekdays: new Set(["wednesday"]), 194 | startTime: { hours: 9, minutes: 30 }, 195 | }], 196 | ["Wednesdays at 9:30pm", { 197 | repeats: "weekly", 198 | weekdays: new Set(["wednesday"]), 199 | startTime: { hours: 21, minutes: 30 }, 200 | }], 201 | ["Mondays at 11:30", { 202 | repeats: "weekly", 203 | weekdays: new Set(["monday"]), 204 | startTime: { hours: 11, minutes: 30 }, 205 | }], 206 | ["Mondays at 9:30 to 10:30", { 207 | repeats: "weekly", 208 | weekdays: new Set(["monday"]), 209 | startTime: { hours: 9, minutes: 30 }, 210 | endTime: { hours: 10, minutes: 30 }, 211 | }], 212 | ["Mondays 9:30–10:30", { 213 | repeats: "weekly", 214 | weekdays: new Set(["monday"]), 215 | startTime: { hours: 9, minutes: 30 }, 216 | endTime: { hours: 10, minutes: 30 }, 217 | }], 218 | ["Mondays and Thursdays at 9:30 to 10:30", { 219 | repeats: "weekly", 220 | weekdays: new Set(["monday", "thursday"]), 221 | startTime: { hours: 9, minutes: 30 }, 222 | endTime: { hours: 10, minutes: 30 }, 223 | }], 224 | ["Mondays at 9:30pm to 10:30pm", { 225 | repeats: "weekly", 226 | weekdays: new Set(["monday"]), 227 | startTime: { hours: 21, minutes: 30 }, 228 | endTime: { hours: 22, minutes: 30 }, 229 | }], 230 | ["Fridays from 11:15am to 12:30pm", { 231 | repeats: "weekly", 232 | weekdays: new Set(["friday"]), 233 | startTime: { hours: 11, minutes: 15 }, 234 | endTime: { hours: 12, minutes: 30 }, 235 | }], 236 | ["Fridays from 11:15am to 12:00am", { 237 | repeats: "weekly", 238 | weekdays: new Set(["friday"]), 239 | startTime: { hours: 11, minutes: 15 }, 240 | endTime: { hours: 24, minutes: 0 }, 241 | }], 242 | ])("%o", (input: string, output) => { 243 | expect(parseNaturalDate(input)).toEqual({ 244 | success: true, 245 | result: output, 246 | remaining: "", 247 | }); 248 | }); 249 | }); 250 | -------------------------------------------------------------------------------- /src/routing.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./test-deps.ts"; 2 | import { invert, mustEnd, parse } from "./index.ts"; 3 | 4 | describe("Router", () => { 5 | type Route = 6 | | { type: "home" } 7 | | { type: "about" } 8 | | { type: "albums" } 9 | | { type: "album"; id: string } 10 | | { type: "albumArt"; id: string }; 11 | 12 | function* Home() { 13 | yield "/"; 14 | yield mustEnd; 15 | return { type: "home" } as Route; 16 | } 17 | 18 | function* About() { 19 | yield "/about"; 20 | yield mustEnd; 21 | return { type: "about" } as Route; 22 | } 23 | 24 | const Albums = { 25 | *List() { 26 | yield "/albums"; 27 | yield mustEnd; 28 | return { type: "albums" } as Route; 29 | }, 30 | *ItemPrefix() { 31 | yield "/albums/"; 32 | const [id]: [string] = yield /^\d+/; 33 | return { id }; 34 | }, 35 | *Item() { 36 | const { id }: { id: string } = yield Albums.ItemPrefix; 37 | yield mustEnd; 38 | return { type: "album", id } as Route; 39 | }, 40 | *ItemArt() { 41 | const { id }: { id: string } = yield Albums.ItemPrefix; 42 | yield "/art"; 43 | yield mustEnd; 44 | return { type: "albumArt", id } as Route; 45 | }, 46 | }; 47 | 48 | function* AlbumRoutes() { 49 | return yield [Albums.List, Albums.Item, Albums.ItemArt]; 50 | } 51 | 52 | function* Route() { 53 | return yield [Home, About, AlbumRoutes]; 54 | } 55 | 56 | it("works with home", () => { 57 | expect(parse("/", Route())).toEqual({ 58 | success: true, 59 | result: { type: "home" }, 60 | remaining: "", 61 | }); 62 | }); 63 | it("works with about", () => { 64 | expect(parse("/about", Route())).toEqual({ 65 | success: true, 66 | result: { type: "about" }, 67 | remaining: "", 68 | }); 69 | }); 70 | it("works with albums", () => { 71 | expect(parse("/albums", Route())).toEqual({ 72 | success: true, 73 | result: { type: "albums" }, 74 | remaining: "", 75 | }); 76 | }); 77 | it("works with album for id", () => { 78 | expect(parse("/albums/42", Route())).toEqual({ 79 | success: true, 80 | result: { type: "album", id: "42" }, 81 | remaining: "", 82 | }); 83 | }); 84 | it("works with album art for id", () => { 85 | expect(parse("/albums/42/art", Route())).toEqual({ 86 | success: true, 87 | result: { type: "albumArt", id: "42" }, 88 | remaining: "", 89 | }); 90 | }); 91 | }); 92 | 93 | describe("Router inversion", () => { 94 | type Route = 95 | | { type: "home" } 96 | | { type: "about" } 97 | | { type: "terms" } 98 | | { type: "albums" } 99 | | { type: "album"; id: string } 100 | | { type: "albumArt"; id: string }; 101 | 102 | function* Home() { 103 | yield "/"; 104 | yield mustEnd; 105 | return { type: "home" } as Route; 106 | } 107 | 108 | function* About() { 109 | yield "/about"; 110 | yield mustEnd; 111 | return { type: "about" } as Route; 112 | } 113 | 114 | function* Terms() { 115 | yield "/legal"; 116 | yield "/terms"; 117 | yield mustEnd; 118 | return { type: "terms" } as Route; 119 | } 120 | 121 | function* AlbumItem() { 122 | yield "/albums/"; 123 | const [id]: [string] = yield /^\d+/; 124 | return { type: "album", id }; 125 | } 126 | 127 | function* blogPrefix() { 128 | yield "/blog"; 129 | } 130 | 131 | function* BlogHome() { 132 | yield blogPrefix; 133 | yield mustEnd; 134 | return { type: "blog" }; 135 | } 136 | 137 | function* BlogArticle() { 138 | yield blogPrefix; 139 | yield "/"; 140 | const [slug]: [string] = yield /^.+/; 141 | return { type: "blogArticle", slug }; 142 | } 143 | 144 | function* BlogRoutes() { 145 | return yield [BlogHome, BlogArticle]; 146 | } 147 | 148 | function* Routes() { 149 | return yield [Home, About, Terms]; 150 | } 151 | 152 | function* DoubleNested() { 153 | return yield [BlogRoutes, Routes]; 154 | } 155 | 156 | it("works with single route definition", () => { 157 | expect(invert({ type: "home" }, Home())).toEqual("/"); 158 | expect(invert({ type: "about" }, About())).toEqual("/about"); 159 | expect(invert({ type: "terms" }, Terms())).toEqual("/legal/terms"); 160 | expect(invert({ type: "BLAH" }, Terms())).toBeNull(); 161 | }); 162 | 163 | it("works with single route definition with param", () => { 164 | expect(invert({ type: "album", id: "123" }, AlbumItem())).toEqual( 165 | "/albums/123", 166 | ); 167 | expect(invert({ type: "album", id: "678" }, AlbumItem())).toEqual( 168 | "/albums/678", 169 | ); 170 | expect(invert({ type: "album", id: "abc" }, AlbumItem())).toBeNull(); 171 | expect(invert({ type: "BLAH", id: "123" }, AlbumItem())).toBeNull(); 172 | }); 173 | 174 | it("works with nested routes", () => { 175 | expect(invert({ type: "home" }, Routes())).toEqual("/"); 176 | expect(invert({ type: "about" }, Routes())).toEqual("/about"); 177 | expect(invert({ type: "terms" }, Routes())).toEqual("/legal/terms"); 178 | expect(invert({ type: "BLAH" }, Routes())).toBeNull(); 179 | }); 180 | 181 | it("works with routes with nested prefix", () => { 182 | expect(invert({ type: "blog" }, BlogHome())).toEqual("/blog"); 183 | expect(invert({ type: "blogArticle", slug: "hello-world" }, BlogArticle())) 184 | .toEqual("/blog/hello-world"); 185 | 186 | expect(invert({ type: "blog" }, BlogRoutes())).toEqual("/blog"); 187 | expect(invert({ type: "blogArticle", slug: "hello-world" }, BlogRoutes())) 188 | .toEqual("/blog/hello-world"); 189 | expect(invert({ type: "BLAH" }, BlogRoutes())).toBeNull(); 190 | }); 191 | 192 | it("all works with double nested routes", () => { 193 | expect(invert({ type: "home" }, DoubleNested())).toEqual("/"); 194 | expect(invert({ type: "blog" }, DoubleNested())).toEqual("/blog"); 195 | expect(invert({ type: "blogArticle", slug: "hello-world" }, DoubleNested())) 196 | .toEqual("/blog/hello-world"); 197 | expect(invert({ type: "BLAH" }, DoubleNested())).toBeNull(); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /src/tailwindcss.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./test-deps.ts"; 2 | 3 | let tailwindExcerpt = 4 | `/*! modern-normalize v1.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */*,::after,::before{box-sizing:border-box}:root{-moz-tab-size:4;tab-size:4}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'}hr{height:0;color:inherit}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}::-moz-focus-inner{border-style:none;padding:0}:-moz-focusring{outline:1px dotted ButtonText}:-moz-ui-invalid{box-shadow:none}legend{padding:0}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}button{background-color:transparent;background-image:none}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}fieldset{margin:0;padding:0}ol,ul{list-style:none;margin:0;padding:0}html{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";line-height:1.5}body{font-family:inherit;line-height:inherit}*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}hr{border-top-width:1px}img{border-style:solid}textarea{resize:vertical}input::placeholder,textarea::placeholder{color:#9ca3af}[role=button],button{cursor:pointer}table{border-collapse:collapse}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}button,input,optgroup,select,textarea{padding:0;line-height:inherit;color:inherit}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px * var(--tw-space-y-reverse))}.space-x-0>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(0px * var(--tw-space-x-reverse));margin-left:calc(0px * calc(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem * var(--tw-space-x-reverse));margin-left:calc(.25rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem * var(--tw-space-x-reverse));margin-left:calc(.75rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem * var(--tw-space-x-reverse));margin-left:calc(1rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}`; 5 | 6 | // tailwindExcerpt = `/*! modern-normalize v1.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */*,::after,::before{box-sizing:border-box}:root{-moz-tab-size:4;tab-size:4}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'}hr{height:0;color:inherit}`; 7 | 8 | import { has, hasMore, lookAhead, parse, ParseGenerator } from "./index.ts"; 9 | 10 | interface CSSComment { 11 | type: "comment"; 12 | content: string; 13 | } 14 | 15 | interface CSSDeclaration { 16 | name: string; 17 | rawValue: string; 18 | } 19 | 20 | interface CSSRule { 21 | type: "rule"; 22 | selectors: Array; 23 | declarations: Array; 24 | } 25 | 26 | interface CSSMedia { 27 | type: "media"; 28 | rawFeatures: string; 29 | rules: Array; 30 | } 31 | 32 | const whitespaceMay = /^\s*/; 33 | 34 | function* PropertyParser() { 35 | const [name]: [string] = yield /^[-a-z]+/; 36 | return name; 37 | } 38 | 39 | function* ValueParser() { 40 | const [rawValue]: [string] = yield /^[^;}]+/; 41 | // const [rawValue]: [ 42 | // string 43 | // ] = yield /^(0|-?(\d+[.]\d+|[.]\d+|\d+[.]|\d+)(rem|em|%|px|pt|ch|)(\s[-\w,\s'"]+)?|var\(--[\w-]+\)|[-\w,\s'"]+)|#[\da-fA-F]+/; 44 | return rawValue; 45 | } 46 | 47 | function* DeclarationParser() { 48 | const name = yield PropertyParser; 49 | yield whitespaceMay; 50 | yield ":"; 51 | yield whitespaceMay; 52 | const rawValue = yield ValueParser; 53 | yield whitespaceMay; 54 | yield has(";"); 55 | return { name, rawValue }; 56 | } 57 | 58 | function* SelectorComponentParser() { 59 | // const [selector]: [ 60 | // string 61 | // ] = yield /^(:root|[*]|::after|::before|html|[a-z][\w]*)/; 62 | const [selector]: [string] = yield /^([.#]?[-:=~*>.#\(\)\w\[\]]+)/; 63 | return selector; 64 | } 65 | 66 | function* RuleParser(): ParseGenerator { 67 | const declarations: Array = []; 68 | 69 | // const [selector] = yield /(:root|[*]|[a-z][\w]*)/; 70 | 71 | const selectors: Array = []; 72 | yield whitespaceMay; 73 | while (true) { 74 | selectors.push(yield SelectorComponentParser); 75 | yield whitespaceMay; 76 | if (yield has(",")) { 77 | yield whitespaceMay; 78 | continue; 79 | } 80 | 81 | if (yield has("{")) break; 82 | } 83 | 84 | // yield whitespaceMay; 85 | // yield "{"; 86 | yield whitespaceMay; 87 | while ((yield has("}")) === false) { 88 | declarations.push(yield DeclarationParser); 89 | yield whitespaceMay; 90 | } 91 | 92 | return { type: "rule", selectors: selectors, declarations }; 93 | } 94 | 95 | type MediaQueryParser = { 96 | type: "media"; 97 | rawFeatures: string; 98 | rules: ReadonlyArray; 99 | }; 100 | function* MediaQueryParser(): 101 | | Generator> 102 | | Generator { 103 | yield "@media"; 104 | yield whitespaceMay; 105 | yield "("; 106 | const [rawFeatures]: [string] = yield /^[^)]+/; 107 | yield ")"; 108 | yield whitespaceMay; 109 | yield "{"; 110 | const rules: ReadonlyArray = yield RulesParser; 111 | yield "}"; 112 | return { type: "media", rawFeatures, rules }; 113 | } 114 | 115 | function* CommentParser(): Generator< 116 | RegExp | string, 117 | CSSComment, 118 | [string, string] 119 | > { 120 | yield "/*"; 121 | const [, content] = yield /^(.*?)\*\//; 122 | return { type: "comment", content }; 123 | } 124 | 125 | function* RulesParser(): ParseGenerator> { 126 | const rules: Array = []; 127 | const hasClosingParent = has(lookAhead(/}/)); 128 | 129 | yield whitespaceMay; 130 | while (yield hasMore) { 131 | if (yield hasClosingParent) break; 132 | 133 | rules.push(yield [RuleParser, CommentParser]); 134 | yield whitespaceMay; 135 | 136 | // if (yield closingParent) break; 137 | } 138 | return rules; 139 | } 140 | 141 | function* StylesheetParser() { 142 | const elements: Array = []; 143 | 144 | yield whitespaceMay; 145 | while (yield hasMore) { 146 | elements.push(yield [RuleParser, CommentParser, MediaQueryParser]); 147 | yield whitespaceMay; 148 | } 149 | return elements; 150 | } 151 | 152 | function parseCSS(cssSource: string) { 153 | return parse(cssSource, StylesheetParser()); 154 | } 155 | 156 | ///// 157 | 158 | function* generateComment(item: CSSComment) { 159 | yield "/*"; 160 | yield item.content; 161 | yield "*/"; 162 | } 163 | 164 | function* generateDeclaration(declaration: CSSDeclaration) { 165 | yield declaration.name; 166 | yield ":"; 167 | yield declaration.rawValue; 168 | } 169 | 170 | function* generateRule(item: CSSRule) { 171 | yield item.selectors.join(","); 172 | yield "{"; 173 | for (const [index, declaration] of item.declarations.entries()) { 174 | yield* generateDeclaration(declaration); 175 | if (index < item.declarations.length - 1) { 176 | yield ";"; 177 | } 178 | } 179 | yield "}"; 180 | } 181 | 182 | function* generateMediaSource(item: CSSMedia) { 183 | yield "@media "; 184 | yield "("; 185 | yield item.rawFeatures; 186 | yield ")"; 187 | yield "{"; 188 | for (const rule of item.rules) { 189 | yield* generateRule(rule); 190 | } 191 | yield "}"; 192 | } 193 | 194 | function* generateCSSSource(items: Array) { 195 | for (const item of items) { 196 | if (item.type === "media") { 197 | yield* generateMediaSource(item); 198 | } else if (item.type === "rule") { 199 | yield* generateRule(item); 200 | } else if (item.type === "comment") { 201 | yield* generateComment(item); 202 | } 203 | } 204 | } 205 | 206 | function stringifyCSS(items: Array) { 207 | return Array.from(generateCSSSource(items)).join(""); 208 | } 209 | 210 | describe("CSS values", () => { 211 | it("parses 42", () => { 212 | expect(parse("42", ValueParser())).toMatchObject({ 213 | remaining: "", 214 | result: "42", 215 | success: true, 216 | }); 217 | }); 218 | 219 | it("parses 1.15", () => { 220 | expect(parse("1.15", ValueParser())).toMatchObject({ 221 | remaining: "", 222 | result: "1.15", 223 | success: true, 224 | }); 225 | }); 226 | 227 | it("parses 1.", () => { 228 | expect(parse("1.", ValueParser())).toMatchObject({ 229 | remaining: "", 230 | result: "1.", 231 | success: true, 232 | }); 233 | }); 234 | 235 | it("parses .1", () => { 236 | expect(parse(".1", ValueParser())).toMatchObject({ 237 | remaining: "", 238 | result: ".1", 239 | success: true, 240 | }); 241 | }); 242 | 243 | it("parses hex color", () => { 244 | expect(parse("#e5e7eb", ValueParser())).toMatchObject({ 245 | remaining: "", 246 | result: "#e5e7eb", 247 | success: true, 248 | }); 249 | }); 250 | 251 | it("parses 100%", () => { 252 | expect(parse("100%", ValueParser())).toMatchObject({ 253 | remaining: "", 254 | result: "100%", 255 | success: true, 256 | }); 257 | }); 258 | 259 | it("parses font stack", () => { 260 | expect( 261 | parse( 262 | `system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'`, 263 | ValueParser(), 264 | ), 265 | ).toMatchObject({ 266 | remaining: "", 267 | result: 268 | `system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'`, 269 | success: true, 270 | }); 271 | }); 272 | 273 | it("parses border long-form", () => { 274 | expect(parse(`1px solid black`, ValueParser())).toMatchObject({ 275 | remaining: "", 276 | result: `1px solid black`, 277 | success: true, 278 | }); 279 | }); 280 | 281 | it("parses var", () => { 282 | expect(parse(`var(--primary)`, ValueParser())).toMatchObject({ 283 | remaining: "", 284 | result: `var(--primary)`, 285 | success: true, 286 | }); 287 | }); 288 | }); 289 | 290 | describe("selectors", () => { 291 | it("parses .container", () => { 292 | expect(parse(".container", SelectorComponentParser())).toEqual({ 293 | remaining: "", 294 | result: ".container", 295 | success: true, 296 | }); 297 | }); 298 | }); 299 | 300 | describe("media queries", () => { 301 | it("parses empty", () => { 302 | expect( 303 | parse(`@media (min-width:640px){}`, MediaQueryParser() as any), 304 | ).toEqual({ 305 | remaining: "", 306 | result: { 307 | rawFeatures: "min-width:640px", 308 | rules: [], 309 | type: "media", 310 | }, 311 | success: true, 312 | }); 313 | }); 314 | 315 | it("parses with class", () => { 316 | expect( 317 | parse( 318 | `@media (min-width:640px){.container{max-width:640px}}`, 319 | MediaQueryParser() as any, 320 | ), 321 | ).toEqual({ 322 | remaining: "", 323 | result: { 324 | rawFeatures: "min-width:640px", 325 | rules: [ 326 | { 327 | declarations: [ 328 | { 329 | name: "max-width", 330 | rawValue: "640px", 331 | }, 332 | ], 333 | selectors: [".container"], 334 | type: "rule", 335 | }, 336 | ], 337 | type: "media", 338 | }, 339 | success: true, 340 | }); 341 | }); 342 | }); 343 | 344 | it("parses Tailwind excerpt", () => { 345 | const result = parseCSS(tailwindExcerpt); 346 | expect(result.success).toBe(true); 347 | }); 348 | 349 | it("parses and stringifies Tailwind excerpt", () => { 350 | const result = parseCSS(tailwindExcerpt); 351 | if (result.success !== true) { 352 | fail("Parsing failed"); 353 | } 354 | 355 | expect(stringifyCSS(result.result)).toEqual(tailwindExcerpt); 356 | }); 357 | -------------------------------------------------------------------------------- /src/test-deps.ts: -------------------------------------------------------------------------------- 1 | export { expect } from "https://deno.land/x/expect/mod.ts"; 2 | export { 3 | afterEach, 4 | beforeEach, 5 | describe, 6 | it, 7 | } from "https://deno.land/std@0.207.0/testing/bdd.ts"; 8 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | type GetYield = T extends { 2 | next(...args: [unknown]): IteratorResult; 3 | } 4 | ? A 5 | : never; 6 | 7 | const internal = Symbol('internal'); 8 | export class YieldedValue { 9 | constructor(stringValue: S) { 10 | this[internal] = stringValue; 11 | } 12 | 13 | get value(): T { 14 | return this[internal]; 15 | } 16 | 17 | get index(): number { 18 | return 0; 19 | } 20 | 21 | *[Symbol.iterator](): IterableIterator { 22 | const a: Array = this[internal]; 23 | yield* a; 24 | } 25 | } 26 | 27 | export type PrimitiveYield = 28 | | S 29 | | RegExp 30 | // | (() => Omit, "next" | "return" | "throw">) 31 | // | (() => Omit, "next" | "return" | "throw">) 32 | | (() => { 33 | [Symbol.iterator](): { 34 | next: { 35 | (result: unknown): IteratorResult; 36 | // (result: unknown): IteratorResult 37 | }; 38 | }; 39 | }) 40 | | ReadonlyArray>; 41 | 42 | type Next = { 43 | // next: { 44 | // (s: string): IteratorResult; 45 | // (matches: [string]): IteratorResult; 46 | // }; 47 | next: { 48 | // (result: YieldedValue): IteratorResult< 49 | // PrimitiveYield | (() => Generator), 50 | // Result 51 | // >; 52 | (result: YieldedValue): IteratorResult< 53 | typeof result extends YieldedValue 54 | ? S2 extends Z 55 | ? PrimitiveYield 56 | : PrimitiveYield 57 | : PrimitiveYield, 58 | Result 59 | >; 60 | // (result: YieldedValue): IteratorResult< 61 | // typeof result extends YieldedValue 62 | // ? PrimitiveYield 63 | // : PrimitiveYield, 64 | // Result 65 | // >; 66 | // (result: 42): IteratorResult<42, Result>; 67 | // (result: YieldedValue): IteratorResult, Result>; 68 | // (result: A): A extends string 69 | // ? IteratorResult 70 | // : A extends Iterable 71 | // ? IteratorResult 72 | // : never; 73 | }; 74 | }; 75 | // | { 76 | // next( 77 | // ...args: [boolean] 78 | // ): IteratorResult<() => Generator, Result>; 79 | // } 80 | // | { 81 | // next(...args: [T]): IteratorResult<() => Generator, Result>; 82 | // } 83 | // & { 84 | // next(s: string): IteratorResult; 85 | // } 86 | // & { 87 | // next(matches: [string]): IteratorResult; 88 | // } 89 | // & { 90 | // next(): IteratorResult; 91 | // }; 92 | // type Next = GetYield extends RegExp ? { 93 | // next( 94 | // ...args: [[string] & ReadonlyArray] 95 | // ): IteratorResult; 96 | // } : GetYield extends string ? { 97 | // next( 98 | // ...args: [string] 99 | // ): IteratorResult; 100 | // } : never; 101 | 102 | // type Next = { 103 | // next( 104 | // ...args: [[string] & ReadonlyArray] 105 | // ): IteratorResult; 106 | // next( 107 | // ...args: [string] 108 | // ): IteratorResult; 109 | // }; 110 | // type Next = T extends RegExp ? { 111 | // next( 112 | // ...args: [[string] & ReadonlyArray] 113 | // ): IteratorResult; 114 | // } 115 | // : T extends string ? { 116 | // next( 117 | // ...args: [string] 118 | // ): IteratorResult; 119 | // } 120 | // : never; 121 | 122 | export type ParserGenerator< 123 | Result, 124 | NextValue extends object | number | boolean = never 125 | > = { 126 | [Symbol.iterator](): Next; 127 | }; 128 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "target": "esnext", 7 | "lib": ["dom", "esnext"], 8 | "importHelpers": true, 9 | // output .d.ts declaration files for consumers 10 | "declaration": true, 11 | // output .js.map sourcemap files for consumers 12 | "sourceMap": true, 13 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 14 | "rootDir": "./src", 15 | // stricter type-checking for stronger correctness. Recommended by TS 16 | "strict": false, 17 | "strictNullChecks": true, 18 | // linter checks for common issues 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": true, 24 | // use Node's module resolution algorithm, instead of the legacy TS one 25 | "moduleResolution": "node", 26 | // transpile JSX to React.createElement 27 | "jsx": "react", 28 | // interop between ESM and CJS modules. Recommended by TS 29 | "esModuleInterop": true, 30 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 31 | "skipLibCheck": true, 32 | // error out if import and file system have a casing mismatch. Recommended by TS 33 | "forceConsistentCasingInFileNames": true, 34 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 35 | "noEmit": true, 36 | "allowImportingTsExtensions": true 37 | } 38 | } 39 | --------------------------------------------------------------------------------