docs: scaffold the Integration Kit — front page, outline, and the checks #1
45
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
45
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: Something here is wrong
|
||||||
|
about: A chapter is inaccurate, an example does not work, or the template does not build
|
||||||
|
title: "[bug] "
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is wrong
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Which file, and which part of it. A chapter that no longer matches the
|
||||||
|
contract, an example that fails, a template that will not build, a dead link.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What happened
|
||||||
|
|
||||||
|
<!-- Exact commands and their output. Paste errors verbatim. -->
|
||||||
|
|
||||||
|
## What you expected
|
||||||
|
|
||||||
|
<!-- What the kit led you to believe would happen. -->
|
||||||
|
|
||||||
|
## Where you were
|
||||||
|
|
||||||
|
- File / chapter:
|
||||||
|
- Kit commit:
|
||||||
|
- Core version you built against (`MODULE_API_VERSION`), if known:
|
||||||
|
- Node version:
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
|
||||||
|
<!-- Anything else. Redact secrets and tokens. -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Two things that are NOT bugs here, and where they go instead:
|
||||||
|
|
||||||
|
* A rule you disagree with. This kit teaches the contract and never defines
|
||||||
|
it — open that against MODULE_API.md in RunicGateway/docs.
|
||||||
|
* A core defect you hit while following along. That belongs in
|
||||||
|
RunicGateway/website.
|
||||||
|
|
||||||
|
Security issue? Do NOT file it here. See SECURITY.md and email
|
||||||
|
whitlocktech@gmail.com instead.
|
||||||
|
-->
|
||||||
8
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
8
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/src/branch/main/SECURITY.md
|
||||||
|
about: Please do not open a public issue for security problems — report them privately by email instead (see SECURITY.md).
|
||||||
|
- name: The module contract itself
|
||||||
|
url: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
|
about: This repo teaches the contract but never defines it. If a rule looks wrong rather than badly explained, it belongs against MODULE_API.md in the docs repo.
|
||||||
39
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
39
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: Something is missing
|
||||||
|
about: A question the kit left you unable to answer
|
||||||
|
title: "[gap] "
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you were trying to do
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The concrete thing. "I am building a module for <game> and I could not work out
|
||||||
|
how to …" is far more useful than "the docs should cover X".
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Where you got stuck
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Which chapter you were in when you ran out of information, and what you tried
|
||||||
|
next — searching core's source, guessing, giving up. The place a reader leaves
|
||||||
|
the kit is the most valuable thing you can tell us.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What you did in the end
|
||||||
|
|
||||||
|
<!-- If you solved it, how? That answer probably belongs in the kit. -->
|
||||||
|
|
||||||
|
## Is it a gap in the kit, or in the contract?
|
||||||
|
|
||||||
|
- [ ] The contract can already do this; the kit does not explain how.
|
||||||
|
- [ ] The contract cannot do this at all.
|
||||||
|
- [ ] Not sure.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
If it is the contract, the kit cannot fix it — a module cannot register an
|
||||||
|
identity provider, for example, and that is recorded as a known boundary in
|
||||||
|
docs/modules/rust-dryrun.md. Say so anyway: a boundary a second reader hits is
|
||||||
|
evidence for changing MODULE_API.md.
|
||||||
|
-->
|
||||||
33
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
33
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!--
|
||||||
|
Thanks for contributing to Runic Gateway!
|
||||||
|
Please fill out the sections below and check every box before requesting review.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What & why
|
||||||
|
|
||||||
|
<!-- What does this PR change, and why? Link any related issue: "Closes #123". -->
|
||||||
|
|
||||||
|
## How it was tested
|
||||||
|
|
||||||
|
<!-- Commands you ran, manual steps, screenshots. -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
- [ ] The change builds and existing tests/checks pass locally.
|
||||||
|
- [ ] I have added or updated tests/docs where it makes sense.
|
||||||
|
- [ ] My commits are reasonably scoped with clear messages.
|
||||||
|
|
||||||
|
## AI-assisted contributions (required)
|
||||||
|
|
||||||
|
This project **requires disclosure of AI tool usage**. Please pick one:
|
||||||
|
|
||||||
|
- [ ] No AI tools were used to produce this contribution.
|
||||||
|
- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand
|
||||||
|
every change, and take responsibility for it. AI-authored commits are
|
||||||
|
marked with a `Co-Authored-By` / `Assisted-By` trailer.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
- [ ] I agree that my contribution is licensed under this project's license
|
||||||
|
(**GNU GPL v3.0 or later**), and I have the right to contribute it.
|
||||||
138
.gitea/workflows/pr-checks.yml
Normal file
138
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Gate every pull request into `main`. This repo is documentation plus a template
|
||||||
|
# module, so the checks are about whether the documentation is still TRUE rather
|
||||||
|
# than whether software works.
|
||||||
|
#
|
||||||
|
# ── What each job is really asking ───────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# • `links` — every relative link resolves, and no link pins a reader to a
|
||||||
|
# commit snapshot of a document that moves. Nothing is fetched: this project's
|
||||||
|
# Gitea is self-hosted, so an HTTP check would fail on a runner without
|
||||||
|
# credentials and teach everyone to ignore red. What breaks in practice is a
|
||||||
|
# relative path after a file moves, and that is answerable offline.
|
||||||
|
#
|
||||||
|
# • `template` — the interesting one, and the anti-rot mechanism of the whole
|
||||||
|
# repo (MODULE_SYSTEM.md §2.11.1 d2). It clones CORE at the ref pinned in
|
||||||
|
# `ci/core-ref.json` and asks three things:
|
||||||
|
#
|
||||||
|
# 1. does `template/module.json`'s `coreApi` still EQUAL that core's
|
||||||
|
# `MODULE_API_VERSION`? Equality, not "satisfies" — a range check would
|
||||||
|
# stay green across a contract bump, and green would then mean "the
|
||||||
|
# template still loads" when we need it to mean "someone has re-read the
|
||||||
|
# book since the contract changed". This failing is the system working.
|
||||||
|
# 2. does the template still build? A kit whose examples do not compile is
|
||||||
|
# worse than no kit, because the reader trusts it first.
|
||||||
|
# 3. do the template's own boundary guards still pass? They are the same
|
||||||
|
# checks a real module ships (MODULE_API.md §5.1, §3.6), and the template
|
||||||
|
# is what teaches a newcomer that they exist.
|
||||||
|
#
|
||||||
|
# ── The guard, and why the template job can report green with no template ────
|
||||||
|
#
|
||||||
|
# Slice 0 is this scaffold; the template lands in slice 1. Rather than leave the
|
||||||
|
# repo ungated in between, or land a workflow that red-Xes every docs PR until
|
||||||
|
# there is something to build, the template steps are conditional on
|
||||||
|
# `template/module.json` existing. Before it lands the job prints why it did
|
||||||
|
# nothing; the moment the file appears the job arms itself with no edit here.
|
||||||
|
# Same guard Module-uo#1 used through its own planning phase.
|
||||||
|
#
|
||||||
|
# Enforcement (one-time, in the Gitea UI):
|
||||||
|
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||||
|
# • Enable Status Check
|
||||||
|
# • Status check patterns: PR Checks / *
|
||||||
|
# Gitea only lists a context in its dropdown after it has reported once, so let
|
||||||
|
# this run on one PR first. The glob keeps matching as jobs are added.
|
||||||
|
#
|
||||||
|
# Runner: the shared self-hosted `ubuntu-latest` runner. Node only — no database,
|
||||||
|
# no Docker socket.
|
||||||
|
|
||||||
|
name: PR Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-checks-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_FETCH_RETRIES: 5
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
links:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
# No dependencies on purpose — this has to run on a clone with nothing
|
||||||
|
# installed, which is also how a reader will run it.
|
||||||
|
- name: Check every link in the book
|
||||||
|
run: node scripts/checkLinks.js
|
||||||
|
|
||||||
|
template:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Is there a template yet?
|
||||||
|
id: guard
|
||||||
|
run: |
|
||||||
|
if [ -f template/module.json ]; then
|
||||||
|
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No template/module.json — the template lands in Phase 5 slice 1."
|
||||||
|
echo "The steps below are skipped until it does; see this file's header."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a
|
||||||
|
# commit sha, and `--depth 1` can only fetch a branch tip.
|
||||||
|
- name: Clone core at the pinned ref
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
run: |
|
||||||
|
REPO=$(node -p "require('./ci/core-ref.json').repo")
|
||||||
|
REF=$(node -p "require('./ci/core-ref.json').ref")
|
||||||
|
echo "core: $REPO @ $REF"
|
||||||
|
git clone --quiet "$REPO" .core
|
||||||
|
git -C .core checkout --quiet "$REF"
|
||||||
|
|
||||||
|
- name: Is the kit still written against this core? (MODULE_SYSTEM.md §2.11.1 d2)
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
run: node scripts/checkCoreApi.js --core .core
|
||||||
|
|
||||||
|
- name: Install the template's deps
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
run: |
|
||||||
|
npm ci --prefix template/server
|
||||||
|
npm ci --prefix template/client
|
||||||
|
|
||||||
|
- name: Check the template's module boundary (MODULE_API.md §5.1)
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
run: npm run check:imports --prefix template/server
|
||||||
|
|
||||||
|
# The build comes before the externals check because that check reads the
|
||||||
|
# BUILT chunk: whether `import { useState } from 'react'` became core's React
|
||||||
|
# or a bare specifier no browser can resolve is decided by vite.config.js, and
|
||||||
|
# is invisible in source.
|
||||||
|
- name: Build the template's client chunk
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
run: npm run build --prefix template/client
|
||||||
|
|
||||||
|
- name: Check the built chunk's externals (MODULE_API.md §3.6)
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
run: npm run check:externals --prefix template/client
|
||||||
|
|
||||||
|
- name: Run the template's tests
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
run: npm test --prefix template/server
|
||||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
template/server/node_modules/
|
||||||
|
template/client/node_modules/
|
||||||
|
|
||||||
|
# The template's built client chunk. A module ships this PREBUILT — an operator
|
||||||
|
# never builds anything — but it is a release artifact, not a source one, and a
|
||||||
|
# reader who clones this repo should build it themselves once. Committing it would
|
||||||
|
# also mean a stale chunk sitting next to fresh template source, which is exactly
|
||||||
|
# the failure mode the chapter warns about.
|
||||||
|
template/client/dist/
|
||||||
|
|
||||||
|
# A checkout of core, if you clone one in here to develop against. CI puts it in
|
||||||
|
# .core; do not commit either.
|
||||||
|
.core/
|
||||||
|
core/
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# env / secrets
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# release staging
|
||||||
|
/dist/
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# logs / os
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# editor / tooling local settings
|
||||||
|
.claude/settings.local.json
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# local planning docs (not part of the tracked repo)
|
||||||
|
.plans/
|
||||||
135
CONTRIBUTING.md
Normal file
135
CONTRIBUTING.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Contributing to Runic Gateway — Integration Kit
|
||||||
|
|
||||||
|
Thanks for your interest! This repo is the **Integration Kit**: the instruction
|
||||||
|
book for putting a game that is not Ultima Online onto a Runic Gateway site, plus
|
||||||
|
a `template/` module that builds.
|
||||||
|
|
||||||
|
It is the only repo in this project written for someone **outside** the
|
||||||
|
organisation, and that changes what a contribution looks like here. Elsewhere the
|
||||||
|
docs record decisions; here they teach a stranger. The most valuable contribution
|
||||||
|
is not a correction — it is a report of the place you got stuck and what you did
|
||||||
|
next.
|
||||||
|
|
||||||
|
By participating you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
## Status: draft
|
||||||
|
|
||||||
|
The kit's acceptance test is that **someone outside this project builds a working
|
||||||
|
module for a new game by following it alone, without reading core's source.** Until
|
||||||
|
that has happened it is a draft however finished it looks, and the README says so
|
||||||
|
on the front page. Do not remove that banner in a PR that is not the one recording
|
||||||
|
a passed acceptance run.
|
||||||
|
|
||||||
|
## The rule that governs every change here
|
||||||
|
|
||||||
|
**The kit never re-specifies a contract.** These are normative; the kit teaches and
|
||||||
|
links out:
|
||||||
|
|
||||||
|
- [`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||||
|
— everything a module may do.
|
||||||
|
- [`MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md)
|
||||||
|
— why the system is shaped this way, and the install model.
|
||||||
|
- [`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||||
|
and [`INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
||||||
|
— the game↔sidecar wire protocol.
|
||||||
|
|
||||||
|
So: **do not copy a member list, a field table or a set of rules into a chapter.**
|
||||||
|
Quote the part you are explaining, with a link, and let the reader go to the
|
||||||
|
authority for the whole. A guide that restates a contract diverges from it
|
||||||
|
silently, and the reader who follows the divergent copy gets a module that fails
|
||||||
|
validation for reasons the guide cannot explain.
|
||||||
|
|
||||||
|
If a rule looks *wrong* rather than badly explained, that is a docs-repo PR against
|
||||||
|
`MODULE_API.md`, not a change here. The kit does not get to disagree with the
|
||||||
|
contract in prose.
|
||||||
|
|
||||||
|
## Ways to contribute
|
||||||
|
|
||||||
|
- **Report where the kit failed you** through the
|
||||||
|
[issue tracker](https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues)
|
||||||
|
— templates provided. "I could not work out how to X" is a bug in this repo.
|
||||||
|
- **Fix or extend a chapter**, or the template, with a pull request.
|
||||||
|
- **Never** report a security vulnerability in a public issue — see
|
||||||
|
[SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## Working on this repo
|
||||||
|
|
||||||
|
**Prerequisites:** Node 20+, and a checkout of
|
||||||
|
[`RunicGateway/website`](https://gitea.whitlocktech.com/RunicGateway/website) if you
|
||||||
|
are touching `template/` — a module is not runnable on its own.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/checkLinks.js # every relative link resolves; no commit permalinks
|
||||||
|
```
|
||||||
|
|
||||||
|
The template's own checks live under `template/` and are described in the chapter
|
||||||
|
that introduces it.
|
||||||
|
|
||||||
|
### What CI checks, and why it is shaped that way
|
||||||
|
|
||||||
|
`.gitea/workflows/pr-checks.yml` clones core at a **pinned commit** and then:
|
||||||
|
|
||||||
|
1. asserts the version `template/module.json` declares still **equals** that core's
|
||||||
|
`MODULE_API_VERSION`;
|
||||||
|
2. builds the template and runs its boundary guards;
|
||||||
|
3. runs the link check over every markdown file.
|
||||||
|
|
||||||
|
Check 1 is the anti-rot mechanism. A contract bump in core changes that number, this
|
||||||
|
repo goes red, and someone has to look at whether a chapter has become untrue — which
|
||||||
|
is the half a machine cannot check. That is also why a `MODULE_API_VERSION` bump in
|
||||||
|
core carries an obligation to pass over this kit in the same PR
|
||||||
|
([`MODULE_SYSTEM.md` §2.10](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md)).
|
||||||
|
|
||||||
|
**The pin points at a commit on core's `edge` branch, not `main`.** The module system
|
||||||
|
has not cut over yet; `main` has no `server/src/modules/` at all. Moving the pin is a
|
||||||
|
deliberate, reviewed change — it is the moment someone re-reads the chapters — so it
|
||||||
|
is a line in the workflow rather than a floating branch name.
|
||||||
|
|
||||||
|
### Prose conventions
|
||||||
|
|
||||||
|
- **Second person, present tense.** "You register a route", not "the module author
|
||||||
|
registers a route".
|
||||||
|
- **Say why before how.** Every rule in this project exists because something broke;
|
||||||
|
a rule without its reason is one the reader will route around.
|
||||||
|
- **Examples are quotations.** Code blocks come from `template/` or from a real repo,
|
||||||
|
with a path next to them. An invented example is one nobody has compiled.
|
||||||
|
- **Own the mistakes.** Where this project got something wrong and paid for it, the
|
||||||
|
kit says so plainly. That is the part a reader cannot get anywhere else.
|
||||||
|
- One sentence per line is not required, but keep lines under ~100 characters.
|
||||||
|
|
||||||
|
### Commit messages
|
||||||
|
|
||||||
|
[Conventional Commits](https://www.conventionalcommits.org/) — `type(scope): summary`.
|
||||||
|
`docs:` for chapters, `feat:`/`fix:` for the template, `ci:` for workflows.
|
||||||
|
|
||||||
|
## Branch & PR workflow
|
||||||
|
|
||||||
|
1. Branch from `main` (`feature/…`, `fix/…`, `docs/…`, `chore/…`).
|
||||||
|
2. Keep it focused.
|
||||||
|
3. Open a pull request against `main` and fill in the template, including the
|
||||||
|
**AI-assisted contributions** disclosure.
|
||||||
|
4. A maintainer reviews; address feedback with follow-up commits.
|
||||||
|
|
||||||
|
This repo develops on its own `main` from its first commit — no `edge`, no cutover.
|
||||||
|
|
||||||
|
## AI-assisted contributions (disclosure required)
|
||||||
|
|
||||||
|
This project is developed openly with AI assistance, and we ask the same
|
||||||
|
transparency of everyone. **If you used an AI tool** (Claude, Copilot, ChatGPT,
|
||||||
|
Cursor, etc.) to help produce a contribution, you must disclose it:
|
||||||
|
|
||||||
|
- Tick the AI-usage box in the pull-request template and name the tool(s).
|
||||||
|
- Mark AI-authored commits with a trailer, e.g.
|
||||||
|
`Co-Authored-By: Claude <noreply@anthropic.com>` or `Assisted-By: <tool>`.
|
||||||
|
- You remain responsible for every line you submit: review it, understand it, and
|
||||||
|
make sure it is correct and that you have the right to contribute it.
|
||||||
|
|
||||||
|
Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are not,
|
||||||
|
and may be closed.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Runic Gateway is licensed under the **GNU General Public License v3.0 or later**
|
||||||
|
(see [LICENSE.md](LICENSE.md)). By submitting a contribution you agree that it is
|
||||||
|
licensed under the same terms (inbound = outbound) and that you have the right to
|
||||||
|
contribute it.
|
||||||
102
README.md
Normal file
102
README.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Runic Gateway — Integration Kit
|
||||||
|
|
||||||
|
**How to put a game on a Runic Gateway site.**
|
||||||
|
|
||||||
|
Runic Gateway is a website platform for game communities. Core knows nothing about
|
||||||
|
any particular game: everything game-specific — routes, tables, pages, navigation,
|
||||||
|
notifications — arrives as an installable **module**, and an operator installs one
|
||||||
|
from an admin panel without building anything. [`module-uo`][module-uo] is the
|
||||||
|
first module and serves an *Ultima Online* shard. This kit is how you write the
|
||||||
|
second one.
|
||||||
|
|
||||||
|
> ### 🚧 This is a draft
|
||||||
|
>
|
||||||
|
> The kit is finished when **someone outside this project builds a working module
|
||||||
|
> for a new game by following it alone, without reading core's source.** That has
|
||||||
|
> not happened yet, so treat every chapter as untested on you. If you are that
|
||||||
|
> person: the places you get stuck are the most valuable thing this repo can
|
||||||
|
> receive — [tell us][issues], and please say where you left the kit and what you
|
||||||
|
> did next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you are building
|
||||||
|
|
||||||
|
Three things, and the kit is one book rather than a page in three repos because
|
||||||
|
the reasons live in the joins between them:
|
||||||
|
|
||||||
|
| # | Part | What it is |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | **The website module** | A bundle core loads at boot: server routes, a schema fragment, a prebuilt client chunk, navigation. The bulk of the work, and the only part every module needs. |
|
||||||
|
| 2 | **The sidecar** | A small service that owns the connection to your game server, and owns the durable copy of what the game said. **Not optional** — see below. |
|
||||||
|
| 3 | **The game-side plugin** | Whatever runs inside your game and feeds the sidecar, without ever letting the sidecar stall the game. |
|
||||||
|
|
||||||
|
```
|
||||||
|
your game server ──dials out──▶ your sidecar ──HTTP + WS──▶ website core
|
||||||
|
(plugin: bounded (owns the socket, (loads your module,
|
||||||
|
queue, writer thread) persists to its own serves the pages)
|
||||||
|
store, then forwards)
|
||||||
|
```
|
||||||
|
|
||||||
|
**The website process never opens a connection to a game server.** That is a rule
|
||||||
|
in the contract ([`MODULE_API.md`][api] §2.7, `MODULE_API_VERSION` 1.4.0), not a
|
||||||
|
style preference, and chapter 3 is mostly about why. The short version: the
|
||||||
|
website is the internet-facing process and your game is not; the sidecar persists
|
||||||
|
before it forwards, so a website that is down or mid-deploy loses nothing; and a
|
||||||
|
game must never block on a web request. A game that already exposes a
|
||||||
|
remote-control surface — Rust's RCON over WebSocket, say — needs a *thin* sidecar,
|
||||||
|
not none.
|
||||||
|
|
||||||
|
## Start here
|
||||||
|
|
||||||
|
1. **[The dry run][dryrun]** — a complete module designed on paper for a second
|
||||||
|
game, *Rust*, chosen for how little it shares with Ultima Online. Read it first.
|
||||||
|
It is the shortest honest picture of the whole job, and it names the one thing
|
||||||
|
the contract cannot do yet.
|
||||||
|
2. **`template/`** — a module that builds and loads, doing almost nothing. Copy it,
|
||||||
|
rename it, and you have a running module before you have read a chapter.
|
||||||
|
3. **The book** — [`book/`](book/), four chapters, in the order the work happens.
|
||||||
|
|
||||||
|
## The one rule this kit follows
|
||||||
|
|
||||||
|
**It never re-specifies a contract.** These documents are normative, and where the
|
||||||
|
kit and one of them disagree, they win and the kit has a bug:
|
||||||
|
|
||||||
|
| Authority | For |
|
||||||
|
| --- | --- |
|
||||||
|
| [`MODULE_API.md`][api] | Everything a module may do: `module.json`, `ctx`, the `register*` calls, the client registry, the UI kit, schema-fragment rules, the loader's obligations. |
|
||||||
|
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
||||||
|
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The shard↔sidecar wire protocol, as one real sidecar implements it. |
|
||||||
|
|
||||||
|
The kit *teaches*: the order to do things in, the reasoning, worked examples, and
|
||||||
|
the mistakes that cost this project time. Where it must show a member list it
|
||||||
|
quotes with a pointer rather than copying, because a guide that restates a
|
||||||
|
contract diverges from it silently — and a reader who follows the divergent copy
|
||||||
|
gets a module that fails validation for reasons the guide cannot explain.
|
||||||
|
|
||||||
|
## What this repo contains
|
||||||
|
|
||||||
|
```
|
||||||
|
book/ the chapters
|
||||||
|
template/ a module that builds — copy this
|
||||||
|
scripts/ the checks CI runs over both
|
||||||
|
```
|
||||||
|
|
||||||
|
CI clones core at a **pinned commit**, asserts the version the template declares
|
||||||
|
still matches that core's `MODULE_API_VERSION`, builds the template, and checks
|
||||||
|
every link in the book. So a change to the contract breaks this repo's build
|
||||||
|
loudly instead of leaving a chapter quietly wrong.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-3.0-or-later, like every Runic Gateway repo — see [LICENSE.md](LICENSE.md).
|
||||||
|
The `template/` directory is meant to be copied and made yours; it carries the
|
||||||
|
same licence, and so does anything derived from it.
|
||||||
|
|
||||||
|
[module-uo]: https://gitea.whitlocktech.com/RunicGateway/Module-uo
|
||||||
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
|
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
||||||
|
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||||
|
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||||
|
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||||
|
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
|
||||||
76
SECURITY.md
Normal file
76
SECURITY.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
Thank you for helping keep Runic Gateway and its users safe.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Please do not report security vulnerabilities through public issues, pull
|
||||||
|
requests, or the wiki.** A public report tips off attackers before a fix is
|
||||||
|
available.
|
||||||
|
|
||||||
|
Instead, report privately by email to:
|
||||||
|
|
||||||
|
**whitlocktech@gmail.com**
|
||||||
|
|
||||||
|
Please include as much of the following as you can:
|
||||||
|
|
||||||
|
- The repository and component affected.
|
||||||
|
- The type of issue (e.g. authentication bypass, injection, secret exposure,
|
||||||
|
remote code execution, denial of service).
|
||||||
|
- Step-by-step instructions to reproduce, and a proof-of-concept if you have one.
|
||||||
|
- The impact — what an attacker could do with it.
|
||||||
|
- Any suggested remediation.
|
||||||
|
|
||||||
|
You will receive an acknowledgement of your report, typically within a few days. We
|
||||||
|
will keep you informed as we investigate and work toward a fix, and we are happy to
|
||||||
|
credit you in the release notes once the issue is resolved (let us know if you would
|
||||||
|
prefer to remain anonymous).
|
||||||
|
|
||||||
|
## What this repo is, for scoping purposes
|
||||||
|
|
||||||
|
This repo is **documentation plus a template module**. It runs nothing, listens on
|
||||||
|
nothing, and stores no data. Two kinds of report are still in scope here, and both
|
||||||
|
are worth sending:
|
||||||
|
|
||||||
|
- **The template teaches an insecure pattern.** It is meant to be copied, so a
|
||||||
|
weakness in it propagates into every module written from it — an unparameterised
|
||||||
|
query, a route missing an authorisation check, a secret handled in the clear, a
|
||||||
|
permissive CORS or CSP suggestion. Treat the template as production code that has
|
||||||
|
not been deployed yet.
|
||||||
|
- **A chapter teaches something dangerous.** Advice that would lead a reader to
|
||||||
|
expose their game server to the internet, hold a secret unencrypted, bypass core's
|
||||||
|
authorisation middleware, or weaken session handling is a security issue in this
|
||||||
|
repo even though no code here does it.
|
||||||
|
|
||||||
|
A defect in core, a module or the sidecar itself belongs to that repo:
|
||||||
|
[`website`](https://gitea.whitlocktech.com/RunicGateway/website),
|
||||||
|
[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo),
|
||||||
|
[`link`](https://gitea.whitlocktech.com/RunicGateway/link).
|
||||||
|
|
||||||
|
## Three things that are policy, not oversight
|
||||||
|
|
||||||
|
A module author reading this kit should know these up front, because they shape what
|
||||||
|
counts as a vulnerability anywhere in this project:
|
||||||
|
|
||||||
|
- **The module boundary is not a security boundary.** A module runs in the same Node
|
||||||
|
process as core, with the same privileges, against the same database. It is a
|
||||||
|
code-organisation and distribution boundary. Installing a module is the same trust
|
||||||
|
decision as installing the site — which is why installation is admin-only. "A
|
||||||
|
module could reach core's internals" is not a vulnerability report; "an
|
||||||
|
unprivileged user can install or enable a module" very much is.
|
||||||
|
- **Access control lives in core.** Route protection is core's middleware, and what
|
||||||
|
a visitor may see of live game state is the website's admin-toggleable visibility
|
||||||
|
framework. A module route that reaches game data without going through those is a
|
||||||
|
security bug. A sidecar that makes its own access-control decisions is a design
|
||||||
|
error — it is a forwarder.
|
||||||
|
- **The website process never connects to a game server.** The game is not
|
||||||
|
network-reachable; it dials out to a sidecar, and only the website's backend talks
|
||||||
|
to that sidecar. This is a rule in the module contract
|
||||||
|
([`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||||
|
§2.7), and a chapter or template that leads someone to break it is the kind of
|
||||||
|
report this repo most wants.
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
This project is developed continuously and does not maintain long-term release
|
||||||
|
branches. Fixes land on `main`; please read a recent copy.
|
||||||
99
book/README.md
Normal file
99
book/README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# The book
|
||||||
|
|
||||||
|
Four chapters, in the order the work happens. **None of them are written yet** —
|
||||||
|
this is the outline, landed first so the shape can be argued with before the prose
|
||||||
|
exists. Chapter status is in the table; a chapter that is not there yet is not
|
||||||
|
there yet, rather than a stub that reads like an answer.
|
||||||
|
|
||||||
|
Read [the dry run][dryrun] before any of them.
|
||||||
|
|
||||||
|
| # | Chapter | File | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | Your first module in twenty minutes | `01-first-module.md` | not written |
|
||||||
|
| 2 | The website module | `02-website-module.md` | not written |
|
||||||
|
| 3 | The sidecar | `03-sidecar.md` | not written |
|
||||||
|
| 4 | The game-side plugin | `04-game-plugin.md` | not written |
|
||||||
|
|
||||||
|
They are named but not linked on purpose: a link to a file that does not exist is
|
||||||
|
the thing this repo's link check is for, and an outline should not be the first
|
||||||
|
thing to fail it.
|
||||||
|
|
||||||
|
## 1. Your first module in twenty minutes
|
||||||
|
|
||||||
|
Copy `template/`, rename it, build it, install it, see a page. No theory. The point
|
||||||
|
is to reach a working module before learning anything, so that everything after it
|
||||||
|
is a change to something that already runs rather than a step toward something that
|
||||||
|
might.
|
||||||
|
|
||||||
|
- What the pieces of `template/` are, one paragraph each.
|
||||||
|
- `module.json`: the fields you must change, and `coreApi`.
|
||||||
|
- Building the client chunk. Why a module ships **prebuilt** and an operator never
|
||||||
|
builds anything.
|
||||||
|
- Installing it: the admin panel, the `MODULES` environment variable, or a directory
|
||||||
|
on the volume.
|
||||||
|
- Reading the state your module lands in, and the four ways it can fail to load.
|
||||||
|
|
||||||
|
## 2. The website module
|
||||||
|
|
||||||
|
The bulk of the kit.
|
||||||
|
|
||||||
|
- **`module.json`** — every field, and which are load-bearing at boot.
|
||||||
|
- **The server entry point.** `register(ctx, api)`; what `ctx` hands you and why
|
||||||
|
each member is handed rather than imported; the lazy-accessor pattern that lets a
|
||||||
|
ported file keep a file-scope `require`, and the require-order rule that comes
|
||||||
|
with it.
|
||||||
|
- **The `register*` calls** — routes per tier, notification streams, announce legs,
|
||||||
|
post hooks, extension slots. Worked examples of each, with the distinctions that
|
||||||
|
are easy to get wrong (a leg is one-shot delivery with retry; a post hook is
|
||||||
|
idempotent state that also runs on delete).
|
||||||
|
- **The schema fragment.** Idempotent, replayed every boot, leading-verb allowlist,
|
||||||
|
the table-prefix rule, and why there is no migration runner anywhere in this
|
||||||
|
project. What belongs in `purge.sql` instead.
|
||||||
|
- **The client half.** The prebuilt ESM chunk; `window.__rg`; the shared-dependency
|
||||||
|
rule (core owns React and hands it over — a module that resolves its own gets two
|
||||||
|
Reacts and a broken page); the Vite library build with anchored aliases and
|
||||||
|
`external: []`, and *why* that combination rather than the obvious one.
|
||||||
|
- **Routes, nav and features on the client**, and how a module's nav row becomes an
|
||||||
|
ordinary row an operator can reorder, relabel or hide.
|
||||||
|
- **The UI kit** — seven members, closed on purpose. What to do about the eighth
|
||||||
|
thing you want.
|
||||||
|
- **The OpenAPI fragment**, and how to generate it from your own registrations.
|
||||||
|
- **Packaging and release CI**: the tarball, the install manifest, the checksum,
|
||||||
|
and the version living in `module.json`.
|
||||||
|
- **Boundaries.** What a module must not do, each with the failure it prevents.
|
||||||
|
|
||||||
|
## 3. The sidecar
|
||||||
|
|
||||||
|
Why it exists, why it is **not optional**, and what "thin" means for a game that
|
||||||
|
already speaks a remote-control protocol.
|
||||||
|
|
||||||
|
- The invariant: your game is never network-reachable; it **dials out**, the
|
||||||
|
sidecar listens, and only the website's backend talks to the sidecar.
|
||||||
|
- **Persist before you forward.** The sidecar owns the durable copy — event
|
||||||
|
history, the latest snapshot of every board, whatever a page must still be able
|
||||||
|
to render when the game or the website is down. A live feed is allowed to be
|
||||||
|
lossy *because* the store is not.
|
||||||
|
- The wire as a **versioned compatibility contract** rather than a build
|
||||||
|
dependency: a version on every response, a mismatch refused rather than
|
||||||
|
mis-parsed, and what a bump obliges you to change in the same commit.
|
||||||
|
- Auth, and why the sidecar is the only exposed part.
|
||||||
|
- `uo-link` as the worked example, and what a *thin* sidecar for an RCON-style game
|
||||||
|
keeps and drops.
|
||||||
|
|
||||||
|
## 4. The game-side plugin
|
||||||
|
|
||||||
|
The chapter with the least code and the highest stakes: a plugin that gets this
|
||||||
|
wrong takes the game down when the sidecar wedges.
|
||||||
|
|
||||||
|
- **Never block the game thread.** Enqueue and return; a bounded, drop-oldest queue;
|
||||||
|
a dedicated writer thread that drains it. Dropping the oldest event is correct,
|
||||||
|
and stalling the game to avoid it is not.
|
||||||
|
- **Read the world only on the game's own thread**, and hand plain data to the
|
||||||
|
writer.
|
||||||
|
- Reconnect, backoff, and what to send on connect so the sidecar can rebuild its
|
||||||
|
picture without asking.
|
||||||
|
- What to emit at all: the difference between an event stream and a state snapshot,
|
||||||
|
and why both exist.
|
||||||
|
- `servuo-plugins` as the worked example. The constraints are general; the C# is not.
|
||||||
|
|
||||||
|
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||||
20
ci/core-ref.json
Normal file
20
ci/core-ref.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
|
"branch": "edge",
|
||||||
|
"ref": "c3120ea3daee579ea9948e9e466065f25ee4e92f",
|
||||||
|
"why": [
|
||||||
|
"The core this kit is written against, pinned to a commit rather than a branch.",
|
||||||
|
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
|
||||||
|
"version template/module.json declares still equals this core's",
|
||||||
|
"MODULE_API_VERSION, so a contract bump turns this repo red on purpose",
|
||||||
|
"(MODULE_SYSTEM.md 2.11.1 d2, 2.10).",
|
||||||
|
"",
|
||||||
|
"The branch is `edge`, not `main`, and that is not a mistake: the module system",
|
||||||
|
"has not cut over yet and core's `main` has no server/src/modules/ at all",
|
||||||
|
"(MODULE_SYSTEM.md decision 11). This pin is one of the things that cutover has",
|
||||||
|
"to revisit.",
|
||||||
|
"",
|
||||||
|
"Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
|
||||||
|
"sha, reviewable in a diff."
|
||||||
|
]
|
||||||
|
}
|
||||||
80
scripts/checkCoreApi.js
Normal file
80
scripts/checkCoreApi.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// The kit declares exactly one contract version, in `template/module.json`'s
|
||||||
|
// `coreApi` — the same field a reader copies. This asserts it still names the
|
||||||
|
// version the pinned core actually exports.
|
||||||
|
//
|
||||||
|
// WHY EQUALITY AND NOT "SATISFIES": a range check is what CORE does at load time,
|
||||||
|
// and it is right there — a module built against 1.4.0 should keep loading into
|
||||||
|
// 1.5.0. It is the wrong question here. This kit's job is to be *current*: if core
|
||||||
|
// moved to 1.5.0, `^1.4.0` still satisfies, the build stays green, and nobody ever
|
||||||
|
// re-reads the chapters. Green would mean "the template still loads", when what we
|
||||||
|
// need it to mean is "someone has looked at this since the contract changed".
|
||||||
|
//
|
||||||
|
// So the failure is deliberate and expected on every core bump, and the fix is a
|
||||||
|
// human reading the book — not a version string.
|
||||||
|
//
|
||||||
|
// Usage: node scripts/checkCoreApi.js --core <path to a core checkout>
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
function arg(name) {
|
||||||
|
const i = process.argv.indexOf(name)
|
||||||
|
return i === -1 ? null : process.argv[i + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const corePath = arg('--core')
|
||||||
|
if (!corePath) {
|
||||||
|
console.error('usage: node scripts/checkCoreApi.js --core <path to a core checkout>')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestPath = path.join(ROOT, 'template', 'module.json')
|
||||||
|
if (!fs.existsSync(manifestPath)) {
|
||||||
|
// Slice 0 landed this check before the template it checks. Not an error: the
|
||||||
|
// workflow guards on the same file, and this message is what a local run says.
|
||||||
|
console.log('checkCoreApi: no template/module.json yet — nothing to check')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionFile = path.resolve(corePath, 'server/src/modules/version.js')
|
||||||
|
if (!fs.existsSync(versionFile)) {
|
||||||
|
console.error(`checkCoreApi: ${versionFile} does not exist.`)
|
||||||
|
console.error(' Either --core does not point at a website checkout, or the pin in')
|
||||||
|
console.error(' ci/core-ref.json names a ref with no module system in it (core `main`')
|
||||||
|
console.error(' has none until the cutover — see that file).')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core's version.js is a plain CommonJS module with no dependencies, so it can be
|
||||||
|
// required straight out of an uninstalled checkout.
|
||||||
|
const { MODULE_API_VERSION: core } = require(versionFile)
|
||||||
|
|
||||||
|
const declared = String(JSON.parse(fs.readFileSync(manifestPath, 'utf8')).coreApi || '')
|
||||||
|
// A `coreApi` is a RANGE (`^1.4.0`); the version it is built on is its base.
|
||||||
|
const base = declared.replace(/^[\^~>=<\s]+/, '').trim()
|
||||||
|
|
||||||
|
if (!base) {
|
||||||
|
console.error(`checkCoreApi: template/module.json declares no coreApi (got ${JSON.stringify(declared)})`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base !== core) {
|
||||||
|
console.error('checkCoreApi: the kit is written against a different core than it is pinned to.')
|
||||||
|
console.error('')
|
||||||
|
console.error(` template/module.json coreApi = ${declared} (base ${base})`)
|
||||||
|
console.error(` pinned core MODULE_API_VERSION = ${core}`)
|
||||||
|
console.error('')
|
||||||
|
console.error(' This is the anti-rot check firing, not a broken build. Someone has to:')
|
||||||
|
console.error(' 1. read MODULE_API.md §1.1 for what changed in the new version;')
|
||||||
|
console.error(' 2. read the book and the template for anything that is now untrue;')
|
||||||
|
console.error(' 3. update template/module.json and ci/core-ref.json together.')
|
||||||
|
console.error('')
|
||||||
|
console.error(' Bumping the two files without doing step 2 is the one way to make this')
|
||||||
|
console.error(' check worthless.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`checkCoreApi: coreApi ${declared} matches the pinned core's ${core} — OK`)
|
||||||
174
scripts/checkLinks.js
Normal file
174
scripts/checkLinks.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Every relative link in this repo's markdown must resolve to a file that exists,
|
||||||
|
// and every in-page anchor must match a heading in the file it points at.
|
||||||
|
//
|
||||||
|
// WHAT THIS DOES NOT DO: fetch anything. External URLs are not checked, on
|
||||||
|
// purpose. This kit points at a self-hosted Gitea, so an HTTP check would fail on
|
||||||
|
// a runner without credentials, flake when the host is busy, and teach us to
|
||||||
|
// ignore red. What breaks in practice is a relative path after a file moves, and
|
||||||
|
// that is answerable offline with certainty.
|
||||||
|
//
|
||||||
|
// It DOES check that every http(s) link into the RunicGateway host names a
|
||||||
|
// branch, because `.../src/branch/main/...` survives and `.../src/commit/<sha>/...`
|
||||||
|
// pins a reader to a snapshot of a document we want them reading the current
|
||||||
|
// version of.
|
||||||
|
//
|
||||||
|
// Usage: node scripts/checkLinks.js (repo root)
|
||||||
|
// node scripts/checkLinks.js --quiet
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..')
|
||||||
|
const QUIET = process.argv.includes('--quiet')
|
||||||
|
|
||||||
|
// Directories that hold no prose we own.
|
||||||
|
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
|
||||||
|
|
||||||
|
/** Every markdown file in the repo, repo-relative, sorted. */
|
||||||
|
function markdownFiles(dir = ROOT, out = []) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (SKIP_DIRS.has(entry.name)) continue
|
||||||
|
markdownFiles(path.join(dir, entry.name), out)
|
||||||
|
} else if (entry.name.toLowerCase().endsWith('.md')) {
|
||||||
|
out.push(path.relative(ROOT, path.join(dir, entry.name)).split(path.sep).join('/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fenced code blocks are stripped before links are read: a fence can legitimately
|
||||||
|
// contain a path that does not exist (a directory listing of a project the reader
|
||||||
|
// has not created yet), and flagging those would make the check useless in exactly
|
||||||
|
// the document type this repo is made of. Stripped by walking lines and toggling
|
||||||
|
// on a fence marker, rather than by regexp — a fence's own content can contain
|
||||||
|
// anything, including a line that looks like the end of one.
|
||||||
|
function stripFences(text) {
|
||||||
|
const out = []
|
||||||
|
let fence = null
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const m = /^\s*(```+|~~~+)/.exec(line)
|
||||||
|
if (fence) {
|
||||||
|
if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null
|
||||||
|
out.push('')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (m) {
|
||||||
|
fence = m[1]
|
||||||
|
out.push('')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.push(line)
|
||||||
|
}
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline `[text](target)` links and `[ref]: target` definitions, with line numbers. */
|
||||||
|
function linksIn(text) {
|
||||||
|
const found = []
|
||||||
|
const lines = stripFences(text).split(/\r?\n/)
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
// Skip inline code spans: `[a](b)` inside backticks is an example, not a link.
|
||||||
|
const bare = line.replace(/`[^`]*`/g, '')
|
||||||
|
for (const m of bare.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
|
||||||
|
found.push({ target: m[1], line: i + 1 })
|
||||||
|
}
|
||||||
|
const def = /^\s{0,3}\[[^\]]+\]:\s*(\S+)/.exec(bare)
|
||||||
|
if (def) found.push({ target: def[1], line: i + 1 })
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GitHub/Gitea-style heading slugs, for anchor checking. */
|
||||||
|
function anchorsIn(text) {
|
||||||
|
const slugs = new Set()
|
||||||
|
for (const line of stripFences(text).split(/\r?\n/)) {
|
||||||
|
const m = /^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/.exec(line)
|
||||||
|
if (!m) continue
|
||||||
|
const slug = m[1]
|
||||||
|
.replace(/`/g, '')
|
||||||
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/gu, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
if (slug) slugs.add(slug)
|
||||||
|
}
|
||||||
|
return slugs
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = markdownFiles()
|
||||||
|
const anchorCache = new Map()
|
||||||
|
function anchorsOf(relPath) {
|
||||||
|
if (!anchorCache.has(relPath)) {
|
||||||
|
anchorCache.set(relPath, anchorsIn(fs.readFileSync(path.join(ROOT, relPath), 'utf8')))
|
||||||
|
}
|
||||||
|
return anchorCache.get(relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = []
|
||||||
|
let checked = 0
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const text = fs.readFileSync(path.join(ROOT, file), 'utf8')
|
||||||
|
const selfAnchors = anchorsIn(text)
|
||||||
|
|
||||||
|
for (const { target, line } of linksIn(text)) {
|
||||||
|
const where = `${file}:${line}`
|
||||||
|
|
||||||
|
if (/^(mailto:|tel:)/i.test(target)) continue
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(target)) {
|
||||||
|
checked++
|
||||||
|
// Not fetched — but a permalink to a moving document is still wrong.
|
||||||
|
if (/gitea\.whitlocktech\.com\/.*\/src\/commit\//.test(target)) {
|
||||||
|
problems.push(`${where}: links to a commit snapshot, not a branch — ${target}`)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.startsWith('#')) {
|
||||||
|
checked++
|
||||||
|
const slug = decodeURIComponent(target.slice(1)).toLowerCase()
|
||||||
|
if (!selfAnchors.has(slug)) problems.push(`${where}: no heading matches ${target}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
checked++
|
||||||
|
const [rawPath, rawAnchor] = target.split('#')
|
||||||
|
// A PR/issue template's text is INLINED into a pull request or issue body, and
|
||||||
|
// Gitea resolves relative links in those against the repo root — not against
|
||||||
|
// `.gitea/`, where the file itself lives. So `[CONTRIBUTING.md](CONTRIBUTING.md)`
|
||||||
|
// is correct in a template and would be wrong anywhere else. Resolve those from
|
||||||
|
// the root, or this check reports every template link as broken and gets muted.
|
||||||
|
const base = file.startsWith('.gitea/') ? ROOT : path.dirname(path.join(ROOT, file))
|
||||||
|
const resolved = path.resolve(base, decodeURIComponent(rawPath))
|
||||||
|
const rel = path.relative(ROOT, resolved).split(path.sep).join('/')
|
||||||
|
|
||||||
|
if (rel.startsWith('..')) {
|
||||||
|
problems.push(`${where}: points outside the repo — ${target}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(resolved)) {
|
||||||
|
problems.push(`${where}: no such file — ${target}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (rawAnchor && resolved.toLowerCase().endsWith('.md')) {
|
||||||
|
const slug = decodeURIComponent(rawAnchor).toLowerCase()
|
||||||
|
if (!anchorsOf(rel).has(slug)) {
|
||||||
|
problems.push(`${where}: ${rawPath} has no heading matching #${rawAnchor}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length) {
|
||||||
|
console.error(`checkLinks: ${problems.length} problem(s) in ${files.length} file(s):\n`)
|
||||||
|
for (const p of problems) console.error(` ${p}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!QUIET) {
|
||||||
|
console.log(`checkLinks: ${checked} link(s) across ${files.length} markdown file(s) — OK`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user