@johnmorrisdotca/jirai 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +546 -29
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +13 -5
- package/src/family.test.js +54 -5
- package/src/index.ts +1 -1
- package/src/readme.test.js +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,48 @@ All notable changes to this project are written here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.2] - 2026-10-06
|
|
10
|
+
|
|
11
|
+
Nothing that was exported has changed. The README is the family's one layout, in full.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- The README has a picture of the demo on a desk and on a phone, in light and dark, taken from the demo by `pnpm screenshots:readme` (the pictures are in `docs/images/` and are not in the package), a picture of each rule set and outline (square, orthogonal, hexagonal, wraparound, a heart, a star), of the explained hint and of the slate board with flowers on a phone, an Examples section of twelve examples that run, examples for React, Vue, Svelte and Angular, tables of the entry points, the calls to learn first and every theme property, and an Accessibility section.
|
|
16
|
+
- `pnpm test:readme` type-checks and runs every TypeScript and JavaScript example in the README against the built package, as a job of its own in CI; `src/readme.test.js` holds the README to the family's standard (sections in order, languages on code fences, pictures with alt text and a caption, no marketing words, version pins) in `pnpm check`; `pnpm test:package` fails if a picture or anything under `docs/` is in the packed package.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- The README told readers to import `DEFAULT_SETTINGS` from `@johnmorrisdotca/jirai/play`, which does not export it (it is in the main entry), and showed `opening="clear"` on `<jirai-board>`, which does not read an `opening` attribute (the tag reads `width`, `height`, `mines`, `seed`, `grid`, `shape`, `no-guess`, `material`, `pieces` and `lang`). Both examples are corrected, and every TypeScript example is now type-checked and run.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- `pnpm pictures` is `pnpm screenshots:readme`, and takes WebP pictures in light and dark under `docs/images/`; `docs/desktop.jpg` and `docs/phone.jpg` are gone, and the presentation check looks for the hero pictures there instead.
|
|
25
|
+
- Repository only: the package and everything it exports are unchanged. `CONTRIBUTING.md` is the family's one text with a section of its own for Jirai, held to the master in johnmorrisdotca/.github by `src/family.test.js`; `ci.yml` and `pages.yml` are the family's one text (`pnpm check`, the demo, and the package on Linux, macOS and Windows), and any jobs of the package's own after them.
|
|
26
|
+
- The demo's page titles read `Jirai · pitch`, like the rest of the family's.
|
|
27
|
+
- The demo's own stylesheet is `demo/jirai.css`, named for the package like the family's.
|
|
28
|
+
- The demo is built into `site/`, where the Pages workflow and every other package look for it.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- The API reference page wraps a long entry path instead of running about 2 px wider than a 360 px screen. Nothing the package exports has changed.
|
|
33
|
+
|
|
34
|
+
## [0.4.1] - 2026-10-05
|
|
35
|
+
|
|
36
|
+
Nothing that was exported has changed.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- A test holds every `@johnmorrisdotca/jirai@N` version pin in the README to this package's major version.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- The family's list, in the README and in the demo's footer, names all twenty-four packages, Karakuri and Houseki included.
|
|
45
|
+
- The npm description is one sentence of 250 characters or fewer, so npm and its search show it whole; it is also the repository's About text. `homepage` is the demo site and `author` is `"John Morris"`, the same in every package.
|
|
46
|
+
- The GitHub Actions workflows use the current versions of the actions (checkout 7, setup-node 7, pnpm/action-setup 6; configure-pages 6, upload-pages-artifact 5 and deploy-pages 5 for Pages), which clears GitHub's Node 20 deprecation warning.
|
|
47
|
+
- Every entry has an `import` condition beside `default`.
|
|
48
|
+
- The README has the family's sections in the family's order (Who it is for, Features, Use it in your project, API, Theming, Limits, Browser support, Languages, Roadmap, Architecture, Where it comes from, Changes), the family list sits under "Where it comes from", and a test holds it to them.
|
|
49
|
+
- The development tools are the family's: Vitest 5 and Playwright 1.63, as in the other packages.
|
|
50
|
+
|
|
9
51
|
## [0.4.0] - 2026-10-05
|
|
10
52
|
|
|
11
53
|
- **Huge fields.** `hugeSettings(level, { grid, shape, size })` and `HUGE_SIZES` (32×32, 48×24 and 24×48) give each of the four levels on a field of four times the medium level's area, 1,024 to 1,152 squares, with the level's share of mines (a 32×32 has 126, 160, 211 and 256 mines at easy, medium, hard and extra-hard), on every rule set and outline. Every one is dealt and proved to need no guess in a median of 3 to 60 ms (73 ms the slowest of ten 32×32 seeds), on the square, orthogonal, hexagonal and wraparound grids and the heart, star and hexagon outlines, and `src/huge.test.ts` wins a 32×32 at easy and at extra-hard by the explained hints alone. The demo's Level menu offers them (`?level=huge-hard`).
|
package/README.md
CHANGED
|
@@ -13,10 +13,24 @@ Minesweeper across four-neighbour, eight-neighbour, hexagonal and wraparound gri
|
|
|
13
13
|
|
|
14
14
|
<p align="center"><a href="https://johnmorrisdotca.github.io/jirai/"><strong>Play Jirai →</strong></a> · <a href="https://johnmorrisdotca.github.io/jirai/api.html">API reference</a></p>
|
|
15
15
|
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
<table align="center">
|
|
17
|
+
<tr>
|
|
18
|
+
<td align="center" valign="top">
|
|
19
|
+
<picture>
|
|
20
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hero-desk-dark.webp">
|
|
21
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hero-desk-light.webp" alt="The demo on a desk, in English: the page header with the language chooser, the API reference link, five cloth patches and the Help switch, the Your field choices (board, shape, level, width, height, mines, no-guess, first opening, material, markers and seed), and the Hexagonal field on green felt: a nine by nine board of hexagons on a wooden tray with the opened cells showing blue and green number clues, the counters Mines left 10, Time 0:00 and Moves 1, and the Start over, Open, Hint and Just the board buttons" width="600">
|
|
22
|
+
</picture>
|
|
23
|
+
<br><em>The demo on a desk: a hexagonal field, opened at its middle.</em>
|
|
24
|
+
</td>
|
|
25
|
+
<td align="center" valign="top">
|
|
26
|
+
<picture>
|
|
27
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hero-phone-dark.webp">
|
|
28
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hero-phone-light.webp" alt="The demo on a phone, in Japanese: an orthogonal nine by nine field with opened cells and number clues, the status line 数字を手がかりに、安全なマスをすべて開けましょう, the buttons やり直す, 開く, ヒント and 盤だけ, and the start of the rules under it" width="190">
|
|
29
|
+
</picture>
|
|
30
|
+
<br><em>On a phone, in Japanese, in the device's light or dark.</em>
|
|
31
|
+
</td>
|
|
32
|
+
</tr>
|
|
33
|
+
</table>
|
|
20
34
|
|
|
21
35
|
Jirai is a Minesweeper rules engine and player for TypeScript and JavaScript. The core is plain functions; drawing, browser controls, a custom element and an optional React wrapper are separate imports. The package has no runtime dependencies and needs Node 22 or a modern browser.
|
|
22
36
|
|
|
@@ -39,12 +53,18 @@ For a dedicated four-neighbour variant, its entry fixes the topology for you:
|
|
|
39
53
|
```ts
|
|
40
54
|
import { makeOrthogonalBoard, newOrthogonalGame } from "@johnmorrisdotca/jirai/orthogonal";
|
|
41
55
|
|
|
42
|
-
const settings = { width: 9, height: 9, mines: 10, noGuess: true, opening: "clear", seed: 42 };
|
|
56
|
+
const settings = { width: 9, height: 9, mines: 10, noGuess: true, opening: "clear" as const, seed: 42 };
|
|
43
57
|
const game = newOrthogonalGame(settings);
|
|
44
58
|
const board = makeOrthogonalBoard(settings, 40); // counts only edge-sharing neighbours
|
|
45
59
|
```
|
|
46
60
|
|
|
47
|
-
##
|
|
61
|
+
## Who it is for
|
|
62
|
+
|
|
63
|
+
- **Puzzle and game sites** that want Minesweeper with boards a player can trust: seeded fields that deal the same everywhere, a safe opening, verified no-guess deals, and the words in English and Japanese.
|
|
64
|
+
- **Developers of other front ends** who want the rules, the dealer and the deduction solver as plain functions, with no DOM, and their own drawing on top.
|
|
65
|
+
- **Players and teachers** who want to learn why a cell is safe: the hints explain the deduction that proves it, and a level steps up a measured difficulty.
|
|
66
|
+
|
|
67
|
+
## Features
|
|
48
68
|
|
|
49
69
|
- **Four rule sets:** square grids count eight neighbours, orthogonal grids count four, hex grids count six axial neighbours, and wraparound grids join opposite square edges.
|
|
50
70
|
- **Board outlines:** rectangles, hearts, stars and hexagon outlines. Shaped boards have cut-outs; wraparound works with rectangles.
|
|
@@ -56,12 +76,96 @@ const board = makeOrthogonalBoard(settings, 40); // counts only edge-sharing nei
|
|
|
56
76
|
- **Accessible controls:** keyboard navigation, pointer and touch, long press to mark, English and Japanese strings, and board labels read by assistive technology.
|
|
57
77
|
- **Materials and markers:** ivory, wood or slate; flags, stones or flowers. Host CSS can replace the palette.
|
|
58
78
|
|
|
59
|
-
|
|
79
|
+
### What's in it
|
|
80
|
+
|
|
81
|
+
Each picture is the real player, drawn by the package and taken from [the demo](https://johnmorrisdotca.github.io/jirai/) with `pnpm screenshots:readme`, in light and dark. Every field is the same seed, opened at the same cell, so the pictures are the same each run.
|
|
82
|
+
|
|
83
|
+
<table>
|
|
84
|
+
<tr>
|
|
85
|
+
<td align="center" valign="top" width="50%">
|
|
86
|
+
<picture>
|
|
87
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/square-desk-dark.webp">
|
|
88
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/square-desk-light.webp" alt="A nine by nine square field on a wooden tray on a desk, opened at its middle cell, with blue, green and red number clues, the counters Mines left 10, Time 0:00 and Moves 1, and the Start over, Open, Hint and Just the board buttons" width="360">
|
|
89
|
+
</picture>
|
|
90
|
+
<br><em><strong>Square.</strong> A number counts the eight cells around it.</em>
|
|
91
|
+
</td>
|
|
92
|
+
<td align="center" valign="top" width="50%">
|
|
93
|
+
<picture>
|
|
94
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/orthogonal-desk-dark.webp">
|
|
95
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/orthogonal-desk-light.webp" alt="A nine by nine orthogonal field on a desk, opened at its middle cell: the opened patch is larger and the clues are fewer, the title Orthogonal field and the counters Mines left 10" width="360">
|
|
96
|
+
</picture>
|
|
97
|
+
<br><em><strong>Orthogonal.</strong> A number counts only the four cells that share an edge.</em>
|
|
98
|
+
</td>
|
|
99
|
+
</tr>
|
|
100
|
+
<tr>
|
|
101
|
+
<td align="center" valign="top" width="50%">
|
|
102
|
+
<picture>
|
|
103
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hex-desk-dark.webp">
|
|
104
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hex-desk-light.webp" alt="A nine by nine hexagonal field on a desk: rows of hexagons offset along a slanting parallelogram, opened at its middle with blue and green clues, titled Hexagonal field" width="360">
|
|
105
|
+
</picture>
|
|
106
|
+
<br><em><strong>Hexagonal.</strong> A number counts the six cells around a hexagon.</em>
|
|
107
|
+
</td>
|
|
108
|
+
<td align="center" valign="top" width="50%">
|
|
109
|
+
<picture>
|
|
110
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/wraparound-desk-dark.webp">
|
|
111
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/wraparound-desk-light.webp" alt="A nine by nine wraparound field on a desk, opened at its middle, with clues along its edges that count cells on the opposite edge, titled Wraparound field" width="360">
|
|
112
|
+
</picture>
|
|
113
|
+
<br><em><strong>Wraparound.</strong> Opposite edges join, so a clue on an edge counts across it.</em>
|
|
114
|
+
</td>
|
|
115
|
+
</tr>
|
|
116
|
+
<tr>
|
|
117
|
+
<td align="center" valign="top" width="50%">
|
|
118
|
+
<picture>
|
|
119
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/heart-desk-dark.webp">
|
|
120
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/heart-desk-light.webp" alt="A heart-shaped sixteen by sixteen field on a desk, made of square cells with the cut-out corners missing, opened in its middle, with many number clues and the counters Mines left 27" width="360">
|
|
121
|
+
</picture>
|
|
122
|
+
<br><em><strong>A heart.</strong> Outlines cut cells out of the field: a heart, a star or a hexagon, with the level's share of mines.</em>
|
|
123
|
+
</td>
|
|
124
|
+
<td align="center" valign="top" width="50%">
|
|
125
|
+
<picture>
|
|
126
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/star-desk-dark.webp">
|
|
127
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/star-desk-light.webp" alt="A star-shaped sixteen by sixteen orthogonal field on a desk, a five-pointed star of square cells with its points cut out of the rectangle, opened in its middle, with the counters Mines left 14" width="360">
|
|
128
|
+
</picture>
|
|
129
|
+
<br><em><strong>A star.</strong> Shaped boards work on every rule; here the four-neighbour rule.</em>
|
|
130
|
+
</td>
|
|
131
|
+
</tr>
|
|
132
|
+
<tr>
|
|
133
|
+
<td align="center" valign="top" width="50%">
|
|
134
|
+
<picture>
|
|
135
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hint-desk-dark.webp">
|
|
136
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hint-desk-light.webp" alt="A nine by nine square field on a desk after the Hint button: one cell is outlined in pale green, and the status line says This cell is safe. The neighbouring count settles it." width="360">
|
|
137
|
+
</picture>
|
|
138
|
+
<br><em><strong>The explained hint.</strong> The cell is outlined and the line says why it is certain.</em>
|
|
139
|
+
</td>
|
|
140
|
+
<td align="center" valign="top" width="50%">
|
|
141
|
+
<picture>
|
|
142
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/slate-flowers-phone-dark.webp">
|
|
143
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/slate-flowers-phone-light.webp" alt="A nine by nine field on a phone in the slate material with flowers as markers: dark grey cells with white numbers, three flowers marking covered cells, the counters Mines left 8, Time 0:00 and Moves 3, the status line, the buttons Start over, Open, Hint and Just the board, and the line of help under them" width="240">
|
|
144
|
+
</picture>
|
|
145
|
+
<br><em><strong>On a phone.</strong> Slate with flowers; a tap opens, and a long press or the Open button marks.</em>
|
|
146
|
+
</td>
|
|
147
|
+
</tr>
|
|
148
|
+
</table>
|
|
149
|
+
|
|
150
|
+
## Use it in your project
|
|
151
|
+
|
|
152
|
+
### Install
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
npm install @johnmorrisdotca/jirai
|
|
156
|
+
pnpm add @johnmorrisdotca/jirai
|
|
157
|
+
yarn add @johnmorrisdotca/jirai
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
It is ES modules only, with its types included, and needs Node 22 or later outside a browser. A page with no bundler loads the tag from a CDN (`@0` is the major version).
|
|
161
|
+
|
|
162
|
+
### A player from a script
|
|
60
163
|
|
|
61
164
|
Mount a player into any element. The first reveal asks the module worker to deal the board, so serve the package over HTTP and allow same-origin module workers in your content security policy.
|
|
62
165
|
|
|
63
|
-
```ts
|
|
64
|
-
import { DEFAULT_SETTINGS
|
|
166
|
+
```ts no-run
|
|
167
|
+
import { DEFAULT_SETTINGS } from "@johnmorrisdotca/jirai";
|
|
168
|
+
import { mountJirai } from "@johnmorrisdotca/jirai/play";
|
|
65
169
|
|
|
66
170
|
const board = mountJirai(document.querySelector<HTMLElement>("#game")!, {
|
|
67
171
|
settings: { ...DEFAULT_SETTINGS, grid: "orthogonal", seed: 7 },
|
|
@@ -79,6 +183,8 @@ board.destroy();
|
|
|
79
183
|
|
|
80
184
|
The handle also provides `game()`, `progress()`, `play(cell, mark?)`, `hint()`, and `load(settings, progress?)`. Set `controls: false` when your page supplies its own controls and status. Change options with `set`; call `destroy` when the host is removed.
|
|
81
185
|
|
|
186
|
+
### The tag
|
|
187
|
+
|
|
82
188
|
Use the custom element without a mount call:
|
|
83
189
|
|
|
84
190
|
```html
|
|
@@ -86,12 +192,313 @@ Use the custom element without a mount call:
|
|
|
86
192
|
import "@johnmorrisdotca/jirai/element/define";
|
|
87
193
|
</script>
|
|
88
194
|
<jirai-board width="9" height="9" mines="10" seed="42"
|
|
89
|
-
grid="orthogonal"
|
|
195
|
+
grid="orthogonal" no-guess="true"
|
|
90
196
|
material="wood" pieces="stones" lang="ja"></jirai-board>
|
|
91
197
|
```
|
|
92
198
|
|
|
199
|
+
The tag reads `width`, `height`, `mines`, `seed`, `grid`, `shape`, `no-guess`, `material`, `pieces` and `lang`, and mounts again when one changes; the first opening is the default (`clear`), and `jirai-error` is the event it fires when a field cannot be dealt.
|
|
200
|
+
|
|
201
|
+
### The React component
|
|
202
|
+
|
|
93
203
|
The optional React entry exports `JiraiBoard` from `@johnmorrisdotca/jirai/react`; React is an optional peer dependency. Its options are read when mounted. Use a new React `key` to start with a different settings object.
|
|
94
204
|
|
|
205
|
+
### In a framework
|
|
206
|
+
|
|
207
|
+
Two ways: the `<jirai-board>` tag, which every framework can carry, and `JiraiBoard`, the React component. The player deals a board in a module worker, so the page must be served over HTTP.
|
|
208
|
+
|
|
209
|
+
#### React
|
|
210
|
+
|
|
211
|
+
```jsx
|
|
212
|
+
import { JiraiBoard } from "@johnmorrisdotca/jirai/react";
|
|
213
|
+
|
|
214
|
+
export function Game({ seed }) {
|
|
215
|
+
// The options are read when it mounts: a new key starts it with different settings.
|
|
216
|
+
return <JiraiBoard key={seed} settings={{ grid: "orthogonal", width: 9, height: 9, mines: 10, seed }} material="wood" onFinish={(game) => console.log(game.status)} />;
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
#### Vue
|
|
221
|
+
|
|
222
|
+
Vue needs to be told that `jirai-board` is a custom element, a compiler option.
|
|
223
|
+
|
|
224
|
+
```vue
|
|
225
|
+
<script setup>
|
|
226
|
+
import "@johnmorrisdotca/jirai/element/define";
|
|
227
|
+
defineProps({ seed: Number });
|
|
228
|
+
</script>
|
|
229
|
+
|
|
230
|
+
<template>
|
|
231
|
+
<jirai-board width="9" height="9" mines="10" :seed="seed" grid="hex" material="slate" pieces="flowers"></jirai-board>
|
|
232
|
+
</template>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```js no-check
|
|
236
|
+
// vite.config.js
|
|
237
|
+
import vue from "@vitejs/plugin-vue";
|
|
238
|
+
|
|
239
|
+
export default { plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === "jirai-board" } } })] };
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
#### Svelte
|
|
243
|
+
|
|
244
|
+
```svelte
|
|
245
|
+
<script>
|
|
246
|
+
import "@johnmorrisdotca/jirai/element/define";
|
|
247
|
+
export let seed = 42;
|
|
248
|
+
</script>
|
|
249
|
+
|
|
250
|
+
<jirai-board width="16" height="16" mines="40" {seed} grid="square" lang="ja"></jirai-board>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### Angular
|
|
254
|
+
|
|
255
|
+
```ts no-check
|
|
256
|
+
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
|
|
257
|
+
import "@johnmorrisdotca/jirai/element/define";
|
|
258
|
+
|
|
259
|
+
@Component({
|
|
260
|
+
selector: "app-game",
|
|
261
|
+
standalone: true,
|
|
262
|
+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
263
|
+
template: `<jirai-board width="9" height="9" mines="10" seed="42" grid="orthogonal"></jirai-board>`,
|
|
264
|
+
})
|
|
265
|
+
export class GameComponent {}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Examples
|
|
269
|
+
|
|
270
|
+
Each example is a whole recipe: copy it and it works. The ones in TypeScript are run in CI against the built package (`pnpm test:readme`), so none of them is a guess, and the output shown is what they print.
|
|
271
|
+
|
|
272
|
+
### A game on a page with no script of your own
|
|
273
|
+
|
|
274
|
+
Save this as a file, serve it over HTTP (the player deals in a module worker, which a `file:` page cannot load) and open it: a hexagonal field, dealt from a seed that is proved to need no guess.
|
|
275
|
+
|
|
276
|
+
```html
|
|
277
|
+
<!doctype html>
|
|
278
|
+
<meta charset="utf-8">
|
|
279
|
+
<title>Jirai</title>
|
|
280
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@johnmorrisdotca/jirai@0/dist/element-define.js"></script>
|
|
281
|
+
<jirai-board width="12" height="12" mines="22" seed="2026" grid="hex" material="wood" pieces="flowers"></jirai-board>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### The first move deals the board
|
|
285
|
+
|
|
286
|
+
`newGame` makes a ready game with no mines in it. The first reveal deals the seeded field, with that cell and, by default, its neighbours safe, so nobody loses on the first move.
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
import { DEFAULT_SETTINGS, newGame, play } from "@johnmorrisdotca/jirai";
|
|
290
|
+
|
|
291
|
+
let game = newGame({ ...DEFAULT_SETTINGS, width: 9, height: 9, mines: 10, seed: 42 });
|
|
292
|
+
console.log(game.status, game.board); // ready, nothing dealt yet
|
|
293
|
+
game = play(game, { kind: "reveal", cell: 40 });
|
|
294
|
+
console.log(game.status, "dealt around cell", game.board!.first, "with", game.board!.mines.filter(Boolean).length, "mines");
|
|
295
|
+
console.log(play(game, { kind: "reveal", cell: 999 }) === game); // a move that cannot be made returns the same game
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
```text
|
|
299
|
+
ready null
|
|
300
|
+
playing dealt around cell 40 with 10 mines
|
|
301
|
+
true
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### A hint that proves its answer
|
|
305
|
+
|
|
306
|
+
`hintFor` reads only what is opened, never the hidden mines, and says which cells are certain, and from which clue. Flags are marks and never evidence.
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
import { DEFAULT_SETTINGS, hintFor, newGame, play, visibleGame } from "@johnmorrisdotca/jirai";
|
|
310
|
+
|
|
311
|
+
let game = newGame({ ...DEFAULT_SETTINGS, seed: 42 });
|
|
312
|
+
game = play(game, { kind: "reveal", cell: 40 });
|
|
313
|
+
const hint = hintFor(visibleGame(game))!;
|
|
314
|
+
console.log(hint);
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
```text
|
|
318
|
+
{
|
|
319
|
+
safe: [ 5 ],
|
|
320
|
+
mines: [],
|
|
321
|
+
reason: 'count',
|
|
322
|
+
sources: [ 6 ],
|
|
323
|
+
contradiction: false
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Solve a field with nothing but hints
|
|
328
|
+
|
|
329
|
+
A no-guess field can be finished by deduction alone, so a bot needs no luck: ask for a hint, open what is safe, flag what is a mine, and repeat. A medium field and a hex star at the hard level are both cleared.
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import { DEFAULT_SETTINGS, gameProgress, hintFor, levelSettings, newGame, play, visibleGame } from "@johnmorrisdotca/jirai";
|
|
333
|
+
|
|
334
|
+
for (const [label, extra] of [["square", {}], ["hex star", { grid: "hex", shape: "star" }]] as const) {
|
|
335
|
+
const level = label === "square" ? "medium" : "hard";
|
|
336
|
+
const settings = { ...DEFAULT_SETTINGS, ...extra, ...levelSettings(level, extra), seed: 3, noGuess: true };
|
|
337
|
+
let game = play(newGame(settings), { kind: "reveal", cell: Math.floor(settings.height / 2) * settings.width + Math.floor(settings.width / 2) });
|
|
338
|
+
let hints = 0;
|
|
339
|
+
while (game.status === "playing") {
|
|
340
|
+
const hint = hintFor(visibleGame(game));
|
|
341
|
+
if (hint === null || (hint.safe.length === 0 && hint.mines.length === 0)) break;
|
|
342
|
+
hints += 1;
|
|
343
|
+
for (const cell of hint.safe) game = play(game, { kind: "reveal", cell });
|
|
344
|
+
for (const cell of hint.mines) if (game.marks[cell] !== "flag") game = play(game, { kind: "mark", cell });
|
|
345
|
+
}
|
|
346
|
+
console.log(label, game.status, `after ${hints} hints and ${game.moves.length} moves; saved in ${gameProgress(game).length} characters`);
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
```text
|
|
351
|
+
square won after 51 hints and 77 moves; saved in 2334 characters
|
|
352
|
+
hex star won after 34 hints and 50 moves; saved in 1601 characters
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Levels, outlines and huge fields
|
|
356
|
+
|
|
357
|
+
Four levels step up in size and mine share, on every rule and outline; a shaped board keeps the level's share of mines over the cells it has left. `hugeSettings` is the same on a field of four times the area.
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
import { hugeSettings, levelSettings } from "@johnmorrisdotca/jirai";
|
|
361
|
+
|
|
362
|
+
console.log(levelSettings("hard"));
|
|
363
|
+
console.log(levelSettings("extra-hard", { grid: "hex", shape: "star" }));
|
|
364
|
+
console.log(hugeSettings("hard"));
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
```text
|
|
368
|
+
{ width: 30, height: 16, mines: 99 }
|
|
369
|
+
{ width: 40, height: 24, mines: 85 }
|
|
370
|
+
{ width: 32, height: 32, mines: 211 }
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Deal a field and check it
|
|
374
|
+
|
|
375
|
+
`makeBoard` deals at a first cell; with `noGuess` it returns only a board its solver can finish. `measureBoard` grades it with the same deductions the hints use, so levels can be put in order.
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
import { DEFAULT_SETTINGS, isSolvable, makeBoard, measureBoard } from "@johnmorrisdotca/jirai";
|
|
379
|
+
|
|
380
|
+
const board = makeBoard({ ...DEFAULT_SETTINGS, noGuess: true, seed: 7 }, 40);
|
|
381
|
+
console.log(isSolvable(board), board.mines.filter(Boolean).length, "mines; dealt on attempt", board.attempt);
|
|
382
|
+
const grade = measureBoard(board);
|
|
383
|
+
console.log(`score ${grade.score}, depth ${grade.depth}, solvable ${grade.solvable}`);
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
```text
|
|
387
|
+
true 10 mines; dealt on attempt 0
|
|
388
|
+
score 27.2, depth 10, solvable true
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Who touches whom
|
|
392
|
+
|
|
393
|
+
The rule sets differ only in which cells a number counts. `neighbours` says, for any cell, which cells those are.
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
import { DEFAULT_SETTINGS, neighbours } from "@johnmorrisdotca/jirai";
|
|
397
|
+
|
|
398
|
+
for (const grid of ["square", "orthogonal", "hex", "wrap"] as const) {
|
|
399
|
+
console.log(grid.padEnd(10), "cell 40:", neighbours({ ...DEFAULT_SETTINGS, grid }, 40).length, "neighbours; cell 0:", neighbours({ ...DEFAULT_SETTINGS, grid }, 0).length);
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
```text
|
|
404
|
+
square cell 40: 8 neighbours; cell 0: 3 neighbours
|
|
405
|
+
orthogonal cell 40: 4 neighbours; cell 0: 2 neighbours
|
|
406
|
+
hex cell 40: 6 neighbours; cell 0: 2 neighbours
|
|
407
|
+
wrap cell 40: 8 neighbours; cell 0: 8 neighbours
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Keep a game and read it back
|
|
411
|
+
|
|
412
|
+
A saved game is the settings and the moves, not an answer anybody could edit. Reading it replays every move under the rules, so a record that is not a game is `null`.
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
import { DEFAULT_SETTINGS, gameFromProgress, gameProgress, newGame, play } from "@johnmorrisdotca/jirai";
|
|
416
|
+
|
|
417
|
+
let game = newGame({ ...DEFAULT_SETTINGS, seed: 42 });
|
|
418
|
+
game = play(game, { kind: "reveal", cell: 40 });
|
|
419
|
+
const saved = gameProgress(game);
|
|
420
|
+
console.log(saved);
|
|
421
|
+
console.log(gameFromProgress(saved)?.status, gameFromProgress("not a game"), gameFromProgress(saved.slice(0, 60)));
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
```text
|
|
425
|
+
{"version":1,"settings":{"width":9,"height":9,"mines":10,"grid":"square","noGuess":true,"opening":"clear","seed":42},"moves":[{"kind":"reveal","cell":40}],"helped":false}
|
|
426
|
+
playing null null
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Today's field
|
|
430
|
+
|
|
431
|
+
`dailySeed` is the same seed for everyone on a date (UTC), per rule set, so a site can offer a daily field with nothing stored.
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
import { dailySeed } from "@johnmorrisdotca/jirai";
|
|
435
|
+
|
|
436
|
+
console.log(dailySeed("2026-10-01"), dailySeed("2026-10-01", "hex"), dailySeed("2026-10-01") === dailySeed("2026-10-01"));
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
```text
|
|
440
|
+
1293497838 968579362 true
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Draw it yourself
|
|
444
|
+
|
|
445
|
+
`boardModel` is the board as row-major labelled cells with no DOM, for a page that draws its own. Each cell says where it is, what it reads aloud and what state it is in.
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
import { DEFAULT_SETTINGS, newGame, play } from "@johnmorrisdotca/jirai";
|
|
449
|
+
import { boardModel } from "@johnmorrisdotca/jirai/draw";
|
|
450
|
+
|
|
451
|
+
const game = play(newGame({ ...DEFAULT_SETTINGS, seed: 42 }), { kind: "reveal", cell: 40 });
|
|
452
|
+
const model = boardModel(game, {});
|
|
453
|
+
console.log(model.width, "by", model.height, "=", model.cells.length, "cells");
|
|
454
|
+
console.log(model.cells[40]);
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
```text
|
|
458
|
+
9 by 9 = 81 cells
|
|
459
|
+
{
|
|
460
|
+
cell: 40,
|
|
461
|
+
x: 4,
|
|
462
|
+
y: 4,
|
|
463
|
+
width: 1,
|
|
464
|
+
height: 1,
|
|
465
|
+
label: '5, 5: empty',
|
|
466
|
+
text: '',
|
|
467
|
+
kind: 'open',
|
|
468
|
+
hint: false
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### A player you steer from code
|
|
473
|
+
|
|
474
|
+
```ts no-run
|
|
475
|
+
import { DEFAULT_SETTINGS } from "@johnmorrisdotca/jirai";
|
|
476
|
+
import { mountJirai } from "@johnmorrisdotca/jirai/play";
|
|
477
|
+
|
|
478
|
+
const board = mountJirai(document.querySelector<HTMLElement>("#game")!, {
|
|
479
|
+
settings: { ...DEFAULT_SETTINGS, grid: "wrap", seed: 7 },
|
|
480
|
+
material: "slate",
|
|
481
|
+
pieces: "flowers",
|
|
482
|
+
language: "ja",
|
|
483
|
+
onChange: () => localStorage.setItem("jirai", board.progress()), // keep the game as it is played
|
|
484
|
+
onFinish: (game) => console.log(game.status, game.helped), // won or lost, and whether a hint was used
|
|
485
|
+
});
|
|
486
|
+
const kept = localStorage.getItem("jirai");
|
|
487
|
+
if (kept) board.load({ ...DEFAULT_SETTINGS, grid: "wrap", seed: 7 }, kept); // play a kept game back
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### A look of your own
|
|
491
|
+
|
|
492
|
+
The player is themed by custom properties, and `material` and `pieces` change how the cells and markers look, never the rules.
|
|
493
|
+
|
|
494
|
+
```css
|
|
495
|
+
jirai-board .jr-root[data-material] { /* as specific as the material's own rule, so that it wins */
|
|
496
|
+
--jr-cover: #cfd8dc;
|
|
497
|
+
--jr-open: #eceff1;
|
|
498
|
+
--jr-ink: #263238;
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
95
502
|
## Rules and settings
|
|
96
503
|
|
|
97
504
|
| Setting | Values and limits |
|
|
@@ -153,13 +560,52 @@ Orthogonal clues carry less information, so its numbers are smaller at every lev
|
|
|
153
560
|
|
|
154
561
|
The full engine entry is `@johnmorrisdotca/jirai`; four-neighbour helpers are in `@johnmorrisdotca/jirai/orthogonal`. Drawing is `@johnmorrisdotca/jirai/draw`, browser play is `/play`, and the custom element is `/element` or `/element/define`. The [API guide](docs/API.md) lists the entries and public calls.
|
|
155
562
|
|
|
156
|
-
##
|
|
563
|
+
## API
|
|
564
|
+
|
|
565
|
+
Every export of every entry point is in the [API reference](https://johnmorrisdotca.github.io/jirai/api.html), made from the source when the demo is built, and the [API guide](docs/API.md) lists the entries and public calls in prose.
|
|
566
|
+
|
|
567
|
+
### Entry points
|
|
568
|
+
|
|
569
|
+
| Import | What it holds |
|
|
570
|
+
| --- | --- |
|
|
571
|
+
| `@johnmorrisdotca/jirai` | The rules engine, grid and shape utilities, levels, the dealer and solver, hints and saved progress |
|
|
572
|
+
| `@johnmorrisdotca/jirai/orthogonal` | The explicit four-neighbour game, board, hint and version-2 save helpers |
|
|
573
|
+
| `@johnmorrisdotca/jirai/draw` | `boardModel`, `JIRAI_STYLE` and the drawing model types |
|
|
574
|
+
| `@johnmorrisdotca/jirai/play` | `mountJirai` and the player's types and options |
|
|
575
|
+
| `@johnmorrisdotca/jirai/element` | `JiraiElement` and `defineJirai`, which registers nothing until called |
|
|
576
|
+
| `@johnmorrisdotca/jirai/element/define` | Defines `<jirai-board>` by being imported |
|
|
577
|
+
| `@johnmorrisdotca/jirai/react` | The optional `JiraiBoard` component; React is an optional peer |
|
|
578
|
+
|
|
579
|
+
### The calls to learn first
|
|
580
|
+
|
|
581
|
+
| Call | What it does |
|
|
582
|
+
| --- | --- |
|
|
583
|
+
| `newGame(settings)` and `play(game, move)` | A ready game, and the game after a move; a move that cannot be made returns the same game |
|
|
584
|
+
| `visibleGame(game)` and `hintFor(visible)` | What a player can see, and the cells that are certain from it |
|
|
585
|
+
| `makeBoard(settings, first, options)` and `isSolvable(board)` | A seeded field dealt at a first cell, and whether deduction can finish it |
|
|
586
|
+
| `levelSettings(level, options)` and `hugeSettings(level, options)` | The size and mines of a level, on any rule and outline |
|
|
587
|
+
| `gameProgress(game)` and `gameFromProgress(text)` | A game as text, and back; `null` for a record that is not a game |
|
|
588
|
+
| `boardModel(game, options)` | The board as labelled cells, for a page that draws its own |
|
|
589
|
+
| `mountJirai(element, options)` | The player |
|
|
590
|
+
|
|
591
|
+
## Theming
|
|
157
592
|
|
|
158
593
|
`boardModel(game, options)` returns row-major labelled cells without touching the DOM. The draw entry exports `JIRAI_STYLE` and the board model. The player uses the same theme variables as its SVG and HTML controls; set `material`, `pieces`, and `language` on mount or override the CSS custom properties in your host.
|
|
159
594
|
|
|
160
595
|
Materials are `ivory`, `wood` and `slate`. Marker sets are `flags`, `stones` and `flowers`. They change appearance only; the engine still stores covered, flag, question or open states.
|
|
161
596
|
|
|
162
|
-
|
|
597
|
+
| Property | What it colours | Ivory | Wood | Slate |
|
|
598
|
+
| --- | --- | --- | --- | --- |
|
|
599
|
+
| `--jr-ground` | the tray round the board | `#a98954` | `#ae804a` | `#252e32` |
|
|
600
|
+
| `--jr-cover` | a covered cell | `#fbf8f1` | `#e0bb7e` | `#455359` |
|
|
601
|
+
| `--jr-open` | an opened cell | `#efe8d8` | `#c69d63` | `#303c41` |
|
|
602
|
+
| `--jr-line` | the lines between cells | `#cfc6b2` | `#936e40` | `#62747a` |
|
|
603
|
+
| `--jr-ink` | the digits and marks | `#1f2320` | `#352c20` | `#f3f0e5` |
|
|
604
|
+
| `--jr-accent` | the cell a hint outlines | `#2f7a4f` | `#2f7a4f` | `#b7d298` |
|
|
605
|
+
|
|
606
|
+
They are set on `.jr-root`, and the material sets them again with `.jr-root[data-material=…]`, so a host rule must be as specific (`.jr-root[data-material]`) to win. The buttons take `--jr-ui-ink`, `--jr-ui-line` and `--jr-ui-surface`, which follow the page's `--ink`, `--rule` and `--surface` when it has them, as the family's demos do.
|
|
607
|
+
|
|
608
|
+
## Limits
|
|
163
609
|
|
|
164
610
|
The board is capped at 60 cells per side and 2,400 cells overall. Shapes need at least 9×9; wraparound is rectangular.
|
|
165
611
|
|
|
@@ -182,34 +628,81 @@ The board is capped at 60 cells per side and 2,400 cells overall. Shapes need at
|
|
|
182
628
|
|
|
183
629
|
For synchronous server use, consider running a deal in a worker; a browser deals extra-hard in the board's own worker without a pause.
|
|
184
630
|
|
|
631
|
+
## Accessibility
|
|
632
|
+
|
|
633
|
+
- **Every cell is a button with a name.** A screen reader hears the cell's place and state ("5, 5: empty", "3, 2: flag", the number a clue shows), and the board is a labelled group, with `aria-busy` while a field is being dealt. `boardModel` gives the same label for a board you draw yourself.
|
|
634
|
+
- **The status line is spoken.** What to do next, what a hint proved and how the game ended is a `role="status"` line, so a change is announced without moving focus. A hint says which cell is certain and why, so it teaches the deduction as well as giving the answer.
|
|
635
|
+
- **The keyboard plays the whole game.** The board has one tab stop and the arrow keys move between cells; Enter opens, F or Space marks, and an open number whose flags match clears its neighbours. The buttons (Start over, Open or Mark, Hint, Just the board) are native buttons.
|
|
636
|
+
- **Touch and pointer.** Tap opens, a long press or right-click marks, and a Mark button switches a touch screen between opening and marking (`aria-pressed`), so nothing needs a long press. The buttons are at least 44 pixels high.
|
|
637
|
+
- **A number is not told by colour alone.** The clue is a digit, in a colour per value for those who see it; flags, stones and flowers are different shapes, not different colours, and every cell state has its name in the label.
|
|
638
|
+
- **Reduced motion.** The only transition, a cell's background, is on only for `prefers-reduced-motion: no-preference`.
|
|
639
|
+
- **Not yet.** The colour pairs of the three materials have not been measured against WCAG contrast ratios. A very wide field (30 and 40 columns) scrolls sideways inside its box on a narrow screen. The Japanese has not been read by a native reader (see [Languages](#languages)).
|
|
640
|
+
|
|
641
|
+
## Browser support
|
|
642
|
+
|
|
185
643
|
The browser player uses ES modules, SVG, custom elements, dialogs and module workers. Serve built files over HTTP; `file:` pages cannot load its worker. The engine and drawing functions do not need DOM globals. Development and tests require Node 22 or later.
|
|
186
644
|
|
|
645
|
+
## Languages
|
|
646
|
+
|
|
647
|
+
The player's words are English and Japanese, chosen with the `language` option or the `lang` attribute: the buttons, the status lines, the hints and the labels read by assistive technology. Corrections to the Japanese are welcome as issues.
|
|
648
|
+
|
|
649
|
+
## Roadmap
|
|
650
|
+
|
|
651
|
+
The engine, the dealer, the hints and the player are in. Nothing else is promised for a date; ideas are welcome in the [issues](https://github.com/johnmorrisdotca/jirai/issues).
|
|
652
|
+
|
|
653
|
+
## Architecture
|
|
654
|
+
|
|
655
|
+
```text
|
|
656
|
+
src/
|
|
657
|
+
├── deduce.ts
|
|
658
|
+
├── draw-entry.ts
|
|
659
|
+
├── draw.ts
|
|
660
|
+
├── element-define.ts
|
|
661
|
+
├── element.ts
|
|
662
|
+
├── enumerate.ts
|
|
663
|
+
├── flood.ts
|
|
664
|
+
├── game.ts
|
|
665
|
+
├── generate.ts
|
|
666
|
+
├── grid.ts
|
|
667
|
+
├── index.ts
|
|
668
|
+
├── jirai.constants.ts
|
|
669
|
+
├── jirai.types.ts
|
|
670
|
+
├── keep.ts
|
|
671
|
+
├── levels.ts
|
|
672
|
+
├── measure.ts
|
|
673
|
+
├── mount.ts
|
|
674
|
+
├── orthogonal.ts
|
|
675
|
+
├── play-entry.ts
|
|
676
|
+
├── random.ts
|
|
677
|
+
├── react.tsx
|
|
678
|
+
├── react.types.ts
|
|
679
|
+
├── repair.ts
|
|
680
|
+
├── shape.ts
|
|
681
|
+
├── solve.ts
|
|
682
|
+
├── strings.ts
|
|
683
|
+
├── style.ts
|
|
684
|
+
├── ui.types.ts
|
|
685
|
+
└── worker.ts
|
|
686
|
+
```
|
|
687
|
+
|
|
187
688
|
## The name
|
|
188
689
|
|
|
189
690
|
*Jirai* (地雷) is Japanese for a land mine, read じらい, said in three beats, *ji-ra-i*. It is made of 地 (*ji*,
|
|
190
691
|
ground) and 雷 (*rai*, thunder): a mine is thunder buried in the ground. Every number on the board is a clue to
|
|
191
692
|
where it lies. ([Wiktionary: 地雷](https://en.wiktionary.org/wiki/地雷).)
|
|
192
693
|
|
|
193
|
-
##
|
|
194
|
-
|
|
195
|
-
```sh
|
|
196
|
-
pnpm install --frozen-lockfile
|
|
197
|
-
pnpm check # lint, types and tests
|
|
198
|
-
pnpm test:package # build and import the actual npm tarball
|
|
199
|
-
pnpm test:demo # browser flows against the built page
|
|
200
|
-
pnpm site # build the standalone page into docs/
|
|
201
|
-
```
|
|
694
|
+
## Where it comes from, and where it is used
|
|
202
695
|
|
|
203
|
-
|
|
696
|
+
Minesweeper's rules are common property. Everything here, the rules, the dealer, the solver, the hints, the pictures and the words, is written for the package, and no third-party puzzle boards or artwork are included.
|
|
204
697
|
|
|
205
|
-
|
|
698
|
+
### Used by
|
|
206
699
|
|
|
207
|
-
|
|
700
|
+
Using Jirai in something? Open an *Add my project* issue and we will add you.
|
|
208
701
|
|
|
209
|
-
|
|
702
|
+
### The family
|
|
210
703
|
|
|
211
704
|
<!-- family:start (made by scripts/family-readme.mjs from scripts/family-template.mjs; change those, not this) -->
|
|
212
|
-
Jirai is one of twenty-
|
|
705
|
+
Jirai is one of twenty-four packages, each made for the same site, each at
|
|
213
706
|
[github.com/johnmorrisdotca](https://github.com/johnmorrisdotca). The code of every one is MIT.
|
|
214
707
|
|
|
215
708
|
- [Korokoro](https://github.com/johnmorrisdotca/korokoro) (コロコロ): dice, with notation, exact odds, real sounds and the dice of many games. [Demo](https://johnmorrisdotca.github.io/korokoro/).
|
|
@@ -234,10 +727,34 @@ Jirai is one of twenty-two packages, each made for the same site, each at
|
|
|
234
727
|
- [Tobiishi](https://github.com/johnmorrisdotca/tobiishi) (飛び石): peg solitaire with nine boards and seeded solvable challenges. [Demo](https://johnmorrisdotca.github.io/tobiishi/).
|
|
235
728
|
- [Jirai](https://github.com/johnmorrisdotca/jirai) (地雷): minesweeper on shaped grids with verified no-guess boards. [Demo](https://johnmorrisdotca.github.io/jirai/).
|
|
236
729
|
- [Gunjin](https://github.com/johnmorrisdotca/gunjin) (軍人): five hidden-rank strategy games with pass-the-device play. [Demo](https://johnmorrisdotca.github.io/gunjin/).
|
|
730
|
+
- [Karakuri](https://github.com/johnmorrisdotca/karakuri) (からくり): eight hyper-casual puzzle games, some of them physics: draw a shield, pull pins, cut ropes, slide blocks, pour tubes. [Demo](https://johnmorrisdotca.github.io/karakuri/).
|
|
731
|
+
- [Houseki](https://github.com/johnmorrisdotca/houseki) (宝石): gem and stone matching puzzles: falling triplets, stone collapse, colour chains and gem swap. [Demo](https://johnmorrisdotca.github.io/houseki/).
|
|
237
732
|
|
|
238
|
-
**This package is Jirai.** The demos of all twenty-
|
|
733
|
+
**This package is Jirai.** The demos of all twenty-four share one header and footer, so each links the rest.
|
|
239
734
|
<!-- family:end -->
|
|
240
735
|
|
|
241
|
-
##
|
|
736
|
+
## Development
|
|
242
737
|
|
|
243
|
-
|
|
738
|
+
```sh
|
|
739
|
+
pnpm install --frozen-lockfile
|
|
740
|
+
pnpm check # lint, types, tests and the presentation checks
|
|
741
|
+
pnpm test:package # build and import the actual npm tarball
|
|
742
|
+
pnpm test:demo # browser flows against the built page
|
|
743
|
+
pnpm site # build the standalone page into site/
|
|
744
|
+
pnpm test:readme # run every example in this README against the built package
|
|
745
|
+
pnpm screenshots:readme # take the README's pictures from the built demo, in light and dark
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
The standalone game is `site/index.html`. The preview binds to `127.0.0.1:6713`; see [CONTRIBUTING.md](CONTRIBUTING.md) before changing the engine or player.
|
|
749
|
+
|
|
750
|
+
## Contributing
|
|
751
|
+
|
|
752
|
+
Bug reports and pull requests are welcome in the [issues](https://github.com/johnmorrisdotca/jirai/issues). See [CONTRIBUTING.md](CONTRIBUTING.md), the [Code of Conduct](CODE_OF_CONDUCT.md) and the [Security policy](SECURITY.md).
|
|
753
|
+
|
|
754
|
+
## Changes
|
|
755
|
+
|
|
756
|
+
Every release is written up in [CHANGELOG.md](./CHANGELOG.md). The latest release, 0.4.2, adds no code: it is this README in full, with pictures of every rule set and outline, examples that are run on every change, examples for React, Vue, Svelte and Angular, and an Accessibility section.
|
|
757
|
+
|
|
758
|
+
## Licence
|
|
759
|
+
|
|
760
|
+
[MIT](LICENSE) © John Morris. No third-party puzzle boards or artwork are included.
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export * from "./deduce.ts";
|
|
|
8
8
|
export * from "./keep.ts";
|
|
9
9
|
export { seededRandom } from "./random.ts";
|
|
10
10
|
/** Package version, kept in step with the release metadata. */
|
|
11
|
-
export declare const VERSION = "0.4.
|
|
11
|
+
export declare const VERSION = "0.4.2";
|
|
12
12
|
export { SHAPES, activeCell, activeCells } from "./shape.ts";
|
|
13
13
|
export * from "./orthogonal.ts";
|
|
14
14
|
export { measureBoard } from "./measure.ts";
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ export * from "./deduce.js";
|
|
|
8
8
|
export * from "./keep.js";
|
|
9
9
|
export { seededRandom } from "./random.js";
|
|
10
10
|
/** Package version, kept in step with the release metadata. */
|
|
11
|
-
export const VERSION = "0.4.
|
|
11
|
+
export const VERSION = "0.4.2";
|
|
12
12
|
export { SHAPES, activeCell, activeCells } from "./shape.js";
|
|
13
13
|
export * from "./orthogonal.js";
|
|
14
14
|
export { measureBoard } from "./measure.js";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/jirai",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Minesweeper on square, orthogonal, hexagonal and wraparound boards: four levels up to extra-hard, seeded games, a safe opening, verified no-guess boards, explained hints
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "Minesweeper on square, orthogonal, hexagonal and wraparound boards: four levels up to extra-hard, seeded games, a safe opening, verified no-guess boards, explained hints and a playable board for any page. Zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "John Morris",
|
|
@@ -54,30 +54,37 @@
|
|
|
54
54
|
"exports": {
|
|
55
55
|
".": {
|
|
56
56
|
"types": "./dist/index.d.ts",
|
|
57
|
+
"import": "./dist/index.js",
|
|
57
58
|
"default": "./dist/index.js"
|
|
58
59
|
},
|
|
59
60
|
"./orthogonal": {
|
|
60
61
|
"types": "./dist/orthogonal.d.ts",
|
|
62
|
+
"import": "./dist/orthogonal.js",
|
|
61
63
|
"default": "./dist/orthogonal.js"
|
|
62
64
|
},
|
|
63
65
|
"./play": {
|
|
64
66
|
"types": "./dist/play-entry.d.ts",
|
|
67
|
+
"import": "./dist/play-entry.js",
|
|
65
68
|
"default": "./dist/play-entry.js"
|
|
66
69
|
},
|
|
67
70
|
"./draw": {
|
|
68
71
|
"types": "./dist/draw-entry.d.ts",
|
|
72
|
+
"import": "./dist/draw-entry.js",
|
|
69
73
|
"default": "./dist/draw-entry.js"
|
|
70
74
|
},
|
|
71
75
|
"./element": {
|
|
72
76
|
"types": "./dist/element.d.ts",
|
|
77
|
+
"import": "./dist/element.js",
|
|
73
78
|
"default": "./dist/element.js"
|
|
74
79
|
},
|
|
75
80
|
"./element/define": {
|
|
76
81
|
"types": "./dist/element-define.d.ts",
|
|
82
|
+
"import": "./dist/element-define.js",
|
|
77
83
|
"default": "./dist/element-define.js"
|
|
78
84
|
},
|
|
79
85
|
"./react": {
|
|
80
86
|
"types": "./dist/react.d.ts",
|
|
87
|
+
"import": "./dist/react.js",
|
|
81
88
|
"default": "./dist/react.js"
|
|
82
89
|
}
|
|
83
90
|
},
|
|
@@ -106,7 +113,8 @@
|
|
|
106
113
|
"test:package": "pnpm build && node scripts/check-package.mjs",
|
|
107
114
|
"prepublishOnly": "pnpm check && pnpm test:package",
|
|
108
115
|
"check:presentation": "node scripts/check-presentation.mjs",
|
|
109
|
-
"
|
|
116
|
+
"screenshots:readme": "pnpm site && node scripts/readme-pictures.mjs",
|
|
117
|
+
"test:readme": "pnpm build && node scripts/check-readme-examples.mjs"
|
|
110
118
|
},
|
|
111
119
|
"peerDependencies": {
|
|
112
120
|
"react": ">=18"
|
|
@@ -121,8 +129,8 @@
|
|
|
121
129
|
"eslint": "^9.0.0",
|
|
122
130
|
"typescript-eslint": "^8.0.0",
|
|
123
131
|
"typescript": "^5.9.0",
|
|
124
|
-
"vitest": "^
|
|
125
|
-
"@playwright/test": "^1.
|
|
132
|
+
"vitest": "^5.0.0",
|
|
133
|
+
"@playwright/test": "^1.63.0",
|
|
126
134
|
"@types/react": "^19.0.0",
|
|
127
135
|
"react": "^19.0.0"
|
|
128
136
|
},
|
package/src/family.test.js
CHANGED
|
@@ -15,13 +15,19 @@ const id = pkg.name.replace(/^@[^/]+\//, "");
|
|
|
15
15
|
|
|
16
16
|
// The recorded hashes. The template's is the one that says every demo's header and footer, and every README's
|
|
17
17
|
// list of the family, are the same text.
|
|
18
|
-
|
|
18
|
+
// The template of 2026-10-05 lists twenty-four packages, Karakuri and Houseki included. The family's list is swept again, in every
|
|
19
|
+
// repository at once, when a package is added to it, and this hash is then the new one.
|
|
20
|
+
const TEMPLATE = { version: "2026-10-05", sha256: "a2dc81808be980438bdef8b91f5c0bbff920a739bc50930cd4632cb017c8fa48" };
|
|
19
21
|
const FILES = {
|
|
20
22
|
"scripts/family-readme.mjs": "3c9d5b2cbf17a92d31bced98edac7f544616edb0dff90bf2d141722a9d4516c5",
|
|
21
23
|
"scripts/release-notes.mjs": "efab0fb78ad05973a8885624c0d2ce3b458b55799c11eabaa5176603ce8cd1e9",
|
|
22
24
|
"scripts/community/SECURITY.md": "ff6f650be7789396233671d2558439736efe7f96a1d1d39115dd5cf94d29275c",
|
|
23
25
|
"scripts/community/CODE_OF_CONDUCT.md": "34da1f56f004ce8f7f95d4449b64d2ecb5827dd9f0d4eea1da351a20713ebe70",
|
|
26
|
+
"scripts/community/CONTRIBUTING.md": "3225201be4f66531c27e849f0bb219f8c7274e5815b726a93565bccadbc6a43c",
|
|
24
27
|
};
|
|
28
|
+
// The Pages workflow is one text in every package. A package that also builds a documentation site adds the one step that
|
|
29
|
+
// builds it, and that line is left out before the text is compared.
|
|
30
|
+
const PAGES_SHA256 = "f45c33dd0403551588b8b00882cfd0c1d953d2095e558ffab0f7451b8cdb6ef6";
|
|
25
31
|
|
|
26
32
|
describe("the family template", () => {
|
|
27
33
|
it("is the one file, byte for byte, in every package", () => {
|
|
@@ -32,7 +38,7 @@ describe("the family template", () => {
|
|
|
32
38
|
it("lists every package of the family, in order, each with its Japanese name and a line on it", () => {
|
|
33
39
|
expect(FAMILY.map((one) => one.id)).toEqual([
|
|
34
40
|
"korokoro", "kyuubu", "hitotsu", "toranpu", "tane", "narabe", "tenka", "kumimoji", "tsunagi", "jarajara",
|
|
35
|
-
"suido", "domino", "kotoba", "sugoroku", "kazu", "meikyuu", "hikidashi", "chizu", "bushu", "tobiishi", "jirai", "gunjin",
|
|
41
|
+
"suido", "domino", "kotoba", "sugoroku", "kazu", "meikyuu", "hikidashi", "chizu", "bushu", "tobiishi", "jirai", "gunjin", "karakuri", "houseki",
|
|
36
42
|
]);
|
|
37
43
|
for (const one of FAMILY) {
|
|
38
44
|
expect(one.name, one.id).toBe(one.id[0].toUpperCase() + one.id.slice(1));
|
|
@@ -49,13 +55,41 @@ describe("the family template", () => {
|
|
|
49
55
|
});
|
|
50
56
|
|
|
51
57
|
describe("the files every package shares", () => {
|
|
52
|
-
it("are copied unchanged: the README writer, the release notes, and the family's SECURITY.md and
|
|
58
|
+
it("are copied unchanged: the README writer, the release notes, and the family's SECURITY.md, CODE_OF_CONDUCT.md and CONTRIBUTING.md", () => {
|
|
53
59
|
for (const [path, hash] of Object.entries(FILES)) expect(sha(path), path).toBe(hash);
|
|
54
60
|
});
|
|
55
61
|
|
|
56
62
|
it("SECURITY.md and CODE_OF_CONDUCT.md are the master text of github.com/johnmorrisdotca/.github, which scripts/community keeps a copy of", () => {
|
|
57
63
|
for (const file of ["SECURITY.md", "CODE_OF_CONDUCT.md"]) expect(read(file), file).toBe(read(`scripts/community/${file}`));
|
|
58
64
|
});
|
|
65
|
+
|
|
66
|
+
it("CONTRIBUTING.md is the master text of github.com/johnmorrisdotca/.github, which scripts/community keeps a copy of, and then what is particular to this package", () => {
|
|
67
|
+
const master = read("scripts/community/CONTRIBUTING.md");
|
|
68
|
+
const own = read("CONTRIBUTING.md");
|
|
69
|
+
const name = FAMILY.find((one) => one.id === id).name;
|
|
70
|
+
expect(own.startsWith(`${master}\n## Particular to ${name}\n`), "CONTRIBUTING.md is the master text, a blank line and '## Particular to <Name>'").toBe(true);
|
|
71
|
+
expect(own.split("\n## Particular to ").length - 1).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("the workflows", () => {
|
|
76
|
+
const ci = read(".github/workflows/ci.yml");
|
|
77
|
+
|
|
78
|
+
it("ci.yml has the family's three jobs, check, demo and package, and runs `pnpm check` rather than its parts, with jobs of the package's own after them", () => {
|
|
79
|
+
expect(ci).toMatch(/^name: CI$/m);
|
|
80
|
+
const jobs = [...ci.slice(ci.indexOf("\njobs:\n")).matchAll(/^ {2}([a-z][a-z-]*):$/gm)].map((match) => match[1]);
|
|
81
|
+
expect(jobs.slice(0, 3)).toEqual(["check", "demo", "package"]);
|
|
82
|
+
expect(ci).toContain(" - run: pnpm check\n");
|
|
83
|
+
expect(ci).not.toMatch(/- run: pnpm (lint|typecheck|test)$/m);
|
|
84
|
+
expect(ci).toContain("os: [ubuntu-latest, macos-latest, windows-latest]");
|
|
85
|
+
expect(ci).toContain("run: pnpm test:package");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("pages.yml is the same text in every package, named Pages, apart from one step that builds a documentation site", () => {
|
|
89
|
+
const pages = read(".github/workflows/pages.yml").replace("\n - run: pnpm docs:site\n", "\n");
|
|
90
|
+
expect(pages).toMatch(/^name: Pages$/m);
|
|
91
|
+
expect(createHash("sha256").update(pages).digest("hex")).toBe(PAGES_SHA256);
|
|
92
|
+
});
|
|
59
93
|
});
|
|
60
94
|
|
|
61
95
|
describe("the README's family", () => {
|
|
@@ -75,13 +109,27 @@ describe("the README's family", () => {
|
|
|
75
109
|
});
|
|
76
110
|
});
|
|
77
111
|
|
|
112
|
+
describe("the README's version pins", () => {
|
|
113
|
+
it("name this package's major version, never an older one: a CDN address says @2 once the package is 2.x", () => {
|
|
114
|
+
const major = pkg.version.split(".")[0];
|
|
115
|
+
const pins = read("README.md")
|
|
116
|
+
.split(`${pkg.name}@`)
|
|
117
|
+
.slice(1)
|
|
118
|
+
.map((rest) => /^\d+/.exec(rest)?.[0])
|
|
119
|
+
.filter((pin) => pin !== undefined);
|
|
120
|
+
for (const pin of pins) expect(pin, `${pkg.name}@${pin} in README.md`).toBe(major);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
78
124
|
describe("the release notes", () => {
|
|
79
125
|
it("are the changelog's section for the version, which the Release workflow puts on the GitHub release", () => {
|
|
80
126
|
const log = "# Changelog\n\n## [Unreleased]\n\n## [1.2.0] - 2026-01-02\n\n### Added\n\n- A thing.\n\n## [1.1.0] - 2026-01-01\n\n- Older.\n\n[Unreleased]: https://example.test\n";
|
|
81
127
|
expect(releaseNotes(log, "1.2.0")).toBe("### Added\n\n- A thing.");
|
|
82
128
|
expect(releaseNotes(log, "1.1.0")).toBe("- Older.");
|
|
83
129
|
expect(releaseNotes(log, "9.9.9")).toBeNull();
|
|
84
|
-
|
|
130
|
+
// Until a version is published its notes are under [Unreleased]; the release takes the heading with the version and the date.
|
|
131
|
+
const notes = releaseNotes(read("CHANGELOG.md"), pkg.version) ?? releaseNotes(read("CHANGELOG.md"), "Unreleased");
|
|
132
|
+
expect(notes?.length, `CHANGELOG.md has nothing under ## [${pkg.version}] or ## [Unreleased]`).toBeGreaterThan(40);
|
|
85
133
|
const workflow = read(".github/workflows/release.yml");
|
|
86
134
|
expect(workflow).toContain("scripts/release-notes.mjs");
|
|
87
135
|
expect(workflow).not.toContain("See CHANGELOG.md.");
|
|
@@ -90,7 +138,8 @@ describe("the release notes", () => {
|
|
|
90
138
|
it("come from a changelog in Keep a Changelog form: an Unreleased heading, then each version in brackets with its date", () => {
|
|
91
139
|
const log = read("CHANGELOG.md");
|
|
92
140
|
expect(log).toContain("\n## [Unreleased]\n");
|
|
93
|
-
|
|
141
|
+
// Before the first release there is no versioned heading yet, only Unreleased.
|
|
142
|
+
if (/^## \[\d/m.test(log)) expect(log).toMatch(/^## \[\d+\.\d+\.\d+\] - \d{4}-\d{2}-\d{2}$/m);
|
|
94
143
|
expect(log).not.toMatch(/^## \d/m);
|
|
95
144
|
});
|
|
96
145
|
});
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ export * from "./deduce.ts";
|
|
|
8
8
|
export * from "./keep.ts";
|
|
9
9
|
export { seededRandom } from "./random.ts";
|
|
10
10
|
/** Package version, kept in step with the release metadata. */
|
|
11
|
-
export const VERSION = "0.4.
|
|
11
|
+
export const VERSION = "0.4.2";
|
|
12
12
|
|
|
13
13
|
export { SHAPES, activeCell, activeCells } from "./shape.ts";
|
|
14
14
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The README, held to the family's README standard (johnmorrisdotca/.github, README-STANDARD.md): its sections, its examples'
|
|
2
|
+
// languages, its pictures and their files, its tables and its tone. The rules are in scripts/readme-lint.mjs, the same file in
|
|
3
|
+
// every package. A fault names the line and what to do.
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { lintReadme, readInputs, REQUIRED_SECTIONS } from "../scripts/readme-lint.mjs";
|
|
7
|
+
|
|
8
|
+
describe("the README keeps the family's standard", () => {
|
|
9
|
+
it("has no fault", () => {
|
|
10
|
+
expect(lintReadme(readInputs("."))).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("lists the sections in the order the standard gives", () => {
|
|
14
|
+
expect(REQUIRED_SECTIONS.map(([name]) => name)).toEqual([
|
|
15
|
+
"In 30 seconds", "Who it is for", "Features", "Use it in your project", "Examples", "API", "Theming", "Limits", "Accessibility",
|
|
16
|
+
"Browser support", "Languages", "Roadmap", "Architecture", "The name", "Where it comes from, and where it is used", "Development",
|
|
17
|
+
"Contributing", "Changes", "Licence",
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("the standard's checks catch what they are for", () => {
|
|
23
|
+
const pkg = { name: "@johnmorrisdotca/sample", version: "2.1.0", files: ["dist", "README.md"] };
|
|
24
|
+
const raw = "https://raw.githubusercontent.com/johnmorrisdotca/sample/main/docs/images/";
|
|
25
|
+
const ok = (name) => ({ file: name, bytes: 1000 });
|
|
26
|
+
const faultsFor = (readme, pictures = []) => lintReadme({ readme, pkg, pictures, minSubjects: 0 });
|
|
27
|
+
|
|
28
|
+
it("refuses a fenced block with no language, and a language that is not listed", () => {
|
|
29
|
+
expect(faultsFor("# T\n\n```\nx\n```\n\n```pascal\nx\n```\n").join("\n")).toMatch(/no language[\s\S]*pascal/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("refuses a picture that is not this repository's docs/images, has no alt text, or has no dark twin", () => {
|
|
33
|
+
const readme = `# T\n\n<picture><img src="docs/a.webp" alt="x" width="1"></picture>\n\n<picture><source media="(prefers-color-scheme: dark)" srcset="${raw}z-desk-dark.webp"><img src="${raw}z-desk-light.webp" alt="" width="1"></picture>\n`;
|
|
34
|
+
const faults = faultsFor(readme, [ok("z-desk-light.webp")]).join("\n");
|
|
35
|
+
expect(faults).toMatch(/not https:\/\/raw\.githubusercontent\.com\/johnmorrisdotca\/sample\/main\/docs\/images\//);
|
|
36
|
+
expect(faults).toMatch(/no alt text/);
|
|
37
|
+
expect(faults).toMatch(/has no dark twin/);
|
|
38
|
+
expect(faults).toMatch(/docs\/images\/z-desk-dark\.webp does not exist/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("refuses a picture over its budget, an unused file and a badly named one", () => {
|
|
42
|
+
const faults = faultsFor("# T\n", [{ file: "big-desk-light.webp", bytes: 300 * 1024 }, { file: "big-desk-dark.webp", bytes: 1000 }, { file: "photo.jpg", bytes: 10 }]).join("\n");
|
|
43
|
+
expect(faults).toMatch(/big-desk-light\.webp is 300 KB; the budget for it is 200 KB/);
|
|
44
|
+
expect(faults).toMatch(/big-desk-dark\.webp is not used/);
|
|
45
|
+
expect(faults).toMatch(/photo\.jpg is not named/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("refuses marketing words and exclamation marks in prose but not in code", () => {
|
|
49
|
+
const faults = faultsFor("# T\n\nA powerful, seamless library. It works!\n\n```ts\nconst powerful = !x; // magic!\n```\n").join("\n");
|
|
50
|
+
expect(faults).toMatch(/"powerful"/);
|
|
51
|
+
expect(faults).toMatch(/"seamless"/);
|
|
52
|
+
expect(faults).toMatch(/exclamation mark/);
|
|
53
|
+
expect(faults).not.toMatch(/"magic"/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("refuses a table row of the wrong width, a skipped heading level and a stale version pin", () => {
|
|
57
|
+
const faults = faultsFor("# T\n\n| a | b |\n| - | - |\n| 1 |\n\n## A\n\n#### Skips\n\nUse `@johnmorrisdotca/sample@1` from the CDN.\n").join("\n");
|
|
58
|
+
expect(faults).toMatch(/1 cells where its header has 2/);
|
|
59
|
+
expect(faults).toMatch(/skips a heading level/);
|
|
60
|
+
expect(faults).toMatch(/@johnmorrisdotca\/sample@1; the package is at 2\.1\.0, so the pin is @2/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("refuses a README longer than npm will show", () => {
|
|
64
|
+
expect(faultsFor(`# T\n\n${"word ".repeat(13_000)}\n`).join("\n")).toMatch(/npm shows only the first 65,536/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("refuses a package whose files ship the pictures", () => {
|
|
68
|
+
expect(lintReadme({ readme: "# T\n", pkg: { ...pkg, files: ["dist", "docs"] }, pictures: [], minSubjects: 0 }).join("\n")).toMatch(/"files" lists docs/);
|
|
69
|
+
});
|
|
70
|
+
});
|