├── .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 |
6 |
7 |
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 |
--------------------------------------------------------------------------------