refactor: remove unused knobs, strings and stale text

Drop what nothing uses any more: the --max-syllables flag with its
sourceSpec field, meta row and reject reason; the store's clearRejection;
the --gap CSS token; three orphaned i18n strings; two exports with no
importer; the preview npm script; the proto.yml "baseline exists" step
that has been unconditionally true since the schema landed on main; and
two root-level .gitignore entries for paths Playwright never writes.

Correct text that outlived its subject: NOTICE named a tools/ directory
that never existed, the README still pointed at the first plan and
carried a migration note for retired sources, and a few comments still
said "release" for an upstream that is a weekly export. The pin test
header now says three copies and also holds README and ATTRIBUTION to
the same URL; the Make-targets table lists clean and help.

Wire fixtures regenerated so client_hello uses the ProtocolVersion
constant and server_game_started carries the 30s turn limit.
builder_version becomes 4 for the dropped meta row.
This commit is contained in:
tiennm99 committed 2026-09-08 18:01:37 +07:00
1 parent 805cbca39b
commit 557de1af94
22 files changed
+66 -111

No files matched your search

-13
View File
@@ -43,23 +43,10 @@ jobs:
- name: Lint the schema
run: buf lint
# Skipped only until this contract first lands on main; after that the
# baseline always exists and the check is unconditional.
- name: Look for a breaking-change baseline on main
id: baseline
run: |
if git cat-file -e origin/main:proto/noitu/v1/game.proto 2>/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "::notice::no schema on main yet, skipping the breaking-change check"
fi
# ref=origin/main, not branch=main: a pull_request checkout is a detached
# HEAD with no local main, and branch=main fails there with a git clone
# error rather than a breaking-change report.
- name: Reject breaking changes
if: steps.baseline.outputs.exists == 'true'
run: buf breaking --against '.git#ref=origin/main'
# A committed generated tree that no longer matches the schema is worse
-2
View File
@@ -24,8 +24,6 @@ web/.svelte-kit/
node_modules/
# Playwright
/test-results/
/playwright-report/
web/test-results/
web/playwright-report/
+2 -2
View File
@@ -62,8 +62,8 @@ fixture-dict: $(FIXTURE_DB)
proto: web/node_modules
buf generate
# What CI runs: the schema is well-formed, and the committed generated trees
# match what the schema currently produces.
# The local equivalent of proto.yml: the schema is well-formed, and the
# committed generated trees match what the schema currently produces.
# --intent-to-add makes a newly emitted file visible: git diff alone ignores
# untracked files and would call an incomplete committed tree clean.
proto-check: proto
+1 -1
View File
@@ -10,7 +10,7 @@ This product is distributed under two licenses, applying to different artifacts.
All source code in this repository is licensed under the Apache License,
Version 2.0. See the LICENSE file at the repository root.
This covers everything under server/, tools/, web/, and proto/.
This covers everything under server/, web/, and proto/.
------------------------------------------------------------------------------
2. DICTIONARY DATA — Creative Commons Attribution-ShareAlike 4.0 International
+6 -7
View File
@@ -21,9 +21,8 @@ there is one implementation of it and not two.
## Status
Playable: vs bot at three difficulties, and online rooms of two to four by room code. See
[`plans/260904-1125-noi-tu-web-game/plan.md`](./plans/260904-1125-noi-tu-web-game/plan.md)
for the implementation plan and phase breakdown.
Playable: vs bot at three difficulties, and online rooms of two to four by room code.
Design history lives under `plans/`.
## Architecture
@@ -169,9 +168,7 @@ The export is fetched fresh, not pinned: kaikki.org re-exports Wiktionary about
keeps no dated snapshots, so two builds a week apart can differ slightly. The database
records the SHA-256 of the file it was built from in its `meta` table. Neither the export
nor the derived database is committed; both are build artifacts. See
[`data/ATTRIBUTION.md`](./data/ATTRIBUTION.md). Leftover `data/dictionary.db` or
`data/undertheseanlp-words.jsonl` files from earlier sources are no longer read and can be
deleted.
[`data/ATTRIBUTION.md`](./data/ATTRIBUTION.md).
## Running the server
@@ -231,9 +228,11 @@ dev-only URL to get wrong.
| `proto` | Regenerate the Go and JS wire types from `proto/` (needs `buf`) |
| `proto-check` | Lint the schema and verify the committed generated code is in sync |
| `fixture-dict` | Build the small test dictionary, no download needed |
| `test` | Run Go and JavaScript tests |
| `test` | Run Go and JavaScript tests (`test-go`, `test-web`) |
| `test-e2e` | Run the Playwright suite against the fixture dictionary |
| `run` | Build and run the server locally |
| `clean` | Remove build artifacts, keeping the downloaded export |
| `help` | List these targets |
### Without `make`
+1 -1
View File
@@ -1,2 +1,2 @@
$r-8f2cNgười chơi ẩn danh
$r-8f2cNgười chơi ẩn danh
+1 -1
View File
@@ -1,6 +1,6 @@
"n
hòa bìnhbình  ×ø©‘3(0 œ:
hòa bìnhbình  ×ø©‘3(0°ê:
p1Người chơi 0:
p2 Khách mời *(0:
p3Khách 2 Bp1
+2 -6
View File
@@ -17,7 +17,6 @@ type rejectReason string
const (
rejectEmpty rejectReason = "empty"
rejectTooShort rejectReason = "fewer than 2 syllables"
rejectTooLong rejectReason = "over max-syllables"
rejectDigit rejectReason = "contains a digit"
rejectPunct rejectReason = "contains punctuation"
rejectNonVietnam rejectReason = "no Vietnamese letters"
@@ -27,8 +26,8 @@ const (
)
// accept normalizes a raw source entry and decides whether it belongs in the
// game dictionary. maxSyllables of 0 means no upper bound.
func accept(raw string, maxSyllables int) (word string, syllables []string, reason rejectReason, ok bool) {
// game dictionary.
func accept(raw string) (word string, syllables []string, reason rejectReason, ok bool) {
word, syllables, err := vietnamese.Normalize(raw)
if err != nil {
return "", nil, rejectEmpty, false
@@ -37,9 +36,6 @@ func accept(raw string, maxSyllables int) (word string, syllables []string, reas
if !vietnamese.HasEnoughSyllables(syllables) {
return "", nil, rejectTooShort, false
}
if maxSyllables > 0 && len(syllables) > maxSyllables {
return "", nil, rejectTooLong, false
}
for _, r := range word {
switch {
+2 -20
View File
@@ -17,7 +17,7 @@ func TestAcceptKeepsValidWords(t *testing.T) {
}
for _, tc := range tests {
word, syllables, reason, ok := accept(tc.raw, 0)
word, syllables, reason, ok := accept(tc.raw)
if !ok {
t.Errorf("accept(%q) rejected: %s", tc.raw, reason)
continue
@@ -55,7 +55,7 @@ func TestAcceptRejects(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, _, reason, ok := accept(tc.raw, 0)
_, _, reason, ok := accept(tc.raw)
if ok {
t.Fatalf("accept(%q) succeeded, want rejection %s", tc.raw, tc.want)
}
@@ -65,21 +65,3 @@ func TestAcceptRejects(t *testing.T) {
})
}
}
func TestAcceptMaxSyllables(t *testing.T) {
const raw = "công nghiệp hóa"
if _, _, _, ok := accept(raw, 0); !ok {
t.Errorf("accept(%q, no limit) rejected, want accepted", raw)
}
if _, _, _, ok := accept(raw, 3); !ok {
t.Errorf("accept(%q, max 3) rejected, want accepted", raw)
}
_, _, reason, ok := accept(raw, 2)
if ok {
t.Fatalf("accept(%q, max 2) accepted, want rejection", raw)
}
if reason != rejectTooLong {
t.Errorf("reason = %s, want %s", reason, rejectTooLong)
}
}
+7 -8
View File
@@ -49,7 +49,7 @@ type kaikkiProvenance struct {
// Lines are read with bufio.Reader rather than bufio.Scanner because a row
// carries every sense and translation of its entry and can run to hundreds of
// kilobytes; a scanner's fixed cap would be a guess that eventually fails.
func readKaikkiList(path string, maxSyllables int) (map[string]entry, map[rejectReason]int, map[string]int, kaikkiProvenance, error) {
func readKaikkiList(path string) (map[string]entry, map[rejectReason]int, map[string]int, kaikkiProvenance, error) {
var prov kaikkiProvenance
f, err := os.Open(path)
@@ -92,7 +92,7 @@ func readKaikkiList(path string, maxSyllables int) (map[string]entry, map[reject
rejects[rejectNotVietnamese]++
} else {
pos[row.Pos]++
if word, syllables, reason, ok := accept(row.Word, maxSyllables); !ok {
if word, syllables, reason, ok := accept(row.Word); !ok {
rejects[reason]++
} else {
words[word] = entry{
@@ -153,13 +153,12 @@ func formatPosTally(pos map[string]int) string {
// kaikkiSourceSpec describes a kaikki build for the meta table. With no commit
// or checksum pinned upstream, the hash and row count of the bytes read are the
// provenance.
func kaikkiSourceSpec(path string, prov kaikkiProvenance, maxSyllables int) sourceSpec {
func kaikkiSourceSpec(path string, prov kaikkiProvenance) sourceSpec {
return sourceSpec{
table: "kaikki:" + filepath.Base(path),
url: kaikkiSourceURL,
license: "CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/)",
attribution: "See data/ATTRIBUTION.md for required attribution and the list of modifications.",
maxSyllables: maxSyllables,
table: "kaikki:" + filepath.Base(path),
url: kaikkiSourceURL,
license: "CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/)",
attribution: "See data/ATTRIBUTION.md for required attribution and the list of modifications.",
extra: [][2]string{
{"source_sha256", prov.sha256},
{"source_rows", fmt.Sprint(prov.rows)},
@@ -32,7 +32,7 @@ func defaultKaikkiLines() []string {
}
func TestKaikkiListKeepsVietnameseEntries(t *testing.T) {
words, rejects, pos, prov, err := readKaikkiList(fixtureKaikki(t, defaultKaikkiLines()...), 0)
words, rejects, pos, prov, err := readKaikkiList(fixtureKaikki(t, defaultKaikkiLines()...))
if err != nil {
t.Fatal(err)
}
@@ -59,7 +59,7 @@ func TestKaikkiListKeepsVietnameseEntries(t *testing.T) {
func TestKaikkiListHashesTheBytesItRead(t *testing.T) {
path := fixtureKaikki(t, defaultKaikkiLines()...)
_, _, _, prov, err := readKaikkiList(path, 0)
_, _, _, prov, err := readKaikkiList(path)
if err != nil {
t.Fatal(err)
}
@@ -104,7 +104,7 @@ func TestKaikkiListHandlesDownloadShapes(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
words, _, _, prov, err := readKaikkiList(fixtureKaikkiRaw(t, tc.raw), 0)
words, _, _, prov, err := readKaikkiList(fixtureKaikkiRaw(t, tc.raw))
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("err = %v, want one containing %q", err, tc.wantErr)
@@ -126,7 +126,7 @@ func TestKaikkiListNamesMalformedLine(t *testing.T) {
`{"word": "học sinh", "pos": "noun", "lang_code": "vi"}`,
`{"word": "broken"`,
)
_, _, _, _, err := readKaikkiList(path, 0)
_, _, _, _, err := readKaikkiList(path)
if err == nil {
t.Fatal("malformed line was skipped, want error")
}
@@ -140,7 +140,7 @@ func TestKaikkiListReadsLongLines(t *testing.T) {
// scanner buffer; the reader must not have a line cap.
padding := strings.Repeat("x", 2<<20)
path := fixtureKaikki(t, `{"word": "học sinh", "pos": "noun", "lang_code": "vi", "note": "`+padding+`"}`)
words, _, _, _, err := readKaikkiList(path, 0)
words, _, _, _, err := readKaikkiList(path)
if err != nil {
t.Fatal(err)
}
+9 -13
View File
@@ -33,17 +33,16 @@ import (
// builderVer changes whenever the meta table's contract does, so two databases
// with different provenance rows never claim the same builder.
const builderVer = "3"
const builderVer = "4"
type config struct {
// kaikki is the corpus: the upstream wiktextract JSONL export.
kaikki string
// words is an alternative source: a plain list, one word per line, used to
// build a small fixture database without the upstream download.
words string
out string
maxSyllables int
minWords int
words string
out string
minWords int
}
func main() {
@@ -53,7 +52,6 @@ func main() {
flag.StringVar(&cfg.kaikki, "kaikki", "", "upstream kaikki.org wiktextract JSONL export to read")
flag.StringVar(&cfg.words, "words", "", "read a plain word list instead of the upstream export (one word per line, # comments)")
flag.StringVar(&cfg.out, "out", "../data/noitu.db", "derived database to write")
flag.IntVar(&cfg.maxSyllables, "max-syllables", 0, "reject words longer than this (0 = no limit)")
flag.IntVar(&cfg.minWords, "min-words", 30000, "fail if fewer words survive filtering")
flag.Parse()
@@ -84,7 +82,7 @@ func runFromKaikkiList(cfg config) error {
return fmt.Errorf("kaikki export not found at %s — run 'make fetch-dict' first: %w", cfg.kaikki, err)
}
words, rejects, pos, prov, err := readKaikkiList(cfg.kaikki, cfg.maxSyllables)
words, rejects, pos, prov, err := readKaikkiList(cfg.kaikki)
if err != nil {
return err
}
@@ -92,7 +90,7 @@ func runFromKaikkiList(cfg config) error {
log.Printf("parts of speech: %s", formatPosTally(pos))
log.Printf("accepted %d distinct words from %s (%d rows, sha256 %s)", len(words), cfg.kaikki, prov.rows, prov.sha256)
return finish(cfg, words, kaikkiSourceSpec(cfg.kaikki, prov, cfg.maxSyllables))
return finish(cfg, words, kaikkiSourceSpec(cfg.kaikki, prov))
}
// finish is the tail every input mode shares: the size floor, alias
@@ -120,7 +118,7 @@ func finish(cfg config, words map[string]entry, src sourceSpec) error {
}
// runFromWordList derives a database from a plain list of words instead of the
// upstream release.
// upstream export.
//
// It exists so tests and CI have a real dictionary to play against without the
// upstream download. The filtering, alias generation, writing and verification
@@ -141,7 +139,7 @@ func runFromWordList(cfg config) error {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
word, syllables, reason, ok := accept(line, cfg.maxSyllables)
word, syllables, reason, ok := accept(line)
if !ok {
rejects[reason]++
continue
@@ -222,8 +220,7 @@ type entry struct {
type sourceSpec struct {
// table names the input: "kaikki:<file>" for the corpus, "wordlist:<file>"
// for a fixture, so the output says which build produced it.
table string
maxSyllables int
table string
// url is the upstream artifact; empty for fixture builds.
url string
// license and attribution describe the data's licence obligations. The
@@ -407,7 +404,6 @@ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
{"word_count", fmt.Sprint(len(words))},
{"alias_count", fmt.Sprint(len(aliases))},
{"source_table", src.table},
{"max_syllables", fmt.Sprint(src.maxSyllables)},
}
meta = append(meta, src.extra...)
for _, kv := range meta {
+9 -10
View File
@@ -39,15 +39,14 @@ func defaultRows() [][2]string {
}
}
func buildFixture(t *testing.T, rows [][2]string, maxSyllables int) string {
func buildFixture(t *testing.T, rows [][2]string) string {
t.Helper()
out := filepath.Join(t.TempDir(), "noitu.db")
cfg := config{
kaikki: fixtureSource(t, rows),
out: out,
maxSyllables: maxSyllables,
minWords: 1,
kaikki: fixtureSource(t, rows),
out: out,
minWords: 1,
}
if err := run(cfg); err != nil {
t.Fatalf("run: %v", err)
@@ -66,7 +65,7 @@ func openOut(t *testing.T, path string) *sql.DB {
}
func TestBuildProducesExpectedWords(t *testing.T) {
db := openOut(t, buildFixture(t, defaultRows(), 0))
db := openOut(t, buildFixture(t, defaultRows()))
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM words`).Scan(&count); err != nil {
@@ -99,7 +98,7 @@ func TestBuildProducesExpectedWords(t *testing.T) {
}
func TestBuildComputesOutDegree(t *testing.T) {
db := openOut(t, buildFixture(t, defaultRows(), 0))
db := openOut(t, buildFixture(t, defaultRows()))
// "pháp luật" and "pháp" (rejected) mean exactly one word starts with "pháp".
assertOutDegree(t, db, "pháp", 1)
@@ -121,7 +120,7 @@ func assertOutDegree(t *testing.T, db *sql.DB, syllable string, want int) {
}
func TestBuildWritesAliases(t *testing.T) {
db := openOut(t, buildFixture(t, defaultRows(), 0))
db := openOut(t, buildFixture(t, defaultRows()))
var canonical string
err := db.QueryRow(`SELECT canonical FROM aliases WHERE variant = ?`, "hoà bình").Scan(&canonical)
@@ -146,7 +145,7 @@ func TestBuildWritesAliases(t *testing.T) {
}
func TestBuildRecordsProvenance(t *testing.T) {
db := openOut(t, buildFixture(t, defaultRows(), 0))
db := openOut(t, buildFixture(t, defaultRows()))
for _, key := range []string{"source_url", "source_license", "attribution", "built_at", "word_count"} {
var value string
@@ -176,7 +175,7 @@ func TestBuildFailsBelowMinWords(t *testing.T) {
// A failed build must leave the previous good database untouched. Building in
// place would delete it and leave an empty file the server would happily open.
func TestFailedBuildPreservesPreviousOutput(t *testing.T) {
out := buildFixture(t, defaultRows(), 0)
out := buildFixture(t, defaultRows())
before, err := os.ReadFile(out)
if err != nil {
+4 -4
View File
@@ -28,7 +28,7 @@ const fixtureDir = "../../../proto/testdata"
func clientVariants() map[string]*noituv1.ClientMessage {
return map[string]*noituv1.ClientMessage{
"client_hello": {Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
ProtocolVersion: 1,
ProtocolVersion: ProtocolVersion,
ResumeToken: "r-8f2c",
Nickname: "Người chơi ẩn danh",
}}},
@@ -50,11 +50,11 @@ func clientVariants() map[string]*noituv1.ClientMessage {
"client_set_ready": {Payload: &noituv1.ClientMessage_SetReady{SetReady: &noituv1.SetReady{
Ready: true,
}}},
"client_start_game": {Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}},
"client_start_game": {Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}},
"client_kick_player": {Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{
PlayerId: "p3",
}}},
"client_leave_room": {Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}},
"client_leave_room": {Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}},
"client_send_chat": {Payload: &noituv1.ClientMessage_SendChat{SendChat: &noituv1.SendChat{
Text: "Chào bạn, ván này khó thật!",
}}},
@@ -76,7 +76,7 @@ func serverVariants() map[string]*noituv1.ServerMessage {
MyTurn: true,
DeadlineUnixMs: 1756998020000,
TurnSeq: 1,
TurnLimitMs: 20000,
TurnLimitMs: 30000,
Players: fixtureScores(),
TurnPlayerId: "p1",
}}},
+1 -1
View File
@@ -2,7 +2,7 @@
#
# Hand-written common Vietnamese compounds, not an extract of the upstream
# dictionary: CI must never download the upstream wordlist, and a fixture derived
# from it would carry that release's licence into every test artifact.
# from it would carry that export's licence into every test artifact.
#
# `make fixture-dict` turns this into a real derived database through the same
# filter, alias and write path production uses, so a fixture cannot drift into
+1 -3
View File
@@ -56,7 +56,7 @@ export const ONE_SYLLABLE_WORD = 'sinh';
* an unhelpful "game_start_failed". Asserting it here turns that into a
* sentence a reader can act on.
*/
export const HUB_SYLLABLE = 'sinh';
const HUB_SYLLABLE = 'sinh';
const MIN_OPENING_OUT_DEGREE = 20;
const hubContinuations = (byFirstSyllable.get(HUB_SYLLABLE) ?? []).length;
@@ -66,5 +66,3 @@ if (hubContinuations < MIN_OPENING_OUT_DEGREE) {
`the server needs ${MIN_OPENING_OUT_DEGREE} before it will open a game on that syllable`
);
}
export { words };
-1
View File
@@ -6,7 +6,6 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"test": "vite build && vitest run",
-1
View File
@@ -30,7 +30,6 @@
--radius: 12px;
--radius-sm: 8px;
--gap: 12px;
--font: system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color-scheme: light;
-3
View File
@@ -34,12 +34,10 @@ export const t = {
you: 'Bạn',
opponent: 'Đối thủ',
someone: 'Người chơi',
scoreLabel: 'Điểm',
syllableUnit: 'tiếng',
correctedFrom: 'Bạn gõ “{typed}”, từ đúng là “{word}”.',
secondsLeft: '{n} giây',
waiting: 'Đang chờ…',
connecting: 'Đang kết nối…',
connected: 'Đã kết nối',
reconnecting: 'Mất kết nối, đang thử lại…',
@@ -77,7 +75,6 @@ export const t = {
copyLink: 'Sao chép liên kết mời',
shareLink: 'Chia sẻ',
copied: 'Đã sao chép',
waitingForOpponent: 'Đang chờ người chơi vào phòng…',
leaveRoom: 'Rời phòng',
shareInvite: 'Vào chơi nối từ với tôi',
-3
View File
@@ -492,9 +492,6 @@ export function createGameStore() {
);
},
clearRejection() {
state.rejection = null;
},
clearError() {
state.error = null;
},
+1 -1
View File
@@ -39,7 +39,7 @@ export const LIVENESS_TIMEOUT_MS = 3 * PING_INTERVAL_MS;
* client reconnects every half second forever after a deploy that bumps the
* protocol.
*/
export const TERMINAL_ERROR_CODES = new Set(['protocol_version_mismatch']);
const TERMINAL_ERROR_CODES = new Set(['protocol_version_mismatch']);
const RESUME_KEY = 'noitu.resumeToken';
+14 -5
View File
@@ -1,10 +1,12 @@
// The upstream dictionary URL lives in two places: the Makefile, which builds
// it for a developer, and the Dockerfile, which builds it for the image. They
// have to agree, or the container ships a wordlist nobody tested against.
// The upstream dictionary URL lives in three places: the Makefile, which
// builds it for a developer, the Dockerfile, which builds it for the image, and
// the builder, which stamps it into the database. They have to agree, or the
// container ships a wordlist nobody tested against. The docs that quote the
// URL are held to the same copy.
//
// This lives in the JavaScript suite for no better reason than that it is the
// suite that already reads other files in the repository. It is checking two
// build files, not the frontend.
// suite that already reads other files in the repository. It is checking build
// files and docs, not the frontend.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
@@ -49,4 +51,11 @@ describe('the upstream dictionary export', () => {
const builderUrl = pin(builder, /kaikkiSourceURL\s*=\s*"([^"]+)"/, 'kaikkiSourceURL in the builder');
expect(builderUrl).toBe(makeUrl);
});
it('is the URL the docs quote', () => {
for (const rel of ['../../README.md', '../../data/ATTRIBUTION.md']) {
const doc = readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8');
expect(doc, `${rel} does not quote DICT_URL`).toContain(makeUrl);
}
});
});