Beter layouting

This commit is contained in:
2026-08-14 22:54:00 +02:00
parent 40ad35b418
commit c54216fae2
9 changed files with 463 additions and 83 deletions
+1
View File
@@ -7,6 +7,7 @@
(displayln (git* next-version))) (displayln (git* next-version)))
(target zip (target zip
(deps clean)
(zip-package)) (zip-package))
(target clean (target clean
+15 -1
View File
@@ -320,8 +320,22 @@ UI text is routed through `private/translate.rkt`. The server can be started wit
racket main.rkt --data ./wiki-data --language nl racket main.rkt --data ./wiki-data --language nl
``` ```
Built-in English and Dutch translations are supplied. Administrators also get a **Translations** action. It opens the special page `wiki-translations-<language>`, for example `wiki-translations-nl`. Translation overrides use one `key = value` assignment per line. Saving that page is enough; the overrides are loaded from PostgreSQL on the next frontend load. Setup and login have built-in translations and therefore do not depend on that special page. Built-in English and Dutch translations are supplied. Administrators also get a **Translations** action. It opens the single special page `wiki-translations`. When that page does not exist yet, the editor is prefilled with every built-in translation key. Each line can contain several languages, for example `save = nl:Opslaan, en:Save, de:Speichern`. Values containing commas or quotes can be quoted. The active language selects the matching value and falls back to `en` when the requested language is not present. Saving the page is enough; the overrides are loaded from PostgreSQL on the next frontend load. Setup and login retain their built-in translations and therefore do not depend on that special page.
Database schema 3 adds `todo_items`. Migration 2 -> 3 creates the table and indexes todo markers from all existing, non-archived pages. The migration is recorded in `wiki_schema` just like earlier schema changes. Database schema 3 adds `todo_items`. Migration 2 -> 3 creates the table and indexes todo markers from all existing, non-archived pages. The migration is recorded in `wiki_schema` just like earlier schema changes.
The web setup also contains a UI-language selector. The selected language is stored in `wiki-data/language.rktd`; the command-line `--language` value is the default when that file does not yet exist. The web setup also contains a UI-language selector. The selected language is stored in `wiki-data/language.rktd`; the command-line `--language` value is the default when that file does not yet exist.
## Version 0.2.12
Version 0.2.12 makes the first/start page title the visible wiki name and breadcrumb root. The account line is moved to the upper-right navigation area as compact links (`name · role · sign out · admin`), and page actions such as Edit, History and Delete use the same small link style as the breadcrumb. The breadcrumb/account/action chrome stays sticky while reading long pages. The sidebar keeps `* Todo list` directly below the wiki name; Users and Translations are reached through the Admin page.
Wiki image options now support placement in addition to width. Examples are `![Schema](schema.png){width=50% center}`, `![Schema](schema.png){width=320px right}`, `![Schema](schema.png){width=320px right float}` and `![Schema](schema.png){width=320px float=left}`. Without `float`, left/center/right controls block alignment. With float enabled, surrounding text can flow around left- or right-aligned images. Width remains limited to pixels and percentages and rendered images keep `max-width: 100%` and automatic height.
The special translations page is now one page named `wiki-translations`. It is prefilled from the built-in language tables and accepts several languages on one line, for example `save = nl:Opslaan, en:Save, de:Speichern`.
## Version 0.2.13
Version 0.2.13 centers the wiki name in the sidebar and makes it a link to the first/start page. The sticky top chrome now starts without top padding (`padding-top: 0`).
+1 -1
View File
@@ -1,7 +1,7 @@
#lang info #lang info
(define pkg-authors '(hnmdijkema)) (define pkg-authors '(hnmdijkema))
(define version "0.2.10") (define version "0.2.14")
(define license 'MIT) (define license 'MIT)
(define collection "racket-wiki") (define collection "racket-wiki")
(define pkg-desc (define pkg-desc
+3 -2
View File
@@ -121,8 +121,9 @@ for page-local checklists.
@defproc[(tr [config any/c] [key symbol?]) string?] { @defproc[(tr [config any/c] [key symbol?]) string?] {
Returns the effective UI translation for @racket[key]. Built-in English and Returns the effective UI translation for @racket[key]. Built-in English and
Dutch translations are available before the database is configured. Once the Dutch translations are available before the database is configured. Once the
database is available, assignments in the special database is available, assignments in the special @tt{wiki-translations} page
@tt{wiki-translations-<language>} page override the built-in strings. override the built-in strings. A line can contain several languages, for example
@tt{save = nl:Opslaan, en:Save, de:Speichern}.
} }
The UI language is selected with @tt{--language} or @racket[#:language]. The The UI language is selected with @tt{--language} or @racket[#:language]. The
+2 -1
View File
@@ -198,7 +198,8 @@ CSS
(λ (_session) (λ (_session)
(json-response (json-response
(hash 'language (current-language config) (hash 'language (current-language config)
'page (translation-page-slug (current-language config)) 'page (translation-page-slug)
'template (translation-page-template)
'translations (translations-for config)))))) 'translations (translations-for config))))))
(define (page-get-handler config req slug) (define (page-get-handler config req slug)
+171
View File
@@ -195,9 +195,16 @@ body {
} }
.brand { .brand {
display: block;
color: #111; color: #111;
font-size: 1.18rem; font-size: 1.18rem;
margin-bottom: 14px; margin-bottom: 14px;
text-align: center;
text-decoration: none;
}
.brand:hover {
text-decoration: underline;
} }
#user-box, #user-box,
@@ -795,3 +802,167 @@ body.editor-mode .editor-metadata-row {
gap: 3px; gap: 3px;
} }
} }
/* Top navigation -------------------------------------------------------- */
.top-chrome {
position: sticky;
z-index: 120;
top: 0;
margin: -8px 0 20px;
padding: 0 0 10px;
background: white;
border-bottom: 1px solid #ededed;
}
.account-bar {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: .5rem;
min-height: 1.35rem;
margin-bottom: 4px;
font-size: .82rem;
line-height: 1.35;
}
.account-bar a,
.page-action-links a,
.admin-menu a,
.sidebar-primary-link {
color: var(--wiki-link);
text-decoration: none;
}
.account-bar a:hover,
.page-action-links a:hover,
.admin-menu a:hover,
.sidebar-primary-link:hover {
text-decoration: underline;
}
.navigation-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
align-items: baseline;
}
.navigation-row .breadcrumbs {
max-width: none;
margin: 0;
}
.page-action-links {
display: flex;
justify-content: flex-end;
gap: .65rem;
white-space: nowrap;
font-size: .86rem;
line-height: 1.35;
}
.sidebar-primary-link {
display: block;
margin: -4px 0 8px;
padding: 2px 0;
font-size: .9rem;
}
.admin-menu {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: .65rem;
font-size: 1rem;
}
#user-admin-view {
max-width: 1040px;
}
/* Wiki image layout extensions. Width itself remains a sanitized inline
style generated by wiki.js; these fixed classes only control placement. */
.markdown-body img.wiki-image-left,
.EasyMDEContainer .editor-preview img.wiki-image-left,
.EasyMDEContainer .editor-preview-side img.wiki-image-left {
display: block;
margin-left: 0;
margin-right: auto;
}
.markdown-body img.wiki-image-center,
.EasyMDEContainer .editor-preview img.wiki-image-center,
.EasyMDEContainer .editor-preview-side img.wiki-image-center {
display: block;
margin-left: auto;
margin-right: auto;
}
.markdown-body img.wiki-image-right,
.EasyMDEContainer .editor-preview img.wiki-image-right,
.EasyMDEContainer .editor-preview-side img.wiki-image-right {
display: block;
margin-left: auto;
margin-right: 0;
}
.markdown-body img.wiki-image-float-left,
.EasyMDEContainer .editor-preview img.wiki-image-float-left,
.EasyMDEContainer .editor-preview-side img.wiki-image-float-left {
float: left;
margin: .2rem 1rem .75rem 0;
}
.markdown-body img.wiki-image-float-right,
.EasyMDEContainer .editor-preview img.wiki-image-float-right,
.EasyMDEContainer .editor-preview-side img.wiki-image-float-right {
float: right;
margin: .2rem 0 .75rem 1rem;
}
@media (max-width: 900px) {
.top-chrome {
position: static;
margin-top: 0;
}
.account-bar {
justify-content: flex-start;
flex-wrap: wrap;
}
.navigation-row {
grid-template-columns: 1fr;
gap: .35rem;
}
.page-action-links {
justify-content: flex-start;
}
}
/* 0.2.12 navigation polish --------------------------------------------- */
.account-separator {
color: #999;
}
/* Account and page actions are navigation, not push buttons. Keep them on
the same visual scale as the breadcrumb. */
.account-bar,
.page-action-links {
font-size: .86rem;
}
.top-chrome {
top: 0;
}
/* Keep the page navigation available while reading long documents too. */
@media (max-width: 900px) {
.top-chrome {
position: sticky;
top: 0;
z-index: 120;
background: white;
}
}
+35 -17
View File
@@ -15,13 +15,8 @@
<div id="connection-status" class="connection-status hidden" role="status"></div> <div id="connection-status" class="connection-status hidden" role="status"></div>
<div id="app"> <div id="app">
<aside id="sidebar"> <aside id="sidebar">
<div class="brand">Racket Wiki</div> <a id="wiki-brand" class="brand" href="#">Racket Wiki</a>
<div id="user-box"></div> <a id="todo-link" class="sidebar-primary-link" href="#">* <span data-tr="todo-list">Todo list</span></a>
<div class="sidebar-actions">
<button id="todo-button" data-tr="todo">Todo</button>
<button id="translations-button" class="admin-only hidden" data-tr="translations">Translations</button>
<button id="admin-button" class="admin-only hidden" data-tr="users">Users</button>
</div>
<form id="search-form" class="wiki-search" role="search"> <form id="search-form" class="wiki-search" role="search">
<input id="search-input" type="search" placeholder="Search wiki" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search wiki"> <input id="search-input" type="search" placeholder="Search wiki" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search wiki">
</form> </form>
@@ -36,7 +31,25 @@
</aside> </aside>
<main id="main"> <main id="main">
<nav id="breadcrumbs" class="breadcrumbs hidden" aria-label="Breadcrumb"></nav> <div id="top-chrome" class="top-chrome">
<div id="account-bar" class="account-bar">
<span id="account-name"></span>
<span class="account-separator" aria-hidden="true">·</span>
<span id="account-role" class="muted"></span>
<span class="account-separator" aria-hidden="true">·</span>
<a id="logout-link" href="#" data-tr="sign-out">Sign out</a>
<span class="account-separator admin-only hidden" aria-hidden="true">·</span>
<a id="admin-link" class="admin-only hidden" href="#" data-tr="admin">Admin</a>
</div>
<div class="navigation-row">
<nav id="breadcrumbs" class="breadcrumbs hidden" aria-label="Breadcrumb"></nav>
<div id="page-action-links" class="page-action-links hidden">
<a id="edit-page" class="editor-only hidden" href="#" data-tr="edit">Edit</a>
<a id="history-page" href="#" data-tr="history">History</a>
<a id="delete-page" class="editor-only danger hidden" href="#" data-tr="delete">Delete</a>
</div>
</div>
</div>
<section id="page-view" class="hidden"> <section id="page-view" class="hidden">
<header class="page-header"> <header class="page-header">
@@ -44,11 +57,6 @@
<h1 id="page-title"></h1> <h1 id="page-title"></h1>
<div id="page-meta" class="muted"></div> <div id="page-meta" class="muted"></div>
</div> </div>
<div class="toolbar">
<button id="edit-page" class="editor-only hidden" data-tr="edit">Edit</button>
<button id="history-page" data-tr="history">History</button>
<button id="delete-page" class="editor-only danger hidden" data-tr="delete">Delete</button>
</div>
</header> </header>
<article id="markdown-preview" class="markdown-body"></article> <article id="markdown-preview" class="markdown-body"></article>
<footer id="page-details" class="page-details"></footer> <footer id="page-details" class="page-details"></footer>
@@ -109,18 +117,28 @@
</section> </section>
<section id="admin-view" class="hidden"> <section id="admin-view" class="hidden">
<header class="page-header">
<h1 data-tr="admin">Admin</h1>
</header>
<nav class="admin-menu">
<a id="admin-users-link" href="#" data-tr="users">Users</a>
<a id="admin-translations-link" href="#" data-tr="translations">Translations</a>
</nav>
</section>
<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>
<button id="close-admin" data-tr="back">Back</button> <a id="close-user-admin" href="#" data-tr="back">Back</a>
</header> </header>
<form id="new-user-form" class="user-form"> <form id="new-user-form" class="user-form">
<input id="new-username" placeholder="Username" data-tr-placeholder="username" required> <input id="new-username" placeholder="Username" data-tr-placeholder="username" required>
<input id="new-display-name" placeholder="Display name" data-tr-placeholder="display-name"> <input id="new-display-name" placeholder="Display name" data-tr-placeholder="display-name">
<input id="new-password" type="password" placeholder="Initial password" data-tr-placeholder="initial-password" required> <input id="new-password" type="password" placeholder="Initial password" data-tr-placeholder="initial-password" required>
<select id="new-role"> <select id="new-role">
<option value="reader">reader</option> <option value="reader" data-tr="role-reader">reader</option>
<option value="editor">editor</option> <option value="editor" data-tr="role-editor">editor</option>
<option value="admin">admin</option> <option value="admin" data-tr="role-admin">admin</option>
</select> </select>
<button type="submit" data-tr="create-user">Create user</button> <button type="submit" data-tr="create-user">Create user</button>
</form> </form>
+129 -48
View File
@@ -9,7 +9,9 @@
newPageSlug: null, newPageSlug: null,
previousView: "page-view", previousView: "page-view",
translations: {}, translations: {},
translationPage: "wiki-translations-en" translationPage: "wiki-translations",
translationTemplate: "",
siteTitle: "Racket Wiki"
}; };
let easyMDE = null; let easyMDE = null;
@@ -17,9 +19,10 @@
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", "todo-view", "history-view", "admin-view"]) { for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "todo-view", "history-view", "admin-view", "user-admin-view"]) {
$(id).classList.toggle("hidden", id !== viewId); $(id).classList.toggle("hidden", id !== viewId);
} }
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
document.body.classList.toggle("editor-mode", viewId === "editor-view"); document.body.classList.toggle("editor-mode", viewId === "editor-view");
} }
@@ -98,8 +101,45 @@
return body; return body;
} }
function applyImageWidthMarkup(html) {
const imageOptionsSuffix = /(<img\b[^>]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi;
return (html || "").replace(imageOptionsSuffix, (_match, imageTag, amount, unit, align, floatFlag, floatSide) => {
const width = `${amount}${unit || "px"}`;
const closing = imageTag.endsWith("/>") ? "/>" : ">";
let tagBody = imageTag.slice(0, imageTag.length - closing.length);
const classes = [];
const effectiveFloatSide = floatSide || (floatFlag && (align === "left" || align === "right") ? align : null);
if (effectiveFloatSide) {
classes.push(`wiki-image-float-${effectiveFloatSide}`);
} else if (align) {
classes.push(`wiki-image-${align}`);
}
if (/\sstyle=(['"])(.*?)\1/i.test(tagBody)) {
tagBody = tagBody.replace(/\sstyle=(['"])(.*?)\1/i, (_style, quote, value) =>
` style=${quote}${value}; width: ${width}${quote}`);
} else {
tagBody += ` style="width: ${width}"`;
}
if (classes.length > 0) {
if (/\sclass=(['"])(.*?)\1/i.test(tagBody)) {
tagBody = tagBody.replace(/\sclass=(['"])(.*?)\1/i, (_class, quote, value) =>
` class=${quote}${value} ${classes.join(" ")}${quote}`);
} else {
tagBody += ` class="${classes.join(" ")}"`;
}
}
return `${tagBody}${closing}`;
});
}
function renderMarkdown(markdown) { function renderMarkdown(markdown) {
return easyMDE.markdown(expandTodoMarkup(markdown || "")); const html = easyMDE.markdown(expandTodoMarkup(markdown || ""));
return DOMPurify.sanitize(applyImageWidthMarkup(html));
} }
function slugTitle(slug) { function slugTitle(slug) {
@@ -261,7 +301,7 @@
.then(onSuccess) .then(onSuccess)
.catch((error) => onError(error.message)); .catch((error) => onError(error.message));
}, },
previewRender: (plainText) => easyMDE ? easyMDE.markdown(expandTodoMarkup(plainText)) : "", previewRender: (plainText) => easyMDE ? renderMarkdown(plainText) : "",
renderingConfig: { renderingConfig: {
codeSyntaxHighlighting: true, codeSyntaxHighlighting: true,
hljs: window.hljs, hljs: window.hljs,
@@ -474,7 +514,7 @@
function pageBreadcrumbs(page, suffix = null) { function pageBreadcrumbs(page, suffix = null) {
const items = [ const items = [
{ label: "Racket Wiki", href: "/" }, { label: state.siteTitle, href: "/" },
{ label: page?.title || page?.slug || "New page" } { label: page?.title || page?.slug || "New page" }
]; ];
@@ -486,6 +526,25 @@
renderBreadcrumbs(items); renderBreadcrumbs(items);
} }
function startPage() {
const namedStartPage = state.pages.find((page) => page.slug === "start") || null;
if (namedStartPage) return namedStartPage;
if (state.pages.length === 0) return null;
return state.pages.reduce((first, page) => {
if (!first) return page;
return Number(page.createdAt) < Number(first.createdAt) ? page : first;
}, null);
}
function updateWikiIdentity() {
const firstPage = startPage();
state.siteTitle = firstPage?.title || "Racket Wiki";
$("wiki-brand").textContent = state.siteTitle;
$("wiki-brand").href = firstPage ? `#/${encodeURIComponent(firstPage.slug)}` : "#";
document.title = state.siteTitle;
}
function renderPageList() { function renderPageList() {
const list = $("page-list"); const list = $("page-list");
list.replaceChildren(); list.replaceChildren();
@@ -506,6 +565,7 @@
async function loadPages() { async function loadPages() {
const result = await api("/api/pages"); const result = await api("/api/pages");
state.pages = result.pages; state.pages = result.pages;
updateWikiIdentity();
renderPageList(); renderPageList();
} }
@@ -545,14 +605,7 @@
$("editor-title").value = translationPage ? tr("translations", "Translations") : ""; $("editor-title").value = translationPage ? tr("translations", "Translations") : "";
$("editor-tags").value = ""; $("editor-tags").value = "";
if (translationPage) { if (translationPage) {
const lines = [ easyMDE.value(state.translationTemplate || "");
"# Translation overrides",
"",
"Edit only the values after =. Unknown keys are ignored by the UI until they are used.",
""
];
Object.keys(state.translations).sort().forEach((key) => lines.push(`${key} = ${state.translations[key]}`));
easyMDE.value(lines.join("\n"));
} else { } else {
easyMDE.value(""); easyMDE.value("");
} }
@@ -620,6 +673,14 @@
state.editingNew = false; state.editingNew = false;
state.newPageSlug = null; state.newPageSlug = null;
await loadPages(); await loadPages();
if (page.slug === state.translationPage) {
const translationData = await api("/api/translations");
state.language = translationData.language;
state.translationTemplate = translationData.template || state.translationTemplate;
state.translations = translationData.translations || {};
applyTranslations();
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
}
location.hash = `#/${encodeURIComponent(page.slug)}`; location.hash = `#/${encodeURIComponent(page.slug)}`;
await openPage(page.slug); await openPage(page.slug);
$("save-status").textContent = tr("saved", "Saved"); $("save-status").textContent = tr("saved", "Saved");
@@ -730,11 +791,12 @@
state.currentPage = null; state.currentPage = null;
await loadPages(); await loadPages();
if (state.pages.length > 0) { if (state.pages.length > 0) {
location.hash = `#/${encodeURIComponent(state.pages[0].slug)}`; const firstPage = startPage();
location.hash = `#/${encodeURIComponent(firstPage.slug)}`;
} else { } else {
$("page-title").textContent = tr("no-pages", "No pages yet"); $("page-title").textContent = tr("no-pages", "No pages yet");
$("markdown-preview").innerHTML = ""; $("markdown-preview").innerHTML = "";
renderBreadcrumbs([{ label: "Racket Wiki" }]); renderBreadcrumbs([{ label: state.siteTitle }]);
renderToc([], () => {}); renderToc([], () => {});
show("page-view"); show("page-view");
} }
@@ -819,7 +881,7 @@
const result = await api(`/api/search?q=${encodeURIComponent(text)}`); const result = await api(`/api/search?q=${encodeURIComponent(text)}`);
renderBreadcrumbs([ renderBreadcrumbs([
{ label: "Racket Wiki", href: "/" }, { label: state.siteTitle, href: "/" },
{ label: tr("search", "Search") } { label: tr("search", "Search") }
]); ]);
renderToc([], () => {}); renderToc([], () => {});
@@ -899,10 +961,22 @@
}); });
} }
async function loadAdmin() { function showAdmin() {
state.previousView = state.currentPage ? "page-view" : "admin-view"; state.previousView = state.currentPage ? "page-view" : "admin-view";
renderBreadcrumbs([]); renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") }
]);
show("admin-view"); show("admin-view");
}
async function loadUsersAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("users", "Users") }
]);
show("user-admin-view");
const result = await api("/api/admin/users"); const result = await api("/api/admin/users");
const list = $("user-list"); const list = $("user-list");
list.replaceChildren(); list.replaceChildren();
@@ -917,7 +991,7 @@
for (const roleName of ["reader", "editor", "admin"]) { for (const roleName of ["reader", "editor", "admin"]) {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = roleName; option.value = roleName;
option.textContent = roleName; option.textContent = tr(`role-${roleName}`, roleName);
option.selected = roleName === user.role; option.selected = roleName === user.role;
role.append(option); role.append(option);
} }
@@ -949,7 +1023,7 @@
remove.addEventListener("click", async () => { remove.addEventListener("click", async () => {
if (!confirm(`Delete user ${user.username}?`)) return; if (!confirm(`Delete user ${user.username}?`)) return;
await api(`/api/admin/users/${user.id}`, { method: "DELETE" }); await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
await loadAdmin(); await loadUsersAdmin();
}); });
actions.append(save, remove); actions.append(save, remove);
row.append(username, display, role, enabled, password, actions); row.append(username, display, role, enabled, password, actions);
@@ -963,7 +1037,7 @@
state.newPageSlug = null; state.newPageSlug = null;
$("not-found-message").textContent = `${tr("page-does-not-exist", "The page does not exist.")} (${slug})`; $("not-found-message").textContent = `${tr("page-does-not-exist", "The page does not exist.")} (${slug})`;
renderBreadcrumbs([ renderBreadcrumbs([
{ label: "Racket Wiki", href: "/" }, { label: state.siteTitle, href: "/" },
{ label: slug } { label: slug }
]); ]);
show("not-found-view"); show("not-found-view");
@@ -980,7 +1054,7 @@
$("markdown-preview").innerHTML = ""; $("markdown-preview").innerHTML = "";
$("page-details").replaceChildren(); $("page-details").replaceChildren();
renderBreadcrumbs([ renderBreadcrumbs([
{ label: "Racket Wiki", href: "/" }, { label: state.siteTitle, href: "/" },
{ label: slugTitle(slug) || slug } { label: slugTitle(slug) || slug }
]); ]);
show("page-view"); show("page-view");
@@ -1008,7 +1082,8 @@
return; return;
} }
if (state.pages.length > 0) { if (state.pages.length > 0) {
location.hash = `#/${encodeURIComponent(state.pages[0].slug)}`; const firstPage = startPage();
location.hash = `#/${encodeURIComponent(firstPage.slug)}`;
} else { } else {
if (can("editor")) { if (can("editor")) {
showMissingEditablePage("start"); showMissingEditablePage("start");
@@ -1017,7 +1092,7 @@
$("page-title").textContent = tr("no-pages", "No pages yet"); $("page-title").textContent = tr("no-pages", "No pages yet");
$("page-meta").textContent = ""; $("page-meta").textContent = "";
$("markdown-preview").innerHTML = ""; $("markdown-preview").innerHTML = "";
renderBreadcrumbs([{ label: "Racket Wiki" }]); renderBreadcrumbs([{ label: state.siteTitle }]);
renderToc([], () => {}); renderToc([], () => {});
show("page-view"); show("page-view");
} }
@@ -1027,7 +1102,7 @@
async function showTodos() { async function showTodos() {
state.previousView = state.currentPage ? "page-view" : "todo-view"; state.previousView = state.currentPage ? "page-view" : "todo-view";
renderBreadcrumbs([ renderBreadcrumbs([
{ label: "Racket Wiki", href: "/" }, { label: state.siteTitle, href: "/" },
{ label: tr("todo", "Todo") } { label: tr("todo", "Todo") }
]); ]);
renderToc([], () => {}); renderToc([], () => {});
@@ -1097,24 +1172,12 @@
const translationData = await api("/api/translations"); const translationData = await api("/api/translations");
state.language = translationData.language; state.language = translationData.language;
state.translationPage = translationData.page; state.translationPage = translationData.page;
state.translationTemplate = translationData.template || "";
state.translations = translationData.translations || {}; state.translations = translationData.translations || {};
applyTranslations(); applyTranslations();
initializeEditor(); initializeEditor();
const userBox = $("user-box"); $("account-name").textContent = state.session.user.displayName;
userBox.replaceChildren(); $("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
const displayName = document.createTextNode(state.session.user.displayName);
const breakNode = document.createElement("br");
const role = document.createElement("span");
role.className = "muted";
role.textContent = state.session.user.role;
const logout = document.createElement("button");
logout.id = "logout";
logout.textContent = tr("sign-out", "Sign out");
userBox.append(displayName, breakNode, role, " ", logout);
logout.addEventListener("click", async () => {
await api("/api/logout", { method: "POST", body: "{}" });
window.location.replace("/login");
});
updateRoleUi(); updateRoleUi();
await loadPages(); await loadPages();
await route(); await route();
@@ -1128,14 +1191,32 @@
searchWiki($("search-input").value).catch((error) => console.error(error)); searchWiki($("search-input").value).catch((error) => console.error(error));
}); });
$("edit-page").addEventListener("click", beginEditPage); $("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); });
$("delete-page").addEventListener("click", deleteCurrentPage); $("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); });
$("history-page").addEventListener("click", showHistory); $("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); });
$("todo-button").addEventListener("click", () => showTodos().catch((error) => console.error(error))); $("todo-link").addEventListener("click", (event) => { event.preventDefault(); showTodos().catch((error) => console.error(error)); });
$("translations-button").addEventListener("click", () => { location.hash = `#/${encodeURIComponent(state.translationPage)}`; }); $("logout-link").addEventListener("click", async (event) => {
$("admin-button").addEventListener("click", loadAdmin); event.preventDefault();
await api("/api/logout", { method: "POST", body: "{}" });
window.location.replace("/login");
});
$("admin-link").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("admin-users-link").addEventListener("click", (event) => { event.preventDefault(); loadUsersAdmin().catch(console.error); });
$("admin-translations-link").addEventListener("click", (event) => {
event.preventDefault();
const page = state.pages.find((item) => item.slug === state.translationPage);
if (page) {
location.hash = `#/${encodeURIComponent(state.translationPage)}`;
return;
}
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("translations", "Translations") }
]);
beginNewPage(state.translationPage);
});
$("close-history").addEventListener("click", () => show("page-view")); $("close-history").addEventListener("click", () => show("page-view"));
$("close-admin").addEventListener("click", () => state.currentPage ? show("page-view") : route()); $("close-user-admin").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("cancel-edit").addEventListener("click", () => { $("cancel-edit").addEventListener("click", () => {
if (state.currentPage) { if (state.currentPage) {
openPage(state.currentPage.slug); openPage(state.currentPage.slug);
@@ -1167,7 +1248,7 @@
}) })
}); });
event.target.reset(); event.target.reset();
await loadAdmin(); await loadUsersAdmin();
}); });
window.addEventListener("hashchange", () => route().catch(console.error)); window.addEventListener("hashchange", () => route().catch(console.error));
+106 -13
View File
@@ -2,6 +2,7 @@
(require db (require db
racket/file racket/file
racket/list
racket/string racket/string
"private/config.rkt" "private/config.rkt"
"private/database.rkt") "private/database.rkt")
@@ -10,11 +11,12 @@
translations-for translations-for
current-language current-language
write-language! write-language!
translation-page-slug) translation-page-slug
translation-page-template)
(define english (define english
(hash (hash
'users "Users" 'translations "Translations" 'todo "Todo" 'users "Users" 'translations "Translations" 'todo "Todo" 'todo-list "Todo list" '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" 'history "History" 'delete "Delete" 'page-not-found "Page not found" 'edit "Edit" '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)"
@@ -47,14 +49,14 @@
(define dutch (define dutch
(hash (hash
'users "Gebruikers" 'translations "Vertalingen" 'todo "Todo" 'users "Gebruikers" 'translations "Vertalingen" 'todo "Todo" 'todo-list "Todo-lijst" '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" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden" 'edit "Bewerken" '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)"
'version-summary "Versiesamenvatting (optioneel)" 'search "Zoeken" 'page-history "Paginageschiedenis" 'version-summary "Versiesamenvatting (optioneel)" 'search "Zoeken" 'page-history "Paginageschiedenis"
'back "Terug" 'user-administration "Gebruikersbeheer" 'username "Gebruikersnaam" 'back "Terug" 'user-administration "Gebruikersbeheer" 'username "Gebruikersnaam"
'display-name "Weergavenaam" 'initial-password "Initieel wachtwoord" 'create-user "Gebruiker aanmaken" 'display-name "Weergavenaam" 'initial-password "Initieel wachtwoord" 'create-user "Gebruiker aanmaken"
'sign-in "Inloggen" 'sign-out "Uitloggen" 'password "Wachtwoord" 'sign-in "Inloggen" 'sign-out "Afmelden" 'password "Wachtwoord"
'upload-image "Afbeelding uploaden" 'upload-file "Bestand uploaden" 'saved "Opgeslagen" 'upload-image "Afbeelding uploaden" 'upload-file "Bestand uploaden" 'saved "Opgeslagen"
'offline "Offline" 'online "Online" 'todo-items "Todo-items" 'offline "Offline" 'online "Online" 'todo-items "Todo-items"
'no-todos "Geen todo-items." 'no-matching-pages "Geen overeenkomende pagina's." 'no-todos "Geen todo-items." 'no-matching-pages "Geen overeenkomende pagina's."
@@ -80,6 +82,7 @@
(define (base-translations language) (define (base-translations language)
(if (string-ci=? language "nl") dutch english)) (if (string-ci=? language "nl") dutch english))
(define (current-language config) (define (current-language config)
(with-handlers ((exn:fail? (λ (_e) (wiki-config-language config)))) (with-handlers ((exn:fail? (λ (_e) (wiki-config-language config))))
(if (file-exists? (language-config-path config)) (if (file-exists? (language-config-path config))
@@ -98,9 +101,85 @@
#:exists 'truncate/replace) #:exists 'truncate/replace)
(void)) (void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Return the slug of the special translations page.
; pre : none; language is accepted for compatibility with older callers.
; post : no state is changed.
; result : The fixed wiki-translations slug.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (translation-page-slug [language #f])
"wiki-translations")
(define (translation-page-slug language) (define (translation-value-needs-quotes? value)
(string-append "wiki-translations-" (string-downcase language))) (or (string-contains? value ",")
(string-contains? value "\"")
(not (string=? value (string-trim value)))))
(define (translation-value->text value)
(if (translation-value-needs-quotes? value)
(string-append "\""
(string-replace (string-replace value "\\" "\\\\") "\"" "\\\"")
"\"")
value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Build the initial contents of the special translations page.
; pre : the built-in translation tables are available.
; post : no state is changed.
; result : Text containing every known key with nl and en values.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (translation-page-template)
(define keys
(sort (remove-duplicates (append (hash-keys english) (hash-keys dutch)))
string<?
#:key symbol->string))
(string-join
(for/list ((key (in-list keys)))
(format "~a = nl:~a, en:~a"
(symbol->string key)
(translation-value->text (hash-ref dutch key (hash-ref english key "")))
(translation-value->text (hash-ref english key ""))))
"\n"))
(define (unquote-translation-value value)
(define text (string-trim value))
(if (and (>= (string-length text) 2)
(char=? (string-ref text 0) #\")
(char=? (string-ref text (sub1 (string-length text))) #\"))
(let ((body (substring text 1 (sub1 (string-length text)))))
(string-replace (string-replace body "\\\"" "\"") "\\\\" "\\"))
text))
(define (split-translation-variants text)
(let loop ((i 0)
(start 0)
(quoted? #f)
(escaped? #f)
(result '()))
(if (>= i (string-length text))
(reverse (cons (substring text start i) result))
(let ((ch (string-ref text i)))
(cond
(escaped?
(loop (add1 i) start quoted? #f result))
((and quoted? (char=? ch #\\))
(loop (add1 i) start quoted? #t result))
((char=? ch #\")
(loop (add1 i) start (not quoted?) #f result))
((and (not quoted?) (char=? ch #\,))
(loop (add1 i) (add1 i) #f #f (cons (substring text start i) result)))
(else
(loop (add1 i) start quoted? #f result)))))))
(define (parse-translation-variants text)
(for/fold ((result (hash)))
((part (in-list (split-translation-variants text))))
(define match (regexp-match #px"^\\s*([A-Za-z][A-Za-z0-9_-]*)\\s*:(.*)$" part))
(if match
(hash-set result
(string-downcase (list-ref match 1))
(unquote-translation-value (list-ref match 2)))
result)))
(define (parse-overrides markdown) (define (parse-overrides markdown)
(for/fold ((result (hash))) (for/fold ((result (hash)))
@@ -108,9 +187,10 @@
(define match (define match
(regexp-match #px"^\\s*([A-Za-z0-9._-]+)\\s*=\\s*(.*?)\\s*$" line)) (regexp-match #px"^\\s*([A-Za-z0-9._-]+)\\s*=\\s*(.*?)\\s*$" line))
(if match (if match
(hash-set result (let ((variants (parse-translation-variants (list-ref match 2))))
(string->symbol (list-ref match 1)) (if (zero? (hash-count variants))
(list-ref match 2)) result
(hash-set result (string->symbol (list-ref match 1)) variants)))
result))) result)))
(define (database-overrides config) (define (database-overrides config)
@@ -123,9 +203,18 @@
(define markdown (define markdown
(query-maybe-value db (query-maybe-value db
"SELECT markdown FROM pages WHERE slug = $1 AND archived = FALSE" "SELECT markdown FROM pages WHERE slug = $1 AND archived = FALSE"
(translation-page-slug (current-language config)))) (translation-page-slug)))
(if markdown (parse-overrides markdown) (hash))))))) (if markdown (parse-overrides markdown) (hash)))))))
(define (translation-override-for-language variants language)
(define language-key (string-downcase language))
(cond
((hash-has-key? variants language-key)
(hash-ref variants language-key))
((hash-has-key? variants "en")
(hash-ref variants "en"))
(else #f)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Return all translations active for the configured wiki language. ; goal : Return all translations active for the configured wiki language.
; pre : config is a wiki-config value. ; pre : config is a wiki-config value.
@@ -133,9 +222,13 @@
; result : A hash from translation symbols to strings. ; result : A hash from translation symbols to strings.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (translations-for config) (define (translations-for config)
(for/fold ((result (base-translations (current-language config)))) (define language (current-language config))
(((key value) (in-hash (database-overrides config)))) (for/fold ((result (base-translations language)))
(hash-set result key value))) (((key variants) (in-hash (database-overrides config))))
(define value (translation-override-for-language variants language))
(if value
(hash-set result key value)
result)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Translate one UI key for the configured wiki language. ; goal : Translate one UI key for the configured wiki language.