Merge pull request 'docs: scaffold the Integration Kit — front page, outline, and the checks' (#1) from docs/scaffold into main
Reviewed-on: #1
This commit is contained in:
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