From 8997f7f94a8bbe1aafd4efb8003974f3bfb28e3e Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Sat, 15 Aug 2026 15:01:50 +0200 Subject: [PATCH] namespaces added and documentation --- README.md | 31 +- info.rkt | 2 +- main.rkt | 4 + private/attachment-references.rkt | 14 +- private/auth.rkt | 4 + private/config.rkt | 4 + private/database.rkt | 8 + private/http-util.rkt | 4 + private/migrations.rkt | 25 +- private/setup.rkt | 4 + private/storage.rkt | 206 ++++++++----- private/todo.rkt | 4 + private/vendor.rkt | 4 + scrbl/racket-wiki.scrbl | 14 +- server.rkt | 26 +- setup-vendor.rkt | 4 + static/css/wiki.css | 17 ++ static/index.html | 4 + static/js/wiki.js | 461 +++++++++++++++++++++++++----- translate.rkt | 14 +- 20 files changed, 696 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 6f39a8f..cbfc069 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # racket-wiki -Version 0.2.30 tightens implicit wiki links to classic WikiWords: only letter-only CamelCase words with at least two capitalized word segments are treated as implicit links. Version-like identifiers and words containing digits or punctuation are not WikiWords. +Version 0.2.31 adds page namespaces as database metadata and extends wiki references to forms such as `RWS:ModelTreeWalker` and `[roadmap](racket:roadmap)`. Todo items and bookmarks are grouped by namespace. The source has also been documented more thoroughly, especially `static/js/wiki.js`. -Current development version: **0.2.29**. +Current development version: **0.2.31**. A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend. @@ -198,6 +198,33 @@ When the site is served through HTTPS, add `--secure-cookie` so the session cook The `--create-admin` command-line option remains available for recovery or scripted administration, but it is no longer part of the normal first-run procedure. + +## Page namespaces + +Every page has a namespace and a page slug. Existing pages are migrated to the root namespace. The external page reference is compact: + +```text +roadmap +racket:roadmap +RWS:model-tree-walker +``` + +The editor exposes the namespace as page metadata. The slug is still generated from the title when a page is first created and remains stable afterwards. The combination of namespace and slug is unique in PostgreSQL. + +Explicit Markdown links may use the compact form directly: + +```markdown +[roadmap](racket:roadmap) +``` + +Classic WikiWords may also be namespace-qualified: + +```text +RWS:ModelTreeWalker +``` + +The WikiWord portion still follows the strict letter-only classic rule. Todo and Bookmark views group their entries by namespace; bookmark user sections remain available as a second grouping level. + ## Editing EasyMDE supplies the Markdown editor, Markdown-aware styling while editing, line numbers, keyboard shortcuts, preview, side-by-side mode and fullscreen mode. On a normal desktop-sized window racket-wiki opens the editor in side-by-side mode by default. EasyMDE is configured with `sideBySideFullscreen: false`, so split editing remains inside the normal wiki page rather than taking over the complete browser window. diff --git a/info.rkt b/info.rkt index 51f4a89..0d8b068 100644 --- a/info.rkt +++ b/info.rkt @@ -1,7 +1,7 @@ #lang info (define pkg-authors '(hnmdijkema)) -(define version "0.2.30") +(define version "0.2.31") (define license 'MIT) (define collection "racket-wiki") (define pkg-desc diff --git a/main.rkt b/main.rkt index 03f90d3..f8d1bde 100644 --- a/main.rkt +++ b/main.rkt @@ -1,5 +1,9 @@ #lang racket/base +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Command-line and programmatic entry points for racket-wiki. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (require racket/cmdline racket/path "private/auth.rkt" diff --git a/private/attachment-references.rkt b/private/attachment-references.rkt index 24a4b16..37d9967 100644 --- a/private/attachment-references.rkt +++ b/private/attachment-references.rkt @@ -1,5 +1,9 @@ #lang racket/base +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Tracking current and historical references to uploaded files. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (require db racket/string) @@ -14,7 +18,7 @@ (define (attachment-rows db) (query-rows db #<6! db) + ;; Namespace is added here as well so a fresh migration chain can rebuild + ;; attachment URLs before schema 7 creates the namespace indexes. + (query-exec db "ALTER TABLE pages ADD COLUMN IF NOT EXISTS namespace TEXT NOT NULL DEFAULT ''") (query-exec db #<7! db) + (query-exec db "ALTER TABLE pages ADD COLUMN IF NOT EXISTS namespace TEXT NOT NULL DEFAULT ''") + (query-exec db "ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_slug_key") + (query-exec db "CREATE UNIQUE INDEX IF NOT EXISTS pages_namespace_slug_idx ON pages(namespace, slug)") + (query-exec db "CREATE INDEX IF NOT EXISTS pages_namespace_idx ON pages(lower(namespace), lower(title), title)") + (record-schema-version! db 7)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Bring a racket-wiki PostgreSQL database to the current schema. ; pre : db is a writable PostgreSQL connection and config identifies the @@ -290,6 +310,9 @@ SQL (define after-todo-reindex (database-schema-version db)) (when (= after-todo-reindex 5) (migrate-5->6! db)) + (define after-attachment-references (database-schema-version db)) + (when (= after-attachment-references 6) + (migrate-6->7! db)) (define resulting-version (database-schema-version db)) (when (> resulting-version current-schema-version) (error 'migrate-database! diff --git a/private/setup.rkt b/private/setup.rkt index 45db0f7..807ede2 100644 --- a/private/setup.rkt +++ b/private/setup.rkt @@ -1,5 +1,9 @@ #lang racket/base +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; First-run setup state and web setup processing. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (require crypto net/uri-codec racket/path diff --git a/private/storage.rkt b/private/storage.rkt index 55d7195..431101b 100644 --- a/private/storage.rkt +++ b/private/storage.rkt @@ -1,5 +1,9 @@ #lang racket/base +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PostgreSQL-backed page, history, search, bookmark and upload storage. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (require db json racket/file @@ -13,6 +17,9 @@ (provide ensure-wiki-data! valid-slug? + valid-page-reference? + page-reference + split-page-reference title->slug list-pages read-page @@ -38,6 +45,10 @@ ; post : The data directory and writable static directory exist. ; result : void. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Supporting functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (define (ensure-wiki-data! config) (for ((directory (in-list (list (wiki-config-data-dir config) (data-static-directory config))))) @@ -66,6 +77,42 @@ (valid-slug-character? char)) (not (member slug '("." ".."))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Build the external wiki reference for a namespace and slug. +; pre : namespace and slug are strings; slug is a valid page slug. +; post : No state is changed. +; result : slug for the root namespace, otherwise namespace:slug. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (page-reference namespace slug) + (if (string=? (string-trim namespace) "") + slug + (string-append (string-trim namespace) ":" slug))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Split an external page reference into namespace and slug. +; pre : reference is a string. +; post : No state is changed. +; result : Two values: namespace and slug. The namespace is empty for root pages. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (split-page-reference reference) + (define match (regexp-match #px"^([^:]+):(.*)$" reference)) + (if match + (values (list-ref match 1) (list-ref match 2)) + (values "" reference))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Check whether a namespace-qualified page reference is valid. +; pre : reference is a string. +; post : No state is changed. +; result : #t for root slugs or namespace:slug references with letter/number namespaces. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (valid-page-reference? reference) + (define-values (namespace slug) (split-page-reference reference)) + (and (valid-slug? slug) + (or (string=? namespace "") + (and (valid-slug? namespace) + (<= (string-length namespace) 80))))) + (define (title->slug title) (define normalized (string-downcase @@ -101,8 +148,12 @@ (if (list? value) value '()))) (define (row->page row [include-markdown? #t]) + (define namespace (vector-ref row 9)) + (define slug (vector-ref row 0)) (define result - (hash 'slug (vector-ref row 0) + (hash 'slug (page-reference namespace slug) + 'pageSlug slug + 'namespace namespace 'title (vector-ref row 1) 'createdAt (vector-ref row 3) 'updatedAt (vector-ref row 4) @@ -115,7 +166,7 @@ result)) (define page-columns - "slug, title, markdown, created_at, updated_at, created_by, updated_by, tags, current_version") + "slug, title, markdown, created_at, updated_at, created_by, updated_by, tags, current_version, namespace") ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : List current wiki page metadata. @@ -130,7 +181,7 @@ (for/list ((row (in-list (query-rows db (string-append "SELECT " page-columns - " FROM pages WHERE archived = FALSE ORDER BY lower(title), title"))))) + " FROM pages WHERE archived = FALSE ORDER BY lower(namespace), namespace, lower(title), title"))))) (row->page row #f))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -139,18 +190,19 @@ ; post : The pages table has only been read. ; result : Page metadata with Markdown, or #f when the page does not exist. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (read-page config slug) - (if (not (valid-slug? slug)) +(define (read-page config reference) + (if (not (valid-page-reference? reference)) #f - (call-with-wiki-database - config - (λ (db) - (define row - (query-maybe-row db - (string-append "SELECT " page-columns - " FROM pages WHERE slug = $1 AND archived = FALSE") - slug)) - (if row (row->page row) #f))))) + (let-values (((namespace slug) (split-page-reference reference))) + (call-with-wiki-database + config + (λ (db) + (define row + (query-maybe-row db + (string-append "SELECT " page-columns + " FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE") + namespace slug)) + (if row (row->page row) #f)))))) (define (replace-todos! db page-id markdown) @@ -178,7 +230,8 @@ SQL ; post : Current page state and version 1 are committed atomically. ; result : The new page metadata with Markdown. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (create-page! config slug title markdown author [summary "Created page"] [tags '()]) +(define (create-page! config reference title markdown author [summary "Created page"] [tags '()]) + (define-values (namespace slug) (split-page-reference reference)) (call-with-wiki-database config (λ (db) @@ -189,20 +242,20 @@ SQL (define page-id (query-value db #<text tags) now author)) + namespace slug title markdown (tags->text tags) now author)) (define page-version-id (insert-version! db page-id 1 title markdown author "create" summary now tags)) (replace-todos! db page-id markdown) (replace-current-attachment-references! db page-id markdown now) (record-version-attachment-references! db page-id page-version-id markdown now))))) - (read-page config slug)) + (read-page config reference)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Save a new version of an existing wiki page. @@ -210,7 +263,8 @@ SQL ; post : Current page and version history are committed atomically. ; result : The updated page metadata with Markdown. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (update-page! config slug title markdown author base-version [summary "Edited page"] [tags #f]) +(define (update-page! config reference title markdown author base-version [summary "Edited page"] [tags #f] [new-namespace #f]) + (define-values (namespace slug) (split-page-reference reference)) (call-with-wiki-database config (λ (db) @@ -219,8 +273,8 @@ SQL (λ () (define row (query-maybe-row db - "SELECT id, current_version, tags FROM pages WHERE slug = $1 AND archived = FALSE FOR UPDATE" - slug)) + "SELECT id, current_version, tags, namespace FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE FOR UPDATE" + namespace slug)) (unless row (error 'update-page! "unknown page: ~a" slug)) (define current-version (vector-ref row 1)) @@ -232,25 +286,33 @@ SQL (error 'update-page! "version-conflict")) (define page-tags (if tags tags (text->tags (vector-ref row 2)))) + (define target-namespace + (if (eq? new-namespace #f) + (vector-ref row 3) + (string-trim new-namespace))) + (unless (or (string=? target-namespace "") + (and (valid-slug? target-namespace) + (<= (string-length target-namespace) 80))) + (error 'update-page! "invalid namespace: ~a" target-namespace)) (define next-version (+ current-version 1)) (define now (current-seconds)) (query-exec db #<text page-tags) next-version now author (vector-ref row 0)) + title markdown (tags->text page-tags) next-version now author target-namespace (vector-ref row 0)) (define page-id (vector-ref row 0)) (define page-version-id (insert-version! db page-id next-version title markdown author "edit" summary now page-tags)) (replace-todos! db page-id markdown) (replace-current-attachment-references! db page-id markdown now) (record-version-attachment-references! db page-id page-version-id markdown now))))) - (read-page config slug)) + (read-page config (page-reference (if (eq? new-namespace #f) namespace (string-trim new-namespace)) slug))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Archive an existing wiki page. @@ -258,7 +320,8 @@ SQL ; post : The page is marked archived while its versions and attachments remain stored. ; result : void. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (archive-page! config slug author) +(define (archive-page! config reference author) + (define-values (namespace slug) (split-page-reference reference)) (call-with-wiki-database config (λ (db) @@ -267,10 +330,10 @@ SQL #<number version))) (and version-number @@ -336,9 +402,9 @@ SQL SELECT v.version, v.title, v.markdown, v.author, v.action, v.summary, v.tags, v.created_at FROM page_versions v JOIN pages p ON p.id = v.page_id -WHERE p.slug = $1 AND p.archived = FALSE AND v.version = $2 +WHERE p.namespace = $1 AND p.slug = $2 AND p.archived = FALSE AND v.version = $3 SQL - slug version-number)) + namespace slug version-number)) (and row (hash 'version (vector-ref row 0) 'title (vector-ref row 1) @@ -369,7 +435,8 @@ SELECT p.slug, p.title, ts_rank(p.search_document, q.query) AS rank, ts_headline('simple', p.markdown, q.query, - 'StartSel=[[[, StopSel=]]], MaxWords=28, MinWords=8, ShortWord=2') AS snippet + 'StartSel=[[[, StopSel=]]], MaxWords=28, MinWords=8, ShortWord=2') AS snippet, + p.namespace FROM pages p, q WHERE p.archived = FALSE AND p.search_document @@ q.query @@ -377,7 +444,8 @@ ORDER BY rank DESC, lower(p.title), p.title LIMIT 50 SQL query-text)))) - (hash 'slug (vector-ref row 0) + (hash 'slug (page-reference (vector-ref row 4) (vector-ref row 0)) + 'namespace (vector-ref row 4) 'title (vector-ref row 1) 'rank (vector-ref row 2) 'snippet (vector-ref row 3))))))) @@ -395,14 +463,15 @@ SQL (for/list ((row (in-list (query-rows db #<jsexpr user) (hash 'id (wiki-user-id user) 'username (wiki-user-username user) @@ -244,7 +252,7 @@ CSS (require-role config req 'reader (λ (_session) - (define page (and (valid-slug? slug) (read-page config slug))) + (define page (and (valid-page-reference? slug) (read-page config slug))) (if page (json-response page) (json-error 404 "Page not found"))))) @@ -264,20 +272,22 @@ CSS (with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e))))) (define body (request-json req)) (define requested-slug (hash-ref body 'slug #f)) + (define namespace (string-trim (hash-ref body 'namespace ""))) (define title (hash-ref body 'title "")) (define markdown (hash-ref body 'markdown "")) (define tags (request-tags body)) (define summary (hash-ref body 'summary "Created page")) (define slug - (if requested-slug - requested-slug - (title->slug title))) + (cond + (requested-slug requested-slug) + ((string=? namespace "") (title->slug title)) + (else (page-reference namespace (title->slug title))))) (cond ((string=? (string-trim title) "") (json-error 400 "Title is required")) ((string=? slug "") (json-error 400 "The title cannot be converted to a page slug")) - ((not (valid-slug? slug)) + ((not (valid-page-reference? slug)) (json-error 400 "Invalid page address")) ((read-page config slug) (json-error 409 "A page with this address already exists")) @@ -303,6 +313,7 @@ CSS (json-error 400 (exn-message e)))))) (define body (request-json req)) (define title (hash-ref body 'title "")) + (define namespace (hash-ref body 'namespace #f)) (define markdown (hash-ref body 'markdown "")) (define tags (request-tags body)) (define base-version (hash-ref body 'baseVersion "")) @@ -315,7 +326,8 @@ CSS (wiki-user-username (wiki-session-user session)) base-version summary - tags)))))) + tags + namespace)))))) (define (page-delete-handler config req slug) (require-write-role @@ -594,7 +606,7 @@ CSS (cond ((not session) (redirect-response "/login")) - ((and slug (valid-slug? slug)) + ((and slug (valid-page-reference? slug)) (let ((page (read-page config slug))) (cond (page diff --git a/setup-vendor.rkt b/setup-vendor.rkt index 84a2ca8..e9683e3 100644 --- a/setup-vendor.rkt +++ b/setup-vendor.rkt @@ -1,5 +1,9 @@ #lang racket/base +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Manual command for installing pinned frontend dependencies. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (require racket/cmdline racket/path "private/config.rkt" diff --git a/static/css/wiki.css b/static/css/wiki.css index e30c4fb..f5c2f2f 100644 --- a/static/css/wiki.css +++ b/static/css/wiki.css @@ -1201,3 +1201,20 @@ body.editor-mode .editor-metadata-row { margin: 4px 0 0 18px; padding: 0; } + +/* Namespace is page metadata; keep it compact beside the title editor. */ +.editor-namespace-row { + display: flex; + align-items: center; + gap: .6rem; + margin: .3rem 0; + font-size: .9rem; +} +.editor-namespace-row input { + max-width: 18rem; +} +.namespace-heading { + margin: 1rem 0 .35rem; + font-size: 1rem; + font-weight: 600; +} diff --git a/static/index.html b/static/index.html index e2db088..2fa6e06 100644 --- a/static/index.html +++ b/static/index.html @@ -78,6 +78,10 @@