feat: view page version by ID

scarlett
NGPixel 4 days ago
parent acb555eaa7
commit 50adab2eac
No known key found for this signature in database

@ -188,7 +188,59 @@ elsewhere, where being wrong means shipping something visibly broken — a compo
content classes is the case that has actually gone wrong. Also for a flow with real state to exercise
(a login, an upload, a save), where a screenshot answers a question reading cannot.
See the `wikijs-isolated-test-instance` memory for how to boot one when it IS warranted.
### Booting a throwaway instance
For the cases above, and never against a running dev instance: that database is somebody's own work,
and its admin account may well have 2FA on, which cannot be scripted.
**A database of its own, not a schema of its own.** Copy `config.yml` to `config.test.yml` with
`port: 3010`, `db.db: wikitest` and `dataPath: ./data-test` — and leave `schema: wiki` alone. A second
*schema* in the same database fails on the first migration: `CREATE TYPE "treeType"` in
`db/migrations/20260809235619_init` is not schema-qualified, and neither is the column that references
it, so the type is created in one search path and looked for in another (`42704 typenameType`). There
is no `psql` in the dev container, so create the database with `pg` out of `backend/node_modules`,
connecting with the credentials already in `config.yml`.
Then `CONFIG_FILE=config.test.yml node --no-experimental-webstorage backend` **from the repo root**.
`CONFIG_FILE` is resolved against `WIKI.ROOTPATH` (`core/config.ts`), so it is a path relative to the
root and not to `backend/`. It seeds itself and takes ~25s to reach listening.
**Puppeteer is not installed in any workspace, and must not be added to one for a screenshot.** Install
`puppeteer-core` into a scratch directory instead and drive the browser already on the box:
`executablePath: '/usr/bin/chromium'`, `args: ['--no-sandbox']`. It pulls ~25 packages and downloads no
browser of its own.
**Scripting the API rather than the browser**, which is the quicker way to get a page and a history in
place. Three things about it are not guessable:
- **The site ID comes from `GET /_api/bootstrap`**, which is `publicAccess: true` and answers with the
site, the flags and the session — it is what the SPA itself calls on boot. Not from `GET /_api/sites`:
that needs `read:sites` or `access:admin`, so logged out it answers 401, and the site ID is what
logging in requires.
- **Login is `PUT /_api/sites/:siteId/auth/login`** (not POST) with `{strategyId, username, password}`.
The strategy is the built-in local one, whose ID is fixed as `systemIds.localAuthId` in `base.yml`.
A fresh instance answers `nextAction: changePassword` with a `continuationToken` for the seeded
`admin@example.com` / `12345678`; feed that to `PUT .../auth/changePassword`, which needs
**`strategyId` as well as** `continuationToken` and `newPassword`. The session cookie is good after
that.
- **The auth endpoints are rate limited, and successes are counted too.** `limitAuthAttempts`
(`helpers/rateLimit.ts`) guards login, 2FA, this password change, passkeys and page unlock with one
counter per client address — ten attempts per five minutes, then a fifteen minute ban. A re-runnable
script that tries the seeded password before the one it changed it to therefore burns a guaranteed
failure per run and eventually locks itself out. Try the changed password FIRST, and clear a ban with
`DELETE FROM wiki."rateLimits"` rather than waiting it out.
**Two things block a fresh install's first screenshot.** The seeded admin is forced through a
change-password form on first login — fill both `input[autocomplete="new-password"]`, the current
password field being `v-if`'d away whenever a continuation token is in hand. And the site root raises
the **Welcome overlay** over the header while there is no home page, so navigate to any other path to
get at the real one.
**Tearing down** is killing your own PID — a dev instance shows up as `node backend` too, so match on
start time or the `CONFIG_FILE` in `/proc/<pid>/environ` rather than on the name — then
`DROP DATABASE wikitest` and deleting `config.test.yml` and `data-test/`. Neither is gitignored:
`.gitignore` names `/config.yml` and `/data` as exact paths, so a copy under any other name is
tracked and will turn up in the next commit.
## TypeScript (backend)

@ -10,6 +10,14 @@
"categories": {
"correctness": "error"
},
"rules": {
// Not in `correctness`, so it has to be named. Free here -- the backend reports none, `WIKI` being
// declared above and everything else imported -- and on for the same reason as the frontend's:
// a name used but never imported is a runtime error that no build step here would catch, there
// being no build step. `npm run typecheck` finds it too, but that is a separate command; this
// puts it in the lint everyone runs.
"no-undef": "error"
},
"ignorePatterns": [
"node_modules/**",
"**/*.min.js",

@ -1263,6 +1263,73 @@ async function routes(app: FastifyInstance) {
}
)
/**
* PAGE VERSION BY ID
*/
app.get<{ Params: { siteId: string; versionId: string } }>(
'/sites/:siteId/versions/:versionId',
{
// -> Checked per page below, for the same reason as the history routes above
schema: {
summary: 'Get a page version by its ID alone',
description:
'The same version as the history route, addressed WITHOUT naming the page — what a `/_version/<id>` link resolves. The page it came off is named in the reply, since that is what the reader is asking to be told.\n\nNeeds `read:history` and the ability to read that page, on the same terms as the history list. A version whose page has since been deleted answers 404: the permissions that would decide who may read it are page rules, and there is no longer a page to check them against.',
tags: ['Pages'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
versionId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId', 'versionId']
},
response: {
200: { $ref: 'PageVersionById#' }
}
}
},
async (req, reply) => {
const version = await WIKI.models.pageHistory.getVersionById(
req.params.siteId,
req.params.versionId
)
if (!version) {
return reply.notFound('This version does not exist.')
}
/*
The page as it stands, which is what carries the access rules a version has none of its own.
Note the rules are matched against the page's CURRENT path, not the path the version was
written at: a page that has moved is one page, and who may read its history is a question about
where it is now.
*/
const page = await loadReadablePage(req, req.params.siteId, version.pageId)
if (!page) {
return reply.notFound('This version does not exist.')
}
if (!mayOnPage(req, 'read:history', page)) {
return reply.forbidden("You are not allowed to read this page's history.")
}
if (page.isLocked) {
return reply.forbidden('This page is password protected.')
}
/*
The page's CURRENT path and locale, alongside the historical ones already on the version.
Both are here so a reader looking at a snapshot can be sent to the page as it stands, and
`version.path` cannot do that job: it is where the page was WHEN the version was written, so
for a page that has since moved it points at nothing. Free to include the page is already
loaded, one line above, to decide whether this reader may be here at all.
*/
return { ...version, pagePath: page.path, pageLocale: page.locale }
}
)
/**
* RESOLVE ALIAS
*/

