@johnmorrisdotca/jirai 0.4.1 → 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 +25 -0
- package/README.md +464 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -2
- package/src/family.test.js +33 -1
- package/src/index.ts +1 -1
- package/src/readme.test.js +66 -30
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ 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
|
+
|
|
9
34
|
## [0.4.1] - 2026-10-05
|
|
10
35
|
|
|
11
36
|
Nothing that was exported has changed.
|
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,7 +53,7 @@ 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
|
```
|
|
@@ -62,12 +76,96 @@ const board = makeOrthogonalBoard(settings, 40); // counts only edge-sharing nei
|
|
|
62
76
|
- **Accessible controls:** keyboard navigation, pointer and touch, long press to mark, English and Japanese strings, and board labels read by assistive technology.
|
|
63
77
|
- **Materials and markers:** ivory, wood or slate; flags, stones or flowers. Host CSS can replace the palette.
|
|
64
78
|
|
|
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
|
+
|
|
65
150
|
## Use it in your project
|
|
66
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
|
|
163
|
+
|
|
67
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.
|
|
68
165
|
|
|
69
|
-
```ts
|
|
70
|
-
import { DEFAULT_SETTINGS
|
|
166
|
+
```ts no-run
|
|
167
|
+
import { DEFAULT_SETTINGS } from "@johnmorrisdotca/jirai";
|
|
168
|
+
import { mountJirai } from "@johnmorrisdotca/jirai/play";
|
|
71
169
|
|
|
72
170
|
const board = mountJirai(document.querySelector<HTMLElement>("#game")!, {
|
|
73
171
|
settings: { ...DEFAULT_SETTINGS, grid: "orthogonal", seed: 7 },
|
|
@@ -85,6 +183,8 @@ board.destroy();
|
|
|
85
183
|
|
|
86
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.
|
|
87
185
|
|
|
186
|
+
### The tag
|
|
187
|
+
|
|
88
188
|
Use the custom element without a mount call:
|
|
89
189
|
|
|
90
190
|
```html
|
|
@@ -92,12 +192,313 @@ Use the custom element without a mount call:
|
|
|
92
192
|
import "@johnmorrisdotca/jirai/element/define";
|
|
93
193
|
</script>
|
|
94
194
|
<jirai-board width="9" height="9" mines="10" seed="42"
|
|
95
|
-
grid="orthogonal"
|
|
195
|
+
grid="orthogonal" no-guess="true"
|
|
96
196
|
material="wood" pieces="stones" lang="ja"></jirai-board>
|
|
97
197
|
```
|
|
98
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
|
+
|
|
99
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.
|
|
100
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
|
+
|
|
101
502
|
## Rules and settings
|
|
102
503
|
|
|
103
504
|
| Setting | Values and limits |
|
|
@@ -163,12 +564,47 @@ The full engine entry is `@johnmorrisdotca/jirai`; four-neighbour helpers are in
|
|
|
163
564
|
|
|
164
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.
|
|
165
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
|
+
|
|
166
591
|
## Theming
|
|
167
592
|
|
|
168
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.
|
|
169
594
|
|
|
170
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.
|
|
171
596
|
|
|
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
|
+
|
|
172
608
|
## Limits
|
|
173
609
|
|
|
174
610
|
The board is capped at 60 cells per side and 2,400 cells overall. Shapes need at least 9×9; wraparound is rectangular.
|
|
@@ -192,6 +628,16 @@ The board is capped at 60 cells per side and 2,400 cells overall. Shapes need at
|
|
|
192
628
|
|
|
193
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.
|
|
194
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
|
+
|
|
195
641
|
## Browser support
|
|
196
642
|
|
|
197
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.
|
|
@@ -245,10 +691,14 @@ src/
|
|
|
245
691
|
ground) and 雷 (*rai*, thunder): a mine is thunder buried in the ground. Every number on the board is a clue to
|
|
246
692
|
where it lies. ([Wiktionary: 地雷](https://en.wiktionary.org/wiki/地雷).)
|
|
247
693
|
|
|
248
|
-
## Where it comes from
|
|
694
|
+
## Where it comes from, and where it is used
|
|
249
695
|
|
|
250
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.
|
|
251
697
|
|
|
698
|
+
### Used by
|
|
699
|
+
|
|
700
|
+
Using Jirai in something? Open an *Add my project* issue and we will add you.
|
|
701
|
+
|
|
252
702
|
### The family
|
|
253
703
|
|
|
254
704
|
<!-- family:start (made by scripts/family-readme.mjs from scripts/family-template.mjs; change those, not this) -->
|
|
@@ -290,10 +740,12 @@ pnpm install --frozen-lockfile
|
|
|
290
740
|
pnpm check # lint, types, tests and the presentation checks
|
|
291
741
|
pnpm test:package # build and import the actual npm tarball
|
|
292
742
|
pnpm test:demo # browser flows against the built page
|
|
293
|
-
pnpm site # build the standalone page into
|
|
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
|
|
294
746
|
```
|
|
295
747
|
|
|
296
|
-
The standalone game is `
|
|
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.
|
|
297
749
|
|
|
298
750
|
## Contributing
|
|
299
751
|
|
|
@@ -301,7 +753,7 @@ Bug reports and pull requests are welcome in the [issues](https://github.com/joh
|
|
|
301
753
|
|
|
302
754
|
## Changes
|
|
303
755
|
|
|
304
|
-
Every release is written up in [CHANGELOG.md](./CHANGELOG.md).
|
|
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.
|
|
305
757
|
|
|
306
758
|
## Licence
|
|
307
759
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/jirai",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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 and a playable board for any page. Zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -113,7 +113,8 @@
|
|
|
113
113
|
"test:package": "pnpm build && node scripts/check-package.mjs",
|
|
114
114
|
"prepublishOnly": "pnpm check && pnpm test:package",
|
|
115
115
|
"check:presentation": "node scripts/check-presentation.mjs",
|
|
116
|
-
"
|
|
116
|
+
"screenshots:readme": "pnpm site && node scripts/readme-pictures.mjs",
|
|
117
|
+
"test:readme": "pnpm build && node scripts/check-readme-examples.mjs"
|
|
117
118
|
},
|
|
118
119
|
"peerDependencies": {
|
|
119
120
|
"react": ">=18"
|
package/src/family.test.js
CHANGED
|
@@ -23,7 +23,11 @@ const FILES = {
|
|
|
23
23
|
"scripts/release-notes.mjs": "efab0fb78ad05973a8885624c0d2ce3b458b55799c11eabaa5176603ce8cd1e9",
|
|
24
24
|
"scripts/community/SECURITY.md": "ff6f650be7789396233671d2558439736efe7f96a1d1d39115dd5cf94d29275c",
|
|
25
25
|
"scripts/community/CODE_OF_CONDUCT.md": "34da1f56f004ce8f7f95d4449b64d2ecb5827dd9f0d4eea1da351a20713ebe70",
|
|
26
|
+
"scripts/community/CONTRIBUTING.md": "3225201be4f66531c27e849f0bb219f8c7274e5815b726a93565bccadbc6a43c",
|
|
26
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";
|
|
27
31
|
|
|
28
32
|
describe("the family template", () => {
|
|
29
33
|
it("is the one file, byte for byte, in every package", () => {
|
|
@@ -51,13 +55,41 @@ describe("the family template", () => {
|
|
|
51
55
|
});
|
|
52
56
|
|
|
53
57
|
describe("the files every package shares", () => {
|
|
54
|
-
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", () => {
|
|
55
59
|
for (const [path, hash] of Object.entries(FILES)) expect(sha(path), path).toBe(hash);
|
|
56
60
|
});
|
|
57
61
|
|
|
58
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", () => {
|
|
59
63
|
for (const file of ["SECURITY.md", "CODE_OF_CONDUCT.md"]) expect(read(file), file).toBe(read(`scripts/community/${file}`));
|
|
60
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
|
+
});
|
|
61
93
|
});
|
|
62
94
|
|
|
63
95
|
describe("the README's family", () => {
|
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
|
|
package/src/readme.test.js
CHANGED
|
@@ -1,34 +1,70 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
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.
|
|
3
4
|
import { describe, expect, it } from "vitest";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
];
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
it("
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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/);
|
|
33
69
|
});
|
|
34
70
|
});
|