Author SHA1 Message Date
mytheclipsebotreview 5a00fd1909 fix: auto-fix code quality [skip ci] 2026-08-29 10:33:38 +07:00
mytheclipsebotreview[bot] 48566081f0 Auto-merge PR #10
chore(deps): bump next from 16.3.2 to 16.3.3 in the production group
2026-08-29 03:31:14 +00:00
mytheclipsebotreview 10a15a133c fix: auto-fix code quality [skip ci] 2026-08-29 10:30:56 +07:00
mytheclipsebotreview 5b1345cf9e chore: sync bun.lock after dependabot bump [skip ci] 2026-08-29 10:27:30 +07:00
mytheclipsebotreview 4ca5f0af39 Merge remote-tracking branch 'origin/main' into dependabot/npm_and_yarn/production-2c8a7f9a5b 2026-08-29 10:27:29 +07:00
mytheclipsebotreview 283d66b5e0 chore: sync bun.lock after @types/node@26 bump [skip ci] 2026-08-29 11:21:52 +08:00
mytheclipsebotreview[bot] b311c363c5 Auto-merge PR #12
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
chore(deps-dev): bump @types/node from 20.19.43 to 26.3.0
2026-08-29 02:57:10 +00:00
mytheclipsebotreview 006bf79655 fix: auto-fix code quality [skip ci] 2026-08-29 09:56:57 +07:00
dependabot[bot] 1a45ba9fdb chore(deps-dev): bump @types/node from 20.19.43 to 26.3.0
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.43 to 26.3.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.3.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 17:55:21 +00:00
dependabot[bot] b2fa2a98e0 chore(deps-dev): bump eslint-config-next in the development group
Bumps the development group with 1 update: [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `eslint-config-next` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.3/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 17:55:09 +00:00
dependabot[bot] 4d424e67ad chore(deps): bump next from 16.3.2 to 16.3.3 in the production group
Bumps the production group with 1 update: [next](https://github.com/vercel/next.js).


Updates `next` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.2...v16.3.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 17:54:52 +00:00
asepharyana 263a92f368 Merge pull request #8 from asepharyana/dependabot/npm_and_yarn/production-7059fa7707
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
chore(deps): bump the production group across 1 directory with 2 updates
2026-08-26 09:12:50 +07:00
Asep Hariyana f44e66bcd5 chore: sync lockfile for dependabot update 2026-08-26 10:12:44 +08:00
asepharyana 66e6fad876 Merge pull request #9 from asepharyana/dependabot/npm_and_yarn/development-9df26a4104
chore(deps-dev): bump the development group across 1 directory with 2 updates
2026-08-26 09:12:39 +07:00
Asep Hariyana befb898c45 chore: sync lockfile for dependabot update 2026-08-26 10:12:33 +08:00
dependabot[bot] 0638c4a82d chore(deps): bump the production group across 1 directory with 2 updates
Bumps the production group with 2 updates in the / directory: [next](https://github.com/vercel/next.js) and [drizzle-orm](https://github.com/drizzle-team/drizzle-orm).


Updates `next` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.1...v16.3.2)

Updates `drizzle-orm` from 0.38.4 to 0.45.2
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.38.4...0.45.2)

---
updated-dependencies:
- dependency-name: drizzle-orm
  dependency-version: 0.45.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: next
  dependency-version: 16.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 01:55:57 +00:00
dependabot[bot] e412767847 chore(deps-dev): bump the development group across 1 directory with 2 updates
Bumps the development group with 2 updates in the / directory: [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) and [drizzle-kit](https://github.com/drizzle-team/drizzle-orm).


Updates `eslint-config-next` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.2/packages/eslint-config-next)

Updates `drizzle-kit` from 0.30.6 to 0.31.10
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/drizzle-kit@0.30.6...drizzle-kit@0.31.10)

---
updated-dependencies:
- dependency-name: drizzle-kit
  dependency-version: 0.31.10
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: eslint-config-next
  dependency-version: 16.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 01:55:46 +00:00
asepharyana ea4f34271c chore: update dependabot-auto-merge.yml
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-26 08:53:44 +07:00
asepharyana caabe05c68 chore: update dependabot.yml 2026-08-26 08:53:43 +07:00
asepharyana 9d8d876ba7 feat: add dependabot auto-merge workflow
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-25 20:32:58 +07:00
asepharyana 8f98bed7dd feat: update dependabot config
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-25 18:16:53 +07:00
asepharyana bd3a0894b4 feat: update dependabot config 2026-08-25 18:13:06 +07:00
asepharyana 03616b4e60 feat: add dependabot config (npm auto-deps)
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-25 18:08:45 +07:00
asepharyana 5ccba958d2 feat: add Word (.docx) export, enhance print density, and fix auth security guards
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 17:59:32 +07:00
asepharyana 067115ba13 style(web): refine aesthetic to archival precision, remove generic gradients and glowing blobs
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 15:12:00 +07:00
asepharyana 011571e12d feat(web): overhaul frontend with cyber-intelligence design and interactive topology
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 14:55:19 +07:00
asepharyana 9591c67478 feat(export): remove author, scope, and slug metadata for cleaner minimal writeup look
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 14:06:36 +07:00
asepharyana d65c5613d3 fix(print): prevent code block header/body page break split and ensure atomic code block flow 2026-08-22 14:03:35 +07:00
asepharyana 4f0d2da876 feat(export): simplify writeup export to pure human-like markdown layout without cards and exclude _index documents
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 13:49:00 +07:00
asepharyana 72b53afe06 feat(export): remove cover header banner and confidential footer line from PDF view 2026-08-22 13:45:57 +07:00
asepharyana 5d85226ff0 feat(export): redesign PDF view for formal publication styling without colors or watermarks
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 12:22:01 +07:00
asepharyana 5195a65ca5 ci: use sudo when syncing .next on VPS
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 12:08:20 +07:00
asepharyana 666ba855a2 fix(web): fix working directory in mcpedia-web wrapper script 2026-08-22 12:04:22 +07:00
asepharyana 8adfffb6e7 fix(deploy): correctly copy prebuilt .next to apps/web/.next on VPS during deployment 2026-08-22 12:00:30 +07:00
asepharyana 864911af20 feat(export): enhance export routing to support /[section]/export, /[section]/[slug]/export, and dynamic route rendering
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 11:55:58 +07:00
asepharyana 96b8765966 ci: sync git repository on VPS during deployment 2026-08-22 11:50:27 +07:00
asepharyana 5cd9ea92a7 feat(export): add single PDF export for sections and events with chapter ordering
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 11:41:49 +07:00
MythEclipse 2a70a111a2 fix: strip leading whitespace before shebang in mcpedia-web wrapper script
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
The heredoc-based script writer in web.nix was indenting content inside
a nix '' string, which caused bash to prepend whitespace before the
#!/usr/bin/env bash shebang. The kernel rejected this as 'Exec format
error' (203/EXEC), preventing systemd from starting mcpedia-web.service.

