Merge pull request 'Wiki upgrade: rich-text editing, categories, drafts, links/backlinks, tags, search, revisions' (#3) from wiki-upgrade into main

Reviewed-on: UOM/website#3
This commit is contained in:
2026-06-27 21:22:44 +00:00
24 changed files with 3552 additions and 108 deletions

366
WIKI_UPGRADE.md Normal file
View File

@@ -0,0 +1,366 @@
# UOMysticmoon Website — Wiki Upgrade Spec
> Branch: **`wiki-upgrade`**. This document is the contract for upgrading the CMS
> wiki from a flat single-table page store into a feature-complete wiki.
> It follows the project workflow: **design (this doc) → build in phases → verify**.
>
> Companion to [`BACKEND_DESIGN.md`](BACKEND_DESIGN.md); reuses its stack, auth,
> logging, and Docker decisions unchanged.
---
## 1. Goal & scope
Turn the wiki into something that behaves like a typical wiki, while staying inside
the existing Node/Express + MariaDB + React/Vite architecture and the **staff-only**
auth model (admin/editor — no new roles, no public contributions).
**In scope**
| Feature | Summary |
|---|---|
| Rich-text editing | TipTap (ProseMirror) WYSIWYG in the admin; outputs HTML |
| Sanitization | Server-side allowlist on save **and** client-side on render (fixes today's stored-XSS gap) |
| Categories / sections | First-class `wiki_categories` table; replaces hardcoded frontend blurbs |
| Drafts & publish | `published` + `published_at`, mirroring the `posts` pattern |
| Tags | Many-to-many tags with filtering |
| Internal links | `[[slug]]`-style links authored in the editor; red-link detection |
| Backlinks | "Linked from" list, maintained on save |
| Inline images | Reuse/generalize the existing multer upload for in-body images |
| Search | MariaDB `FULLTEXT` over title + body |
| Revision history | Per-save snapshots with view / diff / restore |
**Out of scope (this branch)**
- Public/player editing or suggestion workflow, moderation/review queues.
- New roles or per-page ACLs (all staff with a login can edit all pages).
- Real-time collaborative editing, comments/discussion pages, file attachments
other than images, page templates/transclusion, multilingual pages.
**Decisions locked from planning**
- Editor: **TipTap**, storing **HTML** (not Markdown, not JSON).
- Search: **MariaDB FULLTEXT** (no new infrastructure).
- Revision history and search are **included** (recommended additions beyond the
minimum requested set).
- Authoring is **admin + editor** (`isLoggedIn`); no anonymous edits.
---
## 2. Current state (baseline being replaced)
| Layer | Today | File |
|---|---|---|
| Schema | flat `wiki_pages(slug,title,body,updated_by,timestamps)` | [server/db/schema.sql:31](server/db/schema.sql) |
| Model | thin CRUD by slug | [server/src/model/wiki/wiki.db.js](server/src/model/wiki/wiki.db.js), [wiki.model.js](server/src/model/wiki/wiki.model.js) |
| Public API | `GET /public/wiki`, `GET /public/wiki/:slug` | [public.controller.js:53](server/src/router/v1/public/public.controller.js) |
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [admin.routes.js:68](server/src/router/v1/admin/admin.routes.js) |
| Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | [Wiki.jsx](client/src/routes/wiki/Wiki.jsx), [WikiArticle.jsx](client/src/routes/wiki/WikiArticle.jsx) |
| Admin UI | raw-HTML `<textarea>` modal | [WikiAdmin.jsx](client/src/routes/admin/views/WikiAdmin.jsx), [WikiEditor.jsx](client/src/routes/admin/views/WikiEditor.jsx) |
| API client | `api.wiki`, `api.admin.*Wiki` | [client/src/api/client.js:52](client/src/api/client.js) |
**Known issues this upgrade resolves**
- **Stored XSS**: body is raw HTML rendered with `dangerouslySetInnerHTML` and never
sanitized ([WikiArticle.jsx:91](client/src/routes/wiki/WikiArticle.jsx)).
- Category blurbs and ordering are **faked in the component** ([Wiki.jsx:11](client/src/routes/wiki/Wiki.jsx)), not data.
- No drafts (every save is instantly public), no history, no search, no tags, no links.
---
## 3. Data model
`utf8mb4`, InnoDB throughout. All changes are **additive and idempotent** so
`ensureSchema()` upgrades existing databases on boot with no data loss. New columns
are nullable or have safe defaults; **existing pages default to `published = 1`** so
nothing disappears on deploy.
### 3.1 `wiki_categories` (new)
| col | type | notes |
|---|---|---|
| id | INT PK AI | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `guides` |
| title | VARCHAR(200) NOT NULL | |
| description | VARCHAR(400) NULL | card teaser on the wiki index |
| sort_order | INT NOT NULL DEFAULT 0 | manual ordering |
| created_at / updated_at | DATETIME | standard stamps |
### 3.2 `wiki_pages` (altered)
Add to the existing table:
| col | type | notes |
|---|---|---|
| category_id | INT NULL FK→wiki_categories(id) ON DELETE SET NULL | |
| excerpt | VARCHAR(400) NULL | card/search teaser (replaces hardcoded blurbs) |
| published | TINYINT(1) NOT NULL DEFAULT 1 | draft/publish toggle |
| published_at | DATETIME NULL | set on first publish |
| sort_order | INT NOT NULL DEFAULT 0 | ordering within a category |
| FULLTEXT idx_wiki_search (title, body) | | search |
### 3.3 `wiki_tags` + `wiki_page_tags` (new)
```
wiki_tags( id PK, slug VARCHAR(120) UNIQUE, label VARCHAR(120) )
wiki_page_tags( page_id FK→wiki_pages ON DELETE CASCADE,
tag_id FK→wiki_tags ON DELETE CASCADE,
PRIMARY KEY(page_id, tag_id) )
```
### 3.4 `wiki_links` (new) — backlinks index
Rebuilt for a page on every save by parsing its body for internal links.
| col | type | notes |
|---|---|---|
| source_page_id | INT FK→wiki_pages ON DELETE CASCADE | |
| target_slug | VARCHAR(120) NOT NULL | may point at a not-yet-created page (red link) |
| INDEX idx_wiki_links_target (target_slug) | | backlink lookups |
Backlinks for page X = `SELECT source pages WHERE target_slug = X.slug AND source is published`.
### 3.5 `wiki_revisions` (new) — history
| col | type | notes |
|---|---|---|
| id | INT PK AI | |
| page_id | INT FK→wiki_pages ON DELETE CASCADE | |
| title / body / excerpt | snapshot of content at save time | |
| category_id | INT NULL | snapshot |
| editor_id | INT NULL FK→users(id) | who saved |
| change_note | VARCHAR(280) NULL | optional summary |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
A revision is written **inside the same transaction** as each page create/update.
### 3.6 Seed changes
Rework [seed.js](server/db/seed.js): the current 8 hardcoded pages become **categories**
(title + the blurb currently living in the frontend), each seeded idempotently via a new
`seedDefaultCategory`. Existing seeded pages are migrated/attached where applicable.
`seedDefault` for pages stays `INSERT IGNORE` so reseeding is safe.
---
## 4. Backend changes
Keep the `model` (entity) / `db` (SQL) split and the route grouping by access level.
### 4.1 Models (`server/src/model/wiki/`)
- `wiki.db.js` — add SQL for: category CRUD; page list with `category`, `published`,
`q` (FULLTEXT) filters and ordering; tag upsert + attach/detach; `wiki_links` rebuild;
revision insert/list/get; backlink query.
- `wiki.model.js` — orchestration. On **create/update** (single transaction):
1. sanitize `body` with the allowlist (§6),
2. upsert the page,
3. insert a `wiki_revisions` snapshot,
4. parse body for internal links → rebuild `wiki_links` for the page,
5. sync tags.
- A small `wiki.links.js` helper: parse internal links out of the saved HTML
(anchors written by the editor as `href="/wiki/<slug>"` / a `data-wiki-slug` attr),
return the set of target slugs.
### 4.2 Public API (`/api/v1/public`)
| Method | Path | Notes |
|---|---|---|
| GET | `/wiki/categories` | ordered categories with page counts |
| GET | `/wiki?category=&tag=&q=` | **published only**; list/filter/search summaries |
| GET | `/wiki/:slug` | page + category + tags + backlinks (published only) |
Still passes through the `siteMode` maintenance gate like other public content.
### 4.3 Admin API (`/api/v1/admin`, behind `isLoggedIn` + `noindex`)
| Method | Path | Purpose |
|---|---|---|
| GET | `/wiki` | all pages incl. drafts (filters: category, tag, q, status) |
| GET | `/wiki/:slug` | one page incl. draft, tags, category |
| POST | `/wiki` | create (slug, title, body, excerpt, category_id, tags, published) |
| PUT | `/wiki/:slug` | update (allows slug rename — see §7) |
| PATCH | `/wiki/:slug/publish` | `{published}` toggle, stamps `published_at` |
| DELETE | `/wiki/:slug` | delete (cascades revisions/links/tags) |
| GET | `/wiki/:slug/revisions` | list snapshots |
| GET | `/wiki/:slug/revisions/:id` | one snapshot (for diff/preview) |
| POST | `/wiki/:slug/revisions/:id/restore` | restore (writes a new revision) |
| GET/POST/PUT/DELETE | `/wiki/categories[...]` | category CRUD + reorder |
| GET/POST | `/wiki/tags` | list/create tags |
| POST | `/uploads` | generalized image upload (see §4.4) → `{url}` |
Validation via `express-validator` (slug regex `^[a-z0-9-]+$`, title required, etc.),
centralized error handler unchanged. **Every write logs to `activity_log`**
(`wiki.create`, `wiki.update`, `wiki.publish`, `wiki.delete`, `wiki.revision.restore`,
`wiki.category.*`) following the existing convention.
### 4.4 Image uploads
Generalize the existing screenshot upload (multer config in [admin.routes.js:17](server/src/router/v1/admin/admin.routes.js))
into a shared `POST /admin/uploads` returning `{ url: "/uploads/<file>" }`, reused by both
the post editor and the wiki editor. Same size/mime limits. No new storage —
served from the existing `uploads/` volume.
---
## 5. Frontend changes
### 5.1 Admin
- **`WikiEditor.jsx`** — replace the raw-HTML `<textarea>` with a **TipTap** editor:
bold/italic/headings (H2 for TOC)/lists/quote/code, link tool, **image insert**
(uploads via `/admin/uploads`), and an **internal-link picker** (`[[`-triggered
autocomplete over existing slugs; flags red links). Adds: category dropdown, tag
input (create-on-type), excerpt field, **Save draft / Publish** actions, and a
**History** tab (revision list → preview → diff → restore).
- **`WikiAdmin.jsx`** — list gains status (draft/published), category column, and
filters; plus a **Categories** manager (CRUD + drag-to-reorder).
### 5.2 Public
- **`Wiki.jsx`** — fully data-driven: categories + real excerpts from the API
(delete the hardcoded `BLURBS`/`ROMAN` constants), a **search box**, optional
tag filter.
- **`WikiArticle.jsx`** — keep auto-TOC; add category breadcrumb, tag chips, a
**"Linked from"** backlinks section, "last updated by", and **render via DOMPurify**
(`dangerouslySetInnerHTML` only after sanitize).
### 5.3 API client & routes
- Extend [client/src/api/client.js](client/src/api/client.js) with the new public/admin
wiki calls (categories, search params, revisions, tags, uploads).
- Add a public search/category route if needed; admin categories view registered in
[App.jsx](client/src/App.jsx) under `/admin/wiki` (sub-tab, no new top-level route required).
### 5.4 Dependencies (new)
- **client**: `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`,
`@tiptap/extension-image` (+ a small diff lib for history, e.g. `diff`); `dompurify`.
- **server**: `sanitize-html`.
(The client currently ships only React + react-router, so this is the first feature
dependency addition — keep the bundle lean, import only the extensions used.)
---
## 6. Security
- **Two-layer sanitization.** Server sanitizes on save with a strict `sanitize-html`
allowlist (headings, p, lists, blockquote, code/pre, a[href], img[src,alt],
strong/em, hr, table basics); strips scripts, event handlers, `javascript:` URLs,
styles. Client re-sanitizes with DOMPurify before render. The stored value is already
clean, so even direct DB edits or future API clients can't inject script.
- **Upload safety** unchanged from posts: mime allowlist (png/jpe/gif/webp/avif),
8 MB cap, random filenames, served as static files (no execution).
- **Authorization**: all mutating wiki/category/tag/upload routes stay behind
`isLoggedIn` (admin or editor). Public routes are read-only and published-only.
- **No secrets/logging changes**; reuse existing rate-limit, helmet/CSP, noindex.
CSP `img-src` already covers `/uploads`.
---
## 7. Migration & backward compatibility
- Schema migration is additive; run by `ensureSchema()` on boot and shipped in
`schema.sql` for fresh containers. Use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
/ `ADD INDEX` guarded for idempotency (MariaDB 11 supports `IF NOT EXISTS`).
- Existing pages: `published` backfills to `1`, `published_at` to `updated_at`,
`category_id` left NULL (surface as "Uncategorized" until assigned).
- **Slug rename** (new capability): on `PUT` slug change, update the page slug and
best-effort rewrite known internal links pointing at the old slug; old slug is not
auto-redirected (acceptable for a staff-curated wiki) — note in release notes.
- Public API response shape is **extended, not broken**: existing fields
(`slug`, `title`, `body`, `updated_at`) remain; new fields are additive, so the
current frontend keeps working between phases.
---
## 8. Implementation process (phased)
Each phase is a self-contained, shippable unit: build → run locally → verify in the
browser preview → commit on `wiki-upgrade`. Open a PR into `main` at the end (or per
phase if preferred). Do not merge a phase that hasn't been verified.
### Phase 0 — Branch & scaffolding ✅ (this doc)
- `wiki-upgrade` branch created; this spec committed.
### Phase 1 — Foundation & safety (highest value) ✅
- Schema: add `wiki_categories`, alter `wiki_pages` (category_id, excerpt, published,
published_at, sort_order, FULLTEXT), update `seed.js`.
- Server: server-side sanitization on save; drafts/publish endpoints; categories CRUD;
public list filtered to published + categories endpoint.
- Client: data-driven `Wiki.jsx` (remove hardcoded blurbs); DOMPurify render in
`WikiArticle.jsx`; draft/publish + category in the (still-textarea) admin editor.
- **Exit check**: existing pages still render; XSS payload in body is neutralized;
draft pages hidden from the public list/article.
- **Verified** (2026-06-27): schema migration ran clean on MariaDB 11; XSS payload
(`<script>`, `onerror=`, `javascript:`) stripped server-side; drafts return 404 on
the public API and are absent from the public list while visible in admin; public
index is data-driven (categories + sections); article shows category breadcrumb;
client builds and server boots with no errors.
### Phase 2 — Authoring UX ✅
- TipTap editor replaces the textarea; generalized `/admin/uploads`; inline images.
- **Exit check**: create/edit a page with headings, a list, a link, and an inline
image; verify it renders sanitized on the public page.
- **Verified** (2026-06-27): `/admin/uploads` returns `{url}` and the file serves as
an image; a page authored with H2/H3, lists, a link, and an uploaded inline image
round-trips through the WYSIWYG and renders sanitized publicly (link `rel` forced,
`<script>` stripped); a toolbar edit (insert divider) saved and persisted. TipTap
is code-split into its own chunk (lazy-loaded), keeping it off the public bundle.
### Phase 3 — Connectivity ✅
- Internal `[[slug]]` links + red-link detection; `wiki_links` rebuild on save;
backlinks on the article; tags + tag/category filtering.
- **Exit check**: link page A→B, confirm B shows A under "Linked from"; tag filter works.
- **Verified** (2026-06-27): internal links authored via an in-editor page picker
(links to `/wiki/<slug>`); A→B made B list A under "Linked from"; a link to a
non-existent page renders as a red link; removing the link on save cleared the
backlink (link index rebuilt). Tags upsert on save, filter via `?tag=` (chips +
flat index view), list with published counts, and orphan tags are auto-pruned.
- Implementation note: links are plain anchors to `/wiki/<slug>` (the WYSIWYG fits
this better than `[[ ]]` syntax); the sanitizer also allows `data-wiki-slug`.
### Phase 4 — Discovery & trust ✅
- FULLTEXT search (public search box + admin filter); revision history list /
diff / restore.
- **Exit check**: search returns expected pages; edit a page twice, diff the
revisions, restore an older one, confirm a new revision is recorded.
- **Verified** (2026-06-27): `?q=` natural-language search matches on both body
(`recipes`→crafting) and title (`monsters`); the public search box and admin
filter both work. A page edited twice produced 3 revisions; the History modal
shows a word-level diff (added vs removed) of an old revision against current;
restoring reverted the page and appended a "Restored from revision #N" entry.
### Verification (every phase)
Use the preview workflow, not manual hand-off: start the dev server, exercise the
public wiki and the admin editor, check console/network for errors, and capture a
screenshot of the changed surface. Confirm `npm run` lint/build passes for the client
and the server boots cleanly with `ensureSchema()` applying the migration.
---
## 9. File-change map (reference)
| Area | Files |
|---|---|
| Schema/seed | `server/db/schema.sql`, `server/db/seed.js`, `server/src/utils/db.js` (ensureSchema) |
| Models | `server/src/model/wiki/wiki.db.js`, `wiki.model.js`, **new** `wiki.links.js` |
| API | `server/src/router/v1/public/public.{routes,controller}.js`, `server/src/router/v1/admin/admin.{routes,controller}.js` |
| Sanitize | **new** `server/src/utils/sanitizeHtml.js` |
| Client API | `client/src/api/client.js` |
| Public UI | `client/src/routes/wiki/Wiki.jsx`, `WikiArticle.jsx` |
| Admin UI | `client/src/routes/admin/views/WikiAdmin.jsx`, `WikiEditor.jsx`, **new** category manager + revisions view |
| Deps | `client/package.json`, `server/package.json` |
---
## 10. Open questions / assumptions
1. **Slug redirects**: assumed not needed on rename (staff wiki). Revisit if pages get
external inbound links.
2. **Search ranking**: FULLTEXT natural-language mode assumed; can switch to BOOLEAN
mode if operators are wanted later.
3. **Diff granularity**: line/word diff of the HTML source is assumed sufficient for
revision compare; a rendered visual diff is a later nice-to-have.
4. **Editor scope**: tables and embeds beyond images are deferred unless requested.

836
client/package-lock.json generated
View File

@@ -8,6 +8,12 @@
"name": "uomysticmoon-client",
"version": "1.0.0",
"dependencies": {
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"diff": "^5.2.2",
"dompurify": "^3.4.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
@@ -740,6 +746,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
@@ -1145,6 +1167,421 @@
"win32"
]
},
"node_modules/@tiptap/core": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz",
"integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz",
"integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz",
"integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz",
"integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz",
"integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
"integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz",
"integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz",
"integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz",
"integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz",
"integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz",
"integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
"integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz",
"integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-image": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz",
"integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
"integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
"integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz",
"integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz",
"integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz",
"integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
"integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/pm": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.37.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz",
"integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==",
"license": "MIT",
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.27.2",
"@tiptap/extension-floating-menu": "^2.27.2",
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3",
"use-sync-external-store": "^1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz",
"integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.27.2",
"@tiptap/extension-blockquote": "^2.27.2",
"@tiptap/extension-bold": "^2.27.2",
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-document": "^2.27.2",
"@tiptap/extension-dropcursor": "^2.27.2",
"@tiptap/extension-gapcursor": "^2.27.2",
"@tiptap/extension-hard-break": "^2.27.2",
"@tiptap/extension-heading": "^2.27.2",
"@tiptap/extension-history": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
"@tiptap/extension-italic": "^2.27.2",
"@tiptap/extension-list-item": "^2.27.2",
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-paragraph": "^2.27.2",
"@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-text": "^2.27.2",
"@tiptap/extension-text-style": "^2.27.2",
"@tiptap/pm": "^2.27.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1197,6 +1634,41 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1218,6 +1690,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@@ -1293,6 +1771,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1311,6 +1795,24 @@
}
}
},
"node_modules/diff": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.380",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
@@ -1318,6 +1820,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1367,6 +1881,24 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1424,6 +1956,31 @@
"node": ">=6"
}
},
"node_modules/linkify-it": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1446,6 +2003,39 @@
"yallist": "^3.0.2"
}
},
"node_modules/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.1",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1482,6 +2072,12 @@
"node": ">=18"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1518,6 +2114,210 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz",
"integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.9",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.9",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz",
"integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1630,6 +2430,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -1659,6 +2465,21 @@
"node": ">=0.10.0"
}
},
"node_modules/tippy.js": {
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.0"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1690,6 +2511,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -1750,6 +2580,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -9,6 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"diff": "^5.2.2",
"dompurify": "^3.4.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"

View File

@@ -49,7 +49,16 @@ export const api = {
status: () => req('/public/status'),
posts: (category) => req(`/public/posts/${category}`),
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
wiki: () => req('/public/wiki'),
wiki: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.category) qs.set('category', opts.category)
if (opts.tag) qs.set('tag', opts.tag)
if (opts.q) qs.set('q', opts.q)
const s = qs.toString()
return req(`/public/wiki${s ? `?${s}` : ''}`)
},
wikiCategories: () => req('/public/wiki/categories'),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
@@ -69,11 +78,29 @@ export const api = {
fd.append('image', file)
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
},
listWiki: () => req('/admin/wiki'),
// Generalized upload for rich-text editors → { url }.
upload: (file) => {
const fd = new FormData()
fd.append('image', file)
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
},
listWiki: (params = '') => req(`/admin/wiki${params}`),
getWiki: (slug) => req(`/admin/wiki/${slug}`),
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
publishWiki: (slug, published) =>
req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }),
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
listWikiRevisions: (slug) => req(`/admin/wiki/${slug}/revisions`),
getWikiRevision: (slug, id) => req(`/admin/wiki/${slug}/revisions/${id}`),
restoreWikiRevision: (slug, id) =>
req(`/admin/wiki/${slug}/revisions/${id}/restore`, { method: 'POST' }),
listWikiTags: () => req('/admin/wiki/tags'),
listWikiCategories: () => req('/admin/wiki/categories'),
createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }),
updateWikiCategory: (id, data) =>
req(`/admin/wiki/categories/${id}`, { method: 'PUT', body: data }),
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
getSettings: () => req('/admin/settings'),
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),

