Compare commits
5 Commits
673c0400c5
...
7bb992f58d
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bb992f58d | |||
| 7c081ae749 | |||
| 4a7dbf0085 | |||
| b925114923 | |||
| dd1f61222d |
366
WIKI_UPGRADE.md
Normal file
366
WIKI_UPGRADE.md
Normal 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
836
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
174
client/src/components/RichTextEditor.jsx
Normal file
174
client/src/components/RichTextEditor.jsx
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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>
|
||||
)
|
||||
}
|
||||
@@ -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,9 +24,22 @@ 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>
|
||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||
+ New page
|
||||
</button>
|
||||
<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 />}
|
||||
@@ -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}
|
||||
<span
|
||||
style={{ display: 'block', fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)', fontSize: '0.78rem' }}
|
||||
>
|
||||
{w.slug}
|
||||
</span>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}>
|
||||
{w.slug}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
182
client/src/routes/admin/views/WikiCategories.jsx
Normal file
182
client/src/routes/admin/views/WikiCategories.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
<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">Body (HTML — use <h2> for the table of contents)</span>
|
||||
<textarea value={form.body} onChange={set('body')} className="textarea" style={{ minHeight: 260 }} />
|
||||
<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()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
139
client/src/routes/admin/views/WikiHistory.jsx
Normal file
139
client/src/routes/admin/views/WikiHistory.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
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,
|
||||
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);
|
||||
|
||||
@@ -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
228
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
15
server/src/model/wiki/wiki.links.js
Normal file
15
server/src/model/wiki/wiki.links.js
Normal 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 }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
45
server/src/utils/sanitizeHtml.js
Normal file
45
server/src/utils/sanitizeHtml.js
Normal 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 }
|
||||
Reference in New Issue
Block a user