Multiple features. Admin page with verweeste items. Breadcrump working.

This commit is contained in:
2026-08-15 02:13:05 +02:00
parent ea7cd45eed
commit 6eef2d681c
10 changed files with 663 additions and 27 deletions
+16 -1
View File
@@ -1,6 +1,6 @@
# racket-wiki # racket-wiki
Current development version: **0.2.19**. Current development version: **0.2.23**.
A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend. A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend.
@@ -365,3 +365,18 @@ Pages whose slug starts with `template-` are available in the editor Template dr
## 0.2.20 ## 0.2.20
Inline `todo(...)` markers render as a yellow `Todo: text` link. Clicking the marker opens the Todo view at the matching item and highlights it. Todo markers inside fenced code blocks remain untouched. Inline `todo(...)` markers render as a yellow `Todo: text` link. Clicking the marker opens the Todo view at the matching item and highlights it. Todo markers inside fenced code blocks remain untouched.
## 0.2.23
Breadcrumbs now keep a per-browser-tab history of visited wiki pages. The start page remains the fixed root. Earlier pages in the trail are clickable; selecting one truncates the trail at that page, after which navigation continues from there. Returning to the wiki name/Home clears the trail and opens the start page. Special views such as Todo, Recent, Bookmarks, Graph and Admin do not become entries in the page-history breadcrumb. The trail is stored in browser session storage and is limited to the most recent eight non-home wiki pages.
## 0.2.23
CamelCase and normalized unlinked page mentions are rendered as automatic links to existing wiki pages. Section edit temporarily highlights the target source line. The EasyMDE toolbar contains a Raw Markdown toggle; the preference is kept in the browser.
## 0.2.24
Attachment references are tracked in database schema 6. Admin contains an Orphaned uploads page showing uploads no current page references, together with their last historical page/version when available.
+1 -1
View File
@@ -1,7 +1,7 @@
#lang info #lang info
(define pkg-authors '(hnmdijkema)) (define pkg-authors '(hnmdijkema))
(define version "0.2.21") (define version "0.2.25")
(define license 'MIT) (define license 'MIT)
(define collection "racket-wiki") (define collection "racket-wiki")
(define pkg-desc (define pkg-desc
+98
View File
@@ -0,0 +1,98 @@
#lang racket/base
(require db
racket/string)
(provide replace-current-attachment-references!
record-version-attachment-references!
rebuild-attachment-references!)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Supporting functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (attachment-rows db)
(query-rows db
#<<SQL
SELECT a.id, p.slug, a.stored_name
FROM attachments a
JOIN pages p ON p.id = a.page_id
ORDER BY a.id
SQL
))
(define (attachment-url slug stored-name)
(format "/uploads/~a/~a" slug stored-name))
(define (record-references! db page-id page-version-id markdown current? referenced-at)
(for ((row (in-list (attachment-rows db))))
(define attachment-id (vector-ref row 0))
(define slug (vector-ref row 1))
(define stored-name (vector-ref row 2))
(when (string-contains? markdown (attachment-url slug stored-name))
(query-exec db
#<<SQL
INSERT INTO attachment_references
(attachment_id, page_id, page_version_id, current_reference, referenced_at)
VALUES ($1, $2, $3, $4, $5)
SQL
attachment-id
page-id
page-version-id
current?
referenced-at))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Provided functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Replace the current attachment references for one page.
; pre : page-id identifies a page and markdown is its new current source.
; post : Current-reference rows for the page reflect markdown exactly.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (replace-current-attachment-references! db page-id markdown referenced-at)
(query-exec db
"DELETE FROM attachment_references WHERE page_id = $1 AND current_reference = TRUE"
page-id)
(record-references! db page-id sql-null markdown #t referenced-at)
(void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Record attachment references present in an immutable page version.
; pre : page-version-id identifies the stored version represented by markdown.
; post : Historical-reference rows for that version reflect markdown exactly.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (record-version-attachment-references! db page-id page-version-id markdown referenced-at)
(query-exec db
"DELETE FROM attachment_references WHERE page_version_id = $1 AND current_reference = FALSE"
page-version-id)
(record-references! db page-id page-version-id markdown #f referenced-at)
(void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Rebuild all attachment references from current pages and page history.
; pre : attachment_references and the existing wiki tables are available.
; post : The reference table reflects every current and historical Markdown page.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (rebuild-attachment-references! db)
(query-exec db "DELETE FROM attachment_references")
(for ((row (in-list
(query-rows db
"SELECT id, markdown, updated_at FROM pages WHERE archived = FALSE"))))
(replace-current-attachment-references! db
(vector-ref row 0)
(vector-ref row 1)
(vector-ref row 2)))
(for ((row (in-list
(query-rows db
"SELECT id, page_id, markdown, created_at FROM page_versions ORDER BY id"))))
(record-version-attachment-references! db
(vector-ref row 1)
(vector-ref row 0)
(vector-ref row 2)
(vector-ref row 3)))
(void))
+26 -1
View File
@@ -4,6 +4,7 @@
racket/file racket/file
racket/path racket/path
racket/string racket/string
"attachment-references.rkt"
"config.rkt" "config.rkt"
"todo.rkt") "todo.rkt")
@@ -11,7 +12,7 @@
database-schema-version database-schema-version
migrate-database!) migrate-database!)
(define current-schema-version 5) (define current-schema-version 6)
(define schema-1-statements (define schema-1-statements
(list (list
@@ -239,6 +240,27 @@ SQL
(record-schema-version! db 5)) (record-schema-version! db 5))
(define (migrate-5->6! db)
(query-exec db
#<<SQL
CREATE TABLE IF NOT EXISTS attachment_references (
id BIGSERIAL PRIMARY KEY,
attachment_id BIGINT NOT NULL REFERENCES attachments(id) ON DELETE CASCADE,
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
page_version_id BIGINT REFERENCES page_versions(id) ON DELETE CASCADE,
current_reference BOOLEAN NOT NULL,
referenced_at BIGINT NOT NULL
)
SQL
)
(query-exec db
"CREATE INDEX IF NOT EXISTS attachment_references_attachment_idx ON attachment_references(attachment_id, current_reference, referenced_at DESC)")
(query-exec db
"CREATE INDEX IF NOT EXISTS attachment_references_page_idx ON attachment_references(page_id, current_reference)")
(rebuild-attachment-references! db)
(record-schema-version! db 6))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Bring a racket-wiki PostgreSQL database to the current schema. ; goal : Bring a racket-wiki PostgreSQL database to the current schema.
; pre : db is a writable PostgreSQL connection and config identifies the ; pre : db is a writable PostgreSQL connection and config identifies the
@@ -265,6 +287,9 @@ SQL
(define after-bookmarks (database-schema-version db)) (define after-bookmarks (database-schema-version db))
(when (= after-bookmarks 4) (when (= after-bookmarks 4)
(migrate-4->5! db)) (migrate-4->5! db))
(define after-todo-reindex (database-schema-version db))
(when (= after-todo-reindex 5)
(migrate-5->6! db))
(define resulting-version (database-schema-version db)) (define resulting-version (database-schema-version db))
(when (> resulting-version current-schema-version) (when (> resulting-version current-schema-version)
(error 'migrate-database! (error 'migrate-database!
+112 -8
View File
@@ -6,6 +6,7 @@
racket/list racket/list
racket/path racket/path
racket/string racket/string
"attachment-references.rkt"
"config.rkt" "config.rkt"
"database.rkt" "database.rkt"
"todo.rkt") "todo.rkt")
@@ -26,6 +27,8 @@
list-bookmarks list-bookmarks
set-bookmark! set-bookmark!
delete-bookmark! delete-bookmark!
list-orphaned-uploads
delete-orphaned-upload!
save-upload! save-upload!
uploaded-file) uploaded-file)
@@ -161,12 +164,13 @@
(hash-ref item 'text)))) (hash-ref item 'text))))
(define (insert-version! db page-id version title markdown author action summary now tags) (define (insert-version! db page-id version title markdown author action summary now tags)
(query-exec db (query-value db
#<<SQL #<<SQL
INSERT INTO page_versions(page_id, version, title, markdown, tags, author, action, summary, created_at) INSERT INTO page_versions(page_id, version, title, markdown, tags, author, action, summary, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
SQL SQL
page-id version title markdown (tags->text tags) author action summary now)) page-id version title markdown (tags->text tags) author action summary now))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Create a wiki page and its first version. ; goal : Create a wiki page and its first version.
@@ -193,8 +197,11 @@ VALUES ($1, $2, $3, $4, 1, $5, $5, $6, $6,
RETURNING id RETURNING id
SQL SQL
slug title markdown (tags->text tags) now author)) slug title markdown (tags->text tags) now author))
(insert-version! db page-id 1 title markdown author "create" summary now tags) (define page-version-id
(replace-todos! db page-id markdown))))) (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 slug))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -237,8 +244,12 @@ SET title = $1, markdown = $2, tags = $3, current_version = $4,
WHERE id = $7 WHERE id = $7
SQL SQL
title markdown (tags->text page-tags) next-version now author (vector-ref row 0)) title markdown (tags->text page-tags) next-version now author (vector-ref row 0))
(insert-version! db (vector-ref row 0) next-version title markdown author "edit" summary now page-tags) (define page-id (vector-ref row 0))
(replace-todos! db (vector-ref row 0) markdown))))) (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 slug))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -261,7 +272,10 @@ RETURNING id
SQL SQL
(current-seconds) author slug)) (current-seconds) author slug))
(unless id (unless id
(error 'archive-page! "unknown page: ~a" slug)))) (error 'archive-page! "unknown page: ~a" slug))
(query-exec db
"DELETE FROM attachment_references WHERE page_id = $1 AND current_reference = TRUE"
id)))
(void)) (void))
(define (page-id config slug) (define (page-id config slug)
@@ -500,6 +514,96 @@ SQL
user-id slug))) user-id slug)))
(void)) (void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List uploads that no current page references.
; pre : PostgreSQL schema 6 or newer is initialized.
; post : Attachment and reference rows have only been read.
; result : A newest-first list with owner and last historical use metadata.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (list-orphaned-uploads config)
(call-with-wiki-database
config
(λ (db)
(for/list ((row (in-list
(query-rows db
#<<SQL
SELECT a.id,
a.original_name,
a.stored_name,
a.mime_type,
a.size,
a.uploaded_at,
a.uploaded_by,
owner.slug,
owner.title
FROM attachments a
JOIN pages owner ON owner.id = a.page_id
WHERE NOT EXISTS (
SELECT 1
FROM attachment_references current_ref
WHERE current_ref.attachment_id = a.id
AND current_ref.current_reference = TRUE
)
ORDER BY a.uploaded_at DESC, a.id DESC
SQL
))))
(define attachment-id (vector-ref row 0))
(define last-uses
(for/list ((use-row (in-list
(query-rows db
#<<SQL
SELECT p.slug, p.title, pv.version, ar.referenced_at
FROM attachment_references ar
JOIN pages p ON p.id = ar.page_id
LEFT JOIN page_versions pv ON pv.id = ar.page_version_id
WHERE ar.attachment_id = $1
AND ar.current_reference = FALSE
ORDER BY ar.referenced_at DESC, ar.id DESC
LIMIT 5
SQL
attachment-id))))
(hash 'slug (vector-ref use-row 0)
'title (vector-ref use-row 1)
'version (if (sql-null? (vector-ref use-row 2)) #f (vector-ref use-row 2))
'referencedAt (vector-ref use-row 3))))
(hash 'id attachment-id
'originalName (vector-ref row 1)
'storedName (vector-ref row 2)
'mimeType (vector-ref row 3)
'size (vector-ref row 4)
'uploadedAt (vector-ref row 5)
'uploadedBy (vector-ref row 6)
'ownerSlug (vector-ref row 7)
'ownerTitle (vector-ref row 8)
'lastUses last-uses)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Delete an upload only when no current page references it.
; pre : attachment-id identifies a possible attachment.
; post : The attachment and its reference rows are deleted, or an error is raised.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (delete-orphaned-upload! config attachment-id)
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(define current-count
(query-value db
"SELECT COUNT(*) FROM attachment_references WHERE attachment_id = $1 AND current_reference = TRUE"
attachment-id))
(when (> current-count 0)
(error 'delete-orphaned-upload! "attachment is still referenced by a current page"))
(define deleted-id
(query-maybe-value db
"DELETE FROM attachments WHERE id = $1 RETURNING id"
attachment-id))
(unless deleted-id
(error 'delete-orphaned-upload! "unknown attachment: ~a" attachment-id))))))
(void))
(define (safe-file-name name) (define (safe-file-name name)
(define clean (define clean
(regexp-replace* #px"[^A-Za-z0-9._ -]" name "_")) (regexp-replace* #px"[^A-Za-z0-9._ -]" name "_"))
+18
View File
@@ -394,6 +394,20 @@ CSS
(upload-file-response attachment) (upload-file-response attachment)
(json-error 404 "File not found"))))) (json-error 404 "File not found")))))
(define (admin-orphaned-uploads-handler config req)
(require-role
config req 'admin
(λ (_session)
(json-response (hash 'uploads (list-orphaned-uploads config))))))
(define (admin-delete-orphaned-upload-handler config req id)
(require-write-role
config req 'admin
(λ (_session)
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
(delete-orphaned-upload! config id)
(json-response (hash 'ok #t))))))
(define (admin-users-handler config req) (define (admin-users-handler config req)
(require-role (require-role
config req 'admin config req 'admin
@@ -537,6 +551,10 @@ CSS
(λ (req slug) (upload-handler config req slug))] (λ (req slug) (upload-handler config req slug))]
[("uploads" (string-arg) (string-arg)) #:method "get" [("uploads" (string-arg) (string-arg)) #:method "get"
(λ (req slug stored-name) (upload-get-handler config req slug stored-name))] (λ (req slug stored-name) (upload-get-handler config req slug stored-name))]
[("api" "admin" "uploads" "orphaned") #:method "get"
(λ (req) (admin-orphaned-uploads-handler config req))]
[("api" "admin" "uploads" "orphaned" (integer-arg)) #:method "delete"
(λ (req id) (admin-delete-orphaned-upload-handler config req id))]
[("api" "admin" "users") #:method "get" [("api" "admin" "users") #:method "get"
(λ (req) (admin-users-handler config req))] (λ (req) (admin-users-handler config req))]
[("api" "admin" "users") #:method "post" [("api" "admin" "users") #:method "post"
+51
View File
@@ -1150,3 +1150,54 @@ body.editor-mode .editor-metadata-row {
outline: 2px solid #e1c75a; outline: 2px solid #e1c75a;
outline-offset: 2px; outline-offset: 2px;
} }
/* 0.2.23: source-mode and section editing */
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-1,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-2,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-3,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-4,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-5,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-6,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-strong,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-em {
font-size: inherit !important;
line-height: inherit !important;
font-weight: normal !important;
font-style: normal !important;
}
.EasyMDEContainer .CodeMirror .section-edit-highlight {
background: #fff3a3;
}
.wiki-auto-link {
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.orphaned-uploads-list {
display: grid;
gap: 12px;
}
.orphaned-upload-row {
padding: 12px 0;
border-bottom: 1px solid var(--wiki-border);
}
.orphaned-upload-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.orphaned-upload-uses {
margin: 4px 0 0 18px;
padding: 0;
}
.orphaned-upload-uses {
margin: 4px 0 0 18px;
padding: 0;
}
+9
View File
@@ -158,9 +158,18 @@
<nav class="admin-menu"> <nav class="admin-menu">
<a id="admin-users-link" href="#" data-tr="users">Users</a> <a id="admin-users-link" href="#" data-tr="users">Users</a>
<a id="admin-translations-link" href="#" data-tr="translations">Translations</a> <a id="admin-translations-link" href="#" data-tr="translations">Translations</a>
<a id="admin-orphaned-uploads-link" href="#" data-tr="orphaned-uploads">Orphaned uploads</a>
</nav> </nav>
</section> </section>
<section id="orphaned-uploads-view" class="hidden">
<header class="page-header">
<h1 data-tr="orphaned-uploads">Orphaned uploads</h1>
<a id="close-orphaned-uploads" href="#" data-tr="back">Back</a>
</header>
<div id="orphaned-uploads-list" class="orphaned-uploads-list"></div>
</section>
<section id="user-admin-view" class="hidden"> <section id="user-admin-view" class="hidden">
<header class="page-header"> <header class="page-header">
<h1 data-tr="user-administration">User administration</h1> <h1 data-tr="user-administration">User administration</h1>
+328 -12
View File
@@ -12,7 +12,9 @@
translationPage: "wiki-translations", translationPage: "wiki-translations",
translationTemplate: "", translationTemplate: "",
siteTitle: "Racket Wiki", siteTitle: "Racket Wiki",
bookmarks: [] bookmarks: [],
breadcrumbTrail: [],
rawMarkdown: window.localStorage.getItem("racket-wiki-raw-markdown") === "true"
}; };
let easyMDE = null; let easyMDE = null;
@@ -20,7 +22,7 @@
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
function show(viewId) { function show(viewId) {
for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "history-view", "admin-view", "user-admin-view"]) { for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "history-view", "admin-view", "user-admin-view", "orphaned-uploads-view"]) {
$(id).classList.toggle("hidden", id !== viewId); $(id).classList.toggle("hidden", id !== viewId);
} }
$("page-action-links").classList.toggle("hidden", viewId !== "page-view"); $("page-action-links").classList.toggle("hidden", viewId !== "page-view");
@@ -148,9 +150,112 @@
}); });
} }
function normalizeMentionText(text) {
return String(text || "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
}
function camelCaseParts(text) {
return String(text || "").match(/[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]*)+/g) || [];
}
function mentionAliases(page) {
const aliases = new Set();
const values = [page.title, slugTitle(page.slug), page.slug.replaceAll("-", " ")];
for (const value of values) {
if (!value) continue;
aliases.add(value);
for (const camel of camelCaseParts(value)) aliases.add(camel);
}
return Array.from(aliases)
.map((value) => ({ text: value, key: normalizeMentionText(value) }))
.filter((alias) => alias.key.length >= 5);
}
function pageMentionMap(currentSlug = null) {
const map = new Map();
for (const page of state.pages) {
if (page.slug === currentSlug) continue;
for (const alias of mentionAliases(page)) {
if (!map.has(alias.key)) {
map.set(alias.key, page);
} else if (map.get(alias.key)?.slug !== page.slug) {
map.set(alias.key, null);
}
}
}
return map;
}
function linkUnlinkedMentions(html, currentSlug = null) {
const host = document.createElement("div");
host.innerHTML = html || "";
const aliases = pageMentionMap(currentSlug);
if (aliases.size === 0) return host.innerHTML;
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const textNode of textNodes) {
const parent = textNode.parentElement;
if (!parent || parent.closest("a, code, pre, script, style, textarea")) continue;
const text = textNode.nodeValue || "";
const words = Array.from(text.matchAll(/[\p{L}\p{N}]+/gu));
if (words.length === 0) continue;
const replacements = [];
let wordIndex = 0;
while (wordIndex < words.length) {
let match = null;
const maxWords = Math.min(5, words.length - wordIndex);
for (let count = maxWords; count >= 1; count -= 1) {
const first = words[wordIndex];
const last = words[wordIndex + count - 1];
const start = first.index;
const end = last.index + last[0].length;
const candidate = text.slice(start, end);
const page = aliases.get(normalizeMentionText(candidate));
if (page) {
match = { start, end, page };
break;
}
}
if (match) {
replacements.push(match);
while (wordIndex < words.length && words[wordIndex].index < match.end) wordIndex += 1;
} else {
wordIndex += 1;
}
}
if (replacements.length === 0) continue;
const fragment = document.createDocumentFragment();
let position = 0;
for (const replacement of replacements) {
if (replacement.start > position) fragment.append(document.createTextNode(text.slice(position, replacement.start)));
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(replacement.page.slug)}`;
link.className = "wiki-auto-link";
link.title = tr("automatic-wiki-link", "Automatic wiki link");
link.textContent = text.slice(replacement.start, replacement.end);
fragment.append(link);
position = replacement.end;
}
if (position < text.length) fragment.append(document.createTextNode(text.slice(position)));
textNode.replaceWith(fragment);
}
return host.innerHTML;
}
function renderMarkdown(markdown, pageSlug = null) { function renderMarkdown(markdown, pageSlug = null) {
const html = easyMDE.markdown(expandTodoMarkup(markdown || "", pageSlug)); const html = easyMDE.markdown(expandTodoMarkup(markdown || "", pageSlug));
return DOMPurify.sanitize(applyImageWidthMarkup(html)); const withImages = applyImageWidthMarkup(html);
return DOMPurify.sanitize(linkUnlinkedMentions(withImages, pageSlug));
} }
function slugTitle(slug) { function slugTitle(slug) {
@@ -277,6 +382,18 @@
} }
} }
function applyRawMarkdownMode() {
if (!easyMDE) return;
editorContainer().classList.toggle("raw-markdown", state.rawMarkdown);
}
function toggleRawMarkdown() {
state.rawMarkdown = !state.rawMarkdown;
window.localStorage.setItem("racket-wiki-raw-markdown", state.rawMarkdown ? "true" : "false");
applyRawMarkdownMode();
easyMDE.codemirror.refresh();
}
function initializeEditor() { function initializeEditor() {
if (!window.EasyMDE) { if (!window.EasyMDE) {
throw new Error("EasyMDE is not installed. Open /setup to repair the frontend setup."); throw new Error("EasyMDE is not installed. Open /setup to repair the frontend setup.");
@@ -338,12 +455,15 @@
"|", "|",
toolbarButton("undo", EasyMDE.undo, "undo", tr("undo", "Undo")), toolbarButton("undo", EasyMDE.undo, "undo", tr("undo", "Undo")),
toolbarButton("redo", EasyMDE.redo, "repeat", tr("redo", "Redo")), toolbarButton("redo", EasyMDE.redo, "repeat", tr("redo", "Redo")),
toolbarButton("raw-markdown", toggleRawMarkdown, "file-text-o", tr("raw-markdown", "Raw Markdown"), { noDisable: true }),
toolbarButton("preview", EasyMDE.togglePreview, "eye", tr("preview", "Preview"), { noDisable: true }), toolbarButton("preview", EasyMDE.togglePreview, "eye", tr("preview", "Preview"), { noDisable: true }),
toolbarButton("side-by-side", EasyMDE.toggleSideBySide, "columns", tr("side-by-side", "Side by side"), { noDisable: true, noMobile: true }), toolbarButton("side-by-side", EasyMDE.toggleSideBySide, "columns", tr("side-by-side", "Side by side"), { noDisable: true, noMobile: true }),
toolbarButton("fullscreen", EasyMDE.toggleFullScreen, "arrows-alt", tr("fullscreen", "Fullscreen"), { noDisable: true, noMobile: true }) toolbarButton("fullscreen", EasyMDE.toggleFullScreen, "arrows-alt", tr("fullscreen", "Fullscreen"), { noDisable: true, noMobile: true })
] ]
}); });
applyRawMarkdownMode();
easyMDE.codemirror.on("change", () => { easyMDE.codemirror.on("change", () => {
if (!$("editor-view").classList.contains("hidden")) { if (!$("editor-view").classList.contains("hidden")) {
renderEditorToc(); renderEditorToc();
@@ -513,10 +633,26 @@
}); });
} }
let highlightedEditorLine = null;
let highlightedEditorTimer = null;
function clearEditorLineHighlight() {
if (!easyMDE || highlightedEditorLine === null) return;
easyMDE.codemirror.removeLineClass(highlightedEditorLine, "background", "section-edit-highlight");
highlightedEditorLine = null;
if (highlightedEditorTimer) {
window.clearTimeout(highlightedEditorTimer);
highlightedEditorTimer = null;
}
}
function focusEditorLine(line) { function focusEditorLine(line) {
if (!easyMDE || line === null || line === undefined) return; if (!easyMDE || line === null || line === undefined) return;
clearEditorLineHighlight();
easyMDE.codemirror.setCursor({ line, ch: 0 }); easyMDE.codemirror.setCursor({ line, ch: 0 });
easyMDE.codemirror.scrollIntoView({ line, ch: 0 }, 160); easyMDE.codemirror.scrollIntoView({ line, ch: 0 }, 160);
highlightedEditorLine = easyMDE.codemirror.addLineClass(line, "background", "section-edit-highlight");
highlightedEditorTimer = window.setTimeout(clearEditorLineHighlight, 4500);
easyMDE.codemirror.focus(); easyMDE.codemirror.focus();
} }
@@ -527,6 +663,68 @@
}); });
} }
const breadcrumbStorageKey = "racket-wiki-breadcrumb-trail";
const breadcrumbTrailLimit = 8;
function saveBreadcrumbTrail() {
window.sessionStorage.setItem(breadcrumbStorageKey, JSON.stringify(state.breadcrumbTrail));
}
function loadBreadcrumbTrail() {
let stored = [];
try {
stored = JSON.parse(window.sessionStorage.getItem(breadcrumbStorageKey) || "[]");
} catch (_error) {
stored = [];
}
const pageSlugs = new Set(state.pages.map((page) => page.slug));
state.breadcrumbTrail = Array.isArray(stored)
? stored.filter((slug) => typeof slug === "string" && pageSlugs.has(slug))
: [];
saveBreadcrumbTrail();
}
function clearBreadcrumbTrail() {
state.breadcrumbTrail = [];
saveBreadcrumbTrail();
}
function recordPageVisit(page) {
if (!page) return;
const firstPage = startPage();
if (firstPage && page.slug === firstPage.slug) {
clearBreadcrumbTrail();
return;
}
const existingIndex = state.breadcrumbTrail.indexOf(page.slug);
if (existingIndex >= 0) {
state.breadcrumbTrail = state.breadcrumbTrail.slice(0, existingIndex + 1);
} else {
state.breadcrumbTrail.push(page.slug);
if (state.breadcrumbTrail.length > breadcrumbTrailLimit) {
state.breadcrumbTrail = state.breadcrumbTrail.slice(-breadcrumbTrailLimit);
}
}
saveBreadcrumbTrail();
}
function truncateBreadcrumbTrail(slug) {
const firstPage = startPage();
if (firstPage && slug === firstPage.slug) {
clearBreadcrumbTrail();
return;
}
const index = state.breadcrumbTrail.indexOf(slug);
if (index >= 0) {
state.breadcrumbTrail = state.breadcrumbTrail.slice(0, index + 1);
saveBreadcrumbTrail();
}
}
function renderBreadcrumbs(items) { function renderBreadcrumbs(items) {
const breadcrumbs = $("breadcrumbs"); const breadcrumbs = $("breadcrumbs");
breadcrumbs.replaceChildren(); breadcrumbs.replaceChildren();
@@ -554,7 +752,8 @@
if (item.href) { if (item.href) {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = item.href; link.href = item.href;
if (item.href === "/") link.dataset.home = "true"; if (item.home || item.href === "/") link.dataset.home = "true";
if (item.slug) link.dataset.breadcrumbSlug = item.slug;
link.textContent = item.label; link.textContent = item.label;
breadcrumbs.append(link); breadcrumbs.append(link);
} else { } else {
@@ -567,13 +766,36 @@
} }
function pageBreadcrumbs(page, suffix = null) { function pageBreadcrumbs(page, suffix = null) {
const items = [ const firstPage = startPage();
{ label: state.siteTitle, href: "/" }, const items = [];
{ label: page?.title || page?.slug || "New page" }
]; if (firstPage) {
items.push({
label: state.siteTitle,
href: `#/${encodeURIComponent(firstPage.slug)}`,
slug: firstPage.slug,
home: true
});
} else {
items.push({ label: state.siteTitle });
}
for (const slug of state.breadcrumbTrail) {
const trailPage = state.pages.find((item) => item.slug === slug);
if (!trailPage) continue;
const isCurrent = page && trailPage.slug === page.slug && !suffix;
items.push({
label: trailPage.title,
href: isCurrent ? null : `#/${encodeURIComponent(trailPage.slug)}`,
slug: trailPage.slug
});
}
if (page && firstPage && page.slug === firstPage.slug && suffix) {
items[0].href = `#/${encodeURIComponent(firstPage.slug)}`;
}
if (suffix) { if (suffix) {
items[items.length - 1].href = `#/${encodeURIComponent(page.slug)}`;
items.push({ label: suffix }); items.push({ label: suffix });
} }
@@ -601,6 +823,7 @@
async function goHome() { async function goHome() {
const firstPage = startPage(); const firstPage = startPage();
clearBreadcrumbTrail();
if (!firstPage) { if (!firstPage) {
await route(); await route();
return; return;
@@ -708,6 +931,7 @@
async function openPage(slug) { async function openPage(slug) {
const page = await api(`/api/pages/${encodeURIComponent(slug)}`); const page = await api(`/api/pages/${encodeURIComponent(slug)}`);
state.currentPage = page; state.currentPage = page;
recordPageVisit(page);
state.editingNew = false; state.editingNew = false;
state.newPageSlug = null; state.newPageSlug = null;
$("page-title").textContent = page.title; $("page-title").textContent = page.title;
@@ -1109,6 +1333,82 @@
show("admin-view"); show("admin-view");
} }
function formatFileSize(size) {
const bytes = Number(size || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function loadOrphanedUploadsAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("orphaned-uploads", "Orphaned uploads") }
]);
show("orphaned-uploads-view");
const result = await api("/api/admin/uploads/orphaned");
const list = $("orphaned-uploads-list");
list.replaceChildren();
if (!result.uploads || result.uploads.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-orphaned-uploads", "No orphaned uploads.");
list.append(empty);
return;
}
for (const upload of result.uploads) {
const row = document.createElement("article");
row.className = "orphaned-upload-row";
const title = document.createElement("strong");
title.textContent = upload.originalName;
const meta = document.createElement("div");
meta.className = "muted";
meta.textContent = `${upload.mimeType} · ${formatFileSize(upload.size)} · ${tr("uploaded-by", "uploaded by")} ${upload.uploadedBy}`;
const history = document.createElement("div");
const lastUses = Array.isArray(upload.lastUses) ? upload.lastUses : [];
if (lastUses.length > 0) {
const label = document.createElement("div");
label.textContent = `${tr("last-used", "Last used")}:`;
history.append(label);
const uses = document.createElement("ul");
uses.className = "orphaned-upload-uses";
for (const use of lastUses) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(use.slug)}`;
link.textContent = use.title || use.slug;
item.append(link);
if (use.version) item.append(document.createTextNode(` · ${tr("version", "Version")} ${use.version}`));
uses.append(item);
}
history.append(uses);
} else {
history.textContent = tr("never-referenced", "Never referenced by a saved page.");
}
const actions = document.createElement("div");
actions.className = "orphaned-upload-actions";
const view = document.createElement("a");
view.href = `/uploads/${encodeURIComponent(upload.ownerSlug)}/${encodeURIComponent(upload.storedName)}`;
view.target = "_blank";
view.rel = "noopener";
view.textContent = tr("view", "View");
const remove = document.createElement("button");
remove.className = "danger";
remove.textContent = tr("delete", "Delete");
remove.addEventListener("click", async () => {
if (!confirm(tr("delete-orphaned-upload-confirm", "Delete this orphaned upload?"))) return;
await api(`/api/admin/uploads/orphaned/${upload.id}`, { method: "DELETE" });
await loadOrphanedUploadsAdmin();
});
actions.append(view, remove);
row.append(title, meta, history, actions);
list.append(row);
}
}
async function loadUsersAdmin() { async function loadUsersAdmin() {
renderBreadcrumbs([ renderBreadcrumbs([
{ label: state.siteTitle, href: "/" }, { label: state.siteTitle, href: "/" },
@@ -1608,6 +1908,7 @@
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role); $("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
updateRoleUi(); updateRoleUi();
await loadPages(); await loadPages();
loadBreadcrumbTrail();
await loadBookmarks(); await loadBookmarks();
await route(); await route();
startKeepAlive(); startKeepAlive();
@@ -1634,10 +1935,23 @@
goHome().catch((error) => console.error(error)); goHome().catch((error) => console.error(error));
}); });
$("breadcrumbs").addEventListener("click", (event) => { $("breadcrumbs").addEventListener("click", (event) => {
const home = event.target.closest("a[data-home='true']"); const link = event.target.closest("a[data-home='true'], a[data-breadcrumb-slug]");
if (!home) return; if (!link) return;
event.preventDefault(); event.preventDefault();
goHome().catch((error) => console.error(error)); const slug = link.dataset.breadcrumbSlug;
if (link.dataset.home === "true") {
goHome().catch((error) => console.error(error));
return;
}
truncateBreadcrumbTrail(slug);
const targetHash = `#/${encodeURIComponent(slug)}`;
if (location.hash === targetHash) {
openPage(slug).catch((error) => console.error(error));
} else {
location.hash = targetHash;
}
}); });
$("recent-link").addEventListener("click", (event) => { event.preventDefault(); showRecent().catch((error) => console.error(error)); }); $("recent-link").addEventListener("click", (event) => { event.preventDefault(); showRecent().catch((error) => console.error(error)); });
$("bookmarks-link").addEventListener("click", (event) => { event.preventDefault(); showBookmarks().catch((error) => console.error(error)); }); $("bookmarks-link").addEventListener("click", (event) => { event.preventDefault(); showBookmarks().catch((error) => console.error(error)); });
@@ -1651,6 +1965,7 @@
}); });
$("admin-link").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); }); $("admin-link").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("admin-users-link").addEventListener("click", (event) => { event.preventDefault(); loadUsersAdmin().catch(console.error); }); $("admin-users-link").addEventListener("click", (event) => { event.preventDefault(); loadUsersAdmin().catch(console.error); });
$("admin-orphaned-uploads-link").addEventListener("click", (event) => { event.preventDefault(); loadOrphanedUploadsAdmin().catch(console.error); });
$("admin-translations-link").addEventListener("click", (event) => { $("admin-translations-link").addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
const page = state.pages.find((item) => item.slug === state.translationPage); const page = state.pages.find((item) => item.slug === state.translationPage);
@@ -1665,6 +1980,7 @@
beginNewPage(state.translationPage); beginNewPage(state.translationPage);
}); });
$("close-history").addEventListener("click", () => show("page-view")); $("close-history").addEventListener("click", () => show("page-view"));
$("close-orphaned-uploads").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("close-user-admin").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); }); $("close-user-admin").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("cancel-edit").addEventListener("click", () => { $("cancel-edit").addEventListener("click", () => {
if (state.currentPage) { if (state.currentPage) {
+4 -4
View File
@@ -16,7 +16,7 @@
(define english (define english
(hash (hash
'users "Users" 'translations "Translations" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin" 'users "Users" 'translations "Translations" 'orphaned-uploads "Orphaned uploads" 'no-orphaned-uploads "No orphaned uploads." 'uploaded-by "uploaded by" 'last-used "Last used" 'never-referenced "Never referenced by a saved page." 'delete-orphaned-upload-confirm "Delete this orphaned upload?" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin"
'search-wiki "Search wiki" 'contents "Contents" 'pages "Pages" 'search-wiki "Search wiki" 'contents "Contents" 'pages "Pages"
'edit "Edit" 'edit-section "Edit section" 'template "Template" 'no-template "No template" 'replace-with-template "Replace the current page content with template {template}?" 'history "History" 'delete "Delete" 'page-not-found "Page not found" 'edit "Edit" 'edit-section "Edit section" 'template "Template" 'no-template "No template" 'replace-with-template "Replace the current page content with template {template}?" 'history "History" 'delete "Delete" 'page-not-found "Page not found"
'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)" 'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)"
@@ -42,14 +42,14 @@
'bold "Bold" 'italic "Italic" 'strikethrough "Strikethrough" 'heading "Heading" 'quote "Quote" 'bold "Bold" 'italic "Italic" 'strikethrough "Strikethrough" 'heading "Heading" 'quote "Quote"
'bulleted-list "Bulleted list" 'numbered-list "Numbered list" 'checklist "Checklist" 'code-block "Code block" 'bulleted-list "Bulleted list" 'numbered-list "Numbered list" 'checklist "Checklist" 'code-block "Code block"
'table "Table" 'link "Link" 'horizontal-rule "Horizontal rule" 'undo "Undo" 'redo "Redo" 'table "Table" 'link "Link" 'horizontal-rule "Horizontal rule" 'undo "Undo" 'redo "Redo"
'preview "Preview" 'side-by-side "Side by side" 'fullscreen "Fullscreen" 'preview "Preview" 'side-by-side "Side by side" 'fullscreen "Fullscreen" 'raw-markdown "Raw Markdown" 'automatic-wiki-link "Automatic wiki link"
'language "Language" 'language-en "English" 'language-nl "Dutch" 'language "Language" 'language-en "English" 'language-nl "Dutch"
'no-headings "No headings" 'uploading "Uploading" 'image-upload-complete "Image upload complete" 'upload-complete "Upload complete" 'no-headings "No headings" 'uploading "Uploading" 'image-upload-complete "Image upload complete" 'upload-complete "Upload complete"
'by "by" 'result "result" 'results "results" 'for "for" 'page-does-not-exist "The page does not exist.")) 'by "by" 'result "result" 'results "results" 'for "for" 'page-does-not-exist "The page does not exist."))
(define dutch (define dutch
(hash (hash
'users "Gebruikers" 'translations "Vertalingen" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin" 'users "Gebruikers" 'translations "Vertalingen" 'orphaned-uploads "Verweeste uploads" 'no-orphaned-uploads "Geen verweeste uploads." 'uploaded-by "geüpload door" 'last-used "Laatst gebruikt" 'never-referenced "Nooit door een opgeslagen pagina gerefereerd." 'delete-orphaned-upload-confirm "Deze verweeste upload verwijderen?" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin"
'search-wiki "Wiki doorzoeken" 'contents "Inhoud" 'pages "Pagina's" 'search-wiki "Wiki doorzoeken" 'contents "Inhoud" 'pages "Pagina's"
'edit "Bewerken" 'edit-section "Sectie bewerken" 'template "Sjabloon" 'no-template "Geen sjabloon" 'replace-with-template "De huidige pagina-inhoud vervangen door sjabloon {template}?" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden" 'edit "Bewerken" 'edit-section "Sectie bewerken" 'template "Sjabloon" 'no-template "Geen sjabloon" 'replace-with-template "De huidige pagina-inhoud vervangen door sjabloon {template}?" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden"
'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)" 'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)"
@@ -75,7 +75,7 @@
'bold "Vet" 'italic "Cursief" 'strikethrough "Doorhalen" 'heading "Kop" 'quote "Citaat" 'bold "Vet" 'italic "Cursief" 'strikethrough "Doorhalen" 'heading "Kop" 'quote "Citaat"
'bulleted-list "Opsomming" 'numbered-list "Genummerde lijst" 'checklist "Checklist" 'code-block "Codeblok" 'bulleted-list "Opsomming" 'numbered-list "Genummerde lijst" 'checklist "Checklist" 'code-block "Codeblok"
'table "Tabel" 'link "Link" 'horizontal-rule "Horizontale lijn" 'undo "Ongedaan maken" 'redo "Opnieuw" 'table "Tabel" 'link "Link" 'horizontal-rule "Horizontale lijn" 'undo "Ongedaan maken" 'redo "Opnieuw"
'preview "Voorbeeld" 'side-by-side "Naast elkaar" 'fullscreen "Volledig scherm" 'preview "Voorbeeld" 'side-by-side "Naast elkaar" 'fullscreen "Volledig scherm" 'raw-markdown "Ruwe Markdown" 'automatic-wiki-link "Automatische wikilink"
'language "Taal" 'language-en "Engels" 'language-nl "Nederlands" 'language "Taal" 'language-en "Engels" 'language-nl "Nederlands"
'no-headings "Geen koppen" 'uploading "Uploaden" 'image-upload-complete "Afbeelding geüpload" 'upload-complete "Upload voltooid" 'no-headings "Geen koppen" 'uploading "Uploaden" 'image-upload-complete "Afbeelding geüpload" 'upload-complete "Upload voltooid"
'by "door" 'result "resultaat" 'results "resultaten" 'for "voor" 'page-does-not-exist "De pagina bestaat niet.")) 'by "door" 'result "resultaat" 'results "resultaten" 'for "voor" 'page-does-not-exist "De pagina bestaat niet."))