@ -430,7 +430,39 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'object',
additionalProperties: true,
description:
'The rest of the page as it stood: description, icon, tags, publish state and dates, relations, scripts, config, editor and content type.'
'The rest of the page as it stood: description, icon, tags, publish state and dates, relations, scripts, config, editor, content type and the contents list (`toc`).'
}
}
}
]
})
/**
* PAGE VERSION BY ID - The same again, saying which page it came off
*/
app.addSchema({
$id: 'PageVersionById',
type: 'object',
allOf: [
{ $ref: 'PageHistoryVersion#' },
{
type: 'object',
properties: {
pageId: {
type: 'string',
format: 'uuid',
description:
'The page this is a version of. Present because a version URL names only the version, so this is how the reader is told what they are looking at a snapshot OF.'
},
pagePath: {
type: 'string',
description:
'Where that page is NOW — which is what a link to the live page has to be built from. Not to be confused with `path`, which is where it was when this version was written.'
},
pageLocale: {
type: 'string',
description:
"The locale that page is in now, needed to prefix the link on a site that brackets its URLs by locale. Its historical counterpart is not recorded on the version's own fields."
}
}
}

@ -43,8 +43,8 @@ const nanoid = customAlphabet('1234567890abcdef', 10)
* First path segments the SERVER itself answers every prefix registered in `initHTTPServer`.
*
* Spelled out rather than tested with `isPageUrl`, because a leading underscore does not mean the
* server: the frontend router owns `/_admin`, `/_profile`, `/_inbox`, `/_search`, `/_create`, `/_edit`
* and `/_error` too, and those have to reach the app shell like any page path. The distinction the
* server: the frontend router owns `/_admin`, `/_profile`, `/_inbox`, `/_search`, `/_create`, `/_edit`,
* `/_version` and `/_error` too, and those have to reach the app shell like any page path. The distinction the
* shell needs is "does something here serve this", which is this list, and it has to be kept in step
* with the registrations below.
*

@ -1707,7 +1707,7 @@
"common.header.viewSource": "View Source",
"common.license.alr": "All Rights Reserved",
"common.license.cc0": "Public Domain",
"common.license.ccby": " Creative Commons Attribution License",
"common.license.ccby": "Creative Commons Attribution License",
"common.license.ccbync": "Creative Commons Attribution-NonCommercial License",
"common.license.ccbyncnd": "Creative Commons Attribution-NonCommercial-NoDerivs License",
"common.license.ccbyncsa": "Creative Commons Attribution-NonCommercial-ShareAlike License",
@ -2274,6 +2274,7 @@
"history.action.updated": "Updated",
"history.branchFailed": "Failed to create a page from this version.",
"history.branchOff": "Branch off from here",
"history.branchOffShort": "Branch off",
"history.branchReason": "Branched off from the version of {date}",
"history.branchSuccess": "New page created from this version.",
"history.changedFields": "Changed: {fields}",
@ -2281,6 +2282,7 @@
"history.downloadFailed": "Failed to download this version.",
"history.downloadVersion": "Download Version",
"history.emptyPage": "Nothing",
"history.exportPdf": "Export to PDF",
"history.inline": "Inline",
"history.loadFailed": "Failed to load the page history.",
"history.none": "No history has been recorded for this page yet.",
@ -2300,12 +2302,19 @@
"history.setAsSource": "Set as Differencing Source",
"history.setAsTarget": "Set as Differencing Target",
"history.sideBySide": "Side-by-Side",
"history.snapshotFrom": "Snapshot from",
"history.sourceCopied": "Source copied to the clipboard.",
"history.title": "Page History",
"history.unknownAuthor": "Unknown",
"history.versionActions": "Version Actions",
"history.versionId": "Version ID {id}",
"history.versionLoadFailed": "Failed to load this version.",
"history.versionUnavailable": "This version is not available",
"history.versionUnavailableHint": "It may have been purged from the history, or you may not be allowed to see it.",
"history.viewLive": "View Live",
"history.viewSource": "View Source",
"history.viewVersion": "View Version",
"history.viewingVersionId": "Viewing version ID",
"iconPicker.allSets": "All sets",
"iconPicker.icons": "Icons",
"iconPicker.image": "Image",

@ -1,5 +1,6 @@
import { isEqual } from 'es-toolkit/predicate'
import { and, desc, eq, lt, sql } from 'drizzle-orm'
import type { SQL } from 'drizzle-orm'
import {
pageHistory as pageHistoryTable,
pages as pagesTable,
@ -41,10 +42,18 @@ export type PurgeTimeframe = keyof typeof purgeTimeframes
* The page fields a version carries beyond the ones with columns of their own.
*
* Taken straight off the stored row, so a field added to a page is captured here without this list
* being touched. The exclusions are either derived from the content (`render`, `toc`, `searchContent`,
* being touched. The exclusions are either derived from the content (`render`, `searchContent`,
* `ts`), fixed for the page's whole life (`id`, `siteId`, `creatorId`, `createdAt`), or bookkeeping
* that says nothing about the version (`hash`, `updatedAt`, `authorId`, `ratingScore`, `ratingCount`,
* `historyData`, `isSearchableComputed`).
*
* `toc` is derived as well and is kept regardless, because the version view draws a contents column
* beside the snapshot and there is nowhere else to get one: a version records the page's SOURCE, and
* the headings only exist in the render, which is not recorded. Deriving them on the way out would
* mean parsing the source a second time to answer what the save had already answered.
*
* It stays in `NOT_REPORTED_AS_CHANGED` below, which is a different question: a contents list moves
* whenever the source does, so naming it among a version's changed fields tells a reader nothing.
*/
const EXCLUDED_FROM_META = new Set([
'id',
@ -55,7 +64,6 @@ const EXCLUDED_FROM_META = new Set([
'authorId',
'hash',
'render',
'toc',
'searchContent',
'ts',
'ratingScore',
@ -115,6 +123,58 @@ export type PageHistoryVersion = PageHistoryEntry & {
meta: Record<string, any>
}
/**
* One version row, by whatever identifies it.
*
* Shared by the two ways in page and version, or version alone because they differ only in the
* `where`, and a second copy of this projection is a second place for the two to drift apart.
*/
async function selectVersionRow(where: SQL | undefined): Promise<any> {
const rows = await WIKI.db
.select({
id: pageHistoryTable.id,
pageId: pageHistoryTable.pageId,
action: pageHistoryTable.action,
changedFields: pageHistoryTable.changedFields,
reason: pageHistoryTable.reason,
versionDate: pageHistoryTable.versionDate,
path: pageHistoryTable.path,
title: pageHistoryTable.title,
content: pageHistoryTable.content,
meta: pageHistoryTable.meta,
authorId: usersTable.id,
authorName: usersTable.name,
authorEmail: usersTable.email
})
.from(pageHistoryTable)
.leftJoin(usersTable, eq(usersTable.id, pageHistoryTable.authorId))
.where(where)
.limit(1)
return rows[0] ?? null
}
/** That row as a version. `pageId` is deliberately not on it — only one caller wants it. */
function toVersion(row: any): PageHistoryVersion {
return {
id: row.id,
action: row.action,
changedFields: row.changedFields ?? [],
reason: row.reason ?? '',
versionDate: row.versionDate,
path: row.path,
title: row.title,
content: row.content ?? '',
meta: (row.meta ?? {}) as Record<string, any>,
author: {
// -> Null once the account is gone: the version outlives it, see the column's own note
id: row.authorId ?? null,
name: row.authorName ?? '',
email: row.authorEmail ?? ''
}
}
}
/**
* Page history model
*
@ -248,52 +308,40 @@ class PageHistory {
pageId: string,
versionId: string
): Promise<PageHistoryVersion | null> {
const rows = await WIKI.db
.select({
id: pageHistoryTable.id,
action: pageHistoryTable.action,
changedFields: pageHistoryTable.changedFields,
reason: pageHistoryTable.reason,
versionDate: pageHistoryTable.versionDate,
path: pageHistoryTable.path,
title: pageHistoryTable.title,
content: pageHistoryTable.content,
meta: pageHistoryTable.meta,
authorId: usersTable.id,
authorName: usersTable.name,
authorEmail: usersTable.email
})
.from(pageHistoryTable)
.leftJoin(usersTable, eq(usersTable.id, pageHistoryTable.authorId))
.where(
and(
eq(pageHistoryTable.siteId, siteId),
eq(pageHistoryTable.pageId, pageId),
eq(pageHistoryTable.id, versionId)
)
const row = await selectVersionRow(
and(
eq(pageHistoryTable.siteId, siteId),
eq(pageHistoryTable.pageId, pageId),
eq(pageHistoryTable.id, versionId)
)
.limit(1)
)
return row ? toVersion(row) : null
}
const row: any = rows[0]
if (!row) {
return null
}
return {
id: row.id,
action: row.action,
changedFields: row.changedFields ?? [],
reason: row.reason ?? '',
versionDate: row.versionDate,
path: row.path,
title: row.title,
content: row.content ?? '',
meta: (row.meta ?? {}) as Record<string, any>,
author: {
id: row.authorId ?? null,
name: row.authorName ?? '',
email: row.authorEmail ?? ''
}
}
/**
* The same version addressed by its ID ALONE, with the page it belongs to named in the reply.
*
* What `/_version/<id>` needs. A version URL is a link somebody was handed out of the history
* timeline, a notification, a message and the one thing such a link can reasonably carry is the
* version's own ID: the page it came off is exactly what the reader is asking to be told, and a URL
* that already had to name it would be a URL they could not have been given in the first place.
*
* `pageId` comes back because the caller has permissions to check and they are page rules the
* version carries no access of its own, so it has to be turned back into a page first. Everything
* else is what {@link getVersion} returns.
*
* Scoped to the site regardless, so a version ID from one site cannot be read through another's URL.
*
* @returns The version and its page, or null when this site has no such version
*/
async getVersionById(
siteId: string,
versionId: string
): Promise<(PageHistoryVersion & { pageId: string }) | null> {
const row = await selectVersionRow(
and(eq(pageHistoryTable.siteId, siteId), eq(pageHistoryTable.id, versionId))
)
return row ? { ...toVersion(row), pageId: row.pageId } : null
}
/**

@ -10,8 +10,38 @@
"chrome": "readonly",
"API_CLIENT": "readonly",
"EVENT_BUS": "readonly",
"Temporal": "readonly"
"Temporal": "readonly",
// Vue's compiler macros. Not imports and not real globals -- `<script setup>` compiles them away
// -- but nothing tells a linter that, so without these `no-undef` below reports every one of the
// ~190 uses across `src/`, in the editor as much as on the command line.
"defineProps": "readonly",
"defineEmits": "readonly",
"defineExpose": "readonly",
"defineOptions": "readonly",
// Written into `index.html` by the server before the app boots: the site being served, and the
// locales it offers. Absent on a page rendered without them, which is why `App.vue` tests
// `typeof siteConfig !== 'undefined'` rather than reading it straight.
"siteConfig": "readonly",
"siteLangs": "readonly",
// Node's, reachable only from the `import.meta.env.SSR` branches in `boot/*.js`. Dead code in a
// browser build, but it is a real global where those branches run.
"global": "readonly"
},
// `scripts/` is Node build tooling, not browser code -- `generate-icons.mjs` and `generate-emoji.mjs`
// run under `npm run icons` / `npm run emoji`. Given the node env they get Node's globals, so
// `Buffer` there is real. Scoped to that directory deliberately: declaring `Buffer` globally would
// also silence it in `src/`, where it is NOT defined and where one use of it is an actual bug.
"overrides": [
{
"files": ["scripts/**"],
"env": {
"node": true
}
}
],
"ignorePatterns": [
"dist/**",
"node_modules/**",
@ -28,6 +58,16 @@
// allow debugger during development only
"no-debugger": "error",
// Not in the `correctness` category, so it has to be asked for by name -- and worth asking for:
// neither `vite build` nor the rest of this config resolves identifiers, so a name that is used
// but never imported builds cleanly and throws at runtime. That is not hypothetical; it is how a
// stale import left `routableHref` undefined in `pages/Index.vue` and took the whole page view
// down, since `<script setup>` evaluates every top-level binding during setup.
//
// It needs the `globals` block above to be honest about what really is global -- the compiler
// macros especially. That is what made this unusable in the editor before.
"no-undef": "error",
// These are valid rule names but currently inert: the `import` plugin is not enabled above.
// Turning it on reports a false positive for every Vite `?worker` import in boot/monaco.js,
// because the plugin resolves the specifier literally and does not know Vite rewrites those

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
277 icons.
278 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -64,6 +64,7 @@ export const BUNDLED_ICONS = {
"la:file-image": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.594l-.281-.313l-6-6L19.406 3zm2 2h10v6h6v16H8zm12 1.438L22.563 9H20zM21.094 14c-.551 0-1 .45-1 1s.449 1 1 1s1-.45 1-1s-.45-1-1-1M14 15.594l-.719.687l-4 4l1.438 1.438L14 18.437l2.281 2.282l.719.687l.719-.687L19 19.437l2.281 2.282l1.438-1.438l-3-3l-.719-.687l-.719.687L17 18.563l-2.281-2.282z\"/>","width":32,"height":32},
"la:file-import": {"body":"<path fill=\"currentColor\" d=\"M6 4v24h20v-9h-2v7H8V6h16v7h2V4zm11.5 7l-4.313 4.281L12.5 16l.688.719L17.5 21l1.406-1.406L16.313 17H28v-2H16.312l2.594-2.594z\"/>","width":32,"height":32},
"la:file-invoice": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.6l-.3-.3l-6-6l-.3-.3zm2 2h10v6h6v16H8zm12 1.4L22.6 9H20zM10 13v2h12v-2zm0 5v2h7v-2zm9 0v2h3v-2zm-9 4v2h7v-2zm9 0v2h3v-2z\"/>","width":32,"height":32},
"la:file-pdf": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V3zm2 2h16v22H8zm7.406 5.344a1.44 1.44 0 0 0-.906.312c-.254.215-.367.48-.438.75c-.136.54-.097 1.098.032 1.719c.152.727.586 1.602.937 2.438c-.18.761-.226 1.437-.5 2.218c-.234.672-.535 1.059-.812 1.657c-.63.238-1.38.378-1.875.687c-.535.332-1.004.7-1.281 1.219c-.278.52-.247 1.254.124 1.781a1.6 1.6 0 0 0 .75.625c.325.129.676.133.97.031c.59-.203 1.007-.656 1.405-1.187c.372-.492.633-1.328.97-2c.503-.168.866-.38 1.405-.5c.563-.125.942-.067 1.47-.125c.226.258.417.672.655.875c.477.414 1 .742 1.625.781s1.25-.352 1.594-.938h.032v-.03c.152-.266.257-.555.25-.876a1.4 1.4 0 0 0-.375-.875c-.41-.437-.934-.55-1.5-.625c-.438-.058-1.047.098-1.563.125c-.453-.597-.902-1.047-1.313-1.812c-.222-.414-.28-.766-.468-1.188c.144-.68.43-1.437.468-2.031c.047-.719.02-1.34-.187-1.906a1.8 1.8 0 0 0-.531-.781a1.5 1.5 0 0 0-.907-.344h-.03zm.656 7.406c.18.316.403.516.594.813c-.281.05-.496 0-.781.062c-.047.012-.078.05-.125.063c.059-.157.133-.25.188-.407c.062-.183.066-.347.124-.531m3.688 2.031c.336.043.457.106.5.125c-.008.016.012.012 0 .032c-.125.207-.137.19-.219.187c-.066-.004-.32-.14-.562-.313c.07.004.218-.039.281-.03zm-7 1.563c-.055.082-.102.273-.156.343c-.305.407-.586.594-.656.625c-.012-.015.019 0 0-.03h-.032c-.101-.145-.074-.087 0-.22c.074-.132.309-.402.719-.656c.031-.02.094-.043.125-.062\"/>","width":32,"height":32},
"la:fill": {"body":"<path fill=\"currentColor\" d=\"M11.313 3.281L9.905 4.72l1.782 1.78l-6.906 6.906a3.063 3.063 0 0 0 0 4.313l.063.062l6.343 6.313a3.063 3.063 0 0 0 4.313 0l7.594-7.594l.718-.688l-9.718-9.718l-.781-.813l-.22-.187zm1.812 4.656L21 15.813l-6.906 6.876a1.054 1.054 0 0 1-1.5 0L6.219 16.28a1.017 1.017 0 0 1 0-1.468zM25 19.25l-.813 1.188s-.539.753-1.062 1.656c-.262.453-.508.926-.719 1.406S22 24.422 22 25c0 1.645 1.355 3 3 3s3-1.355 3-3c0-.578-.195-1.02-.406-1.5s-.457-.953-.719-1.406c-.523-.903-1.063-1.657-1.063-1.657zm0 3.625c.066.11.059.102.125.219c.238.41.492.847.656 1.218c.164.372.219.715.219.688c0 .555-.445 1-1 1s-1-.445-1-1c0 .027.055-.316.219-.688c.164-.37.418-.808.656-1.218c.066-.117.059-.11.125-.219\"/>","width":32,"height":32},
"la:fingerprint": {"body":"<path fill=\"currentColor\" d=\"M16 4c-.262 0-.496.016-.75.031a13 13 0 0 0-4.063.875l.75 1.875a10.8 10.8 0 0 1 3.407-.75C15.55 6.02 15.774 6 16 6c1.883 0 3.664.477 5.219 1.313l.937-1.75A13 13 0 0 0 16 4M9.5 5.719a13 13 0 0 0-3.188 2.593c-.414.461-.777.981-1.125 1.5c-.382.57-.714 1.168-1 1.782L6 12.406a11.2 11.2 0 0 1 1.813-2.75A11 11 0 0 1 10.5 7.47zm14.469 1L22.75 8.312a10.93 10.93 0 0 1 4.219 8.094c.004.063.047.61 0 1.532l2 .125c.05-1.004.008-1.665 0-1.782a12.94 12.94 0 0 0-5-9.562M16 7v2c4.25 0 7.77 3.313 8 7.563c.008.113.129 3.066-1 6.625l1.906.593c1.239-3.902 1.11-7.031 1.094-7.312C25.715 11.176 21.293 7 16 7m-1.844.156a9.9 9.9 0 0 0-5.594 3.157c-.32.355-.636.753-.906 1.156h.032v.031C6.52 13.262 5.902 15.3 6 17.406v.563l2 .062v-.656c-.09-1.715.383-3.375 1.344-4.813c.21-.32.433-.624.687-.906a7.96 7.96 0 0 1 4.5-2.531zM15.594 10a6.9 6.9 0 0 0-4.25 1.781l1.312 1.5A5 5 0 0 1 15.72 12c.105-.008.183 0 .281 0c.582 0 1.14.098 1.656.281l.688-1.875A7.1 7.1 0 0 0 16 10c-.145 0-.27-.008-.406 0m4.281 1.156l-1.094 1.688A4.95 4.95 0 0 1 21 16.719l2-.094a7.05 7.05 0 0 0-3.125-5.469M15.781 13a4 4 0 0 0-2.75 1.344A3.98 3.98 0 0 0 12 17.219c0-.004.05 1.125-.406 2.437c-.457 1.313-1.371 2.793-3.344 3.75l-.625.282c-.332.148-.75.32-.844.343l.438 1.938c.445-.102.875-.301 1.25-.469s.656-.313.656-.313c2.5-1.21 3.762-3.207 4.344-4.875c.582-1.667.539-2.996.531-3.187v-.031a1.93 1.93 0 0 1 .5-1.438A1.95 1.95 0 0 1 15.875 15c.05-.004.09 0 .125 0v-2c-.082 0-.148-.004-.219 0m-5.625.125A6.96 6.96 0 0 0 9 17.344v.031c.004.082.09 2.266-2.063 3.313C6.891 20.706 6.146 21 5 21v2c1.566 0 2.75-.469 2.75-.469h.031l.032-.031c3.222-1.563 3.19-5.04 3.187-5.219v-.031c-.059-1.09.25-2.11.844-3zm7.75.344l-.968 1.781c.593.32 1.023.902 1.062 1.625c.008.164.285 6.387-4.625 10.344l1.25 1.562c5.719-4.605 5.402-11.531 5.375-12a4 4 0 0 0-2.094-3.312M16 16c-.55 0-1 .45-1 1v.063s.117 2.058-.906 4.375l1.812.812C17.09 19.574 17.008 17.172 17 17v-.063A1.004 1.004 0 0 0 16 16m4.969 1.938c-.125 2.03-.766 6.195-3.719 9.687l1.5 1.281c3.363-3.972 4.078-8.558 4.219-10.843zM13.562 22.5c-.8 1.348-2.039 2.645-4 3.594l.876 1.812c2.32-1.125 3.87-2.77 4.843-4.406z\"/>","width":32,"height":32},
"la:folder-open": {"body":"<path fill=\"currentColor\" d=\"M5 3v24.813l.781.156l12 2.5l1.219.25V28h6V15.437l1.719-1.718l.281-.313V3zm9.125 2H25v7.563l-1.719 1.718l-.281.313V26h-4v-8.906l-.281-.313L17 15.063V5.719zM7 5.281l8 2v8.625l.281.313L17 17.937v10.344L7 26.188z\"/>","width":32,"height":32},

@ -1582,9 +1582,6 @@ onMounted(async () => {
wordWrap: 'on'
})
// TODO: For debugging, remove at some point...
window.edInstance = editor
/*
"Edit Table" over every table in the page, which opens the table editor on that table.
@ -1856,38 +1853,6 @@ onMounted(async () => {
EVENT_BUS.on('insertBlock', insertBlockClb)
EVENT_BUS.on('replaceBlockContent', replaceBlockContentClb)
EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
// this.$root.$on('editorInsert', opts => {
// switch (opts.kind) {
// case 'IMAGE':
// let img = `![${opts.text}](${opts.path})`
// if (opts.align && opts.align !== '') {
// img += `{.align-${opts.align}}`
// }
// this.insertAtCursor({
// content: img
// })
// break
// case 'BINARY':
// this.insertAtCursor({
// content: `[${opts.text}](${opts.path})`
// })
// break
// case 'DIAGRAM':
// const selStartLine = this.cm.getCursor('from').line
// const selEndLine = this.cm.getCursor('to').line + 1
// this.cm.doc.replaceSelection('```diagram\n' + opts.text + '\n```\n', 'start')
// this.processMarkers(selStartLine, selEndLine)
// break
// }
// })
// // Handle save conflict
// this.$root.$on('saveConflict', () => {
// this.toggleModal(`editorModalConflict`)
// })
// this.$root.$on('overwriteEditorContent', () => {
// this.cm.setValue(this.$store.get('editor/content'))
// })
})
onBeforeUnmount(() => {

@ -925,29 +925,17 @@ function notImplemented() {
/*
The phone layout of this row.
The title comes down from `text-h4`, which is a 34px display size written for a header the width of a
desktop window: at 390px a title of any length wrapped, and the description under it was pushed out of
the bar. 24px is the same step `text-h5` takes, chosen as a value rather than as that class so the size
lives beside the breakpoint that asks for it.
The text column's padding halves with it, which is most of what brings the bar's own height down --
`pages/Index.vue` takes the fixed 95px off on the same breakpoint, so what is left of it is this
column. Horizontal too, and deliberately: at 8px the title lines up with the article underneath, which
now pads by the same amount.
And the actions go, all of them -- Watch, Print, the review queue, Edit -- because they are icons
squeezed against the right edge of a row that has no room for the title as it is. Nothing is lost that
is not reachable elsewhere: Print is the browser's own menu, and a page is edited on a machine with a
The actions go, all of them -- Watch, Print, the review queue, Edit -- because they are icons squeezed
against the right edge of a row that has no room for the title as it is. Nothing is lost that is not
reachable elsewhere: Print is the browser's own menu, and a page is edited on a machine with a
keyboard. An editor already open keeps its controls, or there would be no way to save or leave it.
Unlayered scoped rules, so they beat the `text-h4` utility without needing `!important`.
The title's own size comes down on the same breakpoint, and the text column's padding with it -- which
together are most of what brings the bar's height down, `_page-chrome.scss` taking the fixed 95px off
there. Both live in that stylesheet rather than here: they are worn by every header, and a scoped rule
reaches only this one's markup.
*/
@media (max-width: $breakpoint-xs-max) {
.page-header-title {
font-size: 1.5rem;
line-height: 2rem;
}
.page-header-actions:not(.has-editor-actions) {
display: none;
}

@ -115,6 +115,23 @@
<w-item-section>{{ t('history.setAsTarget') }}</w-item-section>
</w-item>
<w-separator class="my-1" />
<!--
A real link, unlike everything else in this menu: the others act on the version
where they stand, while this one LEAVES for a screen of its own, so it should
behave like the address it is -- middle-click and ctrl-click open the snapshot in
a tab, and the status bar shows where it goes.
`leaveForVersion` is only there because this overlay is held open by
`siteStore.overlay` and not by the route: navigating with it still set would drop
the version view in behind it. It closes on a plain click only, so a click that
opens a TAB leaves the timeline where the reader left it.
-->
<w-item :to="`/_version/${version.id}`" @click="leaveForVersion">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:eye" class="text-blue-7" />
</w-item-section>
<w-item-section>{{ t('history.viewVersion') }}</w-item-section>
</w-item>
<w-item clickable @click="viewSource(version)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:code" class="text-blue-7" />
@ -247,9 +264,7 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import * as monaco from 'monaco-editor'
import { fileSave } from 'browser-fs-access'
import { MarkdownRenderer } from '@/renderers/markdown'
import { renderVersionSource, saveVersionSource, versionContentType } from '@/helpers/pageVersions'
import { confirm, dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
@ -453,21 +468,6 @@ async function loadVersion(id) {
return version
}
/** What a version's source is saved as, by the format it was written in. */
const FILE_TYPES = {
markdown: { ext: 'md', mime: 'text/markdown' },
html: { ext: 'html', mime: 'text/html' }
}
/**
* The format a version was written in which decides how it colours, how it renders and what it
* downloads as. Taken from the version rather than from the page, since the page may have been
* converted since.
*/
function contentTypeOf(version) {
return version?.meta?.contentType || version?.meta?.editor || pageStore.editor || 'markdown'
}
/** A version with its source, with the spinner and the error report the menu actions all want. */
async function withVersion(version) {
state.loading++
@ -490,22 +490,35 @@ async function withVersion(version) {
* the markdown pipeline is a frontend one, and the server would otherwise have to drive a headless
* browser an extension most instances do not install.
*/
async function renderOf(version, content) {
if (contentTypeOf(version) !== 'markdown') {
return content
}
async function renderOf(version) {
// -> The renderer is configured per site (line breaks, typographer, ), and that configuration
// arrives with the editor configs rather than on its own
if (!editorStore.configIsLoaded) {
await editorStore.fetchConfigs()
}
// -> Rendered as the page it is a version of, so a relative image in it resolves the way it does
// in the page view rather than against the site root
return new MarkdownRenderer(editorStore.editors.markdown ?? {}).render(content, {
// -> Rendered as the page it is a version OF, so a relative image in it resolves the way it does in
// the page view rather than against the site root
return renderVersionSource(version, {
markdownConfig: editorStore.editors.markdown,
pagePath: pageStore.path
})
}
/**
* Closing this overlay on the way to a version's own screen.
*
* Only for an unmodified left click, which is the one that actually leaves this tab. A ctrl-, meta-,
* shift- or middle-click opens the snapshot elsewhere and the reader stays here, so the timeline they
* were reading should still be in front of them -- and `router-link` declines those for the same
* reason, leaving them to the browser.
*/
function leaveForVersion(ev) {
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) {
return
}
close()
}
async function viewSource(version) {
const full = await withVersion(version)
if (!full) {
@ -521,29 +534,15 @@ async function viewSource(version) {
}
async function downloadVersion(version) {
// -> The timeline carries no source, so the version has to be fetched before it can be saved
const full = await withVersion(version)
if (!full) {
return
}
const type = FILE_TYPES[contentTypeOf(full)] ?? { ext: 'txt', mime: 'text/plain' }
// -> Named for the page and the moment, since a folder of `page.md` files says nothing
const name = full.path.split('/').at(-1) || 'page'
const stamp = full.versionDate.slice(0, 19).replace(/[:T]/g, '-')
try {
/*
A bare MIME type, with no `;charset=` on it: the save picker uses this as an `accept` key and
rejects a type carrying parameters outright. Nothing is lost by dropping it a Blob built from
a JS string is UTF-8 already.
*/
await fileSave(new Blob([full.content ?? ''], { type: type.mime }), {
fileName: `${name}-${stamp}.${type.ext}`,
extensions: [`.${type.ext}`]
})
await saveVersionSource(full)
} catch (err) {
// -> Dismissing the file picker is not a failure
if (err.name !== 'AbortError') {
notify({ type: 'negative', message: t('history.downloadFailed'), caption: err.message })
}
notify({ type: 'negative', message: t('history.downloadFailed'), caption: err.message })
}
}
@ -577,7 +576,7 @@ function restoreVersion(version) {
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/pages/${pageStore.id}`, {
json: {
content,
render: await renderOf(full, content),
render: await renderOf(full),
reasonForChange: t('history.restoreReason', { date: humanizeDate(full.versionDate) })
}
}).json()
@ -633,7 +632,7 @@ function branchFrom(version) {
locale: pageStore.locale,
editor: full.meta?.editor || pageStore.editor,
content,
render: await renderOf(full, content),
render: await renderOf(full),
description: full.meta?.description ?? '',
icon: full.meta?.icon ?? '',
tags: full.meta?.tags ?? [],
@ -702,7 +701,7 @@ async function mountEditor() {
/** The format the page was written in at the time, which is what colours the two sides. */
function languageOf(version) {
return contentTypeOf(version) === 'html' ? 'html' : 'markdown'
return versionContentType(version) === 'html' ? 'html' : 'markdown'
}
async function applyDiff() {

@ -0,0 +1,198 @@
<template>
<div class="page-header flex flex-wrap">
<!-- PAGE ICON -->
<!--
Never a button, unlike `PageHeader`'s: there is no editing surface on a snapshot, so the icon is
the drawing and nothing else. Same size and same column so the two headers line up exactly --
walking from a page to one of its versions should move the title bar's contents nowhere.
-->
<div class="flex-none pl-4 flex items-center">
<w-icon class="rounded" :name="icon" :size="iconSize" color="primary" />
</div>
<!-- PAGE HEADING -->
<!--
Centred rather than top-aligned, as in `PageHeader`: with no description the title is the only
line in this column and would otherwise sit above the middle of the icon beside it.
-->
<div class="min-w-0 flex-1 flex flex-col justify-center p-2 sm:p-4">
<div class="text-h4 page-header-title">{{ title }}</div>
<div class="text-subtitle2 page-header-subtitle">{{ description }}</div>
</div>
<!-- VERSION ACTIONS -->
<!--
What this header has in place of Watch / Print / Edit: the three things there are to do with a
snapshot. Two are icons -- taking a copy of it, in either of two forms -- and the third is
labelled, because it is the one that WRITES, and a button that overwrites the live page should
not be a glyph somebody presses to find out what it does. Its ellipsis is doing the same work:
restoring asks first.
Download, Restore and Branch off each do the same as their entry in the history overlay's version
menu, through the same code. Export to PDF is the one still disabled, because it is not wired up
yet -- disabled rather than inert on purpose: a button that silently does nothing when pressed
reads as a bug, where a dimmed one reads as not-yet.
Restore is the only one that WRITES to the live page, which is why it keeps the orange this app
gives an action that changes a page, and why it asks before doing it. Branch off creates a page
instead of overwriting one, so it sits with the harmless ones.
They stay on a phone, where `PageHeader` drops its whole row: that row is icons for things
reachable elsewhere -- Print is the browser's own menu -- while these three are the only actions
this view offers at all, so hiding them would leave the screen with none.
What they do instead is take a row of their own, which is what `w-full` at phone widths buys: the
bar already wraps, but this block is `flex-none` and about 230px wide, so beside a 32px icon it
left the title column ~60px and the description came out one word per line. Full width wraps it
under the title, and `.page-header` is `height: auto` on the same breakpoint so the bar grows by
the row rather than squeezing it.
-->
<!--
`ml-2` throughout, which is the editor's own action row (see `PageHeader`): 8px between buttons
rather than 16. Every one of them is acrylic and flat there too, except View Live, which is the
one filled button -- see its own note below.
The icon-only pair takes the same treatment as View Documentation in that row: acrylic, flat,
grey, and NOT `dense`, so a glyph-only button is the same height as the labelled ones beside it
instead of a smaller target floating in the middle of them.
-->
<div
class="page-header-actions w-full sm:w-auto flex-none px-4 pb-4 sm:p-4 flex items-center justify-end">
<w-btn
class="acrylic-btn ml-2"
flat
icon="la:download"
color="grey"
:aria-label="t(`history.downloadVersion`)"
@click="emit(`download`)">
<w-tooltip>{{ t('history.downloadVersion') }}</w-tooltip>
</w-btn>
<w-btn
class="acrylic-btn ml-2"
flat
icon="la:file-pdf"
color="grey"
disable
:aria-label="t(`history.exportPdf`)">
<w-tooltip>{{ t('history.exportPdf') }}</w-tooltip>
</w-btn>
<!--
Branch off before Restore: it reads as the gentler of the two, and Restore stays next to the
confirmation it raises.
Indigo, which is the colour this app gives history -- the Schedule tab's calendar, the version
timeline's dots, the bar at the top of this very screen. It also tells this button apart from
the disabled Export to PDF beside it, which grey did not.
Two shades, because one will not do: as a LABEL, `indigo` measures 6.3:1 on the light header
and 2.5:1 on the dark one, while `indigo-4` is 5.0:1 dark and 3.2:1 light. So each theme takes
the shade that is legible in it. Bound rather than left to a `dark:` class because `WBtn`
writes the colour as an inline style (`--w-btn-color`, and `color` with it), which no
stylesheet outranks without `!important`.
-->
<w-btn
class="acrylic-btn ml-2"
flat
icon="la:code-branch"
:color="dark.isActive ? `indigo-4` : `indigo`"
no-caps
:label="t(`history.branchOffShort`)"
:aria-label="t(`history.branchOffShort`)"
@click="emit(`branch`)" />
<!-- -> The same orange every page header gives the action that changes the page -->
<w-btn
class="acrylic-btn ml-2"
flat
icon="la:undo"
color="deep-orange-9"
no-caps
:label="t(`history.restore`)"
:aria-label="t(`history.restore`)"
@click="emit(`restore`)" />
<!--
The one live control on this row, and the only way out of the snapshot that does not go
backwards: everything else here acts on the version, while this leaves it for the page as it
stands. In the site's own colour rather than the orange beside it, because it is not a change
to anything -- it is navigation.
A `to`, not a click handler: it is a link to a path, so it should behave like one middle-click
and ctrl-click open the live page in a tab, and the status bar shows where it goes.
Solid rather than acrylic, and the only filled button in the row: it is where a reader goes
when they are done here, so it carries the weight. `unelevated` because that is how this app
does a solid primary button everywhere else -- the unlock and create buttons in `Index.vue`
-- and a raised one would be the only shadow in a flat header.
No `acrylic-btn`, which paints a 10% tint of the button colour and would be a second, weaker
background under the fill. And no `dark:` variant either: `primary-light` is right for
primary as TEXT on a dark page, but as a FILL behind white it measures 2.4:1 where plain
`primary` gives 4.6:1. A fill carries its own contrast, so one colour serves both themes.
Rendered only with a path to go to. In practice there is always one, since the endpoint behind
this view refuses a version whose page has been deleted (page rules need a page to be checked
against), but a button whose target is empty would navigate to the site root and quietly look
like it had worked.
-->
<w-btn
class="ml-2"
v-if="livePath"
unelevated
icon="la:eye"
color="primary"
no-caps
:to="livePath"
:label="t(`history.viewLive`)"
:aria-label="t(`history.viewLive`)" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
import { useMinWidth } from '@/composables/screen'
const emit = defineEmits(['download', 'restore', 'branch'])
defineProps({
/** The page's icon as the version recorded it, an Iconify reference. */
icon: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
/**
* Where the live page sits now, ready to route to locale prefix and all. Empty when there is
* nowhere to send the reader, which hides the View Live button rather than pointing it at nothing.
*/
livePath: {
type: String,
default: ''
}
})
// COMPOSABLES
const dark = useDark()
// I18N
const { t } = useI18n()
// COMPUTED
/**
* The page icon, halved on a phone the same call, and the same reason, as `PageHeader`: `WIcon`
* renders `size` as an inline `font-size`, which no stylesheet can outrank without `!important`, so it
* has to be bound rather than left to a media query.
*/
const isAtLeastSm = useMinWidth(600)
const iconSize = computed(() => (isAtLeastSm.value ? '64px' : '32px'))
</script>

@ -0,0 +1,416 @@
/*
The chrome a page view is drawn in: the breadcrumb bar, the title header, and the column the article
and its footer scroll inside. Plus the placeholder screen that stands in for the article when there
is nothing to draw there.
Here rather than in `pages/Index.vue`, where all of it began, because there is now more than one view
wearing it -- `pages/PageVersion.vue` draws a historical snapshot in the same chrome. An SFC's
unscoped styles ship in that component's own lazily-imported chunk, so a second view reusing these
class names got the markup and none of the rules, which is the way this goes wrong invisibly: the
header renders, and it is a plain box with no gradient, no border and no height.
The contents column is here too, panel behaviour and all: both views have one, and the version view's
is the same column drawing the same list from the same `toc`. What stayed behind in `Index.vue` is
only what a snapshot has no use for -- the toggle on the tags heading, tags being editable on a page
and not on a record of one.
*/
/*
The Sass variables, loaded explicitly. `vite.config.js` injects both of these into every SFC through
`additionalData`, but that reaches only the files Vite itself hands to Sass -- a partial pulled in by
`@use` from `app.scss` is resolved by Sass and arrives without it. Same note as `_page-contents.scss`,
which is here for the same reason.
Both, not just the palette: the greys and the breakpoint come from `_palette.scss`, the `$dark-*` ramp
these gradients are built on from `_theme.scss`.
*/
@use 'palette' as *;
@use 'theme' as *;
/*
Where the contents column stops being able to afford 300px. A page view's own threshold, not one of the
app's -- `_palette.scss` is for the breakpoints the whole app shares, and this one is a function of a
page's two sidebars. Stated as a `max` value just under 1400px, the way the shared ones are.
*/
$toc-narrow-max: 1399.98px;
/*
...and where it stops being a column at all and becomes a panel over the article. The same boundary as
the 750px `useMinWidth` in each view, which decides whether that view renders the opener, and as the
one each layout uses to stand scroll-to-top down from this corner. They all have to agree.
*/
$toc-overlay-max: 749.98px;
/*
The column in place of the article: the lock screen, the page that does not exist, and the
redirection on its way somewhere else. All three are the same shape -- a large faint icon, a
sentence, and the one button that does something about it -- and share the styling so they cannot
drift apart. `PageRedirect.vue` draws its own screens with these classes for that reason.
*/
.page-placeholder {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
/* -> Off dead centre: the text reads better a little above the middle of the column */
padding: 0 24px 10vh;
text-align: center;
/*
Stated per theme, as everything else in this column is: the article's own colours come from
`_page-contents.scss`, so a plain block dropped in beside it inherits the document's black and
goes invisible on the dark surface. The icon below takes its colour from here as well.
*/
@at-root .body--light & {
color: $grey-9;
}
@at-root .body--dark & {
color: #fff;
}
}
/*
Large and faint. It is the illustration on an otherwise empty column, not something to look at -- the
sentence under it is what the reader is here to read.
*/
.page-placeholder-icon {
margin-bottom: 24px;
font-size: 96px;
opacity: 0.12;
}
.page-breadcrumbs {
@at-root .body--light & {
background: linear-gradient(to bottom, $grey-1 0%, $grey-3 100%);
border-bottom: 1px solid $grey-4;
}
/*
The bar sets a background per theme, so it owes a foreground too: the LAST crumb -- the current
page -- deliberately inherits rather than taking `active-color`, and what it was inheriting in
dark mode was the document's black.
*/
@at-root .body--light & {
color: var(--color-black);
}
@at-root .body--dark & {
background: linear-gradient(to bottom, $dark-3 0%, $dark-4 100%);
border-bottom: 1px solid $dark-3;
color: var(--color-white);
}
/*
A point off the trail on a phone, on the bar rather than on the crumbs: `WBreadcrumbs` sets no size
of its own and its icons are 125% of whatever it inherits, so one declaration here takes the text and
the icons down together and keeps the two in proportion.
13px is where it stops. The trail is how a reader gets back out, and it is already the smallest type
on the screen -- what is wanted is a bar that gives way to the page under it, not one nobody can read.
*/
@media (max-width: $breakpoint-xs-max) {
font-size: 0.8125rem;
}
}
/*
The version view's bar, which says which snapshot is on screen rather than where a page sits.
Indigo and not the page view's grey, because it is not the same statement: the trail on a page is
chrome a reader looks past, while this one is the only thing on screen saying that what is under it
is a RECORD of a page and not the page. A coloured band is what carries that at a glance, and indigo
is the colour the interface already gives history -- the Schedule tab's calendar, the version
timeline's own dots.
Both themes take the same treatment rather than the light one being tinted and the dark one left
dark: the point is a band that stands out from the chrome above and below it, and in the dark theme
a grey bar between a dark header and a dark article is exactly what does not.
Declared after the block above and overriding it at equal specificity, so source order is what
settles it -- one file, one place to look, and nothing depending on which chunk loaded last.
The foreground is stated once for everything in the bar, which is why the markup carries no colour
of its own: the icon paints with `currentColor` and the date inherits. `<strong>` is what marks the
date out, as it did when the bar was grey.
*/
.page-breadcrumbs--version {
@at-root .body--light & {
background: linear-gradient(to bottom, var(--color-indigo-6) 0%, var(--color-indigo-7) 100%);
border-bottom: 1px solid var(--color-indigo-9);
color: #fff;
}
/* -> A step down the same ramp, so the band reads as deliberate against a dark page rather than as
the light theme's bar left switched on */
@at-root .body--dark & {
background: linear-gradient(to bottom, var(--color-indigo-7) 0%, var(--color-indigo-9) 100%);
border-bottom: 1px solid var(--color-indigo-9);
color: #fff;
}
}
/*
The frame round the whole version view: 5px down both sides and along the bottom, with the bar above
closing the fourth. Together they box the screen in, which is the point -- the indigo says "what you
are reading is a RECORD of a page", and that is true of the whole view rather than of its first 30px.
Its colour is the DARK end of that bar's gradient in each theme, which is what makes the top corners
corners: the bar finishes on this exact value, so where the two meet there is no seam and the eye
reads one shape bent round the content. Picking the light end instead would draw a line across the
join.
No top width, since the bar is already there -- a border under it would be a second line saying the
same thing, and the bar has an underline of its own.
On the page element, which is what makes all three sides land where they should: it is a flex item in
`WPageContainer` that also claims `h-full`, so it is exactly as tall as the content cell, and the
bottom edge sits at the bottom of the window rather than below the fold. The article scrolls inside
its own box further in (`.page-container-scrl`), so the frame holds still while the content moves
behind it.
`box-sizing: border-box` is global (Tailwind's reset), so all 10px of the sides come out of the
content box and the view does not grow a scrollbar in either direction. That reset also declares
`border: 0 solid`, which is why a width and a colour are the whole of this -- and why the widths can
be stated once for both themes with only the colour switching.
*/
.page-version {
border-style: solid;
border-width: 0 5px 5px;
@at-root .body--light & {
border-color: var(--color-indigo-7);
}
@at-root .body--dark & {
border-color: var(--color-indigo-9);
}
}
.page-header {
height: 95px;
/*
Sized by its contents on a phone instead, which comes out around 70px: the 95px is pitched for a 64px
icon beside 34px display type, and holding it under the halved icon and title of the phone layout left
a band of empty gradient under the description.
`auto` rather than a smaller fixed height, because a fixed one is what the desktop bar can only just
afford: a title long enough to wrap has nowhere to go in it. Here the bar grows by a line instead, and
a page with no description gets a bar shorter still.
*/
@media (max-width: $breakpoint-xs-max) {
height: auto;
}
@at-root .body--light & {
background: linear-gradient(to bottom, $grey-2 0%, $grey-1 100%);
border-bottom: 1px solid $grey-4;
border-top: 1px solid #fff;
}
@at-root .body--dark & {
background: linear-gradient(to bottom, $dark-4 0%, $dark-3 100%);
// border-bottom: 1px solid $dark-5;
border-top: 1px solid $dark-6;
}
.no-height .q-field__control {
height: auto;
}
&-title {
/*
`text-h4` is a 34px display size written for a header the width of a desktop window: at 390px a
title of any length wrapped and pushed the description out of the bar. 24px is the step
`text-h5` takes, written as a value rather than as that class so the size sits beside the
breakpoint asking for it.
Here rather than in a header component's scoped block, where it started: scoped rules carry a
data attribute and reach only that one component's markup, so the second header wearing these
classes rendered a 34px title on a phone. The header's own `@media` block keeps what is its own
business -- which of ITS actions survive the narrow layout.
*/
@media (max-width: $breakpoint-xs-max) {
font-size: 1.5rem;
line-height: 2rem;
}
@at-root .body--light & {
color: $grey-9;
}
@at-root .body--dark & {
color: #fff;
}
}
&-subtitle {
@at-root .body--light & {
color: $grey-7;
}
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.6);
}
}
}
/*
The article and the footer under it, stacked inside the one box that scrolls.
`flex: 1 0 auto` on the article is what keeps the footer at the BOTTOM of a short page instead of
leaving it hanging under two lines of content: the article takes the leftover height, and past that
grows with its own content and pushes the footer out of view until the reader gets there. It must
not shrink either, or a long article would be squeezed to make room rather than scrolling.
*/
.page-container-scrl {
display: flex;
flex-direction: column;
}
.page-container-body {
flex: 1 0 auto;
/*
The other half of the padding change each view makes on a phone, where the article column pads by
8px a side instead of 16.
`--content-bleed` is how far the rule under an h1 reaches BACK through the padding of whatever holds
the content, so that it starts at the sidebar rather than at the text -- so it is a statement about
this surface's padding, and left at 1rem against 0.5rem of it the rule overhung the column by 8px.
`_page-contents.scss` declares the property expecting exactly this: a surface that pads differently
overrides the one property rather than the rule.
On the `.page-contents` element rather than here, because that is where the default is declared and a
custom property set on the parent would simply be shadowed by it. The editor's preview pane carries
the class itself and still pads 1rem, so it keeps the default.
*/
@media (max-width: $breakpoint-xs-max) {
.page-contents {
--content-bleed: 0.5rem;
}
}
}
/*
A hairline of the page's OWN background between the header and whatever the column starts with, in
each theme's colour -- so it is invisible against the article, which is that colour, and reads as one
pixel of daylight under anything that starts flush to the top of the column. A site banner does
exactly that, and against the header's bottom border it needs the gap.
Both themes: with the dark one left out the banner butted straight into the header there and not in
the light theme, which is the sort of difference that reads as a bug in whichever one you see second.
*/
.page-container {
@at-root .body--light & {
border-top: 1px solid #fff;
}
@at-root .body--dark & {
border-top: 1px solid $dark-6;
}
}
.page-sidebar {
flex: 0 0 300px;
/*
Narrower once the window is: 300px is pitched for a wide desktop, where it is a tenth of the width, and
by 1200px it is a quarter of what is left after the nav sidebar. 200px still holds a heading of a few
words per line -- the contents list wraps rather than truncating (see `PageToc`) -- and hands the
article the other 100px.
1400px is this view's own threshold rather than one of the app's `--breakpoint-*`: it is where THIS
column starts crowding the article, which depends on its own width and the nav's.
*/
@media (max-width: $toc-narrow-max) {
flex: 0 0 200px;
}
/*
And below 750px it stops being a column at all: even at 200px it is a third of a 600px window, and an
article is what the reader came for. It becomes a panel the width of the wide column, parked off the
right edge and slid in when asked for -- the same shape as the nav drawer on a narrow screen, and for
the same reason, so the two behave alike from opposite sides.
`position: fixed` is what takes it out of the row, so the article gets the whole width whether the
panel is open or not; the reader is never made to choose between the two, only to look at one at a
time. `transform` is what animates, being the one property that moves a box without laying anything
out again -- and the panel is out of flow, so there is nothing behind it to reflow anyway.
Right regardless of `tocPosition`: the opener is in the bottom-RIGHT corner, and a panel arriving from
the far side of the screen from the button that summoned it reads as something else appearing.
*/
@media (max-width: $toc-overlay-max) {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 40;
/* -> The wide column's width, capped so it cannot take the whole of a small screen */
width: 300px;
max-width: 85vw;
transform: translateX(100%);
transition: transform 0.2s var(--ease-standard);
box-shadow: -2px 0 12px rgb(0 0 0 / 0.3);
&.is-open {
transform: none;
}
}
@at-root .body--light & {
background-color: $grey-2;
}
@at-root .body--dark & {
background-color: $dark-5;
}
// A light rule on the light sidebar, near-black on the dark one -- it reads as the bevel between
// two panels rather than as a drawn line.
//
// The original set a background-colour here as well as a border. It never showed: the element is
// 1px tall with `box-sizing: border-box`, so the content box is 0px and the opaque border covers
// it completely. Only the border colour is carried across.
.w-separator {
--w-hairline-color: #fff;
}
@at-root .body--dark & .w-separator {
--w-hairline-color: #070a0d;
}
/*
The column is the height of the shell, so its own content scrolls when there is more of it than
there is room -- a long contents list, in practice. Nothing sticky is involved: the shell holds
still on its own, and the article beside this scrolls in its own box.
*/
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgb(102 102 102 / 0.5) transparent;
}
/*
Behind the panel, and under it: the same tint and the same z-index as the nav drawer's scrim, one step
below the panel it dims. The opener is at z-30 as well and is not rendered while the panel is open, so
the two never overlap.
*/
.page-sidebar-scrim {
position: fixed;
inset: 0;
z-index: 30;
background-color: rgb(0 0 0 / 0.4);
}
.page-sidebar-scrim-enter-active,
.page-sidebar-scrim-leave-active,
.toc-open-btn-enter-active,
.toc-open-btn-leave-active {
transition: opacity 0.2s var(--ease-standard);
}
.page-sidebar-scrim-enter-from,
.page-sidebar-scrim-leave-to,
.toc-open-btn-enter-from,
.toc-open-btn-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.page-sidebar,
.page-sidebar-scrim-enter-active,
.page-sidebar-scrim-leave-active,
.toc-open-btn-enter-active,
.toc-open-btn-leave-active {
transition-duration: 0.01ms;
}
}

@ -1404,14 +1404,13 @@
}
/*
Diagram sources -- a mermaid, plantuml or kroki fence, or a base64 `diagram` block -- reach the
page as code, and are drawn later or not at all. A quiet dashed panel says "this is a diagram that
has not been drawn" rather than pretending to be a code sample.
Diagram sources -- a mermaid, plantuml or kroki fence -- reach the page as code, and are drawn
later or not at all. A quiet dashed panel says "this is a diagram that has not been drawn" rather
than pretending to be a code sample.
*/
pre.codeblock-kroki,
pre.codeblock-mermaid,
pre.codeblock-plantuml,
pre.diagram {
pre.codeblock-plantuml {
border-style: dashed;
border-color: var(--content-rule-strong);
background-color: var(--content-surface-alt);

@ -1,4 +1,5 @@
@use 'base';
@use 'animation';
@use 'page-chrome';
@use 'page-contents';

@ -0,0 +1,103 @@
import { fileSave } from 'browser-fs-access'
import { MarkdownRenderer } from '@/renderers/markdown'
/**
* What a recorded page version is, to the two screens that show one.
*
* The history overlay and the version view both have to answer the same questions about a version --
* what format was it written in, what does its source save as, and what HTML does it become -- and
* they used to answer them separately. The format question alone is asked six times between them
* (colouring a diff, two renders, two downloads, a restore), so it lives here once.
*
* Nothing here touches a store or the i18n catalogue, which is what keeps it a helper: a caller
* supplies the renderer config it already holds, and reports failures in its own words.
*/
/** What a version's source is saved as, by the format it was written in. */
const FILE_TYPES = {
markdown: { ext: 'md', mime: 'text/markdown' },
html: { ext: 'html', mime: 'text/html' }
}
/** For a format nothing here knows: plain text saves and reads as itself. */
const FALLBACK_TYPE = { ext: 'txt', mime: 'text/plain' }
/**
* The format a version was written in -- which decides how it colours, how it renders and what it
* downloads as.
*
* Read off the VERSION and never off the page it belongs to: a page converted from markdown to HTML
* since is still a markdown version, and asking the page would render it as the wrong thing.
*
* `meta.contentType` is what `pageHistory.record` writes for every version, so the rest is belt and
* braces for a row that somehow lacks it.
*
* @param {object} version A version as the API returns one.
* @returns {string} `markdown`, `html`, or whatever the version claims.
*/
export function versionContentType(version) {
return version?.meta?.contentType || version?.meta?.editor || 'markdown'
}
/**
* Save a version's source to a file the reader picks.
*
* Named for the page and the moment -- `notes-2026-09-07-03-30-38.md` -- because a folder of
* `page.md` files says nothing about which page or which version each one is.
*
* @param {object} version A version WITH its `content`; the history list alone does not carry it.
* @returns {Promise<boolean>} True once written, false if the reader dismissed the picker. Anything
* that actually went wrong is thrown, for the caller to report in its own words.
*/
export async function saveVersionSource(version) {
const type = FILE_TYPES[versionContentType(version)] ?? FALLBACK_TYPE
const name = version.path?.split('/').at(-1) || 'page'
const stamp = (version.versionDate ?? '').slice(0, 19).replace(/[:T]/g, '-')
try {
/*
A bare MIME type, with no `;charset=` on it: the save picker uses this as an `accept` key and
rejects a type carrying parameters outright. Nothing is lost by dropping it -- a Blob built from
a JS string is UTF-8 already.
*/
await fileSave(new Blob([version.content ?? ''], { type: type.mime }), {
fileName: `${name}-${stamp}.${type.ext}`,
extensions: [`.${type.ext}`]
})
return true
} catch (err) {
// -> Dismissing the file picker is not a failure, and nothing should be said about it
if (err.name === 'AbortError') {
return false
}
throw err
}
}
/**
* A version's source as the HTML a page stores.
*
* Produced on this side for the same reason every save produces it here: the markdown pipeline is a
* frontend one, and the server would otherwise have to drive a headless browser. A version records
* the source it held and not the render, so there is nothing stored to use instead.
*
* The config is passed IN rather than read from `stores/editor` here, because no other helper in this
* directory reaches for a store and this is not the file to start in. The caller has it already, and
* has to make sure it is loaded (`editorStore.fetchConfigs()`) before asking.
*
* @param {object} version A version WITH its `content`.
* @param {object} options
* @param {object} options.markdownConfig `editorStore.editors.markdown` per-site renderer settings
* (line breaks, typographer, ).
* @param {string} options.pagePath The page this HTML is FOR, which is what a relative image in it
* resolves against. Not always the version's own `path`: content being restored onto a page that
* has since moved belongs to where that page is now.
* @returns {string} The HTML, or the source unchanged for a format this does not render.
*/
export function renderVersionSource(version, { markdownConfig, pagePath }) {
const content = version?.content ?? ''
if (versionContentType(version) !== 'markdown') {
return content
}
return new MarkdownRenderer(markdownConfig ?? {}).render(content, { pagePath })
}

@ -249,3 +249,43 @@ export function sameDocumentHash({ href, target, download, rel } = {}, current)
return url.hash
}
/**
* What a click inside rendered content is asking for, if it is asking for anything.
*
* Every view that draws wiki content faces the same three-way decision -- an anchor on the page in
* front of the reader, a link into the wiki, or something to leave to the browser -- so the decision
* lives here and each view acts on the answer. `pages/Index.vue` reads a live page and
* `pages/PageVersion.vue` a snapshot of one; a link in either behaves the same way, and would drift
* if each worked it out for itself.
*
* The modifier tests are what keeps middle-click, ctrl-click and shift-click doing what they do
* everywhere else: opening a tab or a window is the browser's, not the router's.
*
* @param {MouseEvent} ev The click, as delegated from the element holding the content.
* @param {Location|{origin: string, pathname: string}} current Where the reader is now.
* @returns {{kind: 'hash', hash: string}|{kind: 'route', target: string}|null} What to do, or null to
* leave the click alone.
*/
export function resolveContentClick(ev, current) {
if (
ev.defaultPrevented ||
ev.button !== 0 ||
ev.metaKey ||
ev.ctrlKey ||
ev.shiftKey ||
ev.altKey
) {
return null
}
const anchor = ev.target?.closest?.('a[href]')
if (!anchor) {
return null
}
const hash = sameDocumentHash(anchor, current)
if (hash) {
return { kind: 'hash', hash }
}
const target = routableHref(anchor, current)
return target ? { kind: 'route', target } : null
}

@ -0,0 +1,96 @@
<template>
<w-layout>
<w-header class="site-header-wrap">
<header-nav />
</w-header>
<!--
No `<w-drawer>`, which is the whole of what separates this from `MainLayout`: a snapshot is
reached from a link rather than browsed to, and the navigation tree is about where pages are NOW
-- every entry in it would lead out of the version being read without saying so.
What follows from that is the simple part of this file. There is no sidebar, so there is no
opener for one on a narrow viewport, and no column for scroll-to-top to end at: it sits in the
corner at every width, which is the `scrollerAnchorX: null` case `MainLayout` reaches when a site
has its sidebar off.
-->
<!--
No `<w-footer>` here, for the same reason as `MainLayout`: this shell holds still and the article
column scrolls inside it, so a footer at this level would be pinned to the window. The version
view puts one at the end of that scrolling column instead.
-->
<w-page-container>
<router-view />
<!--
Below 750px the version view turns its contents column into a panel and takes this corner for
the opener, exactly as the page view does -- so this stands down there, and the two never
overlap. `.page-container-scrl` is the article column, which is what actually scrolls.
-->
<w-page-scroller
v-if="isAtLeastTocPanelWidth"
:scroll-offset="150"
:anchor-x="null"
target=".page-container-scrl">
<w-btn
class="corner-btn corner-btn--right"
icon="la:arrow-up"
color="primary"
round
size="md" />
</w-page-scroller>
</w-page-container>
</w-layout>
</template>
<script setup>
import { useMinWidth } from '@/composables/screen'
import { useMeta } from '@/composables/meta'
import { useSiteStore } from '@/stores/site'
// COMPONENTS
import HeaderNav from '@/components/HeaderNav.vue'
// STORES
const siteStore = useSiteStore()
// META
/*
The same title template as `MainLayout`, and a getter for the same reason: the site config is
fetched, so a template closing over `siteStore.title` and registered once would keep whatever the
store held at mount. The version view supplies the title half.
*/
useMeta(() => {
const siteTitle = siteStore.title
return {
titleTemplate: (title) => (title ? `${title} - ${siteTitle}` : siteTitle)
}
})
// COMPUTED
/**
* At or above 750px, which is where scroll-to-top keeps the bottom-right corner. Below it the version
* view turns its contents column into a panel and puts that panel's opener here instead. The view owns
* the threshold (`$toc-overlay-max` and the 750px `useMinWidth` in `pages/PageVersion.vue`); this is
* the same number from the side that has to get out of the way.
*/
const isAtLeastTocPanelWidth = useMinWidth(750)
</script>
<style lang="scss">
/*
The window behind the shell, in the dark theme.
Every layout that holds a page-shaped view declares this for itself -- `MainLayout`, `InboxLayout`
and `ProfileLayout` all carry the identical rule -- because it paints `body`, which is outside the
app's own markup and so cannot be reached by anything scoped. Without it the article column sits on
the browser's default white while the header, the contents column and the footer are all dark, and
the body text, which IS white in that theme, disappears into it.
*/
body.body--dark {
background-color: $dark-6;
}
</style>

@ -482,6 +482,17 @@ function copyFromLocale() {
async function save() {
this.$store.commit('loadingStart', 'admin-navigation-save')
try {
/*
FIXME: This whole handler is dead. `APOLLO_CLIENT` is not defined anywhere -- the GraphQL client
went with the rest of Apollo -- so saving the navigation throws here, and the nine
`this.$store.commit(...)` calls around it throw too, this being `<script setup>` with no Vuex
store in the app at all. Porting it to `API_CLIENT` and a REST route is what fixes it; see
"GraphQL is being removed" in CLAUDE.md.
The disable keeps `no-undef` usable repo-wide rather than hiding this: the rule is what found
it, and the comment is here so it stays found.
*/
// eslint-disable-next-line no-undef
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation ($tree: [NavigationTreeInput]!, $mode: NavigationMode!) {

@ -85,8 +85,15 @@
From `la` rather than `mdi` for weight: every other icon on this page is Line
Awesome, which is an outline set, and MDI's solid glyph sat noticeably heavier
beside them.
`indigo` is a mid-ramp shade that sits nearly black against the dark theme's
table, so dark mode takes the lightest indigo the theme has instead.
-->
<w-icon class="mr-2" name="la:calendar" color="indigo" size="sm" />
<w-icon
class="mr-2 dark:text-indigo-4"
name="la:calendar"
color="indigo"
size="sm" />
</w-td>
</template>
<template v-slot:body-cell-task="props">

@ -351,7 +351,11 @@ import { withViewTransition } from '@/composables/viewTransition'
import { loading } from '@/composables/loading'
import { scrollToAnchor, scrollToAnchorWhenReady } from '@/helpers/anchors'
import { splitLocalePath } from '@/helpers/pagePaths'
import { enhanceRenderedContent, routableHref, sameDocumentHash } from '@/helpers/renderedContent'
import {
enhanceRenderedContent,
resolveContentClick,
routableHref
} from '@/helpers/renderedContent'
import { flattenToc } from '@/helpers/toc'
import { useCommonStore } from '@/stores/common'
@ -849,45 +853,30 @@ function relationLink(rel) {
}
function onContentClick(ev) {
if (
ev.defaultPrevented ||
ev.button !== 0 ||
ev.metaKey ||
ev.ctrlKey ||
ev.shiftKey ||
ev.altKey
) {
return
}
const anchor = ev.target?.closest?.('a[href]')
if (!anchor) {
const intent = resolveContentClick(ev, window.location)
if (!intent) {
return
}
/*
A heading on this same page: travelled to rather than jumped at, which is how the contents list
and an arriving `#heading` already reach one. Through the helper, so a heading inside a closed tab
is revealed first, and only claimed once it says it found somewhere to go -- a fragment naming
nothing in the render is left to the browser, as it was.
and an arriving `#heading` already reach one. Only claimed once `scrollToAnchor` says it found
somewhere to go -- it reveals a heading inside a closed tab first, and a fragment naming nothing
in the render is left to the browser, as it was.
The URL still follows, so the address bar can be copied and Back returns to the section before.
`router.push` rather than assigning `location.hash`, which would jump the page as well -- and since
a pushed hash sets no target element, marking where the reader landed is the helper's job (see
`LANDED_CLASS`) rather than `:target`'s.
*/
const hash = sameDocumentHash(anchor, window.location)
if (hash) {
if (scrollToAnchor(hash, { smooth: true })) {
if (intent.kind === 'hash') {
if (scrollToAnchor(intent.hash, { smooth: true })) {
ev.preventDefault()
router.push({ path: route.path, query: route.query, hash })
router.push({ path: route.path, query: route.query, hash: intent.hash })
}
return
}
const target = routableHref(anchor, window.location)
if (!target) {
return
}
ev.preventDefault()
router.push(target)
router.push(intent.target)
}
function openTocPanel() {
@ -943,190 +932,6 @@ function goBack() {
</script>
<style lang="scss">
/*
Where the contents column stops being able to afford 300px. This view's own threshold, not one of the
app's -- `_palette.scss` is for the breakpoints the whole app shares, and this one is a function of this
page's two sidebars. Stated as a `max` value just under 1400px, the way the shared ones are.
*/
$toc-narrow-max: 1399.98px;
/*
...and where it stops being a column at all and becomes a panel over the article. The same boundary as
the 750px `useMinWidth` above, which decides whether the opener is rendered, and as the one `MainLayout`
uses to stand scroll-to-top down from this corner. All three have to agree.
*/
$toc-overlay-max: 749.98px;
/*
The column in place of the article: the lock screen, the page that does not exist, and the
redirection on its way somewhere else. All three are the same shape -- a large faint icon, a
sentence, and the one button that does something about it -- and share the styling so they cannot
drift apart. `PageRedirect.vue` draws its own screens with these classes for that reason.
*/
.page-placeholder {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
/* -> Off dead centre: the text reads better a little above the middle of the column */
padding: 0 24px 10vh;
text-align: center;
/*
Stated per theme, as everything else in this column is: the article's own colours come from
`_page-contents.scss`, so a plain block dropped in beside it inherits the document's black and
goes invisible on the dark surface. The icon below takes its colour from here as well.
*/
@at-root .body--light & {
color: $grey-9;
}
@at-root .body--dark & {
color: #fff;
}
}
/*
Large and faint. It is the illustration on an otherwise empty column, not something to look at -- the
sentence under it is what the reader is here to read.
*/
.page-placeholder-icon {
margin-bottom: 24px;
font-size: 96px;
opacity: 0.12;
}
.page-breadcrumbs {
@at-root .body--light & {
background: linear-gradient(to bottom, $grey-1 0%, $grey-3 100%);
border-bottom: 1px solid $grey-4;
}
/*
The bar sets a background per theme, so it owes a foreground too: the LAST crumb -- the current
page -- deliberately inherits rather than taking `active-color`, and what it was inheriting in
dark mode was the document's black.
*/
@at-root .body--light & {
color: var(--color-black);
}
@at-root .body--dark & {
background: linear-gradient(to bottom, $dark-3 0%, $dark-4 100%);
border-bottom: 1px solid $dark-3;
color: var(--color-white);
}
/*
A point off the trail on a phone, on the bar rather than on the crumbs: `WBreadcrumbs` sets no size
of its own and its icons are 125% of whatever it inherits, so one declaration here takes the text and
the icons down together and keeps the two in proportion.
13px is where it stops. The trail is how a reader gets back out, and it is already the smallest type
on the screen -- what is wanted is a bar that gives way to the page under it, not one nobody can read.
*/
@media (max-width: $breakpoint-xs-max) {
font-size: 0.8125rem;
}
}
.page-header {
height: 95px;
/*
Sized by its contents on a phone instead, which comes out around 70px: the 95px is pitched for a 64px
icon beside 34px display type, and holding it under the halved icon and title of the phone layout left
a band of empty gradient under the description.
`auto` rather than a smaller fixed height, because a fixed one is what the desktop bar can only just
afford: a title long enough to wrap has nowhere to go in it. Here the bar grows by a line instead, and
a page with no description gets a bar shorter still.
*/
@media (max-width: $breakpoint-xs-max) {
height: auto;
}
@at-root .body--light & {
background: linear-gradient(to bottom, $grey-2 0%, $grey-1 100%);
border-bottom: 1px solid $grey-4;
border-top: 1px solid #fff;
}
@at-root .body--dark & {
background: linear-gradient(to bottom, $dark-4 0%, $dark-3 100%);
// border-bottom: 1px solid $dark-5;
border-top: 1px solid $dark-6;
}
.no-height .q-field__control {
height: auto;
}
&-title {
@at-root .body--light & {
color: $grey-9;
}
@at-root .body--dark & {
color: #fff;
}
}
&-subtitle {
@at-root .body--light & {
color: $grey-7;
}
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.6);
}
}
}
/*
The article and the footer under it, stacked inside the one box that scrolls.
`flex: 1 0 auto` on the article is what keeps the footer at the BOTTOM of a short page instead of
leaving it hanging under two lines of content: the article takes the leftover height, and past that
grows with its own content and pushes the footer out of view until the reader gets there. It must
not shrink either, or a long article would be squeezed to make room rather than scrolling.
*/
.page-container-scrl {
display: flex;
flex-direction: column;
}
.page-container-body {
flex: 1 0 auto;
/*
The other half of the padding change in the template above.
`--content-bleed` is how far the rule under an h1 reaches BACK through the padding of whatever holds
the content, so that it starts at the sidebar rather than at the text -- so it is a statement about
this surface's padding, and left at 1rem against 0.5rem of it the rule overhung the column by 8px.
`_page-contents.scss` declares the property expecting exactly this: a surface that pads differently
overrides the one property rather than the rule.
On the `.page-contents` element rather than here, because that is where the default is declared and a
custom property set on the parent would simply be shadowed by it. The editor's preview pane carries
the class itself and still pads 1rem, so it keeps the default.
*/
@media (max-width: $breakpoint-xs-max) {
.page-contents {
--content-bleed: 0.5rem;
}
}
}
/*
A hairline of the page's OWN background between the header and whatever the column starts with, in
each theme's colour -- so it is invisible against the article, which is that colour, and reads as one
pixel of daylight under anything that starts flush to the top of the column. A site banner does
exactly that, and against the header's bottom border it needs the gap.
Both themes: with the dark one left out the banner butted straight into the header there and not in
the light theme, which is the sort of difference that reads as a bug in whichever one you see second.
*/
.page-container {
@at-root .body--light & {
border-top: 1px solid #fff;
}
@at-root .body--dark & {
border-top: 1px solid $dark-6;
}
}
/*
The Tags heading's edit toggle. `visibility` is transitioned alongside the opacity so it still fades
BOTH ways: as a discrete property it flips at the end of the transition when going to hidden, and at
@ -1148,118 +953,4 @@ $toc-overlay-max: 749.98px;
transition-duration: 0.01ms;
}
}
.page-sidebar {
flex: 0 0 300px;
/*
Narrower once the window is: 300px is pitched for a wide desktop, where it is a tenth of the width, and
by 1200px it is a quarter of what is left after the nav sidebar. 200px still holds a heading of a few
words per line -- the contents list wraps rather than truncating (see `PageToc`) -- and hands the
article the other 100px.
1400px is this view's own threshold rather than one of the app's `--breakpoint-*`: it is where THIS
column starts crowding the article, which depends on its own width and the nav's.
*/
@media (max-width: $toc-narrow-max) {
flex: 0 0 200px;
}
/*
And below 750px it stops being a column at all: even at 200px it is a third of a 600px window, and an
article is what the reader came for. It becomes a panel the width of the wide column, parked off the
right edge and slid in when asked for -- the same shape as the nav drawer on a narrow screen, and for
the same reason, so the two behave alike from opposite sides.
`position: fixed` is what takes it out of the row, so the article gets the whole width whether the
panel is open or not; the reader is never made to choose between the two, only to look at one at a
time. `transform` is what animates, being the one property that moves a box without laying anything
out again -- and the panel is out of flow, so there is nothing behind it to reflow anyway.
Right regardless of `tocPosition`: the opener is in the bottom-RIGHT corner, and a panel arriving from
the far side of the screen from the button that summoned it reads as something else appearing.
*/
@media (max-width: $toc-overlay-max) {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 40;
/* -> The wide column's width, capped so it cannot take the whole of a small screen */
width: 300px;
max-width: 85vw;
transform: translateX(100%);
transition: transform 0.2s var(--ease-standard);
box-shadow: -2px 0 12px rgb(0 0 0 / 0.3);
&.is-open {
transform: none;
}
}
@at-root .body--light & {
background-color: $grey-2;
}
@at-root .body--dark & {
background-color: $dark-5;
}
// A light rule on the light sidebar, near-black on the dark one -- it reads as the bevel between
// two panels rather than as a drawn line.
//
// The original set a background-colour here as well as a border. It never showed: the element is
// 1px tall with `box-sizing: border-box`, so the content box is 0px and the opaque border covers
// it completely. Only the border colour is carried across.
.w-separator {
--w-hairline-color: #fff;
}
@at-root .body--dark & .w-separator {
--w-hairline-color: #070a0d;
}
/*
The column is the height of the shell, so its own content scrolls when there is more of it than
there is room -- a long contents list, in practice. Nothing sticky is involved: the shell holds
still on its own, and the article beside this scrolls in its own box.
*/
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgb(102 102 102 / 0.5) transparent;
}
/*
Behind the panel, and under it: the same tint and the same z-index as the nav drawer's scrim, one step
below the panel it dims. The opener is at z-30 as well and is not rendered while the panel is open, so
the two never overlap.
*/
.page-sidebar-scrim {
position: fixed;
inset: 0;
z-index: 30;
background-color: rgb(0 0 0 / 0.4);
}
.page-sidebar-scrim-enter-active,
.page-sidebar-scrim-leave-active,
.toc-open-btn-enter-active,
.toc-open-btn-leave-active {
transition: opacity 0.2s var(--ease-standard);
}
.page-sidebar-scrim-enter-from,
.page-sidebar-scrim-leave-to,
.toc-open-btn-enter-from,
.toc-open-btn-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.page-sidebar,
.page-sidebar-scrim-enter-active,
.page-sidebar-scrim-leave-active,
.toc-open-btn-enter-active,
.toc-open-btn-leave-active {
transition-duration: 0.01ms;
}
}
</style>

@ -0,0 +1,551 @@
<template>
<!--
`h-full min-h-0`, as in the page view: the shell hands this a definite height and it has to CLAIM
it, or the article column below cannot scroll on its own and the whole page scrolls in the shell.
-->
<w-page class="page-version flex flex-col h-full min-h-0">
<!--
Where the page view puts the breadcrumb trail. A snapshot has no trail to draw: the crumbs lead
to where each folder is NOW, which is a walk out of the version and into the live wiki without
saying so -- and the version's own path is already the title bar's business. What identifies this
screen is the version, so the version is what the bar says.
-->
<div
class="page-breadcrumbs page-breadcrumbs--version py-1 px-4 sm:py-2 flex flex-wrap"
v-if="state.version">
<div class="min-w-0 flex-1 flex items-center">
<!-- -> No `color` on the icon and no grey on the date: the indigo bar states one foreground
for everything in it, and `WIcon` paints with `currentColor`, so both inherit it -->
<w-icon class="mr-2" name="la:history" size="sm" />
<!-- -> `shrink-0`, so it is the ID that truncates on a narrow bar and never the words saying
what the ID is -->
<span class="text-caption shrink-0 mr-1">{{ t('history.viewingVersionId') }}</span>
<!-- -> Monospaced and selectable: it is an identifier, and the reason to show one in full is
so it can be read off and quoted -->
<span class="text-caption font-robotomono select-all truncate">{{ state.version.id }}</span>
</div>
<!--
Off on a phone, as the page view's date is: on a 390px screen it takes a whole line of its own
under the identifier.
-->
<div class="flex-none items-center justify-end hidden sm:flex">
<div class="text-caption">
{{ t('history.snapshotFrom') }} <strong>{{ snapshotFrom }}</strong>
</div>
</div>
</div>
<page-version-header
v-if="state.version"
:icon="versionIcon"
:title="state.version.title"
:description="versionDescription"
:live-path="livePath"
@download="downloadVersion"
@restore="restoreVersion"
@branch="branchFrom" />
<!-- -> `min-h-0` so the columns inside can be shorter than their content and scroll -->
<div class="page-container flex min-h-0 flex-nowrap items-stretch" style="flex: 1 1 100%">
<div
class="min-w-0 flex-1"
:style="siteStore.theme.tocPosition === `left` ? `order: 2;` : `order: 1;`">
<!--
The same placeholder column the page view uses for a page that is not there, and for the
same reason: this is a state of the view rather than an error screen. Nothing here offers a
way to fix it -- a version either exists and may be read, or it does not -- so the way out is
the way they came.
-->
<div v-if="state.failed" class="page-placeholder">
<w-icon class="page-placeholder-icon" name="la:history" />
<div class="text-h6">{{ t('history.versionUnavailable') }}</div>
<div class="text-body2 mt-1 opacity-60">{{ t('history.versionUnavailableHint') }}</div>
<w-btn
class="mt-6"
outline
icon="la:arrow-left"
color="primary"
padding="xs lg"
:label="t(`common.newpage.goback`)"
@click="goBack" />
</div>
<w-scroll-area class="page-container-scrl" v-else style="height: 100%">
<!-- -> Half the padding on a phone, matching the page view; `--content-bleed` follows in
`_page-chrome.scss` -->
<div class="page-container-body p-2 sm:p-4">
<!--
Delegated rather than bound per link: the anchors are written by `v-html`, so there is
nothing here to put a handler on.
-->
<div
class="page-contents"
ref="pageContents"
v-html="state.render"
@click="onContentClick" />
</div>
<!-- -> Inside the scrolling column and last, so it is the bottom of the page rather than
something sitting over the article -->
<w-footer>
<footer-nav />
</w-footer>
</w-scroll-area>
</div>
<!-- -> The scrim behind the contents panel while it overlays the article, and how it is
dismissed without picking a heading -->
<transition name="page-sidebar-scrim">
<div v-if="tocPanelIsOpen" class="page-sidebar-scrim" @click="closeTocPanel" />
</transition>
<!--
The contents column, drawn from the `toc` the version recorded -- see `models/pageHistory.ts`,
where `toc` is kept in a version's `meta` for exactly this. Below 750px it stops being a column
and slides in over the article instead, which is why it stays mounted at every width and
`is-open` is what decides whether it is on screen.
Contents and nothing else. The page view's tags and rating are beside them there because both
are things to DO to the page in front of the reader, and neither is a thing to do to a record
of what it once said -- editing tags writes the live page, and a rating is about the page as it
stands.
-->
<div
class="page-sidebar"
v-if="showToc"
:class="{ 'is-open': tocPanelIsOpen }"
:style="siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`"
@click="onSidebarClick">
<div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:stream" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.page.contents') }}</div>
</div>
<div class="px-4 pb-2">
<page-toc
:nodes="versionToc"
:min-depth="tocDepth.min"
:max-depth="tocDepth.max"
v-model:selected="state.tocSelected" />
</div>
</div>
</div>
<!--
What opens that panel, in the bottom-right corner -- the corner `VersionLayout` gives to
scroll-to-top, which stands down below 750px so that this can have it. Same position and the same
`.corner-btn` shape, so the two read as one button that changes what it does.
-->
<transition name="toc-open-btn">
<div v-if="showTocPanelBtn" class="fixed bottom-0 right-0 z-30">
<w-btn
class="corner-btn corner-btn--right"
icon="mdi:file-tree"
color="primary"
round
size="md"
:aria-label="t(`common.page.contents`)"
:aria-expanded="tocPanelIsOpen"
@click="openTocPanel" />
</div>
</transition>
</w-page>
</template>
<script setup>
import { computed, defineAsyncComponent, nextTick, reactive, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen'
import { confirm, dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { apiErrorMessage } from '@/helpers/apiError'
import { scrollToAnchor } from '@/helpers/anchors'
import { enhanceRenderedContent, resolveContentClick } from '@/helpers/renderedContent'
import { renderVersionSource, saveVersionSource } from '@/helpers/pageVersions'
import { flattenToc } from '@/helpers/toc'
import { useEditorStore } from '@/stores/editor'
import { useSiteStore } from '@/stores/site'
import FooterNav from '@/components/FooterNav.vue'
import PageToc from '@/components/PageToc.vue'
import PageVersionHeader from '@/components/PageVersionHeader.vue'
// STORES
const editorStore = useEditorStore()
const siteStore = useSiteStore()
// ROUTER
const router = useRouter()
const route = useRoute()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
/** The version as the API answered it, or null while it is on its way — and after it fails. */
version: null,
/** The version's source, rendered here. See `renderFor`. */
render: '',
/**
* Whether the version could not be loaded gone, never there, or not this reader's to see. One flag
* for all three: the API deliberately does not tell them apart (a version on a page you cannot read
* is indistinguishable from one that does not exist), so neither can this.
*/
failed: false,
tocSelected: null,
/** Whether the contents panel has been slid open. Only consulted below 750px. */
tocPanelOpen: false
})
const pageContents = ref(null)
// META
/*
A getter, for the same reason as the page view's: the title is not known when this runs -- the view
mounts for the version ID and the snapshot arrives a moment later.
Below `state` and not above it, which is not a matter of taste: `useMeta` runs the getter straight
away, so declared first it read `state` from inside its temporal dead zone and the whole view failed
to mount with "Cannot access 'state' before initialization".
*/
useMeta(() => ({
title: state.version?.title ?? ''
}))
// COMPUTED
/** Below 750px, where the contents stop being a column beside the article and become a panel over it. */
const isAtLeast750 = useMinWidth(750)
const tocIsPanel = computed(() => !isAtLeast750.value)
const tocPanelIsOpen = computed(() => tocIsPanel.value && showToc.value && state.tocPanelOpen)
const showTocPanelBtn = computed(() => tocIsPanel.value && showToc.value && !state.tocPanelOpen)
/**
* The page's own fields as the version recorded them.
*
* Everything but the path, the title and the date lives in `meta` those four have columns of their
* own on `pageHistory`. Read off the VERSION rather than off the live page throughout: the point of
* this screen is what the page said then, and its icon and description are part of that.
*/
const versionIcon = computed(() => state.version?.meta?.icon || 'la:file-alt')
const versionDescription = computed(() => state.version?.meta?.description ?? '')
const versionToc = computed(() => state.version?.meta?.toc ?? [])
const tocDepth = computed(() => state.version?.meta?.config?.tocDepth ?? { min: 1, max: 2 })
/**
* Where the live page is, for the View Live button.
*
* Built from `pagePath` / `pageLocale` the page as it stands and NOT from `version.path`, which is
* where the page was when this snapshot was written: a page that has since moved would send the reader
* to a path that no longer holds anything.
*
* Prefixed the way every other in-app link to a page is, so a site that brackets its URLs by locale
* lands on the right one rather than being redirected to the primary locale's copy.
*/
const livePath = computed(() => {
const path = state.version?.pagePath
if (!path) {
return ''
}
return `${siteStore.localeUrlPrefix(state.version.pageLocale)}/${path}`
})
/*
Whether there is a contents section to draw, rather than whether the page asked for one: a version
with no headings, or whose depth settings leave nothing to list, would otherwise show "Contents" over
an empty space. Asked of the same helper the list itself draws from, so the two cannot disagree.
`showToc` on the version is the page's own setting as it stood, and the site's `tocPosition` is the
live one there being no historical copy of a site's theme to consult.
*/
const showToc = computed(() => {
if (!state.version || siteStore.theme.tocPosition === 'off') {
return false
}
if (state.version.meta?.config?.showToc === false) {
return false
}
return (
flattenToc(versionToc.value, {
minDepth: tocDepth.value.min,
maxDepth: tocDepth.value.max
}).length > 0
)
})
/**
* When the snapshot was taken what stands where the page view says "Last modified on".
*
* The same fields that view formats, so the two bars read alike.
*/
const snapshotFrom = computed(() => {
return state.version?.versionDate
? Temporal.Instant.from(state.version.versionDate).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
: 'N/A'
})
// WATCHERS
/*
The copy buttons on code blocks are part of the content, so they are re-added whenever the content
is. Keyed on the render rather than on the route, since it arrives after this has already mounted.
*/
watch(
() => state.render,
() => {
nextTick(() => enhanceRenderedContent(pageContents.value))
},
{ immediate: true }
)
/*
The version in the URL is what this view is OF, so it is what drives the load immediately, since
arriving here IS the request. The router reuses this component when only the parameter changes,
which is what walking from one version to another would do.
*/
watch(() => route.params.versionId, loadVersion, { immediate: true })
// METHODS
async function loadVersion(versionId) {
if (!versionId) {
state.failed = true
return
}
loading.show()
try {
const version = await API_CLIENT.get(`sites/${siteStore.id}/versions/${versionId}`).json()
if (!version?.id) {
throw new Error('ERR_VERSION_NOT_FOUND')
}
state.render = await renderFor(version, version.path)
state.version = version
state.failed = false
state.tocPanelOpen = false
} catch (err) {
state.version = null
state.render = ''
state.failed = true
notify({
type: 'negative',
message: apiErrorMessage(err, t('history.versionLoadFailed'))
})
} finally {
loading.hide()
}
}
/**
* The HTML for a version's source.
*
* @param version The version to render.
* @param pagePath The page the HTML is FOR, which is what a relative image in it resolves against.
* Reading a snapshot, that is the path the version was written at -- it is the page as it was, so
* its links should resolve as they did. Restoring or branching, it is where the content is GOING,
* because that is the page the reader will follow those links from.
*/
async function renderFor(version, pagePath) {
// -> The renderer is configured per site (line breaks, typographer, ), and that configuration
// arrives with the editor configs rather than on its own
if (!editorStore.configIsLoaded) {
await editorStore.fetchConfigs()
}
return renderVersionSource(version, {
markdownConfig: editorStore.editors.markdown,
pagePath
})
}
/**
* Save this version's source, the same as the history overlay's Download.
*
* No fetch first, unlike there: the overlay downloads from a timeline that carries no source, while
* this screen already has the whole version in hand -- it is what is being read.
*/
async function downloadVersion() {
if (!state.version) {
return
}
try {
await saveVersionSource(state.version)
} catch (err) {
notify({ type: 'negative', message: t('history.downloadFailed'), caption: err.message })
}
}
/**
* Put this version's source back on the page, as the history overlay's Restore does.
*
* The source only: the page keeps the title, tags and settings it has now. Restoring those too would
* quietly undo everything done since, and a reader asking for an old version back is asking for the
* text. Nothing is lost either way -- this is an ordinary edit, so it becomes a version of its own
* with the current state recorded in it.
*/
function restoreVersion() {
const version = state.version
if (!version) {
return
}
confirm({
title: t('history.restore'),
message: [
t('history.restoreConfirm', { date: snapshotFrom.value }),
t('history.restoreConfirmHint')
],
caption: t('history.versionId', { id: version.id }),
cancel: true,
color: 'negative',
okLabel: t('history.restore')
}).onOk(async () => {
loading.show()
try {
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/pages/${version.pageId}`, {
json: {
content: version.content ?? '',
// -> For where the content is going, which is the page's path NOW and not the version's
render: await renderFor(version, version.pagePath),
reasonForChange: t('history.restoreReason', { date: snapshotFrom.value })
}
}).json()
if (!resp?.page?.id) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({ type: 'positive', message: t('history.restoreSuccess') })
/*
On to the live page, which is where the restore actually happened. The overlay reloads the
page behind itself for the same reason; here there is nothing behind, and a snapshot does not
change -- staying put would leave the reader on a screen that looks exactly as it did and
gives no sign anything had happened.
*/
router.push(livePath.value)
} catch (err) {
notify({
type: 'negative',
message: t('history.restoreFailed'),
caption: apiErrorMessage(err)
})
} finally {
loading.hide()
}
})
}
/**
* Start a new page from this version, leaving the live one alone.
*
* What to do with an old version that is worth keeping but not worth reverting to. The same path
* picker as duplicating a page, because that is what this is -- a duplicate of a page as it was.
*/
function branchFrom() {
const version = state.version
if (!version) {
return
}
dialog({
component: defineAsyncComponent(() => import('@/components/TreeBrowserDialog.vue')),
componentProps: {
mode: 'duplicatePage',
folderPath: '',
itemId: version.pageId,
itemTitle: version.title,
itemFileName: version.pagePath,
locale: version.pageLocale
}
}).onOk(async (target) => {
loading.show()
try {
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, {
json: {
path: target.path,
title: target.title,
locale: version.pageLocale,
editor: version.meta?.editor || 'markdown',
content: version.content ?? '',
// -> Rendered for where it is going, not for where the version came from
render: await renderFor(version, target.path),
description: version.meta?.description ?? '',
icon: version.meta?.icon ?? '',
tags: version.meta?.tags ?? [],
// -> A version that was scheduled carries dates this new page has not got, and the API
// rightly refuses that combination
publishState: version.meta?.publishState === 'published' ? 'published' : 'draft',
reasonForChange: t('history.branchReason', { date: snapshotFrom.value })
}
}).json()
const page = resp?.page
if (!page?.id) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({ type: 'positive', message: t('history.branchSuccess') })
// -> This page went up with the version's tags, so a tag no page carried any more is back; the
// tag fields have to hear about it, the same as after a save
siteStore.staleTags()
router.push(`/${page.path}`)
} catch (err) {
notify({
type: 'negative',
message: t('history.branchFailed'),
caption: apiErrorMessage(err)
})
} finally {
loading.hide()
}
})
}
function onContentClick(ev) {
const intent = resolveContentClick(ev, window.location)
if (!intent) {
return
}
// -> A heading in the snapshot: travelled to rather than jumped at, as everywhere else in the app.
// The URL follows so the section can be linked to, and Back returns to the one before.
if (intent.kind === 'hash') {
if (scrollToAnchor(intent.hash, { smooth: true })) {
ev.preventDefault()
router.push({ path: route.path, query: route.query, hash: intent.hash })
}
return
}
/*
Anything else goes to the LIVE wiki, and deliberately so: a link in a snapshot was written to point
at a page, not at that page as it stood on the same day, and there is no version of the target to
send the reader to even if it had been.
*/
ev.preventDefault()
router.push(intent.target)
}
/*
Following a link out of the panel puts it away, since what the reader asked for is behind it. A
`<button>` in here is not that, which is why the test is `closest('a')` rather than any click.
*/
function onSidebarClick(ev) {
if (tocPanelIsOpen.value && ev.target?.closest?.('a')) {
closeTocPanel()
}
}
function openTocPanel() {
state.tocPanelOpen = true
}
function closeTocPanel() {
state.tocPanelOpen = false
}
function goBack() {
router.back()
}
</script>

@ -311,13 +311,10 @@ function lineRows(lineCount, lineStart, highlights) {
* @param {string} str The code, as the author wrote it.
* @param {string} lang The first word of the info string.
* @param {object} attributes The rest of it, parsed -- see `parseFenceAttributes`. Ignored by the
* diagram branches, which are a source for something else to draw and have
* diagram branch, which is a source for something else to draw and has
* no gutter, no title bar and no lines to mark.
*/
function codeBlock(str, lang, attributes) {
if (lang === 'diagram') {
return `<pre class="diagram">${Buffer.from(str, 'base64').toString()}</pre>`
}
if (['kroki', 'mermaid', 'plantuml'].includes(lang)) {
/*
Left as source, deliberately: a diagram is drawn by the block whose body it is

@ -117,6 +117,22 @@ const routes = [
children: [{ path: '', component: () => import('../pages/Index.vue') }]
},
// --------------------------------
// PAGE VERSION
// --------------------------------
/*
One recorded version of a page, read on its own. Addressed by the version alone -- a version URL is
a link somebody was handed, and the page it came off is exactly what the reader is asking to be
told, so a URL that already had to name it is one they could not have been given.
Its own layout rather than `MainLayout`: a snapshot is reached from a link rather than browsed to,
so there is no navigation sidebar beside it. See `VersionLayout.vue`.
*/
{
path: '/_version/:versionId',
component: () => import('@/layouts/VersionLayout.vue'),
children: [{ path: '', component: () => import('@/pages/PageVersion.vue') }]
},
// --------------------------------
// EDIT
// --------------------------------
{

Loading…
Cancel
Save