View File

@@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { api } from '../api/client.js'
// Toolbar button.
function Btn({ onClick, active, disabled, title, children }) {
return (
<button
type="button"
title={title}
onMouseDown={(e) => e.preventDefault()} // keep editor selection
onClick={onClick}
disabled={disabled}
className={`rte-btn${active ? ' is-active' : ''}`}
>
{children}
</button>
)
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
}
export default function RichTextEditor({ value, onChange, pages = [] }) {
const fileRef = useRef(null)
const [uploading, setUploading] = useState(false)
const [linkMenu, setLinkMenu] = useState(false)
const [linkFilter, setLinkFilter] = useState('')
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [2, 3] } }),
Link.configure({ openOnClick: false, autolink: true }),
Image.configure({ inline: false }),
],
content: value || '',
onUpdate: ({ editor }) => onChange(editor.getHTML()),
})
// Safety net: sync if the parent resets `value` externally (won't fire during
// normal typing because the parent value equals what the editor just emitted).
useEffect(() => {
if (!editor) return
if (value != null && value !== editor.getHTML()) {
editor.commands.setContent(value, false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, editor])
if (!editor) return null
function setLink() {
const prev = editor.getAttributes('link').href || ''
const url = window.prompt('Link URL (leave blank to remove)', prev)
if (url === null) return
if (url === '') return editor.chain().focus().extendMarkRange('link').unsetLink().run()
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
function insertInternalLink(page) {
const { from, to } = editor.state.selection
if (from === to) {
editor.chain().focus().insertContent(`<a href="/wiki/${page.slug}">${escapeHtml(page.title)}</a> `).run()
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: `/wiki/${page.slug}` }).run()
}
setLinkMenu(false)
setLinkFilter('')
}
async function onPickImage(e) {
const file = e.target.files?.[0]
e.target.value = '' // allow re-selecting the same file
if (!file) return
setUploading(true)
try {
const { url } = await api.admin.upload(file)
editor.chain().focus().setImage({ src: url, alt: file.name }).run()
} catch (err) {
alert(err.message || 'Image upload failed.')
} finally {
setUploading(false)
}
}
return (
<div className="rte">
<div className="rte-toolbar">
<Btn title="Bold" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
<b>B</b>
</Btn>
<Btn title="Italic" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
<i>I</i>
</Btn>
<Btn title="Strikethrough" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
<s>S</s>
</Btn>
<span className="rte-sep" />
<Btn title="Heading 2 (table of contents)" active={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
H2
</Btn>
<Btn title="Heading 3" active={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
H3
</Btn>
<span className="rte-sep" />
<Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
List
</Btn>
<Btn title="Numbered list" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
1. List
</Btn>
<Btn title="Quote" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
</Btn>
<Btn title="Code block" active={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
{'</>'}
</Btn>
<Btn title="Divider" onClick={() => editor.chain().focus().setHorizontalRule().run()}>
</Btn>
<span className="rte-sep" />
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
🔗
</Btn>
<Btn title="Link to another wiki page" disabled={pages.length === 0} onClick={() => setLinkMenu((v) => !v)}>
📄
</Btn>
<Btn title="Insert image" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? '…' : '🖼'}
</Btn>
<span className="rte-sep" />
<Btn title="Undo" disabled={!editor.can().undo()} onClick={() => editor.chain().focus().undo().run()}>
</Btn>
<Btn title="Redo" disabled={!editor.can().redo()} onClick={() => editor.chain().focus().redo().run()}>
</Btn>
</div>
{linkMenu && (
<div className="rte-linkmenu">
<input
autoFocus
className="input"
placeholder="Filter pages…"
value={linkFilter}
onChange={(e) => setLinkFilter(e.target.value)}
/>
<div className="rte-linkmenu-list">
{pages
.filter((p) => {
const q = linkFilter.trim().toLowerCase()
return !q || p.title.toLowerCase().includes(q) || p.slug.includes(q)
})
.slice(0, 30)
.map((p) => (
<button key={p.slug} type="button" className="rte-linkmenu-item" onClick={() => insertInternalLink(p)}>
<span>{p.title}</span>
<span className="rte-linkmenu-slug">/{p.slug}</span>
</button>
))}
</div>
</div>
)}
<EditorContent editor={editor} className="rte-content prose" />
<input ref={fileRef} type="file" accept="image/*" onChange={onPickImage} hidden />
</div>
)
}

View File

@@ -4,12 +4,18 @@ import { useAsync } from '../../../lib/useAsync.js'
import { shortDate } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import WikiEditor from './WikiEditor.jsx'
import WikiCategories from './WikiCategories.jsx'
export default function WikiAdmin() {
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.listWiki(), [tick])
const [q, setQ] = useState('')
const { loading, error, data } = useAsync(
() => api.admin.listWiki(q.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''),
[tick, q],
)
const [editing, setEditing] = useState(null) // null | 'new' | slug
const [managingCats, setManagingCats] = useState(false)
const pages = data || []
return (
@@ -18,10 +24,23 @@ export default function WikiAdmin() {
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
{pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure
</p>
<div style={{ display: 'flex', gap: 10 }}>
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
className="input"
placeholder="Search pages…"
style={{ width: 200 }}
/>
<button onClick={() => setManagingCats(true)} className="pill">
Manage sections
</button>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ New page
</button>
</div>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load wiki pages." />}
@@ -32,7 +51,8 @@ export default function WikiAdmin() {
<thead>
<tr>
<th className="adm-th">Page</th>
<th className="adm-th">Slug</th>
<th className="adm-th">Section</th>
<th className="adm-th">Status</th>
<th className="adm-th">Updated</th>
<th className="adm-th" />
</tr>
@@ -42,9 +62,15 @@ export default function WikiAdmin() {
<tr key={w.slug}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{w.title}
</td>
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}>
<span
style={{ display: 'block', fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)', fontSize: '0.78rem' }}
>
{w.slug}
</span>
</td>
<td className="adm-td dim">{w.category_title || '—'}</td>
<td className="adm-td">
<StatusPill published={w.published} />
</td>
<td className="adm-td dim">{shortDate(w.updated_at)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
@@ -54,6 +80,13 @@ export default function WikiAdmin() {
</td>
</tr>
))}
{pages.length === 0 && (
<tr>
<td className="adm-td dim" colSpan={5}>
No wiki pages yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
@@ -69,6 +102,37 @@ export default function WikiAdmin() {
}}
/>
)}
{managingCats && (
<WikiCategories
onClose={() => {
setManagingCats(false)
reload() // section titles may have changed
}}
/>
)}
</section>
)
}
function StatusPill({ published }) {
const live = Boolean(published)
return (
<span
className="sans"
style={{
fontSize: '0.72rem',
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
padding: '2px 9px',
borderRadius: 999,
border: `1px solid ${live ? 'rgba(108,176,140,0.5)' : 'var(--line)'}`,
color: live ? '#8fc7a6' : 'var(--dim)',
background: live ? 'rgba(108,176,140,0.12)' : 'transparent',
}}
>
{live ? 'Published' : 'Draft'}
</span>
)
}

View File

@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import { api } from '../../../api/client.js'
const EMPTY = { slug: '', title: '', description: '', sort_order: 0 }
export default function WikiCategories({ onClose }) {
const [cats, setCats] = useState([])
const [editing, setEditing] = useState(null) // null = create mode, else category id
const [form, setForm] = useState(EMPTY)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const load = useCallback(() => {
api.admin
.listWikiCategories()
.then(setCats)
.catch(() => setError('Could not load sections.'))
}, [])
useEffect(() => {
load()
}, [load])
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
function startEdit(c) {
setEditing(c.id)
setForm({ slug: c.slug, title: c.title, description: c.description || '', sort_order: c.sort_order })
setError('')
}
function reset() {
setEditing(null)
setForm(EMPTY)
}
async function save() {
if (!form.title.trim()) return setError('Title is required.')
if (!editing && !/^[a-z0-9-]+$/.test(form.slug)) {
return setError('Slug must be lowercase letters, numbers, and dashes.')
}
setBusy(true)
setError('')
const payload = {
title: form.title.trim(),
description: form.description.trim(),
sort_order: Number(form.sort_order) || 0,
}
try {
if (editing) await api.admin.updateWikiCategory(editing, payload)
else await api.admin.createWikiCategory({ slug: form.slug, ...payload })
reset()
load()
} catch (err) {
setError(err.message || 'Could not save section.')
} finally {
setBusy(false)
}
}
async function remove(c) {
if (!confirm(`Delete section "${c.title}"? Its ${c.page_count} page(s) become uncategorized.`)) return
setBusy(true)
try {
await api.admin.deleteWikiCategory(c.id)
if (editing === c.id) reset()
load()
} catch (err) {
setError(err.message || 'Could not delete section.')
} finally {
setBusy(false)
}
}
return (
<Modal
title="Wiki sections"
onClose={onClose}
width={640}
footer={
<button onClick={onClose} className="pill">
Done
</button>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{/* Create / edit form */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">Slug</span>
<input
type="text"
value={form.slug}
onChange={set('slug')}
disabled={Boolean(editing)}
className="input"
style={{ fontFamily: 'ui-monospace,Menlo,monospace', opacity: editing ? 0.6 : 1 }}
placeholder="guides"
/>
</label>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">Title</span>
<input type="text" value={form.title} onChange={set('title')} className="input" />
</label>
<label style={{ flex: '0 0 90px' }}>
<span className="field-label">Order</span>
<input type="number" value={form.sort_order} onChange={set('sort_order')} className="input" />
</label>
</div>
<label>
<span className="field-label">Description</span>
<input
type="text"
value={form.description}
onChange={set('description')}
className="input"
maxLength={400}
placeholder="Shown under the section heading on the wiki home."
/>
</label>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{editing ? 'Save section' : '+ Add section'}
</button>
{editing && (
<button onClick={reset} disabled={busy} className="pill">
Cancel edit
</button>
)}
</div>
</div>
{/* Existing categories */}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Section</th>
<th className="adm-th">Slug</th>
<th className="adm-th">Pages</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{cats.map((c) => (
<tr key={c.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{c.title}
</td>
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}>
{c.slug}
</td>
<td className="adm-td dim">{c.page_count}</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<span className="link-accent" onClick={() => startEdit(c)}>
Edit
</span>
<span style={{ color: 'var(--line)', margin: '0 8px' }}>·</span>
<span className="link-accent" style={{ color: '#d98b84' }} onClick={() => remove(c)}>
Delete
</span>
</td>
</tr>
))}
{cats.length === 0 && (
<tr>
<td className="adm-td dim" colSpan={4}>
No sections yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</Modal>
)
}

View File

@@ -1,20 +1,64 @@
import { useEffect, useState } from 'react'
import { lazy, Suspense, useEffect, useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import WikiHistory from './WikiHistory.jsx'
import { api } from '../../../api/client.js'
// Admin-only and heavy (TipTap) — load as its own chunk so the public bundle
// never pays for it.
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
export default function WikiEditor({ slug, onClose, onSaved }) {
const isEdit = Boolean(slug)
const [form, setForm] = useState({ slug: '', title: '', body: '' })
const [form, setForm] = useState({
slug: '',
title: '',
body: '',
excerpt: '',
category_id: '',
published: true,
tags: '',
})
const [categories, setCategories] = useState([])
const [pages, setPages] = useState([])
const [loading, setLoading] = useState(isEdit)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [showHistory, setShowHistory] = useState(false)
// Categories (dropdown) + pages (internal-link picker), for both new and edit.
useEffect(() => {
let active = true
api.admin
.listWikiCategories()
.then((cats) => active && setCategories(cats))
.catch(() => {})
api.admin
.listWiki()
.then((list) => active && setPages(list.map((p) => ({ slug: p.slug, title: p.title }))))
.catch(() => {})
return () => {
active = false
}
}, [])
useEffect(() => {
if (!isEdit) return
let active = true
api.admin
.getWiki(slug)
.then((p) => active && setForm({ slug: p.slug, title: p.title, body: p.body || '' }))
.then(
(p) =>
active &&
setForm({
slug: p.slug,
title: p.title,
body: p.body || '',
excerpt: p.excerpt || '',
category_id: p.category_id != null ? String(p.category_id) : '',
published: Boolean(p.published),
tags: (p.tags || []).map((t) => t.label).join(', '),
}),
)
.catch(() => active && setError('Could not load this page.'))
.finally(() => active && setLoading(false))
return () => {
@@ -24,14 +68,30 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
function payload() {
return {
title: form.title.trim(),
body: form.body,
excerpt: form.excerpt.trim(),
category_id: form.category_id ? Number(form.category_id) : null,
published: form.published,
tags: form.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
}
}
async function save() {
if (!form.title.trim()) return setError('Title is required.')
if (!isEdit && !/^[a-z0-9-]+$/.test(form.slug)) return setError('Slug must be lowercase letters, numbers, and dashes.')
if (!isEdit && !/^[a-z0-9-]+$/.test(form.slug)) {
return setError('Slug must be lowercase letters, numbers, and dashes.')
}
setBusy(true)
setError('')
try {
if (isEdit) await api.admin.updateWiki(slug, { title: form.title.trim(), body: form.body })
else await api.admin.createWiki({ slug: form.slug, title: form.title.trim(), body: form.body })
if (isEdit) await api.admin.updateWiki(slug, payload())
else await api.admin.createWiki({ slug: form.slug, ...payload() })
onSaved()
} catch (err) {
setError(err.message || 'Could not save.')
@@ -52,6 +112,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
}
return (
<>
<Modal
title={isEdit ? 'Edit wiki page' : 'New wiki page'}
onClose={onClose}
@@ -63,11 +124,16 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
Delete
</button>
)}
{isEdit && (
<button onClick={() => setShowHistory(true)} disabled={busy} className="pill">
History
</button>
)}
<button onClick={onClose} disabled={busy} className="pill">
Cancel
</button>
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save'}
{busy ? 'Saving…' : form.published ? 'Save & publish' : 'Save draft'}
</button>
</>
}
@@ -93,13 +159,74 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
<span className="field-label">Title</span>
<input type="text" value={form.title} onChange={set('title')} className="input" />
</label>
<label>
<span className="field-label">Body (HTML use &lt;h2&gt; for the table of contents)</span>
<textarea value={form.body} onChange={set('body')} className="textarea" style={{ minHeight: 260 }} />
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 200px' }}>
<span className="field-label">Section</span>
<select value={form.category_id} onChange={set('category_id')} className="input">
<option value=""> Uncategorized </option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
</label>
<label style={{ display: 'flex', alignItems: 'flex-end', gap: 8, paddingBottom: 10 }}>
<input
type="checkbox"
checked={form.published}
onChange={(e) => setForm((f) => ({ ...f, published: e.target.checked }))}
/>
<span className="field-label" style={{ margin: 0 }}>
Published
</span>
</label>
</div>
<label>
<span className="field-label">Excerpt (card teaser on the wiki index)</span>
<input
type="text"
value={form.excerpt}
onChange={set('excerpt')}
className="input"
maxLength={400}
placeholder="One-line summary shown on the wiki home."
/>
</label>
<label>
<span className="field-label">Tags (comma-separated)</span>
<input
type="text"
value={form.tags}
onChange={set('tags')}
className="input"
placeholder="beginner, pvp, towns"
/>
</label>
<div>
<span className="field-label">Body (use Heading 2 for table-of-contents sections)</span>
<Suspense fallback={<span className="spin" />}>
<RichTextEditor
value={form.body}
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
pages={pages.filter((p) => p.slug !== form.slug)}
/>
</Suspense>
</div>
</div>
)}
</Modal>
{showHistory && (
<WikiHistory
slug={slug}
onClose={() => setShowHistory(false)}
onRestored={() => {
setShowHistory(false)
onSaved()
}}
/>
)}
</>
)
}

View File

@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react'
import { diffWords } from 'diff'
import Modal from '../../../components/Modal.jsx'
import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// Plain-text view of a body, for a readable word-level diff.
function toText(html) {
if (!html) return ''
if (typeof window === 'undefined' || !window.DOMParser) return html
const doc = new DOMParser().parseFromString(html, 'text/html')
return doc.body.textContent || ''
}
export default function WikiHistory({ slug, onClose, onRestored }) {
const [revisions, setRevisions] = useState([])
const [current, setCurrent] = useState(null)
const [selected, setSelected] = useState(null) // full revision snapshot
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
let active = true
Promise.all([api.admin.listWikiRevisions(slug), api.admin.getWiki(slug)])
.then(([revs, page]) => {
if (!active) return
setRevisions(revs)
setCurrent(page)
})
.catch(() => active && setError('Could not load history.'))
.finally(() => active && setLoading(false))
return () => {
active = false
}
}, [slug])
async function selectRevision(id) {
setError('')
try {
const rev = await api.admin.getWikiRevision(slug, id)
setSelected(rev)
} catch (err) {
setError(err.message || 'Could not load revision.')
}
}
async function restore() {
if (!selected) return
if (!confirm(`Restore the page to revision #${selected.id}? This creates a new revision.`)) return
setBusy(true)
try {
await api.admin.restoreWikiRevision(slug, selected.id)
onRestored()
} catch (err) {
setError(err.message || 'Could not restore.')
setBusy(false)
}
}
// Diff the selected revision (old) against the current saved page (new).
const parts = useMemo(
() => (selected ? diffWords(toText(selected.body), toText(current?.body || '')) : []),
[selected, current],
)
return (
<Modal
title={`History — ${slug}`}
onClose={onClose}
width={760}
footer={
<>
<button onClick={onClose} disabled={busy} className="pill">
Close
</button>
<button onClick={restore} disabled={busy || !selected} className="btn btn-primary btn-sq">
{busy ? 'Restoring…' : 'Restore this revision'}
</button>
</>
}
>
{loading ? (
<span className="spin" />
) : error ? (
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
) : (
<div className="wiki-history">
<ul className="wiki-history-list">
{revisions.map((r, i) => (
<li key={r.id}>
<button
type="button"
className={`wiki-rev${selected?.id === r.id ? ' is-active' : ''}`}
onClick={() => selectRevision(r.id)}
>
<span className="wiki-rev-note">
{r.change_note || 'Edit'}
{i === 0 && <span className="wiki-rev-latest"> · latest</span>}
</span>
<span className="wiki-rev-meta">
{dateTime(r.created_at)}
{r.editor ? ` · ${r.editor}` : ''}
</span>
</button>
</li>
))}
{revisions.length === 0 && <li className="dim sans" style={{ fontSize: '0.85rem' }}>No revisions yet.</li>}
</ul>
<div className="wiki-history-diff">
{!selected ? (
<p className="muted sans" style={{ fontSize: '0.9rem' }}>
Select a revision to see what changed between it and the current page.
</p>
) : (
<>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.76rem' }}>
Diff: revision #{selected.id} current
</p>
<div className="wiki-diff">
{parts.length === 0 || (parts.length === 1 && !parts[0].added && !parts[0].removed) ? (
<span className="muted">No textual differences.</span>
) : (
parts.map((p, i) => (
<span key={i} className={p.added ? 'diff-add' : p.removed ? 'diff-del' : ''}>
{p.value}
</span>
))
)}
</div>
</>
)}
</div>
</div>
)}
</Modal>
)
}

View File

@@ -1,27 +1,90 @@
import { Link } from 'react-router-dom'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII']
function SearchBox({ initial, onSubmit }) {
const [term, setTerm] = useState(initial || '')
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit(term.trim())
}}
style={{ display: 'flex', gap: 8, maxWidth: 460, margin: '0 auto 8px' }}
>
<input
type="search"
value={term}
onChange={(e) => setTerm(e.target.value)}
className="input"
placeholder="Search the wiki…"
/>
<button type="submit" className="btn btn-primary btn-sq">
Search
</button>
</form>
)
}
// Short blurbs for the seeded categories (the list endpoint returns title/slug only).
const BLURBS = {
'new-player-guide': 'First steps, basic survival, and early goals.',
'maps-atlas': 'Regions, towns, routes, and travel notes.',
systems: 'Shard mechanics and custom features.',
items: 'Equipment, treasures, rewards, and curiosities.',
monsters: 'Creatures, bosses, spawns, and dangers.',
crafting: 'Professions, materials, recipes, and tools.',
lore: 'Stories, places, factions, and mysteries.',
rules: 'Player conduct, shard expectations, and policies.',
// Group published pages under their category, preserving category sort order and
// collecting anything uncategorized into a trailing section.
function groupByCategory(categories, pages) {
const byId = new Map(categories.map((c) => [c.id, { ...c, pages: [] }]))
const uncategorized = []
for (const page of pages) {
const bucket = page.category_id != null ? byId.get(page.category_id) : null
if (bucket) bucket.pages.push(page)
else uncategorized.push(page)
}
const sections = [...byId.values()].filter((c) => c.pages.length > 0)
if (uncategorized.length) {
sections.push({ id: 'uncategorized', title: 'Other Pages', description: '', pages: uncategorized })
}
return sections
}
function PageCard({ page }) {
return (
<Link to={`/wiki/${page.slug}`} className="card" style={{ padding: 22 }}>
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.1rem', color: 'var(--head)' }}>
{page.title}
</h3>
<p className="muted" style={{ margin: 0, fontSize: '0.92rem' }}>
{page.excerpt || 'Open the guide →'}
</p>
</Link>
)
}
export default function Wiki() {
const { loading, error, data } = useAsync(() => api.wiki())
const pages = data || []
const [searchParams, setSearchParams] = useSearchParams()
const activeCategory = searchParams.get('category')
const activeTag = searchParams.get('tag')
const activeQ = searchParams.get('q')
// Search / tag views fetch a filtered page list; otherwise all pages (grouped here).
const pageOpts = activeQ ? { q: activeQ } : activeTag ? { tag: activeTag } : {}
const { loading, error, data } = useAsync(
() =>
Promise.all([api.wikiCategories(), api.wiki(pageOpts)]).then(([categories, pages]) => ({
categories,
pages,
})),
[activeTag, activeQ],
)
const allSections = data ? groupByCategory(data.categories, data.pages) : []
const sections = activeCategory
? allSections.filter((s) => s.slug === activeCategory)
: allSections
const hasPages = data && data.pages.length > 0
const flat = Boolean(activeTag || activeQ) // flat-list views
const filtered = Boolean(activeCategory || activeTag || activeQ)
const runSearch = (term) => setSearchParams(term ? { q: term } : {})
return (
<PublicLayout section="wiki">
@@ -30,26 +93,61 @@ export default function Wiki() {
center
eyebrow="Knowledge base"
title="Mysticmoon Wiki"
lead="A calm starting point for shard guides, maps, systems, items, monsters, crafting, lore, and rules."
lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules."
/>
<SearchBox initial={activeQ || ''} onSubmit={runSearch} />
{loading && <Loading />}
{error && <ErrorState message="Could not load the wiki right now." />}
{!loading && !error && pages.length === 0 && <EmptyState>No wiki pages yet.</EmptyState>}
<section className="grid-4">
{pages.map((p, i) => (
<Link key={p.slug} to={`/wiki/${p.slug}`} className="card" style={{ padding: 22 }}>
<span className="display" style={{ color: 'var(--accent)', fontSize: '1.4rem', marginBottom: 10 }}>
{ROMAN[i] || i + 1}
</span>
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.1rem', color: 'var(--head)' }}>
{p.title}
</h3>
<p className="muted" style={{ margin: 0, fontSize: '0.92rem' }}>
{BLURBS[p.slug] || 'Open the guide →'}
</p>
{!loading && !error && !hasPages && !filtered && <EmptyState>No wiki pages yet.</EmptyState>}
{!loading && !error && filtered && (
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
All sections
</Link>
{activeTag && <span className="muted"> · Tagged #{activeTag}</span>}
{activeQ && <span className="muted"> · Results for {activeQ}</span>}
</p>
)}
{/* Flat list: search results or a tag filter (both cross categories). */}
{!loading && !error && flat &&
(data.pages.length === 0 ? (
<EmptyState>{activeQ ? 'No pages match that search.' : 'No pages with this tag.'}</EmptyState>
) : (
<div className="grid-4" style={{ marginTop: 12 }}>
{data.pages.map((p) => (
<PageCard key={p.slug} page={p} />
))}
</div>
))}
{/* Category / full view: grouped sections. */}
{!loading && !error && !flat && hasPages && activeCategory && sections.length === 0 && (
<EmptyState>No pages in this section yet.</EmptyState>
)}
{!flat &&
sections.map((section) => (
<section key={section.id} style={{ marginTop: 36 }}>
<h2
className="display"
style={{ margin: '0 0 4px', fontSize: '1.5rem', color: 'var(--accent)' }}
>
{section.title}
</h2>
{section.description && (
<p className="muted" style={{ margin: '0 0 16px', fontSize: '0.95rem' }}>
{section.description}
</p>
)}
<div className="grid-4" style={{ marginTop: section.description ? 0 : 12 }}>
{section.pages.map((p) => (
<PageCard key={p.slug} page={p} />
))}
</div>
</section>
))}
</div>
</PublicLayout>
)

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom'
import DOMPurify from 'dompurify'
import PublicLayout from '../../components/PublicLayout.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
@@ -13,24 +14,36 @@ function slugify(text) {
.replace(/(^-|-$)/g, '')
}
// Parse the stored body HTML: assign ids to <h2> headings and collect a TOC.
function buildArticle(body) {
// Parse the stored body HTML: sanitize (defense in depth — the server also
// sanitizes on save), assign ids to <h2> headings and collect a TOC, and mark
// internal links to pages that don't exist as "red links".
function buildArticle(body, missing) {
if (!body) return { html: '', toc: [] }
if (typeof window === 'undefined' || !window.DOMParser) return { html: body, toc: [] }
const doc = new DOMParser().parseFromString(body, 'text/html')
if (typeof window === 'undefined' || !window.DOMParser) return { html: '', toc: [] }
const safe = DOMPurify.sanitize(body)
const doc = new DOMParser().parseFromString(safe, 'text/html')
const toc = []
doc.querySelectorAll('h2').forEach((h, i) => {
const id = slugify(h.textContent || '') || `section-${i}`
h.id = id
toc.push({ id, label: h.textContent })
})
doc.querySelectorAll('a[href^="/wiki/"]').forEach((a) => {
const target = a.getAttribute('href').replace(/^\/wiki\//, '').replace(/[#?].*$/, '')
a.removeAttribute('target') // internal links stay in-app
if (missing.has(target)) {
a.classList.add('wiki-red-link')
a.setAttribute('title', 'This page does not exist yet')
}
})
return { html: doc.body.innerHTML, toc }
}
export default function WikiArticle() {
const { slug } = useParams()
const { loading, error, data: page } = useAsync(() => api.wikiPage(slug), [slug])
const { html, toc } = useMemo(() => buildArticle(page?.body), [page])
const missing = useMemo(() => new Set(page?.missing_links || []), [page])
const { html, toc } = useMemo(() => buildArticle(page?.body, missing), [page, missing])
return (
<PublicLayout section="wiki">
@@ -77,6 +90,17 @@ export default function WikiArticle() {
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Wiki
</Link>
{page.category_title && (
<>
<span>/</span>
<Link
to={`/wiki?category=${page.category_slug}`}
style={{ color: 'var(--accent)', textDecoration: 'none' }}
>
{page.category_title}
</Link>
</>
)}
<span>/</span>
<span>{page.title}</span>
</p>
@@ -86,6 +110,15 @@ export default function WikiArticle() {
<p className="sans" style={{ margin: '18px 0 0', color: 'var(--dim)', fontSize: '0.78rem', letterSpacing: '0.04em' }}>
Last updated {longDate(page.updated_at) || '—'}
</p>
{page.tags && page.tags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 14 }}>
{page.tags.map((t) => (
<Link key={t.slug} to={`/wiki?tag=${t.slug}`} className="wiki-tag">
#{t.label}
</Link>
))}
</div>
)}
<div style={{ height: 1, background: 'var(--line)', margin: '30px 0' }} />
{html ? (
<div className="prose" dangerouslySetInnerHTML={{ __html: html }} />
@@ -93,6 +126,28 @@ export default function WikiArticle() {
<p className="muted">This page has no content yet.</p>
)}
{page.backlinks && page.backlinks.length > 0 && (
<section
style={{ marginTop: 40, borderTop: '1px solid var(--line)', paddingTop: 22 }}
>
<p
className="sans"
style={{ margin: '0 0 12px', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase' }}
>
Linked from
</p>
<ul style={{ margin: 0, paddingLeft: 18, fontFamily: 'var(--sans)', fontSize: '0.92rem' }}>
{page.backlinks.map((b) => (
<li key={b.slug} style={{ marginBottom: 6 }}>
<Link to={`/wiki/${b.slug}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{b.title}
</Link>
</li>
))}
</ul>
</section>
)}
<nav style={{ display: 'flex', justifyContent: 'flex-start', marginTop: 40 }}>
<Link to="/wiki" className="pill">
All wiki pages

View File

@@ -312,6 +312,221 @@ button[disabled] {
border: 1px solid var(--line);
}
/* ===== Rich text editor (TipTap) ===== */
.rte {
position: relative;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
}
.rte:focus-within {
border-color: var(--accent);
}
.rte-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
background: var(--panel-flat);
}
.rte-btn {
min-width: 30px;
height: 30px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-family: var(--sans);
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.rte-btn:hover:not([disabled]) {
background: var(--blue);
color: var(--ink);
}
.rte-btn.is-active {
background: var(--blue);
border-color: var(--accent);
color: var(--accent-bright);
}
.rte-btn[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
.rte-sep {
width: 1px;
align-self: stretch;
margin: 2px 4px;
background: var(--line);
}
.rte-content {
padding: 14px 16px;
max-height: 460px;
overflow-y: auto;
}
.rte-content .ProseMirror {
min-height: 220px;
outline: none;
}
.rte-content .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
color: var(--dim);
pointer-events: none;
}
/* Internal-link picker popover */
.rte-linkmenu {
position: absolute;
z-index: 20;
top: 50px;
left: 10px;
width: min(360px, calc(100% - 20px));
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-a);
box-shadow: var(--shadow-card);
}
.rte-linkmenu-list {
margin-top: 8px;
max-height: 220px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.rte-linkmenu-item {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 7px 9px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text);
font-family: var(--sans);
font-size: 0.88rem;
text-align: left;
cursor: pointer;
}
.rte-linkmenu-item:hover {
background: var(--blue);
color: var(--ink);
}
.rte-linkmenu-slug {
color: var(--dim);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.76rem;
}
/* ===== Wiki connectivity (tags + red links) ===== */
.wiki-tag {
display: inline-block;
padding: 3px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(127, 153, 189, 0.1);
color: var(--accent);
font-family: var(--sans);
font-size: 0.78rem;
text-decoration: none;
}
.wiki-tag:hover {
border-color: var(--accent);
background: var(--blue);
}
.prose a.wiki-red-link {
color: #d98b84;
border-bottom: 1px dotted #d98b84;
}
/* ===== Wiki revision history ===== */
.wiki-history {
display: grid;
grid-template-columns: 240px 1fr;
gap: 18px;
align-items: start;
}
@media (max-width: 640px) {
.wiki-history {
grid-template-columns: 1fr;
}
}
.wiki-history-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.wiki-rev {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-flat);
color: var(--text);
text-align: left;
cursor: pointer;
}
.wiki-rev:hover {
border-color: var(--accent);
}
.wiki-rev.is-active {
border-color: var(--accent);
background: var(--blue);
}
.wiki-rev-note {
font-family: var(--sans);
font-size: 0.86rem;
color: var(--head);
}
.wiki-rev-latest {
color: var(--accent);
font-size: 0.74rem;
}
.wiki-rev-meta {
font-family: var(--sans);
font-size: 0.72rem;
color: var(--dim);
}
.wiki-diff {
white-space: pre-wrap;
word-break: break-word;
font-family: var(--sans);
font-size: 0.92rem;
line-height: 1.6;
color: var(--text);
max-height: 360px;
overflow-y: auto;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
}
.diff-add {
background: rgba(95, 185, 138, 0.22);
color: #b9e6cd;
}
.diff-del {
background: rgba(176, 102, 95, 0.24);
color: #e3b0aa;
text-decoration: line-through;
}
/* ===== Admin tables ===== */
.adm-table {
width: 100%;

View File

@@ -28,15 +28,74 @@ CREATE TABLE IF NOT EXISTS posts (
INDEX idx_posts_feed (category, published, published_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Wiki categories / sections. Defined before wiki_pages so the FK resolves on a
-- fresh install. Pages reference a category (nullable = "Uncategorized").
CREATE TABLE IF NOT EXISTS wiki_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
description VARCHAR(400) NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS wiki_pages (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
body MEDIUMTEXT NULL,
excerpt VARCHAR(400) NULL,
category_id INT NULL,
published TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
published_at DATETIME NULL,
CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_wiki_category FOREIGN KEY (category_id) REFERENCES wiki_categories(id) ON DELETE SET NULL,
FULLTEXT INDEX idx_wiki_search (title, body)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Wiki tags (many-to-many with pages).
CREATE TABLE IF NOT EXISTS wiki_tags (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
label VARCHAR(120) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS wiki_page_tags (
page_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (page_id, tag_id),
CONSTRAINT fk_wpt_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_wpt_tag FOREIGN KEY (tag_id) REFERENCES wiki_tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Internal-link index, rebuilt on each save. target_slug may point at a page
-- that does not exist yet (a "red link").
CREATE TABLE IF NOT EXISTS wiki_links (
source_page_id INT NOT NULL,
target_slug VARCHAR(120) NOT NULL,
CONSTRAINT fk_wiki_links_src FOREIGN KEY (source_page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
INDEX idx_wiki_links_target (target_slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Per-save content snapshots for history / diff / restore.
CREATE TABLE IF NOT EXISTS wiki_revisions (
id INT AUTO_INCREMENT PRIMARY KEY,
page_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
body MEDIUMTEXT NULL,
excerpt VARCHAR(400) NULL,
category_id INT NULL,
editor_id INT NULL,
change_note VARCHAR(280) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_wiki_rev_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_wiki_rev_editor FOREIGN KEY (editor_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_wiki_rev_page (page_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS settings (
@@ -57,3 +116,15 @@ CREATE TABLE IF NOT EXISTS activity_log (
CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_activity_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
-- (The category foreign key is only added on fresh installs; on upgraded databases
-- referential integrity for category_id is enforced in application code.)
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL;
ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body);

View File

@@ -22,24 +22,39 @@ const DEFAULT_SETTINGS = {
site_title: 'UOMysticmoon',
}
// The 8 starter wiki categories (editable later via the admin panel).
// Starter wiki sections (editable later via the admin panel).
// [slug, title, description, sort_order]
const WIKI_CATEGORIES = [
['guides', 'Guides', 'Getting started and how-to guides.', 10],
['world', 'World & Lore', 'Regions, maps, and the story of Mysticmoon.', 20],
['gameplay', 'Systems & Gameplay', 'Mechanics, items, monsters, and crafting.', 30],
['community', 'Community & Rules', 'Player conduct and shard policies.', 40],
]
// The 8 starter pages, each mapped to a section. [slug, title, body, categorySlug]
const WIKI_PAGES = [
['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.'],
['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.'],
['systems', 'Server Systems', 'Shard mechanics and custom features.'],
['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.'],
['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.'],
['crafting', 'Crafting', 'Professions, materials, recipes, and tools.'],
['lore', 'Lore', 'Stories, places, factions, and mysteries.'],
['rules', 'Rules', 'Player conduct, shard expectations, and policies.'],
['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.', 'guides'],
['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.', 'world'],
['lore', 'Lore', 'Stories, places, factions, and mysteries.', 'world'],
['systems', 'Server Systems', 'Shard mechanics and custom features.', 'gameplay'],
['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.', 'gameplay'],
['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.', 'gameplay'],
['crafting', 'Crafting', 'Professions, materials, recipes, and tools.', 'gameplay'],
['rules', 'Rules', 'Player conduct, shard expectations, and policies.', 'community'],
]
async function seedDefaults() {
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
await settingsDb.seedDefault(key, value)
}
for (const [slug, title, body] of WIKI_PAGES) {
for (const [slug, title, description, sortOrder] of WIKI_CATEGORIES) {
await wikiDb.seedDefaultCategory(slug, title, description, sortOrder)
}
for (const [slug, title, body, categorySlug] of WIKI_PAGES) {
await wikiDb.seedDefault(slug, title, body)
// Attach to its section (only if not already categorized — safe re-run /
// migration of pages seeded before the wiki upgrade).
await wikiDb.assignCategoryBySlug(slug, categorySlug)
}
log.info('settings and wiki defaults ensured')
}

228
server/package-lock.json generated
View File

@@ -21,7 +21,8 @@
"mariadb": "^3.3.1",
"morgan": "^1.10.0",
"multer": "^2.0.1",
"nodemailer": "^9.0.1"
"nodemailer": "^9.0.1",
"sanitize-html": "^2.17.5"
},
"devDependencies": {
"nodemon": "^3.1.4"
@@ -345,6 +346,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -354,6 +361,15 @@
"ms": "2.0.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -382,6 +398,73 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -432,6 +515,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -468,6 +563,18 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -729,6 +836,25 @@
"node": ">=16.0.0"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -829,6 +955,15 @@
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -878,6 +1013,15 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1097,6 +1241,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1221,6 +1383,12 @@
"node": ">= 0.8"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1236,6 +1404,12 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
@@ -1249,6 +1423,34 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1362,6 +1564,21 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.5",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
"integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^10.1.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -1510,6 +1727,15 @@
"node": ">=10"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View File

@@ -9,7 +9,12 @@
"seed": "node db/seed.js",
"test": "echo \"no tests yet\" && exit 0"
},
"keywords": ["express", "mariadb", "jwt", "bcrypt"],
"keywords": [
"express",
"mariadb",
"jwt",
"bcrypt"
],
"author": "whitlocktech",
"license": "ISC",
"dependencies": {
@@ -25,7 +30,8 @@
"mariadb": "^3.3.1",
"morgan": "^1.10.0",
"multer": "^2.0.1",
"nodemailer": "^9.0.1"
"nodemailer": "^9.0.1",
"sanitize-html": "^2.17.5"
},
"devDependencies": {
"nodemon": "^3.1.4"

View File

@@ -1,33 +1,273 @@
const { query } = require('../../utils/db')
async function listSummaries() {
return query('SELECT slug, title, updated_at FROM wiki_pages ORDER BY title ASC')
// Full page row + joined category fields.
const PAGE_COLS =
'p.id, p.slug, p.title, p.body, p.excerpt, p.category_id, p.published, p.sort_order, ' +
'p.updated_by, p.created_at, p.updated_at, p.published_at, ' +
'c.slug AS category_slug, c.title AS category_title'
// List rows omit the body (lighter payload for indexes/tables).
const SUMMARY_COLS =
'p.id, p.slug, p.title, p.excerpt, p.category_id, p.published, p.sort_order, ' +
'p.updated_at, p.published_at, c.slug AS category_slug, c.title AS category_title'
const FROM = 'FROM wiki_pages p LEFT JOIN wiki_categories c ON c.id = p.category_id'
const ORDER = 'ORDER BY p.sort_order ASC, p.title ASC'
// Shared summary query builder with optional category / tag / status filters.
function buildSummaryQuery({ categoryId = null, tagId = null, published = null }) {
const joins = []
const where = []
const params = []
if (tagId != null) {
joins.push('JOIN wiki_page_tags pt ON pt.page_id = p.id AND pt.tag_id = ?')
params.push(tagId)
}
if (categoryId != null) {
where.push('p.category_id = ?')
params.push(categoryId)
}
if (published != null) {
where.push('p.published = ?')
params.push(published ? 1 : 0)
}
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
return {
sql: `SELECT ${SUMMARY_COLS} ${FROM} ${joins.join(' ')} ${clause} ${ORDER}`,
params,
}
}
// Published summaries (public). Optional category / tag filters.
async function listPublishedSummaries({ categoryId = null, tagId = null } = {}) {
const { sql, params } = buildSummaryQuery({ categoryId, tagId, published: true })
return query(sql, params)
}
// All summaries (admin), with optional category / tag / status filters.
async function listAllSummaries({ categoryId = null, tagId = null, published = null } = {}) {
const { sql, params } = buildSummaryQuery({ categoryId, tagId, published })
return query(sql, params)
}
async function findBySlug(slug) {
const rows = await query('SELECT * FROM wiki_pages WHERE slug = ? LIMIT 1', [slug])
const rows = await query(`SELECT ${PAGE_COLS} ${FROM} WHERE p.slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
async function insert({ slug, title, body, updatedBy = null }) {
async function findPublishedBySlug(slug) {
const rows = await query(
`SELECT ${PAGE_COLS} ${FROM} WHERE p.slug = ? AND p.published = 1 LIMIT 1`,
[slug],
)
return rows[0] || null
}
// Full-text search over title + body, ordered by relevance.
async function searchSummaries(q, { publishedOnly = true } = {}) {
const pub = publishedOnly ? 'AND p.published = 1' : ''
return query(
`SELECT ${SUMMARY_COLS} ${FROM}
WHERE MATCH(p.title, p.body) AGAINST (? IN NATURAL LANGUAGE MODE) ${pub}
ORDER BY MATCH(p.title, p.body) AGAINST (?) DESC, p.title ASC`,
[q, q],
)
}
// ── Page writes ────────────────────────────────────────────────────────
async function insert({
slug,
title,
body = null,
excerpt = null,
categoryId = null,
published = true,
sortOrder = 0,
updatedBy = null,
}) {
const res = await query(
'INSERT INTO wiki_pages (slug, title, body, updated_by) VALUES (?, ?, ?, ?)',
[slug, title, body || null, updatedBy],
'INSERT INTO wiki_pages (slug, title, body, excerpt, category_id, published, sort_order, published_at, updated_by) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
slug,
title,
body,
excerpt,
categoryId,
published ? 1 : 0,
sortOrder,
published ? new Date() : null,
updatedBy,
],
)
return res.insertId
}
async function updateBySlug(slug, { title, body, updatedBy = null }) {
await query(
'UPDATE wiki_pages SET title = ?, body = ?, updated_by = ? WHERE slug = ?',
[title, body || null, updatedBy, slug],
)
// Dynamic update — only the provided columns are written.
async function updateBySlug(slug, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(slug)
await query(`UPDATE wiki_pages SET ${cols.join(', ')} WHERE slug = ?`, params)
}
async function deleteBySlug(slug) {
return query('DELETE FROM wiki_pages WHERE slug = ?', [slug])
}
// ── Categories ─────────────────────────────────────────────────────────
const CAT_COLS = 'id, slug, title, description, sort_order, created_at, updated_at'
// Categories with page counts (total + published) for index/admin views.
async function listCategories() {
return query(
`SELECT c.id, c.slug, c.title, c.description, c.sort_order, c.created_at, c.updated_at,
(SELECT COUNT(*) FROM wiki_pages p WHERE p.category_id = c.id) AS page_count,
(SELECT COUNT(*) FROM wiki_pages p WHERE p.category_id = c.id AND p.published = 1) AS published_count
FROM wiki_categories c
ORDER BY c.sort_order ASC, c.title ASC`,
)
}
async function findCategoryBySlug(slug) {
const rows = await query(`SELECT ${CAT_COLS} FROM wiki_categories WHERE slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
async function findCategoryById(id) {
const rows = await query(`SELECT ${CAT_COLS} FROM wiki_categories WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function insertCategory({ slug, title, description = null, sortOrder = 0 }) {
const res = await query(
'INSERT INTO wiki_categories (slug, title, description, sort_order) VALUES (?, ?, ?, ?)',
[slug, title, description, sortOrder],
)
return res.insertId
}
async function updateCategory(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE wiki_categories SET ${cols.join(', ')} WHERE id = ?`, params)
}
// Detach pages first (works even on upgraded DBs that lack the FK), then delete.
async function deleteCategory(id) {
await query('UPDATE wiki_pages SET category_id = NULL WHERE category_id = ?', [id])
return query('DELETE FROM wiki_categories WHERE id = ?', [id])
}
// ── Tags ───────────────────────────────────────────────────────────────
async function listTags() {
return query(
`SELECT t.id, t.slug, t.label,
(SELECT COUNT(*) FROM wiki_page_tags pt
JOIN wiki_pages p ON p.id = pt.page_id
WHERE pt.tag_id = t.id AND p.published = 1) AS published_count
FROM wiki_tags t ORDER BY t.label ASC`,
)
}
async function findTagBySlug(slug) {
const rows = await query('SELECT id, slug, label FROM wiki_tags WHERE slug = ? LIMIT 1', [slug])
return rows[0] || null
}
async function getTagsForPage(pageId) {
return query(
'SELECT t.slug, t.label FROM wiki_tags t ' +
'JOIN wiki_page_tags pt ON pt.tag_id = t.id WHERE pt.page_id = ? ORDER BY t.label ASC',
[pageId],
)
}
async function upsertTag(slug, label) {
await query('INSERT INTO wiki_tags (slug, label) VALUES (?, ?) ON DUPLICATE KEY UPDATE label = VALUES(label)', [
slug,
label,
])
const rows = await query('SELECT id FROM wiki_tags WHERE slug = ? LIMIT 1', [slug])
return rows[0].id
}
async function setPageTags(pageId, tagIds) {
await query('DELETE FROM wiki_page_tags WHERE page_id = ?', [pageId])
for (const tagId of tagIds) {
await query('INSERT IGNORE INTO wiki_page_tags (page_id, tag_id) VALUES (?, ?)', [pageId, tagId])
}
}
// Drop tags no longer attached to any page (keeps the tag list tidy).
async function deleteOrphanTags() {
return query('DELETE FROM wiki_tags WHERE id NOT IN (SELECT tag_id FROM wiki_page_tags)')
}
// ── Internal links / backlinks ─────────────────────────────────────────
async function clearLinks(pageId) {
return query('DELETE FROM wiki_links WHERE source_page_id = ?', [pageId])
}
async function insertLink(pageId, targetSlug) {
return query('INSERT INTO wiki_links (source_page_id, target_slug) VALUES (?, ?)', [pageId, targetSlug])
}
// Pages that link TO targetSlug (excludes the page linking to itself).
async function getBacklinks(targetSlug, { publishedOnly = true } = {}) {
const pub = publishedOnly ? 'AND p.published = 1' : ''
return query(
`SELECT DISTINCT p.slug, p.title FROM wiki_links l
JOIN wiki_pages p ON p.id = l.source_page_id
WHERE l.target_slug = ? AND p.slug <> ? ${pub}
ORDER BY p.title ASC`,
[targetSlug, targetSlug],
)
}
// Of the given slugs, which actually exist (for red-link detection).
async function getExistingSlugs(slugs) {
if (!slugs || slugs.length === 0) return new Set()
const placeholders = slugs.map(() => '?').join(',')
const rows = await query(`SELECT slug FROM wiki_pages WHERE slug IN (${placeholders})`, slugs)
return new Set(rows.map((r) => r.slug))
}
// ── Revisions ──────────────────────────────────────────────────────────
async function insertRevision({ pageId, title, body, excerpt, categoryId, editorId, changeNote }) {
return query(
'INSERT INTO wiki_revisions (page_id, title, body, excerpt, category_id, editor_id, change_note) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)',
[pageId, title, body || null, excerpt || null, categoryId ?? null, editorId ?? null, changeNote || null],
)
}
async function listRevisions(pageId) {
return query(
`SELECT r.id, r.change_note, r.created_at, r.editor_id, u.username AS editor
FROM wiki_revisions r LEFT JOIN users u ON u.id = r.editor_id
WHERE r.page_id = ? ORDER BY r.id DESC`,
[pageId],
)
}
async function findRevision(id) {
const rows = await query('SELECT * FROM wiki_revisions WHERE id = ? LIMIT 1', [id])
return rows[0] || null
}
// ── Seeding (idempotent) ───────────────────────────────────────────────
async function seedDefault(slug, title, body) {
await query('INSERT IGNORE INTO wiki_pages (slug, title, body) VALUES (?, ?, ?)', [
slug,
@@ -36,11 +276,52 @@ async function seedDefault(slug, title, body) {
])
}
async function seedDefaultCategory(slug, title, description, sortOrder = 0) {
await query(
'INSERT IGNORE INTO wiki_categories (slug, title, description, sort_order) VALUES (?, ?, ?, ?)',
[slug, title, description || null, sortOrder],
)
}
// Assign a seeded page to a category by slug, only if not already categorized —
// migrates pre-upgrade pages without clobbering manual changes.
async function assignCategoryBySlug(pageSlug, categorySlug) {
await query(
'UPDATE wiki_pages SET category_id = (SELECT id FROM wiki_categories WHERE slug = ?) ' +
'WHERE slug = ? AND category_id IS NULL',
[categorySlug, pageSlug],
)
}
module.exports = {
listSummaries,
listPublishedSummaries,
listAllSummaries,
findBySlug,
findPublishedBySlug,
searchSummaries,
insert,
updateBySlug,
deleteBySlug,
listCategories,
findCategoryBySlug,
findCategoryById,
insertCategory,
updateCategory,
deleteCategory,
listTags,
findTagBySlug,
getTagsForPage,
upsertTag,
setPageTags,
deleteOrphanTags,
clearLinks,
insertLink,
getBacklinks,
getExistingSlugs,
insertRevision,
listRevisions,
findRevision,
seedDefault,
seedDefaultCategory,
assignCategoryBySlug,
}

View File

@@ -0,0 +1,15 @@
// Extract internal wiki-link targets from a saved (already sanitized) body.
// Internal links are anchors to /wiki/<slug> or elements carrying a
// data-wiki-slug attribute. Returns a de-duplicated array of slugs.
function extractTargets(html) {
if (!html) return []
const targets = new Set()
const hrefRe = /href="\/wiki\/([a-z0-9-]+)"/g
const dataRe = /data-wiki-slug="([a-z0-9-]+)"/g
let m
while ((m = hrefRe.exec(html))) targets.add(m[1])
while ((m = dataRe.exec(html))) targets.add(m[1])
return [...targets]
}
module.exports = { extractTargets }

View File

@@ -1,25 +1,241 @@
const wikiDb = require('./wiki.db')
const { cleanBody } = require('../../utils/sanitizeHtml')
const { extractTargets } = require('./wiki.links')
async function list() {
return wikiDb.listSummaries()
function slugifyTag(label) {
return String(label)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
// Upsert each label into wiki_tags and set the page's tag set exactly.
async function syncTags(pageId, tags) {
const ids = []
const seen = new Set()
for (const raw of tags) {
const label = String(raw).trim()
if (!label) continue
const slug = slugifyTag(label)
if (!slug || seen.has(slug)) continue
seen.add(slug)
ids.push(await wikiDb.upsertTag(slug, label))
}
await wikiDb.setPageTags(pageId, ids)
await wikiDb.deleteOrphanTags()
}
// Rebuild the page's outgoing internal-link rows from its (sanitized) body.
async function rebuildLinks(pageId, html) {
await wikiDb.clearLinks(pageId)
for (const target of extractTargets(html)) {
await wikiDb.insertLink(pageId, target)
}
}
// Snapshot the current content of a page into the revision history.
async function writeRevision(page, editorId, changeNote = null) {
await wikiDb.insertRevision({
pageId: page.id,
title: page.title,
body: page.body,
excerpt: page.excerpt,
categoryId: page.category_id,
editorId,
changeNote,
})
}
// ── Pages ──────────────────────────────────────────────────────────────
async function listPublished(filters = {}) {
return wikiDb.listPublishedSummaries(filters)
}
async function listAll(filters = {}) {
return wikiDb.listAllSummaries(filters)
}
async function search(q, opts = {}) {
return wikiDb.searchSummaries(q, opts)
}
// Admin detail: page + its tags.
async function getBySlug(slug) {
return wikiDb.findBySlug(slug)
const page = await wikiDb.findBySlug(slug)
if (!page) return null
page.tags = await wikiDb.getTagsForPage(page.id)
return page
}
async function create({ slug, title, body, updatedBy }) {
await wikiDb.insert({ slug, title, body, updatedBy })
return wikiDb.findBySlug(slug)
// Public detail: page + tags + backlinks + missing (red) link targets.
async function getPublishedBySlug(slug) {
const page = await wikiDb.findPublishedBySlug(slug)
if (!page) return null
page.tags = await wikiDb.getTagsForPage(page.id)
page.backlinks = await wikiDb.getBacklinks(slug, { publishedOnly: true })
const targets = extractTargets(page.body)
const existing = await wikiDb.getExistingSlugs(targets)
page.missing_links = targets.filter((t) => !existing.has(t))
return page
}
async function update(slug, { title, body, updatedBy }) {
await wikiDb.updateBySlug(slug, { title, body, updatedBy })
return wikiDb.findBySlug(slug)
async function create({ slug, title, body, excerpt, categoryId, published, updatedBy, tags }) {
const clean = cleanBody(body)
await wikiDb.insert({
slug,
title,
body: clean,
excerpt: excerpt || null,
categoryId: categoryId ?? null,
published: published !== false,
updatedBy,
})
const page = await wikiDb.findBySlug(slug)
if (Array.isArray(tags)) await syncTags(page.id, tags)
await rebuildLinks(page.id, clean)
await writeRevision(page, updatedBy, 'Created')
return getBySlug(slug)
}
// Partial update — only keys present in `input` are written.
async function update(slug, input) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { updated_by: input.updatedBy ?? null }
let cleanForLinks = null
if ('title' in input) fields.title = input.title
if ('body' in input) {
fields.body = cleanBody(input.body)
cleanForLinks = fields.body
}
if ('excerpt' in input) fields.excerpt = input.excerpt || null
if ('categoryId' in input) fields.category_id = input.categoryId ?? null
if ('published' in input) {
fields.published = input.published ? 1 : 0
if (input.published && !current.published_at) fields.published_at = new Date()
}
await wikiDb.updateBySlug(slug, fields)
if (Array.isArray(input.tags)) await syncTags(current.id, input.tags)
if (cleanForLinks != null) await rebuildLinks(current.id, cleanForLinks)
const page = await wikiDb.findBySlug(slug)
await writeRevision(page, input.updatedBy ?? null, input.changeNote || null)
return getBySlug(slug)
}
async function setPublished(slug, published) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { published: published ? 1 : 0 }
if (published && !current.published_at) fields.published_at = new Date()
await wikiDb.updateBySlug(slug, fields)
return getBySlug(slug)
}
async function remove(slug) {
return wikiDb.deleteBySlug(slug)
const res = await wikiDb.deleteBySlug(slug)
await wikiDb.deleteOrphanTags() // page_tags cascade on delete; drop now-empty tags
return res
}
module.exports = { list, getBySlug, create, update, remove }
// ── Revisions ──────────────────────────────────────────────────────────
async function listRevisions(slug) {
const page = await wikiDb.findBySlug(slug)
if (!page) return null
return wikiDb.listRevisions(page.id)
}
async function getRevision(slug, revId) {
const page = await wikiDb.findBySlug(slug)
if (!page) return null
const rev = await wikiDb.findRevision(revId)
if (!rev || rev.page_id !== page.id) return null
return rev
}
// Restore an old revision: overwrite the page with the snapshot, rebuild links,
// then record a new revision (history stays append-only).
async function restoreRevision(slug, revId, editorId) {
const page = await wikiDb.findBySlug(slug)
if (!page) return null
const rev = await wikiDb.findRevision(revId)
if (!rev || rev.page_id !== page.id) return null
await wikiDb.updateBySlug(slug, {
title: rev.title,
body: rev.body,
excerpt: rev.excerpt,
category_id: rev.category_id,
updated_by: editorId,
})
await rebuildLinks(page.id, rev.body)
const restored = await wikiDb.findBySlug(slug)
await writeRevision(restored, editorId, `Restored from revision #${revId}`)
return getBySlug(slug)
}
// ── Tags ───────────────────────────────────────────────────────────────
async function listTags() {
return wikiDb.listTags()
}
async function getTagBySlug(slug) {
return wikiDb.findTagBySlug(slug)
}
// ── Categories ─────────────────────────────────────────────────────────
async function listCategories() {
return wikiDb.listCategories()
}
async function getCategoryBySlug(slug) {
return wikiDb.findCategoryBySlug(slug)
}
async function getCategoryById(id) {
return wikiDb.findCategoryById(id)
}
async function createCategory({ slug, title, description, sortOrder }) {
const id = await wikiDb.insertCategory({ slug, title, description, sortOrder })
return wikiDb.findCategoryById(id)
}
async function updateCategory(id, input) {
const fields = {}
if ('title' in input) fields.title = input.title
if ('slug' in input) fields.slug = input.slug
if ('description' in input) fields.description = input.description || null
if ('sortOrder' in input) fields.sort_order = input.sortOrder
await wikiDb.updateCategory(id, fields)
return wikiDb.findCategoryById(id)
}
async function removeCategory(id) {
return wikiDb.deleteCategory(id)
}
module.exports = {
listPublished,
listAll,
search,
getBySlug,
getPublishedBySlug,
create,
update,
setPublished,
remove,
listRevisions,
getRevision,
restoreRevision,
listTags,
getTagBySlug,
listCategories,
getCategoryBySlug,
getCategoryById,
createCategory,
updateCategory,
removeCategory,
}

View File

@@ -160,10 +160,33 @@ async function uploadImage(req, res) {
return res.status(201).json({ image_url: imageUrl })
}
// ── Wiki ──────────────────────────────────────────────────────────────
// Generalized upload used by rich-text editors (wiki, etc.). Same multer config
// as the screenshot upload; returns a neutral { url }.
async function uploadFile(req, res) {
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
const url = `/uploads/${req.file.filename}`
await activity.log({ req, action: 'upload', detail: { url } })
return res.status(201).json({ url })
}
// ── Wiki pages ─────────────────────────────────────────────────────────
async function listWiki(req, res) {
try {
return res.json(await wiki.list())
const q = (req.query.q || '').trim()
if (q) return res.json(await wiki.search(q, { publishedOnly: false }))
const filters = {}
if (req.query.category) {
const category = await wiki.getCategoryBySlug(req.query.category)
filters.categoryId = category ? category.id : -1 // unknown → match nothing
}
if (req.query.tag) {
const tag = await wiki.getTagBySlug(req.query.tag)
filters.tagId = tag ? tag.id : -1 // unknown → match nothing
}
if (req.query.status === 'draft') filters.published = false
if (req.query.status === 'published') filters.published = true
return res.json(await wiki.listAll(filters))
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
@@ -179,15 +202,33 @@ async function getWiki(req, res) {
}
}
// Resolve a category_id from the request, validating it exists. Returns
// { ok, value } so the caller can distinguish "not provided" from "invalid".
async function resolveCategoryId(body) {
if (!('category_id' in body) || body.category_id == null || body.category_id === '') {
return { ok: true, value: null }
}
const category = await wiki.getCategoryById(Number(body.category_id))
if (!category) return { ok: false }
return { ok: true, value: category.id }
}
async function createWiki(req, res) {
try {
if (await wiki.getBySlug(req.body.slug)) {
return res.status(409).json({ message: 'A page with that slug already exists' })
}
const cat = await resolveCategoryId(req.body)
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
const page = await wiki.create({
slug: req.body.slug,
title: req.body.title,
body: req.body.body || null,
excerpt: req.body.excerpt || null,
categoryId: cat.value,
published: req.body.published !== false,
tags: Array.isArray(req.body.tags) ? req.body.tags : undefined,
updatedBy: req.user.id,
})
await activity.log({ req, action: 'wiki.create', detail: { slug: page.slug } })
@@ -202,11 +243,21 @@ async function updateWiki(req, res) {
try {
const existing = await wiki.getBySlug(req.params.slug)
if (!existing) return res.status(404).json({ message: 'Not found' })
const page = await wiki.update(req.params.slug, {
title: req.body.title,
body: req.body.body || null,
updatedBy: req.user.id,
})
const input = { updatedBy: req.user.id }
if ('title' in req.body) input.title = req.body.title
if ('body' in req.body) input.body = req.body.body || null
if ('excerpt' in req.body) input.excerpt = req.body.excerpt || null
if ('published' in req.body) input.published = Boolean(req.body.published)
if ('change_note' in req.body) input.changeNote = req.body.change_note
if ('tags' in req.body) input.tags = Array.isArray(req.body.tags) ? req.body.tags : []
if ('category_id' in req.body) {
const cat = await resolveCategoryId(req.body)
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
input.categoryId = cat.value
}
const page = await wiki.update(req.params.slug, input)
await activity.log({ req, action: 'wiki.update', detail: { slug: req.params.slug } })
return res.json(page)
} catch (err) {
@@ -215,6 +266,22 @@ async function updateWiki(req, res) {
}
}
async function publishWiki(req, res) {
try {
const page = await wiki.setPublished(req.params.slug, Boolean(req.body.published))
if (!page) return res.status(404).json({ message: 'Not found' })
await activity.log({
req,
action: 'wiki.publish',
detail: { slug: req.params.slug, published: Boolean(req.body.published) },
})
return res.json(page)
} catch (err) {
log.error('publishWiki', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteWiki(req, res) {
try {
await wiki.remove(req.params.slug)
@@ -225,6 +292,121 @@ async function deleteWiki(req, res) {
}
}
// ── Wiki revisions ─────────────────────────────────────────────────────
async function listWikiRevisions(req, res) {
try {
const revisions = await wiki.listRevisions(req.params.slug)
if (revisions == null) return res.status(404).json({ message: 'Not found' })
return res.json(revisions)
} catch (err) {
log.error('listWikiRevisions', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWikiRevision(req, res) {
try {
const rev = await wiki.getRevision(req.params.slug, Number(req.params.id))
if (!rev) return res.status(404).json({ message: 'Not found' })
return res.json(rev)
} catch (err) {
log.error('getWikiRevision', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function restoreWikiRevision(req, res) {
try {
const page = await wiki.restoreRevision(req.params.slug, Number(req.params.id), req.user.id)
if (!page) return res.status(404).json({ message: 'Not found' })
await activity.log({
req,
action: 'wiki.revision.restore',
detail: { slug: req.params.slug, revision: Number(req.params.id) },
})
return res.json(page)
} catch (err) {
log.error('restoreWikiRevision', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Wiki tags ──────────────────────────────────────────────────────────
async function listWikiTags(req, res) {
try {
return res.json(await wiki.listTags())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Wiki categories ────────────────────────────────────────────────────
async function listWikiCategories(req, res) {
try {
return res.json(await wiki.listCategories())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function createWikiCategory(req, res) {
try {
if (await wiki.getCategoryBySlug(req.body.slug)) {
return res.status(409).json({ message: 'A category with that slug already exists' })
}
const category = await wiki.createCategory({
slug: req.body.slug,
title: req.body.title,
description: req.body.description || null,
sortOrder: Number(req.body.sort_order) || 0,
})
await activity.log({ req, action: 'wiki.category.create', detail: { slug: category.slug } })
return res.status(201).json(category)
} catch (err) {
log.error('createWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function updateWikiCategory(req, res) {
const id = Number(req.params.id)
try {
const existing = await wiki.getCategoryById(id)
if (!existing) return res.status(404).json({ message: 'Not found' })
const input = {}
if ('title' in req.body) input.title = req.body.title
if ('description' in req.body) input.description = req.body.description || null
if ('sort_order' in req.body) input.sortOrder = Number(req.body.sort_order) || 0
if ('slug' in req.body && req.body.slug !== existing.slug) {
const clash = await wiki.getCategoryBySlug(req.body.slug)
if (clash) return res.status(409).json({ message: 'A category with that slug already exists' })
input.slug = req.body.slug
}
const category = await wiki.updateCategory(id, input)
await activity.log({ req, action: 'wiki.category.update', detail: { id } })
return res.json(category)
} catch (err) {
log.error('updateWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteWikiCategory(req, res) {
const id = Number(req.params.id)
try {
const existing = await wiki.getCategoryById(id)
if (!existing) return res.status(404).json({ message: 'Not found' })
await wiki.removeCategory(id) // pages in it become uncategorized
await activity.log({ req, action: 'wiki.category.delete', detail: { id } })
return res.json({ id })
} catch (err) {
log.error('deleteWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Settings ──────────────────────────────────────────────────────────
async function getSettings(req, res) {
try {
@@ -342,11 +524,21 @@ module.exports = {
publishPost,
deletePost,
uploadImage,
uploadFile,
listWiki,
getWiki,
createWiki,
updateWiki,
publishWiki,
deleteWiki,
listWikiRevisions,
getWikiRevision,
restoreWikiRevision,
listWikiTags,
listWikiCategories,
createWikiCategory,
updateWikiCategory,
deleteWikiCategory,
getSettings,
updateSettings,
listActivity,

View File

@@ -54,6 +54,8 @@ adminRouter.post(
ctrl.createPost,
)
adminRouter.post('/posts/upload', upload.single('image'), ctrl.uploadImage)
// Generalized upload (rich-text editors). Same multer middleware; returns { url }.
adminRouter.post('/uploads', upload.single('image'), ctrl.uploadFile)
adminRouter.get('/posts/:id', param('id').isInt(), validate, ctrl.getPost)
adminRouter.put('/posts/:id', param('id').isInt(), validate, ctrl.updatePost)
adminRouter.patch(
@@ -65,22 +67,71 @@ adminRouter.patch(
)
adminRouter.delete('/posts/:id', param('id').isInt(), validate, ctrl.deletePost)
// ── Wiki ──────────────────────────────────────────────────────────────
// ── Wiki categories (static paths registered before /wiki/:slug) ───────
adminRouter.get('/wiki/categories', ctrl.listWikiCategories)
adminRouter.post(
'/wiki/categories',
body('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('sort_order').optional().isInt(),
validate,
ctrl.createWikiCategory,
)
adminRouter.put(
'/wiki/categories/:id',
param('id').isInt(),
body('slug').optional().matches(/^[a-z0-9-]+$/),
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('sort_order').optional().isInt(),
validate,
ctrl.updateWikiCategory,
)
adminRouter.delete('/wiki/categories/:id', param('id').isInt(), validate, ctrl.deleteWikiCategory)
// ── Wiki tags ──────────────────────────────────────────────────────────
adminRouter.get('/wiki/tags', ctrl.listWikiTags)
// ── Wiki pages ─────────────────────────────────────────────────────────
adminRouter.get('/wiki', ctrl.listWiki)
adminRouter.post(
'/wiki',
body('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty(),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('category_id').optional({ values: 'null' }).isInt(),
body('published').optional().isBoolean(),
body('tags').optional().isArray(),
validate,
ctrl.createWiki,
)
adminRouter.get('/wiki/:slug', ctrl.getWiki)
adminRouter.put(
'/wiki/:slug',
body('title').isString().trim().notEmpty(),
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('category_id').optional({ values: 'null' }).isInt(),
body('published').optional().isBoolean(),
body('tags').optional().isArray(),
body('change_note').optional({ values: 'falsy' }).isString().isLength({ max: 280 }),
validate,
ctrl.updateWiki,
)
adminRouter.patch(
'/wiki/:slug/publish',
body('published').isBoolean(),
validate,
ctrl.publishWiki,
)
adminRouter.get('/wiki/:slug/revisions', ctrl.listWikiRevisions)
adminRouter.get('/wiki/:slug/revisions/:id', param('id').isInt(), validate, ctrl.getWikiRevision)
adminRouter.post(
'/wiki/:slug/revisions/:id/restore',
param('id').isInt(),
validate,
ctrl.restoreWikiRevision,
)
adminRouter.delete('/wiki/:slug', ctrl.deleteWiki)
// ── Settings ──────────────────────────────────────────────────────────

View File

@@ -50,9 +50,40 @@ async function getPost(req, res) {
}
}
async function getWikiCategories(req, res) {
try {
return res.json(await wiki.listCategories())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWikiTags(req, res) {
try {
return res.json(await wiki.listTags())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWikiList(req, res) {
try {
return res.json(await wiki.list())
// Full-text search takes precedence over category/tag filters.
const q = (req.query.q || '').trim()
if (q) return res.json(await wiki.search(q, { publishedOnly: true }))
const filters = {}
if (req.query.category) {
const category = await wiki.getCategoryBySlug(req.query.category)
if (!category) return res.json([]) // unknown category → no pages
filters.categoryId = category.id
}
if (req.query.tag) {
const tag = await wiki.getTagBySlug(req.query.tag)
if (!tag) return res.json([]) // unknown tag → no pages
filters.tagId = tag.id
}
return res.json(await wiki.listPublished(filters))
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
@@ -60,7 +91,8 @@ async function getWikiList(req, res) {
async function getWikiPage(req, res) {
try {
const page = await wiki.getBySlug(req.params.slug)
// Public sees published pages only; drafts 404 like any missing page.
const page = await wiki.getPublishedBySlug(req.params.slug)
if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page)
} catch (err) {
@@ -84,6 +116,8 @@ module.exports = {
getStatus,
getPosts,
getPost,
getWikiCategories,
getWikiTags,
getWikiList,
getWikiPage,
contact,

View File

@@ -25,6 +25,9 @@ publicRouter.post(
publicRouter.get('/posts/:category', siteMode, ctrl.getPosts)
publicRouter.get('/posts/:category/:idOrSlug', siteMode, ctrl.getPost)
publicRouter.get('/wiki', siteMode, ctrl.getWikiList)
// Static paths must precede the :slug route so they aren't captured as a slug.
publicRouter.get('/wiki/categories', siteMode, ctrl.getWikiCategories)
publicRouter.get('/wiki/tags', siteMode, ctrl.getWikiTags)
publicRouter.get('/wiki/:slug', siteMode, ctrl.getWikiPage)
module.exports = publicRouter

View File

@@ -0,0 +1,45 @@
const sanitizeHtml = require('sanitize-html')
// Allowlist for wiki/post body HTML. Anything not listed is stripped. This runs
// on every save so the stored value is already safe; the client re-sanitizes on
// render as defense in depth. Tuned for rich-text content from the admin editor.
const OPTIONS = {
allowedTags: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr', 'blockquote', 'pre', 'code',
'ul', 'ol', 'li',
'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span',
'a', 'img', 'figure', 'figcaption',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
allowedAttributes: {
a: ['href', 'name', 'target', 'rel', 'title'],
img: ['src', 'alt', 'title', 'width', 'height'],
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan'],
},
// http/https for links and images, mailto for links, plus relative URLs so
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through.
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesByTag: { img: ['http', 'https'] },
allowProtocolRelative: false,
// Force safe rel on links that open a new tab; drop empty/odd attributes.
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
},
disallowedTagsMode: 'discard',
}
/**
* Sanitize a block of body HTML against the allowlist above.
* Null/empty input is returned unchanged.
* @param {string|null|undefined} html
* @returns {string|null|undefined}
*/
function cleanBody(html) {
if (html == null || html === '') return html
return sanitizeHtml(String(html), OPTIONS)
}
module.exports = { cleanBody, OPTIONS }