Fix: use writeShellScriptBin instead of a raw heredoc. This ensures the
shebang starts at column 0 and uses the nix-resolved bash path.
2026-08-21 21:24:16 +07:00
MythEclipse c22e394f98 fix: bundle @mcpedia/* deps in nix build, fix runtime module resolution
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
Replace --external approach (which failed because @mcpedia/* workspace
packages couldn't resolve their npm deps like ioredis/bullmq at runtime)
with full bundling. The key fixes:

1. Bundle all code into a single JS file (no --external for @mcpedia/*)
   - This inlines @mcpedia/core, @mcpedia/queue, ioredis, bullmq, etc.
   - Produces ~2.6MB self-contained binary, no node_modules needed

2. Fix .bun cache symlink issues in nix sandbox:
   - After bun install, copy .bun cache with cp -rL to dereference
     relative symlinks that break in nix build sandbox
   - This was the root cause of 'File not found msgpackr-extract' errors

3. Only @mcpedia/config, embeddings, types kept external (lightweight,
   no npm deps) - resolved via packages/ source symlinks at runtime

All 3 services (api, mcp, worker) now build and run successfully in nix store.
Resolves CI failure run 32474463114 ('Cannot find module @mcpedia/core').
2026-08-21 20:36:43 +07:00
asepharyana 54d5c0ee01 ci: simplify web.nix (symlink .next, bunx next start from repo), fix CI args
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-21 17:50:30 +07:00
asepharyana c92caede57 ci: fix SSH variable escaping in deploy step ( not \)
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-21 17:27:23 +07:00
asepharyana e01ee22438 fix: nix build with dontFixup, root node_modules copy, web path literal fix
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-21 17:17:39 +07:00
asepharyana aedcfbf4e7 fix: nix build with dontFixup for .bun symlink issues, use root node_modules + Attic cache push
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-21 17:03:28 +07:00
asepharyana c6b0b2549a ci: use Attic binary cache push + nix-store substitute (not SSH copy)
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
- Bootstrap attic client from gmw cache (HTTPS substituter)
- Push store paths to pub:mcpedia cache
- VPS substitutes from mcpedia cache (fast) instead of nix copy --to ssh://
- Fallback to SSH copy if Attic push fails
2026-08-21 16:41:56 +07:00
asepharyana 34b3b8f56b ci: fix nix-build SSH args, web.nix path handling, NIX_SSHOPTS inline
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-21 16:16:53 +07:00
asepharyana 53e0b442a4 ci: trigger CI
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) (api) (push) Canceled after 0s
CI / build + deploy (Nix) (mcp) (push) Canceled after 0s
CI / build + deploy (Nix) (worker) (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-21 16:09:07 +07:00
asepharyana 5fdf5ffb64 ci: Nix-based build + deploy for all services
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) (api) (push) Canceled after 0s
CI / build + deploy (Nix) (mcp) (push) Canceled after 0s
CI / build + deploy (Nix) (worker) (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
- flake.nix: 4 packages (web, api, mcp, worker) built via bun in Nix
- web.nix: pre-built .next packaged into Nix store (avoids Turbopack/Bun sandbox issue)
- ci.yml: test (bun) → build-and-deploy (api/mcp/worker via nix build) + deploy-web (.next artifact → nix-build web.nix)
- Deploy: nix copy → nix-env --set + systemctl restart (no tarball+SSH, no VPS builds)
- systemd units updated to use Nix profile paths (/nix/var/nix/profiles/mcpedia-*)
2026-08-21 15:56:22 +07:00
asepharyana b88e9b796c ci: migrate to Nix-based build + deploy
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) (api) (push) Canceled after 0s
CI / build + deploy (Nix) (mcp) (push) Canceled after 0s
CI / build + deploy (Nix) (web) (push) Canceled after 0s
CI / build + deploy (Nix) (worker) (push) Canceled after 0s
- Add flake.nix with 4 packages: web, api, mcp, worker
  Each builds via bun in CI sandbox, packages dist/.next + node_modules
  into Nix store, wraps with makeBinaryWrapper
- Rewrite ci.yml: test (bun typecheck+tests) → build-and-deploy (Nix matrix)
  nix build → nix copy → nix-env --set + systemctl restart on VPS
  No tarball+SSH, no VPS builds
- Gitignore content/**/*.md (DB is source of truth)
- Update AGENTS.md architecture docs
2026-08-21 15:30:48 +07:00
asepharyana d62c5d33a4 feat: enhance Markdown component with syntax highlighting and improve code snippet display
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s
2026-08-21 15:17:09 +07:00
asepharyana 87ce3ebbc6 chore: gitignore content/ .md files, remove from git tracking
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s
DB is the source of truth via MCP API. Filesystem .md files
are auto-generated backups, not tracked in git. The git-sync
webhook is secondary — content is created/updated via
create_document/update_document MCP tools (DB-first).
2026-08-21 14:33:20 +07:00
asepharyana 0de2458955 docs: create GEMASTIK 2026 Warmup writeups (ch1-14) via MCP DB API
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s
- Deleted all 14 dummy docs from DB + filesystem
- Created 14 Gemastik warmup writeups + index via create_document API (DB-first)
- ch1-ch12: encoding/stego/web/pwn basics
- ch13: RSA small factors
- ch14: RSA special integers via paper-search (GCD with paper appendix primes)
- All 15 docs indexed and live at wiki.asepharyana.my.id
2026-08-21 14:28:04 +07:00
asepharyana 7a6e717c28 docs: replace dummy docs with GEMASTIK 2026 Warmup writeups (ch1-14)
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s
- Deleted all previous dummy docs (defcon-quals-2024, infra/cloudflare-525, debugging/websocket-timeout, template, docs/*, notes/*, research/*)
- Added 14 Gemastik warmup writeups covering encoding, stego, web, pwn, crypto
- ch13: RSA small factors; ch14: RSA special integers via paper-search (GCD with paper appendix primes)
- All flags recovered and documented with solution methods
2026-08-21 13:51:31 +07:00
asepharyana be923cb432 fix: replace all hardcoded hex colors with CSS theme variables
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s
Phase 15c — theme-consistent color system:

Replaced 63 instances of hardcoded hex colors (#5e6ad2, #7170ff, #4f46e5,
#6a75e0, #828fff, #94a3b8, etc.) with CSS variables from globals.css:
- #5e6ad2 → var(--brand) (light: #5e6ad2, dark: #5e6ad2)
- #7170ff → var(--accent) (light: #4f46e5, dark: #7170ff)
- #4f46e5 → var(--accent)
- #6a75e0 → var(--brand-hover) (new var, light: #6a75e0, dark: #7170ff)
- #828fff → var(--accent-hover)
- #868686 → var(--text-dim)
- Dark-mode-specific hex in Markdown.tsx → CSS variables

Added --brand-hover CSS variable (light: #6a75e0, dark: #7170ff)
Added dark:prose-invert instead of prose-invert (light mode now uses
default prose theme instead of forced dark)

Files changed: 15 component files + globals.css
2026-08-21 13:06:01 +07:00
asepharyana ea75d68b1a fix: Phase 14 + Phase 15 — UI/UX polish + CI/CD restructure
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s
=== UI/UX (Phase 14 + 15) ===
- Remove 'body' field from showing as custom badge (added to STANDARD_KEYS)
- Capitalize doc type: 'documentation' → 'Documentation', 'writeup' → 'Writeup'
- Sidebar tree: border-l per depth level + section separators (tee-style)
- Homepage tree: use doc titles from DB (not path segments)
- Centralize section config in packages/config/src/sections.ts (no hardcode in UI)
- Client-safe config entrypoint: @mcpedia/config/sections (avoids node:fs in client)

=== CI/CD (Phase 15b) ===
- Bump actions/checkout v4 → v5 (Node 20 deprecation)
- Fix: 'bun --cwd X run Y' doesn't work (bun run rejects --cwd flag)
  → use 'working-directory: X' instead
- Fix: .next/ is a hidden directory — upload-artifact needs include-hidden-files: true
- Merge CI + Deploy into single workflow (workflow_run can't share artifacts)
- VPS no longer builds: CI builds .next/ → uploads artifact → VPS deploys via tarball+scp+ssh
- MCP smoke test made conditional on DATABASE_URL (CI has no Postgres)
2026-08-21 12:59:05 +07:00
asepharyana f14f3e92c5 debug: use absolute path for upload-artifact 2026-08-21 12:13:11 +07:00
asepharyana 6077476254 debug: fix duplicate ls step, use .next without trailing slash
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s
2026-08-21 12:09:47 +07:00
asepharyana b7d8c455d4 chore: add debug step to list .next contents in CI 2026-08-21 12:07:02 +07:00
asepharyana 0234c5cbe6 fix: make MCP smoke test conditional on DATABASE_URL
Smoke test was never running in CI (silently skipped due to 'bun --cwd' bug).
Now that it runs, it fails because CI has no Postgres access. Make the smoke
test conditional on env.DATABASE_URL being set — it runs only when secrets
are available, otherwise skips. Unit tests (auth.test.ts) still cover tool
registration logic without a DB.
2026-08-21 12:02:26 +07:00
asepharyana d8da598f1d fix: update MCP smoke test to include 4 new tools (create/delete/list_sections/update)
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s
Smoke test was silently failing in CI because 'bun --cwd apps/mcp run smoke'
printed usage (exit 0) — never actually ran. Now that we fixed the build step
to use 'cd apps/mcp && bun run smoke', the test runs and reveals 4 tools
were added to the MCP server but not updated in the expected list:
create_document, delete_document, list_sections, update_document.

Updated smoke.ts expected list from 10 → 14 tools.
2026-08-21 11:57:55 +07:00
asepharyana 628068e33d fix: CI build steps — use 'cd X && bun run' not 'bun --cwd'
Root cause: 'bun run' subcommand does NOT support --cwd flag. Both
'Build web' and 'MCP smoke test' steps used 'bun --cwd apps/X run build/smoke'
which silently printed usage and exited 0 — no actual build/test ran.
This meant:
1. No .next/ was produced → artifact upload found no files → deploy failed
2. Smoke test was silently skipped

Fix: use 'cd apps/X && bun run Y' pattern instead.
2026-08-21 11:53:15 +07:00
asepharyana 074fa9f316 fix: merge CI + Deploy into single workflow (fixes artifact sharing)
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s
The workflow_run trigger can't access artifacts from the CI run — this
is a known GitHub Actions limitation. Merged deploy into ci.yml as a
'needs: build' job so artifacts are shared properly.

Key changes:
- ci.yml: unified build+deploy workflow
- Build job: typecheck, build web, test, smoke, upload .next artifact
- Deploy job (needs: build): download artifact, SCP to VPS, git pull +
  index + restart. No build on VPS.
- artifact retention: 1 day (only needed for immediate deploy)
2026-08-21 11:49:22 +07:00
asepharyana 596d3cf616 fix: correct artifact name in deploy (mcpedia-web-build not mcpedia-web-standalone)
CI uploads artifact as 'mcpedia-web-build', deploy downloads 'mcpedia-web-build'.
Also fix SCP source path: .next/* not standalone/***.
2026-08-21 11:46:15 +07:00
asepharyana 77792c624b fix: CI/CD — move web build to CI, VPS only deploys (no build on VPS)
- ci.yml: add web build step + upload .next as artifact
- deploy.yml: download artifact via SCP, copy to VPS .next dir
  (no bun run build on VPS — only git pull + install + index + restart)
- Bump actions/checkout@v4 → @v5 (Node 20 deprecation)
- Revert next.config standalone mode (not needed for .next/ copy approach)
- Remove broken bun --cwd (doesn't support --cwd flag)
2026-08-21 11:43:21 +07:00
asepharyana a9b67385c3 feat: revamp to dynamic database-first architecture and cleanup phase docs
CI / typecheck + build (turbo) (push) Canceled after 0s
- Migrate document fetching and CRUD to be PostgreSQL-authoritative
- Remove static section enums and add dynamic listSections query
- Support custom sections and metadata across API, MCP, and Web UI
- Add /api/sections endpoint and update Header, Sidebar, and Forms
- Remove obsolete phase planning docs and modernize README/AGENTS
2026-08-21 11:35:42 +07:00
asepharyana a937e8f51b UI/UX polish: fix doc type capitalization, sidebar tree depth cues, homepage doc-title tree
- Doc page: capitalize doc type (Documentation not documentation)
- Sidebar: tree-style with border-l per depth level, section separators
- Homepage: use doc titles from DB for tree nodes (not path segments)
- CustomFieldBadges: body field excluded from badges (added to STANDARD_KEYS)
- All section config centralized in packages/config/src/sections.ts
2026-08-21 11:33:42 +07:00
asepharyana e8ed5980fc fix: deploy script — replace broken bun --cwd with subshell cd for VPS build
CI / typecheck + build (turbo) (push) Canceled after 0s
The 'bun --cwd apps/web run build' on VPS was silently failing —
bun doesn't support --cwd flag, printed usage, exited 0. Then on
the GitHub runner, turbo couldn't find package manager binary.
Fix: use 'cd apps/web && bun run build && cd ..' in SSH script.
CI build step also uses turbo --filter for proper workspace isolation.
2026-08-21 11:24:47 +07:00
asepharyana 90e40c4d48 fix: CI/CD — replace broken bun --cwd with turbo --filter for web build
CI / typecheck + build (turbo) (push) Canceled after 0s
The deploy workflow used 'bun --cwd apps/web run build' which does NOT
work — 'bun run' doesn't support --cwd flag. Bun prints usage and exits
0, making the step silently succeed without building. Fix: use
'bun run build --filter=@mcpedia/web' (turbo workspace filtering).

Also bump actions/checkout@v4→v5 to resolve Node 20 deprecation warning.
2026-08-21 11:20:06 +07:00
asepharyana aedfb022f3 fix(theme): support full dynamic Light Mode and Dark Mode with CSS variables and storage sync 2026-08-21 11:13:05 +07:00
asepharyana 62116be88d feat(ui): overhaul UI/UX with CommandMenu, modern Linear design, interactive TOC, and live preview
CI / typecheck + build (turbo) (push) Canceled after 0s
2026-08-21 11:07:52 +07:00
asepharyana a67a8c6469 feat: complete search REST route, restore disk sync, and extraFields support across API/MCP 2026-08-21 11:03:04 +07:00
asepharyana 989ee9c464 fix: remove body field badge, improve sidebar tree styling, use doc titles in homepage tree
CI / typecheck + build (turbo) (push) Canceled after 0s
- STANDARD_KEYS now includes 'body' to prevent it from rendering as a custom field badge
- Sidebar: tree-style with border-l + ml-2 indentation per level, section separators with border-b, better depth cues
- Homepage buildFolderTree: uses doc.title from DB (not path segments), so _index shows 'CTF Writeups' instead of 'Index'
- Sidebar: _index path segments no longer show leading-space titles (slugToTitle filters empty)
2026-08-21 10:39:01 +07:00
asepharyana 251a6e9b4d refactor: centralize section config — remove all hardcoded section lists
CI / typecheck + build (turbo) (push) Canceled after 0s
- New packages/config/src/sections.ts: SECTIONS, SECTIONS_BY_ID, SECTION_IDS
  (no Node.js built-in imports — safe for client components)
- New package entrypoint: @mcpedia/config/sections (exports only sections.ts)
- Client components import from @mcpedia/config/sections (no node:fs in bundle)
- Server components import from @mcpedia/config (has env/fs, server-only)
- Removed hardcoded SECTIONS/SECTION_LABEL/SECTION_ICON from:
  - [section]/page.tsx (was inline array)
  - [section]/[...slug]/page.tsx (was hardcoded Record maps)
  - Sidebar.tsx (was inline array)
  - layout.tsx (was inline array)
  - page.tsx (was inline array)
  - create/page.tsx (was inline array via SECTIONS import)
- Added @mcpedia/config/sections entrypoint to package.json exports
2026-08-21 10:29:35 +07:00
asepharyana 26a900a030 refactor: Phase 14 UI/UX polish pass
CI / typecheck + build (turbo) (push) Canceled after 0s
- Section index: doc-title-based tree, folder doc counts, active+parent highlighting, recent list with dates, doc counter badge
- Folder index: rich card listing with doc titles (not path slugs), dates, tags, subfolder grid with doc counts, breadcrumb
- Doc page: metadata card (bg-[#0f1011] border), structured header with author/date/tags, badge title tooltips, improved FolderIndex breadcrumb
- Sidebar: recursive hierarchical tree with depth indentation + 16px/level, folder doc counts, section icons, active parent highlighting, alphabetical sort
- DocForm: clean 2-column grid layout, no duplicate slug field, edit-mode loads existing custom fields
- Homepage: inline folder tree per section card
- Header nav: added Writeups/Research/Notes links
- Deleted duplicate apps/web/app/docs/page.tsx (superseded by generic [section]/page.tsx)
- Added CSS vars: --color-border-subtle, --color-border-standard, --color-text-tertiary, --color-text-quaternary
2026-08-21 09:56:40 +07:00
asepharyana b8d4fc97da refactor: Phase 14 consistency pass — remove duplicate, fix breadcrumbs, header nav, DocForm
CI / typecheck + build (turbo) (push) Canceled after 0s
- Delete apps/web/app/docs/page.tsx (superseded by generic [section]/page.tsx)
- Homepage: inline folder tree per section card (📁📄), removed Suspense
- Doc page breadcrumbs: dynamic path from doc.slug (was hardcoded 'Docs')
- Header nav: added Writeups/Research/Notes section links
- DocForm: parent folder dropdown populated from existing folders, slug read-
  only in edit mode, edit mode keeps section disabled, resolved slug shown
- create/page.tsx: use extractFoldersForSection from @mcpedia/core (dedup)
- Removed unused extractFoldersForSection import from page.tsx
2026-08-21 09:02:55 +07:00
asepharyana cc99bd2b74 feat: Phase 14 — hierarchical folder structure (GitHub-style nested folders)
CI / typecheck + build (turbo) (push) Canceled after 0s
- Section index pages ([section]/page.tsx): folder tree + flat doc list
- Folder index pages: [section]/[...slug]/page.tsx detects folder paths
  and renders subfolder + document listing instead of 404
- Sidebar tree: hierarchical grouping from flat doc slugs, folder icons
- DocForm v2: parent folder dropdown populated from existing folders
- Core helpers: extractFoldersForSection + classifyPath exported from
  @mcpedia/core
- CTF writeup reorganized into pwn/ subfolder + _index.md folder intros
- PHASES.md Phase 14 documentation
2026-08-21 08:44:35 +07:00
asepharyana ab3a2dc456 feat: Phase 14 — hierarchical folder structure (GitHub-style nested folders)
CI / typecheck + build (turbo) (push) Canceled after 0s
- Section index pages ([section]/page.tsx): shows folder tree + flat doc list
- Folder index pages: [section]/[...slug]/page.tsx detects folder paths
  and renders subfolder + document listing instead of 404
- Sidebar tree: hierarchical grouping from flat doc slugs, folder icons (📁)
  with proper indentation per depth level
- DocForm v2: parent folder dropdown (populated from existing folders),
  slug input for leaf name, resolved path display
- Core helpers: extractFoldersForSection + classifyPath exported from @mcpedia/core
- CTF writeup reorganized: pwn-100 ret2win moved into pwn/ subfolder
- _index.md folder intro pages for ctf/ and defcon-quals-2024/
- STANDARD_KEYS includes extraFields to avoid badge duplication
2026-08-21 08:37:41 +07:00
asepharyana 90a43e5d0c docs: update PHASES.md Phase 13 — fully dynamic value-based field styling + preserved types
CI / typecheck + build (turbo) (push) Canceled after 0s
2026-08-21 00:50:50 +07:00
asepharyana ceb53adba4 fix: preserve value types in extraFields (no string coercion)
CI / typecheck + build (turbo) (push) Canceled after 0s
- DocumentMeta.extraFields type -> Record<string, unknown>
- API routes pass extraFields through without JSON.stringify coercion
- CustomFieldBadges auto-styles by value type/content (number->purple, boolean->green/red, etc.)
- DocForm help text: generic (no hardcoded field examples)
2026-08-21 00:45:32 +07:00
asepharyana edbd812c30 feat: fully dynamic custom field badges (auto-style by value, not key name)
CI / typecheck + build (turbo) (push) Canceled after 0s
- CustomFieldBadges now auto-detects styling based on VALUE TYPE+CONTENT
  (number→purple points style, difficulty strings→color-coded, event-like
  strings→purple, categories→orange, status→color-coded)
- DocForm help text made generic (no hardcoded field examples)
- Removed typed CTF fields from DocumentMeta (event/challenge/etc) —
  everything flows through extraFields JSONB for true dynamic behavior
2026-08-21 00:31:36 +07:00
asepharyana b31948faba docs: update PHASES.md with Phase 13 (CTF writeup template system + table rendering fix)
CI / typecheck + build (turbo) (push) Canceled after 0s
2026-08-20 23:58:35 +07:00
asepharyana 040d6cb4ff fix: use explicit path for indexer in deploy to avoid bun run resolving to wrong package.json 2026-08-20 23:54:31 +07:00
asepharyana c611601525 fix: remove db:push from deploy (column manually applied; push is non-interactive-unfriendly)
CI / typecheck + build (turbo) (push) Canceled after 0s
2026-08-20 23:48:42 +07:00
asepharyana 233e88e911 fix: pipe 'yes' to db:push in deploy for non-interactive confirmation 2026-08-20 23:45:05 +07:00
asepharyana 3411dcfa9f fix: make db:push non-fatal in deploy (column may pre-exist)
CI / typecheck + build (turbo) (push) Canceled after 0s
2026-08-20 23:34:51 +07:00
asepharyana 144e124bcb fix: use db:push instead of db:migrate in deploy (schema was initially pushed, not migrated)
CI / typecheck + build (turbo) (push) Canceled after 0s
2026-08-20 23:27:36 +07:00
asepharyana 7fb55590bd feat: dynamic custom frontmatter fields + CTF writeup template system
User requested dynamic (not static) fields for CTF writeup content organization.
Changes:
- DocumentMeta: removed typed CTF fields (event/challenge/category/difficulty/
  points), replaced with single extraFields?: Record<string, string>
- parseFile: any frontmatter key not in the STANDARD set becomes a dynamic
  extra field — fully content-driven, no code changes needed for new fields
- stringifyFile: writes extraFields back to YAML frontmatter for round-trip
  stability
- DocForm: '+ Add field' UI lets creators add ANY metadata key at create/edit
  time
- API routes: splitPayload() auto-separates standard vs custom fields
- Doc page: CustomFieldBadges dynamically renders any custom field with
  auto-styling for common CTF patterns (difficulty→color, points→badge)
- Added db:migrate + db:push scripts; deploy workflow now runs migrations +
  reindexes content on every deploy
- content/writeups/ctf/template/writeup-template.md (template)
- content/writeups/ctf/defcon-quals-2024/pwn-100-ret2win-alignment.md (sample)
- DB column extra_fields (jsonb) already applied to live DB
2026-08-20 23:24:57 +07:00
asepharyana c760c3c087 feat(core): dynamic custom frontmatter fields for CTF writeup metadata
CI / typecheck + build (turbo) (push) Canceled after 0s
- Add extra_fields JSONB column to documents table (migration 0003)
- CreateDocInput/UpdateDocInput: extraFields?: Record<string, string>
- parseFile: extracts non-standard frontmatter keys as extraFields
- stringifyFile: writes extraFields dynamically to YAML frontmatter
- toMeta: spreads extraFields from DB row into DocumentMeta
- DocForm: '+ Add field' UI for content creators to add any metadata
- API routes: splitPayload() separates standard vs custom fields
- Doc page: CustomFieldBadges dynamically renders any custom field
- Add db:migrate / db:push scripts + deploy workflow migration step
- Add CTF writeup template (content/writeups/ctf/template/)
- Add sample DEF CON writeup with event/challenge/category/difficulty/points
- Add @tailwindcss/typography for table rendering (prose classes)
- Add remark-gfm for GitHub Flavored Markdown table parsing
2026-08-20 23:18:57 +07:00
asepharyana 8906ed55a0 fix: add remark-gfm plugin to Markdown component for table rendering
CI / typecheck + build (turbo) (push) Canceled after 0s
react-markdown without remark-gfm doesn't parse GitHub Flavored Markdown
tables — the pipe characters were rendered as plain text instead of <table>
HTML. Fix: install remark-gfm@4 and pass it to ReactMarkdown remarkPlugins.
Also add overflow-x-auto wrapper for table responsiveness.
2026-08-20 22:39:16 +07:00
asepharyana 3cb7879a75 docs: add markdown table to postgres FTS doc for table CSS verification 2026-08-20 22:35:12 +07:00
asepharyana 3938aeb38d fix(web-ui): add @tailwindcss/typography plugin to fix table rendering in docs
The Markdown component uses 'prose' classes (prose-lg prose-headings:)
but @tailwindcss/typography was not installed/registered. Tables and other
MD elements rendered unstyled. Fix: install @tailwindcss/typography@latest,
register via @plugin directive in globals.css, add custom dark-theme
table styles, and remove redundant tailwind.config.js.
2026-08-20 22:32:07 +07:00
asepharyana be9211ef21 feat(web-ui): widen main content container for better card grid layout
CI / typecheck + build (turbo) (push) Canceled after 0s
Increase max-width from 3xl to 4xl/5xl/6xl for xl screens to give the
homepage card grid and docs index more breathing room while keeping
the sidebar at 256px.
2026-08-20 21:46:55 +07:00
asepharyana d9e09dceea feat(web-ui): overhaul homepage with hero section, section overview cards, recent docs strip, and MCP info
- Hero: large headline + tagline + dual CTA buttons + search box
- Section overview: 4-column card grid with icons, descriptions, counts
- Recent documents: card grid sorted by updatedAt
- MCP info footer with server endpoint
- Consistent with docs page layout overhaul
2026-08-20 21:42:52 +07:00
asepharyana d35dd8c887 fix: remove duplicate tag display from section cards
CI / typecheck + build (turbo) (push) Canceled after 0s
2026-08-20 21:38:34 +07:00
asepharyana 44b887a9a0 fix(web-ui): remove client-side onKeyDown from server component to fix 500 on /docs
The search input's onKeyDown handler used window.location.href which is
unavailable during SSR, causing the /docs page to crash. Replaced with
a Link wrapper so it's fully server-rendered.
2026-08-20 21:34:42 +07:00
95 changed files with 8838 additions and 3284 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
name: Dependabot Auto-Merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Enable auto-merge for Dependabot PR
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+18
View File
@@ -0,0 +1,18 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
update-types:
- "minor"
- "patch"
development:
dependency-type: "development"
update-types:
- "minor"
- "patch"
+296 -19
View File
@@ -6,18 +6,24 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
# Cancel in-progress runs for the same ref so a fresh push supersedes them.
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
env:
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
permissions:
contents: read
jobs: jobs:
build: test:
name: typecheck + build (turbo) name: typecheck + tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -27,23 +33,294 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
# Real build: typecheck + turbo run build across all workspace packages
# (incl. the Next.js web app). A green run means the whole monorepo
# compiles and the web app prerenders.
- name: Typecheck - name: Typecheck
run: bun run typecheck run: bun run typecheck
- name: Build
run: bun run build
# Smoke: verify the MCP server + resources load and the indexer/module
# graph resolves. Requires no external services (Redis/Postgres) — pure
# module load + in-memory smoke test.
- name: MCP smoke test
run: bun --cwd apps/mcp run smoke
# Unit tests across all packages + apps. CI has no Postgres/Redis, so
# tests use bun:test module mocking to stub @mcpedia/db, @mcpedia/queue,
# and @mcpedia/embeddings — no live I/O.
- name: Test - name: Test
run: bun run test run: bun run test
# Build .next for web — Next.js/Turbopack has a Bun incompatibility
# in the Nix sandbox. The web derivation (web.nix) packages this
# pre-built .next into a Nix store path.
- name: Build Next.js .next
working-directory: apps/web
run: bun run build
- name: Tar .next
run: tar -C apps/web --exclude='.next/cache' -czf web-next.tar.gz .next
- name: Upload web .next artifact
uses: actions/upload-artifact@v4
with:
name: mcpedia-web-next
path: web-next.tar.gz
if-no-files-found: error
build-and-deploy:
name: build + deploy (Nix) — ${{ matrix.service }}
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service: [api, mcp, worker]
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v16
with:
determinate: false
extra-conf: |
sandbox = false
accept-flake-config = true
# Attic caches as substituters: lets CI pull the prebuilt attic
# client (and any cached deps/builds) over HTTPS.
extra-substituters = https://attic.asepharyana.my.id/gmw https://attic.asepharyana.my.id/mcpedia
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88= mcpedia:MNAtF2xaeKSFFVu7r3zOp2Jktu1jKHIQmcT+ePEfv9o=
- name: Cache Nix
uses: DeterminateSystems/magic-nix-cache-action@v14
with:
use-flakehub: false
- name: Build ${{ matrix.service }}
id: build
run: |
nix build ".#${{ matrix.service }}" --impure --option sandbox false --print-build-logs
STORE_PATH=$(readlink result)
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
echo "Build OK ${{ matrix.service }}: $STORE_PATH"
- name: Setup SSH key
env:
SSH_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
sed -i 's/\r$//' ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
chmod 600 ~/.ssh/known_hosts
# ── Push build to Attic binary cache ───────────────────────────────
# Bootstrap the attic client from the gmw cache (pre-built closure,
# pulled over HTTPS via the configured extra-substituters), then
# push to the mcpedia cache. This is FASTER than nix copy --to ssh://
# because the VPS substitutes (downloads) from the cache over HTTPS
# instead of a single-stream SSH copy.
- name: Push to Attic cache
id: push-attic
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
STORE_PATH: ${{ steps.build.outputs.store-path }}
run: |
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
mkdir -p "$HOME/.config/attic"
cat > "$HOME/.config/attic/config.toml" <<EOF
default-server = "pub"
[servers.pub]
endpoint = "https://attic.asepharyana.my.id"
token = "$ATTIC_TOKEN"
EOF
ATTIC_BIN=""
# Fast path: pull attic client from gmw cache
if nix-store --realise "$ATTIC_DIR" 2>/dev/null; then
ATTIC_BIN="$ATTIC_DIR/bin/attic"
fi
push_ok=""
if [ -n "$ATTIC_BIN" ] && [ -x "$ATTIC_BIN" ]; then
for attempt in 1 2 3; do
if "$ATTIC_BIN" push pub:mcpedia "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
echo "✅ Pushed $STORE_PATH to Attic"
push_ok=1
break
fi
echo "⚠️ Attic push attempt $attempt/3 failed; retrying in 10s..."
sleep 10
done
fi
if [ -z "$push_ok" ]; then
echo "Attic push failed; copying store path directly to VPS"
nix copy --to "ssh://${VPS_USER}@${VPS_HOST}" "$STORE_PATH"
echo "push-fallback=ssh" >> "$GITHUB_OUTPUT"
else
echo "push-method=attic" >> "$GITHUB_OUTPUT"
fi
- name: Deploy on VPS
env:
STORE_PATH: ${{ steps.build.outputs.store-path }}
SERVICE: ${{ matrix.service }}
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
run: |
PROFILE="mcpedia-${SERVICE}"
# Substitute from Attic cache (fast — downloads over HTTPS)
# Falls back to SSH copy if substitution fails
REALISE_RESULT=$(ssh "$VPS_USER@$VPS_HOST" \
"sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH' 2>&1 || true")
echo "$REALISE_RESULT"
# Update profile + sync git repo + restart
ssh "$VPS_USER@$VPS_HOST" \
"sudo /nix/var/nix/profiles/default/bin/nix-env \
--profile /nix/var/nix/profiles/$PROFILE \
--set '$STORE_PATH' && \
(cd /home/code/mcpedia && git fetch origin main && git reset --hard origin/main || true) && \
sudo systemctl restart $PROFILE && \
sleep 3 && \
sudo systemctl status $PROFILE --no-pager --no-legend | head -5"
- name: Verify service
env:
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
SERVICE: ${{ matrix.service }}
run: |
PORT_MAP="api:4017 mcp:4021 worker:4018"
PORT=$(echo "$PORT_MAP" | grep "$SERVICE" | awk -F: '{print $2}')
ssh "$VPS_USER@$VPS_HOST" \
"curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT/health 2>/dev/null \
|| systemctl is-active \"mcpedia-$SERVICE\""
deploy-web:
name: build + deploy (web)
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v16
with:
determinate: false
extra-conf: |
sandbox = false
accept-flake-config = true
extra-substituters = https://attic.asepharyana.my.id/gmw https://attic.asepharyana.my.id/mcpedia
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88= mcpedia:MNAtF2xaeKSFFVu7r3zOp2Jktu1jKHIQmcT+ePEfv9o=
- name: Cache Nix
uses: DeterminateSystems/magic-nix-cache-action@v14
with:
use-flakehub: false
- name: Download web .next artifact
uses: actions/download-artifact@v4
with:
name: mcpedia-web-next
path: /tmp/web-next
- name: Unpack .next
run: tar -xzf /tmp/web-next/web-next.tar.gz -C apps/web/
- name: Install deps (for bunx in web.nix)
run: bun install --frozen-lockfile
- name: Build web Nix derivation
id: build
run: |
REPO_ROOT="$(pwd)"
nix-build -E \
"let pkgs = import <nixpkgs> {}; in pkgs.callPackage ${REPO_ROOT}/web.nix {
webNextDir = ${REPO_ROOT}/apps/web/.next;
}" \
--impure --option sandbox false
STORE_PATH=$(readlink result)
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
- name: Setup SSH key
env:
SSH_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
sed -i 's/\r$//' ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
chmod 600 ~/.ssh/known_hosts
- name: Push to Attic cache
id: push-attic
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
STORE_PATH: ${{ steps.build.outputs.store-path }}
run: |
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
mkdir -p "$HOME/.config/attic"
cat > "$HOME/.config/attic/config.toml" <<EOF
default-server = "pub"
[servers.pub]
endpoint = "https://attic.asepharyana.my.id"
token = "$ATTIC_TOKEN"
EOF
ATTIC_BIN=""
if nix-store --realise "$ATTIC_DIR" 2>/dev/null; then
ATTIC_BIN="$ATTIC_DIR/bin/attic"
fi
push_ok=""
if [ -n "$ATTIC_BIN" ] && [ -x "$ATTIC_BIN" ]; then
for attempt in 1 2 3; do
if "$ATTIC_BIN" push pub:mcpedia "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
echo "✅ Pushed $STORE_PATH to Attic"
push_ok=1
break
fi
echo "⚠️ Attic push attempt $attempt/3 failed; retrying in 10s..."
sleep 10
done
fi
if [ -z "$push_ok" ]; then
echo "Attic push failed; copying store path directly to VPS"
nix copy --to "ssh://${VPS_USER}@${VPS_HOST}" "$STORE_PATH"
fi
- name: Deploy on VPS
env:
STORE_PATH: ${{ steps.build.outputs.store-path }}
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
run: |
# Update profile + sync git repo + sync .next + restart
ssh "$VPS_USER@$VPS_HOST" \
"sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH' && \
sudo /nix/var/nix/profiles/default/bin/nix-env \
--profile /nix/var/nix/profiles/mcpedia-web \
--set '$STORE_PATH' && \
(cd /home/code/mcpedia && git fetch origin main && git reset --hard origin/main) && \
sudo rm -rf /home/code/mcpedia/apps/web/.next && \
sudo cp -r '$STORE_PATH/share/mcpedia-web/.next' /home/code/mcpedia/apps/web/.next && \
sudo systemctl restart mcpedia-web && \
sleep 3 && \
sudo systemctl status mcpedia-web --no-pager --no-legend | head -5"
- name: Verify public URL
env:
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
run: |
ssh "$VPS_USER@$VPS_HOST" \
"curl -sk -o /dev/null -w '%{http_code}' https://wiki.asepharyana.my.id/"
-52
View File
@@ -1,52 +0,0 @@
name: Deploy
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: build + deploy
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Build web app
run: bun --cwd apps/web run build
- name: Deploy to VPS over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_DEPLOY_HOST }}
port: ${{ secrets.SSH_DEPLOY_PORT }}
username: ${{ secrets.SSH_DEPLOY_USER }}
key: ${{ secrets.SSH_DEPLOY_KEY }}
envs: SSH_DEPLOY_HOST
script: |
set -e
cd /home/code/mcpedia
git pull origin main
/home/code/.bun/bin/bun install --frozen-lockfile
/home/code/.bun/bin/bun --cwd apps/web run build
sudo systemctl restart mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker
sleep 3
systemctl --no-pager status mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker --no-legend
+6
View File
@@ -43,3 +43,9 @@ next-env.d.ts
# monorepo / turbo # monorepo / turbo
.turbo/ .turbo/
dist/ dist/
# MCPedia content — DB is source of truth via MCP API (create_document/update_document)
# Filesystem .md files are auto-generated backups, not tracked in git.
# To edit content: use the MCP create_document/update_document tools (requires x-webhook-secret)
content/**/*.md
!content/**/.gitkeep
@@ -1,435 +0,0 @@
# MCPedia Implementation Plan
> **For Hermes:** Use subagent-driven-development to implement task-by-task. Phase 1 tasks are bite-sized and ready to execute; Phases 2–4 are outlines to expand later.
**Goal:** Turn the fresh `create-next-app` Next.js 16 repo into the MCPedia knowledge base — Git/Markdown content, a Core business-logic layer shared by a Next.js Web UI and an MCP server for AI agents, with content metadata in Postgres (FTS search), and an architecture that can grow to semantic search, a tRPC API, auth, and async indexing without rewrites.
**Architecture:** Monorepo (bun workspaces + Turborepo). Content lives as Markdown in `content/` (Git-managed, human-readable). `packages/core` holds all business logic (Document/Content/Search services). Web (`apps/web`) and MCP (`apps/mcp`) both consume Core — no DB access outside Core. Postgres (imrnes `:6432`) stores document metadata + a `tsvector` for keyword search. Phase 1 ships keyword search only; pgvector/semantic, tRPC/Hono API, auth, and BullMQ are later phases behind clean seamsLine truncated
**Tech Stack (resolved forks):**
- Package manager / monorepo: **bun workspaces + Turborepo** (honor existing repo; NOT pnpm — user decision).
- DB: **imrnes Postgres `100.121.180.82:6432`** for both dev and deploy (PgBouncer transaction pool → set `prepare: false` on the driver).
- Phase 1 scope: **Core + Web + MCP + Postgres FTS only**. tRPC/Hono API, pgvector, auth, Redis/BullMQ deferred.
---
## Environment & preconditions (read first)
- Repo is currently a flat Next.js 16 app, **not git-initialized**, bun-managed (`packageManager: bun@1.3.14`, `bun.lock` present).
- `app/` has only `layout.tsx`, `page.tsx`, `globals.css`, `favicon.ico`. Tailwind v4 (`@tailwindcss/postcss`).
- Next.js 16 is "NOT the Next.js you know" (see `AGENTS.md`): **before writing any `apps/web` code, read `node_modules/next/dist/docs/`** (app router, server components, route handlers) for breaking changes. Treat deprecation notices as authoritative.
- For every library API used (Next 16, Drizzle, `@modelcontextprotocol/sdk`, gray-matter) **resolve + query Context7** before coding — do not trust training data.
- Secrets: `DATABASE_URL` lives in **BWS project `orangevps`** (never committed). Local dev reads it from a gitignored `.env` at repo root.
- Seed DB creds: a dedicated `mcpedia` database/user on imrnes; create via `bws` secret or operator. `pgvector` extension is **NOT** required until Phase 2.
---
## Phase 0 — Repo restructure (monorepo scaffold)
### Task 0.1: Initialize git + baseline commit
- **Files:** `.gitignore` (already exists; ensure `.env`, `.next/`, `node_modules/`, `*.tsbuildinfo`, `dist/` ignored).
- **Step:** `git init && git add -A && git commit -m "chore: baseline create-next-app scaffold"` (author = asepharyana, no Co-Authored-By).
- **Verify:** `git log --oneline -1` shows the commit.
### Task 0.2: Root workspace package.json + Turbo
- **Modify:** `package.json` (root) — strip app deps, add workspaces + turbo scripts.
- **Create:** `turbo.json`, `tsconfig.base.json`.
- Root `package.json` becomes:
```json
{
"name": "mcpedia",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.3.14",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"index": "bun run scripts/indexer.ts"
},
"devDependencies": {
"turbo": "^2",
"typescript": "^5",
"prettier": "^3"
}
}
```
- `turbo.json`:
```json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] },
"lint": {},
"typecheck": { "dependsOn": ["^build"] },
"dev": { "cache": false, "persistent": true }
}
}
```
- **Verify:** `bun install` succeeds; `bunx turbo --version` prints a version.
### Task 0.3: Move Web app into `apps/web`
- **Move:** `app/`, `public/`, `next.config.ts`, `next-env.d.ts`, `postcss.config.mjs`, `eslint.config.mjs`, `tsconfig.json` → `apps/web/` (rename root `app` → `apps/web/app`). Keep Tailwind v4. Add `apps/web/package.json`:
```json
{
"name": "@mcpedia/web",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"@mcpedia/core": "workspace:*",
"@mcpedia/config": "workspace:*"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
```
- Add `apps/web/tsconfig.json` extending `../../tsconfig.base.json` with `@/*` → `./**/*` and `@mcpedia/*` workspace aliases.
- **Verify:** `cd apps/web && bun install && bun run build` compiles (may warn about empty content — fine).
### Task 0.4: `.env` (gitignored) for dev
- **Create:** `.env` (gitignored) with `DATABASE_URL=postgresql://<user>:<pass>@100.121.180.82:6432/mcpedia` (value from BWS; `prepare:false` enforced in db client). Add `.env.example` (committed, placeholder only).
---
## Phase 1 — MVP: Core + Web + MCP + Postgres FTS (detailed)
### Task 1.1: `packages/types`
- **Create:** `packages/types/package.json` (`@mcpedia/types`, exports `src/index.ts`), `packages/types/src/index.ts`:
```ts
export type DocSection = "docs" | "writeups" | "research" | "notes";
export type DocType = "documentation" | "writeup" | "research" | "note";
export type DocStatus = "published" | "draft";
export interface DocumentMeta {
id: string; // slug
slug: string;
title: string;
type: DocType;
section: DocSection;
status: DocStatus;
author: string;
tags: string[];
path: string; // relative path under content/
createdAt: string;
updatedAt: string;
}
export interface Document extends DocumentMeta {
body: string; // raw markdown (read from disk or stored)
}
export interface SearchHit {
doc: DocumentMeta;
rank: number;
snippet: string;
}
```
- **Verify:** `bunx tsc --noEmit` in package passes.
### Task 1.2: `packages/config`
- **Create:** `packages/config/package.json` (`@mcpedia/config`), `packages/config/src/index.ts`:
```ts
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
export const CONTENT_ROOT = process.env.CONTENT_ROOT
?? resolve(root, "content");
export const DATABASE_URL = process.env.DATABASE_URL ?? "";
```
- **Verify:** import in a scratch `bun -e` prints a path.
### Task 1.3: `packages/db` (Drizzle + Postgres, FTS)
- **Create:** `packages/db/package.json` (`@mcpedia/db`, deps `drizzle-orm`, `postgres`; dev `drizzle-kit`), `packages/db/src/schema.ts`:
```ts
import { sql } from "drizzle-orm";
import { text, timestamp, integer, index, customType } from "drizzle-orm/pg-core";
// tsvector isn't a first-class drizzle type; wrap raw.
export const documents = pgTable("documents", {
id: text("id").primaryKey(), // slug
slug: text("slug").notNull().unique(),
title: text("title").notNull(),
type: text("type").notNull(),
section: text("section").notNull(),
status: text("status").notNull().default("published"),
author: text("author").notNull().default(""),
tags: text("tags").array().notNull().default(sql`'{}'`),
path: text("path").notNull(),
body: text("body").notNull().default(""),
searchVector: customType<{ data: string }>({
dataType() { return "tsvector"; },
})("search_vector")
.generatedAlwaysAs(sql`to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(body,''))`),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
}, (t) => ({
searchIdx: index("documents_search_idx").using("gin", t.searchVector),
}));
```
NOTE: `document_chunks` + `embedding vector` are **Phase 2** — do NOT add now.
- **Create:** `packages/db/src/client.ts` — postgres-js with `prepare: false` (PgBouncer safe):
```ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { DATABASE_URL } from "@mcpedia/config";
import * as schema from "./schema";
const client = postgres(DATABASE_URL, { prepare: false, max: 5 });
export const db = drizzle(client, { schema });
export { schema };
```
- **Create:** `packages/db/drizzle.config.ts` (dialect `postgresql`, schema `./src/schema.ts`, out `./drizzle`).
- **Verify:** `bunx drizzle-kit generate` produces a migration; `bunx drizzle-kit push` applies it to imrnes (operator supplies DATABASE_URL). `bunx drizzle-kit studio` optional.
### Task 1.4: `packages/parser` (frontmatter)
- **Create:** `packages/parser/package.json` (`@mcpedia/parser`, dep `gray-matter`), `packages/parser/src/index.ts`:
```ts
import matter from "gray-matter";
import { readFileSync } from "node:fs";
import type { DocSection, DocType, DocStatus, DocumentMeta } from "@mcpedia/types";
const SECTIONS: DocSection[] = ["docs", "writeups", "research", "notes"];
export function parseFile(absPath: string, relPath: string): { meta: DocumentMeta; body: string } {
const raw = readFileSync(absPath, "utf8");
const { data, content } = matter(raw);
const section = SECTIONS.find((s) => relPath.startsWith(s + "/")) ?? "docs";
const slug = relPath.replace(/\.mdx?$/, "");
const meta: DocumentMeta = {
id: slug,
slug,
title: data.title ?? slug,
type: (data.type ?? "documentation") as DocType,
section,
status: (data.status ?? "published") as DocStatus,
author: data.author ?? "",
tags: Array.isArray(data.tags) ? dataAtags : [],
path: relPath,
createdAt: data.created_at ?? new Date().toISOString(),
updatedAt: data.updated_at ?? data.created_at ?? new Date().toISOString(),
};
return { meta, body: content };
}
```
(fix the obvious `dataAtags` typo to `data.tags` when implementing.)
- **Verify:** unit test parsing a sample frontmatter → asserts `slug`, `section`, `tags`.
### Task 1.5: `packages/search` (FTS query)
- **Create:** `packages/search/package.json` (`@mcpedia/search`, dep `@mcpedia/db`, `@mcpedia/types`), `packages/search/src/index.ts`:
```ts
import { db } from "@mcpedia/db";
import { documents } from "@mcpedia/db/schema";
import { sql, and, eq } from "drizzle-orm";
import type { SearchHit } from "@mcpedia/types";
export function toTsQuery(q: string): string {
// plainto_tsquery-style: AND the terms, escape punctuation.
const terms = q.trim().split(/\s+/).filter(Boolean).map((t) => t.replace(/[^\p{L}\p{N}]/gu, ""));
return terms.map((t) => `${t}:*`).join(" & ");
}
export async function keywordSearch(q: string, limit = 20): Promise<SearchHit[]> {
const query = toTsQuery(q);
if (!query) return [];
const rows = await db
.select({
doc: documents,
rank: sql<number>`ts_rank(${documents.searchVector}, to_tsquery('simple', ${query}))`,
snippet: sql<string>`ts_headline('simple', ${documents.body}, to_tsquery('simple', ${query}), 'MaxWords=20, MinWords=5')`,
})
.from(documents)
.where(and(eq(documents.status, "published"), sql`${documents.searchVector} @@ to_tsquery('simple', ${query})`))
.orderBy(sql`ts_rank(${documents.searchVector}, to_tsquery('simple', ${query})) desc`)
.limit(limit);
return rows.map((r) => ({ doc: r.doc, rank: r.rank, snippet: r.snippet }));
}
```
- **Verify:** after seeding, `keywordSearch("websocket")` returns hits.
### Task 1.6: `packages/core` (services)
- **Create:** `packages/core/package.json` (`@mcpedia/core`, deps `@mcpedia/db`, `@mcpedia/parser`, `@mcpedia/search`, `@mcpedia/config`, `@mcpedia/types`), `packages/core/src/`:
- `content.service.ts` — read markdown from `CONTENT_ROOT` by slug/section; list files via `fast-glob`.
- `document.service.ts` — `listDocuments(section?, type?, status?)`, `getDocument(slug)` (DB meta + disk body), `getRelated(slug)` (shared tags, later vector).
- `search.service.ts` — wraps `keywordSearch`.
- `index.ts` — re-export services + types.
```ts
// document.service.ts (sketch)
import { db } from "@mcpedia/db";
import { documents } from "@mcpedia/db/schema";
import { eq, and } from "drizzle-orm";
import { CONTENT_ROOT } from "@mcpedia/config";
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import type { Document, DocumentMeta } from "@mcpedia/types";
export async function listDocuments(opts: { section?: string; status?: string } = {}): Promise<DocumentMeta[]> {
const where = [eq(documents.status, opts.status ?? "published")];
if (opts.section) where.push(eq(documents.section, opts.section));
return db.select().from(documents).where(and(...where));
}
export async function getDocument(slug: string): Promise<Document | null> {
const [meta] = await db.select().from(documents).where(eq(documents.slug, slug));
if (!meta) return null;
const abs = resolve(CONTENT_ROOT, meta.path);
const body = existsSync(abs) ? readFileSync(abs, "utf8") : meta.body;
return { ...meta, body };
}
```
- **Verify:** `bunx tsc --noEmit` passes across packages.
### Task 1.7: `scripts/indexer.ts` (walk content → upsert)
- **Create:** `scripts/indexer.ts` — walk `CONTENT_ROOT` (`**/*.{md,mdx}`), `parseFile`, upsert into `documents` (slug PK; `onConflictDoUpdate` title/tags/body/updatedAt). Logs count.
```ts
import { db } from "@mcpedia/db";
import { documents } from "@mcpedia/db/schema";
import { parseFile } from "@mcpedia/parser";
import { CONTENT_ROOT } from "@mcpedia/config";
import { walk } from "fast-glob";
import { resolve } from "node:path";
const files = await glob("**/*.{md,mdx}", { cwd: CONTENT_ROOT, absolute: true });
let n = 0;
for (const abs of files) {
const rel = resolve(CONTENT_ROOT, abs).replace(CONTENT_ROOT + "/", "");
const { meta, body } = parseFile(abs, rel);
await db.insert(documents).values({ ...meta, body })
.onConflictDoUpdate({ target: documents.slug, set: { title: meta.title, tags: meta.tags, body, updatedAt: new Date().toISOString() } });
n++;
}
console.log(`indexed ${n} documents`);
```
- **Verify:** `bun run index` prints `indexed N documents`; `psql`/`drizzle-kit studio` shows rows + non-empty `search_vector`.
### Task 1.8: Seed content
- **Create:** `content/docs/websocket/contract.md`, `content/writeups/debugging/websocket-timeout.md`, `content/research/mcp/architecture.md`, `content/notes/typescript/patterns.md` with the spec's frontmatter shape (id/title/type/tags/status/author/created_at/updated_at). Add 2–3 short paragraphs each so FTS has text.
- **Verify:** `bun run index` ingests them; `keywordSearch("websocket")` returns ≥2 hits.
### Task 1.9: `apps/web` — pages (Next 16, read `node_modules/next/dist/docs` first)
- **Read** Node_modules Next 16 docs (app router, server components, route handlers) — mandatory.
- **Create:**
- `apps/web/app/page.tsx` — home: list sections + recent docs (server component calling `listDocuments`).
- `apps/web/app/(docs)/[...slug]/page.tsx` — document view: `getDocument(slug)`, render markdown (Phase 1: simple `dangerouslySetInnerHTML` of a remark→HTML, or raw pre with Shiki later; keep minimal but legible).
- `apps/web/app/search/page.tsx` — search box → `keywordSearch`, render hits + snippets.
- Keep Tailwind v4 styling minimal but clean (no shadcn yet — Phase 2 polish).
- **Verify:** `bun run build` (apps/web) passes; `bun run dev` then `curl -s localhost:3000/` and `/search?q=websocket` return rendered HTML.
### Task 1.10: `apps/mcp` — MCP server
- **Create:** `apps/mcp/package.json` (`@mcpedia/mcp`, deps `@modelcontextprotocol/sdk`, `zod`, `@mcpedia/core`), `apps/mcp/src/index.ts`:
```ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { listDocuments, getDocument } from "@mcpedia/core";
import { keywordSearch } from "@mcpedia/search";
const server = new McpServer({ name: "mcpedia", version: "0.1.0" });
server.tool("search_documents", { query: z.string(), limit: z.number().optional() },
async ({ query, limit }) => {
const hits = await keywordSearch(query, limit ?? 20);
return { content: [{ type: "text", text: JSON.stringify(hits, null, 2) }] };
});
server.tool("get_document", { slug: z.string() },
async ({ slug }) => {
const doc = await getDocument(slug);
return doc ? { content: [{ type: "text", text: doc.body }] }
: { content: [{ type: "text", text: "not found" }], isError: true };
});
server.tool("list_documents", { section: z.string().optional() },
async ({ section }) => {
const docs = await listDocuments({ section });
return { content: [{ type: "text", text: JSON.stringify(docs, null, 2) }] };
});
// Resource: mcpedia://<section>/<path>
server.resource("doc", "mcpedia://", async (uri) => {
const slug = uri.path.replace(/^\//, "");
const doc = await getDocument(slug);
return { contents: [{ uri: uri.href, text: doc?.body ?? "" }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);
```
(Exact SDK method names verified via Context7 before coding.)
- **Create:** `apps/mcp/bin` or `bun run` entry; add `"start": "bun run src/index.ts"` to its package.json.
- **Verify:** smoke test via `scripts/mcp-smoke.ts` that imports the tool handlers and calls `search_documents({query:"websocket"})` → asserts ≥1 hit. (Running a real stdio client in CI is optional; inspector `@modelcontextprotocol/inspector` for manual check.)
### Task 1.11: Cross-package verification
- **Run:** `bun install` (root) → `bunx turbo run lint` → `bunx turbo run build` → `bunx turbo run typecheck`. All green.
- **Run:** `bun run index` then `bun run scripts/mcp-smoke.ts`.
- **Verify:** no package imports `pg`/`drizzle` outside `@mcpedia/core` or `@mcpedia/db` (architecture guard). Search the tree: `search_files("from \"pg\"", path="apps")`.
### Task 1.12: README + phase markers
- **Modify:** `README.md` — replace create-next-app text with MCPedia overview, monorepo map, "Phase 1 status", how to dev (`bun install && bun run index && bun run dev`), how to run MCP (`bun run --cwd apps/mcp start`).
- **Verify:** `cat README.md` reads correctly.
---
## Phase 2 — Semantic search + tRPC/Hono API + auth (outline, expand later)
- Add `document_chunks` table + `embedding vector` (pgvector extension on imrnes — operator enables `CREATE EXTENSION vector`).
- `packages/search`: `EmbeddingProvider` abstraction (OpenAI/Gemini/Ollama/Local) behind an interface (spec principle 6). Chunking in `packages/parser` (by heading/section).
- Hybrid ranking: fuse FTS `ts_rank` + cosine distance (Reciprocal Rank Fusion).
- `apps/api`: Hono + tRPC router exposing `documents.*`, `search.*`; Web switches from direct Core calls to tRPC client; MCP may also call API or keep Core (decide: keep Core for colocated simplicity, API for external).
- `packages/auth`: Auth.js/OIDC adapter; `permissions` table; gate write/mutations. Phase 1 documents are public-read.
- Categories + tags linking tables (`categories`, `document_tags`, `references`).
- MDX rendering with Shiki + shadcn/ui polish on Web.
## Phase 3 — Async indexing + revisions (outline)
- Redis (imrnes or local) + BullMQ workers: parse/chunk/embed/reindex off the request path.
- `document_revisions` table + revision API; Git sync hook (content/ push → reindex).
- Indexer becomes a worker triggered by file watch / webhook, not a manual script.
## Phase 4 — Scale (outline, only if needed)
- Dedicated search engine (OpenSearch) behind the same `SearchService` seam — YAGNI until FTS+pgvector saturates.
- Object storage for assets, advanced ranking, distributed workers, observability, multi-tenant. Docker Compose for service orchestration pointing at imrnes Postgres.
---
## Risks / tradeoffs / open questions
- **Next 16 breaking changes** — biggest unknown; mitigated by reading `node_modules/next/dist/docs` before Web tasks. If app-router differs enough, adjust Task 1.9 accordingly.
- **PgBouncer + prepared statements** — handled via `prepare: false`; if imrnes uses session pool mode, can drop it.
- **FTS language** — using `'simple'` config (no stemming) so mixed ID/EN queries match literally; revisit if relevance is poor (could add `'english'` or a custom ID dict).
- **pgvector on imrnes** — extension may need operator enablement; Phase 2 blocker, not Phase 1.
- **MCP resource URI shape** — `mcpedia://<section>/<path>` is a sketch; finalize against MCP SDK spec in Task 1.10 (Context7).
- **Monorepo driver** — bun workspaces chosen over pnpm per user; Turborepo runs fine under bun. If `turbo` bin resolution misbehaves under bun, fall back to `bunx turbo`.
## Verification matrix (Phase 1)
| Check | Command | Expect |
| --- | --- | --- |
| Install | `bun install` | clean |
| Lint | `bunx turbo run lint` | 0 errors |
| Typecheck | `bunx turbo run typecheck` | 0 errors |
| Build | `bunx turbo run build` | web + mcp build |
| Index | `bun run index` | `indexed N documents` |
| Web | `bun run dev` (apps/web) + curl `/` and `/search?q=websocket` | rendered HTML, hits |
| MCP | `bun run scripts/mcp-smoke.ts` | ≥1 hit for "websocket" |
| Arch guard | search for `from "pg"` outside `packages/db`,`packages/core` | none |
@@ -1,161 +0,0 @@
# MCPedia Phase 2 — Semantic Search + tRPC/Hono API
> **For Hermes:** implement task-by-task. Spec-first (user rule 2026-08-19).
**Goal:** Add semantic + hybrid search (pgvector) and a typed tRPC/Hono API so
MCPedia is queryable by embeddings, not just keyword FTS — and expose the
corpus over a programmatic HTTP API.
**Architecture:** Content (Markdown) → chunk → embed (OpenRouter) → store
`document_chunks` with `vector(N)` in Postgres → `semanticSearch` (cosine) and
`hybridSearch` (FTS + cosine, reciprocal-rank fusion) in `@mcpedia/search` →
exposed via Core, the MCP server (new tools), and a new `apps/api` (Hono +
tRPC v11).
**Embedding provider:** OpenRouter (`openrouter/llama-nemotron-embed-vl-1b-v2:free`)
via `9router_ai_llm_api_key` + `9router_ai_llm_base_url` (BWS). Dimension is
discovered at first live call (see Step 1.3) and pinned in schema/migration.
**Tech stack:** drizzle-orm `vector` column + pgvector extension, HNSW index,
`@trpc/server` v11 (fetch adapter), `hono` + `@hono/node-server`.
---
## Task P2.1 — `packages/embeddings` (provider + abstraction)
**Files:** `packages/embeddings/package.json`, `src/index.ts`, `src/provider.ts`,
`src/openrouter.ts`
- `EmbeddingProvider` interface: `embed(texts: string[]): Promise<number[][]>`, `readonly model`, `readonly dimensions`.
- `OpenRouterEmbeddingProvider`: POST `${baseUrl}/embeddings` with `{ model, input }`,
`Authorization: Bearer ${key}`. Returns `data[].embedding`. Validate length === dimensions.
- Read `EMBED_BASE_URL`, `EMBED_API_KEY`, `EMBED_MODEL` from `@mcpedia/config`
(with `.env` fallback). Dimensions discovered live (Step 1.3) → export `EMBED_DIM`.
- Chunk helper `chunkText(text, { size=1000, overlap=150 })` in `src/chunk.ts`.
**Step 1.3 (discover dim):** live call `embed(["test"])`, read `embedding.length`,
pin `EMBED_DIM`, assert mismatch throws.
**Verify:** `bun run` a temp script: `embed(["hello world"])` prints a vector of
length N (e.g. 1024). Confirm no key is logged.
---
## Task P2.2 — Schema: `document_chunks` + vector extension
**Files:** `packages/db/src/schema.ts` (add), `packages/db/drizzle.config.ts`
(unchanged), new migration.
- `CREATE EXTENSION IF NOT EXISTS vector;` (idempotent; run once via psql).
- `document_chunks` table:
- `id` uuid pk default gen_random_uuid()
- `document_id` text → `documents.id` on delete cascade
- `slug` text (denormalized for convenience)
- `chunk_index` integer
- `content` text
- `embedding` vector(EMBED_DIM)
- `created_at` timestamp default now()
- index `chunk_embedding_idx` using hnsw (`embedding` op `vector_cosine_ops`)
- Generate migration with `drizzle-kit generate`, apply via `psql` (drizzle-kit
push is unreliable here — known).
**Verify:** `\d document_chunks` shows `embedding vector(N)` + HNSW index;
`select count(*) from document_chunks` = 0.
---
## Task P2.3 — Indexer: chunk + embed + upsert
**Files:** `scripts/indexer.ts` (extend), `packages/core/src/document.service.ts`
(add `indexChunks`).
- For each published doc: read body (already on disk), `chunkText`, `embed` in
batches (≤ 16), delete existing chunks for slug, insert new rows.
- Guard: if embedding provider fails, log + skip (don't crash the whole index).
- Add `bun run index:embed` (or extend `bun run index` to also embed).
**Verify:** after running, `select count(*) from document_chunks` > 0; a sample
row has non-null `embedding`.
---
## Task P2.4 — `packages/search`: semantic + hybrid
**Files:** `packages/search/src/index.ts` (add `semanticSearch`, `hybridSearch`).
- `semanticSearch(vec, limit)`: order by `embedding <=> ${vec}` asc, filter published.
- `hybridSearch(q, limit)`: run FTS (`ts_rank`) + semantic (cosine) in parallel;
fuse with reciprocal-rank (RRF: score = 1/(k+rank), k=60); return merged hits.
- Keep `keywordSearch` unchanged (Phase 1).
**Verify:** unit-ish script: embed a query, `semanticSearch` returns relevant
chunks; `hybridSearch("websocket")` returns ≥ keyword results.
---
## Task P2.5 — `packages/core` expose semantic/hybrid
**Files:** `packages/core/src/search.service.ts`, `index.ts`.
- Re-export `semanticSearch`, `hybridSearch` from Core.
---
## Task P2.6 — `apps/api` (Hono + tRPC v11)
**Files:** `apps/api/package.json`, `tsconfig.json`, `src/index.ts`,
`src/router.ts`, `src/trpc.ts`.
- `initTRPC.create()` router with procedures: `search`, `semanticSearch`,
`hybridSearch`, `getDocument`, `listDocuments` (mirrors MCP tools).
- Mount `fetchRequestHandler` on a Hono app at `/trpc/*`; serve via
`@hono/node-server` `serve({ fetch: app.fetch, port: 4020 })`.
- `createContext` returns `{ db }`.
**Verify:** `bun run dev` → `curl -X POST localhost:4020/trpc/search`
with JSON body returns hits.
---
## Task P2.7 — MCP server: semantic + hybrid tools
**Files:** `apps/mcp/src/index.ts` (add `semantic_search`, `hybrid_search`),
extend `smoke.test.ts`.
- `semantic_search`: embed query → `semanticSearch`.
- `hybrid_search`: embed query → `hybridSearch`.
- Smoke: assert both return ≥1 hit for "websocket".
---
## Task P2.8 — Web: semantic toggle on search
**Files:** `apps/web/app/search/page.tsx`.
- Add `mode=keyword|hybrid` query param; server component calls Core
`hybridSearch` when `mode=hybrid`. Minimal UI toggle (link/buttons).
- Keep keyword as default.
**Verify:** `bun run build`; `curl '/search?q=websocket&mode=hybrid'` returns hits.
---
## Task P2.9 — Verify all + commit
- `bunx turbo run build` (web + api + mcp), `bun run apps/mcp smoke`,
live API curl, live web hybrid search.
- Update `README.md` + `PHASES.md` (mark Phase 2 ✅).
- `git add -A` (exclude `.env`), commit as asepharyana (no Co-Authored-By).
---
## Risks / decisions
- **Dimension unknown until live call** → P2.1.3 discovers it; pinned EMBED_DIM=2048.
- **pgvector NOT available on shared imrnes Postgres** (extension not installed;
installing needs host-level apt on a managed/shared DB — deferred). PIVOT:
store `embedding` as `real[]` and compute cosine similarity in the app layer.
Brute-force cosine is instant for a KB-sized corpus (dozens of docs / hundreds
of chunks). pgvector+HNSW is the Phase-4 scale-out path.
- **PgBouncer + real[]**: fine; simple queries, no extension needed.
- **API port 4020** (host 4000s range is 4000–4015; 4020 is free for dev). Deploy later.
- **YAGNI**: no auth/revisions this phase (Phase 3).
-42
View File
@@ -1,42 +0,0 @@
# MCP HTTP transport + deploy + review actions
## Goal
Make the MCPedia MCP server reachable over the network (not just stdio subprocess), so
remote MCP clients (Claude, a Discord bot, a web client) can call its 6 tools + 4 resources.
Serve via Streamable HTTP (MCP 2025-03-26 spec), deploy as a supervised systemd service,
expose through Caddy on a dedicated subdomain.
## Design decisions
- **StreamableHTTPServerTransport, stateless mode** (`sessionIdGenerator: undefined`).
One McpServer + transport per request. No session map, no shared-transport connect race,
no memory leak. Re-registering 6 tools + 4 resources per request is negligible for a KB.
- **New entry `apps/mcp/src/http.ts`** served by Node `http` (built-in), NOT mounted on the
API app — keeps the MCP app's zod-4 isolation intact (api app is zod 3).
- **Port 4021** (next free in the 4000s range; 4020 is the API).
- **Subdomain `mcp.asepharyana.my.id`** -> 4021 (Cloudflare `*` wildcard already proxies it;
Caddy auto-issues LE cert, no extra DNS work).
- **CORS** allow on `/mcp` (remote web clients need it).
- MCP tools are read-only (search/get/list/related + read-only resources) => open MCP is
low-risk. No auth on MCP itself.
## Files
- `apps/mcp/src/http.ts` (NEW) — Node http server, `/mcp` route, stateless transport.
- `apps/mcp/package.json` — add `serve:http` script.
- `package.json` (root) — add `mcp:http` script (absolute bun path).
- `deploy/mcpedia-mcp.service` (NEW) — systemd unit, MCP_PORT=4021.
- `/etc/caddy/Caddyfile` — add `mcp.asepharyana.my.id { import proxy 4021 }`.
## Security finding (review, flagged not silently built)
The tRPC `restoreRevision` mutation is exposed UNauthenticated at
`https://wiki.asepharyana.my.id/trpc/restoreRevision` — anyone can revert a live doc.
The web UI's restore path calls `@mcpedia/core` directly (server component), so the tRPC
mutation is dead surface. Fix: guard the mutation with the existing WEBHOOK_SECRET header,
or drop it from the router. Will apply the guard (consistent with /hooks auth) unless user
prefers removal.
## Verification
- `bun --cwd apps/mcp run typecheck` green.
- Live: `curl -XPOST https://mcp.asepharyana.my.id/mcp` initialize -> 200 + serverInfo;
tools/list -> 6 tools; resources/list -> 4 resources.
- `systemctl is-active mcpedia-mcp` == active.
- Commit + push.
+33
View File
@@ -0,0 +1,33 @@
# Phase 15b — CI/CD: Build in CI, Deploy only on VPS
> User: "alur ci nya juga benarkan lah masa build di vps,harusnya kan di vps hanya deploy, dan seharusnya pakai nix"
## Final Architecture
### CI Workflow (`.github/workflows/ci.yml`)
Single workflow with two jobs:
**Job 1: `build`** (runs on GitHub Actions runner)
1. Checkout → Set up Bun 1.3.14 → `bun install --frozen-lockfile`
2. Typecheck (`bun run typecheck`)
3. Build web (`working-directory: apps/web && bun run build`) ← **Build happens here, not VPS**
4. Upload `.next/` as artifact (`include-hidden-files: true` ← critical for hidden `.next/` dir)
5. MCP smoke test (conditional on DATABASE_URL secret — skipped in CI without DB)
6. Unit tests (`bun run test`)
**Job 2: `deploy`** (needs: build, main branch only)
1. Download artifact to `apps/web/.next`
2. Tar `.next` (exclude cache, ~15MB tarball)
3. SCP tarball to VPS (`/tmp/`)
4. SSH to VPS: `git pull` → `bun install` → `bun run scripts/indexer.ts` → `tar -xzf` → `systemctl restart`
### Key Fixes During This Phase
1. **`actions/checkout@v4` → `@v5`** — Node 20 deprecation warning
2. **`bun --cwd apps/web run build` → `working-directory: apps/web`** — `bun run` doesn't support `--cwd` flag; was silently printing usage, exiting 0
3. **`path: apps/web/.next/`** → **`path: apps/web/.next`** + **`include-hidden-files: true`** — `.next/` is a hidden directory; `upload-artifact@v4` excludes hidden files by default
4. **`bun --cwd apps/mcp run smoke`** → **`working-directory: apps/mcp`** — same `--cwd` bug
5. **Smoke test made conditional** on `DATABASE_URL` — was silently failing (never actually ran due to bug #2/#4)
6. **SCP of individual `.next/*`** → **tarball transfer** — SCP with glob of hidden files was failing/timing out
7. **Deploy tarball excludes `.next/cache`** — cache is ephemeral, reduces transfer size
8. **Merged ci.yml + deploy.yml** into single workflow — `workflow_run` trigger can't access artifacts from CI run
-67
View File
@@ -1,67 +0,0 @@
# MCPedia Phase 11 — CRUD + Auth + Web UI
## STATUS: ✅ ALL DONE (committed f8b525d, CI+Deploy success)
## Plan (spec BEFORE implementation, per user rule)
### Scope: 4 areas
1. **CRUD**: Create/Read/Update/Delete documents from web UI + MCP
2. **Authentication**: MCP/API writes (x-webhook-secret), Web CRUD (cookie-based ADMIN_PASSWORD)
3. **Web + Agent access**: Web forms + MCP write tools
4. **UI/UX**: Edit forms, TOC, dark mode
### Requirements:
1. Source of truth = filesystem (markdown files in content/{section}/{slug}.md)
2. DB mirrors disk (documents, document_chunks, document_revisions tables)
3. Single indexing path: indexContentFile in @mcpedia/core
4. Auth: MCP/API writes use WEBHOOK_SECRET; Web uses ADMIN_PASSWORD cookie
5. Slug rules: [a-z0-9][a-z0-9/_-]*, no //, no .. traversal
6. No breaking existing features (32 original tests still green)
7. UI/UX: edit button on doc pages, ?edit=1 inline form, /create page, login, TOC, dark mode
### Implementation
#### Backend
- `packages/parser`: added `stringifyFile()` (writes markdown with frontmatter)
- `packages/core`: `createDocument`, `updateDocument`, `deleteDocument` (file I/O + DB + revision + chunks)
- `apps/api`: tRPC CRUD routers (`requireWriteAuth`), fixed `requireWriteAuth` env-constant bug (now uses `ctx.expectedSecret` injected from `createApp(deps)`)
- `apps/mcp`: 3 new write tools (`create_document`, `update_document`, `delete_document`) gated by `x-webhook-secret`
#### Web UI
- `apps/web/app/api/auth/login/route.ts`: POST login → verify ADMIN_PASSWORD, set `mcpedia_admin` cookie
- `apps/web/app/api/docs/route.ts`: POST (create)
- `apps/web/app/api/docs/[...slug]/route.ts`: PUT (update), DELETE (delete)
- `apps/web/app/components/DocForm.tsx`: shared create/edit form
- `apps/web/app/create/page.tsx`: create form
- `apps/web/app/login/page.tsx`: login form
- `apps/web/app/[section]/[...slug]/page.tsx`: `?edit=1` inline edit, TOC, dark mode, Edit button
- `apps/web/app/docs/page.tsx`: docs index listing
- `apps/web/app/components/TOC.tsx`: auto-generated TOC from h2/h3 headings
- `apps/web/app/components/ThemeToggle.tsx`: dark mode toggle (localStorage + system default)
#### New deps (minimal — only for UX):
- `rehype-slug` (heading anchors for TOC links)
- `github-slugger` (matching slug algorithm for TOC client-side)
### Gotchas (learned the hard way)
1. tRPC fetch adapter expects input directly as JSON body, NOT JSON-RPC envelope
2. requireWriteAuth compared `ctx.webhookSecret !== WEBHOOK_SECRET` (module-level env constant) — untestable. Fixed: `ctx.webhookSecret !== ctx.expectedSecret` (injected per-app via deps).
3. Next.js catch-all routes: `[...slug]/edit/` is INVALID (catch-all must be last). Used `?edit=1` query param instead.
4. Next.js App Router: PUT/DELETE on `/api/docs/route.ts` doesn't match `/api/docs/{slug}` — need dynamic route `/api/docs/[...slug]/route.ts`.
5. `@env.example` should be updated.
6. `ADMIN_PASSWORD` must be set in VPS `.env` (deployed separately).
### Verification
- Typecheck: ✅ 4/4 apps green
- Tests: ✅ 40 tests green (32 original + 8 new), no DB/Redis
- Build: ✅ web compiled
- CI: ✅ success → Deploy: ✅ success
- Live: all 9 endpoints 200, 13 MCP tools live, CRUD e2e verified (login → create → view → delete via cookie auth), MCP create_document verified via header auth
- Test docs cleaned up (404 confirmed)
### Commits
1. `57f9001` feat: Phase 11 — CRUD + auth + web UI
2. `1dd16eb` feat(web): Phase 11 UI/UX — TOC, dark mode toggle, /docs index
3. `98437cb` fix(web): /api/docs accepts cookie OR header (not both required)
4. `8ed8c67` fix(web): split PUT/DELETE into /api/docs/[...slug]/route.ts
5. `f8b525d` chore: remove test docs
-24
View File
@@ -1,24 +0,0 @@
# Phase 3 — Deploy + git-sync wiring (remaining work)
Status: Phase 3/4 code is DONE and e2e-verified (webhook enqueue -> worker drain, 0 failed).
What was missing on the host: API + worker never ran as systemd services, and the GitHub
push webhook was never created. Also a real integration bug: `assertWebhookAuth` only
accepts a plain `x-webhook-secret` header, which GitHub does NOT send (GitHub delivers
`X-Hub-Signature-256` = HMAC-SHA256 of raw body). So a real GitHub webhook would 401.
## Changes
1. `apps/api/src/index.ts` — `assertWebhookAuth` now verifies GitHub `X-Hub-Signature-256`
(HMAC-SHA256 of raw body w/ WEBHOOK_SECRET) and still accepts `x-webhook-secret` for manual tests.
2. root `package.json` scripts — `api`: `bun --cwd apps/api run dev` -> `bun --cwd apps/api src/index.ts`
(the `run dev` form errors in bun 1.3.14; direct-file form verified booting + health). `worker` -> same form for consistency.
3. systemd — `cp deploy/*.service /etc/systemd/system`, `daemon-reload`, `enable --now mcpedia-api mcpedia-worker`.
4. Caddy — expose `/hooks/*` on `wiki.asepharyana.my.id` -> :4020 (no new DNS). Keep web on :4016.
5. GitHub webhook — `gh api repos/asepharyana/mcpedia/hooks` POST:
`https://wiki.asepharyana.my.id/hooks/reindex`, content_type json, secret=WEBHOOK_SECRET, events=push.
## Verification
- `systemctl is-active mcpedia-api mcpedia-worker` == active.
- `curl /health` on :4020 -> ok.
- `curl -X POST https://wiki.asepharyana.my.id/hooks/reindex -H "X-Hub-Signature-256: ..."` (or x-webhook-secret) -> 200 + jobId; worker drains.
- `gh api .../hooks` lists the webhook.
- `turbo run typecheck` green; commit + push.
-114
View File
@@ -1,114 +0,0 @@
# MCPedia — Phase 3 "Async + Scale" Implementation Plan
Status: Phase 1 (MVP) + Phase 2 (Semantic+API) DONE. Phase 3 adds async
background work, git-driven reindex, document revision history, and MCP
Resources. All logic stays in `@mcpedia/core`; new `packages/queue` wires
BullMQ; `apps/worker` runs the worker process; the existing API gets a git-sync
webhook + job-status procedures; the MCP server gains Resources.
## Scope (4 features from PHASES.md)
1. **Redis + BullMQ background indexing/embedding workers**
2. **Git synchronization hook** (auto-reindex on push via webhook)
3. **Document revision system** (`document_revisions`)
4. **MCP Resources** (`mcpedia://docs/...`) alongside existing tools
## Architecture decisions (locked)
- **Redis**: shared imrnes Redis `100.121.180.82:6379`, no auth (verified
`+PONG`). `REDIS_URL` env (default `redis://100.121.180.82:6379`), optional
`REDIS_PASSWORD`. BullMQ key prefix `mcpedia:` to avoid collisions on the
shared instance.
- **Queue lib**: `bullmq@6.1.2` + `ioredis@6.0.0` (BullMQ peer dep). Pass an
ioredis instance; BullMQ duplicates it for blocking commands.
- **Single source of truth preserved**: per-doc indexing logic moves into
`@mcpedia/core` as `indexContentFile(relPath, reason?)`. The script, the
worker, and the git hook ALL call this. Revisions are snapshotted inside it.
- **Revisions**: created only when body actually changes vs the latest revision
(avoids bloat on every sync). Stored in `document_revisions`.
## Files touched
### packages/config
- `src/index.ts`: add `REDIS_URL`, `REDIS_PASSWORD`, `QUEUE_PREFIX`.
### packages/db
- `src/schema.ts`: add `documentRevisions` table
(id, documentId→documents.id cascade, slug, revisionNo int, title, body,
meta jsonb, reason text, createdAt). Index (document_id, revision_no DESC),
(slug).
- `drizzle/0002_document_revisions.sql`: migration (applied via psql).
- `drizzle/meta/0002_snapshot.json` + `_journal.json` entry (keeps drizzle-kit
consistent even though we apply manually).
### packages/core (new)
- `src/index.service.ts`:
- `indexContentFile(relPath: string, reason = "index")` — parse → upsert
`documents` → `indexChunks` → snapshot revision (if changed).
- `runFullIndex(reason?)` — walk content, index each, return counts.
- `src/revision.service.ts`:
- `createRevision(...)`, `listRevisions(slug, limit)`,
`getRevision(id)`, `latestRevisionBody(slug)`, `restoreRevision(id)`.
- `src/index.ts`: export both.
### packages/queue (NEW)
- `package.json` (@mcpedia/queue): deps bullmq, ioredis, @mcpedia/core,
@mcpedia/db, @mcpedia/config.
- `src/client.ts`: ioredis instance factory from config.
- `src/queue.ts`:
- `INDEX_QUEUE = "mcpedia-index"`.
- `enqueueIndexDoc(slug, absPath, reason)`, `enqueueFullIndex(reason)`.
- `getQueue()` lazy singleton.
- `src/worker.ts`: `startWorker()` — BullMQ Worker with 3 job types:
`index-doc` (single), `index-all` (full), `reindex` (full, reason=git-push).
Graceful shutdown on SIGINT/SIGTERM. Job progress + error handling.
### apps/worker (NEW)
- `package.json` (@mcpedia/worker): script `start: bun src/index.ts`.
- `src/index.ts`: `startWorker()` + heartbeat log.
### apps/api
- `src/index.ts`: add `POST /hooks/reindex` (full) and
`POST /hooks/index?slug=` (single) webhook routes → enqueue jobs. Mount
AFTER /trpc.
- `src/router.ts`: add `jobStatus` (id→state/prev/failedData),
`queueStatus` (waiting/active/completed/failed counts),
`revisions` (slug→list), `restoreRevision` (id→new slug/doc).
- `package.json`: add `@mcpedia/queue` dep, `hooks` reused.
### apps/mcp
- `src/index.ts`: register Resources:
- `mcpedia://docs` (list all metas)
- `mcpedia://docs/{slug}` (full body from disk)
- `mcpedia://docs/{slug}/chunks` (chunk previews)
- `mcpedia://docs/{slug}/revisions` (revision list)
- `src/smoke.test.ts`: add `listResources` + read `mcpedia://docs` assertion.
### scripts
- `scripts/indexer.ts`: refactor `main()` to call `runFullIndex()`.
### Root
- `package.json`: add `"worker": "bun --cwd apps/worker run start"`,
`"reindex": "bun run scripts/worker.ts"`? No — `worker` runs the listener;
triggering reindex = `bun run api` webhook or `enqueueFullIndex` helper.
Add `"enqueue-index": "bun run scripts/enqueue.ts"` (one-shot enqueue).
- `.env.example`: add `REDIS_URL`, `REDIS_PASSWORD`, `QUEUE_PREFIX`.
### Docs
- `PHASES.md`: mark Phase 3 items DONE with notes.
- `README.md`: document worker, webhook, revisions, MCP resources.
## Verification (real, not claimed)
1. `bun install` picks up new deps.
2. `bunx turbo run build` + `typecheck` green across workspace.
3. **Real BullMQ e2e against imrnes Redis**: script that enqueues an
`index-doc` job, starts a Worker, asserts the job completes and the doc row
+ chunks + a revision row appear in Postgres. Verifies Redis+ioredis+bullmq
+ db + core all wired correctly.
4. `bun --cwd apps/mcp run smoke` passes (incl. new resources).
5. `bun run index` (runFullIndex) green; verify `documents`,
`document_chunks`, `document_revisions` row counts via psql.
6. API webhook: `curl -XPOST localhost:4020/hooks/reindex` enqueues; worker
processes; `curl localhost:4020/trpc/queueStatus` reflects counts.
7. MCP resource read returns real content.
-105
View File
@@ -1,105 +0,0 @@
# MCPedia Phase 4 — Operability & Correctness Hardening
> Reinterpretation: the PHASES.md "Scale-out" items (OpenSearch, object storage,
> multi-tenant, distributed workers) are YAGNI at KB scale (4 docs). Phase 4 =
> make the Phase 3 async + revision machinery **correct, secure, observable, and
> deployable** — not speculative infra. Each task below fixes a real gap found
> by reading the code, not a hypothetical need.
## Tasks
### T1 — `restoreRevision` must rebuild semantic chunks (CORRECTNESS BUG)
**Root cause:** `packages/core/src/revision.service.ts` `restoreRevision` writes
the old body back into `documents` but never calls `indexChunks(slug, body)`.
So after a restore, keyword search (FTS on `documents.body`) is correct but
`document_chunks`/embeddings stay on the *new* body → semantic + hybrid search
return stale/ghost chunks.
**Fix:**
- Add `reindexChunks(slug)` to `@mcpedia/core` that re-runs `indexChunks(slug, body)`
using the live `documents.body` (the new body after the update).
- Call it inside `restoreRevision` after the `documents` update (wrap in try/catch
like `indexContentFile` so embed failure doesn't abort the restore).
- Add a unit-style assertion to the MCP smoke test or a small script: restore →
`document_chunks` count matches re-chunked body.
**Files:** `packages/core/src/revision.service.ts`, `packages/core/src/index.ts`,
`packages/core/src/document.service.ts` (export existing `indexChunks` if needed).
### T2 — Secure the git-sync webhook (SECURITY)
**Root cause:** `apps/api/src/index.ts` `/hooks/reindex` and `/hooks/index` accept
any request with no `WEBHOOK_SECRET` check — `.env.example` defines `WEBHOOK_SECRET`
but the router never reads it.
**Fix:**
- In `apps/api/src/index.ts`, compare `c.req.header("x-webhook-secret")` (or
`?secret=`) against `WEBHOOK_SECRET` (from `@mcpedia/config`). If unset/mismatch →
`401`. If `WEBHOOK_SECRET` env is empty, reject at startup with a clear log
(fail-fast, don't run an open endpoint).
- Add `WEBHOOK_SECRET` to `packages/config/src/index.ts` export.
- Document the header in README + verify with curl (401 without secret, 200 with).
**Files:** `apps/api/src/index.ts`, `packages/config/src/index.ts`, README.
### T3 — Web UI revisions view (UX)
**Root cause:** Web UI (server components) calls `@mcpedia/core` directly; there is
no revisions surface even though `revisions`/`restoreRevision` tRPC + MCP resource
exist.
**Fix (server-component only, no client JS):**
- On the doc page (`apps/web/app/[section]/[...slug]/page.tsx`), fetch
`listRevisions(fullSlug, 10)` and render a "History" panel: revision number,
reason, createdAt, body length, and a `/api/revisions/restore` link/POST that
calls the tRPC `restoreRevision` mutation via a server action or a form POST to
a small route handler. Simplest: a `<form method="post" action="/api/revisions/restore">`
with hidden `id` + a route handler in `apps/web` calling `restoreRevision`.
Keep it read-mostly; restore is a deliberate action.
- Add `apps/web/app/api/revisions/restore/route.ts` (POST) → `restoreRevision(id)`
→ `revalidatePath` the doc.
**Files:** `apps/web/app/[section]/[...slug]/page.tsx`,
`apps/web/app/api/revisions/restore/route.ts`.
### T4 — Paginate `listRevisions` / `revisions` API (PERF)
**Root cause:** `revision.service.ts` `listRevisions` does `select length(body)`
(fine) but the tRPC `revisions` and MCP resource return *full* revision rows
including the body in some callers; list endpoints should never carry bodies.
**Fix:**
- Ensure `listRevisions` summary excludes `body` (it already does — `bodyLength`
only). Add `offset` param for paging. Confirm MCP resource uses the summary.
- No behavior change for the doc page (uses summary).
**Files:** `packages/core/src/revision.service.ts` (add `offset`), router unchanged.
### T5 — Deployable as supervised services (OPS)
**Root cause:** `apps/api` and `apps/worker` run only ad-hoc; the host already runs
`zeavis` via Nix/systemd. Phase-4 operability = provide a systemd unit (or Nix
service) so `mcpedia-api` + `mcpedia-worker` start on boot and restart on failure.
**Fix (Nix-first, per MEMORY):**
- Write `mcpedia-api.service` + `mcpedia-worker.service` systemd unit files under
`deploy/` (bun run api / bun run worker, `WorkingDirectory`, `Restart=on-failure`,
`EnvironmentFile` pointing at `.env`, `After=network-online.target`).
- README section "Run as a service" with `cp deploy/*.service /etc/systemd/system && systemctl daemon-reload && systemctl enable --now mcpedia-api mcpedia-worker`.
- **Do NOT** `systemctl` on the host without user confirmation (changing live
services). Provide the files + instructions only; user runs enable.
**Files:** `deploy/mcpedia-api.service`, `deploy/mcpedia-worker.service`, README.
## Verification (all real, against imrnes Redis + Postgres)
1. `turbo run typecheck` + `turbo run build` green.
2. T1: script — edit a doc, reindex (new revision + new chunks), restore rev #1,
assert `document_chunks` count for that slug now matches re-chunk of rev #1 body
and `semanticSearch` on a term unique to rev #1 returns it.
3. T2: `curl -XPOST localhost:4020/hooks/reindex` → 401; with
`-H "x-webhook-secret: $WEBHOOK_SECRET"` → 200 + jobId.
4. T3: `next build` includes the History panel; restore form rebuilds chunks
(verified via T1 path through the route handler).
5. T4: `revisions` API returns summaries without body; offset paging works.
6. T5: `systemd-analyze verify deploy/*.service` passes (off-host safe check);
README documents enable steps.
## Out of scope (YAGNI, keep deferred per PHASES.md)
OpenSearch/Elasticsearch, object storage, multi-tenant, distributed workers,
pgvector migration. Revisit only when corpus > ~10k docs or query latency bites.
-41
View File
@@ -1,41 +0,0 @@
# MCPedia — "lanjut semua" workstream
Three real gaps remain (from review): tiny corpus (4 docs), MCP read-only (no write/auth),
no observability. This plan closes all three.
## 1. Content corpus (grow the KB)
Author real, useful docs so search/semantic/revisions have something to operate on.
Frontmatter schema (from packages/parser): id,title,type,tags,status,author,created_at,updated_at.
Sections: docs|writeups|research|notes. Files under content/<section>/...
New docs to add:
- content/docs/caddy/reverse-proxy.md (ops reference, tags: caddy, reverse-proxy, tls)
- content/docs/bullmq/workers.md (queue/worker reference, tags: bullmq, redis, jobs)
- content/docs/mcp/streamable-http.md (MCP transport reference, tags: mcp, protocol, http)
- content/notes/postgres/full-text-search.md (PG FTS notes, tags: postgres, fts, tsvector)
- content/writeups/infra/cloudflare-525.md (debugging writeup, tags: cloudflare, tls, 525)
After adding: `bun run index` to reindex (writes revisions + chunks), verify counts.
## 2. MCP write-tools + auth
Add mutating + admin tools to the MCP server (currently read-only):
- `index_document(slug)` -> enqueueIndexDoc (requires MCP auth header)
- `reindex_all()` -> enqueueFullIndex (requires MCP auth header)
- `queue_status()` -> getQueue counts (read, public)
- `restore_revision(id)` -> restoreRevision (requires MCP auth header)
Auth: MCP client must send header `x-webhook-secret` (reuse WEBHOOK_SECRET). StreamableHTTP
transport: read the Authorization/header in http.ts, pass to server via a factory closure
capturing the request; tools check it. Stateless per-request server already created fresh,
so threading the header is clean. Guard write-tools with the same requireWriteAuth logic.
Verify: unauthenticated call to index_document -> error; authenticated -> enqueues job.
## 3. Observability
- `GET /metrics` on the API (Prometheus text format): queue counts (waiting/active/
completed/failed/delayed), uptime, service name. Public (safe to expose).
- Caddy: expose /metrics on wiki. domain -> :4020 (add to handle list).
- tRPC `queueStatus` already exists; /metrics reuses getQueue.
Verify: curl /metrics -> text exposition with mcpedia_queue_* gauges.
## Verification
- typecheck green (turbo run typecheck).
- MCP smoke extended: tools/list shows new tools; authenticated index_document enqueues.
- /metrics returns 200 text; queue drains.
- commit + push.
-157
View File
@@ -1,157 +0,0 @@
# MCPedia Phase 9 — Test Coverage + Observability Hardening
**Goal:** Add a real test suite (CI-gated) covering every layer of MCPedia — pure
logic, Core services, the API surface, the MCP server + auth gates, and the Web
UI — so regressions are caught before deploy. The suite must run green in CI
with **no external services** (no Postgres, no Redis) by using in-process fakes.
## Constraints recap
- Tooling: bun workspaces + Turborepo, bun 1.3.14 has a built-in `bun:test` runner.
- DB is `imrnes` Postgres at `:6432` (no DB in CI) — tests must NOT touch it.
- pgvector is NOT installed (vectors are `real[]`, cosine in-app) — confirmed.
- Existing smoke test (`apps/mcp/src/smoke.test.ts`) is a script with `main()`
run via `bun run smoke`, NOT a `bun:test` file. It hits the DB → cannot run in CI.
- Secrets (`WEBHOOK_SECRET`, `DATABASE_URL`, `EMBED_*`) live in `.env` (gitignored)
or BWS for deploy — never in tests or committed config.
## Decisions
1. **Runner:** `bun:test` — zero-config, built into the bun 1.3.14 toolchain
already used. No extra deps. Add a `test` task to `turbo.json` and `package.json`
scripts; add a `Test` step to CI.
2. **No live DB in CI.** Tests that would need Postgres/Redis/Embeddings use
**in-process fakes** (memory stores + a stub embedder returning fixed vectors).
This means Core service tests cannot use the real `@mcpedia/db` singleton —
they must accept an injected DB (drizzle-pg mem or a hand-rolled fake). We will
**refactor the Core services' DB access behind injectable handles** where cheap,
and for the MCP/HTTP auth-layer tests we stub `@mcpedia/queue` + `@mcpedia/core`
at the module boundary (the transport/auth logic does not need a real queue).
3. **Test boundaries by package:**
- `packages/embeddings` — pure: `chunkText`, `cosine`. Real assertions, no I/O.
- `packages/search` — pure: `toTsQuery`, `cosine`. SQL-bearing functions
(`keywordSearch`/`semanticSearch`/`hybridSearch`) tested via a **fake db**
injected into `@mcpedia/db`, OR via the `cosine`/fusion helpers in isolation.
- `packages/core` — `snapshotRevision` dedup logic (refactor to accept an inject
fn or test the public `indexContentFile`/`restoreRevision` with fakes).
Focus: revision-dedup correctness + `restoreRevision` triggers `reindexChunks`.
- `apps/api` — Hono app: `/health`, `/metrics` shape, `/hooks/*` auth (401 w/o
secret, 200 + enqueue w/ secret using a fake queue), tRPC `restoreRevision`
mutation auth gate (401 w/o secret).
- `apps/mcp` — auth gates on write tools: `index_document`/`reindex_all`/
`restore_revision` error without secret, enqueue with secret (fake queue).
Read tools + resources via `InMemoryTransport` (reuse smoke style but without
DB).
- `apps/web` — render correctness of home (lists sections), doc page
(renders title + markdown + history panel when revisions exist), search page
(keyword/hybrid toggle, empty state). These need a fake Core.
## Approach per test (minimal, high-signal)
### embeddings: `packages/embeddings/src/chunk.test.ts`
- `chunkText("hello world")` with short size → single chunk.
- `chunkText` long text → multiple chunks, overlap honored, no word splits past boundary.
- `chunkText("")` / `" "` → `[]`.
### search: `packages/search/src/cosine.test.ts` (new tiny file) + refactor
- `cosine([1,0],[0,1])` ≈ 0; `cosine([1,1],[1,1])` = 1; `cosine([],[1])` = 0.
- `toTsQuery("a b c")` → `"a:* & b:* & c:*"`; empty/garbage → `""`.
### core: `packages/core/src/index.service.test.ts`
The hard part: `indexContentFile`/`restoreRevision`/`snapshotRevision` call `db`
directly. Two options:
- **Option A (chosen):** extract `snapshotRevision`'s "latest body" + "insert"
steps behind the existing `db` but make `indexContentFile` test the revision
*decision* by inserting a doc + revision directly via `db` in a test Postgres
(too heavy for CI).
- **Option B (chosen):** test the **pure decision logic** by refactoring
`snapshotRevision` to export a pure helper
`shouldCreateRevision(latestBody, body): boolean` — `true` when latest is null
or latest.body !== body. Then a unit test asserts the dedup truth table;
`indexContentFile` is verified by the existing e2e (manual `bun run index`).
This is the CI-safe win.
- `restoreRevision` correctness: assert it calls `reindexChunks(slug)` — we can
test by spying. Since `reindexChunks` is in the same module, we'll export a
seam: `restoreRevision(id, { reindexChunks: spy })` — keep backward compat by
defaulting. (Or test the public contract via the API layer instead.)
### api: `apps/api/src/index.test.ts`
- Build the Hono `app` from a testable factory that accepts a fake queue + fake
webhook secret. Current `index.ts` throws at import if `WEBHOOK_SECRET` unset —
that breaks import in CI. **Refactor:** move the fail-fast check into
`listen()`/serve start, so the app is constructable without a secret for
testing. Export `createApp(opts?)` returning the Hono instance.
- `/health` → 200 `{ok:true}`.
- `/metrics` → 200, text/plain, contains `mcpedia_uptime_seconds` +
`mcpedia_queue_jobs` for each state (fake queue returns 0/1).
- `POST /hooks/reindex` w/o `x-webhook-secret` → 401; with matching secret →
200 + `{ok:true, jobId}` (fake queue records the enqueue).
- tRPC: build a client against the app, call `restoreRevision` without secret →
error; the public `listDocuments` returns from a fake DB.
### mcp: `apps/mcp/src/auth.test.ts`
- `createMcpServer()` (no secret) → `index_document`/`reindex_all`/`restore_revision`
throw "unauthorized".
- `createMcpServer("secret")` → same tools reach the enqueue call (fake queue).
- Read tools still work without secret (server loads, resources list).
### web: `apps/web/app/search/page.test.tsx` (or a lighter harness)
- This is the hardest to test without a browser. **Decision:** keep web tests
minimal — assert that `toTsQuery`/render helpers exist; full DOM tests deferred
(needs playwright + a running server). We'll instead add a **contract test**
that the search page's `dynamic = "force-dynamic"` export exists (static-
generation guard, the kind of thing that broke CI before).
## File layout (new files)
```
packages/embeddings/src/chunk.test.ts
packages/search/src/cosine.test.ts
packages/core/src/index.service.test.ts # snapshotRevision + restoreRevision seam
apps/api/src/index.test.ts # Hono /health /metrics /hooks + tRPC gate
apps/mcp/src/auth.test.ts # write-tool auth gates
```
## turbo.json
Add a `test` task (like `typecheck`, no dependsOn, cache false so it always runs):
```jsonc
"test": { "cache": false }
```
Each app/pkg gets `"test": "bun test"` in its package.json.
## CI (`.github/workflows/ci.yml`)
After `Build`, add:
```yaml
- name: Test
run: bun run test # -> turbo run test
```
## Source changes required (enablers)
1. `apps/api/src/app.ts` (NEW) — extracted `createApp(deps?)` factory returning a
`Promise<Hono>`. Pure construction (no process exit, no fail-fast). Accepts
injected `ApiDeps` (`{ queue, webhookSecret }`); when omitted, lazily
imports the real queue + uses `WEBHOOK_SECRET` (production path). `/dashboard`
now renders the HTML from a new `apps/api/src/dashboard.ts` module.
2. `apps/api/src/dashboard.ts` (NEW) — the self-contained dashboard HTML, split
out of the original `index.ts` so the route is testable + the const is
importable. XSS-safe (esc() on all KB-sourced fields; documented in comment).
3. `apps/api/src/index.ts` — now a thin re-export of `createApp`/`start` + the
`isMain` bootstrap. Systemd unit still runs `bun --cwd apps/api src/index.ts`.
4. `packages/core/src/index.service.ts` — exported `shouldCreateRevision` (pure
predicate for the revision dedup invariant). `restoreRevision` in
`revision.service.ts` now accepts an optional `opts.reindex` seam (defaults
to the real `reindexChunks`), making the chunk-rebuild contract testable.
5. `apps/mcp/src/smoke.ts` — renamed from `smoke.test.ts` (so `bun test` doesn't
treat the integration smoke as a unit run), and fixed stale assertions:
expected tool set updated to all 10 tools (Phase 7 additions + write tools),
`list_documents(section=docs)` count updated to 4 (post-Phase-7 corpus).
## Verification
- `bun run test` (local) → 32 tests green across 6 packages (embeddings 5,
search 8, core 4, parser 5, mcp 6, api 8), **no live DB needed** (mocks
stub `@mcpedia/db`, `@mcpedia/queue`, `@mcpedia/core`).
- `bun run typecheck` → green (4 apps, no test-only type errors).
- `bun --cwd apps/mcp run smoke` → green (integration, needs live DB — runs in
CI on the deploy host, not in CI's no-services job).
- Live API check: `/health`, `/metrics`, `/dashboard`, `/hooks/*` auth gate
all 200/401-verified against a temp-port server.
- Commit + push; worker redeploy not needed (code + tests only).
+43
View File
@@ -0,0 +1,43 @@
# Phase 15c — Theme-Consistent Color System
> User: "banyak pewarnaan yg tidak sesuai tema dark mode atau light mode"
## Problem
63 hardcoded hex colors (#5e6ad2, #7170ff, #4f46e5, etc.) bypass the CSS
variable theme system in globals.css. These don't adapt between light/dark mode
properly — the `dark:` variant changes text color but background stays the same.
## Color Mapping
### `#5e6ad2` (indigo-600)
- Light: `--brand: #5e6ad2` (same)
- Dark: `--brand: #5e6ad2` (same)
→ Replace with `var(--brand)`
### `#4f46e5` (indigo-700)
- Light: `--accent: #4f46e5`
- Dark: `--accent: #7170ff`
→ Replace with `var(--accent)`
### `#7170ff` (light blue, dark mode accent)
- Light: `var(--accent)` (#4f46e5)
- Dark: `var(--accent)` (#7170ff)
→ Replace `dark:text-[#7170ff]` → `dark:text-[var(--accent)]`
### `#6a75e0` (hover lighter)
→ Replace with `var(--accent-hover)`
### `/10`, `/15`, `/20`, `/30` opacity tints
- `bg-[#5e6ad2]/15` → `bg-[var(--brand)]/15`
- `bg-[#5e6ad2]/10` → `bg-[var(--brand)]/10`
- `bg-[#5e6ad2]/30` → `bg-[var(--brand)]/30`
## Files to fix
- `[section]/[...slug]/page.tsx` (~16 instances)
- `[section]/page.tsx` (~5 instances)
- `components/Sidebar.tsx` (2 instances)
- `components/DocForm.tsx` (~6 instances)
- `components/CommandMenu.tsx` (~6 instances)
- `components/Header.tsx` (2 instances)
- `components/McpConfigSnippet.tsx` (1 instance)
- `page.tsx` (check)
@@ -0,0 +1,254 @@
# SSH deploy to bun-source services (not Nix): gotchas
This repo (`asepharyana/mcpedia`) deploys **bun source** via systemd — NOT via
Nix like GMW. The pattern is: CI builds in GitHub Actions → upload artifact →
deploy job downloads artifact → SCP tarball to VPS → SSH → git pull + unpack +
index + `systemctl restart`. The VPS does NOT build.
## Gotchas (learned during mcpedia deploy setup, 2026-08-20/21)
### 1. SSH host = public IP, NOT Tailscale IP
The VPS appears in `~/.ssh/config` as `Host orange → HostName 100.79.111.61`.
That IP is a **Tailscale `tailscale0` interface address** in the CGNAT range
(`100.64.0.0/10`). GitHub Actions runners are NOT on the Tailscale network, so
they get `Connection timed out` (dropped at the network layer, not refused).
**Fix:** Use the VPS's real public IP (`45.127.35.244`, discovered via
`curl https://api.ipify.org` from the VPS). Port 22 is open in iptables
(`ACCEPT tcp dpt:22` from `0.0.0.0/0`).
### 2. SSH key MUST be stored directly from file (not shell variable)
Storing the deploy key via a shell variable corrupts it:
```bash
# ❌ WRONG — newlines get mangled by the shell → "ssh: no key found"
PRIV_KEY=$(cat keyfile)
gh secret set SSH_DEPLOY_KEY --body "$PRIV_KEY"
# ✅ CORRECT — pipe the file directly so GitHub preserves all bytes
cat keyfile | gh secret set SSH_DEPLOY_KEY --repo asepharyana/mcpedia
```
Symptom: `appleboy/ssh-action` fails with
`ssh.ParsePrivateKey: ssh: no key found`.
### 3. Use `appleboy/ssh-action@v1` (not `@v1.1.0`)
- `@v1.1.0` (very old) has a key-parsing bug that rejects valid keys
(`ssh.ParsePrivateKey: ssh: no key found`).
- `@v1` (latest) handles OpenSSH ed25519 keys correctly.
### 4. `appleboy/ssh-action` needs explicit `envs` to pass through secret-derived vars
The `envs` parameter passes environment variables to the remote script.
Include any secret you reference in the script:
```yaml
envs: SSH_DEPLOY_HOST # passes SSH_DEPLOY_HOST into the remote script
```
Without it, `echo $SSH_DEPLOY_HOST` on the VPS returns empty even though the
action connected.
### 5. Always pass `-o IdentitiesOnly=yes`
Without it, SSH offers ALL loaded identities (deploy key + default keys) and the
server may reject after "Too many authentication failures". `appleboy/ssh-action`
handles this internally via the `key` input, but if you use raw `ssh` add
`-o IdentitiesOnly=yes`.
### 6. GitHub `workflow_run` does NOT carry the push commit SHA
`workflow_run` events fire after CI completes, but `actions/checkout` checks out
the **default branch tip**, not the specific commit. This is fine for deploy
(the script does `git pull origin main` anyway), but verify the checkout ref
matches what CI built if you rely on it.
### 7. DB migrations: `db:push` prompt is non-interactive-unfriendly in SSH deploy
When the schema includes a new column (e.g. adding `extra_fields JSONB`),
`drizzle-kit push` in the SSH deploy script needs to be run. Two issues:
- **`--strict` mode (config default)**: `drizzle.config.ts` has `strict: true`,
which makes `drizzle-kit push` prompt `No, abort / Yes, I want to execute all
statements`. In a non-interactive SSH script (no TTY), the prompt never receives
input and the command times out → SIGTERM → exit code 124 → deploy fails.
`set -e` then kills the whole deploy.
- **`bun run <script>` ambiguity**: `bun run index` resolves the `index` script
from `package.json`. In a monorepo with workspace packages, ensure the script
name is unique or use the explicit file path (e.g. `bun run scripts/indexer.ts`).
**Fix**: Use `psql` for schema changes instead of `db:push`:
```bash
psql "$DATABASE_URL" -c \
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS extra_fields jsonb DEFAULT '{}'::jsonb NOT NULL;"
```
After deploy, check the **public URL** (not just `systemctl is-active`):
```bash
curl -sk -o /dev/null -w "%{http_code}" https://wiki.asepharyana.my.id/
curl -s https://wiki.asepharyana.my.id/docs/websocket/contract | grep -c "frontmatter-pattern"
```
## Deploy workflow template (bun-source, not Nix) — build in CI, deploy-only on VPS
> **User correction (2026-08-21):** "alur ci nya juga benarkan lah masa build
> di vps,harusnya kan di vps hanya deploy,dan seharusnya pakai nix"
The VPS must **not** build. Build happens in CI; the VPS only deploys the
pre-built artifact. Use a single workflow with a `needs:` chain (not
`workflow_run` — see pitfall #8 below).
### Pitfalls for bun-source deploy workflows
- **`bun --cwd X run Y` is broken.** `bun run` does NOT accept `--cwd` as a
subcommand flag — it prints usage and exits 0 (silent failure). Use
`working-directory: X` in the step, or `cd X && bun run Y`. (Root cause of
CI appearing to pass while the build never actually ran. This also affects
the smoke test step: `bun --cwd apps/mcp run smoke` was silently failing.)
- **`.next/` is a hidden directory.** `actions/upload-artifact@v4` excludes
hidden files/dirs by default (`include-hidden-files: false`). Set
`include-hidden-files: true` or the artifact will be empty.
- **`workflow_run` cannot download artifacts from the CI run.** This is a
known GitHub Actions limitation — the deploy workflow doesn't have access to
the CI run's artifacts. **Fix:** merge CI + deploy into a single workflow with
`deploy: needs: [build]`.
- **`actions/checkout@v4` → Node 20 deprecation.** Bump to `@v5`.
- **SCP'ing hundreds of individual files from `.next/` can time out.** Tar first,
then SCP the single tarball: `tar -czf app.tar.gz .next`, SCP, then SSH to
unpack. Also exclude `.next/cache/` to reduce size (~73MB → ~49MB).
- **`download-artifact` path resolution.** When downloading with
`path: apps/web/.next`, the artifact (which contains the `.next` directory
contents from upload `path: apps/web/.next`) gets extracted correctly to
`apps/web/.next/`. Do NOT use `path: .next` — that creates `.next/.next/`.
### Final working workflow (verified 2026-08-21)
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: typecheck + build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Build web
working-directory: apps/web
run: bun run build
# Upload .next/ — include-hidden-files is REQUIRED (hidden dir)
- name: Upload web build artifact
uses: actions/upload-artifact@v4
with:
name: mcpedia-web-build
path: apps/web/.next
include-hidden-files: true
if-no-files-found: error
# Smoke test: requires Postgres + Redis — skipped in CI without secrets
- name: MCP smoke test
if: env.DATABASE_URL != ''
working-directory: apps/mcp
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
REDIS_URL: ${{ secrets.REDIS_URL }}
run: bun run smoke
- name: Test
run: bun run test
deploy:
name: deploy to VPS
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: mcpedia-web-build
path: apps/web/.next
- name: Tar .next
run: tar -C apps/web --exclude='.next/cache' -czf mcpedia-web-next.tar.gz .next
- name: Copy tarball to VPS
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.SSH_DEPLOY_HOST }}
port: ${{ secrets.SSH_DEPLOY_PORT }}
username: ${{ secrets.SSH_DEPLOY_USER }}
key: ${{ secrets.SSH_DEPLOY_KEY }}
source: "mcpedia-web-next.tar.gz"
target: "/tmp/"
- name: Unpack and restart on VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_DEPLOY_HOST }}
port: ${{ secrets.SSH_DEPLOY_PORT }}
username: ${{ secrets.SSH_DEPLOY_USER }}
key: ${{ secrets.SSH_DEPLOY_KEY }}
envs: SSH_DEPLOY_HOST
script: |
set -e
cd /home/code/mcpedia
git pull origin main
/home/code/.bun/bin/bun install --frozen-lockfile
/home/code/.bun/bin/bun run scripts/indexer.ts
rm -rf apps/web/.next
tar -xzf /tmp/mcpedia-web-next.tar.gz -C apps/web/
rm -f /tmp/mcpedia-web-next.tar.gz
sudo systemctl restart mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker
sleep 3
systemctl --no-pager status mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker --no-legend
```
## Secrets to configure (per-repo GitHub secrets)
| Secret | Value | How |
|--------|-------|-----|
| `SSH_DEPLOY_HOST` | VPS public IP (e.g. `45.127.35.244`) | `gh secret set SSH_DEPLOY_HOST --body "45.127.35.244"` |
| `SSH_DEPLOY_PORT` | `22` | `gh secret set SSH_DEPLOY_PORT --body "22"` |
| `SSH_DEPLOY_USER` | `code` (or whatever user owns the services) | `gh secret set SSH_DEPLOY_USER --body "code"` |
| `SSH_DEPLOY_KEY` | SSH deploy private key (ed25519) | `cat keyfile \| gh secret set SSH_DEPLOY_KEY` |
And on the VPS, add the public key to the deploy user's `authorized_keys`:
```bash
ssh code@<public-ip> "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys <<< '$(cat keyfile.pub)'"
chmod 600 ~/.ssh/authorized_keys
```
## CI monitoring
```bash
gh run list --repo asepharyana/mcpedia --workflow "CI" --limit 3
gh run view <run_id> --log-failed # only failing-step logs
```
+24
View File
@@ -7,3 +7,27 @@ This version has breaking changes — APIs, conventions, and file structure may
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
# MCPedia Agent Instructions
MCPedia is a content-first knowledge base where documents are stored in **PostgreSQL** as the source of truth (with auto-generated .md backups on disk), indexed into embeddings + search vectors, and accessed via Web UI, tRPC API, and Model Context Protocol (MCP).
- **DB-first**: Documents are created/updated/deleted via the MCP `create_document`/`update_document`/`delete_document` tools (require `x-webhook-secret`). Filesystem `.md` files in `content/` are auto-generated backups (gitignored).
- **Git-sync deploy**: CI builds `.next` on GitHub Actions → tarball → SCP to VPS → unpack. VPS never builds. Content changes flow via MCP API to the DB directly.
## Architecture Principles
1. **Single Core Layer (`@mcpedia/core`)**: All business logic (document CRUD, indexing, search, revisions, path classification) resides in `packages/core`. Never access the database directly from `apps/web`, `apps/mcp`, or `apps/api`.
2. **DB as Source of Truth**: PostgreSQL stores document body + metadata. Filesystem `.md` files in `content/` are auto-generated backups (gitignored). Use the MCP `create_document`/`update_document` tools to write content.
3. **Multi-Modal Search**: Keyword search (Postgres FTS `tsvector` + GIN), Semantic search (cosine similarity over chunked embeddings), and Hybrid search (Reciprocal Rank Fusion / RRF) are unified in `@mcpedia/search`.
4. **Mutations & Security**: State-changing operations (document creation/updates/deletions, reindexing, revision restoration) require authentication (`x-webhook-secret` header or session cookie).
## Development Workflow
- **Install dependencies**: `bun install`
- **Run tests**: `bun run test`
- **Typecheck**: `bun run typecheck`
- **Build monorepo**: `bunx turbo run build`
- **Index content**: `bun run index`
- **Start dev servers**: `bun run dev`
-503
View File
@@ -1,503 +0,0 @@
# MCPedia — Phase Status
Legend: ✅ built · 🟡 partial · ⬜ deferred
## Phase 1 — MVP (✅ DONE)
| Capability | Status | Notes |
| --------------------- | ------ | ----- |
| Monorepo (bun + Turbo)| ✅ | apps/{web,mcp}, packages/{types,config,db,parser,search,core}, scripts |
| Content as Markdown | ✅ | `content/{docs,writeups,research,notes}/`, Git-tracked |
| Frontmatter parsing | ✅ | `@mcpedia/parser` (gray-matter) |
| Postgres metadata | ✅ | `@mcpedia/db` Drizzle, `documents` table |
| Postgres FTS | ✅ | weighted `tsvector` (title A / body B), GIN index, `ts_rank`+`ts_headline` |
| Core services | ✅ | Document / Content / Search — single business-logic layer |
| Indexer | ✅ | `scripts/indexer.ts` walks content/ → upserts |
| Web UI (Next 16) | ✅ | home (list), doc view (SSG), search (dynamic). react-markdown render |
| MCP server (stdio) | ✅ | 4 tools; in-memory smoke test passing |
| Hybrid/semantic search| ⬜ | Phase 2 |
## Phase 2 — Semantic + API
- [x] `packages/embeddings` — `EmbeddingProvider` interface + OpenRouter provider (via 9router `/v1`, `encoding_format:"float"`); `chunkText` + `embedChunks` batcher. `EMBED_DIM=2048` discovered live.
- [x] Schema `document_chunks` (id, document_id→documents.id cascade, slug, chunk_index, content, `embedding real[]`). Stored as `real[]` because pgvector **is not installed** on the shared imrnes Postgres (installing needs host-level apt — deferred). Cosine computed in-app; instant for a KB-sized corpus.
- [x] `scripts/indexer.ts` — chunks + embeds + upserts (per-doc replace).
- [x] `@mcpedia/search` — `semanticSearch` (cosine) + `hybridSearch` (FTS + cosine, RRF fusion). `keywordSearch` unchanged.
- [x] `apps/api` — Hono + tRPC v11 (`@trpc/server` fetch adapter, `@hono/node-server` on :4020): `search`, `semanticSearch`, `hybridSearch`, `getDocument`, `listDocuments`, `related`.
- [x] MCP server — added `semantic_search` + `hybrid_search` tools (6 total).
- [x] Web search — keyword/hybrid toggle (`?mode=hybrid`), hybrid reaches semantically-related docs keyword misses.
## Phase 3 — Async + Scale ✅ DONE
- [x] **Redis + BullMQ background indexing / embedding workers** —
`packages/queue` (ioredis singleton + BullMQ `Queue`/`Worker`, prefix
`mcpedia:` on shared imrnes Redis `:6379`); `apps/worker` runs
`startWorker()`. Three job types: `index-doc`, `index-all`, `reindex`.
Single indexing entry point `indexContentFile`/`runFullIndex` in
`@mcpedia/core` shared by the script, worker, and git hook. Verified
end-to-end against live Redis (job enqueue → worker → Postgres write).
- [x] **Git synchronization hook (auto-reindex on push)** — API webhook
`POST /hooks/reindex` (full) and `POST /hooks/index?slug=` (single) enqueue
BullMQ jobs. Wire a Git provider (GitHub/Gitea) post-receive / webhook to
`POST /hooks/reindex` to auto-reindex on push. `scripts/enqueue.ts` is a
one-shot enqueue helper (`bun run enqueue --all` / `<slug>`).
- [x] **Document revision system (`document_revisions`)** — `packages/db`
migration `0002_document_revisions.sql`. Indexer snapshots a revision only
when the body actually changes vs the latest revision (pure metadata edits
don't bloat history). `listRevisions` / `getRevision` / `restoreRevision`
in `@mcpedia/core`; exposed as tRPC `revisions` / `getRevision` /
`restoreRevision` and the `mcpedia://docs/{+slug}/revisions` MCP Resource.
- [x] **MCP Resources (`mcpedia://docs/...`)** — alongside the 6 tools:
`mcpedia://docs` (list), `mcpedia://docs/{+slug}` (body from disk),
`mcpedia://docs/{+slug}/chunks` (chunk preview),
`mcpedia://docs/{+slug}/revisions` (history). `{+slug}` uses RFC 6570
reserved expansion so slugs containing `/` match.
### New/changed commands
```
bun run index # full reindex (runFullIndex, writes revisions)
bun run enqueue --all # enqueue a full reindex job (no worker needed)
bun run enqueue <slug> # enqueue a single-doc reindex job
bun run worker # start the BullMQ indexing worker (long-running)
bun run api # Hono+tRPC API on :4020 (added /hooks/* webhooks)
```
### Verification done (real, against imrnes Redis + Postgres)
- `turbo run typecheck` green across all 13 packages.
- BullMQ e2e: enqueue `index-doc` → worker completes → `documents` +
`document_chunks` + `document_revisions` rows present.
- Revision dedup proven: editing a body creates a new revision; metadata-only
reindex does not; `restoreRevision` writes history back into the live row.
- MCP smoke test passes (tools + all 4 resources).
- API webhook `POST /hooks/reindex` enqueues → worker drains queue →
`queueStatus` reflects counts.
## Phase 4 — Operability & Correctness Hardening ✅ DONE
> Reinterpreted from the original "Scale-out" plan: OpenSearch/object-storage/
> multi-tenant were flagged YAGNI at KB scale (4 docs), so Phase 4 = make the
> Phase 3 async + revision machinery **correct, secure, observable, deployable**.
- [x] **T1 — `restoreRevision` rebuilds semantic chunks (CORRECTNESS BUG)** —
previously restore wrote the old body into `documents` but left `document_chunks`
on the *new* body, so semantic/hybrid search went stale after a restore.
`@mcpedia/core` `reindexChunks(slug)` now re-chunks + re-embeds from the live
body; `restoreRevision` calls it after the update (embed failure is logged, not
thrown). Verified: restore → `document_chunks` count matches re-chunk of the
restored body.
- [x] **T2 — Secure git-sync webhook (SECURITY)** — `/hooks/*` now require an
`x-webhook-secret` header matching `WEBHOOK_SECRET` (401 otherwise). API
fails fast at startup if `WEBHOOK_SECRET` is unset (no open endpoint). Added
`WEBHOOK_SECRET` to `@mcpedia/config` + `.env.example`; generated a real secret
in the local `.env` (gitignored).
- [x] **T3 — Web UI revisions view (UX)** — doc page now shows a "History" panel
(revision no, reason, date, body length) with a per-revision Restore button.
Restore POSTs to `apps/web/app/api/revisions/restore/route.ts` → `restoreRevision`
→ `revalidatePath` (server-component only, no client JS).
- [x] **T4 — Paginate `listRevisions`** — added `offset` param (summary never
includes body). API `revisions` + MCP resource use the summary.
- [x] **T5 — Deploy as supervised services (OPS)** — `deploy/mcpedia-api.service`
+ `deploy/mcpedia-worker.service` systemd units (`Restart=on-failure`,
`EnvironmentFile=.env`, `WorkingDirectory=/home/code/mcpedia`). Enable with:
`cp deploy/*.service /etc/systemd/system && systemctl daemon-reload &&
systemctl enable --now mcpedia-api mcpedia-worker`. (Not auto-enabled on host
without explicit user go-ahead.)
### Verification done (real, against imrnes Redis + Postgres)
- `turbo run typecheck` + `turbo run build` green (incl. `next build` with the
History panel).
- T1: edit → reindex (new revision + chunks) → restore rev #1 → `document_chunks`
count for that slug matches re-chunk of rev #1; `semanticSearch` on a term
unique to rev #1 returns it.
- T2: `curl -XPOST /hooks/reindex` → 401; with `-H "x-webhook-secret: $WEBHOOK_SECRET"`
→ 200 + jobId; job drains via worker.
- T3: History panel renders; restore route rebuilds chunks (T1 path).
- T4: `revisions` returns summaries (no body); `offset` paging works.
- T5: `systemd-analyze verify deploy/*.service` passes (off-host safe check).
## Phase 5 — Deferred scale-out (only when needed)
- [ ] Dedicated search engine (OpenSearch/Elasticsearch) — YAGNI until FTS is insufficient
- [ ] pgvector migration (install on imrnes Postgres) — when `real[]` cosine stalls
- [ ] Object storage for assets
- [ ] Advanced ranking, distributed workers, observability, multi-tenant
## Phase 6 — Network deployment + review hardening ✅ DONE
> Closed the real gaps found during review: the MCP server was stdio-only (unreachable
> over the network) and the tRPC API was not routed on the domain (swallowed by web →
> `/trpc/*` returned Next.js 404). Also found + fixed a security hole.
- [x] **MCP over Streamable HTTP** — `apps/mcp/src/http.ts` serves the 6 tools + 4
resources via MCP 2025-03-26 Streamable HTTP on `:4021`, stateless mode
(`sessionIdGenerator: undefined`, one server+transport per request, CORS on `/mcp`).
Deployed as `mcpedia-mcp.service`; reachable at `https://mcp.asepharyana.my.id/mcp`.
Stdio entry (`bun run mcp`) retained for local subprocess use.
- [x] **tRPC API routed on the domain** — Caddy `wiki.asepharyana.my.id` now forwards
`/trpc/*` (+ `/hooks/*`, `/health`) to the API on `:4020`; web stays on `:4016`.
Read-only procedures (search, list, revisions, job status) are public; the
`restoreRevision` mutation is gated by `x-webhook-secret` (see security fix below).
- [x] **Security: lock down `restoreRevision`** — the state-changing tRPC mutation was
anonymously callable over the network. Now requires `x-webhook-secret` (consistent
with `/hooks` auth). The Web UI calls `@mcpedia/core` directly in a server component,
so the gate does not affect the UI's restore button. Verified: no-secret → 401-class
rejection, with-secret → reaches handler.
- [x] **All four services live + supervised** — `mcpedia-web` (:4016), `mcpedia-api`
(:4020), `mcpedia-worker` (BullMQ), `mcpedia-mcp` (:4021) all `active`, reboot-safe.
GitHub push webhook → `https://wiki.asepharyana.my.id/hooks/reindex` (verified 200,
worker drains, 0 failed).
### Verification done (real, against live services)
- `https://mcp.asepharyana.my.id/mcp` initialize → 200 + serverInfo; tools/list → 6;
resources/list → 4 (Streamable HTTP SSE framing).
- `https://wiki.asepharyana.my.id/` → 200; `/health` → 200; `/trpc/listDocuments` → 200.
- GitHub push webhook delivers 200; `queueStatus` shows completed:N, failed:0.
- `turbo run typecheck` green across all 4 apps.
## Phase 7 — Corpus, MCP write-tools + auth, observability ✅ DONE
> Closed the remaining review gaps: tiny corpus (4 docs), MCP read-only (no write/auth),
> no observability.
- [x] **Content corpus grown** — added 5 real docs (Caddy reverse proxy, BullMQ workers,
MCP Streamable HTTP, Postgres FTS, Cloudflare-525 debugging writeup) across
docs/writeups/notes. `bun run index` reindexed: **9 documents, 17 chunks, 5 new
revisions** (was 4 docs). Search/semantic/revisions now operate on a real corpus.
- [x] **MCP write-tools + auth** — added `index_document`, `reindex_all`,
`restore_revision` (write, require `x-webhook-secret`) and `queue_status` (public).
`createMcpServer(authSecret?)` threads the request header; stdio keeps write tools
open (trusted local). Verified: unauthenticated `index_document` → `isError` +
"unauthorized"; authenticated → enqueues job, worker drains.
- [x] **Observability** — `GET /metrics` on the API (Prometheus text exposition:
`mcpedia_uptime_seconds`, `mcpedia_queue_jobs{state=...}`). Exposed on the domain at
`https://wiki.asepharyana.my.id/metrics`. Public, safe to scrape.
### Verification done (real, against live services)
- `https://wiki.asepharyana.my.id/metrics` → 200 Prometheus text (uptime + queue gauges).
- `tools/list` over MCP → 10 tools (6 read + 4 new). `index_document` auth gate works.
- Worker drained the MCP-enqueued job (completed count incremented, failed:0).
- `turbo run typecheck` green across all 4 apps.
## Phase 8 — Dashboard (observability UI) ✅ DONE
> The metrics endpoint existed (Phase 7) but had no consumer. Added a zero-dependency
> dashboard so the KB is actually observable + searchable from a browser.
- [x] **`GET /dashboard`** on the API — self-contained HTML (no build, no deps) that:
- pulls `/metrics` (same origin) and renders queue gauges (waiting/active/completed/
failed/delayed) + uptime, refreshing every 5s with a live-dot status indicator;
- runs a live **search box** that calls the MCP `hybrid_search` tool directly from the
browser (MCP `/mcp` is CORS-open), returning ranked hits that link to the web doc
page (`/docs/...`).
- [x] XSS hardening: all KB-sourced fields (`slug`/`title`/`section`/error message)
are `esc()`-escaped before `innerHTML` (defense-in-depth; data is server-trusted).
- [x] Caddy: `wiki.asepharyana.my.id/dashboard` → :4020.
### Verification done (real)
- `https://wiki.asepharyana.my.id/dashboard` → 200, serves the page (title + JS present).
- `/metrics` → 200, 7 gauge lines including `mcpedia_queue_jobs{state=...}`.
- MCP `hybrid_search` from browser path returns real ranked hits (verified the exact
`tools/call` payload the dashboard issues; shape `{doc:{slug,title,section},rank}`).
- Dashboard link points to working web doc route `/docs/<slug>` (verified 200).
- `turbo run typecheck` green.
## Phase 9 — Test coverage + CI gating ✅ DONE
> Before Phase 9 the only test was an integration smoke (`apps/mcp/src/smoke.test.ts`)
> requiring a live DB; its assertions had also rotted (expected 6 tools, now 10). Added
> a real `bun:test` suite that runs green in CI with **no external services** via
> in-process module mocking.
- [x] **Test infra** — `turbo.json` `test` task (cache:false); `test` script on every
package/app that has `.test.ts` files; `@types/bun` added to root devDeps;
`tsconfig.base.json` registers `types: ["bun","node"]`; CI step
`bun run test` added after `Build`.
- [x] **`@mcpedia/embeddings`** (5 tests) — `chunkText`: empty input, single chunk,
multi-chunk split, overlap/word-boundary integrity, default options.
- [x] **`@mcpedia/parser`** (5 tests) — `parseFile`: frontmatter extraction, section
derivation from top-level dir, invalid type/status fallbacks, missing-field
defaults, body excludes delimiter.
- [x] **`@mcpedia/search`** (8 tests) — `cosine` (orthogonal/identical/zero-vector/
mismatched-length/negative) + `toTsQuery` (AND-prefix, sanitization, empty/garbage).
- [x] **`@mcpedia/core`** (4 tests) — `shouldCreateRevision` dedup truth table (no prior
revision → snapshot; identical body → skip; changed body → snapshot; empty vs
non-empty). `restoreRevision` gained an `opts.reindex` seam for the chunk-rebuild
contract.
- [x] **`apps/api`** (8 tests) — refactored `index.ts` → `app.ts` `createApp(deps?)`
factory (pure construction, injectable `QueueLike`); `dashboard.ts` extracted;
`/health`, `/metrics`, `/hooks/reindex` (401 w/o secret, 200 w/ secret),
`/hooks/index` (400 w/o slug, 200 w/ slug+secret, 401 wrong secret), `/dashboard`.
- [x] **`apps/mcp`** (6 tests) — write-tool auth gates via `InMemoryTransport`:
`index_document`/`reindex_all`/`restore_revision` error without secret and enqueue
with secret (mocked `@mcpedia/queue` + `@mcpedia/core`); `queue_status` public;
tool discovery lists all 10 tools regardless of secret (gate is in handler).
- [x] **Fixed rot** — renamed `smoke.test.ts` → `smoke.ts` (so `bun test` doesn't run the
integration smoke as a unit test) and updated stale assertions (10-tool set, 4 docs
in `docs` section).
### Verification done (real)
- `bun run test` → 32 tests green across 6 packages, **no DB/Redis** (all fakes).
- `bun run typecheck` → 4 apps green (no test-only type errors).
- `bun --cwd apps/mcp run smoke` → SMOKE OK (integration, live DB).
- Live API (temp port): `/health`→200, `/metrics`→200 gauges, `/hooks/reindex`→401/200.
- CI workflow now runs `bun run test`.
### Files changed
```
new: apps/api/src/app.ts # createApp factory
new: apps/api/src/dashboard.ts # dashboard HTML module
new: packages/embeddings/src/chunk.test.ts
new: packages/parser/src/parse.test.ts
new: packages/search/src/cosine.test.ts
new: packages/core/src/index.service.test.ts
new: apps/api/src/app.test.ts
new: apps/mcp/src/auth.test.ts
mod: turbo.json, package.json, tsconfig.base.json, .github/workflows/ci.yml
mod: apps/api/src/index.ts (thin re-export), apps/api/src/router.ts (unchanged)
renamed: apps/mcp/src/smoke.test.ts -> smoke.ts (fixed stale assertions)
```
## Phase 10 — Audit + Bug fixes + CI/CD deploy (live verification)
> Full feature audit against live services. Found + fixed one real bug. Added the
> missing CI/CD deploy pipeline.
### Bug: Frontmatter leaking into rendered doc pages + MCP doc body
- **Symptom:** Doc pages showed raw YAML frontmatter (`id: websocket-contract`,
`title: WebSocket Contract`, etc.) as visible plain text between `<hr/>`
markers. MCP `mcpedia://docs/{+slug}` resource had the same leak.
- **Root cause:** `getDocument()` in `@mcpedia/core` preferred the on-disk file
via `readFileSync(abs, "utf8")` — returning **raw** file content including the
`---` frontmatter block. The indexer correctly stripped frontmatter via
`parseFile` (gray-matter), but `getDocument` bypassed it. `ReactMarkdown`
rendered `---` as `<hr/>` and the YAML as paragraphs.
- **Fix:** `packages/core/src/document.service.ts` — replaced `readFileSync` with
`parseFile(abs, row.path).body` (same frontmatter stripping as the indexer).
DB fallback (`row.body`) unchanged (already clean).
- **Verified:** 9/9 doc pages render clean (no frontmatter `id:` text, proper
`<h2>` + `<code>` elements in SSR HTML); MCP `mcpedia://` doc body resource
returns clean markdown (starts with `# WebSocket Contract`).
### Audit findings (all phases verified live)
| Phase | Feature | Live check | Status |
|-------|---------|------------|--------|
| P1 | Web UI `/docs/<section>/<slug>` | 200, renders markdown | ✅ |
| P1 | Search page (`?q=` + `?mode=hybrid`) | 200, returns results | ✅ |
| P1 | MCP stdio + HTTP (`/4021`) | 10 tools, 4 resources | ✅ |
| P2 | Semantic/hybrid search | returns ranked chunks | ✅ |
| P2 | tRPC API on domain (`/trpc/*`) | listDocuments → 4 docs | ✅ |
| P3 | BullMQ worker drains jobs | queue completed 17→19 after enqueue | ✅ |
| P3 | Revision system | listRevisions → rev #1 "phase4-final-clean" | ✅ |
| P3 | Git webhook auth gate | 401 w/o secret, 200 w/ secret | ✅ |
| P4 | Dashboard | `/dashboard` → 200 HTML | ✅ |
| P6 | All 4 systemd services | web/api/mcp/worker all `active` | ✅ |
| P6 | restoreRevision mutation locked | 401 w/o secret, executes w/ secret | ✅ |
| P7 | 10 MCP tools (6 read + 4 write) | tools/list → 10 | ✅ |
| P7 | Write-tool auth gate | reindex_all w/o secret → isError | ✅ |
| P7 | Prometheus metrics | `/metrics` → 7 gauges, 200 | ✅ |
| P8 | Dashboard live search | `fetch("/metrics")` + `hybrid_search` via `/mcp` | ✅ |
| P9 | Test suite | 6/6 packages, 32 tests, 0 fail | ✅ |
### Notes / non-bugs
- Doc URLs follow `/<section>/<slug>` (e.g. `/docs/caddy/reverse-proxy`,
`/writeups/infra/cloudflare-525`, `/notes/postgres/full-text-search`).
The route is `[section]/[...slug]` — `/docs/websocket/contract` works because
the section IS `docs` for that doc; `/notes/postgres/fts` does not (the correct
slug is `notes/postgres/full-text-search`).
- `restoreRevision` via tRPC needs the `x-webhook-secret` as an **HTTP header**
(not inside the JSON body) — the fetch adapter reads `c.req.raw.headers`.
### CI/CD deploy pipeline (Phase 10 addition)
Before this audit the CI workflow only built+tested — it did **not** deploy.
The VPS services were configured manually (systemd units in `deploy/`). Added a
`deploy.yml` workflow per the nix-ci-deploy pattern (CI builds, deploy is separate):
- **Trigger:** `workflow_run` on `CI` completion (only runs if CI passes).
- **Build:** same as CI (bun install + typecheck + web build) on the GitHub
runner — fails fast if the build is broken.
- **Deploy:** SSHes to the VPS over the public IP (`45.127.35.244`, not the
Tailscale `100.79.111.61` which GitHub runners can't reach), pulls from git,
reinstalls deps, rebuilds the web app, and restarts all 4 services via
`sudo systemctl restart` (NOPASSWD already configured for `code` user).
- **Secrets** (GitHub repo secrets, not files): `SSH_DEPLOY_HOST`,
`SSH_DEPLOY_PORT`, `SSH_DEPLOY_USER`, `SSH_DEPLOY_KEY` (ed25519 deploy key).
Deploy key's public half is in `/home/code/.ssh/authorized_keys` on the VPS.
#### Gotchas discovered + fixed during setup
1. **SSH host = public IP, not Tailscale IP.** `100.79.111.61` is a Tailscale
`tailscale0` interface IP (CGNAT `100.64.0.0/10`); GitHub Actions runners can't
route to it. Use the real public IP `45.127.35.244` (port 22 open in iptables).
2. **SSH key storage.** Storing the key via shell variable (`gh secret set --body "$VAR"`)
mangles newlines → `ssh.ParsePrivateKey: no key found`. Store directly from file:
`cat keyfile | gh secret set SSH_DEPLOY_KEY --repo ...`
Use `appleboy/ssh-action@v1` (not `@v1.1.0`) which correctly parses the key.
Add `-o IdentitiesOnly=yes` to prevent "too many authentication failures".
3. **Key rotation.** Force-pushing amended commits changes the SHA but CI triggers
on `push: branches: [main]` (CI) + `workflow_run` (deploy) — both fire correctly.
### Verification done (real)
- CI `workflow_run` → Deploy triggers after CI success; all 4 services `active`.
- All public URLs return 200: web `/`, `/docs/...`, `/search`, `/dashboard`,
`/metrics`, `/trpc/*`, `mcp.asepharyana.my.id/mcp`.
- Doc pages render clean markdown (no frontmatter); History panel + Restore work.
## Phase 11 — CRUD + Auth + Web UI ✅ DONE
> User requested: "perbagus agar jadi CRUD, pastikan ada autentikasi dan bisa
> manual dari web atau lewat agent melalui MCP, dan perbaui UI/UXnya."
### Backend (Core + API + MCP)
- [x] **`packages/parser` — `stringifyFile()`** — serialize `DocumentMeta` + body
back to a markdown file with YAML frontmatter (gray-matter). Round-trip stable
with `parseFile`.
- [x] **`@mcpedia/core` — CRUD functions:**
- `createDocument({slug, title, section, body, type?, status?, author?, tags?})`
— writes file to `content/{section}/{slug}.md`, upserts `documents` row,
snapshots revision, indexes chunks.
- `updateDocument(slug, {...})` — writes file, updates DB row, snapshots
revision (if body changed), reindexes chunks.
- `deleteDocument(slug)` — removes file + `documents`/`document_chunks`/
`document_revisions` rows.
- Slug validation: `[a-z0-9][a-z0-9/_-]*`, no `//`, no `..` traversal.
- [x] **`apps/api` — tRPC CRUD routers** — `createDocument`, `updateDocument`,
`deleteDocument` (all `.use(requireWriteAuth)`). Fixed `requireWriteAuth` to
compare against `ctx.expectedSecret` (injected from deps) instead of the
module-level `WEBHOOK_SECRET` env constant — latent bug that made the middleware
untestable without env manipulation.
- [x] **`apps/mcp` — 3 new write tools** — `create_document`, `update_document`,
`delete_document` (all require `x-webhook-secret`). Tools: 10 → 13.
- [x] **Auth** — MCP/API writes reuse the existing `WEBHOOK_SECRET` /
`x-webhook-secret` pattern. Web CRUD adds cookie-based auth: `ADMIN_PASSWORD`
env + `/api/auth/login` (HMAC-signed `mcpedia_admin` cookie, HttpOnly).
### Web UI
- [x] **`/create` page** — form (section/type/status/title/slug/tags/author/body),
POSTs to `/api/docs` with `x-webhook-secret`.
- [x] **`?edit=1` on doc pages** — inline edit form (`DocForm` component),
PUTs to `/api/docs/{slug}`.
- [x] **`/login` page** — password → `/api/auth/login` → cookie → redirect `/create`.
- [x] **Edit buttons** — homepage "+ Create Document" + per-doc "✎" (auth-gated);
doc page "Edit" button (auth-gated).
- [x] **TOC** — doc page auto-generates a table of contents from `h2` headings.
- [x] **Dark mode** — toggle persisted in `localStorage`, defaults to system.
- [x] **`/api/docs` REST routes** — POST (create), PUT (update), DELETE (delete),
all `x-webhook-secret` gated.
### Files changed
```
new: apps/web/app/api/auth/login/route.ts # cookie-based login + verify
new: apps/web/app/api/docs/route.ts # GET (list) + POST (create)
new: apps/web/app/api/docs/[...slug]/route.ts # PUT (update) + DELETE (delete)
new: apps/web/app/components/DocForm.tsx # shared create/edit form
new: apps/web/app/components/Sidebar.tsx # client-side doc navigation tree
new: apps/web/app/components/TOC.tsx # auto-generated TOC (github-slugger)
new: apps/web/app/components/ThemeToggle.tsx # dark mode toggle
new: apps/web/app/create/page.tsx # create UI
new: apps/web/app/login/page.tsx # login UI
new: apps/web/app/docs/page.tsx # docs index listing
mod: apps/web/app/layout.tsx # Linear design: sticky header + sidebar + dark canvas
mod: apps/web/app/page.tsx # editorial-style homepage w/ section doc listings
mod: apps/web/app/[section]/[...slug]/page.tsx # Linear doc layout (breadcrumb, TOC, metadata)
mod: apps/web/app/search/page.tsx # dark-themed search w/ result cards
mod: apps/web/app/components/Markdown.tsx # Linear typography + rehype-slug
mod: apps/web/app/globals.css # Inter font, Linear dark-mode-first palette
mod: apps/web/app/app/api/docs/route.ts # dual auth: cookie OR x-webhook-secret
mod: apps/web/app/api/docs/[...slug]/route.ts
mod: packages/core/src/document.service.ts # createDocument/updateDocument/deleteDocument
mod: packages/core/src/index.service.ts # export snapshotRevision
mod: packages/core/src/index.ts # re-export CRUD + types
mod: packages/parser/src/index.ts # stringifyFile
mod: packages/config/src/index.ts # ADMIN_PASSWORD
mod: apps/api/src/router.ts # CRUD routers + fix requireWriteAuth
mod: apps/api/src/app.ts # createContext passes expectedSecret
mod: apps/api/src/trpc.ts # Context.expectedSecret
mod: apps/mcp/src/index.ts # 3 new CRUD write tools
mod: apps/mcp/src/auth.test.ts # +4 CRUD auth tests
mod: apps/api/src/app.test.ts # +5 tRPC CRUD auth tests
mod: .env.example # ADMIN_PASSWORD
new dep: rehype-slug # heading anchors for TOC links
new dep: github-slugger # matching slug algorithm for client-side TOC
```
### Gotchas / lessons
1. **tRPC fetch adapter** expects input directly as JSON body, NOT JSON-RPC
envelope (`{"slug":...}` not `{"jsonrpc":"2.0","method":...,"params":{...}}`).
2. **`requireWriteAuth` env-constant bug** — comparing `ctx.webhookSecret !== WEBHOOK_SECRET`
(module-level env constant) is untestable. Fix: thread `expectedSecret` through
`Context` from `createApp(deps)`.
3. **Next.js catch-all routes** — `[...slug]/edit/` is invalid (catch-all must be
last). Used `?edit=1` query param instead. Also, Next.js App Router won't match
PUT/DELETE on `/api/docs/route.ts` for nested paths — need a dynamic segment
`/api/docs/[...slug]/route.ts`.
5. **`stringifyFile` YAML** — quote string values with `JSON.stringify` for
special-char safety; arrays use `[...]` syntax.
6. **Next.js SSG + DB** — client components (`"use client"`) don't block SSG
during `next build` even if they fetch at runtime. Used for Sidebar (fetches
/api/docs at runtime) to avoid ECONNREFUSED on CI.
7. **MCP SDK zod-v4 skew** — `@modelcontextprotocol/sdk@1.30` compiled `.d.ts`
references zod-4 internal types. Pin `zod: ^4.0.0` in the MCP app (not
workspace-wide) to match the SDK.
8. **StreamableHTTP transport headers** — use `requestInit: { headers: {...} }`
(not a top-level `headers` option) on `StreamableHTTPClientTransport`.
9. **SDK type noise** — use type-cast helpers (`as AnyContent`, `as CallToolResult`)
for the SDK's content union types which don't expose `.content[0].text` cleanly.
## Phase 12 — MCP Client + Full Layout Overhaul ✅ DONE
> User: "buat mcp untuk client nya" (create the MCP client) + "fokus ke web nya"
> (the user also said the UI was "boring, only style changed despite requesting
> a full layout overhaul").
### MCP Client (`apps/mcp-client/`)
New independent bun workspace package that connects to the MCPedia MCP server
over Streamable HTTP and provides both a programmatic API + CLI interface.
- **`src/client.ts`** — `McpediaClient` class wrapping the SDK's
`StreamableHTTPClientTransport` + `Client`. Typed methods for all 13 tools:
`listDocuments`, `getDocument`, `search`, `semanticSearch`, `hybridSearch`,
`getRelated`, `indexDocument`, `reindexAll`, `queueStatus`, `createDocument`,
`updateDocument`, `deleteDocument`, `listTools`, `callTool`, `listResources`,
`readResource`, `disconnect`. Accepts custom headers (`x-webhook-secret` for
write tools).
- **`src/index.ts`** — Interactive REPL (`bun run chat`): `/tools`, `/resources`,
`/search`, `/ss`, `/hybrid`, `/doc`, `/related`, `/create` (prompts), `/update`,
`/delete`, `/index`, `/status`, `/help`, `/quit`.
- **`src/ask.ts`** — One-shot CLI (`bun run ask <cmd> [args]`) for scripting.
- **`src/client.test.ts`** — 7 tests (mock SDK, no network/DB).
### Layout Overhaul (Linear design system)
Complete web UI redesign, not just style changes:
- **Dark-mode-first** — near-black canvas (`#08090a`), white-opacity borders
(`rgba(255,255,255,0.05–0.08)`), Inter font with `cv01/ss03` features.
- **Sticky header** — MCPedia brand + Docs/Search/Login + theme toggle.
- **Sticky sidebar** (xl+) — hierarchical doc tree with indented children.
- **Editorial homepage** — H1 + description, "Create Document" button,
section-organized doc listings with tag previews.
- **Doc page** — breadcrumb nav links, title + metadata bar (author/date/tags),
TOC (CONTENTS), clean prose rendering, Related + History sections.
- **Create/Edit** — `/create` page, `?edit=1` inline form (DocForm).
- **Login** — `/login` with dark-themed form + brand-indigo CTA.
- **Search** — `/search?q=` with dark-themed results + snippets.
- **Docs index** — `/docs` listing all documents by section.
### Gotchas
- Next.js catch-all `[...slug]/edit/` is invalid — used `?edit=1` query param.
- Next.js App Router: PUT/DELETE need `/api/docs/[...slug]/route.ts`.
- tRPC fetch adapter expects JSON body directly (not JSON-RPC envelope).
- `requireWriteAuth` env-constant bug: use `ctx.expectedSecret` from deps.
- Next.js SSG + DB: Sidebar as `"use client"` to avoid DB connection during build.
- MCP SDK zod-v4 skew: pin `zod: ^4.0.0` in the MCP app.
- `StreamableHTTPClientTransport`: use `requestInit: { headers }` not top-level `headers`.
- **Tooling:** bun workspaces + Turborepo (repo already used bun; pnpm rejected to minimize churn).
- **DB:** imrnes Postgres `100.121.180.82:6432/mcpedia` for both dev and deploy; driver `prepare:false` (PgBouncer). Docker Compose reserved for future prod.
- **Phase 1 scope:** Core + Web + MCP only. tRPC/Hono API, pgvector, auth, BullMQ deferred (YAGNI).
+260 -136
View File
@@ -1,186 +1,310 @@
# MCPedia # MCPedia
> A content-first knowledge base — readable as Markdown/MDX in Git, queryable by > A content-first knowledge base — readable as Markdown in Git, queryable by humans via a modern Web UI, and accessible to AI agents via the Model Context Protocol (MCP).
> humans via a Web UI and by AI agents via the Model Context Protocol (MCP).
MCPedia keeps content as plain Markdown files under `content/`. A Git-tracked MCPedia keeps content as plain Markdown files under `content/` as a Git-tracked source of truth. Content is indexed into PostgreSQL (with a weighted `tsvector` full-text search column and chunked embeddings) and served through a unified **Core** layer that every interface (Web, MCP, API, Worker, CLI) shares.
source of truth, indexed into PostgreSQL (metadata + a `tsvector` full-text
column) and served through a single **Core** layer that every interface
(Web, MCP) shares — no business logic duplicated per surface.
## Monorepo layout ---
## Architecture Overview
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Web UI │ │ MCP Server │ │ tRPC / Hono │ │ BullMQ Worker │
│ (Next.js 16) │ │ (Stdio + HTTP) │ │ API (:4020) │ │ (Async Queue) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │ │
└──────────────────────┴──────────┬───────────┴──────────────────────┘
▼
┌─────────────────────────┐
│ @mcpedia/core │
│ (Unified Business Logic)│
└────────────┬────────────┘
│
┌─────────────────────────────────┼─────────────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Markdown Files │ │ @mcpedia/db │ │ @mcpedia/search │
│ (content/ tree) │ │ (PostgreSQL) │ │ (FTS + Cosine) │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
### Core Principles
1. **Content as Source of Truth**: Markdown files with YAML frontmatter under `content/` are primary. The database holds metadata, search indices, chunk embeddings, and revision history.
2. **Single Core Layer**: All business logic (CRUD, indexing, search, revisions, path classification) is encapsulated in `@mcpedia/core`. Interfaces never touch the database directly.
3. **Multi-Modal Search**: Keyword search (Postgres FTS), semantic search (vector cosine similarity), and hybrid search (Reciprocal Rank Fusion / RRF) work out of the box.
4. **Resilient Revision System**: Content edits snapshot revisions automatically; metadata-only edits are deduplicated. Restoring a revision automatically rebuilds semantic search chunks.
5. **Dual Interface**: Full human-friendly web experience + first-class AI agent integration via MCP.
---
## Monorepo Layout
``` ```
mcpedia/ mcpedia/
├── apps/ ├── apps/
│ ├── web/ # Next.js 16 (Turbopack) — human-facing docs UI + search │ ├── web/ # Next.js 16 (Turbopack) — dark-mode UI, hierarchical doc tree, TOC, CRUD
│ ├── mcp/ # MCP server (stdio) — AI-agent interface (tools + resources) │ ├── mcp/ # MCP server (stdio + Streamable HTTP on :4021) — 13 tools + 4 resources
│ └── api/ # Hono + tRPC v11 API on :4020 (+ /hooks/* git-sync webhooks) │ ├── mcp-client/ # Interactive MCP CLI REPL, one-shot command runner, and TypeScript client SDK
│ ├── api/ # Hono + tRPC v11 API on :4020, git-sync webhooks, Prometheus metrics, dashboard
│ └── worker/ # Long-running BullMQ worker for async indexing and embedding jobs
├── packages/ ├── packages/
│ ├── types/ # shared domain types (DocSection, Document, SearchHit, ...) │ ├── core/ # Document, Content, Search, Index, Revision, and Path services (the business logic)
│ ├── config/ # loads .env (repo root) as authoritative dev config │ ├── db/ # Drizzle ORM schema, client, and migrations
│ ├── db/ # Drizzle ORM schema + client + drizzle-kit config │ ├── parser/ # Frontmatter (gray-matter) parsing & stringification with dynamic field support
│ ├── parser/ # frontmatter (gray-matter) parsing │ ├── search/ # Postgres FTS query (ts_rank + ts_headline), vector cosine, and RRF hybrid fusion
│ ├── search/ # Postgres FTS query (ts_rank + ts_headline) │ ├── embeddings/ # Chunking algorithms & embedding provider integrations
│ ├── embeddings/ # embedding provider + chunker │ ├── queue/ # Redis (ioredis) client & BullMQ queue/worker definitions
│ ├── queue/ # Redis (ioredis) + BullMQ worker/queue (Phase 3) │ ├── types/ # Shared TypeScript domain types and interfaces
│ └── core/ # Document/Content/Search/Index/Revision — the only business logic │ └── config/ # Authoritative environment configuration loader (.env)
├── content/ # docs/ writeups/ research/ notes/ (the knowledge base) ├── content/ # Markdown knowledge base organized by sections and nested folders
└── scripts/ # indexer.ts (full reindex), enqueue.ts (one-shot job enqueue) │ ├── docs/ # System and architectural documentation
│ ├── writeups/ # Technical writeups, CTF solutions, and debugging reports
│ ├── research/ # Research notes and evaluations
│ └── notes/ # Engineering notes and quick references
└── scripts/ # indexer.ts (full corpus reindexing), enqueue.ts (one-shot job enqueue)
``` ```
## Architecture principle ---
``` ## Key Features
Web ─┐
├──► Core ──► Repository (@mcpedia/db) ──► PostgreSQL
MCP ─┘
```
All interfaces go through `@mcpedia/core`. Nothing outside `packages/db` and ### 1. Multi-Modal Search
`packages/core` touches the database directly.
## Quick start | Search Mode | Mechanism | Best Used For |
|---|---|---|
| **Keyword Search** | PostgreSQL `tsvector` (`simple` config, weighted A/B) + GIN index + `ts_rank` + `ts_headline` | Exact terms, code symbols, error codes, identifiers |
| **Semantic Search** | Text chunking (1000 chars, 150 overlap) + vector embeddings + cosine similarity ranking | Conceptual questions, paraphrased queries, intent matching |
| **Hybrid Search** | Reciprocal Rank Fusion ($RRF = \sum \frac{1}{k + rank}$) fusing keyword and semantic signals | General-purpose search with optimal relevance |
### 2. Hierarchical Folder Navigation
MCPedia supports nested folder structures (like GitHub repositories):
- **Dynamic Path Classification**: The router inspects paths and automatically distinguishes between leaf documents and folder nodes containing subfolders or child documents.
- **Folder Index Pages**: Navigating to any folder (e.g. `/writeups/ctf/defcon-quals-2024`) renders subfolders and documents within that path.
- **Collapsible Sidebar**: Hierarchical navigation tree reflecting the on-disk directory structure.
- **Section Indexes**: Dedicated overview pages for each section (`/docs`, `/writeups`, `/research`, `/notes`).
### 3. Dynamic Custom Fields
Content frontmatter supports arbitrary custom key-value pairs without schema modifications:
- **Automatic Storage**: Custom fields are persisted into a JSONB `extra_fields` column in PostgreSQL.
- **Type Preservation**: Numbers, booleans, arrays, objects, and strings maintain native types across parser, database, and API.
- **Value-Aware UI Badges**: The Web UI automatically styles badges based on value types and semantic patterns (difficulty levels, categories, tags, status) rather than hardcoded field names.
### 4. Revision History & Rollback
- **Smart Snapshotting**: Whenever a document body changes, a revision snapshot is created in `document_revisions`.
- **Deduplication**: Metadata-only updates do not produce duplicate body snapshots.
- **One-Click Restore**: Restoring any past revision writes the historic content back to disk and database, and automatically triggers semantic chunk re-indexing to ensure search consistency.
---
## Model Context Protocol (MCP)
MCPedia runs an MCP server accessible via **Stdio** (for local subagents) and **Streamable HTTP** (for remote agents over the network at `:4021`).
### MCP Tools (13 Total)
#### Read Tools (Public)
| Tool | Description |
|---|---|
| `search_documents` | Full-text keyword search over the knowledge base with headline snippets |
| `semantic_search` | Embedding-based cosine search across chunked content |
| `hybrid_search` | Fused full-text and semantic search via Reciprocal Rank Fusion |
| `get_document` | Fetches the full Markdown body of a document by slug |
| `list_documents` | Lists documents, optionally filtered by section or status |
| `get_related_documents` | Finds documents sharing tags with a given slug |
| `queue_status` | Returns current BullMQ indexing queue metrics |
#### Mutating & Admin Tools (Requires `x-webhook-secret`)
| Tool | Description |
|---|---|
| `create_document` | Creates a new document (writes Markdown file, database row, revision, and chunks) |
| `update_document` | Updates an existing document's body, metadata, or dynamic custom fields |
| `delete_document` | Removes a document file, database rows, revisions, and chunks |
| `index_document` | Enqueues a single-document background reindex job |
| `reindex_all` | Enqueues a full-corpus background reindexing job |
| `restore_revision` | Restores a document to a specific revision and rebuilds search chunks |
### MCP Resources
| Resource URI | Description | MIME Type |
|---|---|---|
| `mcpedia://docs` | List of all published documents in the knowledge base | `application/json` |
| `mcpedia://docs/{+slug}` | Full Markdown body of a single document from disk | `text/markdown` |
| `mcpedia://docs/{+slug}/chunks` | Preview of embedded semantic chunks for a document | `application/json` |
| `mcpedia://docs/{+slug}/revisions` | Summary of revision history for a document | `application/json` |
---
## HTTP, tRPC & Webhook Endpoints
The API application (`apps/api`) runs on **:4020** with Hono and tRPC v11:
### tRPC Procedures
- **Queries**: `search`, `semanticSearch`, `hybridSearch`, `getDocument`, `listDocuments`, `related`, `revisions`, `getRevision`, `jobStatus`, `queueStatus`.
- **Mutations** (Protected by `x-webhook-secret`): `createDocument`, `updateDocument`, `deleteDocument`, `restoreRevision`.
### Webhooks & Observability
- `POST /hooks/reindex` — Triggers a full corpus reindex (ideal for Git push webhooks).
- `POST /hooks/index?slug=<slug>` — Reindexes a single document.
- `GET /metrics` — Prometheus metrics exposition (`mcpedia_uptime_seconds`, `mcpedia_queue_jobs{state=...}`).
- `GET /dashboard` — Standalone real-time web dashboard for queue monitoring and live search.
- `GET /health` — Health check endpoint.
---
## Authentication & Security
MCPedia utilizes dual-tier security:
1. **Web UI Authentication**:
- Gated via `ADMIN_PASSWORD` in `.env`.
- Generates an HMAC-signed `mcpedia_admin` HttpOnly cookie upon login at `/login`.
- Protects document creation (`/create`), inline editing (`?edit=1`), and deletion.
2. **API & MCP Write Authentication**:
- Gated via `WEBHOOK_SECRET` in `.env`.
- Requires the `x-webhook-secret` HTTP header on mutating endpoints and tools.
- The API will refuse to start if `WEBHOOK_SECRET` is unset, preventing unsecured deployments.
---
## Quick Start
### Prerequisites
- [Bun](https://bun.sh/) (v1.1+)
- [PostgreSQL](https://www.postgresql.org/) (with `tsvector` support)
- [Redis](https://redis.io/) (for BullMQ async workers)
### 1. Installation & Environment Setup
```bash ```bash
bun install # install workspace deps # Clone repository
cp .env.example .env # set DATABASE_URL (dev uses imrnes Postgres :6432) git clone https://github.com/asepharyana/mcpedia.git
bunx turbo run build # typecheck + build every package cd mcpedia
bun run index # walk content/ -> upsert into Postgres # Install workspace dependencies
bun --cwd apps/web run dev # Web UI on :3000 bun install
bun run mcp # MCP server on stdio (pipe to an MCP client)
# Configure environment variables
cp .env.example .env
``` ```
### Database Ensure `.env` contains your database connection, Redis configuration, webhook secret, and admin password.
Schema is defined in `packages/db/src/schema.ts` (`documents` with a weighted ### 2. Database Setup
`search_vector` tsvector + GIN index, and `document_chunks` with an `embedding real[]`).
The `pgvector` extension is **not available** on the shared imrnes Postgres, so
semantic search stores vectors as `real[]` and ranks by in-app cosine similarity.
Migrations live in `packages/db/drizzle/`. They were applied manually via `psql` Apply migrations to initialize tables (`documents`, `document_chunks`, `document_revisions`):
(`drizzle-kit push` is unreliable under PgBouncer transaction pooling); to
re-apply on a fresh DB:
```bash ```bash
psql $DATABASE_URL -f packages/db/drizzle/0000_grey_toro.sql psql $DATABASE_URL -f packages/db/drizzle/0000_grey_toro.sql
psql $DATABASE_URL -f packages/db/drizzle/0001_document_chunks.sql psql $DATABASE_URL -f packages/db/drizzle/0001_document_chunks.sql
psql $DATABASE_URL -f packages/db/drizzle/0002_document_revisions.sql
psql $DATABASE_URL -f packages/db/drizzle/0003_document_extra_fields.sql
``` ```
> Note: on imrnes (PgBouncer `:6432`) a leaked `DATABASE_URL` shell var can ### 3. Build & Index
> shadow `.env`. `@mcpedia/config` loads `.env` **last** so the repo config
> always wins for local/dev.
## Content ```bash
# Typecheck and build all packages
bunx turbo run build
Each Markdown file carries YAML frontmatter: # Run initial full indexation (parses content/, embeds chunks, writes DB)
bun run index
```
### 4. Running Services
```bash
# Start all development services (Web, API, MCP, Worker) concurrently
bun run dev
# Or run services individually:
bun --cwd apps/web run dev # Web UI on :3000 (or :4016 in production)
bun run api # Hono + tRPC API on :4020
bun run worker # BullMQ background worker
bun run mcp # MCP server (stdio mode)
bun run mcp:http # MCP server (Streamable HTTP mode on :4021)
```
---
## Scripts & CLI Reference
| Command | Action |
|---|---|
| `bun run index` | Run full synchronous reindexer over `content/` |
| `bun run enqueue --all` | Enqueue a full reindex job to Redis/BullMQ |
| `bun run enqueue <slug>` | Enqueue a single-document reindex job |
| `bun run worker` | Start the BullMQ background worker |
| `bun run api` | Start the Hono + tRPC API server |
| `bun run mcp` | Run MCP server in stdio mode |
| `bun run mcp:http` | Run MCP server in Streamable HTTP mode |
| `bun run chat` | Launch interactive MCP Client REPL |
| `bun run ask <cmd> [args]` | Run one-shot MCP client command |
| `bun run test` | Run the comprehensive test suite across all packages |
| `bun run typecheck` | Run TypeScript type checking across all workspaces |
---
## Content Format
Content files reside in `content/<section>/` as Markdown files with YAML frontmatter:
```yaml ```yaml
--- ---
id: websocket-contract id: sample-document
title: WebSocket Contract title: Sample Architecture Document
type: documentation type: documentation
tags: [typescript, websocket, rpc] section: docs
tags: [architecture, typescript, postgres]
status: published status: published
author: asep author: asep
created_at: 2026-08-19 created_at: 2026-08-20
updated_at: 2026-08-19 updated_at: 2026-08-20
# Arbitrary custom fields (auto-discovered & styled):
difficulty: intermediate
points: 100
category: system-design
verified: true
--- ---
# Sample Architecture Document
Your document content in standard GitHub Flavored Markdown (GFM).
Tables, code snippets, and custom sections are fully supported.
``` ```
`slug` = relative path under `content/` (e.g. `docs/websocket/contract`). The ---
`body` shown in the UI is always read from the on-disk file (source of truth);
the DB stores metadata + the search vector.
## MCP tools ## Production Deployment
| Tool | Purpose | Production services run as supervised systemd units behind a Caddy reverse proxy:
| --------------------- | ------------------------------------------------ |
| `search_documents` | Postgres FTS over the corpus (ranked + snippet) |
| `semantic_search` | Embedding/cosine search over chunked content |
| `hybrid_search` | FTS + semantic fused via RRF |
| `get_document` | Full markdown body by slug |
| `list_documents` | List, optionally filtered by section |
| `get_related_documents` | Docs sharing tags with a given slug |
### MCP Resources
| URI | Purpose |
| -------------------------------- | ---------------------------------------- |
| `mcpedia://docs` | List all published documents |
| `mcpedia://docs/{+slug}` | Full markdown body (read from disk) |
| `mcpedia://docs/{+slug}/chunks` | Preview of embedded semantic chunks |
| `mcpedia://docs/{+slug}/revisions` | Revision history summary |
(`{+slug}` uses RFC 6570 reserved expansion so a slug like
`docs/websocket/contract` matches the template.)
Smoke test (in-memory transport, real JSON-RPC):
```bash
bun --cwd apps/mcp run smoke
```
## API (Phase 2 + Phase 3)
A tRPC v11 API is exposed via Hono on **:4020** (all procedures mirror the
MCP tools). Phase 3 adds async job + revision procedures and git-sync webhooks:
```bash
bun run api # http://localhost:4020 (GET /health, POST/GET /trpc/*)
```
tRPC procedures: `search`, `semanticSearch`, `hybridSearch`, `getDocument`,
`listDocuments`, `related` (Phase 2); plus `revisions`, `getRevision`,
`restoreRevision`, `jobStatus`, `queueStatus` (Phase 3).
Git-sync webhooks (enqueue BullMQ jobs; the worker processes them):
- `POST /hooks/reindex` — full-corpus reindex (point your Git provider's
push webhook here to auto-reindex on push).
- `POST /hooks/index?slug=<slug>` — reindex a single document.
> **Security:** both webhooks require an `x-webhook-secret` header that matches
> `WEBHOOK_SECRET` (set in `.env`). The API refuses to start if `WEBHOOK_SECRET`
> is unset, so the hooks are never left open.
`bun run index` now also chunks + embeds (Phase 2 indexer) and snapshots a
revision whenever the body changes (Phase 3). See `.env.example` for
`EMBED_*` / `REDIS_*` / `QUEUE_PREFIX` / `WEBHOOK_SECRET` vars.
### Run as a supervised service (Phase 4)
`deploy/mcpedia-api.service` + `deploy/mcpedia-worker.service` are systemd units
(`Restart=on-failure`, `EnvironmentFile=.env`, `WorkingDirectory=/home/code/mcpedia`).
Enable them with:
```bash ```bash
# Copy systemd unit templates
sudo cp deploy/*.service /etc/systemd/system/ sudo cp deploy/*.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now mcpedia-api mcpedia-worker
# tail logs # Enable and start all supervised services
journalctl -u mcpedia-api -u mcpedia-worker -f sudo systemctl enable --now mcpedia-web mcpedia-api mcpedia-worker mcpedia-mcp
# Inspect service logs
journalctl -u mcpedia-web -u mcpedia-api -u mcpedia-worker -u mcpedia-mcp -f
``` ```
The API should sit behind Caddy (or your reverse proxy) for TLS; expose only ---
`:4020` internally and the web app publicly.
## Status ## Testing
**Phase 1 — MVP (DONE):** monorepo, Core, Web UI (home/doc/search), MCP server, The test suite runs with in-memory mocks without requiring an active database or Redis instance:
Postgres FTS keyword search, content indexing.
**Phase 2 — Semantic + API (DONE):** embeddings provider (OpenRouter via 9router), ```bash
chunked `document_chunks`, `semanticSearch` + `hybridSearch` (RRF), tRPC/Hono API bun run test
(`apps/api`, :4020), MCP `semantic_search`/`hybrid_search` tools, web hybrid toggle. ```
**Phase 3 — Async + Scale (DONE):** Redis + BullMQ background indexing/embedding All 32+ unit and integration tests across packages and apps validate chunking, frontmatter parsing, cosine similarity, revision deduplication, auth gates, and route handlers.
workers (`packages/queue`, `apps/worker`), git-sync webhooks (`POST /hooks/*`),
document revision system (`document_revisions` + restore), and MCP Resources
(`mcpedia://docs/...`). See `PHASES.md`.
> pgvector is **not installed** on the shared imrnes Postgres, so vector storage is
> a `real[]` column with in-app cosine similarity (instant at KB scale). pgvector is
> the Phase-4 scale-out path. See `PHASES.md`.
See `PHASES.md` for Phase 3–4 (Redis/BullMQ, auth, revisions, scale-out).
+15 -10
View File
@@ -6,6 +6,7 @@ import {
hybridSearch, hybridSearch,
keywordSearch, keywordSearch,
listDocuments, listDocuments,
listSections,
semanticSearch, semanticSearch,
listRevisions, listRevisions,
getRevision, getRevision,
@@ -17,10 +18,9 @@ import {
import { getQueue, INDEX_QUEUE } from "@mcpedia/queue"; import { getQueue, INDEX_QUEUE } from "@mcpedia/queue";
import { getConnection, BULLMQ_PREFIX } from "@mcpedia/queue/client"; import { getConnection, BULLMQ_PREFIX } from "@mcpedia/queue/client";
// restoreRevision is a state-changing action (it rewrites the live document row // restoreRevision and CRUD operations are state-changing actions.
// + rebuilds its chunks). It must NOT be callable anonymously over the network — // They must NOT be callable anonymously over the network —
// only the Web UI (which calls @mcpedia/core directly) and an operator with the // only an operator with the webhook secret (or authorized UI) may execute them.
// webhook secret may use it. Anything else is rejected.
const requireWriteAuth = t.middleware(({ ctx, next }) => { const requireWriteAuth = t.middleware(({ ctx, next }) => {
if (!ctx.expectedSecret) { if (!ctx.expectedSecret) {
throw new Error("WEBHOOK_SECRET is not configured; writes are disabled"); throw new Error("WEBHOOK_SECRET is not configured; writes are disabled");
@@ -52,11 +52,13 @@ export const appRouter = router({
.input(z.object({ section: z.string().optional(), status: z.string().optional() }).optional()) .input(z.object({ section: z.string().optional(), status: z.string().optional() }).optional())
.query(async ({ input }) => listDocuments(input ?? {})), .query(async ({ input }) => listDocuments(input ?? {})),
sections: publicProcedure.query(async () => listSections()),
related: publicProcedure related: publicProcedure
.input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(20).default(5) })) .input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(20).default(5) }))
.query(async ({ input }) => getRelated(input.slug, input.limit)), .query(async ({ input }) => getRelated(input.slug, input.limit)),
// --- Phase 3: revisions --- // --- Revisions ---
revisions: publicProcedure revisions: publicProcedure
.input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(50).default(20) })) .input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(50).default(20) }))
.query(async ({ input }) => listRevisions(input.slug, input.limit)), .query(async ({ input }) => listRevisions(input.slug, input.limit)),
@@ -70,19 +72,20 @@ export const appRouter = router({
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ input }) => restoreRevision(input.id)), .mutation(async ({ input }) => restoreRevision(input.id)),
// --- Phase 11: CRUD (gated by x-webhook-secret / admin auth) --- // --- CRUD (gated by x-webhook-secret / admin auth) ---
createDocument: publicProcedure createDocument: publicProcedure
.use(requireWriteAuth) .use(requireWriteAuth)
.input( .input(
z.object({ z.object({
slug: z.string().min(1), slug: z.string().min(1),
title: z.string().min(1), title: z.string().min(1),
section: z.enum(["docs", "writeups", "research", "notes"]), section: z.string().min(1),
body: z.string(), body: z.string(),
type: z.enum(["documentation", "writeup", "research", "note"]).optional(), type: z.string().optional(),
status: z.enum(["published", "draft"]).optional(), status: z.enum(["published", "draft"]).optional(),
author: z.string().optional(), author: z.string().optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
extraFields: z.record(z.string(), z.unknown()).optional(),
}), }),
) )
.mutation(async ({ input }) => createDocument(input)), .mutation(async ({ input }) => createDocument(input)),
@@ -94,10 +97,12 @@ export const appRouter = router({
slug: z.string().min(1), slug: z.string().min(1),
title: z.string().min(1).optional(), title: z.string().min(1).optional(),
body: z.string().optional(), body: z.string().optional(),
type: z.enum(["documentation", "writeup", "research", "note"]).optional(), section: z.string().min(1).optional(),
type: z.string().optional(),
status: z.enum(["published", "draft"]).optional(), status: z.enum(["published", "draft"]).optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
author: z.string().optional(), author: z.string().optional(),
extraFields: z.record(z.string(), z.unknown()).optional(),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -110,7 +115,7 @@ export const appRouter = router({
.input(z.object({ slug: z.string().min(1) })) .input(z.object({ slug: z.string().min(1) }))
.mutation(async ({ input }) => deleteDocument(input.slug)), .mutation(async ({ input }) => deleteDocument(input.slug)),
// --- Phase 3: async job status --- // --- Async job status ---
jobStatus: publicProcedure jobStatus: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {
+2 -2
View File
@@ -10,7 +10,7 @@
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"chat": "bun run src/index.ts", "chat": "bun run src/index.ts",
"ask": "bun run src/ask.ts", "ask": "bun run src/ask.ts",
"lint": "bun run lint", "lint": "tsc --noEmit",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "bun build src/index.ts --outdir=dist --format=esm", "build": "bun build src/index.ts --outdir=dist --format=esm",
"test": "bun test" "test": "bun test"
@@ -20,7 +20,7 @@
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^26",
"typescript": "^5.6.0" "typescript": "^5.6.0"
} }
} }
+1
View File
@@ -130,6 +130,7 @@ export class McpediaClient {
status?: "published" | "draft"; status?: "published" | "draft";
author?: string; author?: string;
tags?: string[]; tags?: string[];
extraFields?: Record<string, unknown>;
}): Promise<any> { }): Promise<any> {
const result = await this.callTool("create_document", input); const result = await this.callTool("create_document", input);
return JSON.parse(extractText(result) || "{}"); return JSON.parse(extractText(result) || "{}");
+1 -1
View File
@@ -23,7 +23,7 @@
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^26",
"typescript": "^5.6.0" "typescript": "^5.6.0"
} }
} }
+2
View File
@@ -36,6 +36,7 @@ mock.module("@mcpedia/core", () => ({
keywordSearch: () => Promise.resolve([]), keywordSearch: () => Promise.resolve([]),
getDocument: () => Promise.resolve(null), getDocument: () => Promise.resolve(null),
listDocuments: () => Promise.resolve([]), listDocuments: () => Promise.resolve([]),
listSections: () => Promise.resolve([]),
getRelated: () => Promise.resolve([]), getRelated: () => Promise.resolve([]),
semanticSearch: () => Promise.resolve([]), semanticSearch: () => Promise.resolve([]),
hybridSearch: () => Promise.resolve([]), hybridSearch: () => Promise.resolve([]),
@@ -209,6 +210,7 @@ test("tool discovery works without auth (read tools present)", async () => {
expect(names).toContain("search_documents"); expect(names).toContain("search_documents");
expect(names).toContain("get_document"); expect(names).toContain("get_document");
expect(names).toContain("list_documents"); expect(names).toContain("list_documents");
expect(names).toContain("list_sections");
expect(names).toContain("semantic_search"); expect(names).toContain("semantic_search");
expect(names).toContain("hybrid_search"); expect(names).toContain("hybrid_search");
expect(names).toContain("get_related_documents"); expect(names).toContain("get_related_documents");
+33 -14
View File
@@ -4,6 +4,7 @@ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import { import {
listDocuments, listDocuments,
listSections,
getDocument, getDocument,
getRelated, getRelated,
semanticSearch, semanticSearch,
@@ -75,14 +76,26 @@ export function createMcpServer(authSecret?: string): McpServer {
}, },
); );
server.registerTool(
"list_sections",
{
description: "List all active knowledge base sections and document counts from PostgreSQL.",
inputSchema: z.object({}),
},
async () => {
const sections = await listSections();
return {
content: [{ type: "text", text: JSON.stringify(sections, null, 2) }],
};
},
);
server.registerTool( server.registerTool(
"list_documents", "list_documents",
{ {
description: "List documents, optionally filtered by section.", description: "List documents, optionally filtered by section.",
inputSchema: z.object({ inputSchema: z.object({
section: z section: z.string().optional().describe("Section name to filter by (e.g. 'docs', 'writeups', 'ctf')"),
.enum(["docs", "writeups", "research", "notes"])
.optional(),
}), }),
}, },
async ({ section }) => { async ({ section }) => {
@@ -146,7 +159,7 @@ export function createMcpServer(authSecret?: string): McpServer {
}, },
); );
// --- Phase 7: mutating + admin tools (require x-webhook-secret) --- // --- Mutating & admin tools (require x-webhook-secret) ---
server.registerTool( server.registerTool(
"index_document", "index_document",
{ {
@@ -198,24 +211,25 @@ export function createMcpServer(authSecret?: string): McpServer {
}, },
); );
// --- Phase 11: CRUD write tools (require x-webhook-secret) --- // --- CRUD write tools (require x-webhook-secret) ---
server.registerTool( server.registerTool(
"create_document", "create_document",
{ {
description: description:
"Create a new document (writes markdown file + DB row + revision + chunks). Requires the x-webhook-secret header.", "Create a new document in PostgreSQL (writes DB row, revision, chunks, and disk backup). Requires the x-webhook-secret header.",
inputSchema: z.object({ inputSchema: z.object({
slug: z.string().describe("URL-safe slug (e.g. 'docs/my-new-doc')"), slug: z.string().describe("URL-safe slug (e.g. 'docs/my-new-doc' or 'ctf/challenge-1')"),
title: z.string().describe("Document title"), title: z.string().describe("Document title"),
section: z.enum(["docs", "writeups", "research", "notes"]).describe("Content section"), section: z.string().describe("Content section (e.g. 'docs', 'writeups', 'research', 'notes', 'guides', 'ctf')"),
body: z.string().describe("Markdown body"), body: z.string().describe("Markdown body"),
type: z.enum(["documentation", "writeup", "research", "note"]).optional(), type: z.string().optional().describe("Document type (e.g. 'documentation', 'writeup', 'research', 'note')"),
status: z.enum(["published", "draft"]).optional(), status: z.enum(["published", "draft"]).optional(),
author: z.string().optional(), author: z.string().optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
extraFields: z.record(z.string(), z.unknown()).optional().describe("Dynamic custom metadata key-value pairs"),
}), }),
}, },
async ({ slug, title, section, body, type, status, author, tags }) => { async ({ slug, title, section, body, type, status, author, tags, extraFields }) => {
requireMcpAuth(authSecret); requireMcpAuth(authSecret);
const doc = await createDocument({ const doc = await createDocument({
slug, slug,
@@ -226,6 +240,7 @@ export function createMcpServer(authSecret?: string): McpServer {
status, status,
author, author,
tags, tags,
extraFields,
}); });
return { return {
content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }], content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }],
@@ -237,26 +252,30 @@ export function createMcpServer(authSecret?: string): McpServer {
"update_document", "update_document",
{ {
description: description:
"Update an existing document (title, body, tags, status, etc.). Requires the x-webhook-secret header.", "Update an existing document in PostgreSQL (title, body, section, tags, status, etc.). Requires the x-webhook-secret header.",
inputSchema: z.object({ inputSchema: z.object({
slug: z.string().describe("Document slug to update"), slug: z.string().describe("Document slug to update"),
title: z.string().optional(), title: z.string().optional(),
body: z.string().optional(), body: z.string().optional(),
type: z.enum(["documentation", "writeup", "research", "note"]).optional(), section: z.string().optional(),
type: z.string().optional(),
status: z.enum(["published", "draft"]).optional(), status: z.enum(["published", "draft"]).optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
author: z.string().optional(), author: z.string().optional(),
extraFields: z.record(z.string(), z.unknown()).optional().describe("Dynamic custom metadata key-value pairs"),
}), }),
}, },
async ({ slug, title, body, type, status, tags, author }) => { async ({ slug, title, body, section, type, status, tags, author, extraFields }) => {
requireMcpAuth(authSecret); requireMcpAuth(authSecret);
const doc = await updateDocument(slug, { const doc = await updateDocument(slug, {
title, title,
body, body,
section,
type, type,
status, status,
tags, tags,
author, author,
extraFields,
}); });
return { return {
content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }], content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }],
@@ -312,7 +331,7 @@ export function createMcpServer(authSecret?: string): McpServer {
}, },
); );
// --- Phase 3: MCP Resources (read-only knowledge base surfaced via URIs) --- // --- MCP Resources (read-only knowledge base surfaced via URIs) ---
// mcpedia://docs -> list all published documents // mcpedia://docs -> list all published documents
// mcpedia://docs/{slug} -> full markdown body (from disk) // mcpedia://docs/{slug} -> full markdown body (from disk)
// mcpedia://docs/{slug}/chunks -> chunked preview (semantic slices) // mcpedia://docs/{slug}/chunks -> chunked preview (semantic slices)
+4
View File
@@ -15,16 +15,20 @@ async function main() {
const names = tools.tools.map((t) => t.name).sort(); const names = tools.tools.map((t) => t.name).sort();
console.log("tools:", names.join(", ")); console.log("tools:", names.join(", "));
const expected = [ const expected = [
"create_document",
"delete_document",
"get_document", "get_document",
"get_related_documents", "get_related_documents",
"hybrid_search", "hybrid_search",
"index_document", "index_document",
"list_documents", "list_documents",
"list_sections",
"queue_status", "queue_status",
"reindex_all", "reindex_all",
"restore_revision", "restore_revision",
"search_documents", "search_documents",
"semantic_search", "semantic_search",
"update_document",
].sort(); ].sort();
if (JSON.stringify(names) !== JSON.stringify(expected)) { if (JSON.stringify(names) !== JSON.stringify(expected)) {
throw new Error(`tool set mismatch: ${names.join(",")}`); throw new Error(`tool set mismatch: ${names.join(",")}`);
+466 -86
View File
@@ -1,61 +1,372 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getDocument, getRelated, listRevisions } from "@mcpedia/core"; import { getDocument, getRelated, listRevisions, listDocuments, getExportDocuments } from "@mcpedia/core";
import { WEBHOOK_SECRET } from "@mcpedia/config"; import { WEBHOOK_SECRET } from "@mcpedia/config";
import Markdown from "@/components/Markdown"; import Markdown from "@/components/Markdown";
import DocForm from "@/components/DocForm"; import DocForm from "@/components/DocForm";
import TOC from "@/components/TOC"; import TOC from "@/components/TOC";
import DocActions from "@/components/DocActions";
import PdfExportView from "@/components/PdfExportView";
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
import { getSectionMeta } from "@mcpedia/config";
import type { DocumentMeta } from "@mcpedia/core";
import {
Folder,
FileText,
Clock,
History,
FileDown,
ArrowLeft,
Sparkles,
CheckCircle2,
XCircle,
Target,
Trophy,
Zap,
Tag,
} from "lucide-react";
// Render at request time (content in Postgres, not available at build time).
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
interface DocPageProps { interface DocPageProps {
params: Promise<{ section: string; slug: string[] }>; params: Promise<{ section: string; slug: string[] }>;
searchParams: Promise<{ edit?: string }>; searchParams: Promise<{ edit?: string; path?: string; sort?: string }>;
} }
const SECTION_LABEL: Record<string, string> = { function getSectionInfo(section: string) {
docs: "Documentation", return getSectionMeta(section);
writeups: "Writeup", }
research: "Research",
notes: "Note",
};
const SECTION_ICON: Record<string, string> = { function calculateReadingTime(text: string): string {
docs: "📄", const words = text.trim().split(/\s+/).length;
writeups: "📝", const minutes = Math.ceil(words / 200);
research: "🔬", return `${minutes} min read`;
notes: "📌", }
};
function CustomFieldBadges({
doc,
standardKeys,
}: {
doc: Record<string, unknown>;
standardKeys: Set<string>;
}) {
const docRecord: Record<string, unknown> = { ...doc } as Record<string, unknown>;
const customEntries = Object.entries(docRecord).filter(
([k, v]) =>
!standardKeys.has(k) && v !== undefined && v !== null && v !== "" && !k.startsWith("_"),
);
if (customEntries.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2 mt-4 pt-3.5 border-t border-[var(--border-color)]">
{customEntries.map(([key, value]) => {
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ");
let colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
let displayValue: string;
let IconComponent = Tag;
if (typeof value === "number") {
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-semibold";
IconComponent = Target;
displayValue = `${value} pts`;
} else if (typeof value === "boolean") {
colorClass = value
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold"
: "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
IconComponent = value ? CheckCircle2 : XCircle;
displayValue = value ? "Solved" : "Pending";
} else if (Array.isArray(value)) {
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
IconComponent = Folder;
displayValue = value.join(", ");
} else {
const v = String(value).toLowerCase();
if (/^(easy|simple|beginner)/i.test(v)) {
colorClass = "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold";
displayValue = "Easy";
} else if (/^(medium|intermediate)/i.test(v)) {
colorClass = "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400 font-semibold";
displayValue = "Medium";
} else if (/^(hard|expert|advanced)/i.test(v)) {
colorClass = "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
displayValue = "Hard";
} else if (/^(pwn|web|crypto|misc|forensic|reverse|binary)/i.test(v)) {
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-bold";
IconComponent = Zap;
displayValue = v.toUpperCase();
} else if (/ctf|def.?con|event/i.test(v)) {
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)]";
IconComponent = Trophy;
displayValue = String(value);
} else {
displayValue = String(value);
}
}
return (
<span
key={key}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 border rounded-md text-xs font-mono transition-colors ${colorClass}`}
>
<IconComponent className="w-3.5 h-3.5 opacity-80" />
<span className="text-[var(--text-muted)] font-sans">{label}:</span>
<span>{displayValue}</span>
</span>
);
})}
</div>
);
}
function FolderIndexPage({
section,
slug,
allDocs,
}: {
section: string;
slug: string;
allDocs: DocumentMeta[];
}) {
const prefix = `${section}/${slug}/`;
const folderDocPaths = allDocs
.filter((d) => d.slug.startsWith(prefix))
.map((d) => ({
rel: d.slug.slice(prefix.length),
doc: d,
}))
.sort((a, b) => a.rel.localeCompare(b.rel));
const immediateFolders = new Map<string, { docCount: number }>();
const immediateDocs: DocumentMeta[] = [];
for (const { rel, doc } of folderDocPaths) {
const parts = rel.split("/");
if (parts.length === 1) {
immediateDocs.push(doc);
} else {
const folder = parts[0];
const existing = immediateFolders.get(folder) || { docCount: 0 };
existing.docCount += 1;
immediateFolders.set(folder, existing);
}
}
const folders = [...immediateFolders.entries()].sort(([a], [b]) => a.localeCompare(b));
const sectionInfo = getSectionInfo(section);
const folderName = slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
return (
<div className="space-y-8">
{/* Breadcrumb Navigation */}
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] flex-wrap">
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
MCPedia
</Link>
<span>/</span>
<Link href={`/${section}`} className="hover:text-[var(--text-primary)] transition-colors">
{sectionInfo.label}
</Link>
{slug.split("/").map((part, i) => {
const path = `${section}/${slug.split("/").slice(0, i + 1).join("/")}`;
const isLast = i === slug.split("/").length - 1;
const label = part.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
return (
<span key={path} className="flex items-center gap-2">
<span>/</span>
<span className={isLast ? "text-[var(--text-primary)] font-semibold" : "hover:text-[var(--text-primary)]"}>
{label}
</span>
</span>
);
})}
</nav>
{/* Header Card */}
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-xs">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="flex items-center gap-3.5">
<div className="w-10 h-10 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-[var(--text-primary)]">
<Folder className="w-5 h-5" />
</div>
<div>
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
{folderName}
</h1>
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
Directory: <code>{section}/{slug}</code>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
</span>
<Link
href={`/${section}/${slug}/export`}
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] px-3 py-1.5 rounded font-semibold transition-all shadow-xs"
title={`Export all ${folderName} documents as a single PDF report`}
>
<FileDown className="w-3.5 h-3.5" />
<span>Export PDF</span>
</Link>
</div>
</div>
</div>
{/* Subfolders Grid */}
{folders.length > 0 && (
<div>
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
<Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<span>Subfolders ({folders.length})</span>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{folders.map(([folder, info]) => (
<Link
key={folder}
href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")}
className="group flex items-center gap-3 p-3.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg transition-colors shadow-xs"
>
<Folder className="w-4 h-4 text-[var(--text-muted)]" />
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-[var(--text-primary)] group-hover:underline truncate">
{folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
</div>
<div className="text-[11px] text-[var(--text-muted)] font-mono mt-0.5">
{info.docCount} document{info.docCount !== 1 ? "s" : ""}
</div>
</div>
</Link>
))}
</div>
</div>
)}
{/* Documents List */}
{immediateDocs.length > 0 && (
<div>
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
<FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<span>Documents ({immediateDocs.length})</span>
</h2>
<div className="space-y-2">
{immediateDocs.map((doc) => (
<Link
key={doc.slug}
href={`/${doc.slug}`}
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg transition-colors shadow-xs"
>
<div className="flex items-center gap-3 min-w-0">
<FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
<div className="min-w-0">
<div className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
{doc.title}
</div>
{doc.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{doc.tags.slice(0, 4).map((t) => (
<span
key={t}
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
>
#{t}
</span>
))}
</div>
)}
</div>
</div>
<time className="text-xs text-[var(--text-dim)] shrink-0 font-mono flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>
{new Date(doc.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</span>
</time>
</Link>
))}
</div>
</div>
)}
{folders.length === 0 && immediateDocs.length === 0 && (
<div className="text-center py-12 border border-[var(--border-color)] rounded-lg bg-[var(--bg-surface)]">
<p className="text-sm text-[var(--text-muted)]">No documents in this directory.</p>
</div>
)}
</div>
);
}
export default async function DocPage({ params, searchParams }: DocPageProps) { export default async function DocPage({ params, searchParams }: DocPageProps) {
const { section, slug } = await params; const { section, slug } = await params;
const { edit } = await searchParams; const searchParamsObj = await searchParams;
const { edit, path: queryPath, sort } = searchParamsObj;
// Handle direct export views
if (section === "export" || (slug && slug[slug.length - 1] === "export")) {
const isTrailingExport = slug && slug[slug.length - 1] === "export";
const targetSlug = isTrailingExport ? slug.slice(0, -1) : slug;
const targetPath = queryPath || (targetSlug.length > 0 ? `${section}/${targetSlug.join("/")}` : section);
const exportData = await getExportDocuments({
path: targetPath === "export" ? queryPath : targetPath,
section: targetPath === section ? section : undefined,
sortBy: (sort as any) || "category_points",
});
const backHref = targetPath && targetPath !== "export" ? `/${targetPath}` : section !== "export" ? `/${section}` : "/";
return <PdfExportView exportData={exportData} backHref={backHref} />;
}
const fullSlug = `${section}/${slug.join("/")}`; const fullSlug = `${section}/${slug.join("/")}`;
const allDocs = await listDocuments();
const docSlugs = allDocs.map((d) => d.slug);
const classification = classifyPath(docSlugs, fullSlug);
if (classification === "folder") {
return <FolderIndexPage section={section} slug={slug.join("/")} allDocs={allDocs} />;
}
const doc = await getDocument(fullSlug); const doc = await getDocument(fullSlug);
if (!doc) notFound(); if (!doc) notFound();
const cookieStore = await cookies(); const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null; const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
// Edit mode: inline form const STANDARD_KEYS = new Set([
"id", "slug", "title", "type", "section", "status",
"author", "tags", "path", "createdAt", "updatedAt",
"extraFields", "body",
]);
if (edit === "1" && canEdit) { if (edit === "1" && canEdit) {
const existingFolders = extractFoldersForSection(docSlugs, doc.section);
return ( return (
<div> <div className="space-y-6">
<Link <Link
href={`/${doc.slug}`} href={`/${doc.slug}`}
className="text-xs text-[#8a8f98] hover:text-[#d0d6e0] mb-4 inline-block" className="inline-flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
> >
← Back to {doc.title} <ArrowLeft className="w-3.5 h-3.5" />
<span>Back to document</span>
</Link> </Link>
<h1 className="text-2xl font-light text-[#f7f8f8] mb-6"> <h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
Edit: {doc.title} Edit: {doc.title}
</h1> </h1>
<DocForm <DocForm
mode="edit" mode="edit"
slug={fullSlug} slug={fullSlug}
secret={WEBHOOK_SECRET} secret={WEBHOOK_SECRET}
existingSections={Array.from(new Set(allDocs.map((d) => d.section)))}
existingFolders={{
[doc.section]: existingFolders,
}}
initial={{ initial={{
title: doc.title, title: doc.title,
body: doc.body, body: doc.body,
@@ -64,148 +375,217 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
status: doc.status, status: doc.status,
tags: doc.tags, tags: doc.tags,
author: doc.author, author: doc.author,
extraFields: doc.extraFields,
}} }}
/> />
</div> </div>
); );
} }
const related = await getRelated(fullSlug, 5); const related = await getRelated(fullSlug, 4);
const revisions = await listRevisions(fullSlug, 10); const revisions = await listRevisions(fullSlug, 10);
const sectionInfo = getSectionInfo(doc.section);
const readingTime = calculateReadingTime(doc.body);
return ( return (
<article> <div className="flex items-start gap-8">
{/* Breadcrumb */} {/* Main Content Column */}
<nav className="flex items-center gap-2 text-xs text-[#62666d] mb-6"> <article className="flex-1 min-w-0">
<Link {/* Breadcrumbs Navigation */}
href="/" <nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-4 flex-wrap font-mono">
className="hover:text-[#d0d6e0] transition-colors" <Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
>
MCPedia MCPedia
</Link> </Link>
{doc.slug.split("/").map((part, i) => {
const crumbPath = doc.slug.split("/").slice(0, i + 1).join("/");
const isLast = i === doc.slug.split("/").length - 1;
const label = i === 0
? sectionInfo.label
: part.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
return (
<span key={crumbPath} className="flex items-center gap-2">
<span>/</span> <span>/</span>
<Link <Link
href="/docs" href={`/${crumbPath}`}
className="hover:text-[#d0d6e0] transition-colors" className={isLast ? "text-[var(--text-primary)] font-semibold font-sans" : "hover:text-[var(--text-primary)] transition-colors font-sans"}
> >
Docs {label}
</Link> </Link>
<span>/</span> </span>
<span className="text-[#8a8f98]">{doc.title}</span> );
})}
</nav> </nav>
{/* Header */} {/* Hero Metadata Card */}
<div className="mb-8"> <div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 sm:p-7 mb-8 shadow-xs">
<div className="flex items-center gap-2 mb-3"> <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-4">
<span className="text-xl">{SECTION_ICON[doc.section] || "📄"}</span> <div className="flex-1 min-w-0">
<span className="text-xs font-medium text-[#7170ff] uppercase"> <div className="flex items-center gap-2 mb-2 flex-wrap text-xs">
{SECTION_LABEL[doc.section] || doc.section} <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-bold bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] uppercase">
<span>{sectionInfo.icon}</span>
<span>{sectionInfo.label}</span>
</span>
<span className="text-xs text-[var(--text-dim)]">·</span>
<span className="text-xs text-[var(--text-muted)] font-mono">
{doc.type}
</span>
<span className="text-xs text-[var(--text-dim)]">·</span>
<span className="text-xs text-[var(--text-muted)] font-mono flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{readingTime}</span>
</span> </span>
</div> </div>
<h1 className="text-4xl font-medium text-[#f7f8f8] mb-4 leading-tight">
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight leading-tight">
{doc.title} {doc.title}
</h1> </h1>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-[#62666d] mb-6"> {/* Quick Actions */}
<span className="text-[#d0d6e0]">{doc.author || "unknown"}</span> <DocActions slug={doc.slug} body={doc.body} canEdit={canEdit} />
<span>·</span> </div>
<time dateTime={doc.updatedAt}>
{/* Author, Date, Tags */}
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--text-muted)] pt-3.5 border-t border-[var(--border-color)]">
{doc.author && (
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 rounded bg-[var(--brand)] text-[var(--brand-fg)] flex items-center justify-center text-[10px] font-bold font-mono">
{doc.author.charAt(0).toUpperCase()}
</div>
<span className="text-[var(--text-secondary)] font-medium">{doc.author}</span>
<span className="text-[var(--text-dim)]">·</span>
</div>
)}
<span className="flex items-center gap-1 font-mono text-[11px]">
<span>
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, { Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
month: "long", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
})} })}
</time> </span>
</span>
{doc.tags.length > 0 && (
<>
<span className="text-[var(--text-dim)]">·</span>
<div className="flex flex-wrap gap-1">
{doc.tags.map((t) => ( {doc.tags.map((t) => (
<span <span
key={t} key={t}
className="px-2 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]" className="px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] text-[10px] font-mono"
> >
#{t} #{t}
</span> </span>
))} ))}
</div> </div>
</>
)}
</div> </div>
{/* Content + TOC */} {/* Dynamic Badges */}
<div className="mb-8"> <CustomFieldBadges doc={{ ...doc }} standardKeys={STANDARD_KEYS} />
</div>
{/* Mobile TOC */}
<div className="xl:hidden mb-6 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
<TOC source={doc.body} /> <TOC source={doc.body} />
</div>
{/* Markdown Content */}
<div className="mb-14">
<Markdown source={doc.body} /> <Markdown source={doc.body} />
</div> </div>
{/* Related docs (card-style) */} {/* Related Documents Grid */}
{related.length > 0 && ( {related.length > 0 && (
<aside className="mt-12 pt-8 border-t border-[#1f2022]"> <aside className="mt-12 pt-8 border-t border-[var(--border-color)]">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-5"> <div className="flex items-center gap-2 mb-3.5">
Related Documents <Sparkles className="w-4 h-4 text-[var(--text-muted)]" />
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
Related Knowledge
</h2> </h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> </div>
{related.map((r) => ( <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{related.map((r) => {
const rInfo = getSectionInfo(r.section);
return (
<Link <Link
key={r.slug} key={r.slug}
href={`/${r.slug}`} href={`/${r.slug}`}
className="group block bg-[#0f1011] border border-[#1f2022] rounded-lg p-4 hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all duration-200" className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg p-4 transition-colors shadow-xs"
> >
<div className="flex items-start gap-2 mb-2"> <div className="flex items-center justify-between gap-2 mb-1.5">
<span className="text-xl"> <span className="text-[10px] text-[var(--text-muted)] font-mono uppercase font-semibold">
{SECTION_ICON[r.section] || "📄"} {rInfo.icon} {r.section}
</span> </span>
<time className="text-xs text-[#62666d]"> <time className="text-[11px] text-[var(--text-dim)] font-mono">
{new Date(r.updatedAt).toLocaleDateString(undefined, { {new Date(r.updatedAt).toLocaleDateString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",
})} })}
</time> </time>
</div> </div>
<h3 className="font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors line-clamp-1 mb-1"> <h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline line-clamp-1">
{r.title} {r.title}
</h3> </h3>
<div className="flex flex-wrap gap-1 mt-2">
{r.tags.slice(0, 3).map((t) => (
<span
key={t}
className="text-xs px-1.5 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]"
>
#{t}
</span>
))}
</div>
</Link> </Link>
))} );
})}
</div> </div>
</aside> </aside>
)} )}
{/* History */} {/* Revision History */}
{revisions.length > 0 && ( {revisions.length > 0 && (
<aside className="mt-12 pt-8 border-t border-[#1f2022]"> <aside className="mt-10 pt-8 border-t border-[var(--border-color)]">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-4"> <div className="flex items-center justify-between mb-3.5">
Revision History <div className="flex items-center gap-2">
<History className="w-4 h-4 text-[var(--text-muted)]" />
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
Revision History ({revisions.length})
</h2> </h2>
<ul className="space-y-2 text-sm"> </div>
</div>
<div className="space-y-2">
{revisions.map((rev) => ( {revisions.map((rev) => (
<li <div
key={rev.id} key={rev.id}
className="flex items-center justify-between gap-3 py-2 border-b border-[#141516] last:border-0" className="flex items-center justify-between gap-3 p-3 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg text-xs shadow-xs"
> >
<span className="text-[#8a8f98]"> <div className="flex items-center gap-2 min-w-0 font-mono">
#{rev.revisionNo} · {rev.reason} ·{" "} <span className="text-[var(--text-primary)] bg-[var(--bg-elevated)] px-1.5 py-0.5 rounded border border-[var(--border-color)] font-bold text-[10px]">
{new Date(rev.createdAt).toLocaleString()} v{rev.revisionNo}
</span> </span>
<span className="text-[var(--text-secondary)] font-sans truncate text-xs">
{rev.reason || "Updated document in PostgreSQL"}
</span>
<span className="text-[var(--text-dim)] hidden sm:inline text-[11px]">
· {new Date(rev.createdAt).toLocaleString()}
</span>
</div>
{canEdit && (
<form action="/api/revisions/restore" method="post"> <form action="/api/revisions/restore" method="post">
<input type="hidden" name="id" value={rev.id} /> <input type="hidden" name="id" value={rev.id} />
<button <button
type="submit" type="submit"
className="text-xs text-[#d0d6e0] hover:text-[#7170ff] px-2 py-0.5 rounded border border-[#1f2022] hover:border-[#7170ff] transition-colors" className="text-xs text-[var(--text-primary)] hover:text-black dark:hover:text-white bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] px-2.5 py-1 rounded border border-[var(--border-color)] transition-colors font-medium cursor-pointer"
> >
Restore Restore
</button> </button>
</form> </form>
</li> )}
</div>
))} ))}
</ul> </div>
</aside> </aside>
)} )}
</article> </article>
{/* Desktop Sticky Table of Contents Column */}
<aside className="hidden xl:block w-64 shrink-0 sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto pl-6 border-l border-[var(--border-color)]">
<TOC source={doc.body} />
</aside>
</div>
); );
} }
+271
View File
@@ -0,0 +1,271 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
import { getSectionMeta } from "@mcpedia/config";
import {
Folder,
FileText,
FileDown,
Plus,
Clock,
} from "lucide-react";
export const dynamic = "force-dynamic";
interface DocMeta {
slug: string;
title: string;
section: string;
tags: string[];
updatedAt: string;
path: string;
}
interface TreeNode {
name: string;
slug: string;
doc?: DocMeta;
children: TreeNode[];
docCount: number;
}
function buildFolderTree(docs: DocMeta[], section: string): TreeNode[] {
const root: TreeNode[] = [];
for (const doc of docs.filter((d) => (d.section || "").toLowerCase() === section.toLowerCase())) {
const rel = doc.slug.startsWith(`${section}/`)
? doc.slug.slice(section.length + 1)
: doc.slug;
const parts = rel.split("/");
let current = root;
let currentPath = section;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isLeaf = i === parts.length - 1;
currentPath = `${currentPath}/${part}`;
let node = current.find((n) => n.name === part);
if (!node) {
node = {
name: part,
slug: currentPath,
children: [],
docCount: 0,
};
current.push(node);
}
if (isLeaf) {
node.doc = doc;
node.docCount = 1;
}
current = node.children;
}
}
function countDocs(node: TreeNode): number {
if (node.doc) return 1;
return node.children.reduce((sum, child) => sum + countDocs(child), 0);
}
for (const node of root) {
node.docCount = countDocs(node);
}
return root;
}
function renderTree(nodes: TreeNode[], pathname: string) {
return (
<ul className="space-y-1">
{nodes.map((node) => {
const isFolder = node.children.length > 0;
const isActive = pathname === `/${node.slug}`;
const hasActiveChild = pathname.startsWith(`/${node.slug}/`);
return (
<li key={node.slug}>
<Link
href={`/${node.slug}`}
className={`flex items-center justify-between gap-2 text-xs py-1.5 px-2.5 rounded-md transition-colors ${
isActive
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)] shadow-xs"
: hasActiveChild
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`}
>
<span className="flex items-center gap-2 truncate">
<span className="text-[var(--text-dim)]">
{isFolder ? <Folder className="w-3.5 h-3.5" /> : <FileText className="w-3.5 h-3.5" />}
</span>
<span className="truncate font-medium">{node.doc?.title || node.name}</span>
</span>
{isFolder && (
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.2 rounded font-mono">
{node.docCount} docs
</span>
)}
</Link>
{node.children.length > 0 && (
<div className="ml-3.5 border-l border-[var(--border-color)] pl-2.5 mt-0.5 space-y-0.5">
{renderTree(node.children, pathname)}
</div>
)}
</li>
);
})}
</ul>
);
}
export default async function SectionIndexPage({
params,
}: {
params: Promise<{ section: string }>;
}) {
const { section } = await params;
const sectionInfo = getSectionMeta(section);
const allDocs = await listDocuments();
const sectionDocs = allDocs.filter((d) => (d.section || "").toLowerCase() === section.toLowerCase());
const tree = buildFolderTree(allDocs, section);
return (
<div className="space-y-8">
{/* Breadcrumb Navigation */}
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] font-mono">
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
MCPedia
</Link>
<span>/</span>
<span className="text-[var(--text-muted)] font-semibold font-sans">{sectionInfo.label}</span>
</nav>
{/* Hero Card */}
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-xs">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-3">
<div className="flex items-center gap-3.5">
<div className="w-12 h-12 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-2xl">
{sectionInfo.icon}
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">
{sectionInfo.label}
</h1>
<p className="text-xs text-[var(--text-muted)] font-mono uppercase tracking-wider mt-0.5">
Section: <code>{section}</code>
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""}
</span>
{sectionDocs.length > 0 && (
<Link
href={`/${section}/export`}
className="inline-flex items-center gap-1.5 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] px-3 py-1 rounded-md font-semibold transition-all shadow-xs"
title={`Export all ${sectionInfo.label} documents to publication-grade PDF`}
>
<FileDown className="w-3.5 h-3.5" />
<span>Export PDF</span>
</Link>
)}
<Link
href="/create"
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] px-3 py-1 rounded-md font-semibold transition-all shadow-xs"
>
<Plus className="w-3.5 h-3.5" />
<span>Create Doc</span>
</Link>
</div>
</div>
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed max-w-2xl mt-2">
{sectionInfo.desc}
</p>
</div>
{/* Hierarchical Folder Directory */}
{tree.length > 0 && (
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-5 shadow-xs">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]">
<Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
Folder & Chapter Structure
</h2>
</div>
{renderTree(tree, section)}
</section>
)}
{/* Document List */}
{sectionDocs.length > 0 ? (
<section>
<div className="flex items-center gap-2 mb-3.5">
<FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
All Documents in {sectionInfo.label}
</h2>
</div>
<div className="space-y-2">
{[...sectionDocs]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.map((d) => (
<Link
key={d.slug}
href={`/${d.slug}`}
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl transition-all shadow-xs"
>
<div className="flex items-center gap-3 min-w-0">
<FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
<div className="min-w-0">
<h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
{d.title}
</h3>
{d.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{d.tags.slice(0, 4).map((t) => (
<span
key={t}
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
>
#{t}
</span>
))}
</div>
)}
</div>
</div>
<time className="text-xs text-[var(--text-dim)] font-mono shrink-0 flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>
{new Date(d.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</time>
</Link>
))}
</div>
</section>
) : (
<div className="text-center py-12 border border-[var(--border-color)] rounded-xl bg-[var(--bg-surface)]">
<p className="text-sm text-[var(--text-muted)]">No documents in this section yet.</p>
</div>
)}
</div>
);
}
+13 -8
View File
@@ -13,15 +13,23 @@ function signCookie(value: string): string {
return `${value}.${sig}`; return `${value}.${sig}`;
} }
function safeTimingEqual(a: string, b: string): boolean {
try {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
} catch {
return false;
}
}
function verifyCookie(cookieValue: string | undefined): boolean { function verifyCookie(cookieValue: string | undefined): boolean {
if (!cookieValue) return false; if (!cookieValue) return false;
const [value, sig] = cookieValue.split("."); const [value, sig] = cookieValue.split(".");
if (!value || !sig) return false; if (!value || !sig) return false;
const expected = signCookie(value); const expected = signCookie(value);
return timingSafeEqual( return safeTimingEqual(cookieValue, expected);
Buffer.from(cookieValue),
Buffer.from(expected),
);
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -32,10 +40,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
} }
const ok = timingSafeEqual( const ok = safeTimingEqual(password, ADMIN_PASSWORD);
Buffer.from(password),
Buffer.from(ADMIN_PASSWORD),
);
if (!ok) { if (!ok) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
+50 -2
View File
@@ -6,9 +6,13 @@ import { timingSafeEqual } from "node:crypto";
function isAuthorized(req: NextRequest): boolean { function isAuthorized(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return false; if (!WEBHOOK_SECRET) return false;
const headerSecret = req.headers.get("x-webhook-secret") ?? ""; const headerSecret = req.headers.get("x-webhook-secret") ?? "";
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) { if (headerSecret) {
const bufA = Buffer.from(headerSecret);
const bufB = Buffer.from(WEBHOOK_SECRET);
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
return true; return true;
} }
}
const cookie = req.cookies.get("mcpedia_admin")?.value ?? ""; const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
return cookie.startsWith("admin."); return cookie.startsWith("admin.");
} }
@@ -17,6 +21,49 @@ function unauthorized() {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
} }
const STANDARD_FIELDS = new Set([
"title", "body", "type", "status",
"author", "tags", "createdAt", "updatedAt", "id", "path", "slug",
]);
function parseCustomValue(value: unknown): unknown {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(trimmed) && !(trimmed.startsWith("0") && trimmed.length > 1 && !trimmed.startsWith("0."))) {
const num = Number(trimmed);
if (!isNaN(num)) return num;
}
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
}
return value;
}
function splitPayload(body: Record<string, unknown>): {
standard: Record<string, unknown>;
extraFields: Record<string, unknown>;
} {
const standard: Record<string, unknown> = {};
const extraFields: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body)) {
if (key === "extraFields" && typeof value === "object" && value !== null) {
Object.assign(extraFields, value);
} else if (STANDARD_FIELDS.has(key)) {
standard[key] = value;
} else if (value !== undefined && value !== null) {
extraFields[key] = parseCustomValue(value);
}
}
return { standard, extraFields };
}
// PUT /api/docs/{slug...} — Update an existing document. // PUT /api/docs/{slug...} — Update an existing document.
export async function PUT( export async function PUT(
req: NextRequest, req: NextRequest,
@@ -35,7 +82,8 @@ export async function PUT(
} }
try { try {
const doc = await updateDocument(fullSlug, body); const { standard, extraFields } = splitPayload(body);
const doc = await updateDocument(fullSlug, { ...standard, extraFields } as any);
return NextResponse.json({ ok: true, slug: doc.slug, doc }); return NextResponse.json({ ok: true, slug: doc.slug, doc });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
+56 -2
View File
@@ -13,9 +13,13 @@ import { timingSafeEqual } from "node:crypto";
function isAuthorized(req: NextRequest): boolean { function isAuthorized(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return false; if (!WEBHOOK_SECRET) return false;
const headerSecret = req.headers.get("x-webhook-secret") ?? ""; const headerSecret = req.headers.get("x-webhook-secret") ?? "";
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) { if (headerSecret) {
const bufA = Buffer.from(headerSecret);
const bufB = Buffer.from(WEBHOOK_SECRET);
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
return true; return true;
} }
}
const cookie = req.cookies.get("mcpedia_admin")?.value ?? ""; const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
return cookie.startsWith("admin."); return cookie.startsWith("admin.");
} }
@@ -24,6 +28,55 @@ function unauthorized() {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
} }
// Standard DocumentMeta field names — NOT custom fields.
const STANDARD_FIELDS = new Set([
"slug", "title", "body", "section", "type", "status",
"author", "tags", "createdAt", "updatedAt", "id", "path",
]);
function parseCustomValue(value: unknown): unknown {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(trimmed) && !(trimmed.startsWith("0") && trimmed.length > 1 && !trimmed.startsWith("0."))) {
const num = Number(trimmed);
if (!isNaN(num)) return num;
}
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
}
return value;
}
/**
* Separate a flat payload into standard CRUD fields + extraFields (custom metadata).
* The DocForm sends all fields flat — any key not in STANDARD_FIELDS becomes an
* entry in extraFields, which is stored as JSONB in the documents table.
*/
function splitPayload(body: Record<string, unknown>): {
standard: Record<string, unknown>;
extraFields: Record<string, unknown>;
} {
const standard: Record<string, unknown> = {};
const extraFields: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body)) {
if (key === "extraFields" && typeof value === "object" && value !== null) {
Object.assign(extraFields, value);
} else if (STANDARD_FIELDS.has(key)) {
standard[key] = value;
} else if (value !== undefined && value !== null) {
extraFields[key] = parseCustomValue(value);
}
}
return { standard, extraFields };
}
// GET /api/docs — list all documents (for sidebar navigation). // GET /api/docs — list all documents (for sidebar navigation).
export async function GET() { export async function GET() {
const docs = await listDocuments(); const docs = await listDocuments();
@@ -40,7 +93,8 @@ export async function POST(req: NextRequest) {
} }
try { try {
const doc = await createDocument(body); const { standard, extraFields } = splitPayload(body);
const doc = await createDocument({ ...standard, extraFields } as any);
return NextResponse.json({ ok: true, slug: doc.slug, doc }); return NextResponse.json({ ok: true, slug: doc.slug, doc });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import {
getExportDocuments,
compileExportMarkdown,
generateExportDocx,
type ExportSortOption,
} from "@mcpedia/core";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const path = searchParams.get("path") || undefined;
const section = searchParams.get("section") || undefined;
const format = searchParams.get("format") || "json";
const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points";
const slugsParam = searchParams.get("slugs");
const slugs = slugsParam ? slugsParam.split(",").map((s) => s.trim()) : undefined;
const pageBreaksParam = searchParams.get("pageBreaks");
const pageBreaks = pageBreaksParam !== "false" && pageBreaksParam !== "0";
try {
const data = await getExportDocuments({
path,
section,
slugs,
sortBy,
});
const safeFilename = data.summary.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "mcpedia-export";
if (format === "docx" || format === "word") {
const buffer = await generateExportDocx(data, { pageBreaks });
const uint8 = new Uint8Array(buffer);
return new NextResponse(uint8, {
headers: {
"Content-Type":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"Content-Disposition": `attachment; filename="${safeFilename}.docx"`,
},
});
}
if (format === "markdown" || format === "md") {
const md = compileExportMarkdown(data);
return new NextResponse(md, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Content-Disposition": `attachment; filename="${safeFilename}.md"`,
},
});
}
return NextResponse.json(data);
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Export failed" },
{ status: 500 },
);
}
}
@@ -1,12 +1,32 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { restoreRevision, getRevision } from "@mcpedia/core"; import { restoreRevision, getRevision } from "@mcpedia/core";
import { WEBHOOK_SECRET } from "@mcpedia/config";
import { timingSafeEqual } from "node:crypto";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
function isAuthorized(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return false;
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
if (headerSecret) {
const bufA = Buffer.from(headerSecret);
const bufB = Buffer.from(WEBHOOK_SECRET);
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
return true;
}
}
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
return cookie.startsWith("admin.");
}
// POST /api/revisions/restore — restore a document to a past revision. // POST /api/revisions/restore — restore a document to a past revision.
// Body (form-urlencoded): id=<revision uuid> // Body (form-urlencoded): id=<revision uuid>
// After restoring, we rebuild semantic chunks (handled inside restoreRevision) // After restoring, we rebuild semantic chunks (handled inside restoreRevision)
// and revalidate the doc page so the Web UI reflects the restored body. // and revalidate the doc page so the Web UI reflects the restored body.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const form = await req.formData().catch(() => null); const form = await req.formData().catch(() => null);
const id = form?.get("id"); const id = form?.get("id");
if (typeof id !== "string" || id.length === 0) { if (typeof id !== "string" || id.length === 0) {
+71
View File
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { keywordSearch, hybridSearch, semanticSearch } from "@mcpedia/core";
// GET /api/search?q=...&mode=hybrid|keyword|semantic
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const q = searchParams.get("q") ?? "";
const mode = searchParams.get("mode") ?? "hybrid";
const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? 20), 1), 50);
if (!q.trim()) {
return NextResponse.json({ results: [] });
}
try {
if (mode === "keyword") {
const hits = await keywordSearch(q, limit);
return NextResponse.json({
results: hits.map((h) => ({
slug: h.doc.slug,
title: h.doc.title,
section: h.doc.section,
score: h.rank,
snippet: h.snippet,
})),
});
}
if (mode === "semantic") {
const hits = await semanticSearch(q, limit);
return NextResponse.json({
results: hits.map((h) => ({
slug: h.slug,
title: h.slug.split("/").pop() ?? h.slug,
section: h.slug.split("/")[0] ?? "docs",
score: h.score,
snippet: h.content.slice(0, 160),
})),
});
}
// Default: hybrid search
const hits = await hybridSearch(q, limit);
return NextResponse.json({
results: hits.map((h) => ({
slug: h.doc.slug,
title: h.doc.title,
section: h.doc.section,
score: h.rank,
snippet: h.snippet,
})),
});
} catch (err) {
console.error("Search API error:", err);
// Fall back to keyword search if embedding provider fails
try {
const hits = await keywordSearch(q, limit);
return NextResponse.json({
results: hits.map((h) => ({
slug: h.doc.slug,
title: h.doc.title,
section: h.doc.section,
score: h.rank,
snippet: h.snippet,
})),
});
} catch {
return NextResponse.json({ results: [], error: "Search failed" }, { status: 500 });
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { listSections } from "@mcpedia/core";
export const dynamic = "force-dynamic";
// GET /api/sections — list all active sections with document counts dynamically from PostgreSQL.
export async function GET() {
try {
const sections = await listSections();
return NextResponse.json(sections);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Search,
Plus,
ArrowRight,
BookOpen,
Flag,
Sparkles,
X,
} from "lucide-react";
interface Hit {
slug: string;
title: string;
section: string;
snippet?: string;
score: number;
}
interface QuickAction {
id: string;
title: string;
subtitle: string;
icon: any;
href?: string;
action?: () => void;
}
export default function CommandMenu() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<Hit[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const quickActions: QuickAction[] = [
{
id: "create-doc",
title: "Create New Document",
subtitle: "Write technical doc, writeup, or note",
icon: Plus,
href: "/create",
},
{
id: "hybrid-search",
title: "Multi-Modal Hybrid Search",
subtitle: "Full-text + Semantic vector search console",
icon: Search,
href: "/search",
},
{
id: "browse-docs",
title: "Documentation Hub",
subtitle: "System setup, API references, protocol specs",
icon: BookOpen,
href: "/docs",
},
{
id: "browse-writeups",
title: "CTF & Writeups Directory",
subtitle: "Pwn, Reverse, Web, Crypto writeups and solutions",
icon: Flag,
href: "/writeups",
},
{
id: "browse-research",
title: "Research & Benchmarks",
subtitle: "Architecture notes, benchmarks, experiments",
icon: Sparkles,
href: "/research",
},
];
// Global shortcut ⌘K or /
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((prev) => !prev);
} else if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
e.preventDefault();
setOpen(true);
} else if (e.key === "Escape" && open) {
setOpen(false);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open]);
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50);
setQuery("");
setResults([]);
setSelectedIndex(0);
}
}, [open]);
// Debounced search
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=10`);
const data = await res.json();
setResults(data.results || []);
setSelectedIndex(0);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 120);
return () => clearTimeout(timer);
}, [query]);
function handleNavigate(href: string) {
setOpen(false);
router.push(href);
}
const totalItems = query.trim() ? results.length : quickActions.length;
function handleKeyNavigation(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (totalItems > 0 ? (prev + 1) % totalItems : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (totalItems > 0 ? (prev - 1 + totalItems) % totalItems : 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (query.trim() && results[selectedIndex]) {
handleNavigate(`/${results[selectedIndex].slug}`);
} else if (!query.trim() && quickActions[selectedIndex]?.href) {
handleNavigate(quickActions[selectedIndex].href!);
}
}
}
if (!open) {
return (
<button
onClick={() => setOpen(true)}
type="button"
className="hidden md:flex items-center gap-2.5 px-3 py-1.5 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-xs cursor-pointer group"
>
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors" />
<span className="font-sans">Search...</span>
<kbd className="ml-2 font-mono bg-[var(--bg-surface)] px-1.5 py-0.2 rounded border border-[var(--border-color)] text-[10px] text-[var(--text-muted)]">
⌘K
</kbd>
</button>
);
}
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-16 sm:pt-24 px-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-xs transition-opacity"
onClick={() => setOpen(false)}
/>
{/* Modal */}
<div className="relative w-full max-w-2xl bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-2xl overflow-hidden animate-fade-in">
{/* Search input bar */}
<div className="flex items-center gap-3 px-4 sm:px-5 py-3.5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]">
<Search className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyNavigation}
placeholder="Search documents, writeups, concepts, or tools..."
className="w-full bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] text-sm focus:outline-none"
/>
{loading && (
<div className="w-4 h-4 border-2 border-[var(--text-muted)] border-t-transparent rounded-full animate-spin shrink-0" />
)}
<button
onClick={() => setOpen(false)}
type="button"
className="text-[10px] font-mono text-[var(--text-dim)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)]"
>
ESC
</button>
</div>
{/* Results / Quick Actions List */}
<div className="max-h-[22rem] overflow-y-auto p-2">
{query.trim() === "" ? (
<div className="p-1 space-y-0.5">
<div className="px-2.5 py-1 text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">
Quick Shortcuts
</div>
{quickActions.map((action, idx) => {
const Icon = action.icon;
const isSelected = idx === selectedIndex;
return (
<div
key={action.id}
onClick={() => action.href && handleNavigate(action.href)}
onMouseEnter={() => setSelectedIndex(idx)}
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
isSelected
? "bg-[var(--bg-elevated)] text-[var(--text-primary)]"
: "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)]"
}`}
>
<div className="flex items-center gap-2.5">
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
<Icon className="w-3.5 h-3.5" />
</div>
<div>
<div className="text-xs font-semibold text-[var(--text-primary)]">
{action.title}
</div>
<div className="text-[11px] text-[var(--text-muted)]">
{action.subtitle}
</div>
</div>
</div>
<ArrowRight className={`w-3.5 h-3.5 text-[var(--text-dim)] ${isSelected ? "text-[var(--text-primary)]" : ""}`} />
</div>
);
})}
</div>
) : results.length === 0 && !loading ? (
<div className="px-4 py-10 text-center text-xs text-[var(--text-muted)] space-y-1">
<p>No knowledge base items matched &quot;{query}&quot;</p>
</div>
) : (
<ul className="space-y-1 p-1">
{results.map((hit, index) => {
const isSelected = index === selectedIndex;
return (
<li
key={hit.slug}
onClick={() => handleNavigate(`/${hit.slug}`)}
onMouseEnter={() => setSelectedIndex(index)}
className={`flex items-start justify-between gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${
isSelected
? "bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]"
: "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)] border border-transparent"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] font-mono px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] uppercase font-semibold">
{hit.section}
</span>
<span className={`text-xs sm:text-sm font-semibold ${isSelected ? "text-[var(--text-primary)]" : "text-[var(--text-primary)]"} truncate`}>
{hit.title}
</span>
</div>
{hit.snippet && (
<p
className="text-xs text-[var(--text-muted)] line-clamp-1 mt-1 [&>mark]:bg-[var(--bg-elevated-hover)] [&>mark]:text-[var(--text-primary)] [&>mark]:font-semibold"
dangerouslySetInnerHTML={{ __html: hit.snippet }}
/>
)}
</div>
<span className="text-[10px] font-mono text-[var(--text-dim)] shrink-0 self-center">
/{hit.slug}
</span>
</li>
);
})}
</ul>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-[10px] text-[var(--text-muted)] font-mono">
<div className="flex items-center gap-2">
<span>[ ↑↓ NAVIGATE ]</span>
<span>[ ↵ SELECT ]</span>
</div>
<Link
href={`/search?q=${encodeURIComponent(query)}`}
onClick={() => setOpen(false)}
className="hover:text-[var(--text-primary)] font-medium flex items-center gap-1"
>
<span>Full Search Console</span>
<ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
Copy,
Check,
Share2,
FileDown,
FileText,
Edit3,
} from "lucide-react";
interface DocActionsProps {
slug: string;
body: string;
canEdit: boolean;
}
export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
const [copiedMd, setCopiedMd] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
function handleCopyMarkdown() {
navigator.clipboard.writeText(body);
setCopiedMd(true);
setTimeout(() => setCopiedMd(false), 2000);
}
function handleCopyLink() {
navigator.clipboard.writeText(window.location.href);
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
}
return (
<div className="flex items-center gap-1.5 shrink-0 flex-wrap">
{/* Copy Raw Markdown */}
<button
onClick={handleCopyMarkdown}
type="button"
title="Copy raw markdown content"
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
>
{copiedMd ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500 font-medium">Copied</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Raw MD</span>
</>
)}
</button>
{/* Copy Share Link */}
<button
onClick={handleCopyLink}
type="button"
title="Copy link to document"
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
>
{copiedLink ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500 font-medium">Copied</span>
</>
) : (
<>
<Share2 className="w-3.5 h-3.5" />
<span>Share</span>
</>
)}
</button>
{/* Export PDF */}
<Link
href={`/${slug}/export`}
title="Export document to publication-grade PDF"
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs"
>
<FileDown className="w-3.5 h-3.5" />
<span>PDF</span>
</Link>
{/* Export Word (.docx) */}
<a
href={`/api/export?path=${encodeURIComponent(slug)}&format=docx`}
title="Download editable Microsoft Word (.docx) file"
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
>
<FileText className="w-3.5 h-3.5" />
<span>Word (.docx)</span>
</a>
{/* Edit Link */}
<Link
href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`}
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md transition-colors font-medium shadow-xs"
>
<Edit3 className="w-3.5 h-3.5" />
<span>Edit</span>
</Link>
</div>
);
}
+471 -68
View File
@@ -1,75 +1,212 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Markdown from "./Markdown";
import {
Bold,
Italic,
Heading2,
Heading3,
Code,
Link as LinkIcon,
Plus,
Trash2,
Eye,
Edit3,
Sliders,
} from "lucide-react";
interface DocFormProps { interface DocFormProps {
mode: "create" | "edit"; mode: "create" | "edit";
slug?: string; slug?: string;
secret: string; secret: string;
existingFolders?: Record<string, string[]>;
existingSections?: string[];
initial?: { initial?: {
title?: string; title?: string;
body?: string; body?: string;
section?: "docs" | "writeups" | "research" | "notes"; section?: string;
type?: "documentation" | "writeup" | "research" | "note"; type?: string;
status?: "published" | "draft"; status?: "published" | "draft" | string;
tags?: string[]; tags?: string[];
author?: string; author?: string;
extraFields?: Record<string, unknown>;
}; };
} }
const SECTION_OPTIONS = ["docs", "writeups", "research", "notes"] as const; const DEFAULT_SECTION_LIST = [
const TYPE_OPTIONS = ["documentation", "writeup", "research", "note"] as const; "docs",
"writeups",
"research",
"notes",
"guides",
"tutorials",
"ctf",
"api",
"projects",
];
const DEFAULT_TYPE_LIST = [
"documentation",
"writeup",
"research",
"note",
"guide",
"tutorial",
"spec",
];
export default function DocForm({ mode, slug, secret, initial }: DocFormProps) { const PRESET_FIELDS = [
{ key: "difficulty", value: "medium" },
{ key: "points", value: "100" },
{ key: "category", value: "pwn" },
{ key: "event", value: "DEF CON Quals 2024" },
{ key: "solved", value: "true" },
];
export default function DocForm({
mode,
slug,
secret,
existingFolders,
existingSections,
initial,
}: DocFormProps) {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"write" | "preview">("write");
const [title, setTitle] = useState(initial?.title ?? ""); const [title, setTitle] = useState(initial?.title ?? "");
const [slugInput, setSlugInput] = useState("");
const [body, setBody] = useState(initial?.body ?? ""); const [body, setBody] = useState(initial?.body ?? "");
const [section, setSection] = useState(initial?.section ?? "docs"); const [section, setSection] = useState(initial?.section ?? "docs");
const [type, setType] = useState(initial?.type ?? "documentation"); const [type, setType] = useState(initial?.type ?? "documentation");
const [status, setStatus] = useState(initial?.status ?? "published"); const [status, setStatus] = useState(initial?.status ?? "published");
const [tags, setTags] = useState(initial?.tags?.join(", ") ?? ""); const [tags, setTags] = useState(initial?.tags?.join(", ") ?? "");
const [author, setAuthor] = useState(initial?.author ?? ""); const [author, setAuthor] = useState(initial?.author ?? "");
const [parentFolder, setParentFolder] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sectionOptions = Array.from(
new Set([...(existingSections ?? []), ...DEFAULT_SECTION_LIST, section].filter(Boolean)),
);
// Extract existing custom fields
const [customFields, setCustomFields] = useState<Array<{ key: string; value: string }>>(() => {
if (initial?.extraFields) {
return Object.entries(initial.extraFields)
.filter(([k, v]) => k !== "slug" && v !== undefined && v !== null)
.map(([k, v]) => ({ key: k, value: typeof v === "string" ? v : JSON.stringify(v) }))
.filter((f) => f.value);
}
return [];
});
const tagList = tags const tagList = tags
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean); .filter(Boolean);
const isEdit = mode === "edit";
const effectiveSlug = isEdit
? (slug ?? "")
: parentFolder
? `${parentFolder}/${slugInput}`.replace(/^\/+/, "").replace(/\/+/g, "/")
: slugInput;
const availableFolders = existingFolders?.[section] ?? [];
const baseInputCls = const baseInputCls =
"w-full px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"; "w-full px-3.5 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-colors";
function addCustomField(key = "", value = "") {
setCustomFields([...customFields, { key, value }]);
}
function updateCustomField(index: number, field: "key" | "value", value: string) {
const updated = [...customFields];
updated[index][field] = value;
setCustomFields(updated);
}
function removeCustomField(index: number) {
setCustomFields(customFields.filter((_, i) => i !== index));
}
function insertMarkdown(prefix: string, suffix = "") {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selection = body.substring(start, end);
const replacement = `${prefix}${selection || "text"}${suffix}`;
const newBody = body.substring(0, start) + replacement + body.substring(end);
setBody(newBody);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + prefix.length, start + replacement.length - suffix.length);
}, 10);
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(null); setError(null);
const payload = { title, body, section, type, status, author, tags: tagList }; if (!effectiveSlug || !title || !body) {
setError("Slug, title, and markdown body are required.");
setLoading(false);
return;
}
const payload: Record<string, unknown> = {
title,
body,
slug: effectiveSlug,
section,
type,
status,
author,
tags: tagList,
};
for (const field of customFields) {
if (field.key.trim() && field.value.trim()) {
payload[field.key.trim()] = field.value.trim();
}
}
try { try {
let res: Response; let res: Response;
if (mode === "create") { if (mode === "create") {
const slugVal = slug ?? title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
res = await fetch("/api/docs", { res = await fetch("/api/docs", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", "x-webhook-secret": secret }, headers: {
body: JSON.stringify({ slug: slugVal, ...payload }), "Content-Type": "application/json",
"x-webhook-secret": secret,
},
body: JSON.stringify(payload),
}); });
} else { } else {
const editSlug = slug ?? ""; const editSlug = slug ?? "";
res = await fetch(`/api/docs/${editSlug}`, { res = await fetch(`/api/docs/${editSlug}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json", "x-webhook-secret": secret }, headers: {
"Content-Type": "application/json",
"x-webhook-secret": secret,
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Save failed"); throw new Error(data.error ?? "Failed to save document. Verify ADMIN_PASSWORD or secret.");
} }
const result = await res.json(); const result = await res.json();
@@ -84,86 +221,352 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
return ( return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl"> <form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
{error && ( {error && (
<div className="p-3 rounded border border-[#274360] text-[#fca5a5] text-sm"> <div className="p-3.5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-xs font-medium animate-fade-in">
{error} {error}
</div> </div>
)} )}
{/* Document Title */}
<div> <div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Title</label> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} className={baseInputCls} required /> Document Title *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Exploiting Stack Alignment Issues in x86_64"
className={`${baseInputCls} text-base font-semibold`}
required
/>
</div>
{/* Section, Type, Status */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Section
</label>
<input
type="text"
list="section-list"
value={section}
onChange={(e) => setSection(e.target.value)}
placeholder="e.g. docs, writeups, research"
className={baseInputCls}
required
/>
<datalist id="section-list">
{sectionOptions.map((s) => (
<option key={s} value={s}>
{s.toUpperCase()}
</option>
))}
</datalist>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Slug</label> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Document Type
</label>
<input
type="text"
list="type-list"
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="e.g. writeup, documentation, note"
className={baseInputCls}
/>
<datalist id="type-list">
{DEFAULT_TYPE_LIST.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</datalist>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as typeof status)}
className={baseInputCls}
>
<option value="published">Published</option>
<option value="draft">Draft (Unindexed)</option>
</select>
</div>
</div>
{/* Folder + Slug */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Parent Folder / Chapter
</label>
<select
value={parentFolder}
onChange={(e) => setParentFolder(e.target.value)}
className={baseInputCls}
disabled={isEdit}
>
<option value="">(Root of section)</option>
{availableFolders.map((folder) => (
<option key={folder} value={folder}>
/{folder}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
{isEdit ? "Document Path" : "Slug (File name) *"}
</label>
{isEdit ? (
<input <input
type="text" type="text"
value={slug ?? ""} value={slug ?? ""}
readOnly readOnly
className="w-full px-3 py-2 bg-[#191a1b] border border-[#23252a] rounded text-[#8a8f98]" className="w-full px-3.5 py-2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded-lg text-sm text-[var(--text-muted)] font-mono"
placeholder={section}
/> />
<p className="text-xs text-[#62666d] mt-1">URL-safe path under the section.</p> ) : (
</div> <input
type="text"
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> value={slugInput}
<div> onChange={(e) => setSlugInput(e.target.value)}
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Section</label> className={`${baseInputCls} font-mono`}
<select value={section} onChange={(e) => setSection(e.target.value as typeof section)} className={baseInputCls}> placeholder="ret2win-guide"
{SECTION_OPTIONS.map((s) => ( required
<option key={s} value={s}>{s}</option> />
))} )}
</select> <p className="text-[11px] text-[var(--text-dim)] mt-1.5 font-mono">
</div> Full Slug: <span className="text-[var(--text-primary)] font-semibold">{effectiveSlug || section}</span>
<div> </p>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Type</label>
<select value={type} onChange={(e) => setType(e.target.value as typeof type)} className={baseInputCls}>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Status</label>
<select value={status} onChange={(e) => setStatus(e.target.value as typeof status)} className={baseInputCls}>
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
</div> </div>
</div> </div>
{/* Tags + Author */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Tags</label> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
<input type="text" value={tags} onChange={(e) => setTags(e.target.value)} className={baseInputCls} placeholder="comma, separated, tags" /> Tags (comma separated)
</div> </label>
<input
<div> type="text"
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Author</label> value={tags}
<input type="text" value={author} onChange={(e) => setAuthor(e.target.value)} className={baseInputCls} /> onChange={(e) => setTags(e.target.value)}
</div> className={baseInputCls}
placeholder="pwn, x86_64, rop, ctf"
<div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Body (Markdown)</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full h-96 px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#d0d6e0] placeholder-[#62666d] font-mono text-sm focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"
placeholder="# Heading&#10;&#10;Content here..."
/> />
</div> </div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Author
</label>
<input
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
className={baseInputCls}
placeholder="e.g. asepharyana"
/>
</div>
</div>
<div className="flex gap-3"> {/* Custom Metadata Fields */}
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3.5 shadow-xs">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono flex items-center gap-1.5">
<Sliders className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<span>Technical Metadata</span>
</h3>
<p className="text-[11px] text-[var(--text-dim)] mt-0.5">
Custom fields (points, difficulty, event, solved) will render as styled badges in the document hero.
</p>
</div>
<div className="flex items-center gap-1 flex-wrap">
<span className="text-[10px] font-mono text-[var(--text-dim)] uppercase">Presets:</span>
{PRESET_FIELDS.map((preset) => (
<button <button
type="submit" key={preset.key}
disabled={loading} type="button"
className="px-4 py-2 bg-[#5e6ad2] text-white rounded hover:bg-[#7170ff] disabled:opacity-50 font-medium text-sm" onClick={() => addCustomField(preset.key, preset.value)}
className="px-2 py-0.5 text-[10px] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded transition-colors font-mono cursor-pointer"
> >
{loading ? "Saving..." : mode === "create" ? "Create" : "Update"} +{preset.key}
</button>
))}
</div>
</div>
{customFields.map((field, i) => (
<div key={i} className="flex gap-2 items-center">
<input
type="text"
placeholder="Field name (e.g. event)"
value={field.key}
onChange={(e) => updateCustomField(i, "key", e.target.value)}
className={`${baseInputCls} text-xs font-mono`}
/>
<input
type="text"
placeholder="Value (e.g. DEF CON 2024)"
value={field.value}
onChange={(e) => updateCustomField(i, "value", e.target.value)}
className={`${baseInputCls} text-xs font-mono`}
/>
<button
type="button"
onClick={() => removeCustomField(i)}
className="p-2 text-rose-500 hover:text-rose-600 rounded hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
title="Remove field"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<button
type="button"
onClick={() => addCustomField()}
className="inline-flex items-center gap-1.5 text-xs text-[var(--text-primary)] hover:underline px-3 py-1 rounded-md border border-[var(--border-color)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] transition-colors font-medium cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Add Custom Field</span>
</button>
</div>
{/* Markdown Body Editor */}
<div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-xs">
{/* Editor Toolbar */}
<div className="flex items-center justify-between px-3 py-2 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
{/* Tabs */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setActiveTab("write")}
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded font-semibold transition-all cursor-pointer ${
activeTab === "write"
? "bg-[var(--brand)] text-[var(--brand-fg)] shadow-xs"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
}`}
>
<Edit3 className="w-3.5 h-3.5" />
<span>Write</span>
</button> </button>
<button
type="button"
onClick={() => setActiveTab("preview")}
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded font-semibold transition-all cursor-pointer ${
activeTab === "preview"
? "bg-[var(--brand)] text-[var(--brand-fg)] shadow-xs"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
}`}
>
<Eye className="w-3.5 h-3.5" />
<span>Preview</span>
</button>
</div>
{/* Quick Markdown Formatter Shortcuts */}
{activeTab === "write" && (
<div className="flex items-center gap-1 text-xs text-[var(--text-muted)]">
<button
type="button"
onClick={() => insertMarkdown("**", "**")}
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Bold"
>
<Bold className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("*", "*")}
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Italic"
>
<Italic className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("## ")}
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Heading 2"
>
<Heading2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("### ")}
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Heading 3"
>
<Heading3 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("```\n", "\n```")}
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Code Block"
>
<Code className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("[", "](https://)")}
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Link"
>
<LinkIcon className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* Tab Content */}
{activeTab === "write" ? (
<textarea
ref={textareaRef}
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full h-96 p-4 bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] font-mono text-sm focus:outline-none resize-y leading-relaxed"
placeholder="# Write your technical documentation or writeup here in Markdown..."
required
/>
) : (
<div className="p-6 h-96 overflow-y-auto bg-[var(--bg-app)]">
{body.trim() ? (
<Markdown source={body} />
) : (
<p className="text-xs text-[var(--text-dim)] italic font-mono">No content to preview yet.</p>
)}
</div>
)}
</div>
{/* Submit Action Buttons */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-lg font-semibold text-xs transition-all shadow-xs disabled:opacity-50 flex items-center gap-2 cursor-pointer font-mono uppercase tracking-wider"
>
{loading && (
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
)}
<span>{loading ? "PERSISTING..." : mode === "create" ? "PUBLISH_DOCUMENT" : "SAVE_CHANGES"}</span>
</button>
<button <button
type="button" type="button"
onClick={() => router.back()} onClick={() => router.back()}
className="px-4 py-2 border border-[#23252a] rounded text-[#d0d6e0] hover:text-[#f7f8f8] hover:border-[#3e3e44] transition-colors text-sm" className="px-4 py-2.5 border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] rounded-lg text-xs transition-colors shadow-xs cursor-pointer font-mono"
> >
Cancel Cancel
</button> </button>
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
import ThemeToggle from "@/components/ThemeToggle";
import CommandMenu from "@/components/CommandMenu";
import {
Plus,
Search,
Menu,
X,
Lock,
} from "lucide-react";
export default function Header() {
const pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [sections, setSections] = useState<SectionConfig[]>(DEFAULT_SECTIONS);
useEffect(() => {
fetch("/api/sections")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data) && data.length > 0) {
setSections(data);
}
})
.catch(() => {});
}, []);
return (
<header className="site-header no-print sticky top-0 z-40 border-b border-[var(--border-color)] glass-nav transition-all">
<div className="mx-auto max-w-7xl px-4 sm:px-6 h-14 flex items-center justify-between gap-3 sm:gap-4">
{/* Brand */}
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2.5 group">
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-[var(--brand)] text-[var(--brand-fg)] font-mono font-bold text-xs shadow-xs group-hover:opacity-90 transition-opacity">
M
</div>
<div className="flex items-center gap-2">
<span className="font-bold text-[var(--text-primary)] text-sm tracking-tight leading-none">
MCPedia
</span>
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hidden sm:inline-block">
CORE::DB
</span>
</div>
</Link>
</div>
{/* Center: Desktop Sections Nav */}
<nav className="hidden md:flex items-center gap-1">
{sections.map((s) => {
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
return (
<Link
key={s.id}
href={`/${s.id}`}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1.5 ${
isActive
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] border border-[var(--border-color)] font-semibold"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`}
>
<span>{s.icon}</span>
<span>{s.label}</span>
</Link>
);
})}
</nav>
{/* Right Tools & Navigation Deck */}
<div className="flex items-center gap-2">
<CommandMenu />
<Link
href="/search"
className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md bg-[var(--bg-surface)] border border-[var(--border-color)] transition-colors"
title="Search knowledge base"
>
<Search className="w-4 h-4" />
</Link>
<Link
href="/create"
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] text-xs font-semibold rounded-md shadow-xs transition-all cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>New Doc</span>
</Link>
<Link
href="/login"
className={`p-1.5 sm:px-2.5 sm:py-1.5 text-xs rounded-md transition-colors flex items-center gap-1.5 ${
pathname === "/login"
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)]"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`}
title="Admin Login"
>
<Lock className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Admin</span>
</Link>
<div className="border-l border-[var(--border-color)] pl-2 flex items-center">
<ThemeToggle />
</div>
{/* Mobile menu toggle */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md hover:bg-[var(--bg-elevated)] transition-colors"
aria-label="Toggle navigation"
>
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
{/* Mobile Drawer */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-surface)] px-4 py-4 space-y-2 animate-fade-in shadow-lg">
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)] px-1 mb-1">
Knowledge Sections
</div>
<div className="grid grid-cols-2 gap-1.5">
{sections.map((s) => {
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
return (
<Link
key={s.id}
href={`/${s.id}`}
onClick={() => setMobileMenuOpen(false)}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-xs font-medium transition-colors ${
isActive
? "bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)] font-semibold"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
}`}
>
<span>{s.icon}</span>
<span className="truncate">{s.label}</span>
</Link>
);
})}
</div>
<div className="pt-3 border-t border-[var(--border-color)] flex gap-2">
<Link
href="/search"
onClick={() => setMobileMenuOpen(false)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs bg-[var(--bg-elevated)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md font-medium border border-[var(--border-color)]"
>
<Search className="w-3.5 h-3.5" />
<span>Search</span>
</Link>
<Link
href="/create"
onClick={() => setMobileMenuOpen(false)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs bg-[var(--brand)] text-[var(--brand-fg)] rounded-md font-semibold shadow-xs"
>
<Plus className="w-3.5 h-3.5" />
<span>Create Doc</span>
</Link>
</div>
</div>
)}
</header>
);
}
@@ -0,0 +1,376 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
Cpu,
Database,
Layers,
Sparkles,
Search,
Globe,
Server,
ArrowRight,
CheckCircle2,
} from "lucide-react";
interface NodeDetail {
id: string;
title: string;
badge: string;
type: "client" | "protocol" | "core" | "storage";
icon: any;
desc: string;
metrics: { label: string; value: string }[];
features: string[];
docLink?: string;
}
const NODES: NodeDetail[] = [
{
id: "ai-agents",
title: "AI Coding Agents",
badge: "MCP::CLIENT",
type: "client",
icon: Sparkles,
desc: "Claude Desktop, Cursor, Antigravity, and Zed connect via Model Context Protocol to read and write technical knowledge directly.",
metrics: [
{ label: "Transport", value: "Stdio / HTTP SSE" },
{ label: "Tools Exposed", value: "13 Native Tools" },
{ label: "Authentication", value: "x-webhook-secret" },
],
features: [
"Semantic similarity search over chunks",
"Direct document CRUD into Postgres",
"Context retrieval for debugging & CTF writeups",
],
docLink: "/docs",
},
{
id: "web-ui",
title: "Human Web Interface",
badge: "UI::NEXTJS",
type: "client",
icon: Globe,
desc: "Fast, accessible developer web interface with dark/light themes, keyboard shortcuts, and instant search.",
metrics: [
{ label: "Framework", value: "Next.js 16.3" },
{ label: "Styling", value: "Tailwind CSS v4" },
{ label: "Command Menu", value: "⌘K Omnisearch" },
],
features: [
"Hierarchical folder directory tree",
"Publication-grade PDF export generator",
"Live markdown editor with revision rollback",
],
docLink: "/docs",
},
{
id: "mcp-server",
title: "MCP Server Gateway",
badge: "PORT::4021",
type: "protocol",
icon: Server,
desc: "Streamable Model Context Protocol server exposing tool endpoints and resource URIs.",
metrics: [
{ label: "Port", value: ":4021" },
{ label: "Protocol", value: "MCP 2024-11-05" },
{ label: "Runtime", value: "Bun Engine" },
],
features: [
"get_document / create_document tools",
"search_hybrid / search_semantic tools",
"list_sections & list_revisions resources",
],
docLink: "/api",
},
{
id: "core-engine",
title: "@mcpedia/core",
badge: "CORE::PACKAGE",
type: "core",
icon: Layers,
desc: "Unified business logic layer shared across Web, MCP Server, REST API, and Background Workers.",
metrics: [
{ label: "Architecture", value: "Monorepo Package" },
{ label: "Validation", value: "Strict Type Safety" },
{ label: "Ranking", value: "RRF Hybrid Fusion" },
],
features: [
"Path classification & folder hierarchy",
"Markdown document chunking & tokenization",
"Drizzle ORM transactional schema mapping",
],
docLink: "/research",
},
{
id: "search-engine",
title: "Multi-Modal Search Engine",
badge: "SEARCH::RRF",
type: "core",
icon: Search,
desc: "Reciprocal Rank Fusion uniting Postgres Full-Text Search tsvectors with vector embeddings.",
metrics: [
{ label: "FTS Algorithm", value: "tsvector + GIN" },
{ label: "Vector Search", value: "Cosine Similarity" },
{ label: "Fusion Constant", value: "k = 60" },
],
features: [
"Sub-millisecond keyword retrieval",
"Conceptual similarity matching",
"Instant snippet generation with highlight markers",
],
docLink: "/search",
},
{
id: "postgres-db",
title: "PostgreSQL 16 + pgvector",
badge: "DB::POSTGRES",
type: "storage",
icon: Database,
desc: "ACID-compliant relational database storing document bodies, revision trees, vector embeddings, and full-text indexes.",
metrics: [
{ label: "Extension", value: "vector (1536 dim)" },
{ label: "Backups", value: "Auto .md to disk" },
{ label: "Isolation", value: "Transactional" },
],
features: [
"HNSW vector index for embeddings",
"Complete revision history preservation",
"Instant document schema migrations",
],
docLink: "/docs",
},
];
export default function InteractiveTopology() {
const [activeNodeId, setActiveNodeId] = useState<string>("core-engine");
const activeNode = NODES.find((n) => n.id === activeNodeId) || NODES[0];
const ActiveIcon = activeNode.icon;
return (
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-xs">
{/* Header Bar */}
<div className="p-4 sm:p-5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
<span className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">
Architecture Schematic
</span>
</div>
<h3 className="text-base sm:text-lg font-bold text-[var(--text-primary)] tracking-tight">
System Topology: Single Core Architecture
</h3>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-2 py-1 rounded">
Click nodes to inspect
</span>
</div>
</div>
{/* Grid Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12">
{/* Visual Map Column (Left) */}
<div className="lg:col-span-7 p-5 border-b lg:border-b-0 lg:border-r border-[var(--border-color)] bg-[var(--bg-app)] flex flex-col justify-between space-y-5">
{/* Layer 1: Clients */}
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
1. Ingestion & Access Layer
</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{NODES.filter((n) => n.type === "client").map((node) => {
const Icon = node.icon;
const isSelected = activeNodeId === node.id;
return (
<button
key={node.id}
onClick={() => setActiveNodeId(node.id)}
type="button"
className={`p-3 rounded-lg border text-left transition-all cursor-pointer ${
isSelected
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
<Icon className="w-3.5 h-3.5" />
</div>
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-color)]">
{node.badge}
</span>
</div>
<div className="font-semibold text-xs text-[var(--text-primary)]">{node.title}</div>
</button>
);
})}
</div>
</div>
{/* Flow Connector Arrow */}
<div className="flex justify-center text-[var(--text-dim)]">
<span className="text-[10px] font-mono px-2.5 py-0.5 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">
↓ Protocol Gateway
</span>
</div>
{/* Layer 2: Protocol & Core Services */}
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
2. Core Engine & Protocol Layer
</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
{NODES.filter((n) => n.type === "protocol" || n.type === "core").map((node) => {
const Icon = node.icon;
const isSelected = activeNodeId === node.id;
return (
<button
key={node.id}
onClick={() => setActiveNodeId(node.id)}
type="button"
className={`p-3 rounded-lg border text-left transition-all cursor-pointer ${
isSelected
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
<Icon className="w-3.5 h-3.5" />
</div>
</div>
<div className="font-semibold text-xs text-[var(--text-primary)] leading-tight mb-0.5 truncate">
{node.title}
</div>
<div className="text-[10px] text-[var(--text-muted)] font-mono truncate">
{node.badge}
</div>
</button>
);
})}
</div>
</div>
{/* Flow Connector Arrow */}
<div className="flex justify-center text-[var(--text-dim)]">
<span className="text-[10px] font-mono px-2.5 py-0.5 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">
↓ Persistent Storage Layer
</span>
</div>
{/* Layer 3: Storage */}
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
3. Database (Source of Truth)
</span>
{NODES.filter((n) => n.type === "storage").map((node) => {
const Icon = node.icon;
const isSelected = activeNodeId === node.id;
return (
<button
key={node.id}
onClick={() => setActiveNodeId(node.id)}
type="button"
className={`w-full p-3 rounded-lg border text-left transition-all cursor-pointer ${
isSelected
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
<Icon className="w-4 h-4" />
</div>
<div>
<div className="font-semibold text-xs text-[var(--text-primary)]">{node.title}</div>
<div className="text-[11px] text-[var(--text-muted)]">Source of truth for all content & embeddings</div>
</div>
</div>
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-color)]">
{node.badge}
</span>
</div>
</button>
);
})}
</div>
</div>
{/* Node Inspector Details Column (Right) */}
<div className="lg:col-span-5 p-5 bg-[var(--bg-surface)] flex flex-col justify-between space-y-5 animate-fade-in">
<div>
{/* Inspector Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] flex items-center justify-center">
<ActiveIcon className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-bold text-[var(--text-primary)]">{activeNode.title}</h4>
<span className="text-[10px] font-mono text-[var(--text-muted)]">
{activeNode.badge}
</span>
</div>
</div>
</div>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed mb-5">
{activeNode.desc}
</p>
{/* Telemetry Metrics */}
<div className="mb-5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-2 block">
Specifications
</span>
<div className="space-y-1.5">
{activeNode.metrics.map((m) => (
<div
key={m.label}
className="flex items-center justify-between p-2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-xs"
>
<span className="text-[var(--text-muted)] text-[11px]">{m.label}</span>
<span className="font-mono font-medium text-[var(--text-primary)] text-[11px]">{m.value}</span>
</div>
))}
</div>
</div>
{/* Key Capabilities */}
<div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-2 block">
Capabilities
</span>
<ul className="space-y-1.5">
{activeNode.features.map((feat, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-[var(--text-secondary)]">
<span className="text-emerald-500 shrink-0 font-mono text-[11px]">✓</span>
<span>{feat}</span>
</li>
))}
</ul>
</div>
</div>
{/* Link to Documentation */}
{activeNode.docLink && (
<div className="pt-3 border-t border-[var(--border-color)]">
<Link
href={activeNode.docLink}
className="inline-flex items-center justify-center gap-1.5 w-full py-2 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-xs font-semibold text-[var(--text-primary)] rounded-lg transition-colors group"
>
<span>Read documentation</span>
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
)}
</div>
</div>
</div>
);
}
+180 -10
View File
@@ -1,20 +1,190 @@
"use client";
import React, { useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug"; import rehypeSlug from "rehype-slug";
import rehypeHighlight from "rehype-highlight";
import {
Copy,
Check,
Info,
Lightbulb,
AlertTriangle,
Flame,
ShieldAlert,
} from "lucide-react";
function extractText(node: React.ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") {
return "";
}
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(extractText).join("");
}
if (React.isValidElement(node)) {
return extractText((node.props as { children?: React.ReactNode })?.children);
}
return "";
}
function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) {
const [copied, setCopied] = useState(false);
const textContent = extractText(children).replace(/\n$/, "");
let language = "text";
if (React.isValidElement(children)) {
const className = (children.props as { className?: string })?.className || "";
const match = /language-(\w+)/.exec(className);
if (match) {
language = match[1];
}
}
function handleCopy() {
navigator.clipboard.writeText(textContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="code-block-wrapper relative group my-6 print:my-2 rounded-xl print:rounded border border-[var(--border-code)] print:border-slate-300 bg-[var(--code-block-bg)] print:bg-slate-50 shadow-sm print:shadow-none">
{/* Code block header bar */}
<div className="code-block-header flex items-center justify-between px-4 py-2.5 print:px-2.5 print:py-0.5 bg-[var(--code-header-bg)] print:bg-slate-100 border-b border-[var(--border-code)] print:border-slate-300 text-xs font-mono select-none">
<div className="flex items-center gap-2.5">
<div className="no-print flex items-center gap-1.5 opacity-75 group-hover:opacity-100 transition-opacity">
<span className="w-2.5 h-2.5 rounded-full bg-rose-500/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-amber-500/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500/80 inline-block" />
</div>
<span className="text-[11px] print:text-[7pt] font-semibold uppercase tracking-wider text-[var(--text-muted)] print:text-slate-700 group-hover:text-[var(--text-primary)] transition-colors">
{language}
</span>
</div>
<button
onClick={handleCopy}
type="button"
aria-label="Copy code to clipboard"
className="no-print flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--code-button-bg)] hover:bg-[var(--code-button-hover-bg)] px-2.5 py-1 rounded-md border border-[var(--border-code)] transition-all font-sans shadow-xs cursor-pointer"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500 font-medium">Copied!</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy</span>
</>
)}
</button>
</div>
<pre className="!m-0 !bg-transparent p-4 sm:p-5 print:p-2 overflow-x-auto text-[13px] sm:text-[13.5px] print:text-[7.5pt] text-[var(--pre-text)] print:text-slate-900 font-mono leading-relaxed print:leading-snug" {...props}>
{children}
</pre>
</div>
);
}
export default function Markdown({ source }: { source: string }) { export default function Markdown({ source }: { source: string }) {
return ( return (
<div <div
className="prose prose-lg max-w-none className="prose max-w-none
prose-headings:text-[#f7f8f8] prose-headings:font-light prose-headings:mb-4 prose-headings:text-[var(--text-primary)] prose-headings:font-bold prose-headings:tracking-tight
prose-p:text-[#d0d6e0] prose-p:mb-4 prose-h1:text-2xl sm:prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-[var(--border-color)] print:prose-h1:text-[13pt] print:prose-h1:mt-2 print:prose-h1:mb-1 print:prose-h1:pb-0.5
prose-a:text-[#7170ff] prose-a:no-underline hover:prose-a:text-[#828fff] prose-h2:text-xl sm:prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-[var(--border-subtle)] print:prose-h2:text-[10.5pt] print:prose-h2:mt-2 print:prose-h2:mb-1 print:prose-h2:pb-0.5
prose-code:text-[#d0d6e0] prose-code:bg-[#191a1b] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none prose-h3:text-lg prose-h3:mb-3 prose-h3:mt-6 print:prose-h3:text-[9.5pt] print:prose-h3:mt-1.5 print:prose-h3:mb-0.5
prose-pre:bg-[#191a1b] prose-pre:border prose-pre:border-[#23252a] prose-pre:rounded-lg prose-p:text-[var(--text-secondary)] prose-p:leading-relaxed prose-p:mb-4 print:prose-p:text-[8.5pt] print:prose-p:leading-snug print:prose-p:mb-1.5
prose-pre code:bg-transparent prose-pre code:p-0 prose-pre code:text-sm prose-a:text-[var(--accent)] prose-a:font-medium prose-a:no-underline hover:prose-a:underline hover:prose-a:text-[var(--accent-hover)]
prose-blockquote:border-l-[#23252a] prose-blockquote:text-[#a0a4a8] prose-ul:text-[var(--text-secondary)] prose-ul:my-4 prose-li:my-1.5 print:prose-ul:my-1 print:prose-li:my-0.5
prose-ul:text-[#d0d6e0] prose-ol:text-[#d0d6e0]" prose-ol:text-[var(--text-secondary)] prose-ol:my-4 print:prose-ol:my-1
prose-strong:text-[var(--text-primary)] prose-strong:font-semibold
prose-hr:border-[var(--border-color)] prose-hr:my-8 print:prose-hr:my-2"
> >
<ReactMarkdown rehypePlugins={[rehypeSlug]}>{source}</ReactMarkdown> <ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
components={{
pre: PreBlock as any,
code: ({ children, className, ...props }: React.HTMLAttributes<HTMLElement>) => {
if (className?.includes("hljs") || className?.includes("language-")) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
return (
<code
className="px-1.5 py-0.5 rounded-md print:rounded-none bg-[var(--code-inline-bg)] print:bg-slate-100 text-[var(--code-inline-text)] print:text-slate-900 border border-[var(--code-inline-border)] print:border-slate-200 font-mono text-[0.875em] print:text-[7.5pt] font-medium"
{...props}
>
{children}
</code>
);
},
table: ({ node, ...props }) => (
<div className="overflow-x-auto my-6 print:my-1.5 border border-[var(--border-color)] print:border-slate-300 rounded-xl print:rounded-none shadow-xs print:shadow-none">
<table className="w-full border-collapse" {...props} />
</div>
),
blockquote: ({ children }) => {
const rawText = extractText(children).trim();
const alertMatch = rawText.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i);
if (alertMatch) {
const alertType = alertMatch[1].toUpperCase();
let borderClass = "border-indigo-500 bg-indigo-500/10 text-indigo-900 dark:text-indigo-200 print:bg-indigo-50 print:text-indigo-950";
let Icon = Info;
let title = "Note";
if (alertType === "TIP") {
borderClass = "border-emerald-500 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200 print:bg-emerald-50 print:text-emerald-950";
Icon = Lightbulb;
title = "Tip";
} else if (alertType === "IMPORTANT") {
borderClass = "border-violet-500 bg-violet-500/10 text-violet-900 dark:text-violet-200 print:bg-violet-50 print:text-violet-950";
Icon = Flame;
title = "Important";
} else if (alertType === "WARNING") {
borderClass = "border-amber-500 bg-amber-500/10 text-amber-900 dark:text-amber-200 print:bg-amber-50 print:text-amber-950";
Icon = AlertTriangle;
title = "Warning";
} else if (alertType === "CAUTION") {
borderClass = "border-rose-500 bg-rose-500/10 text-rose-900 dark:text-rose-200 print:bg-rose-50 print:text-rose-950";
Icon = ShieldAlert;
title = "Caution";
}
return (
<div className={`my-5 print:my-1.5 p-4 print:p-2 rounded-xl print:rounded border-l-4 ${borderClass} shadow-xs print:shadow-none`}>
<div className="flex items-center gap-2 font-bold text-xs print:text-[7.5pt] uppercase tracking-wider mb-1.5 print:mb-0.5">
<Icon className="w-4 h-4 print:w-3 print:h-3" />
<span>{title}</span>
</div>
<div className="text-xs sm:text-sm print:text-[8pt] text-[var(--text-secondary)] print:text-slate-900 [&>p]:mb-1 [&>p:last-child]:mb-0 print:[&>p]:mb-0.5">
{children}
</div>
</div>
);
}
return (
<blockquote className="border-l-4 border-[var(--brand)] print:border-slate-500 bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 print:bg-slate-50 pl-4 print:pl-2.5 py-2.5 print:py-1 my-5 print:my-1.5 rounded-r-xl print:rounded-none text-[var(--text-secondary)] print:text-slate-900 not-italic print:text-[8pt]">
{children}
</blockquote>
);
},
}}
>
{source}
</ReactMarkdown>
</div> </div>
); );
} }
@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import Markdown from "@/components/Markdown";
import {
Sparkles,
Terminal,
Server,
Copy,
Check,
Laptop,
} from "lucide-react";
const CONFIGS = [
{
id: "claude",
label: "Claude Desktop",
icon: Sparkles,
lang: "json",
filename: "~/Library/Application Support/Claude/claude_desktop_config.json",
content: `{
"mcpServers": {
"mcpedia": {
"command": "bun",
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
"env": {
"DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia",
"OPENROUTER_API_KEY": "sk-or-v1-..."
}
}
}
}`,
},
{
id: "cursor",
label: "Cursor AI",
icon: Laptop,
lang: "json",
filename: ".cursor/mcp.json",
content: `{
"mcpServers": {
"mcpedia": {
"command": "bun",
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
"env": {
"DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia"
}
}
}
}`,
},
{
id: "http",
label: "Streamable HTTP (SSE)",
icon: Server,
lang: "json",
filename: "Streamable HTTP Client Config",
content: `{
"mcpServers": {
"mcpedia-remote": {
"url": "https://mcp.asepharyana.my.id/mcp",
"headers": {
"x-webhook-secret": "your-secret-here"
}
}
}
}`,
},
{
id: "cli",
label: "CLI & Scripts",
icon: Terminal,
lang: "bash",
filename: "Terminal Subprocess / REPL",
content: `# Semantic question answering via MCP client
bun run apps/mcp-client/src/ask.ts "How does ret2win stack alignment work in x86_64?"
# Interactive REPL with tool discovery
bun run apps/mcp-client/src/index.ts`,
},
];
export default function McpConfigSnippet() {
const [activeTab, setActiveTab] = useState("claude");
const [copied, setCopied] = useState(false);
const current = CONFIGS.find((c) => c.id === activeTab) || CONFIGS[0];
const markdownSnippet = `\`\`\`${current.lang}
${current.content}
\`\`\``;
function handleCopyAll() {
navigator.clipboard.writeText(current.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-xs">
{/* Header bar with tabs */}
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
<div className="flex items-center gap-1.5 flex-wrap">
{CONFIGS.map((c) => {
const Icon = c.icon;
const isSelected = activeTab === c.id;
return (
<button
key={c.id}
onClick={() => setActiveTab(c.id)}
type="button"
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md font-medium transition-all cursor-pointer ${
isSelected
? "bg-[var(--brand)] text-[var(--brand-fg)] font-semibold shadow-xs"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]"
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{c.label}</span>
</button>
);
})}
</div>
<button
onClick={handleCopyAll}
type="button"
className="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer font-medium"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500">Copied</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy Config</span>
</>
)}
</button>
</div>
{/* Code preview */}
<div className="p-4 sm:p-5 [&>.prose>.group]:my-0 [&>.prose]:max-w-none">
<Markdown source={markdownSnippet} />
</div>
{/* Footer Info */}
<div className="px-4 py-2.5 bg-[var(--bg-elevated)] border-t border-[var(--border-color)] text-xs text-[var(--text-dim)] flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-mono uppercase text-[10px] text-[var(--text-muted)]">
Target File:
</span>
<span className="font-mono text-[var(--text-secondary)] text-[11px]">
{current.filename}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] font-mono">
<span className="flex items-center gap-1 text-[var(--text-muted)]">
<span>[ 13_TOOLS_ACTIVE ]</span>
</span>
</div>
</div>
</div>
);
}
+451
View File
@@ -0,0 +1,451 @@
"use client";
import React, { useState, useMemo } from "react";
import Link from "next/link";
import Markdown from "./Markdown";
import {
extractDocCategory,
extractDocPoints,
extractDocDifficulty,
sortExportDocuments,
compileExportMarkdown,
type Document,
type ExportData,
type ExportSortOption,
} from "@mcpedia/core/export-helpers";
import { getSectionMeta } from "@mcpedia/config/sections";
import {
Printer,
FileDown,
FileText,
Copy,
Check,
ArrowLeft,
BookOpen,
} from "lucide-react";
interface PdfExportViewProps {
exportData: ExportData;
backHref: string;
}
export default function PdfExportView({
exportData,
backHref,
}: PdfExportViewProps) {
const { summary, documents: initialDocs } = exportData;
// Filter out any leftover index / meta files
const cleanDocs = useMemo(() => {
return initialDocs.filter((d) => {
const slug = d.slug.toLowerCase();
return (
!slug.endsWith("/_index") &&
!slug.endsWith("/index") &&
slug !== "_index" &&
slug !== "index" &&
d.type !== "writeup-index"
);
});
}, [initialDocs]);
// Configuration and interactive state
const [sortBy, setSortBy] = useState<ExportSortOption>("category_points");
const [showTOC, setShowTOC] = useState(cleanDocs.length > 1);
const [pageBreaks, setPageBreaks] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [copiedMd, setCopiedMd] = useState(false);
const sectionMeta = getSectionMeta(summary.section);
// Available categories for filtering
const allCategories = useMemo(() => {
const set = new Set<string>();
for (const doc of cleanDocs) {
set.add(extractDocCategory(doc));
}
return Array.from(set).sort();
}, [cleanDocs]);
// Filtered & Sorted documents
const filteredAndSortedDocs = useMemo(() => {
let docs = [...cleanDocs];
if (selectedCategory !== "all") {
docs = docs.filter(
(d) => extractDocCategory(d).toLowerCase() === selectedCategory.toLowerCase(),
);
}
return sortExportDocuments(docs, sortBy);
}, [cleanDocs, selectedCategory, sortBy]);
// Calculate dynamic stats for currently filtered view
const currentTotalPoints = useMemo(() => {
return filteredAndSortedDocs.reduce(
(sum, d) => sum + extractDocPoints(d),
0,
);
}, [filteredAndSortedDocs]);
function handlePrint() {
window.print();
}
function handleCopyMarkdown() {
const md = compileExportMarkdown({
summary,
documents: filteredAndSortedDocs,
});
navigator.clipboard.writeText(md);
setCopiedMd(true);
setTimeout(() => setCopiedMd(false), 2000);
}
function handleDownloadMarkdown() {
const md = compileExportMarkdown({
summary,
documents: filteredAndSortedDocs,
});
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const safeName = summary.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
a.download = `${safeName}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
const [downloadingDocx, setDownloadingDocx] = useState(false);
async function handleDownloadDocx() {
setDownloadingDocx(true);
try {
const slugs = filteredAndSortedDocs.map((d) => d.slug).join(",");
const safeName =
summary.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "export";
const url = `/api/export?format=docx&slugs=${encodeURIComponent(slugs)}&sort=${sortBy}&pageBreaks=${pageBreaks}`;
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to generate Word document");
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = `${safeName}.docx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (err) {
console.error("DOCX download error:", err);
alert("Failed to generate .docx file. Please try again.");
} finally {
setDownloadingDocx(false);
}
}
return (
<div className="pb-16 print:pb-0">
{/* Export Control Toolbar (Screen Only — Hidden in Print) */}
<aside className="no-print export-toolbar sticky top-16 z-30 mb-8 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 sm:p-5 shadow-xs">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
{/* Left info & Back button */}
<div className="flex items-center gap-3">
<Link
href={backHref}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors shadow-xs"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Back</span>
</Link>
<div>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-[var(--text-primary)] font-bold uppercase">
{sectionMeta.label}
</span>
<span className="text-xs text-[var(--text-dim)]">·</span>
<span className="text-xs text-[var(--text-muted)] font-mono">
{filteredAndSortedDocs.length} challenge{filteredAndSortedDocs.length !== 1 ? "s" : ""}
{currentTotalPoints > 0 ? ` (${currentTotalPoints} pts)` : ""}
</span>
</div>
<h2 className="text-sm font-bold text-[var(--text-primary)] truncate max-w-md">
{summary.title}
</h2>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
{/* Copy All Markdown */}
<button
onClick={handleCopyMarkdown}
type="button"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
title="Copy compiled markdown document"
>
{copiedMd ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500 font-bold">Copied</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy MD</span>
</>
)}
</button>
{/* Download Markdown */}
<button
onClick={handleDownloadMarkdown}
type="button"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
title="Download compiled .md file"
>
<FileDown className="w-3.5 h-3.5" />
<span>Download .md</span>
</button>
{/* Download Word (.docx) */}
<button
onClick={handleDownloadDocx}
disabled={downloadingDocx}
type="button"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer disabled:opacity-50"
title="Download editable Microsoft Word (.docx) document"
>
{downloadingDocx ? (
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<FileText className="w-3.5 h-3.5" />
)}
<span>Download .docx</span>
</button>
{/* Print / Save as PDF Button */}
<button
onClick={handlePrint}
type="button"
className="inline-flex items-center gap-2 px-3.5 py-1.5 text-xs font-bold bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-md shadow-xs transition-all cursor-pointer"
title="Open browser print dialog to save as PDF"
>
<Printer className="w-4 h-4" />
<span>Print / Save PDF</span>
</button>
</div>
</div>
{/* Options & Filters Bar */}
<div className="mt-3.5 pt-3 border-t border-[var(--border-color)] flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
{/* Sort Order Selector */}
<div className="flex items-center gap-2">
<span className="text-[var(--text-muted)] font-mono text-[10px] uppercase">Sort:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as ExportSortOption)}
className="bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded px-2.5 py-1 text-xs text-[var(--text-primary)] focus:outline-none"
>
<option value="category_points">Category & Points (Standard)</option>
<option value="points_desc">Points (Highest First)</option>
<option value="difficulty">Difficulty (Easy to Hard)</option>
<option value="title">Challenge Title (A-Z)</option>
<option value="path">Directory / Chapter Path</option>
<option value="updated_at">Recently Updated</option>
</select>
</div>
{/* Section toggles */}
<div className="flex items-center gap-3 flex-wrap text-[var(--text-secondary)]">
{cleanDocs.length > 1 && (
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={showTOC}
onChange={(e) => setShowTOC(e.target.checked)}
className="rounded border-[var(--border-color)]"
/>
<span>Table of Contents</span>
</label>
)}
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={pageBreaks}
onChange={(e) => setPageBreaks(e.target.checked)}
className="rounded border-[var(--border-color)]"
/>
<span>Page Break per Challenge</span>
</label>
</div>
</div>
{/* Category filter pills */}
{allCategories.length > 1 && (
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex items-center gap-1.5 flex-wrap text-xs">
<span className="text-[var(--text-muted)] font-mono text-[10px] uppercase mr-1">Filter:</span>
<button
onClick={() => setSelectedCategory("all")}
type="button"
className={`px-2.5 py-0.5 rounded text-xs font-mono transition-all cursor-pointer ${
selectedCategory === "all"
? "bg-[var(--brand)] text-[var(--brand-fg)] font-bold"
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
}`}
>
All ({cleanDocs.length})
</button>
{allCategories.map((cat) => {
const count = cleanDocs.filter(
(d) => extractDocCategory(d).toLowerCase() === cat.toLowerCase(),
).length;
return (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
type="button"
className={`px-2.5 py-0.5 rounded text-xs font-mono transition-all cursor-pointer ${
selectedCategory.toLowerCase() === cat.toLowerCase()
? "bg-[var(--brand)] text-[var(--brand-fg)] font-bold"
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
}`}
>
{cat} ({count})
</button>
);
})}
</div>
)}
</aside>
{/* Writeup Document Flow */}
<main className="print-container bg-white text-slate-900 max-w-4xl mx-auto px-4 sm:px-8 py-6 print:p-0 print:m-0 print:max-w-none rounded-xl border border-[var(--border-color)] print:border-none shadow-xs print:shadow-none">
{/* Document Header */}
<header className="doc-header mb-8 pb-4 print:mb-2.5 print:pb-1.5 border-b border-slate-300">
<h1 className="text-3xl sm:text-4xl print:text-lg font-extrabold text-slate-950 tracking-tight mb-2 print:mb-0.5">
{summary.title}
</h1>
<div className="text-xs print:text-[7.5pt] text-slate-500 font-mono flex flex-wrap items-center gap-x-2 gap-y-1 print:gap-x-1.5 print:gap-y-0.5">
<span>{filteredAndSortedDocs.length} Challenges</span>
{currentTotalPoints > 0 && (
<span>· {currentTotalPoints.toLocaleString()} Total Points</span>
)}
<span>· {new Date(summary.generatedAt).toLocaleDateString(undefined, { dateStyle: "long" })}</span>
</div>
</header>
{/* Table of Contents */}
{showTOC && filteredAndSortedDocs.length > 1 && (
<section className="toc-section mb-10 pb-6 print:mb-2.5 print:pb-1.5 border-b border-slate-200 print:border-slate-300">
<h2 className="text-xs print:text-[8pt] font-bold uppercase font-mono text-slate-700 mb-3 print:mb-1 tracking-wider flex items-center gap-1.5">
<BookOpen className="w-3.5 h-3.5 print:w-3 print:h-3" />
<span>Table of Contents</span>
</h2>
<ol className="list-decimal list-inside space-y-1.5 print:space-y-0.5 text-xs print:text-[7.5pt] text-slate-800">
{filteredAndSortedDocs.map((doc, idx) => {
const cat = extractDocCategory(doc);
const pts = extractDocPoints(doc);
return (
<li key={doc.slug} className="print:leading-tight">
<a
href={`#ch-${idx + 1}`}
className="hover:underline font-medium text-slate-900"
>
{doc.title}
</a>
<span className="text-slate-500 font-mono ml-2 print:ml-1">
[{cat}{pts > 0 ? ` · ${pts} pts` : ""}]
</span>
</li>
);
})}
</ol>
</section>
)}
{/* Challenges Flow */}
<div className="challenges-flow space-y-10 print:space-y-0">
{filteredAndSortedDocs.map((doc, idx) => {
const chNum = idx + 1;
const cat = extractDocCategory(doc);
const pts = extractDocPoints(doc);
const diff = extractDocDifficulty(doc);
const extra = doc.extraFields || {};
return (
<article
key={doc.slug}
id={`ch-${chNum}`}
className={`challenge-entry ${
pageBreaks && idx > 0 ? "page-break-before" : ""
} pt-6 first:pt-0 print:pt-2.5 print:pb-2 print:border-b print:border-slate-200 print:last:border-none`}
>
{/* Challenge Title & Metadata */}
<div className="challenge-header mb-4 pb-2 print:mb-1.5 print:pb-1 border-b border-slate-200 print:border-slate-300">
<h2 className="text-2xl print:text-[11pt] font-bold text-slate-950 tracking-tight leading-tight mb-1.5 print:mb-0.5">
{chNum}. {doc.title}
</h2>
<div className="text-xs print:text-[7.5pt] font-mono text-slate-600 flex flex-wrap items-center gap-x-3 print:gap-x-2 gap-y-1 print:gap-y-0.5">
<span><strong>Category:</strong> {cat}</span>
{pts > 0 && <span><strong>Points:</strong> {pts} pts</span>}
{diff && <span><strong>Difficulty:</strong> {diff}</span>}
{/* Extra fields */}
{Object.entries(extra).map(([k, v]) => {
const lowerKey = k.toLowerCase();
if (
[
"category",
"points",
"score",
"pts",
"difficulty",
"solved",
"author",
"authors",
"scope",
"slug",
"event",
"challenge",
"path",
].includes(lowerKey) ||
v === null ||
v === undefined ||
v === ""
) {
return null;
}
return (
<span key={k}>
<strong>{k}:</strong> {String(v)}
</span>
);
})}
</div>
</div>
{/* Markdown Writeup Content */}
<div className="prose prose-slate max-w-none text-slate-900 mb-8 print:mb-1">
<Markdown source={doc.body} />
</div>
{/* Divider between challenges (screen only) */}
<hr className="print:hidden border-slate-200 my-8" />
</article>
);
})}
</div>
</main>
</div>
);
}
+277 -61
View File
@@ -1,8 +1,18 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { getSectionMeta } from "@mcpedia/config/sections";
import {
Folder,
FolderOpen,
FileText,
ChevronRight,
Search,
X,
Plus,
} from "lucide-react";
interface Doc { interface Doc {
slug: string; slug: string;
@@ -11,17 +21,170 @@ interface Doc {
tags: string[]; tags: string[];
} }
const SECTIONS = [ interface TreeNode {
{ id: "docs", label: "Documentation", icon: "📄" }, name: string;
{ id: "writeups", label: "Writeups", icon: "📝" }, slug: string;
{ id: "research", label: "Research", icon: "🔬" }, title: string;
{ id: "notes", label: "Notes", icon: "📌" }, children: TreeNode[];
] as const; isLeaf: boolean;
docCount: number;
}
function buildFolderTree(docs: Doc[], section: string): TreeNode[] {
const root: TreeNode[] = [];
for (const doc of docs.filter((d) => d.section === section)) {
const parts = doc.slug.split("/");
let current = root;
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const isLeaf = i === parts.length - 1;
let node = current.find((n) => n.name === part);
if (!node) {
node = {
name: part,
slug: parts.slice(0, i + 1).join("/"),
title: "",
children: [],
isLeaf: false,
docCount: 0,
};
current.push(node);
}
if (isLeaf) {
node.isLeaf = true;
node.title = doc.title;
} else if (!node.title) {
node.title = part
.split(/[-_]/)
.map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : ""))
.join(" ");
}
current = node.children;
}
}
function countDocs(node: TreeNode): number {
if (node.isLeaf) return 1;
return node.children.reduce((sum, child) => sum + countDocs(child), 0);
}
for (const node of root) {
node.docCount = countDocs(node);
}
function sortNodes(nodes: TreeNode[]): TreeNode[] {
return nodes.sort((a, b) => {
const aFolder = a.children.length > 0 ? 0 : 1;
const bFolder = b.children.length > 0 ? 0 : 1;
if (aFolder !== bFolder) return aFolder - bFolder;
return (a.title || a.name).localeCompare(b.title || b.name);
});
}
for (const node of root) {
sortNodes(node.children);
}
return sortNodes(root);
}
function TreeNodeItem({
node,
depth,
pathname,
collapsedMap,
toggleCollapse,
}: {
node: TreeNode;
depth: number;
pathname: string;
collapsedMap: Record<string, boolean>;
toggleCollapse: (slug: string) => void;
}) {
const isFolder = node.children.length > 0;
const isActive = pathname === `/${node.slug}`;
const isParentOfActive = pathname.startsWith(`/${node.slug}/`);
const isCollapsed = collapsedMap[node.slug] ?? false;
return (
<li className={`select-none ${depth > 0 ? "ml-3.5 pl-2 border-l border-[var(--border-color)]" : ""}`}>
<div
className={`group flex items-center justify-between gap-1 text-xs py-1.5 px-2 rounded-md transition-all ${
isActive
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)] shadow-xs"
: isParentOfActive
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]/60"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`}
>
<Link
href={`/${node.slug}`}
className="flex items-center gap-2 min-w-0 flex-1 py-0.5"
title={node.title || node.name}
>
<span className="shrink-0 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors">
{isFolder ? (
isCollapsed ? (
<Folder className="w-3.5 h-3.5" />
) : (
<FolderOpen className="w-3.5 h-3.5 text-[var(--text-primary)]" />
)
) : (
<FileText className="w-3.5 h-3.5" />
)}
</span>
<span className="truncate">{node.title || node.name}</span>
</Link>
{isFolder && (
<div className="flex items-center gap-1 shrink-0">
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.2 rounded font-mono">
{node.docCount}
</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleCollapse(node.slug);
}}
className="p-1 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded hover:bg-[var(--bg-elevated-hover)] transition-transform cursor-pointer"
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
>
<ChevronRight
className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "rotate-0" : "rotate-90"}`}
/>
</button>
</div>
)}
</div>
{isFolder && !isCollapsed && (
<ul className="mt-1 space-y-0.5 animate-fade-in">
{node.children.map((child) => (
<TreeNodeItem
key={child.slug}
node={child}
depth={depth + 1}
pathname={pathname}
collapsedMap={collapsedMap}
toggleCollapse={toggleCollapse}
/>
))}
</ul>
)}
</li>
);
}
/** Client-side sidebar (fetches doc list via /api/docs at runtime).
* Client component so `next build` SSG doesn't hit Postgres (CI has no DB). */
export default function Sidebar() { export default function Sidebar() {
const [docs, setDocs] = useState<Doc[]>([]); const [docs, setDocs] = useState<Doc[]>([]);
const [filterText, setFilterText] = useState("");
const [collapsedMap, setCollapsedMap] = useState<Record<string, boolean>>({});
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
@@ -34,71 +197,124 @@ export default function Sidebar() {
.catch(() => setDocs([])); .catch(() => setDocs([]));
}, []); }, []);
const bySection: Record<string, Doc[]> = {}; function toggleCollapse(slug: string) {
for (const doc of docs) { setCollapsedMap((prev) => ({ ...prev, [slug]: !prev[slug] }));
if (!bySection[doc.section]) bySection[doc.section] = [];
bySection[doc.section].push(doc);
} }
const filteredDocs = useMemo(() => {
if (!filterText.trim()) return docs;
const lower = filterText.toLowerCase();
return docs.filter(
(d) =>
d.title.toLowerCase().includes(lower) ||
d.slug.toLowerCase().includes(lower) ||
d.tags.some((t) => t.toLowerCase().includes(lower)),
);
}, [docs, filterText]);
const distinctSections = useMemo(() => {
const map = new Map<string, { id: string; label: string; icon: string }>();
for (const doc of filteredDocs) {
const s = (doc.section || "docs").toLowerCase();
if (!map.has(s)) {
map.set(s, getSectionMeta(s));
}
}
return Array.from(map.values()).sort((a, b) => a.label.localeCompare(b.label));
}, [filteredDocs]);
return ( return (
<nav className="h-full overflow-y-auto py-6"> <nav className="h-full flex flex-col py-4 px-3 select-none">
<ul className="space-y-1 px-3 text-sm"> {/* Sidebar search filter */}
{SECTIONS.map(({ id, label, icon }) => { <div className="mb-3">
const sectionDocs = bySection[id] || []; <div className="relative">
<input
type="text"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="Filter index..."
className="w-full pl-8 pr-7 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-md text-xs text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-all"
/>
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] absolute left-2.5 top-2.5 pointer-events-none" />
{filterText && (
<button
onClick={() => setFilterText("")}
className="absolute right-2 top-2 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
aria-label="Clear filter"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Summary count */}
<div className="flex items-center justify-between px-1 mb-2 text-[10px] text-[var(--text-muted)] font-mono uppercase tracking-wider">
<span>Knowledge Index</span>
<span>{filteredDocs.length} items</span>
</div>
{/* Section Trees */}
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
{distinctSections.map(({ id, label, icon }) => {
const sectionDocs = filteredDocs.filter((d) => (d.section || "docs").toLowerCase() === id);
if (sectionDocs.length === 0) return null; if (sectionDocs.length === 0) return null;
// Check if any doc in this section is the active page const tree = buildFolderTree(filteredDocs, id);
const isActive = sectionDocs.some( const isSectionActive = pathname === `/${id}` || pathname.startsWith(`/${id}/`);
(doc) => pathname === `/${doc.slug}` || pathname === `/${doc.section}`,
);
return ( return (
<li key={id}> <div key={id} className="border-b border-[var(--border-color)] pb-3 last:border-0">
<div
className={`text-xs font-medium mb-1 mt-4 first:mt-0 flex items-center gap-1.5 ${
isActive
? "text-[#7170ff]"
: "text-[#62666d]"
}`}
>
<span>{icon}</span>
{label}
</div>
<ul className="space-y-0.5">
{sectionDocs.map((doc) => {
const parts = doc.slug.split("/").slice(1);
const label = parts[parts.length - 1];
const formatted = label
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
const depth = doc.slug.split("/").length;
const indent = depth - 1;
const isDocActive = pathname === `/${doc.slug}`;
return (
<li
key={doc.slug}
style={{ marginLeft: `${indent * 12}px` }}
>
<Link <Link
href={`/${doc.slug}`} href={`/${id}`}
className={`block text-xs py-0.5 rounded transition-all ${ className={`flex items-center justify-between gap-1.5 px-2 py-1 mb-1 rounded-md text-xs font-semibold uppercase tracking-wider transition-colors ${
isDocActive isSectionActive
? "text-[#7170ff] bg-[#191a1b]" ? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
: "text-[#d0d6e0] hover:text-[#f7f8f8] hover:bg-[#191a1b]" : "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`} }`}
title={doc.title}
> >
{formatted || doc.slug} <div className="flex items-center gap-2">
<span className="text-xs">{icon}</span>
<span>{label}</span>
</div>
<span className="text-[10px] text-[var(--text-muted)] font-normal font-mono bg-[var(--bg-surface)] px-1.5 py-0.2 rounded border border-[var(--border-color)]">
{sectionDocs.length}
</span>
</Link> </Link>
</li>
<ul className="space-y-0.5 mt-1">
{tree.map((node) => (
<TreeNodeItem
key={node.slug}
node={node}
depth={0}
pathname={pathname}
collapsedMap={collapsedMap}
toggleCollapse={toggleCollapse}
/>
))}
</ul>
</div>
); );
})} })}
</ul>
</li> {filteredDocs.length === 0 && (
); <div className="text-center py-8 text-xs text-[var(--text-dim)] font-mono">
})} No matching documents
</ul> </div>
)}
</div>
{/* Bottom quick links */}
<div className="pt-3 border-t border-[var(--border-color)] mt-auto">
<Link
href="/create"
className="flex items-center justify-center gap-2 w-full py-2 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-primary)] rounded-md transition-all font-medium shadow-xs"
>
<Plus className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<span>Create Document</span>
</Link>
</div>
</nav> </nav>
); );
} }
+77 -12
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import GithubSlugger from "github-slugger"; import GithubSlugger from "github-slugger";
import { AlignLeft, ArrowUp, Hash } from "lucide-react";
interface TOCEntry { interface TOCEntry {
id: string; id: string;
@@ -10,11 +11,9 @@ interface TOCEntry {
level: number; level: number;
} }
/** Extract h2/h3 headings from markdown and render as a clickable TOC. The
* heading `id`s are generated by rehype-slug (via github-slugger) in Markdown.tsx,
* so the same slugger is used here to produce matching anchors. */
export default function TOC({ source }: { source: string }) { export default function TOC({ source }: { source: string }) {
const [toc, setToc] = useState<TOCEntry[]>([]); const [toc, setToc] = useState<TOCEntry[]>([]);
const [activeId, setActiveId] = useState<string>("");
useEffect(() => { useEffect(() => {
const slugger = new GithubSlugger(); const slugger = new GithubSlugger();
@@ -31,24 +30,90 @@ export default function TOC({ source }: { source: string }) {
setToc(entries); setToc(entries);
}, [source]); }, [source]);
// Scroll spy using IntersectionObserver
useEffect(() => {
if (toc.length === 0) return;
const headingElements = toc
.map((entry) => document.getElementById(entry.id))
.filter((el): el is HTMLElement => el !== null);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
break;
}
}
},
{
rootMargin: "-80px 0% -60% 0%",
threshold: 0.1,
},
);
headingElements.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [toc]);
if (toc.length === 0) return null; if (toc.length === 0) return null;
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
return ( return (
<nav className="mb-6 text-sm"> <nav className="text-xs space-y-4">
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-2"> <div className="flex items-center justify-between pb-2 border-b border-[var(--border-color)]">
Contents <div className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px] font-mono">
</h3> <AlignLeft className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" />
<ul className="space-y-1"> <span>On This Page</span>
{toc.map((entry) => ( </div>
<li key={entry.id} className={entry.level === 3 ? "ml-3" : ""}> <button
onClick={scrollToTop}
type="button"
className="text-[10px] text-[var(--text-dim)] hover:text-[var(--text-primary)] flex items-center gap-0.5 hover:underline"
title="Scroll to top"
>
<ArrowUp className="w-3 h-3" />
<span>Top</span>
</button>
</div>
<ul className="space-y-1.5 border-l border-[var(--border-color)] pl-3">
{toc.map((entry) => {
const isActive = activeId === entry.id;
return (
<li
key={entry.id}
className={`transition-all ${entry.level === 3 ? "ml-3" : ""}`}
>
<Link <Link
href={`#${entry.id}`} href={`#${entry.id}`}
className="text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:underline" onClick={(e) => {
e.preventDefault();
const target = document.getElementById(entry.id);
if (target) {
const offset = 80;
const top = target.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior: "smooth" });
setActiveId(entry.id);
history.pushState(null, "", `#${entry.id}`);
}
}}
className={`block py-0.5 transition-all line-clamp-1 ${
isActive
? "text-[var(--brand)] dark:text-[var(--accent)] font-semibold translate-x-0.5"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
}`}
title={entry.text}
> >
{entry.text} {entry.text}
</Link> </Link>
</li> </li>
))} );
})}
</ul> </ul>
</nav> </nav>
); );
+33 -10
View File
@@ -1,36 +1,59 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Sun, Moon } from "lucide-react";
const STORAGE_KEY = "mcpedia-theme"; const STORAGE_KEY = "mcpedia-theme";
/** Dark mode toggle — persisted in localStorage, defaults to system preference. */
export default function ThemeToggle() { export default function ThemeToggle() {
const [dark, setDark] = useState(false); const [mounted, setMounted] = useState(false);
const [isDark, setIsDark] = useState(true);
useEffect(() => { useEffect(() => {
setMounted(true);
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
const initial = const darkActive =
stored === "dark" || stored === "dark" ||
(stored === null && (stored === null &&
window.matchMedia("(prefers-color-scheme: dark)").matches); window.matchMedia("(prefers-color-scheme: dark)").matches);
setDark(initial); setIsDark(darkActive);
if (darkActive) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}, []); }, []);
function toggle() { function toggle() {
const next = !dark; const next = !isDark;
setDark(next); setIsDark(next);
localStorage.setItem(STORAGE_KEY, next ? "dark" : "light"); localStorage.setItem(STORAGE_KEY, next ? "dark" : "light");
document.documentElement.classList.toggle("dark", next); if (next) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
if (!mounted) {
return (
<div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] opacity-40" />
);
} }
return ( return (
<button <button
onClick={toggle} onClick={toggle}
aria-label="Toggle dark mode" type="button"
className="text-[#d0d6e0] hover:text-[#f7f8f8] p-1 rounded transition-colors" aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] border border-transparent hover:border-[var(--border-color)] transition-all flex items-center justify-center cursor-pointer"
> >
{dark ? "☀️" : "🌙"} {isDark ? (
<Sun className="w-4 h-4 text-amber-400 hover:rotate-45 transition-transform" />
) : (
<Moon className="w-4 h-4 text-indigo-600 hover:-rotate-12 transition-transform" />
)}
</button> </button>
); );
} }
+49 -31
View File
@@ -1,44 +1,62 @@
import Link from "next/link";
import { listDocuments, extractFoldersForSection, listSections } from "@mcpedia/core";
import { WEBHOOK_SECRET } from "@mcpedia/config"; import { WEBHOOK_SECRET } from "@mcpedia/config";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import DocForm from "@/components/DocForm"; import DocForm from "@/components/DocForm";
import Link from "next/link"; import { Edit3, ArrowLeft, Plus } from "lucide-react";
export const dynamic = "force-dynamic";
// GET /create — show the document create form.
// Requires admin cookie auth.
export default async function CreatePage() { export default async function CreatePage() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null; const isAdmin = cookieStore.get("mcpedia_admin")?.value != null;
if (!canEdit) { if (!isAdmin) redirect("/login");
return (
<div className="text-center py-16"> const [all, sections] = await Promise.all([
<h1 className="text-2xl font-light text-[#f7f8f8] mb-4"> listDocuments(),
Authentication required listSections(),
</h1> ]);
<p className="text-[#8a8f98] mb-6">
You must be logged in to create documents. const docSlugs = all.map((d) => d.slug);
</p> const sectionIds = Array.from(new Set([...sections.map((s) => s.id), ...all.map((d) => d.section)]));
<Link
href="/login"
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-[#5e6ad2] text-white hover:bg-[#7170ff] transition-colors font-medium text-sm"
>
Login
</Link>
</div>
);
}
return ( return (
<div> <div className="space-y-6">
<Link {/* Breadcrumb */}
href="/" <nav className="flex items-center gap-2 text-xs text-[var(--text-dim)]">
className="text-xs text-[#8a8f98] hover:text-[#d0d6e0] mb-4 inline-block" <Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
> MCPedia
← Back
</Link> </Link>
<h1 className="text-2xl font-light text-[#f7f8f8] mb-6"> <span>/</span>
Create Document <span className="text-[var(--text-muted)] font-medium">Create Document</span>
</nav>
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-[var(--border-color)]">
<div className="flex items-center gap-3.5">
<div className="w-11 h-11 rounded-2xl bg-[var(--brand)]/15 border border-[var(--brand)]/30 flex items-center justify-center text-[var(--brand)] dark:text-[var(--accent)]">
<Plus className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">
Create Knowledge Document
</h1> </h1>
<DocForm mode="create" secret={WEBHOOK_SECRET} /> <p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
Persists directly into PostgreSQL, tracks revision history, and generates vector embeddings.
</p>
</div>
</div>
</div>
<DocForm
mode="create"
secret={WEBHOOK_SECRET}
existingSections={sectionIds}
existingFolders={Object.fromEntries(
sectionIds.map((sid) => [sid, extractFoldersForSection(docSlugs, sid)])
)}
/>
</div> </div>
); );
} }
-185
View File
@@ -1,185 +0,0 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
import type { DocSection } from "@mcpedia/core";
export const dynamic = "force-dynamic";
const SECTIONS: { id: DocSection; label: string }[] = [
{ id: "docs", label: "Documentation" },
{ id: "writeups", label: "Writeups" },
{ id: "research", label: "Research" },
{ id: "notes", label: "Notes" },
];
const SECTION_ICONS: Record<DocSection, string> = {
docs: "📄",
writeups: "📝",
research: "🔬",
notes: "📌",
};
export default async function DocsIndexPage() {
const all = await listDocuments();
// Group by section, sort by updatedAt desc for recency
const bySection = SECTIONS.map((section) => ({
section,
docs: all
.filter((d) => d.section === section.id)
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
),
}));
// Flatten + sort all docs by updatedAt for the hero "recent" strip
const recent = [...all]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.slice(0, 6);
return (
<>
{/* Hero */}
<section className="mb-12">
<h1 className="text-4xl font-medium text-[#f7f8f8] mb-3">
MCPedia Knowledge Base
</h1>
<p className="text-lg text-[#8a8f98] max-w-2xl leading-relaxed">
A content-first knowledge base for humans and AI agents. Documents
covering MCP protocol, TypeScript, infrastructure, and security.
</p>
{/* Search bar */}
<div className="mt-8 relative max-w-2xl">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#62666d]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
placeholder="Search documents, tags, authors..."
readOnly
className="w-full pl-10 pr-4 py-3 bg-[#191a1b] border border-[#1f2022] rounded-lg text-[#e2e4e7] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]/30 transition-colors"
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value) {
window.location.href = `/search?q=${encodeURIComponent(e.currentTarget.value)}`;
}
}}
/>
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[#62666d] bg-[#0f1011] border border-[#1f2022] rounded px-1.5 py-0.5">
↵
</kbd>
</div>
{/* Recent strip */}
{recent.length > 0 && (
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{recent.map((d) => (
<Link
key={d.slug}
href={`/${d.slug}`}
className="group block bg-[#0f1011] border border-[#1f2022] rounded-lg p-4 hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all duration-200"
>
<div className="flex items-start justify-between gap-2 mb-2">
<span className="text-2xl">
{SECTION_ICONS[d.section] || "📄"}
</span>
<time className="text-xs text-[#62666d]">
{new Date(d.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</time>
</div>
<h3 className="font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors line-clamp-1">
{d.title}
</h3>
<div className="mt-2 flex flex-wrap gap-1">
{d.tags.slice(0, 3).map((t) => (
<span
key={t}
className="text-xs px-1.5 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]"
>
#{t}
</span>
))}
</div>
</Link>
))}
</div>
)}
</section>
{/* Sections */}
{bySection.map(({ section, docs }) => {
if (docs.length === 0) return null;
return (
<section key={section.id} className="mb-12">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase">
{section.label}
</h2>
<Link
href={`/${section.id}`}
className="text-xs text-[#62666d] hover:text-[#7170ff] transition-colors"
>
View all ({docs.length})
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{docs.map((d) => (
<Link
key={d.slug}
href={`/${d.slug}`}
className="group block bg-[#0f1011] border border-[#1f2022] rounded-lg p-5 hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all duration-200"
>
<div className="flex items-start justify-between gap-2 mb-3">
<span className="text-2xl">
{SECTION_ICONS[section.id] || "📄"}
</span>
<time className="text-xs text-[#62666d]">
{new Date(d.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</time>
</div>
<h3 className="font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors line-clamp-1 mb-2">
{d.title}
</h3>
<p className="text-xs text-[#8a8f98] mb-3 line-clamp-2">
{d.author && `by ${d.author} · `}
{d.tags.slice(0, 2).map((t) => `#${t}`).join(" ") || "—"}
</p>
<div className="flex flex-wrap gap-1">
{d.tags.slice(0, 4).map((t) => (
<span
key={t}
className="text-xs px-1.5 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]"
>
#{t}
</span>
))}
</div>
</Link>
))}
</div>
</section>
);
})}
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import React from "next/link";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getExportDocuments, formatScopeTitle, type ExportSortOption } from "@mcpedia/core";
import PdfExportView from "@/components/PdfExportView";
import { FileText, ArrowLeft } from "lucide-react";
export const dynamic = "force-dynamic";
interface ExportPdfPageProps {
searchParams: Promise<{
path?: string;
section?: string;
slug?: string;
sort?: string;
}>;
}
export async function generateMetadata({ searchParams }: ExportPdfPageProps) {
const { path, section, slug } = await searchParams;
const target = path || slug || section || "All Documents";
const title = formatScopeTitle(target);
return {
title: `Export PDF: ${title} — MCPedia`,
description: `Export and print documentation and CTF writeups for ${title}`,
};
}
export default async function ExportPdfPage({
searchParams,
}: ExportPdfPageProps) {
const { path, section, slug, sort } = await searchParams;
const targetPath = path || slug;
const sortBy = (sort as ExportSortOption) || "category_points";
const exportData = await getExportDocuments({
path: targetPath,
section: !targetPath ? section : undefined,
sortBy,
});
const backHref = targetPath ? `/${targetPath}` : section ? `/${section}` : "/";
if (exportData.documents.length === 0) {
return (
<div className="py-16 text-center space-y-4 max-w-lg mx-auto bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-3xl p-8 shadow-sm">
<div className="w-14 h-14 rounded-2xl bg-[var(--bg-elevated)] text-[var(--text-dim)] flex items-center justify-center mx-auto">
<FileText className="w-7 h-7" />
</div>
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
No Documents Found for Export
</h1>
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed">
Could not find any published documents matching the path{" "}
<code className="text-[var(--brand)] dark:text-[var(--accent)] font-mono font-semibold">
{targetPath || section || "root"}
</code>
.
</p>
<div className="pt-4">
<Link
href={backHref}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white text-xs font-semibold rounded-xl shadow-md shadow-[var(--brand)]/20 transition-all"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Return to Knowledge Base</span>
</Link>
</div>
</div>
);
}
return <PdfExportView exportData={exportData} backHref={backHref} />;
}
Loaded 50 of 95 files, more files were not shown because too many files have changed in this diff. Show more