mirror of https://github.com/requarks/wiki
parent
59ef5e779d
commit
ccb2b702fe
@ -0,0 +1,429 @@
|
|||||||
|
import { createTransport } from 'nodemailer'
|
||||||
|
import type { Transporter } from 'nodemailer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The templates this wiki sends, and what each one needs.
|
||||||
|
*
|
||||||
|
* Two of them are the ones the admin area names under Mail Templates; `test` is the button beside
|
||||||
|
* them. Held as literals rather than rows in a table because nothing sends a mail this wiki did not
|
||||||
|
* ask it to — a template is part of the flow that uses it, and a flow that gained one would have to
|
||||||
|
* gain code here anyway.
|
||||||
|
*/
|
||||||
|
export interface MailTemplateData {
|
||||||
|
welcome: {
|
||||||
|
/** Who the account was created for, as they typed it. */
|
||||||
|
name: string
|
||||||
|
/** Where the site the account was created on lives, without a trailing slash. */
|
||||||
|
baseUrl: string
|
||||||
|
/**
|
||||||
|
* Where to go to confirm the address, when it has to be confirmed at all. Absent on a site whose
|
||||||
|
* local strategy does not validate addresses, where the account is usable as soon as it is made.
|
||||||
|
*
|
||||||
|
* A page that asks, not a link that acts: fetching it confirms nothing, which is what keeps the
|
||||||
|
* mail scanners that follow every link in a message from spending the token before the reader
|
||||||
|
* does.
|
||||||
|
*/
|
||||||
|
verifyUrl?: string
|
||||||
|
}
|
||||||
|
resetPwd: {
|
||||||
|
name: string
|
||||||
|
baseUrl: string
|
||||||
|
/** Where to choose the new password. Stands for the request until it is used or expires. */
|
||||||
|
resetUrl: string
|
||||||
|
}
|
||||||
|
test: {
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A template key, i.e. one of the keys of `MailTemplateData`. */
|
||||||
|
export type MailTemplate = keyof MailTemplateData
|
||||||
|
|
||||||
|
/** What a rendered template is: a subject line and the two bodies every mail carries. */
|
||||||
|
interface RenderedMail {
|
||||||
|
subject: string
|
||||||
|
text: string
|
||||||
|
html: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SMTP settings, as they are stored under the `mail` key of the settings table.
|
||||||
|
*
|
||||||
|
* Everything here is what an administrator typed in the admin area's Mail page, which is also the
|
||||||
|
* only thing that writes it — see `api/mail.ts`.
|
||||||
|
*/
|
||||||
|
interface MailConfig {
|
||||||
|
senderName?: string
|
||||||
|
senderEmail?: string
|
||||||
|
defaultBaseURL?: string
|
||||||
|
host?: string
|
||||||
|
port?: number
|
||||||
|
name?: string
|
||||||
|
secure?: boolean
|
||||||
|
verifySSL?: boolean
|
||||||
|
user?: string
|
||||||
|
pass?: string
|
||||||
|
useDKIM?: boolean
|
||||||
|
dkimDomainName?: string
|
||||||
|
dkimKeySelector?: string
|
||||||
|
dkimPrivateKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One outgoing mail, as the models ask for it. */
|
||||||
|
export interface MailRequest<K extends MailTemplate = MailTemplate> {
|
||||||
|
/** The site the mail is about, which is what names the wiki in it. */
|
||||||
|
siteId: string
|
||||||
|
to: string
|
||||||
|
template: K
|
||||||
|
data: MailTemplateData[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a value out of the template language it is being put into.
|
||||||
|
*
|
||||||
|
* Every substitution below is a name somebody typed or a URL built from a hostname, so all of it goes
|
||||||
|
* through here on the way into the HTML body. The text body needs none of it.
|
||||||
|
*/
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTML body every mail shares: a heading, some paragraphs, at most one button.
|
||||||
|
*
|
||||||
|
* Written as a table with inline styles and no external anything, which is what a mail client will
|
||||||
|
* actually render — the stylesheet, the web font and the background image a page would use are all
|
||||||
|
* either stripped or blocked by the ones people read mail in.
|
||||||
|
*/
|
||||||
|
function htmlShell({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
action,
|
||||||
|
footer
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
/** Paragraphs, already escaped. */
|
||||||
|
body: string[]
|
||||||
|
action?: { label: string; url: string }
|
||||||
|
footer: string
|
||||||
|
}): string {
|
||||||
|
const paragraphs = body
|
||||||
|
.map((p) => `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#37474f;">${p}</p>`)
|
||||||
|
.join('')
|
||||||
|
const button = action
|
||||||
|
? `<p style="margin:0 0 16px;"><a href="${escapeHtml(action.url)}" style="display:inline-block;padding:12px 24px;border-radius:4px;background:#1976d2;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;">${escapeHtml(action.label)}</a></p>` +
|
||||||
|
// -> The same link in full, for the client that will not render the button and for the reader
|
||||||
|
// who wants to see where it goes before following it
|
||||||
|
`<p style="margin:0 0 16px;font-size:12px;line-height:1.6;color:#78909c;word-break:break-all;">${escapeHtml(action.url)}</p>`
|
||||||
|
: ''
|
||||||
|
return [
|
||||||
|
'<!DOCTYPE html>',
|
||||||
|
'<html><body style="margin:0;padding:24px;background:#eceff1;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;">',
|
||||||
|
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:6px;">',
|
||||||
|
'<tr><td style="padding:32px;">',
|
||||||
|
`<h1 style="margin:0 0 24px;font-size:20px;line-height:1.4;color:#263238;">${escapeHtml(title)}</h1>`,
|
||||||
|
paragraphs,
|
||||||
|
button,
|
||||||
|
`<p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #eceff1;font-size:12px;line-height:1.6;color:#90a4ae;">${escapeHtml(footer)}</p>`,
|
||||||
|
'</td></tr></table></body></html>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail model
|
||||||
|
*
|
||||||
|
* The one way anything in this wiki sends an email, and the only place nodemailer is used. Three
|
||||||
|
* flows need it — confirming an address at registration, resetting a forgotten password, and the
|
||||||
|
* admin area's test button — and all three go through `send()`.
|
||||||
|
*
|
||||||
|
* **A wiki with no SMTP settings is the normal case.** Plenty of instances never configure one, so
|
||||||
|
* nothing here throws on its own: `isConfigured` is what a caller asks first, and what decides
|
||||||
|
* whether a flow that needs mail is offered at all. `send()` refuses with `ERR_MAIL_NOT_CONFIGURED`
|
||||||
|
* rather than failing obscurely inside the transport, so a flow that got that far says something
|
||||||
|
* an administrator can act on.
|
||||||
|
*
|
||||||
|
* The transport is built once and kept, and rebuilt when the settings behind it change —
|
||||||
|
* `configFingerprint()` is how that is noticed, rather than an event, because the settings can be
|
||||||
|
* changed on another instance in an HA set and this one would never hear about it.
|
||||||
|
*/
|
||||||
|
class Mail {
|
||||||
|
private transporter: Transporter | null = null
|
||||||
|
private fingerprint = ''
|
||||||
|
|
||||||
|
/** The stored settings, whatever state they are in. */
|
||||||
|
private get config(): MailConfig {
|
||||||
|
return (WIKI.config.mail ?? {}) as MailConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether mail can be sent at all.
|
||||||
|
*
|
||||||
|
* A host and a sender address, which are the two things no default can stand in for. Everything
|
||||||
|
* else has one: a port, whether to use TLS, and credentials that plenty of relays do not want.
|
||||||
|
*/
|
||||||
|
get isConfigured(): boolean {
|
||||||
|
const conf = this.config
|
||||||
|
return Boolean(conf.host?.trim() && conf.senderEmail?.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the current transport was built from. A change here is what invalidates it.
|
||||||
|
*/
|
||||||
|
private configFingerprint(): string {
|
||||||
|
const conf = this.config
|
||||||
|
return JSON.stringify([
|
||||||
|
conf.host,
|
||||||
|
conf.port,
|
||||||
|
conf.name,
|
||||||
|
conf.secure,
|
||||||
|
conf.verifySSL,
|
||||||
|
conf.user,
|
||||||
|
conf.pass,
|
||||||
|
conf.useDKIM,
|
||||||
|
conf.dkimDomainName,
|
||||||
|
conf.dkimKeySelector,
|
||||||
|
conf.dkimPrivateKey
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTransporter(): Transporter {
|
||||||
|
const fingerprint = this.configFingerprint()
|
||||||
|
if (this.transporter && fingerprint === this.fingerprint) {
|
||||||
|
return this.transporter
|
||||||
|
}
|
||||||
|
this.transporter?.close?.()
|
||||||
|
const conf = this.config
|
||||||
|
this.transporter = createTransport({
|
||||||
|
host: conf.host,
|
||||||
|
port: conf.port ?? 465,
|
||||||
|
secure: conf.secure ?? true,
|
||||||
|
// -> The name this client identifies itself as in EHLO. Left off, nodemailer sends the machine
|
||||||
|
// hostname, which is what most relays expect.
|
||||||
|
...(conf.name?.trim() && { name: conf.name.trim() }),
|
||||||
|
// -> No credentials at all rather than empty ones: a relay that authenticates by IP address
|
||||||
|
// refuses an empty AUTH instead of skipping it.
|
||||||
|
...(conf.user?.trim() && {
|
||||||
|
auth: {
|
||||||
|
user: conf.user.trim(),
|
||||||
|
pass: conf.pass ?? ''
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: conf.verifySSL !== false
|
||||||
|
},
|
||||||
|
...(conf.useDKIM &&
|
||||||
|
conf.dkimPrivateKey?.trim() && {
|
||||||
|
dkim: {
|
||||||
|
domainName: conf.dkimDomainName ?? '',
|
||||||
|
keySelector: conf.dkimKeySelector ?? '',
|
||||||
|
privateKey: conf.dkimPrivateKey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.fingerprint = fingerprint
|
||||||
|
return this.transporter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where links in emails point, without a trailing slash.
|
||||||
|
*
|
||||||
|
* Three answers, in the order they are preferred:
|
||||||
|
*
|
||||||
|
* 1. The configured base URL, which is the only one an administrator has actually vouched for. An
|
||||||
|
* instance behind a proxy, on a private address, or answering to several hostnames cannot be
|
||||||
|
* trusted to describe itself to somebody reading a mail somewhere else.
|
||||||
|
* 2. The site's own hostname, which is what the account is on — and which is not necessarily the
|
||||||
|
* host the request came in on: an administrator creating an account for another site is doing
|
||||||
|
* exactly that. Skipped for the wildcard site, which names no host.
|
||||||
|
* 3. What the request was addressed to, which is right often enough that a small instance never has
|
||||||
|
* to configure anything.
|
||||||
|
*
|
||||||
|
* @param req The request that triggered the mail, when there is one
|
||||||
|
* @param siteId The site the mail is about, when it is about one
|
||||||
|
*/
|
||||||
|
baseUrl({
|
||||||
|
req,
|
||||||
|
siteId
|
||||||
|
}: { req?: { protocol: string; host: string }; siteId?: string } = {}): string {
|
||||||
|
const configured = this.config.defaultBaseURL?.trim()
|
||||||
|
if (configured) {
|
||||||
|
return configured.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
const hostname = siteId ? WIKI.sites[siteId]?.hostname : null
|
||||||
|
if (hostname && hostname !== '*') {
|
||||||
|
// -> The scheme the caller was reached by, since the hostname alone does not carry one
|
||||||
|
return `${req?.protocol ?? 'https'}://${hostname}`
|
||||||
|
}
|
||||||
|
if (req) {
|
||||||
|
return `${req.protocol}://${req.host}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to call this wiki in a mail. Per site, since that is what the reader was looking at.
|
||||||
|
*/
|
||||||
|
private siteName(siteId: string): string {
|
||||||
|
return WIKI.sites[siteId]?.config?.title || 'Wiki.js'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one of the templates.
|
||||||
|
*
|
||||||
|
* Both bodies are built from the same values: the text one is what a client that will not render
|
||||||
|
* HTML shows, and is also what keeps the mail out of a spam folder that scores HTML-only mail.
|
||||||
|
*/
|
||||||
|
private render<K extends MailTemplate>(
|
||||||
|
siteName: string,
|
||||||
|
template: K,
|
||||||
|
data: MailTemplateData[K]
|
||||||
|
): RenderedMail {
|
||||||
|
switch (template) {
|
||||||
|
case 'welcome': {
|
||||||
|
const d = data as MailTemplateData['welcome']
|
||||||
|
const footer = `You are receiving this because an account was created for this address on ${siteName}.`
|
||||||
|
if (d.verifyUrl) {
|
||||||
|
return {
|
||||||
|
subject: `Confirm your email address — ${siteName}`,
|
||||||
|
text: [
|
||||||
|
`Hi ${d.name},`,
|
||||||
|
'',
|
||||||
|
`An account was created for this address on ${siteName}. Confirm that it is yours to finish signing up:`,
|
||||||
|
'',
|
||||||
|
d.verifyUrl,
|
||||||
|
'',
|
||||||
|
'This link is valid for 24 hours. If you did not create this account, you can ignore this message.',
|
||||||
|
'',
|
||||||
|
footer
|
||||||
|
].join('\n'),
|
||||||
|
html: htmlShell({
|
||||||
|
title: 'Confirm your email address',
|
||||||
|
body: [
|
||||||
|
`Hi ${escapeHtml(d.name)},`,
|
||||||
|
`An account was created for this address on ${escapeHtml(siteName)}. Confirm that it is yours to finish signing up.`,
|
||||||
|
'This link is valid for 24 hours. If you did not create this account, you can ignore this message.'
|
||||||
|
],
|
||||||
|
action: { label: 'Confirm my email address', url: d.verifyUrl },
|
||||||
|
footer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subject: `Welcome to ${siteName}`,
|
||||||
|
text: [
|
||||||
|
`Hi ${d.name},`,
|
||||||
|
'',
|
||||||
|
`Your account on ${siteName} is ready. You can sign in at any time:`,
|
||||||
|
'',
|
||||||
|
`${d.baseUrl}/login`,
|
||||||
|
'',
|
||||||
|
footer
|
||||||
|
].join('\n'),
|
||||||
|
html: htmlShell({
|
||||||
|
title: `Welcome to ${escapeHtml(siteName)}`,
|
||||||
|
body: [
|
||||||
|
`Hi ${escapeHtml(d.name)},`,
|
||||||
|
'Your account is ready. You can sign in at any time.'
|
||||||
|
],
|
||||||
|
action: { label: 'Go to the wiki', url: `${d.baseUrl}/login` },
|
||||||
|
footer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'resetPwd': {
|
||||||
|
const d = data as MailTemplateData['resetPwd']
|
||||||
|
const footer = `You are receiving this because a password reset was requested for this address on ${siteName}.`
|
||||||
|
return {
|
||||||
|
subject: `Reset your password — ${siteName}`,
|
||||||
|
text: [
|
||||||
|
`Hi ${d.name},`,
|
||||||
|
'',
|
||||||
|
`Somebody asked to reset the password for your account on ${siteName}. Choose a new one here:`,
|
||||||
|
'',
|
||||||
|
d.resetUrl,
|
||||||
|
'',
|
||||||
|
'This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.',
|
||||||
|
'',
|
||||||
|
footer
|
||||||
|
].join('\n'),
|
||||||
|
html: htmlShell({
|
||||||
|
title: 'Reset your password',
|
||||||
|
body: [
|
||||||
|
`Hi ${escapeHtml(d.name)},`,
|
||||||
|
`Somebody asked to reset the password for your account on ${escapeHtml(siteName)}.`,
|
||||||
|
'This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.'
|
||||||
|
],
|
||||||
|
action: { label: 'Choose a new password', url: d.resetUrl },
|
||||||
|
footer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const d = data as MailTemplateData['test']
|
||||||
|
const footer =
|
||||||
|
'You are receiving this because somebody sent a test email from the Wiki.js admin area.'
|
||||||
|
return {
|
||||||
|
subject: `Test email — ${siteName}`,
|
||||||
|
text: [
|
||||||
|
'This is a test email.',
|
||||||
|
'',
|
||||||
|
`If you are reading it, ${siteName} can send mail through the SMTP server it is configured with.`,
|
||||||
|
'',
|
||||||
|
d.baseUrl,
|
||||||
|
'',
|
||||||
|
footer
|
||||||
|
].join('\n'),
|
||||||
|
html: htmlShell({
|
||||||
|
title: 'This is a test email',
|
||||||
|
body: [
|
||||||
|
`If you are reading it, ${escapeHtml(siteName)} can send mail through the SMTP server it is configured with.`
|
||||||
|
],
|
||||||
|
footer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one mail, and wait for the relay to have taken it.
|
||||||
|
*
|
||||||
|
* Waiting is deliberate: every caller has something to tell the user about the result — a
|
||||||
|
* registration that says to go and check, a reset that says the same, a test button whose entire
|
||||||
|
* purpose is the answer — and a queued send would have to report success before it knew.
|
||||||
|
*
|
||||||
|
* @throws `ERR_MAIL_NOT_CONFIGURED` when there is no SMTP server to send through, and whatever
|
||||||
|
* nodemailer raises for a send that was attempted and failed
|
||||||
|
*/
|
||||||
|
async send<K extends MailTemplate>({
|
||||||
|
siteId,
|
||||||
|
to,
|
||||||
|
template,
|
||||||
|
data
|
||||||
|
}: MailRequest<K>): Promise<void> {
|
||||||
|
if (!this.isConfigured) {
|
||||||
|
throw new Error('ERR_MAIL_NOT_CONFIGURED')
|
||||||
|
}
|
||||||
|
const conf = this.config
|
||||||
|
const siteName = this.siteName(siteId)
|
||||||
|
const { subject, text, html } = this.render(siteName, template, data)
|
||||||
|
WIKI.logger.debug(`Sending ${template} email to <${to}>...`)
|
||||||
|
await this.getTransporter().sendMail({
|
||||||
|
from: {
|
||||||
|
name: conf.senderName?.trim() || siteName,
|
||||||
|
address: conf.senderEmail!.trim()
|
||||||
|
},
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html
|
||||||
|
})
|
||||||
|
WIKI.logger.info(`Sent ${template} email to <${to}>.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mail = new Mail()
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@ -0,0 +1,996 @@
|
|||||||
|
/**
|
||||||
|
* The pages the admin area's **Generate Sample Content** writes, and the tag it puts on them.
|
||||||
|
*
|
||||||
|
* Content rather than code: a development instance starts empty, and checking a stylesheet, a
|
||||||
|
* renderer or the navigation against it means writing dummy pages by hand first. This is that
|
||||||
|
* writing, done once.
|
||||||
|
*
|
||||||
|
* **Loaded on demand.** `AdminUtilities.vue` imports this dynamically, so the whole set sits in a
|
||||||
|
* chunk nobody fetches unless they press the button.
|
||||||
|
*
|
||||||
|
* Generated here rather than on the server for one reason: a page stores the HTML its editor produced,
|
||||||
|
* and the only markdown renderer this project has is the one in `renderers/markdown.js`, which runs in
|
||||||
|
* a browser. Rendering server-side means driving a headless browser through the Puppeteer extension,
|
||||||
|
* which a plain checkout does not install — so a backend generator would write pages that are blank
|
||||||
|
* until somebody re-renders them, which is precisely the opposite of the point.
|
||||||
|
*
|
||||||
|
* Paths are absolute from the site root, and the folders in them are created by the tree as each page
|
||||||
|
* lands. Links between the pages are written the same way, which is what makes the set navigable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tag every page here carries, and the only thing the purge looks for.
|
||||||
|
*
|
||||||
|
* **Also written in `backend/api/system.ts`** as `SAMPLE_CONTENT_TAG`, since the three workspaces
|
||||||
|
* share no package. Changing one without the other leaves content nothing will clean up.
|
||||||
|
*/
|
||||||
|
export const SAMPLE_CONTENT_TAG = 'test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} SamplePage
|
||||||
|
* @property {string} path Absolute from the site root, without a leading slash.
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string} icon An Iconify reference, materialized before the pages are written.
|
||||||
|
* @property {string[]} tags Beside {@link SAMPLE_CONTENT_TAG}, which is added to every page.
|
||||||
|
* @property {string} content Markdown source. The render is produced from it at generation time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {SamplePage[]} */
|
||||||
|
export const SAMPLE_PAGES = [
|
||||||
|
{
|
||||||
|
path: 'sample/home',
|
||||||
|
title: 'Sample Content',
|
||||||
|
description: 'A tour of everything a page can do in this wiki.',
|
||||||
|
icon: 'mdi:book-open-variant',
|
||||||
|
tags: ['guide'],
|
||||||
|
content: `# Sample Content
|
||||||
|
|
||||||
|
Every page under here was written by **Generate Sample Content** in the admin area's Utilities
|
||||||
|
page. It exists so a fresh instance has something to look at — formatting to check a stylesheet
|
||||||
|
against, blocks to check a renderer against, and a folder tree deep enough to exercise navigation.
|
||||||
|
|
||||||
|
> [!NOTE] Everything here is disposable
|
||||||
|
> Every one of these pages carries the \`test\` tag. **Purge Sample Content**, on the same Utilities
|
||||||
|
> page, deletes exactly those and nothing else.
|
||||||
|
|
||||||
|
## Formatting
|
||||||
|
|
||||||
|
How the markdown renderer draws the ordinary things.
|
||||||
|
|
||||||
|
- [Text Formatting](/sample/formatting/text) — headings, emphasis, and the inline marks
|
||||||
|
- [Lists and Tasks](/sample/formatting/lists) — bullets, numbers, definitions, checkboxes
|
||||||
|
- [Tables](/sample/formatting/tables) — alignment, spans of content, a wide one that scrolls
|
||||||
|
- [Code Blocks](/sample/formatting/code) — titles, line numbering, highlighted lines
|
||||||
|
- [Alerts and Quotes](/sample/formatting/alerts) — the five GitHub alert kinds
|
||||||
|
- [Links, Images and Footnotes](/sample/formatting/media) — how a page points elsewhere
|
||||||
|
|
||||||
|
## Blocks
|
||||||
|
|
||||||
|
The web components a page can embed. Each is a \`::block-name\` in the source.
|
||||||
|
|
||||||
|
- [Tabs](/sample/blocks/tabs)
|
||||||
|
- [Diagrams](/sample/blocks/diagrams)
|
||||||
|
- [Mathematics](/sample/blocks/math)
|
||||||
|
- [Infoboxes and Spoilers](/sample/blocks/callouts)
|
||||||
|
- [Widgets](/sample/blocks/widgets)
|
||||||
|
- [Index and Include](/sample/blocks/navigation)
|
||||||
|
|
||||||
|
## A folder tree to walk
|
||||||
|
|
||||||
|
- [Getting Started](/sample/guides/getting-started/installation) — three pages, two levels down
|
||||||
|
- [Advanced](/sample/guides/advanced/permissions) — three more beside them
|
||||||
|
- [Reference](/sample/reference/glossary) — a glossary, an API page and a changelog
|
||||||
|
|
||||||
|
## What is under here
|
||||||
|
|
||||||
|
::block-index{path="sample" depth="2" columns="2" showIcons="true"}
|
||||||
|
::
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/formatting/text',
|
||||||
|
title: 'Text Formatting',
|
||||||
|
description: 'Headings, emphasis, and every inline mark the renderer understands.',
|
||||||
|
icon: 'mdi:format-text',
|
||||||
|
tags: ['formatting'],
|
||||||
|
content: `# Text Formatting
|
||||||
|
|
||||||
|
The first heading on a page is its title in the table of contents; everything below nests under it.
|
||||||
|
|
||||||
|
## Second level
|
||||||
|
|
||||||
|
### Third level
|
||||||
|
|
||||||
|
#### Fourth level
|
||||||
|
|
||||||
|
Regular text, with *emphasis*, **strong emphasis**, ***both at once***, ~~struck through~~ and
|
||||||
|
\`inline code\`. The renderer also draws ==highlighted text==, H~2~O as a subscript and E=mc^2^ as a
|
||||||
|
superscript.
|
||||||
|
|
||||||
|
Typography is applied where it is turned on: "quotes" become curly ones, -- becomes an en dash,
|
||||||
|
--- an em dash, and ... an ellipsis.
|
||||||
|
|
||||||
|
## Abbreviations
|
||||||
|
|
||||||
|
The HTML spec is what a browser implements, and CSS is what it paints with.
|
||||||
|
|
||||||
|
*[HTML]: HyperText Markup Language
|
||||||
|
*[CSS]: Cascading Style Sheets
|
||||||
|
|
||||||
|
## A horizontal rule
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Line breaks
|
||||||
|
|
||||||
|
A paragraph is separated by a blank line.
|
||||||
|
This line follows a single newline, which is a break only where the editor has soft breaks on.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Lists and Tasks](/sample/formatting/lists)
|
||||||
|
- [Alerts and Quotes](/sample/formatting/alerts)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/formatting/lists',
|
||||||
|
title: 'Lists and Tasks',
|
||||||
|
description: 'Bulleted, numbered, nested, definition and task lists.',
|
||||||
|
icon: 'mdi:format-list-bulleted',
|
||||||
|
tags: ['formatting'],
|
||||||
|
content: `# Lists and Tasks
|
||||||
|
|
||||||
|
## Bulleted
|
||||||
|
|
||||||
|
- A first item
|
||||||
|
- A second item
|
||||||
|
- Nested one level
|
||||||
|
- And another
|
||||||
|
- Two levels down
|
||||||
|
- Back to the top level
|
||||||
|
|
||||||
|
## Numbered
|
||||||
|
|
||||||
|
1. Install the wiki
|
||||||
|
2. Configure a storage target
|
||||||
|
3. Write a page
|
||||||
|
1. Give it a title
|
||||||
|
2. Give it some content
|
||||||
|
4. Publish it
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Write the sample content generator
|
||||||
|
- [x] Tag every page it writes
|
||||||
|
- [ ] Decide what to have for lunch
|
||||||
|
- [ ] Purge it all again
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
Page
|
||||||
|
: A document in the wiki, addressed by its path.
|
||||||
|
|
||||||
|
Folder
|
||||||
|
: A branch of the tree. It holds pages and other folders, and is not a page itself.
|
||||||
|
|
||||||
|
Block
|
||||||
|
: A web component embedded in a page's source with \`::block-name\`.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Text Formatting](/sample/formatting/text)
|
||||||
|
- [Tables](/sample/formatting/tables)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/formatting/tables',
|
||||||
|
title: 'Tables',
|
||||||
|
description:
|
||||||
|
'Column alignment, inline formatting inside cells, and a table wide enough to scroll.',
|
||||||
|
icon: 'mdi:table',
|
||||||
|
tags: ['formatting'],
|
||||||
|
content: `# Tables
|
||||||
|
|
||||||
|
## Alignment
|
||||||
|
|
||||||
|
| Left | Centered | Right |
|
||||||
|
| :---------- | :----------: | ------------: |
|
||||||
|
| \`markdown\` | Default | 42 |
|
||||||
|
| \`html\` | WYSIWYG | 1,024 |
|
||||||
|
| \`asciidoc\` | Optional | 7 |
|
||||||
|
|
||||||
|
## Formatting inside cells
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
| -------------------- | --------- | --------------------------------------------------- |
|
||||||
|
| **\`sitePrefix\`** | \`false\` | Files the tree under a folder named after the site |
|
||||||
|
| **\`localePrefix\`** | \`true\` | Brackets the tree by locale |
|
||||||
|
| **\`largeThreshold\`** | \`10 MB\` | The size at which a file becomes *large* |
|
||||||
|
|
||||||
|
## A wide one
|
||||||
|
|
||||||
|
A table wider than the page scrolls inside its own box rather than stretching it.
|
||||||
|
|
||||||
|
| Target | Reads | Writes | Presigns | History | Notes |
|
||||||
|
| ------ | :---: | :----: | :------: | :-----: | ----------------------------------------- |
|
||||||
|
| \`db\` | yes | yes | no | no | Always on, cannot be turned off |
|
||||||
|
| \`disk\` | yes | yes | no | no | The wiki's tree as files on a filesystem |
|
||||||
|
| \`git\` | yes | yes | no | yes | The same tree, committed and synced |
|
||||||
|
| \`s3\` | yes | yes | yes | no | S3 and anything speaking its API |
|
||||||
|
| \`azure\`| yes | yes | yes | no | Azure Blob Storage |
|
||||||
|
| \`gcs\` | yes | yes | yes | no | Google Cloud Storage |
|
||||||
|
| \`sftp\` | yes | yes | no | no | A copy, never a delivery source |
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Code Blocks](/sample/formatting/code)
|
||||||
|
- [Storage Targets](/sample/guides/advanced/storage)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/formatting/code',
|
||||||
|
title: 'Code Blocks',
|
||||||
|
description: 'Fenced code with a title, a starting line number and highlighted lines.',
|
||||||
|
icon: 'mdi:code-braces',
|
||||||
|
tags: ['formatting'],
|
||||||
|
content: `# Code Blocks
|
||||||
|
|
||||||
|
A plain fence, with the language named:
|
||||||
|
|
||||||
|
\`\`\`js
|
||||||
|
const wiki = await connect()
|
||||||
|
await wiki.pages.create({ path: 'home', title: 'Home' })
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## With a title
|
||||||
|
|
||||||
|
\`\`\`ts title="backend/models/mail.ts"
|
||||||
|
export const mail = new Mail()
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Numbered from somewhere else
|
||||||
|
|
||||||
|
Useful when the excerpt starts partway through a file.
|
||||||
|
|
||||||
|
\`\`\`ts title="api/authentication.ts" linesStart=482
|
||||||
|
app.post('/sites/:siteId/auth/verifyEmail', {
|
||||||
|
config: { publicAccess: true },
|
||||||
|
onRequest: limitAuthAttempts
|
||||||
|
}, async (req, reply) => {
|
||||||
|
await WIKI.models.users.verifyUserEmail(req.body.token)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## With lines called out
|
||||||
|
|
||||||
|
\`\`\`js title="fetchStrategies" linesHighlight="3,6-8"
|
||||||
|
async function fetchStrategies() {
|
||||||
|
try {
|
||||||
|
state.strategies = await API_CLIENT.get('auth/strategies').json()
|
||||||
|
} catch (err) {
|
||||||
|
notify({ type: 'negative', message: err.message })
|
||||||
|
} finally {
|
||||||
|
state.strategiesLoaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Other languages
|
||||||
|
|
||||||
|
\`\`\`yaml title="config.yml"
|
||||||
|
port: 3000
|
||||||
|
db:
|
||||||
|
host: db
|
||||||
|
user: postgres
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`sql
|
||||||
|
SELECT "folderPath", "fileName" FROM tree WHERE tree = 'folder';
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
node backend
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Tables](/sample/formatting/tables)
|
||||||
|
- [Diagrams](/sample/blocks/diagrams)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/formatting/alerts',
|
||||||
|
title: 'Alerts and Quotes',
|
||||||
|
description: 'The five GitHub alert kinds, and ordinary block quotes.',
|
||||||
|
icon: 'mdi:alert-circle-outline',
|
||||||
|
tags: ['formatting'],
|
||||||
|
content: `# Alerts and Quotes
|
||||||
|
|
||||||
|
## The five kinds
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Useful information a reader should take in even when skimming.
|
||||||
|
|
||||||
|
> [!TIP] Give it a title of your own
|
||||||
|
> Text after the marker replaces the label, which is how an aside says what it is about rather than
|
||||||
|
> only what kind of thing it is.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Something the reader needs in order to succeed at what they came here to do.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Something that deserves immediate attention to avoid a problem.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> A risk of something going irreversibly wrong.
|
||||||
|
|
||||||
|
## Ordinary quotes
|
||||||
|
|
||||||
|
> A block quote is not an alert. It is somebody else's words.
|
||||||
|
>
|
||||||
|
> — Someone, probably
|
||||||
|
|
||||||
|
Quotes nest:
|
||||||
|
|
||||||
|
> The outer quote.
|
||||||
|
>
|
||||||
|
> > And one inside it.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Text Formatting](/sample/formatting/text)
|
||||||
|
- [Infoboxes and Spoilers](/sample/blocks/callouts)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/formatting/media',
|
||||||
|
title: 'Links, Images and Footnotes',
|
||||||
|
description: 'How a page points at another page, at a file, and at a note of its own.',
|
||||||
|
icon: 'mdi:link-variant',
|
||||||
|
tags: ['formatting'],
|
||||||
|
content: `# Links, Images and Footnotes
|
||||||
|
|
||||||
|
## Links within the wiki
|
||||||
|
|
||||||
|
An absolute path addresses a page from the site root: [the glossary](/sample/reference/glossary),
|
||||||
|
[the permissions guide](/sample/guides/advanced/permissions), or
|
||||||
|
[three folders down](/sample/guides/getting-started/first-page).
|
||||||
|
|
||||||
|
A link can also carry a fragment, to land on a heading: [straight to the tables](/sample/formatting/tables#a-wide-one).
|
||||||
|
|
||||||
|
## Links that leave
|
||||||
|
|
||||||
|
[The Wiki.js website](https://js.wiki) is marked as external by the renderer, because it resolves to
|
||||||
|
a different origin than the page it is written on.
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
An image is addressed the way a file beside the page would be, and resolved at render time — so the
|
||||||
|
source stays readable if the page is ever exported to a repository.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Footnotes
|
||||||
|
|
||||||
|
The storage system writes to every target that claims a content type[^write] and reads from exactly
|
||||||
|
one[^read].
|
||||||
|
|
||||||
|
[^write]: An upload goes to all of them; a write that fails anywhere fails the upload.
|
||||||
|
[^read]: \`assetDelivery.servedTypes\` names it, at most one target per type.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Index and Include](/sample/blocks/navigation)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/blocks/tabs',
|
||||||
|
title: 'Tabs',
|
||||||
|
description: 'Content split across tabbed panels.',
|
||||||
|
icon: 'mdi:tab',
|
||||||
|
tags: ['blocks'],
|
||||||
|
content: `# Tabs
|
||||||
|
|
||||||
|
A set of tabs is fenced with three colons, because the panels inside it are blocks of their own.
|
||||||
|
|
||||||
|
:::block-tabs
|
||||||
|
::block-tab{label="npm"}
|
||||||
|
Install the dependencies from the workspace directory:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
npm install
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
::block-tab{label="Docker"}
|
||||||
|
Or build the production image:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
docker build -f dev/build/Dockerfile -t wikijs .
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
::block-tab{label="From source"}
|
||||||
|
Node 26 runs the backend's TypeScript directly, so there is no build step:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
node backend
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Tabs with icons
|
||||||
|
|
||||||
|
:::block-tabs
|
||||||
|
::block-tab{label="Linux" icon="mdi:linux"}
|
||||||
|
Everything the wiki needs is in the package manager.
|
||||||
|
::
|
||||||
|
|
||||||
|
::block-tab{label="macOS" icon="mdi:apple"}
|
||||||
|
Homebrew has Node and PostgreSQL.
|
||||||
|
::
|
||||||
|
|
||||||
|
::block-tab{label="Windows" icon="mdi:microsoft-windows"}
|
||||||
|
Use the installers, or WSL.
|
||||||
|
::
|
||||||
|
:::
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Diagrams](/sample/blocks/diagrams)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/blocks/diagrams',
|
||||||
|
title: 'Diagrams',
|
||||||
|
description: 'Mermaid and Kroki, rendered in the page.',
|
||||||
|
icon: 'mdi:sitemap-outline',
|
||||||
|
tags: ['blocks'],
|
||||||
|
content: `# Diagrams
|
||||||
|
|
||||||
|
## Mermaid
|
||||||
|
|
||||||
|
::block-diagram{caption="What happens when somebody registers" align="center"}
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Register form] --> B{Email validation on?}
|
||||||
|
B -->|No| C[Signed in straight away]
|
||||||
|
B -->|Yes| D[Account created unverified]
|
||||||
|
D --> E[Confirmation email sent]
|
||||||
|
E --> F[Reader presses Confirm]
|
||||||
|
F --> G[Account verified]
|
||||||
|
G --> H[Sign in]
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
## A sequence
|
||||||
|
|
||||||
|
::block-diagram{caption="A password reset, end to end"}
|
||||||
|
\`\`\`mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant R as Reader
|
||||||
|
participant W as Wiki
|
||||||
|
participant M as Mail server
|
||||||
|
R->>W: I forgot my password
|
||||||
|
W->>M: Send a reset link
|
||||||
|
W-->>R: Check your email
|
||||||
|
M-->>R: Reset link
|
||||||
|
R->>W: Here is my new password
|
||||||
|
W-->>R: Done, sign in
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
## Kroki
|
||||||
|
|
||||||
|
::block-kroki{type="graphviz" caption="A tiny graph"}
|
||||||
|
\`\`\`kroki
|
||||||
|
digraph G {
|
||||||
|
rankdir=LR
|
||||||
|
Pages -> Tree
|
||||||
|
Pages -> Storage
|
||||||
|
Storage -> Disk
|
||||||
|
Storage -> Git
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Mathematics](/sample/blocks/math)
|
||||||
|
- [Code Blocks](/sample/formatting/code)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/blocks/math',
|
||||||
|
title: 'Mathematics',
|
||||||
|
description: 'Formulas rendered with KaTeX and MathJax.',
|
||||||
|
icon: 'mdi:function-variant',
|
||||||
|
tags: ['blocks'],
|
||||||
|
content: `# Mathematics
|
||||||
|
|
||||||
|
## KaTeX
|
||||||
|
|
||||||
|
::block-katex{caption="The quadratic formula"}
|
||||||
|
\`\`\`latex
|
||||||
|
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
::block-katex{align="left"}
|
||||||
|
\`\`\`latex
|
||||||
|
\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
## MathJax
|
||||||
|
|
||||||
|
::block-mathjax{caption="Euler's identity"}
|
||||||
|
\`\`\`latex
|
||||||
|
e^{i\\pi} + 1 = 0
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Diagrams](/sample/blocks/diagrams)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/blocks/callouts',
|
||||||
|
title: 'Infoboxes and Spoilers',
|
||||||
|
description: 'A summary box beside the text, and content hidden until it is asked for.',
|
||||||
|
icon: 'mdi:card-text-outline',
|
||||||
|
tags: ['blocks'],
|
||||||
|
content: `# Infoboxes and Spoilers
|
||||||
|
|
||||||
|
## An infobox
|
||||||
|
|
||||||
|
::block-infobox{name="Wiki.js" image="/_assets/logo-wikijs.svg" imageCaption="The project logo"}
|
||||||
|
\`\`\`yaml
|
||||||
|
Written in: JavaScript and TypeScript
|
||||||
|
License: AGPL-3.0
|
||||||
|
Database: PostgreSQL 16+
|
||||||
|
Runtime: Node.js 26+
|
||||||
|
Website: https://js.wiki
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
The box floats beside the text on a wide screen and stacks above it on a narrow one, so a paragraph
|
||||||
|
of ordinary content is needed to see the difference. This is that paragraph, and it goes on a little
|
||||||
|
longer than it strictly needs to for exactly that reason.
|
||||||
|
|
||||||
|
## A spoiler
|
||||||
|
|
||||||
|
::block-spoiler{label="The answer" hint="Click to reveal"}
|
||||||
|
Forty-two. The content is laid out either way and only hidden from view, so nothing below the box
|
||||||
|
moves when it opens.
|
||||||
|
::
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Alerts and Quotes](/sample/formatting/alerts)
|
||||||
|
- [Widgets](/sample/blocks/widgets)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/blocks/widgets',
|
||||||
|
title: 'Widgets',
|
||||||
|
description: 'A QR code and a countdown.',
|
||||||
|
icon: 'mdi:widgets-outline',
|
||||||
|
tags: ['blocks'],
|
||||||
|
content: `# Widgets
|
||||||
|
|
||||||
|
## QR code
|
||||||
|
|
||||||
|
::block-qr-code{value="https://js.wiki" size="180" caption="js.wiki"}
|
||||||
|
::
|
||||||
|
|
||||||
|
## Countdown
|
||||||
|
|
||||||
|
::block-countdown{date="2030-01-01T00:00:00Z" label="Until 2030" expiredMsg="It is 2030."}
|
||||||
|
::
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Infoboxes and Spoilers](/sample/blocks/callouts)
|
||||||
|
- [Index and Include](/sample/blocks/navigation)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/blocks/navigation',
|
||||||
|
title: 'Index and Include',
|
||||||
|
description: 'Listing the pages under a folder, and pulling one page into another.',
|
||||||
|
icon: 'mdi:file-tree-outline',
|
||||||
|
tags: ['blocks'],
|
||||||
|
content: `# Index and Include
|
||||||
|
|
||||||
|
## An index of a folder
|
||||||
|
|
||||||
|
Everything filed under the guides, two levels deep:
|
||||||
|
|
||||||
|
::block-index{path="sample/guides" depth="2" columns="2" showIcons="true"}
|
||||||
|
::
|
||||||
|
|
||||||
|
## An index by tag
|
||||||
|
|
||||||
|
Every page in this sample set carries the \`test\` tag, which is also what the purge action looks for:
|
||||||
|
|
||||||
|
::block-index{tags="blocks" limit="10" orderBy="title"}
|
||||||
|
::
|
||||||
|
|
||||||
|
## Including another page
|
||||||
|
|
||||||
|
The glossary, rendered inside this one:
|
||||||
|
|
||||||
|
::block-include{path="sample/reference/glossary" showTitle="true"}
|
||||||
|
::
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Links, Images and Footnotes](/sample/formatting/media)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/guides/getting-started/installation',
|
||||||
|
title: 'Installation',
|
||||||
|
description: 'What the wiki needs before it will start.',
|
||||||
|
icon: 'mdi:download',
|
||||||
|
tags: ['guide'],
|
||||||
|
content: `# Installation
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Component | Version |
|
||||||
|
| ---------- | --------- |
|
||||||
|
| Node.js | 26 or later |
|
||||||
|
| PostgreSQL | 16 or later |
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Install the dependencies in each workspace — they are installed separately, and there is no root
|
||||||
|
package.
|
||||||
|
2. Copy \`config.sample.yml\` to \`config.yml\` and point it at your database.
|
||||||
|
3. Build the frontend, which is what the backend serves.
|
||||||
|
4. Start the backend from the repository root.
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
cd backend && npm install
|
||||||
|
cd ../frontend && npm install && npm run build
|
||||||
|
cd .. && node backend
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
> [!TIP] The dev container does all of this
|
||||||
|
> Open the repository in the dev container and it installs everything, brings up PostgreSQL, pgAdmin
|
||||||
|
> and a mail server, and leaves you at a prompt.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [Configuration](/sample/guides/getting-started/configuration)
|
||||||
|
- [Your First Page](/sample/guides/getting-started/first-page)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/guides/getting-started/configuration',
|
||||||
|
title: 'Configuration',
|
||||||
|
description: 'Where settings live, and which of them are files.',
|
||||||
|
icon: 'mdi:cog-outline',
|
||||||
|
tags: ['guide'],
|
||||||
|
content: `# Configuration
|
||||||
|
|
||||||
|
Settings come from three places, merged in this order:
|
||||||
|
|
||||||
|
1. \`base.yml\` — the defaults for every key, which defines the shape.
|
||||||
|
2. \`config.yml\` — what this instance overrides, and the only one an operator edits.
|
||||||
|
3. The \`settings\` table — everything the admin area writes.
|
||||||
|
|
||||||
|
::block-infobox{name="config.yml"}
|
||||||
|
\`\`\`yaml
|
||||||
|
Read at: boot
|
||||||
|
Also read by: the frontend dev server
|
||||||
|
Holds: port, database, data path
|
||||||
|
Never holds: anything the admin area can change
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
## What belongs where
|
||||||
|
|
||||||
|
A value an operator sets before the wiki starts belongs in \`config.yml\`. A value an administrator
|
||||||
|
changes while it is running belongs in the database, because changing it must not need a restart.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> \`base.yml\` is not a user-facing config. It defines the shape of what the other two merge into.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [Your First Page](/sample/guides/getting-started/first-page)
|
||||||
|
- [Storage Targets](/sample/guides/advanced/storage)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/guides/getting-started/first-page',
|
||||||
|
title: 'Your First Page',
|
||||||
|
description: 'Writing, saving and publishing.',
|
||||||
|
icon: 'mdi:file-document-edit-outline',
|
||||||
|
tags: ['guide'],
|
||||||
|
content: `# Your First Page
|
||||||
|
|
||||||
|
## Choose an editor
|
||||||
|
|
||||||
|
:::block-tabs
|
||||||
|
::block-tab{label="Markdown"}
|
||||||
|
The default. The source is markdown, and the editor renders a live preview beside it.
|
||||||
|
::
|
||||||
|
|
||||||
|
::block-tab{label="Visual"}
|
||||||
|
A WYSIWYG editor that stores HTML.
|
||||||
|
::
|
||||||
|
|
||||||
|
::block-tab{label="Redirect"}
|
||||||
|
Not a document at all — a page whose only content is where it points.
|
||||||
|
::
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Save
|
||||||
|
|
||||||
|
A save asks for a reason, which is recorded on the version rather than on the page. That is what a
|
||||||
|
history timeline is made of.
|
||||||
|
|
||||||
|
- [x] Give the page a title
|
||||||
|
- [x] Write something
|
||||||
|
- [ ] Add it to the navigation
|
||||||
|
- [ ] Tell somebody about it
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [Permissions](/sample/guides/advanced/permissions)
|
||||||
|
- [Search](/sample/guides/advanced/search)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/guides/advanced/permissions',
|
||||||
|
title: 'Permissions',
|
||||||
|
description: 'The two kinds, and why they are not interchangeable.',
|
||||||
|
icon: 'mdi:shield-key-outline',
|
||||||
|
tags: ['guide'],
|
||||||
|
content: `# Permissions
|
||||||
|
|
||||||
|
There are two kinds, granted separately and checked in different places.
|
||||||
|
|
||||||
|
## Global permissions
|
||||||
|
|
||||||
|
Held site-wide, bound to no path. \`access:admin\`, \`manage:users\`, \`manage:groups\`,
|
||||||
|
\`manage:navigation\`, \`manage:theme\`, \`manage:sites\`, \`manage:system\`. That list is the whole of it.
|
||||||
|
|
||||||
|
\`manage:system\` bypasses every check everywhere.
|
||||||
|
|
||||||
|
## Page rule permissions
|
||||||
|
|
||||||
|
Bound to paths, and to locales and sites. A group grants them through **rules**: each rule names some
|
||||||
|
permissions, says how it addresses pages, and says what it does with them.
|
||||||
|
|
||||||
|
| Mode | What it means |
|
||||||
|
| ------------ | -------------------------------------------- |
|
||||||
|
| \`ALLOW\` | Grant these, unless something more specific denies |
|
||||||
|
| \`DENY\` | Refuse these |
|
||||||
|
| \`FORCEALLOW\` | Grant these, and let nothing override it |
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> A page permission cannot be enforced by a route-level check — that reads the group-wide list only,
|
||||||
|
> so declaring one there refuses everybody.
|
||||||
|
|
||||||
|
::block-spoiler{label="Which kind is \`manage:pages\`?" hint="Click to check yourself"}
|
||||||
|
A page rule permission. It does not imply \`write:pages\` either — a rule grants the exact strings it
|
||||||
|
names.
|
||||||
|
::
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Storage Targets](/sample/guides/advanced/storage)
|
||||||
|
- [Glossary](/sample/reference/glossary)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/guides/advanced/storage',
|
||||||
|
title: 'Storage Targets',
|
||||||
|
description: 'Where a page goes when it is saved.',
|
||||||
|
icon: 'mdi:database-outline',
|
||||||
|
tags: ['guide'],
|
||||||
|
content: `# Storage Targets
|
||||||
|
|
||||||
|
Content is **written** to every target that claims it, and **read** from one. Those are two separate
|
||||||
|
questions with two separate answers.
|
||||||
|
|
||||||
|
::block-diagram{caption="One upload, several destinations"}
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart LR
|
||||||
|
U[Upload] --> S{Which targets claim this type?}
|
||||||
|
S --> DB[(Database)]
|
||||||
|
S --> D[Disk]
|
||||||
|
S --> G[Git]
|
||||||
|
DB --> R[Served to readers]
|
||||||
|
\`\`\`
|
||||||
|
::
|
||||||
|
|
||||||
|
## The targets that ship
|
||||||
|
|
||||||
|
| Key | What it is |
|
||||||
|
| ------- | ------------------------------------------------- |
|
||||||
|
| \`db\` | Bytes in the asset's own row. Always on. |
|
||||||
|
| \`disk\` | The wiki's tree as files |
|
||||||
|
| \`git\` | That same tree, with history and a remote |
|
||||||
|
| \`s3\` | S3, and anything speaking its API |
|
||||||
|
| \`azure\` | Azure Blob Storage |
|
||||||
|
| \`gcs\` | Google Cloud Storage |
|
||||||
|
| \`sftp\` | The tree on another host. A copy, never a source. |
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> A pull from a git remote is authoritative, and that includes deletions. Push access to the remote
|
||||||
|
> is effectively write access to the wiki.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Configuration](/sample/guides/getting-started/configuration)
|
||||||
|
- [Search](/sample/guides/advanced/search)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/guides/advanced/search',
|
||||||
|
title: 'Search',
|
||||||
|
description: 'What is indexed, and when.',
|
||||||
|
icon: 'mdi:magnify',
|
||||||
|
tags: ['guide'],
|
||||||
|
content: `# Search
|
||||||
|
|
||||||
|
A page is indexed from its rendered HTML rather than its source, which is why a block's output is
|
||||||
|
searchable and its \`::block-name\` line is not.
|
||||||
|
|
||||||
|
1. The page is saved.
|
||||||
|
2. Its render is reduced to plain search text.
|
||||||
|
3. The row is written to the index.
|
||||||
|
|
||||||
|
::block-index{path="sample/reference" columns="1" showIcons="true"}
|
||||||
|
::
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Permissions](/sample/guides/advanced/permissions)
|
||||||
|
- [API Reference](/sample/reference/api)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/reference/glossary',
|
||||||
|
title: 'Glossary',
|
||||||
|
description: 'The words this wiki uses for its own parts.',
|
||||||
|
icon: 'mdi:book-alphabet',
|
||||||
|
tags: ['reference'],
|
||||||
|
content: `# Glossary
|
||||||
|
|
||||||
|
Asset
|
||||||
|
: Any uploaded file. Where its bytes live is decided by the site's storage targets, not by the asset.
|
||||||
|
|
||||||
|
Block
|
||||||
|
: A web component embedded in a page with \`::block-name\`. Its code is fetched only when its tag turns
|
||||||
|
up in a page.
|
||||||
|
|
||||||
|
Folder
|
||||||
|
: A branch of the tree. It holds pages and other folders, and is not itself a page.
|
||||||
|
|
||||||
|
Page rule
|
||||||
|
: How a group grants the permissions that are bound to paths. See
|
||||||
|
[Permissions](/sample/guides/advanced/permissions).
|
||||||
|
|
||||||
|
Storage target
|
||||||
|
: One storage module configured for one site. See
|
||||||
|
[Storage Targets](/sample/guides/advanced/storage).
|
||||||
|
|
||||||
|
Tree
|
||||||
|
: The structure of the wiki — what is filed where. A page is served from its own row and only located
|
||||||
|
through the tree.
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/reference/api',
|
||||||
|
title: 'API Reference',
|
||||||
|
description: 'A worked example of the REST API, and where the real documentation lives.',
|
||||||
|
icon: 'mdi:api',
|
||||||
|
tags: ['reference'],
|
||||||
|
content: `# API Reference
|
||||||
|
|
||||||
|
The whole API is browsable at \`/_api\` in a running instance, generated from the route schemas
|
||||||
|
themselves — so it is never out of date with the server answering it.
|
||||||
|
|
||||||
|
## Authenticating
|
||||||
|
|
||||||
|
Session cookie for a browser, bearer token for everything else.
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
curl -H "Authorization: Bearer $WIKI_API_KEY" https://wiki.example.com/_api/sites
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Creating a page
|
||||||
|
|
||||||
|
\`\`\`js title="create-page.mjs"
|
||||||
|
const res = await fetch(\`/_api/sites/\${'$'}{siteId}/pages\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: 'notes/first',
|
||||||
|
title: 'My first page',
|
||||||
|
editor: 'markdown',
|
||||||
|
content: '# Hello',
|
||||||
|
render: '<h1>Hello</h1>',
|
||||||
|
tags: ['test']
|
||||||
|
})
|
||||||
|
})
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> \`content\` is the source and \`render\` is the HTML produced from it. The server sanitizes the render
|
||||||
|
> against what the author is allowed to embed, so read the response rather than assuming what was
|
||||||
|
> sent is what was stored.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Search](/sample/guides/advanced/search)
|
||||||
|
- [Changelog](/sample/reference/changelog)
|
||||||
|
- [Back to the sample home](/sample/home)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sample/reference/changelog',
|
||||||
|
title: 'Changelog',
|
||||||
|
description: 'A page of nothing but lists and dates, for checking vertical rhythm.',
|
||||||
|
icon: 'mdi:history',
|
||||||
|
tags: ['reference'],
|
||||||
|
content: `# Changelog
|
||||||
|
|
||||||
|
## 3.0.0 — unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Self-registration, email confirmation and password reset on the login screen
|
||||||
|
- A mail transport, and a test button in the admin area
|
||||||
|
- Sample content generation, which is what wrote this page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The login identifier now declares \`autocomplete="username"\` rather than \`email\`
|
||||||
|
- Pages hold their own render, sanitized against the author's permissions
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Enforced two-factor authentication, which read the wrong property and did nothing
|
||||||
|
- Dark mode on the login screen, where nothing set a foreground colour
|
||||||
|
|
||||||
|
## 2.5.308
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Various
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This page is fictional. It exists so there is something with a lot of short list items in it.*
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Reference in new issue