feat: blog pages

pull/8104/head
NGPixel 2 weeks ago
parent 76845ab462
commit 11cba35777
No known key found for this signature in database

1
.gitignore vendored

@ -46,3 +46,4 @@ test-results/
!/server/locales/en.json !/server/locales/en.json
cloudflared.deb cloudflared.deb
/.claude

@ -0,0 +1,201 @@
import type { FastifyInstance } from 'fastify'
import { mayOnPage } from './pages.ts'
import { normalizePagePath } from '../helpers/common.ts'
const siteIdParam = {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
}
/** The locale a request that named none is answered in. */
function defaultLocale(siteId: string): string {
return WIKI.sites[siteId]?.config?.locales?.primary ?? 'en'
}
/**
* Blogs API
*
* One route, because a blog listing is one question: which posts to draw, where the reader is in the
* blog, and what the sidebar should offer to narrow it by. Those three come out of a single read (see
* `MAX_POSTS` in `models/blogs.ts`), and splitting them across two requests would only make it
* possible for a tag cloud to disagree with the listing beside it.
*
* No route-level permissions: a blog is read by whoever may read its front page, which is a PAGE
* rule and not a group-wide one — `config.permissions` reads only the group-wide list, so declaring
* anything here would refuse the anonymous reader a public blog exists for. The front page is
* checked in the handler and each post is checked again in the model.
*/
async function routes(app: FastifyInstance) {
/**
* LIST A BLOG'S POSTS
*/
app.get<{
Params: { siteId: string }
Querystring: {
path: string
locale?: string
tag?: string
year?: number
month?: number
page?: number
}
}>(
'/sites/:siteId/blogs/posts',
{
schema: {
summary: "List a blog's posts",
description:
"One page of a blog's listing, with the tag and archive facets beside it.\n\n`path` is the blog's own path — the page written with the `blog` editor — and the posts are the pages under it, as deep as that blog is configured to collect them. A page with no body of its own is never a post (a redirection is a doorway, and a nested blog is its own blog), and neither is anything under a nested blog: that blog lists its own posts, so no post is ever listed twice.\n\nReadable without a session, because the blog's front page is: an anonymous request sees only published posts, the same set the page view would serve it. Every post is additionally held to `read:pages` at its own path, and the facets are counted over what survives that — a tag cloud that counted pages the reader may not open would be telling them those pages exist.\n\nThe facets describe the whole blog rather than the filtered set, so that picking a tag never removes the control that would undo it.",
tags: ['Pages'],
params: siteIdParam,
querystring: {
type: 'object',
properties: {
path: {
type: 'string',
maxLength: 2048,
description: "The blog's own path, without a leading slash."
},
locale: {
type: 'string',
maxLength: 10,
description: "The site's primary locale when absent."
},
tag: {
type: 'string',
maxLength: 255,
description: 'Only posts carrying this tag.'
},
year: {
type: 'integer',
minimum: 1,
maximum: 9999,
description: 'Only posts published in this year.'
},
month: {
type: 'integer',
minimum: 1,
maximum: 12,
description: 'Only posts published in this month of `year`. Ignored without it.'
},
page: {
type: 'integer',
minimum: 1,
default: 1,
description:
'Which page of the listing to answer with. Clamped to the last one that exists.'
}
},
required: ['path']
},
response: {
200: {
description: 'The posts, and what the sidebar should offer',
type: 'object',
properties: {
posts: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
path: { type: 'string' },
title: { type: 'string' },
description: { type: 'string' },
icon: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
publishedAt: {
type: 'string',
format: 'date-time',
description:
"The post's publication date: its scheduled start, or when it was created."
},
updatedAt: { type: 'string', format: 'date-time' },
authorId: { type: 'string', format: 'uuid' },
authorName: { type: 'string' }
}
}
},
total: {
type: 'integer',
description: 'How many posts match, ignoring which page was asked for.'
},
page: { type: 'integer' },
pageCount: { type: 'integer' },
facets: {
type: 'object',
properties: {
tags: {
type: 'array',
items: {
type: 'object',
properties: {
tag: { type: 'string' },
count: { type: 'integer' }
}
}
},
archive: {
type: 'array',
items: {
type: 'object',
properties: {
year: { type: 'integer' },
month: { type: 'integer' },
count: { type: 'integer' }
}
}
}
}
},
truncated: {
type: 'boolean',
description:
'Whether the blog holds more posts than one request will read, and the listing is therefore a prefix of it.'
}
}
}
}
}
},
async (req, reply) => {
if (!WIKI.sites[req.params.siteId]) {
return reply.notFound('This site does not exist.')
}
const locale = req.query.locale ?? defaultLocale(req.params.siteId)
const path = normalizePagePath(req.query.path)
const blog = await WIKI.models.blogs.blogAt(req.params.siteId, locale, path)
if (!blog) {
return reply.notFound('There is no blog at this path.')
}
/*
The front page's own rules decide whether there is a listing to hand out at all. Refused as
not-found rather than forbidden, which is the same answer the page itself gives: whether a
blog exists at a path the reader may not read is not something to tell them.
*/
if (!mayOnPage(req, 'read:pages', { siteId: req.params.siteId, path, locale, tags: [] })) {
return reply.notFound('There is no blog at this path.')
}
return WIKI.models.blogs.listing({
siteId: req.params.siteId,
blog,
actor: WIKI.models.groups.actorForRequest(req),
publicOnly: !req.session?.authenticated,
filter: {
tag: req.query.tag,
year: req.query.year,
month: req.query.month,
page: req.query.page
}
})
}
)
}
export default routes

@ -37,6 +37,7 @@ async function routes(app: FastifyInstance) {
app.register(import('./auditLog.ts'), { prefix: '/audit' }) app.register(import('./auditLog.ts'), { prefix: '/audit' })
app.register(import('./authentication.ts')) app.register(import('./authentication.ts'))
app.register(import('./blocks.ts')) app.register(import('./blocks.ts'))
app.register(import('./blogs.ts'))
app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' }) app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' })
app.register(import('./comments.ts')) app.register(import('./comments.ts'))
app.register(import('./groups.ts'), { prefix: '/groups' }) app.register(import('./groups.ts'), { prefix: '/groups' })

@ -699,7 +699,7 @@ async function routes(app: FastifyInstance) {
is what makes a page view one request instead of four. is what makes a page view one request instead of four.
*/ */
const actorId = actor?.id ?? null const actorId = actor?.id ?? null
const [approvalState, isWatching, commentsCount] = await Promise.all([ const [approvalState, isWatching, commentsCount, blog] = await Promise.all([
WIKI.models.approvals.pageViewerState(req, req.params.siteId, { WIKI.models.approvals.pageViewerState(req, req.params.siteId, {
id: page.id, id: page.id,
path: page.path, path: page.path,
@ -715,11 +715,24 @@ async function routes(app: FastifyInstance) {
*/ */
WIKI.models.comments.usesBuiltIn(req.params.siteId) WIKI.models.comments.usesBuiltIn(req.params.siteId)
? WIKI.models.comments.countForPage(page.id) ? WIKI.models.comments.countForPage(page.id)
: 0 : 0,
/*
The blog this page is a post of, so that the page view can say where the reader is and link
back to it. Worked out rather than stored -- a post is a post because of where it sits --
which is one lookup on the unique `(siteId, locale, path)` index over the page's own
ancestors, and no lookup at all for a page at the site root.
*/
WIKI.models.blogs.blogFor(req.params.siteId, page.locale, page.path)
]) ])
return { return {
...page, ...page,
commentsCount, commentsCount,
/*
Only what the page view draws: a post shows the name of the blog it is in and links to it.
The blog's own settings are not a fact about this page -- the front page carries them, and
it is the front page that draws a listing.
*/
blog: blog ? { path: blog.path, title: blog.title } : null,
viewer: { viewer: {
permissions: pagePermissionsFor(req, page), permissions: pagePermissionsFor(req, page),
...approvalState, ...approvalState,

@ -218,7 +218,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
content: { content: {
type: 'string', type: 'string',
description: description:
'Only present when the request asked for it — except on a redirection, whose content is where it sends its reader rather than a body, and comes back either way.' 'Only present when the request asked for it — except on a page whose editor writes no body (a redirection, a blog’s front page), whose content is the settings that page is made of rather than something to read, and comes back either way.'
}, },
allowBacklinks: { type: 'boolean' }, allowBacklinks: { type: 'boolean' },
allowComments: { type: 'boolean' }, allowComments: { type: 'boolean' },
@ -248,6 +248,15 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
authorName: { type: 'string' }, authorName: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' }, createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' },
blog: {
type: ['object', 'null'],
description:
'The blog this page is a post of, or null for a page that is not in one — a page is a post because of where it sits, so this is worked out per request rather than stored. The NEAREST blog above it, so a blog inside a blog owns its own posts. Present when a page is fetched on its own.',
properties: {
path: { type: 'string', description: 'The blog front page’s path within the site.' },
title: { type: 'string' }
}
},
viewer: { viewer: {
type: 'object', type: 'object',
description: description:

@ -287,6 +287,18 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
} }
} }
}, },
blog: {
type: 'object',
properties: {
isActive: {
type: 'boolean'
},
config: {
type: 'object',
additionalProperties: true
}
}
},
markdown: { markdown: {
type: 'object', type: 'object',
properties: { properties: {

@ -341,6 +341,53 @@ function bodyForPage(page: PageDescription): string {
return `<div id="${PRERENDER_ID}">${stripActiveMarkup(page.render)}</div>` return `<div id="${PRERENDER_ID}">${stripActiveMarkup(page.render)}</div>`
} }
/**
* How many of a blog's posts the served document lists.
*
* A front page is what gets linked, shared and crawled, so it cannot be an empty document — but this
* is a summary of the blog rather than the blog, and every post is in the sitemap in its own right.
* The first screenful is what says what the blog is about; a crawler follows the links for the rest.
*/
const BLOG_CRAWLER_POSTS = 20
/**
* A blog's front page, for a client that will not run the app.
*
* A blog has no `render` — its body is its posts, which the app fetches and draws — so the document
* served for one would otherwise be a correct head over an empty page, on exactly the URL somebody
* pastes into a chat window. This is the same list the app draws, written out as markup.
*
* Nothing is rendered: a post contributes its title, its description and its URL, which are three
* columns. The public's own view of the blog, built from `actorForPublic()` like everything else that
* is cached and handed on, so a post the guests group may not read is not named here.
*/
async function bodyForBlog(siteId: string, page: PageDescription): Promise<string> {
const blog = await WIKI.models.blogs.blogAt(siteId, page.locale, page.path)
if (!blog) {
return ''
}
const { posts } = await WIKI.models.blogs.postsFor({
siteId,
blog,
actor: WIKI.models.groups.actorForPublic(),
publicOnly: true
})
const intro = blog.settings.intro ? `<p>${htmlEscape(blog.settings.intro)}</p>` : ''
const items = posts
.slice(0, BLOG_CRAWLER_POSTS)
.map((post) => {
const href = htmlEscape(WIKI.models.pages.urlFor(siteId, page.locale, post.path))
const title = htmlEscape(post.title)
const description = post.description ? ` &mdash; ${htmlEscape(post.description)}` : ''
return `<li><a href="${href}">${title}</a>${description}</li>`
})
.join('')
if (!intro && items.length < 1) {
return ''
}
return `<div id="${PRERENDER_ID}">${intro}<ul>${items}</ul></div>`
}
/** /**
* The fragments for a client that will never run the app — a crawler, an unfurl card, a reader with * The fragments for a client that will never run the app — a crawler, an unfurl card, a reader with
* JavaScript off. * JavaScript off.
@ -397,7 +444,8 @@ async function fragmentsForCrawler(
return { return {
head: headForPage(originOf(req), siteId, siteConfig, page), head: headForPage(originOf(req), siteId, siteConfig, page),
body: bodyForPage(page), // -> A blog keeps its body somewhere else: in the pages under it, rather than in a column
body: page.editor === 'blog' ? await bodyForBlog(siteId, page) : bodyForPage(page),
status: 200, status: 200,
robots: robotsTagFor(siteId, page.isIndexable) robots: robotsTagFor(siteId, page.isIndexable)
} }

@ -856,44 +856,6 @@
"admin.nav.site": "Site", "admin.nav.site": "Site",
"admin.nav.system": "System", "admin.nav.system": "System",
"admin.nav.users": "Users", "admin.nav.users": "Users",
"admin.navigation.copyFromLocale": "Copy from locale...",
"admin.navigation.copyFromLocaleInfoText": "Select the locale from which items will be copied from. Items will be appended to the current list of items in the active locale.",
"admin.navigation.delete": "Delete {kind}",
"admin.navigation.divider": "Divider",
"admin.navigation.edit": "Edit {kind}",
"admin.navigation.emptyList": "Navigation is empty",
"admin.navigation.header": "Header",
"admin.navigation.icon": "Icon",
"admin.navigation.label": "Label",
"admin.navigation.link": "Link",
"admin.navigation.mode": "Navigation Mode",
"admin.navigation.modeCustom.description": "Static Navigation Menu + Site Tree Button",
"admin.navigation.modeCustom.title": "Custom Navigation",
"admin.navigation.modeNone.description": "Disable Site Navigation",
"admin.navigation.modeNone.title": "None",
"admin.navigation.modeSiteTree.description": "Classic Tree-based Navigation",
"admin.navigation.modeSiteTree.title": "Site Tree",
"admin.navigation.modeStatic.description": "Static Navigation Menu Only",
"admin.navigation.modeStatic.title": "Static Navigation",
"admin.navigation.navType.external": "External Link",
"admin.navigation.navType.externalblank": "External Link (New Window)",
"admin.navigation.navType.home": "Home",
"admin.navigation.navType.page": "Page",
"admin.navigation.navType.searchQuery": "Search Query",
"admin.navigation.noItemsText": "Click the Add button to add your first navigation item.",
"admin.navigation.noSelectionText": "Select a navigation item on the left.",
"admin.navigation.saveSuccess": "Navigation saved successfully.",
"admin.navigation.selectPageButton": "Select Page...",
"admin.navigation.sourceLocale": "Source Locale",
"admin.navigation.sourceLocaleHint": "The locale from which navigation items will be copied from.",
"admin.navigation.subtitle": "Manage the site navigation",
"admin.navigation.target": "Target",
"admin.navigation.targetType": "Target Type",
"admin.navigation.title": "Navigation",
"admin.navigation.untitled": "Untitled {kind}",
"admin.navigation.visibilityMode.all": "Visible to everyone",
"admin.navigation.visibilityMode.restricted": "Visible to select groups...",
"admin.pages.title": "Pages",
"admin.rendering.subtitle": "Configure the content rendering pipeline", "admin.rendering.subtitle": "Configure the content rendering pipeline",
"admin.rendering.title": "Rendering", "admin.rendering.title": "Rendering",
"admin.scheduler.active": "Active", "admin.scheduler.active": "Active",
@ -1410,9 +1372,9 @@
"admin.utilities.flushCacheSuccess": "The cache has been flushed.", "admin.utilities.flushCacheSuccess": "The cache has been flushed.",
"admin.utilities.generateSample": "Generate Sample Content", "admin.utilities.generateSample": "Generate Sample Content",
"admin.utilities.generateSampleConfirm": "Write {count} sample pages to {site}?", "admin.utilities.generateSampleConfirm": "Write {count} sample pages to {site}?",
"admin.utilities.generateSampleConfirmWarn": "They are filed under /sample and every one is tagged \"{tag}\", which is what Purge Sample Content deletes. A page already at one of those paths is left alone.", "admin.utilities.generateSampleConfirmWarn": "They are filed under /sample and /my-blog, and every one is tagged \"{tag}\", which is what Purge Sample Content deletes. A page already at one of those paths is left alone.",
"admin.utilities.generateSampleFailed": "The sample content could not be generated.", "admin.utilities.generateSampleFailed": "The sample content could not be generated.",
"admin.utilities.generateSampleHint": "Fill this site with pages covering every kind of formatting and every block, in a folder tree several levels deep. Mostly for development / debugging purposes.", "admin.utilities.generateSampleHint": "Fill this site with pages covering every kind of formatting and every block, in a folder tree several levels deep, plus a blog at /my-blog with 25 posts under it. Mostly for development / debugging purposes.",
"admin.utilities.generateSamplePartial": "No sample pages could be written. | Wrote 1 sample page; the rest could not be written. | Wrote {count} sample pages; the rest could not be written.", "admin.utilities.generateSamplePartial": "No sample pages could be written. | Wrote 1 sample page; the rest could not be written. | Wrote {count} sample pages; the rest could not be written.",
"admin.utilities.generateSampleSuccess": "No sample pages were written. | Wrote 1 sample page. | Wrote {count} sample pages.", "admin.utilities.generateSampleSuccess": "No sample pages were written. | Wrote 1 sample page. | Wrote {count} sample pages.",
"admin.utilities.graphEndpointSubtitle": "Change the GraphQL endpoint for Wiki.js", "admin.utilities.graphEndpointSubtitle": "Change the GraphQL endpoint for Wiki.js",
@ -1682,6 +1644,22 @@
"common.actions.upload": "Upload", "common.actions.upload": "Upload",
"common.actions.view": "View", "common.actions.view": "View",
"common.actions.viewDocs": "View Documentation", "common.actions.viewDocs": "View Documentation",
"common.blog.archive": "Archive",
"common.blog.clearFilter": "Show all",
"common.blog.count": "1 post | {count} posts",
"common.blog.empty": "This blog has no posts yet.",
"common.blog.emptyHint": "Pages saved under {path} appear here.",
"common.blog.filterTag": "Tagged {tag}",
"common.blog.loadFailed": "The posts of this blog could not be loaded.",
"common.blog.loading": "Loading posts...",
"common.blog.newPost": "New Post",
"common.blog.noFacets": "Tags and dates appear here once this blog has posts.",
"common.blog.noMatches": "No posts match this selection.",
"common.blog.pagination": "Blog pages",
"common.blog.postedIn": "Posted in {blog}",
"common.blog.tags": "Tags",
"common.blog.toggleYear": "Show the months of {year}",
"common.blog.truncated": "This blog has more posts than can be listed at once. The most recent ones are shown; use search to find an older post.",
"common.browse.empty": "There is nothing here.", "common.browse.empty": "There is nothing here.",
"common.browse.loadFailed": "Failed to load the contents of this folder.", "common.browse.loadFailed": "Failed to load the contents of this folder.",
"common.browse.openFolder": "Open the {title} folder", "common.browse.openFolder": "Open the {title} folder",
@ -1739,7 +1717,7 @@
"common.comments.write": "Write", "common.comments.write": "Write",
"common.createPage.api": "New API Documentation", "common.createPage.api": "New API Documentation",
"common.createPage.asciidoc": "New AsciiDoc Page", "common.createPage.asciidoc": "New AsciiDoc Page",
"common.createPage.blog": "New Blog Page", "common.createPage.blog": "New Blog",
"common.createPage.channel": "New Discussion Space", "common.createPage.channel": "New Discussion Space",
"common.createPage.markdown": "New Markdown Page", "common.createPage.markdown": "New Markdown Page",
"common.createPage.redirect": "New Redirection", "common.createPage.redirect": "New Redirection",
@ -1972,6 +1950,40 @@
"editor.blockPicker.noProps": "This block takes no properties — insert it as it is.", "editor.blockPicker.noProps": "This block takes no properties — insert it as it is.",
"editor.blockPicker.selectHint": "Pick a block on the left to fill in its properties.", "editor.blockPicker.selectHint": "Pick a block on the left to fill in its properties.",
"editor.blockPicker.title": "Insert Block", "editor.blockPicker.title": "Insert Block",
"editor.blog.depth": "Depth",
"editor.blog.depthHint": "How many folders below this page posts are collected from. 0 lists only the pages directly underneath it.",
"editor.blog.intro": "Introduction",
"editor.blog.introHint": "A few lines shown above the list of posts. Plain text — a blog's front page has no formatting of its own.",
"editor.blog.layout": "Layout",
"editor.blog.layoutHint": "How each post is drawn in the list.",
"editor.blog.layouts.cards": "Cards",
"editor.blog.layouts.list": "List",
"editor.blog.listingTitle": "List of Posts",
"editor.blog.pageDescription": "Blog Short Description",
"editor.blog.pageDescriptionHint": "One line about this blog, shown under its name and used as the page description in search results and link previews.",
"editor.blog.pageTitle": "Blog Title",
"editor.blog.pageTitleHint": "The name this blog appears under in navigation and in the file manager.",
"editor.blog.perPage": "Posts per Page",
"editor.blog.perPageHint": "How many posts a page of the list holds before it is paginated.",
"editor.blog.postsHint": "Any page saved under {path} is a post of this blog. Nothing has to be marked as one — move a page in or out of that path and it joins or leaves the blog.",
"editor.blog.postsTitle": "Posts",
"editor.blog.show.author": "Author",
"editor.blog.show.date": "Date",
"editor.blog.show.description": "Description",
"editor.blog.show.icon": "Icon",
"editor.blog.show.tags": "Tags",
"editor.blog.showFields": "Show for each Post",
"editor.blog.showFieldsHint": "The description is the one a post sets in its own properties.",
"editor.blog.sidebarArchive": "Archive",
"editor.blog.sidebarArchiveHint": "Show the years and months this blog has posts in, so readers can browse it by date.",
"editor.blog.sidebarTags": "Tag Cloud",
"editor.blog.sidebarTagsHint": "Show every tag used by a post of this blog, with how many posts carry it.",
"editor.blog.sidebarTitle": "Sidebar",
"editor.blog.sort": "Order",
"editor.blog.sortHint": "Which end of the blog the list starts at. Posts are dated by their publication date, or by when they were created.",
"editor.blog.sorts.newest": "Newest first",
"editor.blog.sorts.oldest": "Oldest first",
"editor.blog.title": "Blog",
"editor.ckeditor.stats": "{chars} chars, {words} words", "editor.ckeditor.stats": "{chars} chars, {words} words",
"editor.codeBlock.filter": "Filter languages...", "editor.codeBlock.filter": "Filter languages...",
"editor.codeBlock.noResults": "No language matches that.", "editor.codeBlock.noResults": "No language matches that.",
@ -2377,6 +2389,7 @@
"fileman.assetRenameMove": "Rename / Move Asset", "fileman.assetRenameMove": "Rename / Move Asset",
"fileman.aviFileType": "AVI Video File", "fileman.aviFileType": "AVI Video File",
"fileman.binFileType": "Binary File", "fileman.binFileType": "Binary File",
"fileman.blogPageType": "Blog",
"fileman.bz2FileType": "BZIP2 Archive", "fileman.bz2FileType": "BZIP2 Archive",
"fileman.copyURLSuccess": "URL has been copied to the clipboard.", "fileman.copyURLSuccess": "URL has been copied to the clipboard.",
"fileman.createFolderInvalidData": "One or more fields are invalid.", "fileman.createFolderInvalidData": "One or more fields are invalid.",

@ -0,0 +1,507 @@
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
import { decodeTreePath, encodeTreePath } from '../helpers/common.ts'
import { isBodylessEditor, parseBlogContent, type BlogContent } from './pages.ts'
import type { AccessActor } from './groups.ts'
/**
* How many of a blog's posts are read out of the database for one request.
*
* Every post is read, not just the page of them being shown, because the ordering that matters is
* the one that survives the page rules: which posts a reader may open cannot be expressed in SQL —
* a rule matches on path, locale and tags and is resolved a page at a time — so a `LIMIT 10` here
* would be ten CANDIDATES, of which this reader might see six, and page 2 would then start in the
* wrong place. Filtering first and slicing afterwards is what makes "posts 11-20 of 47" true.
*
* The facets are the same read. A tag cloud and an archive counted in SQL would count posts this
* reader may not open, which is a listing of pages they were told nothing about — the same reasoning
* `tags.getTags` applies site-wide.
*
* The ceiling is what keeps that honest rather than unbounded. A blog past it is not a blog any more
* and wants a search page; the listing says so rather than quietly serving a prefix.
*/
const MAX_POSTS = 2000
/** A blog's front page: where it is, and how its author set it up. */
export interface BlogRef {
id: string
/** Slash-separated path of the front page, i.e. the blog's own URL within the site. */
path: string
title: string
locale: string
settings: BlogContent
}
/** One post, as a listing draws it. */
export interface BlogPost {
id: string
/** Slash-separated path of the post, i.e. its URL within the site. */
path: string
title: string
description: string
/** The post's icon, as an Iconify reference. Empty when it has none. */
icon: string
tags: string[]
/**
* When the post counts as having been published: its `publishStartDate`, or when it was created.
*
* One field rather than two, because a blog is ordered by one thing and a reader is shown one
* date, and a post written today about last week belongs where its author dated it.
*
* It is not what decides whether the post is LISTED. That is `publishState`, here as everywhere
* else in the wiki — so a post left `published` with a start date next Tuesday is in the blog
* today, dated next Tuesday. `scheduled` is what holds one back.
*/
publishedAt: Date
updatedAt: Date
authorId: string
/** Who wrote the version that stands. Empty once that account is deleted. */
authorName: string
}
/** What the column beside a listing offers to narrow it by. */
export interface BlogFacets {
/** Every tag carried by a readable post, most used first. */
tags: { tag: string; count: number }[]
/** One entry per month that has posts, newest first. */
archive: { year: number; month: number; count: number }[]
}
/** A listing, as one request answers it. */
export interface BlogListing {
posts: BlogPost[]
/** How many posts match the filters, ignoring which page of them was asked for. */
total: number
/** Which page of the listing this is, counted from 1. */
page: number
pageCount: number
/**
* The facets over every readable post of the blog, NOT over the filtered set — a tag cloud that
* emptied itself as soon as a tag was picked would leave a reader with no way back out of the
* filter they just applied.
*/
facets: BlogFacets
/** Whether the blog holds more posts than one request will read. See `MAX_POSTS`. */
truncated: boolean
}
/** What a listing may be narrowed by, as a reader picks it out of the sidebar. */
export interface BlogFilter {
/** Only posts carrying this tag. */
tag?: string | null
/** Only posts published in this year, and in this month of it when one is given. */
year?: number | null
month?: number | null
/** Which page of the listing to answer with, counted from 1. */
page?: number
}
/** One candidate row, before the page rules and the nesting rule have had their say. */
interface Candidate extends BlogPost {
editor: string
}
/**
* Blogs
*
* A blog is a page written with the `blog` editor plus the pages underneath it. That is the whole of
* the data model: the front page's content column holds how the blog should look (`BlogContent`), and
* WHICH pages are its posts is worked out here, at read time, from where they sit.
*
* Nothing is written on a post to say it is one. That is deliberate and it is what makes the feature
* cost nothing to the rest of the wiki: a post is an ordinary page with ordinary history, ordinary
* permissions and an ordinary place in the tree, and moving it out of the blog's path is how it stops
* being a post. The tree already supports a page and a folder sharing a name — `/my-blog` is both the
* front page and the way into `/my-blog/…`, the same way `/guide` is — so nothing had to change there
* either.
*
* The cost of that choice is that the blog's membership is a fact about PATHS, and paths move. Moving
* the front page away from its posts, or renaming the folder out from under it, leaves a blog with
* nothing in it and no row anywhere recording that anything is wrong. `listing` reports the empty
* case as its own answer rather than as a listing that happens to be empty, so the failure is at
* least legible to whoever caused it.
*/
class Blogs {
/**
* The blog whose front page IS this path, or null where that page is not a blog.
*
* One lookup on `(siteId, locale, path)`, which is a unique index.
*/
async blogAt(siteId: string, locale: string, path: string): Promise<BlogRef | null> {
const rows = await WIKI.db
.select({
id: pagesTable.id,
path: pagesTable.path,
title: pagesTable.title,
locale: pagesTable.locale,
content: pagesTable.content,
editor: pagesTable.editor
})
.from(pagesTable)
.where(
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.locale, locale),
eq(pagesTable.path, path),
eq(pagesTable.editor, 'blog')
)
)
.limit(1)
const row = rows[0]
return row ? { ...row, settings: parseBlogContent(row.content) } : null
}
/**
* The blog a page belongs to, or null for a page that is not in one.
*
* Asked of every page view, so it is one indexed read and not a scan: the candidates are the page's
* own ancestors, which is a handful of paths a path already names, so the lookup is an `IN` over the
* same unique index `blogAt` uses. A page at the site root has no ancestors and costs no query at
* all.
*
* The NEAREST ancestor wins. A blog inside a blog is its own blog — its posts belong to it and not
* to the one above, which is also what `postsFor` implements coming the other way.
*
* The page itself is never the answer: a blog's front page is not one of its own posts.
*/
async blogFor(siteId: string, locale: string, path: string): Promise<BlogRef | null> {
const parts = path.split('/').filter((part) => part.length > 0)
if (parts.length < 2) {
return null
}
const ancestors: string[] = []
for (let i = 1; i < parts.length; i++) {
ancestors.push(parts.slice(0, i).join('/'))
}
const rows = await WIKI.db
.select({
id: pagesTable.id,
path: pagesTable.path,
title: pagesTable.title,
locale: pagesTable.locale,
content: pagesTable.content
})
.from(pagesTable)
.where(
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.locale, locale),
eq(pagesTable.editor, 'blog'),
inArray(pagesTable.path, ancestors)
)
)
if (rows.length < 1) {
return null
}
// -> The nearest one, which is the longest path: every row here is an ancestor of the same page
const nearest = rows.reduce((best, row) => (row.path.length > best.path.length ? row : best))
return { ...nearest, settings: parseBlogContent(nearest.content) }
}
/**
* Make sure the folder a blog's posts will go in exists.
*
* A blog's front page is a page at `my-blog`, and its posts are pages at `my-blog/…` — which means
* the folder `my-blog` is where an author is about to be working, and until something is saved
* under it the folder is not there at all. The tree creates folders from the middle out as pages
* arrive, so nothing is BROKEN without this: the folder appears the moment the first post is saved.
* What is missing is somewhere to save that first post FROM — the file manager and the tree browser
* have no folder to open, so the blog looks like a dead end until somebody types the path by hand.
*
* A page and a folder of the same name sit side by side quite happily — that is how `/guide` gets
* to be both a page and the way into `/guide/…` — so this adds a folder rather than changing
* anything about the page.
*
* **It never fails the caller.** The folder is a convenience and the blog is complete without it,
* so the two ways this can legitimately not work are logged and stepped over rather than raised:
* a folder name is held to `[a-z0-9-]` while a page path may also carry underscores (a blog at
* `my_blog` has no legal folder name), and an ASSET already sitting at that name blocks a folder
* where it would not have blocked the page.
*
* @returns Whether a folder was created. False when one was already there, and when one could not be.
*/
async ensureFolder({
siteId,
locale,
path,
title
}: {
siteId: string
locale: string
path: string
title: string
}): Promise<boolean> {
const encoded = encodeTreePath(path)
const parts = encoded.split('.')
const fileName = parts.at(-1)!
const folderPath = parts.slice(0, -1).join('.')
/*
Asked directly rather than through `tree.getFolder({ createIfMissing: true })`, which would find
or create in one call: that one titles what it creates after the path segment, and this folder
IS the blog — `Engineering Notes` is what somebody making it by hand would have called it, and
the title is what the file manager and the browse menu show.
*/
const existing = await WIKI.db
.select({ id: treeTable.id })
.from(treeTable)
.where(
and(
eq(treeTable.siteId, siteId),
eq(treeTable.locale, locale),
eq(treeTable.folderPath, folderPath),
eq(treeTable.fileName, fileName),
eq(treeTable.type, 'folder')
)
)
.limit(1)
if (existing.length > 0) {
return false
}
try {
await WIKI.models.tree.createFolder({
parentPath: decodeTreePath(folderPath) ?? '',
pathName: fileName,
title,
locale,
siteId
})
return true
} catch (err: any) {
WIKI.logger.warn(
`Could not create the folder for the blog at /${path}: ${err.message}. Posts saved under it will create it.`
)
return false
}
}
/**
* Every post of a blog this reader may open, newest or oldest first as the blog is set up.
*
* Three things are dropped on the way, and each for its own reason:
*
* - **A page with no body of its own** — a redirection, or the front page of a blog nested inside
* this one. A doorway is not a post, and neither is another blog.
* - **A page under a nested blog.** Its own blog lists it; this one stops where that one starts, so
* a post never appears in two listings under two different bylines.
* - **A page the reader may not open**, by the same rule the page view applies. Filtered here rather
* than in SQL because a page rule is not a `WHERE` clause.
*
* @param publicOnly Restrict to what a reader with no session may see, i.e. published posts only.
*/
async postsFor({
siteId,
blog,
actor,
publicOnly = true
}: {
siteId: string
blog: BlogRef
actor?: AccessActor
publicOnly?: boolean
}): Promise<{ posts: BlogPost[]; truncated: boolean }> {
const encodedPath = encodeTreePath(blog.path)
const depth = blog.settings.depth
const levels = depth > 0 ? `*{,${depth}}` : '*{0}'
const pathQuery = encodedPath ? `${encodedPath}.${levels}` : levels
/*
`publishStartDate` when the author gave one and the creation date otherwise, which is the one
date a blog orders by and shows. Computed in SQL so the order it produces is the order the rows
arrive in, and `MAX_POSTS` therefore cuts the far end of the blog rather than an arbitrary set.
`mapWith` and not a bare `sql<Date>`: that generic is an assertion and nothing more, so without
a mapper the driver hands back postgres's own `2026-08-22 09:00:00` and every caller downstream
gets a string where the column beside it is a `Date`. Mapped through `updatedAt`, which is the
same `timestamp` type, so both dates on a post are the same kind of value.
*/
const publishedAt =
sql<Date>`coalesce(${pagesTable.publishStartDate}, ${pagesTable.createdAt})`.mapWith(
pagesTable.updatedAt
)
const conditions: (SQL | undefined)[] = [
eq(treeTable.siteId, siteId),
eq(treeTable.locale, blog.locale),
eq(treeTable.type, 'page'),
sql`${treeTable.folderPath} ~ ${pathQuery}::lquery`
]
if (publicOnly) {
conditions.push(eq(pagesTable.publishState, 'published'))
}
const rows = await WIKI.db
.select({
id: treeTable.id,
folderPath: treeTable.folderPath,
fileName: treeTable.fileName,
title: treeTable.title,
tags: treeTable.tags,
description: pagesTable.description,
icon: pagesTable.icon,
editor: pagesTable.editor,
updatedAt: pagesTable.updatedAt,
authorId: pagesTable.authorId,
authorName: usersTable.name,
publishedAt
})
.from(treeTable)
.innerJoin(pagesTable, eq(pagesTable.id, treeTable.id))
.leftJoin(usersTable, eq(usersTable.id, pagesTable.authorId))
.where(and(...conditions))
.orderBy(blog.settings.sort === 'oldest' ? sql`${publishedAt} asc` : sql`${publishedAt} desc`)
.limit(MAX_POSTS + 1)
const truncated = rows.length > MAX_POSTS
const candidates: Candidate[] = rows.slice(0, MAX_POSTS).map((row) => {
const folderPath = decodeTreePath(row.folderPath ?? '') ?? ''
return {
id: row.id,
path: folderPath ? `${folderPath}/${row.fileName}` : row.fileName,
title: row.title,
description: row.description ?? '',
icon: row.icon ?? '',
tags: row.tags ?? [],
editor: row.editor,
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
authorId: row.authorId,
authorName: row.authorName ?? ''
}
})
/*
Where this blog stops. A blog nested inside this one owns everything below it, so the outer
listing ends at its front page — otherwise the same post appears in two blogs, dated and
attributed by two sets of settings that need not agree.
*/
const nestedRoots = candidates
.filter((row) => row.editor === 'blog')
.map((row) => `${row.path}/`)
const posts = candidates.filter((row) => {
if (isBodylessEditor(row.editor)) {
return false
}
if (nestedRoots.some((root) => row.path.startsWith(root))) {
return false
}
if (!actor) {
return true
}
return WIKI.models.groups.checkAccess(actor, 'read:pages', {
siteId,
path: row.path,
locale: blog.locale,
tags: row.tags
})
})
// -> Which editor wrote a post is how it was filtered, not something a listing shows
return {
posts: posts.map((post) => ({
id: post.id,
path: post.path,
title: post.title,
description: post.description,
icon: post.icon,
tags: post.tags,
publishedAt: post.publishedAt,
updatedAt: post.updatedAt,
authorId: post.authorId,
authorName: post.authorName
})),
truncated
}
}
/**
* Count what the sidebar offers to narrow a listing by.
*
* Over the posts as they were handed in, which is to say over the posts this reader may open: a
* count is a statement about how many pages exist, and one that included pages they were never
* shown would be answering a question they did not ask about content they cannot reach.
*/
facetsFor(posts: BlogPost[]): BlogFacets {
const tagCounts = new Map<string, number>()
const monthCounts = new Map<string, number>()
for (const post of posts) {
for (const tag of post.tags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1)
}
const at = new Date(post.publishedAt)
// -> UTC, because the stored instant is: a blog's archive must not put a post in a different
// month for a reader in a different place, and the server's own zone is nobody's
const key = `${at.getUTCFullYear()}-${at.getUTCMonth() + 1}`
monthCounts.set(key, (monthCounts.get(key) ?? 0) + 1)
}
return {
tags: [...tagCounts.entries()]
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)),
archive: [...monthCounts.entries()]
.map(([key, count]) => {
const [year, month] = key.split('-')
return { year: Number(year), month: Number(month), count }
})
.sort((a, b) => b.year - a.year || b.month - a.month)
}
}
/**
* One page of a blog's listing, with the facets beside it.
*
* The whole answer to one request: the posts to draw, where the reader is in the blog, and what
* the sidebar should offer. Read once and narrowed in memory — see `MAX_POSTS` for why the filters
* are not a `WHERE` clause.
*/
async listing({
siteId,
blog,
actor,
publicOnly = true,
filter = {}
}: {
siteId: string
blog: BlogRef
actor?: AccessActor
publicOnly?: boolean
filter?: BlogFilter
}): Promise<BlogListing> {
const { posts: readable, truncated } = await this.postsFor({ siteId, blog, actor, publicOnly })
const facets = this.facetsFor(readable)
const matching = readable.filter((post) => {
if (filter.tag && !post.tags.includes(filter.tag)) {
return false
}
if (filter.year) {
const at = new Date(post.publishedAt)
if (at.getUTCFullYear() !== filter.year) {
return false
}
if (filter.month && at.getUTCMonth() + 1 !== filter.month) {
return false
}
}
return true
})
const perPage = blog.settings.perPage
const pageCount = Math.max(1, Math.ceil(matching.length / perPage))
// -> Clamped rather than answered empty: a `?p=99` typed into the bar, or left over from a filter
// that used to have more pages, should land on the last page of the blog and not on nothing
const page = Math.min(Math.max(1, Math.trunc(filter.page ?? 1)), pageCount)
return {
posts: matching.slice((page - 1) * perPage, page * perPage),
total: matching.length,
page,
pageCount,
facets,
truncated
}
}
}
export const blogs = new Blogs()

@ -5,6 +5,7 @@ import { assets } from './assets.ts'
import { auditLog } from './auditLog.ts' import { auditLog } from './auditLog.ts'
import { authentication } from './authentication.ts' import { authentication } from './authentication.ts'
import { blocks } from './blocks.ts' import { blocks } from './blocks.ts'
import { blogs } from './blogs.ts'
import { comments } from './comments.ts' import { comments } from './comments.ts'
import { extensions } from './extensions.ts' import { extensions } from './extensions.ts'
import { flags } from './flags.ts' import { flags } from './flags.ts'
@ -41,6 +42,7 @@ export default {
auditLog, auditLog,
authentication, authentication,
blocks, blocks,
blogs,
comments, comments,
extensions, extensions,
flags, flags,

@ -19,21 +19,8 @@ const EDITOR_CONTENT_TYPES: Record<string, string> = {
markdown: 'markdown', markdown: 'markdown',
visual: 'markdown', visual: 'markdown',
asciidoc: 'asciidoc', asciidoc: 'asciidoc',
redirect: 'redirect' redirect: 'redirect',
} blog: 'blog'
/**
* The editor a page of a given content type is opened with when nothing says which.
*
* `markdown` and `visual` both produce markdown — that is what lets a page be converted between them
* without touching a byte of what it says — so the content type alone no longer identifies an editor.
* A file being imported carries its source and not the editor somebody wrote it in, and this is the
* answer for that case.
*/
const DEFAULT_EDITOR_FOR_CONTENT_TYPE: Record<string, string> = {
markdown: 'markdown',
asciidoc: 'asciidoc',
redirect: 'redirect'
} }
/** /**
@ -67,7 +54,8 @@ export const PAGE_FILE_EXTENSIONS: Record<string, string> = {
markdown: 'md', markdown: 'md',
html: 'html', html: 'html',
asciidoc: 'adoc', asciidoc: 'adoc',
redirect: 'json' redirect: 'json',
blog: 'json'
} }
/** For a content type added since this was written. */ /** For a content type added since this was written. */
@ -77,6 +65,31 @@ export function pageFileExtension(contentType: string): string {
return PAGE_FILE_EXTENSIONS[contentType] ?? DEFAULT_PAGE_FILE_EXTENSION return PAGE_FILE_EXTENSIONS[contentType] ?? DEFAULT_PAGE_FILE_EXTENSION
} }
/**
* Which editor a file of a given extension is taken to belong to, when it did not say.
*
* Written out rather than derived by reading `PAGE_FILE_EXTENSIONS` backwards, because that map is
* one-to-one in neither direction. Two editors write `.json` — `redirect` and `blog` — and an answer
* that depended on which of them was declared first would change every time somebody reordered the
* table; and `markdown` and `visual` both write `.md`, which is what lets a page be converted between
* them without touching a byte of what it says, so the extension alone was never going to identify an
* editor. A file being imported carries its source and not the editor somebody wrote it in, and this
* is the answer for that case: the one each pair opens in by default.
*
* `.json` is read as a REDIRECTION. Every `.json` a storage target writes carries its editor at the
* top level of the document and never reaches this — see `importTree` — so what is left is a file
* somebody wrote by hand, and a redirection is both the older of the two meanings and the one whose
* document is a page's whole definition rather than a blog's chrome.
*
* `.html` has no editor and deliberately keeps none: the content type exists (a page can be stored as
* HTML) but nothing in this wiki authors one.
*/
const EDITOR_FOR_PAGE_EXTENSION: Record<string, string> = {
md: 'markdown',
adoc: 'asciidoc',
json: 'redirect'
}
/** /**
* Which editor writes a given file extension. * Which editor writes a given file extension.
* *
@ -87,11 +100,7 @@ export function pageFileExtension(contentType: string): string {
* reserved something this wiki has no editor for * reserved something this wiki has no editor for
*/ */
export function pageEditorForExtension(ext: string): string | null { export function pageEditorForExtension(ext: string): string | null {
const contentType = Object.entries(PAGE_FILE_EXTENSIONS).find(([, e]) => e === ext)?.[0] return EDITOR_FOR_PAGE_EXTENSION[ext] ?? null
if (!contentType) {
return null
}
return DEFAULT_EDITOR_FOR_CONTENT_TYPE[contentType] ?? null
} }
/** /**
@ -104,6 +113,54 @@ export function pageEditorForExtension(ext: string): string | null {
*/ */
const REDIRECT_EDITOR = 'redirect' const REDIRECT_EDITOR = 'redirect'
/**
* The editor whose pages are a blog's front page.
*
* Like a redirection it is an ordinary page with no body: it has a path, a title, an icon and a place
* in the tree, and what an author fills in is how the blog behaves rather than anything to read. That
* is what its content column carries — see `normalizeBlogContent` and `BlogContent`.
*
* Unlike a redirection it is a destination. A blog's front page is what gets linked, bookmarked and
* searched for, so it stays in the sitemap, stays indexable and stays searchable; only the three
* checks that ask specifically about a REDIRECTION say otherwise, and none of them means "bodyless".
*
* Which pages are its posts is not recorded anywhere: they are the pages under its path. See
* `models/blogs.ts`.
*/
const BLOG_EDITOR = 'blog'
/**
* The editors whose pages have no body, and whose content column holds a settings document instead.
*
* A set rather than a flag per editor, because the same three questions get asked of each of them in
* `createPage` and `updatePage` — is an empty content column an error, does the content go through a
* normalizer, does the source travel to every reader — and answering them with one `isRedirect`
* boolean apiece is how those two functions become a lattice. What is NOT in here is anything a
* redirection is asked about *as a redirection*: staying out of the sitemap, never being indexable
* and never being searchable are facts about a doorway, not about having no body.
*/
const BODYLESS_EDITORS = new Set([REDIRECT_EDITOR, BLOG_EDITOR])
/** Whether `editor` writes a settings document rather than something to read. */
export function isBodylessEditor(editor: string): boolean {
return BODYLESS_EDITORS.has(editor)
}
/**
* Put a bodyless editor's content into the one spelling its column holds, refusing what it cannot
* use. Returns the content unchanged for an editor that writes an actual body.
*/
function normalizeBodylessContent(editor: string, content: string | undefined): string | undefined {
switch (editor) {
case REDIRECT_EDITOR:
return normalizeRedirectContent(content)
case BLOG_EDITOR:
return normalizeBlogContent(content)
default:
return content
}
}
/** /**
* How long a site's sitemap list is held before it is read again, in seconds. * How long a site's sitemap list is held before it is read again, in seconds.
* *
@ -220,8 +277,8 @@ export interface Page {
toc: TocNode[] toc: TocNode[]
render: string render: string
/** /**
* The source. Present when the request asked for it, and always for a redirection — see `toPage`, * The source. Present when the request asked for it, and always for a bodyless editor — see
* and `RedirectContent` for what a redirection's holds. * `toPage`, and `RedirectContent` / `BlogContent` for what those hold instead of a body.
*/ */
content?: string content?: string
allowComments: boolean allowComments: boolean
@ -333,6 +390,15 @@ export interface PageDescription {
path: string path: string
title: string title: string
description: string | null description: string | null
/**
* Which editor wrote the page.
*
* Carried so that a caller building a document can tell a page whose body is in `render` from one
* that keeps no body at all — a blog's front page has its posts in place of an article, and a
* document served to a client that will not run the app has to say what is on it. See
* `fragmentsForCrawler`.
*/
editor: string
/** /**
* The stored render, or null where this page has no body to show a reader who has not asked for * The stored render, or null where this page has no body to show a reader who has not asked for
* one: a password-protected page, whose body is exactly what the password covers, and a redirection, * one: a password-protected page, whose body is exactly what the password covers, and a redirection,
@ -455,6 +521,190 @@ function normalizeRedirectContent(content: string | undefined): string {
return JSON.stringify(redirect) return JSON.stringify(redirect)
} }
/**
* The ways a listing can draw its posts, and the enum the API validates a saved blog against.
*
* Two, because there are two: a responsive grid of posts, or one post per row. A third narrower grid
* was a track width pretending to be a layout — how COMPACT a post reads is `show`, which turns off
* the description, the byline and the tags, and that is a choice an author can reason about in a way
* that `cards` versus `grid` was not.
*
* The default leads. Order means nothing to the validation below, but the editor's copy of this list
* (`helpers/pageBlog.js`) is mapped straight onto its layout dropdown, and the two are kept in step.
*/
export const BLOG_LAYOUTS = ['cards', 'list'] as const
/**
* How a blog behaves, as its front page's content column holds it.
*
* A blog has no state of its own beyond this: its posts are the pages under its path, worked out at
* read time (`models/blogs.ts`), so nothing here is a list of anything. Every field is a display
* decision, which is why the whole document can be replaced without touching a post.
*/
export interface BlogContent {
/** How a post is drawn in the listing. */
layout: 'list' | 'cards'
/** How many posts a page of the listing holds. */
perPage: number
/** Which end of the blog the listing starts at. */
sort: 'newest' | 'oldest'
/**
* How many folders below the blog's own path posts are collected from.
*
* Deep by default, so that a blog filed by year (`/my-blog/2026/spring-notes`) is one blog rather
* than an empty one — a flat blog is the same setting with the number turned down.
*/
depth: number
/** An introduction shown above the listing. Plain text: the front page has no renderer. */
intro: string
/** Which of a post's fields the listing shows. */
show: {
icon: boolean
description: boolean
author: boolean
date: boolean
tags: boolean
}
/** What the column beside the listing carries. */
sidebar: {
tags: boolean
archive: boolean
}
}
/** What a blog starts as, and what any missing field falls back to. */
export const BLOG_DEFAULTS: BlogContent = {
/*
Cards, because a blog that has just been created has nothing in it to look at: the grid shows an
author what the listing is for in a way a stack of single lines does not, and every field a post
carries -- the icon, the blurb, the byline, the tags -- has somewhere to sit in it. `list` is the
denser reading and stays one option away.
*/
layout: 'cards',
perPage: 10,
sort: 'newest',
depth: 5,
intro: '',
show: { icon: true, description: true, author: true, date: true, tags: true },
sidebar: { tags: true, archive: true }
}
/** The widest a blog page may be set to, so that one request cannot ask for the whole wiki. */
const BLOG_MAX_PER_PAGE = 100
/** As deep as posts may be collected from, matching the tree's own ceiling on a recursive listing. */
const BLOG_MAX_DEPTH = 10
/** As long an introduction as the front page will carry; past this it is an article, not a preamble. */
const BLOG_MAX_INTRO = 2000
/**
* Read a blog's settings back out of what the editor sent.
*
* Every field is defaulted rather than required, because none of them is a destination the way a
* redirection's target is: a blog with nothing filled in is a perfectly good blog, and refusing to
* save one would only mean an author cannot create the front page before deciding how it should
* look. What IS refused is a number outside what the listing can serve — a `perPage` of a million is
* not a preference, it is a request for the whole wiki on one screen.
*
* Re-serialized rather than stored as it arrived, for the same reason a redirection is: the column
* holds one canonical spelling, so a save that changes nothing reports no change.
*/
function normalizeBlogContent(content: string | undefined): string {
let parsed: any
try {
parsed = JSON.parse(content ?? '')
} catch {
parsed = null
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
parsed = {}
}
const perPage = Number.parseInt(parsed.perPage, 10)
if (parsed.perPage !== undefined && (!Number.isFinite(perPage) || perPage < 1)) {
throw new CustomError('pageBlogInvalidPerPage', 'A blog must show at least one post per page.')
}
const depth = Number.parseInt(parsed.depth, 10)
if (parsed.depth !== undefined && (!Number.isFinite(depth) || depth < 0)) {
throw new CustomError('pageBlogInvalidDepth', 'A blog cannot collect posts from above itself.')
}
const intro = typeof parsed.intro === 'string' ? parsed.intro.trim() : BLOG_DEFAULTS.intro
if (intro.length > BLOG_MAX_INTRO) {
throw new CustomError(
'pageBlogIntroTooLong',
`A blog's introduction cannot be longer than ${BLOG_MAX_INTRO} characters.`
)
}
const blog: BlogContent = {
layout: BLOG_LAYOUTS.includes(parsed.layout) ? parsed.layout : BLOG_DEFAULTS.layout,
perPage: Number.isFinite(perPage)
? Math.min(perPage, BLOG_MAX_PER_PAGE)
: BLOG_DEFAULTS.perPage,
sort: parsed.sort === 'oldest' ? 'oldest' : BLOG_DEFAULTS.sort,
depth: Number.isFinite(depth) ? Math.min(depth, BLOG_MAX_DEPTH) : BLOG_DEFAULTS.depth,
intro,
show: {
icon: parsed.show?.icon ?? BLOG_DEFAULTS.show.icon,
description: parsed.show?.description ?? BLOG_DEFAULTS.show.description,
author: parsed.show?.author ?? BLOG_DEFAULTS.show.author,
date: parsed.show?.date ?? BLOG_DEFAULTS.show.date,
tags: parsed.show?.tags ?? BLOG_DEFAULTS.show.tags
},
sidebar: {
tags: parsed.sidebar?.tags ?? BLOG_DEFAULTS.sidebar.tags,
archive: parsed.sidebar?.archive ?? BLOG_DEFAULTS.sidebar.archive
}
}
return JSON.stringify(blog)
}
/**
* Read a stored blog's settings, for a caller that has the page in hand.
*
* Never throws: a front page whose content is missing or unparseable is a blog with every setting at
* its default, which is a working blog rather than a broken screen. The editor's own reading of the
* same document is `frontend/src/helpers/pageBlog.js`.
*/
export function parseBlogContent(content: string | null | undefined): BlogContent {
let parsed: any
try {
parsed = JSON.parse(content ?? '')
} catch {
parsed = null
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {
...BLOG_DEFAULTS,
show: { ...BLOG_DEFAULTS.show },
sidebar: { ...BLOG_DEFAULTS.sidebar }
}
}
const perPage = Number.parseInt(parsed.perPage, 10)
const depth = Number.parseInt(parsed.depth, 10)
return {
layout: BLOG_LAYOUTS.includes(parsed.layout) ? parsed.layout : BLOG_DEFAULTS.layout,
perPage:
Number.isFinite(perPage) && perPage > 0
? Math.min(perPage, BLOG_MAX_PER_PAGE)
: BLOG_DEFAULTS.perPage,
sort: parsed.sort === 'oldest' ? 'oldest' : BLOG_DEFAULTS.sort,
depth:
Number.isFinite(depth) && depth >= 0 ? Math.min(depth, BLOG_MAX_DEPTH) : BLOG_DEFAULTS.depth,
intro: typeof parsed.intro === 'string' ? parsed.intro : BLOG_DEFAULTS.intro,
show: {
icon: parsed.show?.icon ?? BLOG_DEFAULTS.show.icon,
description: parsed.show?.description ?? BLOG_DEFAULTS.show.description,
author: parsed.show?.author ?? BLOG_DEFAULTS.show.author,
date: parsed.show?.date ?? BLOG_DEFAULTS.show.date,
tags: parsed.show?.tags ?? BLOG_DEFAULTS.show.tags
},
sidebar: {
tags: parsed.sidebar?.tags ?? BLOG_DEFAULTS.sidebar.tags,
archive: parsed.sidebar?.archive ?? BLOG_DEFAULTS.sidebar.archive
}
}
}
/** /**
* Pages model * Pages model
* *
@ -478,11 +728,11 @@ class Pages {
* being asked for a password to. * being asked for a password to.
* @param withPassword Include the page's own password. Only for a requester who may edit the page, * @param withPassword Include the page's own password. Only for a requester who may edit the page,
* which is the one that has to be able to read it back and save it again. * which is the one that has to be able to read it back and save it again.
* @param withContent Include the source. A redirection's comes back either way: its content is not * @param withContent Include the source. A bodyless editor's comes back either way: its content is
* a body somebody wrote, it is where the page sends its reader — which every * not a body somebody wrote, it is the settings the page view needs to draw
* reader is about to be shown by being taken there. Withholding it would leave * anything at all — where a redirection sends its reader, how a blog lists its
* the page view unable to do the one thing the page is for, and the page view * posts. Withholding it would leave the page view unable to do the one thing the
* does not ask for content. * page is for, and the page view does not ask for content.
*/ */
private toPage( private toPage(
row: any, row: any,
@ -526,7 +776,7 @@ class Pages {
tags: row.tags ?? [], tags: row.tags ?? [],
toc: locked ? [] : (row.toc ?? []), toc: locked ? [] : (row.toc ?? []),
render: locked ? '' : (row.render ?? ''), render: locked ? '' : (row.render ?? ''),
...((withContent || row.editor === REDIRECT_EDITOR) && !locked ...((withContent || isBodylessEditor(row.editor)) && !locked
? { content: row.content ?? '' } ? { content: row.content ?? '' }
: {}), : {}),
/* /*
@ -1188,6 +1438,7 @@ class Pages {
path: row.path, path: row.path,
title: row.title, title: row.title,
description: row.description, description: row.description,
editor: row.editor,
render: isProtected || isRedirect ? null : row.render, render: isProtected || isRedirect ? null : row.render,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
isIndexable: row.isSearchable && !isProtected && !isRedirect, isIndexable: row.isSearchable && !isProtected && !isRedirect,
@ -1417,10 +1668,10 @@ class Pages {
} }
const editor = input.editor || 'markdown' const editor = input.editor || 'markdown'
const isRedirect = editor === REDIRECT_EDITOR const isRedirect = editor === REDIRECT_EDITOR
// -> A redirection has no body to be empty: what it holds instead is where it points, and that has // -> A bodyless editor has no body to be empty: what its content column holds instead is a
// its own rules about being filled in // settings document, which has its own rules about what it may say
const content = isRedirect ? normalizeRedirectContent(input.content) : input.content const content = normalizeBodylessContent(editor, input.content)
if (!isRedirect && (!content || content.trim().length < 1)) { if (!isBodylessEditor(editor) && (!content || content.trim().length < 1)) {
throw new CustomError('pageEmptyContent', 'A page cannot be empty.') throw new CustomError('pageEmptyContent', 'A page cannot be empty.')
} }
@ -1528,6 +1779,19 @@ class Pages {
throw err throw err
} }
/*
A blog's posts are the pages under its path, so the folder at that path is where its author is
about to be working — and nothing has put one there yet. Created here so that the blog has
somewhere to write its first post into rather than looking like a dead end in the file manager.
Deliberately OUTSIDE the rollback above: the folder is a convenience and the blog is complete
without it, so a folder that cannot be made must not take the page with it. `ensureFolder` never
throws for that reason.
*/
if (editor === BLOG_EDITOR) {
await WIKI.models.blogs.ensureFolder({ siteId, locale, path, title })
}
// -> What this page points at, which is only knowable once it has an id and an address of its // -> What this page points at, which is only knowable once it has an id and an address of its
// own: a relative link resolves against the page holding it // own: a relative link resolves against the page holding it
await WIKI.models.pageLinks.refreshForPage(page, links) await WIKI.models.pageLinks.refreshForPage(page, links)
@ -1600,7 +1864,7 @@ class Pages {
const values: Record<string, any> = { updatedAt: sql`now()` } const values: Record<string, any> = { updatedAt: sql`now()` }
let treeTitle: string | null = null let treeTitle: string | null = null
// -> Which editor authored a page is not something a save may change, so the row is the authority // -> Which editor authored a page is not something a save may change, so the row is the authority
// on whether this is a redirection // on what kind of page this is
const isRedirect = existing.editor === REDIRECT_EDITOR const isRedirect = existing.editor === REDIRECT_EDITOR
if (patch.title !== undefined) { if (patch.title !== undefined) {
@ -1621,7 +1885,7 @@ class Pages {
values.alias = await this.validateAlias(siteId, patch.alias, id) values.alias = await this.validateAlias(siteId, patch.alias, id)
} }
if (patch.content !== undefined) { if (patch.content !== undefined) {
values.content = isRedirect ? normalizeRedirectContent(patch.content) : patch.content values.content = normalizeBodylessContent(existing.editor, patch.content)
} }
if (patch.publishState !== undefined) { if (patch.publishState !== undefined) {
if ( if (

@ -203,6 +203,15 @@ class Sites {
isActive: true, isActive: true,
config: {} config: {}
}, },
/*
No config of its own: how a blog behaves is set up per blog, on its own front page, which
is where somebody who wants a blog is already standing. This flag is only whether the site
offers `New Blog` at all.
*/
blog: {
isActive: true,
config: {}
},
markdown: { markdown: {
isActive: true, isActive: true,
config: { config: {
@ -470,6 +479,15 @@ class Sites {
isActive: true, isActive: true,
config: {} config: {}
}, },
/*
No config of its own: how a blog behaves is set up per blog, on its own front page, which
is where somebody who wants a blog is already standing. This flag is only whether the site
offers `New Blog` at all.
*/
blog: {
isActive: true,
config: {}
},
markdown: { markdown: {
isActive: true, isActive: true,
config: { config: {

@ -5,9 +5,10 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or 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. removing an icon; `check-icons.mjs` fails the build if this drifts.
271 icons. 275 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
"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}, "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},
"la:arrow-circle-left": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-.719 4.594L8.875 16l6.406 6.406L16.72 21l-4-4H23v-2H12.719l4-4z\"/>","width":32,"height":32}, "la:arrow-circle-left": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-.719 4.594L8.875 16l6.406 6.406L16.72 21l-4-4H23v-2H12.719l4-4z\"/>","width":32,"height":32},
"la:arrow-circle-right": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m.719 4.594L15.28 11l4 4H9v2h10.281l-4 4l1.438 1.406L23.125 16z\"/>","width":32,"height":32}, "la:arrow-circle-right": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m.719 4.594L15.28 11l4 4H9v2h10.281l-4 4l1.438 1.406L23.125 16z\"/>","width":32,"height":32},
@ -24,6 +25,7 @@ export const BUNDLED_ICONS = {
"la:broadcast-tower": {"body":"<path fill=\"currentColor\" d=\"M7.188 4.188c-4.297 4.183-4.282 11.125 0 15.406l1.406-1.407c-3.52-3.519-3.504-9.148 0-12.562zm17.625.093L23.405 5.72c3.524 3.523 3.524 9.039 0 12.562l1.407 1.438a10.897 10.897 0 0 0 0-15.438zM9.905 7.188c-2.586 2.585-2.586 6.82 0 9.406l1.406-1.407a4.68 4.68 0 0 1 0-6.593zm12.188.093L20.687 8.72a4.64 4.64 0 0 1 0 6.562l1.407 1.438c2.586-2.586 2.586-6.852 0-9.438zM16 10a2 2 0 0 0-2 2c0 .625.3 1.164.75 1.531L10.312 26H9v2h4v-2h-.594L16 15.969L19.594 26H19v2h4v-2h-1.313L17.25 13.531c.45-.367.75-.906.75-1.531a2 2 0 0 0-2-2\"/>","width":32,"height":32}, "la:broadcast-tower": {"body":"<path fill=\"currentColor\" d=\"M7.188 4.188c-4.297 4.183-4.282 11.125 0 15.406l1.406-1.407c-3.52-3.519-3.504-9.148 0-12.562zm17.625.093L23.405 5.72c3.524 3.523 3.524 9.039 0 12.562l1.407 1.438a10.897 10.897 0 0 0 0-15.438zM9.905 7.188c-2.586 2.585-2.586 6.82 0 9.406l1.406-1.407a4.68 4.68 0 0 1 0-6.593zm12.188.093L20.687 8.72a4.64 4.64 0 0 1 0 6.562l1.407 1.438c2.586-2.586 2.586-6.852 0-9.438zM16 10a2 2 0 0 0-2 2c0 .625.3 1.164.75 1.531L10.312 26H9v2h4v-2h-.594L16 15.969L19.594 26H19v2h4v-2h-1.313L17.25 13.531c.45-.367.75-.906.75-1.531a2 2 0 0 0-2-2\"/>","width":32,"height":32},
"la:broom": {"body":"<path fill=\"currentColor\" d=\"m28.281 2.281l-10 10L17 11v-.031l-.031-.031c-.64-.57-1.477-.844-2.282-.844s-1.582.3-2.187.906l-.156.125l-.5.5l-.344.281L2.375 19l-.875.719L12.281 30.5l.719-.875l7.063-9.063l.03.032l1-1h.032l.031-.032c1.14-1.285 1.149-3.257-.062-4.468l-1.375-1.375l10-10zm-13.593 9.813a1.4 1.4 0 0 1 .906.312c.011.008.02.024.031.031l4.063 4.063c.375.375.41 1.172 0 1.688c-.016.019-.016.042-.032.062l-.312.281l-5.782-5.781l.344-.344c.192-.191.473-.304.781-.312zM12.03 14.03l5.938 5.938l-5.875 7.5l-1.438-1.438l2.156-2.25l-1.437-1.375l-2.125 2.219l-1.313-1.313l3.875-3.906L10.406 18L6.5 21.875l-1.969-1.969z\"/>","width":32,"height":32}, "la:broom": {"body":"<path fill=\"currentColor\" d=\"m28.281 2.281l-10 10L17 11v-.031l-.031-.031c-.64-.57-1.477-.844-2.282-.844s-1.582.3-2.187.906l-.156.125l-.5.5l-.344.281L2.375 19l-.875.719L12.281 30.5l.719-.875l7.063-9.063l.03.032l1-1h.032l.031-.032c1.14-1.285 1.149-3.257-.062-4.468l-1.375-1.375l10-10zm-13.593 9.813a1.4 1.4 0 0 1 .906.312c.011.008.02.024.031.031l4.063 4.063c.375.375.41 1.172 0 1.688c-.016.019-.016.042-.032.062l-.312.281l-5.782-5.781l.344-.344c.192-.191.473-.304.781-.312zM12.03 14.03l5.938 5.938l-5.875 7.5l-1.438-1.438l2.156-2.25l-1.437-1.375l-2.125 2.219l-1.313-1.313l3.875-3.906L10.406 18L6.5 21.875l-1.969-1.969z\"/>","width":32,"height":32},
"la:calendar": {"body":"<path fill=\"currentColor\" d=\"M9 4v1H5v22h22V5h-4V4h-2v1H11V4zM7 7h2v1h2V7h10v1h2V7h2v2H7zm0 4h18v14H7zm6 2v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 17v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 21v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2z\"/>","width":32,"height":32}, "la:calendar": {"body":"<path fill=\"currentColor\" d=\"M9 4v1H5v22h22V5h-4V4h-2v1H11V4zM7 7h2v1h2V7h10v1h2V7h2v2H7zm0 4h18v14H7zm6 2v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 17v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 21v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2z\"/>","width":32,"height":32},
"la:calendar-alt": {"body":"<path fill=\"currentColor\" d=\"M9 4v1H5v22h22V5h-4V4h-2v1H11V4zM7 7h2v1h2V7h10v1h2V7h2v2H7zm0 4h18v14H7zm6 2v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 17v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 21v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2z\"/>","width":32,"height":32},
"la:caret-square-right": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm7.219 2.281L12.78 10.72L18.062 16l-5.28 5.281l1.437 1.438l6-6l.687-.719l-.687-.719z\"/>","width":32,"height":32}, "la:caret-square-right": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm7.219 2.281L12.78 10.72L18.062 16l-5.28 5.281l1.437 1.438l6-6l.687-.719l-.687-.719z\"/>","width":32,"height":32},
"la:chalkboard": {"body":"<path fill=\"currentColor\" d=\"M5 7v16H3v2h26v-2h-2V7zm2 2h18v14H7zm14.281 3.281L17 16.562l-3.281-3.28l-.719-.688l-.719.687l-3 3l1.438 1.438L13 15.437l3.281 3.282l.719.687l.719-.687l5-5zM20 20l-1 1l1 1h4v-2z\"/>","width":32,"height":32}, "la:chalkboard": {"body":"<path fill=\"currentColor\" d=\"M5 7v16H3v2h26v-2h-2V7zm2 2h18v14H7zm14.281 3.281L17 16.562l-3.281-3.28l-.719-.688l-.719.687l-3 3l1.438 1.438L13 15.437l3.281 3.282l.719.687l.719-.687l5-5zM20 20l-1 1l1 1h4v-2z\"/>","width":32,"height":32},
"la:chart-area": {"body":"<path fill=\"currentColor\" d=\"m28 4.063l-1.625 1.25l-4.625 3.625L16.156 8l-.375-.063l-.344.22l-5.687 3.78l-4.563-.906L4 10.781V28h24zm-2 4.093v5.375l-4.219 3.344l-5.468-1.813l-.47-.156l-.405.25l-5.563 3.719L6 17.312V13.22l3.813.75l.406.094l.344-.22l5.656-3.78l5.625.937l.437.063l.344-.282zm0 7.938V26H6v-6.5l3.625 1.438l.5.187l.438-.281l5.624-3.75l5.5 1.843l.5.188l.438-.344z\"/>","width":32,"height":32}, "la:chart-area": {"body":"<path fill=\"currentColor\" d=\"m28 4.063l-1.625 1.25l-4.625 3.625L16.156 8l-.375-.063l-.344.22l-5.687 3.78l-4.563-.906L4 10.781V28h24zm-2 4.093v5.375l-4.219 3.344l-5.468-1.813l-.47-.156l-.405.25l-5.563 3.719L6 17.312V13.22l3.813.75l.406.094l.344-.22l5.656-3.78l5.625.937l.437.063l.344-.282zm0 7.938V26H6v-6.5l3.625 1.438l.5.187l.438-.281l5.624-3.75l5.5 1.843l.5.188l.438-.344z\"/>","width":32,"height":32},
@ -67,6 +69,7 @@ export const BUNDLED_ICONS = {
"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-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-upload": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.6l-.3-.3l-6-6l-.3-.3zm2 2h10v6h6v16H8zm12 1.4L22.6 9H20zM16 13l-4 4h3v5h2v-5h3zm-4 10v2h8v-2z\"/>","width":32,"height":32}, "la:file-upload": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.6l-.3-.3l-6-6l-.3-.3zm2 2h10v6h6v16H8zm12 1.4L22.6 9H20zM16 13l-4 4h3v5h2v-5h3zm-4 10v2h8v-2z\"/>","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: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:filter": {"body":"<path fill=\"currentColor\" d=\"M5 4v2.344l.219.281L13 16.344V28l1.594-1.188l4-3L19 23.5v-7.156l7.781-9.719l.219-.281V4zm2.281 2H24.72l-7.188 9H14.47zM15 17h2v5.5L15 24z\"/>","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: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:fire-alt": {"body":"<path fill=\"currentColor\" d=\"m16.799 4.39l-2.996 4.997l-1.85-1.848l-.703.799C7.767 12.286 6 15.873 6 19c0 4.962 4.486 9 10 9s10-4.038 10-9c0-4.762-5.197-10.634-8.295-13.71zm.392 3.233C19.767 10.309 24 15.288 24 19c0 2.391-1.38 4.504-3.477 5.768A6 6 0 0 0 21 22.43c0-2.381-1.685-5.206-3.098-7.155l-.843-1.166l-2.215 3.323l-1.406-1.407l-.66 1.09C11.597 19.061 11 20.85 11 22.43c0 .837.178 1.624.477 2.338C9.38 23.504 8 21.39 8 19s1.398-5.323 4.057-8.53l2.14 2.143zm-.087 10.025C18.334 19.565 19 21.234 19 22.43c0 1.969-1.346 3.57-3 3.57s-3-1.601-3-3.57c0-.922.29-1.978.865-3.149l1.291 1.29z\"/>","width":32,"height":32}, "la:fire-alt": {"body":"<path fill=\"currentColor\" d=\"m16.799 4.39l-2.996 4.997l-1.85-1.848l-.703.799C7.767 12.286 6 15.873 6 19c0 4.962 4.486 9 10 9s10-4.038 10-9c0-4.762-5.197-10.634-8.295-13.71zm.392 3.233C19.767 10.309 24 15.288 24 19c0 2.391-1.38 4.504-3.477 5.768A6 6 0 0 0 21 22.43c0-2.381-1.685-5.206-3.098-7.155l-.843-1.166l-2.215 3.323l-1.406-1.407l-.66 1.09C11.597 19.061 11 20.85 11 22.43c0 .837.178 1.624.477 2.338C9.38 23.504 8 21.39 8 19s1.398-5.323 4.057-8.53l2.14 2.143zm-.087 10.025C18.334 19.565 19 21.234 19 22.43c0 1.969-1.346 3.57-3 3.57s-3-1.601-3-3.57c0-.922.29-1.978.865-3.149l1.291 1.29z\"/>","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}, "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},
@ -102,6 +105,7 @@ export const BUNDLED_ICONS = {
"la:microchip": {"body":"<path fill=\"currentColor\" d=\"M7 6v2H3v18h4v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h4V8h-4V6h-2v2h-2V6h-2v2h-2V6h-2v2h-2V6h-2v2H9V6zm-2 4h22v14H5zm3 2c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 16c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m16 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 20c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32}, "la:microchip": {"body":"<path fill=\"currentColor\" d=\"M7 6v2H3v18h4v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h4V8h-4V6h-2v2h-2V6h-2v2h-2V6h-2v2h-2V6h-2v2H9V6zm-2 4h22v14H5zm3 2c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 16c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m16 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 20c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
"la:minus": {"body":"<path fill=\"currentColor\" d=\"M5 15v2h22v-2z\"/>","width":32,"height":32}, "la:minus": {"body":"<path fill=\"currentColor\" d=\"M5 15v2h22v-2z\"/>","width":32,"height":32},
"la:mountain": {"body":"<path fill=\"currentColor\" d=\"m17.012 3.021l-.912 1.66l-6.522 11.856l-1.916-1.916l-.66 1.098l-5.86 9.767L.235 27h31.284l-.598-1.395l-3-7l-.582-1.357l-2.068 2.068l-7.403-14.605zm-.073 4.282l3.04 5.996l-.774.664l-2.28-1.953l-2.279 1.953l-.93-.799zm-.013 7.34l2.28 1.953l1.702-1.46l3.2 6.315l.622 1.233l1.932-1.932L28.482 25H3.766l4.293-7.154l1.988 1.988l.642-1.166l2.043-3.713l1.914 1.64z\"/>","width":32,"height":32}, "la:mountain": {"body":"<path fill=\"currentColor\" d=\"m17.012 3.021l-.912 1.66l-6.522 11.856l-1.916-1.916l-.66 1.098l-5.86 9.767L.235 27h31.284l-.598-1.395l-3-7l-.582-1.357l-2.068 2.068l-7.403-14.605zm-.073 4.282l3.04 5.996l-.774.664l-2.28-1.953l-2.279 1.953l-.93-.799zm-.013 7.34l2.28 1.953l1.702-1.46l3.2 6.315l.622 1.233l1.932-1.932L28.482 25H3.766l4.293-7.154l1.988 1.988l.642-1.166l2.043-3.713l1.914 1.64z\"/>","width":32,"height":32},
"la:newspaper": {"body":"<path fill=\"currentColor\" d=\"M3 5v18c0 2.21 1.79 4 4 4h18c2.21 0 4-1.79 4-4V12h-6V5zm2 2h16v16c0 .73.223 1.41.563 2H7c-1.191 0-2-.809-2-2zm2 2v5h12V9zm2 2h8v1H9zm14 3h4v9c0 1.191-.809 2-2 2s-2-.809-2-2zM7 15v2h5v-2zm7 0v2h5v-2zm-7 3v2h5v-2zm7 0v2h5v-2zm-7 3v2h5v-2zm7 0v2h5v-2z\"/>","width":32,"height":32},
"la:otter": {"body":"<path fill=\"currentColor\" d=\"M14.5 5c-3.711 0-7.057 1.584-8.594 4.002A3.003 3.003 0 0 0 3 12c0 .899.407 2.021 1 2.818V27h2V14.068l-.268-.287C5.3 13.314 5 12.47 5 12a1 1 0 0 1 1-1q.097 0 .23.031l.784.188l.347-.729C8.347 8.435 11.283 7 14.5 7h3c3.217 0 6.153 1.435 7.139 3.49l.347.729l.784-.188q.133-.03.23-.031a1 1 0 0 1 1 1c0 .469-.3 1.314-.732 1.781l-.268.287V27h2V14.818c.593-.797 1-1.919 1-2.818a3.003 3.003 0 0 0-2.906-2.998C24.557 6.584 21.21 5 17.5 5zM10 12a1 1.5 0 0 0 0 3a1 1.5 0 0 0 0-3m12 0a1 1.5 0 0 0 0 3a1 1.5 0 0 0 0-3m-6 1.018c-.915 0-1.83.323-2.629.968c-3.557 2.878-3.64 2.95-3.887 3.157l-.107.09C8.489 17.974 8 18.958 8 20c0 2.206 1.794 4 4 4c1.498 0 2.914-.36 4-1.006C17.086 23.64 18.502 24 20 24c2.206 0 4-1.794 4-4c0-1.041-.489-2.024-1.377-2.768l-.107-.09c-.246-.206-.33-.279-3.887-3.158c-.798-.644-1.714-.966-2.629-.966m0 1.986c.463 0 .927.179 1.371.537c3.527 2.854 3.613 2.926 3.856 3.13l.113.097c.3.252.66.669.66 1.232c0 1.103-.897 2-2 2c-1.339 0-2.6-.371-3.371-.994l-.596-.48c.075-.87.353-1.526.967-1.526c1.105 0 2-.202 2-.727C19 17.57 17.657 17 16 17s-3 .57-3 1.273c0 .525.895.727 2 .727c.614 0 .892.656.967 1.525l-.596.48C14.6 21.63 13.34 22 12 22c-1.103 0-2-.897-2-2c0-.562.36-.98.662-1.232l.113-.096c.244-.205.328-.276 3.854-3.129c.445-.36.908-.54 1.371-.54z\"/>","width":32,"height":32}, "la:otter": {"body":"<path fill=\"currentColor\" d=\"M14.5 5c-3.711 0-7.057 1.584-8.594 4.002A3.003 3.003 0 0 0 3 12c0 .899.407 2.021 1 2.818V27h2V14.068l-.268-.287C5.3 13.314 5 12.47 5 12a1 1 0 0 1 1-1q.097 0 .23.031l.784.188l.347-.729C8.347 8.435 11.283 7 14.5 7h3c3.217 0 6.153 1.435 7.139 3.49l.347.729l.784-.188q.133-.03.23-.031a1 1 0 0 1 1 1c0 .469-.3 1.314-.732 1.781l-.268.287V27h2V14.818c.593-.797 1-1.919 1-2.818a3.003 3.003 0 0 0-2.906-2.998C24.557 6.584 21.21 5 17.5 5zM10 12a1 1.5 0 0 0 0 3a1 1.5 0 0 0 0-3m12 0a1 1.5 0 0 0 0 3a1 1.5 0 0 0 0-3m-6 1.018c-.915 0-1.83.323-2.629.968c-3.557 2.878-3.64 2.95-3.887 3.157l-.107.09C8.489 17.974 8 18.958 8 20c0 2.206 1.794 4 4 4c1.498 0 2.914-.36 4-1.006C17.086 23.64 18.502 24 20 24c2.206 0 4-1.794 4-4c0-1.041-.489-2.024-1.377-2.768l-.107-.09c-.246-.206-.33-.279-3.887-3.158c-.798-.644-1.714-.966-2.629-.966m0 1.986c.463 0 .927.179 1.371.537c3.527 2.854 3.613 2.926 3.856 3.13l.113.097c.3.252.66.669.66 1.232c0 1.103-.897 2-2 2c-1.339 0-2.6-.371-3.371-.994l-.596-.48c.075-.87.353-1.526.967-1.526c1.105 0 2-.202 2-.727C19 17.57 17.657 17 16 17s-3 .57-3 1.273c0 .525.895.727 2 .727c.614 0 .892.656.967 1.525l-.596.48C14.6 21.63 13.34 22 12 22c-1.103 0-2-.897-2-2c0-.562.36-.98.662-1.232l.113-.096c.244-.205.328-.276 3.854-3.129c.445-.36.908-.54 1.371-.54z\"/>","width":32,"height":32},
"la:paper-plane": {"body":"<path fill=\"currentColor\" d=\"m3.594 5.344l.437 1.875L5.97 16l-1.94 8.781l-.437 1.875l1.781-.718l22-9L29.656 16l-2.281-.938l-22-9zm2.781 3.312L21.906 15H7.781zM7.781 17h14.125L6.375 23.344z\"/>","width":32,"height":32}, "la:paper-plane": {"body":"<path fill=\"currentColor\" d=\"m3.594 5.344l.437 1.875L5.97 16l-1.94 8.781l-.437 1.875l1.781-.718l22-9L29.656 16l-2.281-.938l-22-9zm2.781 3.312L21.906 15H7.781zM7.781 17h14.125L6.375 23.344z\"/>","width":32,"height":32},
"la:pen": {"body":"<path fill=\"currentColor\" d=\"M23.906 3.969A4.1 4.1 0 0 0 21 5.188L5.187 21l-.062.313l-1.094 5.5l-.312 1.468l1.469-.312l5.5-1.094l.312-.063L26.813 11a4.075 4.075 0 0 0 0-5.813a4.1 4.1 0 0 0-2.907-1.218m0 1.906c.504 0 1.012.23 1.5.719c.973.972.973 2.027 0 3l-.718.687l-2.97-2.969l.688-.718c.489-.489.996-.719 1.5-.719m-3.593 2.844l2.968 2.969L11.188 23.78a6.8 6.8 0 0 0-2.97-2.968zM6.938 22.438a4.73 4.73 0 0 1 2.625 2.625l-3.282.656z\"/>","width":32,"height":32}, "la:pen": {"body":"<path fill=\"currentColor\" d=\"M23.906 3.969A4.1 4.1 0 0 0 21 5.188L5.187 21l-.062.313l-1.094 5.5l-.312 1.468l1.469-.312l5.5-1.094l.312-.063L26.813 11a4.075 4.075 0 0 0 0-5.813a4.1 4.1 0 0 0-2.907-1.218m0 1.906c.504 0 1.012.23 1.5.719c.973.972.973 2.027 0 3l-.718.687l-2.97-2.969l.688-.718c.489-.489.996-.719 1.5-.719m-3.593 2.844l2.968 2.969L11.188 23.78a6.8 6.8 0 0 0-2.97-2.968zM6.938 22.438a4.73 4.73 0 0 1 2.625 2.625l-3.282.656z\"/>","width":32,"height":32},

@ -0,0 +1,489 @@
<template>
<div class="editor-blog">
<w-scroll-area style="height: 100%">
<div class="editor-blog-form">
<!-- ----------------------- -->
<!-- The blog itself -->
<!-- ----------------------- -->
<w-card class="pb-2">
<w-card-header>{{ t('editor.blog.title') }}</w-card-header>
<w-item>
<blueprint-icon icon="new-document" />
<w-item-section>
<w-item-label>{{ t('editor.blog.pageTitle') }}</w-item-label>
<w-item-label caption>{{ t('editor.blog.pageTitleHint') }}</w-item-label>
</w-item-section>
<w-item-section>
<!--
The same title the header edits in place, so the two are one field with two places to
type it: both write to the store, and the header's watcher follows what is typed here.
-->
<w-input
outlined
dense
hide-bottom-space
:model-value="pageStore.title"
:aria-label="t(`editor.blog.pageTitle`)"
@update:model-value="setTitle" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="subtitles" />
<w-item-section>
<w-item-label>{{ t('editor.blog.pageDescription') }}</w-item-label>
<w-item-label caption>{{ t('editor.blog.pageDescriptionHint') }}</w-item-label>
</w-item-section>
<w-item-section>
<!--
The page's own description, not a blog setting -- the same field the properties panel
calls Short Description, offered here because it is what a reader sees under the
blog's name and an author on this screen should not have to go looking for it.
-->
<w-input
outlined
dense
hide-bottom-space
:model-value="pageStore.description"
:aria-label="t(`editor.blog.pageDescription`)"
@update:model-value="setDescription" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="quote-left" top />
<w-item-section>
<w-item-label>{{ t('editor.blog.intro') }}</w-item-label>
<w-item-label caption>{{ t('editor.blog.introHint') }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
outlined
dense
hide-bottom-space
type="textarea"
:rows="3"
:model-value="state.intro"
:aria-label="t(`editor.blog.intro`)"
@update:model-value="setIntro" />
</w-item-section>
</w-item>
</w-card>
<!-- ----------------------- -->
<!-- Which pages are posts -->
<!-- ----------------------- -->
<w-card class="mt-4 pb-2">
<w-card-header>{{ t('editor.blog.postsTitle') }}</w-card-header>
<!--
The one rule of the whole feature, said out loud where the author is deciding where to
save the page: what is under this path is a post. Nothing is written on a post to mark it
as one, so this sentence is the entire contract.
-->
<div class="editor-blog-note">
<w-icon name="la:info-circle" />
<div class="pl-3">{{ t('editor.blog.postsHint', { path: postsPath }) }}</div>
</div>
<w-item>
<blueprint-icon icon="depth" />
<w-item-section>
<w-item-label>{{ t('editor.blog.depth') }}</w-item-label>
<w-item-label caption>{{ t('editor.blog.depthHint') }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-input
class="editor-blog-number"
outlined
dense
hide-bottom-space
type="number"
:min="0"
:max="BLOG_MAX_DEPTH"
:model-value="state.depth"
:aria-label="t(`editor.blog.depth`)"
@update:model-value="setDepth"
@blur="settleDepth" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="sort-by-follow-up-date" />
<w-item-section>
<w-item-label>{{ t('editor.blog.sort') }}</w-item-label>
<w-item-label caption>{{ t('editor.blog.sortHint') }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-select
class="editor-blog-select"
outlined
dense
emit-value
map-options
:options="sortOptions"
:model-value="state.sort"
:aria-label="t(`editor.blog.sort`)"
@update:model-value="setSort" />
</w-item-section>
</w-item>
</w-card>
<!-- ----------------------- -->
<!-- How the listing looks -->
<!-- ----------------------- -->
<w-card class="mt-4 pb-2">
<w-card-header>{{ t('editor.blog.listingTitle') }}</w-card-header>
<w-item>
<blueprint-icon icon="index" />
<w-item-section>
<w-item-label>{{ t('editor.blog.layout') }}</w-item-label>
<w-item-label caption>{{ t('editor.blog.layoutHint') }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-select
class="editor-blog-select"
outlined
dense
emit-value
map-options
:options="layoutOptions"
:model-value="state.layout"
:aria-label="t(`editor.blog.layout`)"
@update:model-value="setLayout" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="list" />
<w-item-section>
<w-item-label>{{ t('editor.blog.perPage') }}</w-item-label>
<w-item-label caption>{{ t('editor.blog.perPageHint') }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-input
class="editor-blog-number"
outlined
dense
hide-bottom-space
type="number"
:min="1"
:max="BLOG_MAX_PER_PAGE"
:model-value="state.perPage"
:aria-label="t(`editor.blog.perPage`)"
@update:model-value="setPerPage"
@blur="settlePerPage" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<!--
What a post entry carries, as one row of switches rather than five rows of their own: they
are one decision made five times, and a row apiece would be most of this screen.
-->
<w-item>
<blueprint-icon icon="tune" top />
<w-item-section>
<w-item-label>{{ t('editor.blog.showFields') }}</w-item-label>
<w-item-label caption>{{ t('editor.blog.showFieldsHint') }}</w-item-label>
</w-item-section>
</w-item>
<div class="editor-blog-checks">
<w-checkbox
v-for="field of SHOW_FIELDS"
:key="field"
:label="t(`editor.blog.show.${field}`)"
:model-value="state.show[field]"
@update:model-value="setShow(field, $event)" />
</div>
</w-card>
<!-- ----------------------- -->
<!-- What the sidebar offers -->
<!-- ----------------------- -->
<w-card class="mt-4 pb-2">
<w-card-header>{{ t('editor.blog.sidebarTitle') }}</w-card-header>
<w-item>
<blueprint-icon icon="filtration" />
<w-item-section>
<w-item-label>{{ t('editor.blog.sidebarTags') }}</w-item-label>
<w-item-label caption>{{ t('editor.blog.sidebarTagsHint') }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-toggle
:model-value="state.sidebar.tags"
:aria-label="t(`editor.blog.sidebarTags`)"
@update:model-value="setSidebar('tags', $event)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="calendar" />
<w-item-section>
<w-item-label>{{ t('editor.blog.sidebarArchive') }}</w-item-label>
<w-item-label caption>{{ t('editor.blog.sidebarArchiveHint') }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-toggle
:model-value="state.sidebar.archive"
:aria-label="t(`editor.blog.sidebarArchive`)"
@update:model-value="setSidebar('archive', $event)" />
</w-item-section>
</w-item>
</w-card>
</div>
</w-scroll-area>
</div>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
BLOG_LAYOUTS,
BLOG_MAX_DEPTH,
BLOG_MAX_INTRO,
BLOG_MAX_PER_PAGE,
BLOG_SORTS,
parseBlog,
serializeBlog
} from '@/helpers/pageBlog'
import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
/**
* As long a description as the page's column will take. Mirrors the `maxLength` the API schema puts
* on it (`api/schemas/page.ts`), which is what makes it a ceiling rather than a preference.
*/
const PAGE_DESCRIPTION_MAX = 255
/**
* The `blog` editor: the front page of a blog.
*
* There is no content to write, so this is a form rather than an editor — a title, a description, an
* introduction, and how the listing beneath it should behave. All of it except the title and the
* description is the page's content, as JSON; those two are columns on the page itself and are
* written straight to the store, the same ones the properties panel edits. See `helpers/pageBlog.js`,
* and `models/blogs.ts` on the server for what a POST is: nothing here names one, because a post is a
* post by sitting under this page's path.
*
* What the page then DOES with that is `PageBlog.vue`, which the page view draws in place of an
* article.
*/
/** The fields of a post whose display is a switch, in the order they are offered. */
const SHOW_FIELDS = ['icon', 'description', 'author', 'date', 'tags']
// STORES
const editorStore = useEditorStore()
const pageStore = usePageStore()
// I18N
const { t } = useI18n()
// DATA
/**
* The blog being edited, which is also exactly what gets saved — every field of the form is one of
* these. Seeded from the stored content and written back by the watcher below.
*/
const state = reactive(parseBlog(pageStore.content))
// COMPUTED
const layoutOptions = computed(() =>
BLOG_LAYOUTS.map((value) => ({ value, label: t(`editor.blog.layouts.${value}`) }))
)
const sortOptions = computed(() =>
BLOG_SORTS.map((value) => ({ value, label: t(`editor.blog.sorts.${value}`) }))
)
/**
* The path posts go under, as the sentence above the settings names it.
*
* Read off the page being written rather than off a saved blog, so that moving the page in the path
* field moves the sentence with it — this is the one rule of the feature and it has to describe where
* the author is actually about to save.
*/
const postsPath = computed(() => `/${pageStore.path}/`)
// WATCHERS
/*
The form IS the content, so the store follows it on every keystroke — there is nothing here that a
save would collect afterwards.
Immediate, and deliberately not a change: this also writes the canonical spelling of what was
already stored, and seeds a page being created with an empty blog. Neither is an edit, so neither
may set the unsaved-changes flag — `touch` is called by the handlers instead, where a person
actually did something.
*/
watch(
state,
(value) => {
pageStore.content = serializeBlog(value)
},
{ immediate: true, deep: true }
)
// METHODS
/** Say that the page has unsaved changes, which is what turns the header's Save button on. */
function touch() {
editorStore.lastChangeTimestamp = Temporal.Now.instant()
}
function setTitle(title) {
pageStore.title = title
touch()
}
/**
* Held to the column's own 255 characters, in the form rather than at the save — the same reason
* {@link setIntro} holds the introduction to its ceiling: a limit the field does not enforce is a
* save refused after the typing instead of during it.
*/
function setDescription(description) {
pageStore.description = (description ?? '').slice(0, PAGE_DESCRIPTION_MAX)
touch()
}
/**
* Held to the same ceiling the server holds it to, here rather than with a `maxlength` attribute: the
* field is a `w-input`, which passes on the props it declares and nothing else, and a limit the form
* does not enforce would be a save refused after the typing rather than during it.
*/
function setIntro(intro) {
state.intro = (intro ?? '').slice(0, BLOG_MAX_INTRO)
touch()
}
/**
* A number field, as it is being typed.
*
* Two things a field like this gets wrong if it is written naively, and both of them here:
*
* - **An emptied field hands back an empty string**, which `Number.parseInt` reads as `NaN`. Left
* alone until it parses again, so that clearing the field in order to type a new number does not
* reset it to the default under the typist.
* - **The CEILING is not enforced while typing.** Editing `10` into `500` passes through `50` and
* then `500`, and a handler that clamped each keystroke would write `100` the moment the third
* digit landed — after which every further keystroke appends to `100` and clamps back to it, so
* the field can never be typed down again without being cleared first. The floor has no such
* problem (nothing types its way up through a number that is too small), so it is applied here and
* the ceiling waits for `settleNumber`.
*/
function setNumber(key, value, min) {
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) {
return
}
state[key] = Math.max(parsed, min)
touch()
}
/**
* The same field, once the typist has left it.
*
* Where the ceiling is applied, so that what is on screen at rest is what will be saved — the server
* clamps to the same figure (`normalizeBlogContent`), and a field that showed 500 while the page
* stored 100 would be the form disagreeing with itself.
*/
function settleNumber(key, max) {
if (state[key] > max) {
state[key] = max
}
}
function setDepth(value) {
setNumber('depth', value, 0)
}
function settleDepth() {
settleNumber('depth', BLOG_MAX_DEPTH)
}
function setPerPage(value) {
setNumber('perPage', value, 1)
}
function settlePerPage() {
settleNumber('perPage', BLOG_MAX_PER_PAGE)
}
function setLayout(layout) {
state.layout = layout
touch()
}
function setSort(sort) {
state.sort = sort
touch()
}
function setShow(field, value) {
state.show[field] = value
touch()
}
function setSidebar(field, value) {
state.sidebar[field] = value
touch()
}
</script>
<style lang="scss">
.editor-blog {
height: 100%;
@at-root .body--light & {
background-color: $grey-3;
}
@at-root .body--dark & {
background-color: $dark-6;
}
/* -> A form, not a document: it stops widening well before the column does */
&-form {
max-width: 780px;
margin: 0 auto;
padding: 24px 16px 48px;
}
/* -> Wide enough for the longest option and no wider; these sit in a `side` section */
&-select {
min-width: 160px;
}
&-number {
width: 96px;
}
/*
Lined up with the main section of the row above it: `w-item` pads 16px and its avatar section is
56px wide, so the switches start where that row's label does.
*/
&-checks {
display: flex;
flex-wrap: wrap;
gap: 4px 20px;
padding: 0 16px 8px 72px;
}
/* -> The one rule of the feature, stated where the author is deciding where to save the page */
&-note {
display: flex;
align-items: flex-start;
margin: 0 16px 8px;
padding: 12px 16px;
border-radius: 4px;
background-color: rgba(25, 118, 210, 0.1);
color: $blue-9;
font-size: 0.8rem;
line-height: 1.4;
@at-root .body--dark & {
color: $blue-3;
}
}
}
</style>

@ -410,11 +410,14 @@
</w-item-section> </w-item-section>
<w-item-section>{{ t(`common.actions.edit`) }}</w-item-section> <w-item-section>{{ t(`common.actions.edit`) }}</w-item-section>
</w-item> </w-item>
<!-- -> Nothing to render on a redirection: it has a target where a page has <!-- -> Nothing to render on a page whose content is not an article: a
content, and the endpoint behind this refuses any editor but markdown --> redirection has a target and a blog has the posts under it, and the
endpoint behind this refuses any editor but markdown anyway -->
<w-item <w-item
clickable clickable
v-if="item.type === `page` && item.pageType !== `redirect`" v-if="
item.type === `page` && ![`redirect`, `blog`].includes(item.pageType)
"
@click="rerenderPage(item)"> @click="rerenderPage(item)">
<w-item-section side> <w-item-section side>
<w-icon name="la:magic" color="orange" /> <w-icon name="la:magic" color="orange" />
@ -787,8 +790,13 @@ const files = computed(() => {
break break
} }
case 'page': { case 'page': {
// -> A redirection has a target where a page has content, so it reads as its own kind of row /*
f.icon = f.pageType === 'redirect' ? fileTypes.redirect.icon : fileTypes.page.icon A page whose content is not an article reads as its own kind of row: a redirection has a
target, a blog has the posts under it. Looked up by editor, which shares a namespace with
the file extensions here on purpose -- `folder`, `page` and `redirect` are already in it --
so an editor with no icon of its own falls back to the ordinary page.
*/
f.icon = fileTypes[f.pageType]?.icon ?? fileTypes.page.icon
f.caption = t(`fileman.${f.pageType}PageType`) f.caption = t(`fileman.${f.pageType}PageType`)
break break
} }

@ -172,7 +172,9 @@
><w-item-label>{{ t('convertPage.action') }}</w-item-label></w-item-section ><w-item-label>{{ t('convertPage.action') }}</w-item-label></w-item-section
> >
</w-item> </w-item>
<w-item clickable v-if="userStore.can(`write:pages`)" @click="rerenderPage"> <!-- -> Nothing to render on a page whose content is a settings document rather than an
article, and the endpoint behind this refuses any editor but markdown anyway -->
<w-item clickable v-if="userStore.can(`write:pages`) && hasBody" @click="rerenderPage">
<w-item-section class="items-center" avatar> <w-item-section class="items-center" avatar>
<w-icon class="text-deep-orange-9" name="la:magic" size="sm" /> <w-icon class="text-deep-orange-9" name="la:magic" size="sm" />
</w-item-section> </w-item-section>
@ -295,15 +297,27 @@ const canConvert = computed(() =>
*/ */
const isRedirect = computed(() => pageStore.editor === 'redirect') const isRedirect = computed(() => pageStore.editor === 'redirect')
/**
* Whether the page on screen holds text somebody wrote.
*
* False for the two editors that keep a settings document where a page keeps content: a redirection
* holds where it points, a blog holds how it lists the posts under it. Neither has a source worth
* showing a reader, and neither can be re-rendered — the endpoint refuses any editor but markdown.
*/
const hasBody = computed(() => !['redirect', 'blog'].includes(pageStore.editor))
/** /**
* Whether the "..." menu has anything to show. * Whether the "..." menu has anything to show.
* *
* Every entry in it is behind something: Rerender Page behind `write:pages`, Convert Page and View * The disjunction of the three entries' own conditions, spelled out, because with none of them true
* Backlinks behind the experimental flag (the first behind `manage:pages` as well). So those two tests * it opened an empty panel -- which is what a guest got on every page. Rerender Page needs
* cover the whole menu -- and with neither of them true it opened an empty panel, which is what a guest * `write:pages` and a page with a body; Convert Page needs `write:pages` and somewhere to convert to;
* got on every page. Keep this in step with the entries themselves. * View Backlinks needs the experimental flag. Keep this in step with the entries themselves.
*/ */
const hasPageActions = computed(() => flagsStore.experimental || userStore.can('write:pages')) const hasPageActions = computed(
() =>
(userStore.can('write:pages') && (canConvert.value || hasBody.value)) || flagsStore.experimental
)
/* /*
Whoever may write the page may read its source too — the editor is what opens it — so the button is Whoever may write the page may read its source too — the editor is what opens it — so the button is
@ -330,7 +344,7 @@ const isSaved = computed(() => Boolean(pageStore.id))
const showHistory = computed( const showHistory = computed(
() => !isRedirect.value && isSaved.value && userStore.can('read:history') () => !isRedirect.value && isSaved.value && userStore.can('read:history')
) )
const showSource = computed(() => !isRedirect.value && isSaved.value && canViewSource.value) const showSource = computed(() => hasBody.value && isSaved.value && canViewSource.value)
/** /**
* Whether the "..." menu is offered at all. * Whether the "..." menu is offered at all.

@ -0,0 +1,787 @@
<template>
<div class="page-blog">
<!--
The blog's own words, above the listing. Plain text, deliberately: the front page has no
renderer and no editor for one -- see `helpers/pageBlog.js` -- so `white-space: pre-line` is
what gives the author their paragraph breaks back without any of the rest of it.
-->
<p class="page-blog-intro" v-if="settings.intro">{{ settings.intro }}</p>
<!--
What the reader has narrowed the blog to, and the way back out of it. Only drawn once something
is selected: an empty filter bar over an unfiltered blog is a control that does nothing.
-->
<div class="page-blog-filters" v-if="activeFilter">
<w-icon name="la:filter" size="sm" />
<span class="pl-2">{{ activeFilter }}</span>
<w-btn
class="ml-2"
flat
dense
size="sm"
no-caps
icon="la:times"
color="primary"
:label="t(`common.blog.clearFilter`)"
@click="clearFilter" />
</div>
<div class="page-blog-status" v-if="blogState.loading && !blogState.loaded">
{{ t('common.blog.loading') }}
</div>
<div class="page-blog-status" v-else-if="blogState.failed">
{{ t('common.blog.loadFailed') }}
</div>
<template v-else>
<!--
An empty blog is reported as an empty blog, and an empty FILTER as an empty filter: they are
different situations and only one of them is something the reader did. The first is also where
a blog whose posts have been moved out from under it ends up -- there is no row anywhere
recording that, so naming the path is the only thing that makes it legible.
-->
<div class="page-blog-empty" v-if="blogState.posts.length < 1">
<w-icon class="page-blog-empty-icon" name="la:newspaper" />
<template v-if="activeFilter">
<div class="text-h6">{{ t('common.blog.noMatches') }}</div>
<w-btn
class="mt-4"
outline
no-caps
color="primary"
padding="xs md"
:label="t(`common.blog.clearFilter`)"
@click="clearFilter" />
</template>
<template v-else>
<div class="text-h6">{{ t('common.blog.empty') }}</div>
<div class="text-body2 mt-1 opacity-60">
{{ t('common.blog.emptyHint', { path: `/${pageStore.path}/` }) }}
</div>
<!--
The way out of an empty blog, for whoever can take it. Under the sentence that says what
a post IS, because it is that sentence acted on: the menu writes the new page under this
blog's path, which is the whole of what makes it a post.
Only under the empty BLOG. The filtered branch above is empty because of something the
reader did, and the answer there is to undo it rather than to write a post.
-->
<w-btn
class="mt-6"
v-if="canWriteHere"
unelevated
no-caps
icon="la:plus"
color="primary"
padding="xs md"
:label="t(`common.blog.newPost`)">
<page-new-menu hide-asset-btn :only="POST_EDITORS" :base-path="pageStore.path" />
</w-btn>
</template>
</div>
<template v-else>
<div class="page-blog-list" :class="`is-` + settings.layout">
<router-link
class="page-blog-post"
v-for="(post, index) of blogState.posts"
:key="post.id"
:to="postLink(post)">
<!--
The card's lid: a band of brand colour across the top of it, holding the post's icon.
One element for both layouts rather than two -- in `list` it is `display: contents` and
leaves the layout entirely, so the icon is the flex item beside the text it always was.
It goes with the icon rather than standing on its own, since a band whose only content
is switched off is a stripe of colour saying nothing.
-->
<span class="page-blog-post-media" v-if="settings.show.icon">
<!-- -> The size is a prop rather than a class: `WIcon` writes it as an inline style,
which beats anything a rule here could say -->
<w-icon
class="page-blog-post-icon"
:size="iconSizeFor(index)"
:name="post.icon || defaultPageIcon" />
</span>
<span class="page-blog-post-body">
<span class="page-blog-post-title">{{ post.title }}</span>
<span
class="page-blog-post-desc"
v-if="settings.show.description && post.description">
{{ post.description }}
</span>
<!--
Who wrote it and when, on one line under the text: they are the byline, and two lines
for two short facts is most of a card spent on its footer. Separated only where both
are on, so a blog showing one of them has no orphan bullet.
-->
<span class="page-blog-post-meta" v-if="settings.show.date || settings.show.author">
<span v-if="settings.show.date">{{ publishedOn(post) }}</span>
<span class="page-blog-post-sep" v-if="settings.show.date && settings.show.author"
>&middot;</span
>
<span v-if="settings.show.author && post.authorName">{{ post.authorName }}</span>
</span>
<span class="page-blog-post-tags" v-if="settings.show.tags && post.tags.length > 0">
<!--
Plain text, not links: a tag here is inside the link to the post, and an anchor
inside an anchor is invalid HTML that browsers unnest -- the filter is picked from
the column beside the listing instead, where it can be a control of its own.
-->
<span class="page-blog-post-tag" v-for="tag of post.tags" :key="tag">{{
tag
}}</span>
</span>
</span>
</router-link>
</div>
<!--
Only once there is more than one page of the blog. The count above it says what the reader
is looking at, because a bare row of numbers does not say how many posts they stand for.
-->
<div class="page-blog-pager" v-if="blogState.pageCount > 1">
<w-pagination
:model-value="blogState.page"
:max="blogState.pageCount"
:max-pages="7"
boundary-numbers
direction-links
:aria-label="t(`common.blog.pagination`)"
@update:model-value="goToPage" />
<div class="page-blog-count">
{{ t('common.blog.count', blogState.total, { count: blogState.total }) }}
</div>
</div>
<!--
The one thing a listing cannot do quietly: a blog past the ceiling is served as a prefix of
itself, and a reader paging to the end of it would otherwise be told they had reached the
end of the blog.
-->
<div class="page-blog-truncated" v-if="blogState.truncated">
<w-icon name="la:exclamation-triangle" />
<div class="pl-3">{{ t('common.blog.truncated') }}</div>
</div>
</template>
</template>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { blogState, loadBlog, resetBlog } from '@/composables/blog'
import { parseBlog } from '@/helpers/pageBlog'
import { blogFilterFromQuery } from '@/helpers/blogFilter'
import { DEFAULT_PAGE_ICON, usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import PageNewMenu from '@/components/PageNewMenu.vue'
/**
* What a post may be written with, offered by the New Post button on an empty blog.
*
* The editors that author an ARTICLE, which is what a post is. Not `redirect` and not `blog` — both
* write a page with no body, and `models/blogs.ts` does not count either as a post: a redirection is
* a doorway and a nested blog is its own blog, so creating one here would add nothing to this
* listing. Filtered against what the site has enabled, so this is a ceiling and not a list.
*/
const POST_EDITORS = ['markdown', 'visual']
/**
* A blog's front page: its posts, rather than an article.
*
* Drawn by the page view in place of the content, the way `PageRedirect.vue` is — but inside the
* scrolling column rather than instead of it, because a blog is a destination: a reader stays on it,
* scrolls it and reaches the footer at the bottom of it.
*
* **Which pages are posts is not decided here and cannot be.** A post is a page under this page's
* path, and which of those this reader may open is a page rule resolved on the server — so this
* component draws what `api/blogs.ts` hands it and filters nothing. See `models/blogs.ts`.
*
* The reader's selection lives in the query string (`?tag=…&year=…&month=…&p=…`) and not in the path,
* because the path namespace under a blog belongs to its posts: `/my-blog/2026/03` is a page somebody
* may well have written. It also makes a filtered blog a link that can be handed to somebody, which
* is what `/_tags?t=a,b` does for the same reason.
*/
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// ROUTER
const route = useRoute()
const router = useRouter()
// I18N
const { t } = useI18n()
const defaultPageIcon = DEFAULT_PAGE_ICON
// COMPUTED
/** How this blog is set up, which is the front page's content. See `helpers/pageBlog.js`. */
const settings = computed(() => parseBlog(pageStore.content))
/**
* Whether this reader may write a post here.
*
* `write:pages` from the PAGE rules and not from the group-wide list, which is the same check the
* missing-page screen makes and the same one the create endpoint will make — the group-wide list
* answers "may write pages somewhere", which is how a button ends up leading to a 403.
*
* Asked at the blog's own path, since that is where this reader is standing and a post goes directly
* under it. A rule written deeper could still refuse one particular path, and the editor's own save
* is what answers that.
*/
const canWriteHere = computed(() => userStore.pagePermissions.includes('write:pages'))
/** The reader's selection, as the URL carries it. */
const filter = computed(() => blogFilterFromQuery(route.query))
/**
* What the blog is currently narrowed to, in words, or null for a blog showing everything.
*
* One line rather than a chip per part, because the parts are not separately removable: a month
* without its year means nothing, so the way out is out of all of it at once.
*/
const activeFilter = computed(() => {
const parts = []
if (filter.value.tag) {
parts.push(t('common.blog.filterTag', { tag: filter.value.tag }))
}
if (filter.value.year) {
parts.push(
filter.value.month
? monthLabel(filter.value.year, filter.value.month)
: String(filter.value.year)
)
}
return parts.length > 0 ? parts.join(' · ') : null
})
// METHODS
/**
* How big a post's icon is drawn.
*
* A prop and not a rule, because `WIcon` writes the size as an inline `font-size` — the stylesheet
* cannot reach it, so this is the only place the featured card's icon can be made bigger. Which
* card is featured is the same answer the stylesheet gives: the first one of a `cards` listing. A
* `list` listing has no featured row, and every other card keeps the one size.
*
* Note it does not follow the breakpoint the LAYOUT does. Under 600px the featured card puts its
* panel back across the top, and the big icon goes with it into a taller band — an inline style has
* no media query, and a band that says "this one is the lead" is the right answer there anyway.
*/
function iconSizeFor(index) {
return index === 0 && settings.value.layout === 'cards' ? '56px' : '28px'
}
/** Where a post lives, as a route on this site — locale prefix included where the site uses one. */
function postLink(post) {
return `${siteStore.localeUrlPrefix(pageStore.locale)}/${post.path}`
}
/** A post's publication date, as a reader reads a date rather than as the column stores one. */
function publishedOn(post) {
return Temporal.Instant.from(post.publishedAt).toLocaleString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
/**
* A month, named. Built from a date at noon UTC rather than midnight: the archive counts months in
* UTC (see `facetsFor` on the server), and midnight on the first falls into the previous month for
* every reader west of Greenwich.
*/
function monthLabel(year, month) {
return new Date(Date.UTC(year, month - 1, 15)).toLocaleString(undefined, {
year: 'numeric',
month: 'long',
timeZone: 'UTC'
})
}
/** Drop every narrowing, which also takes the reader back to the first page of the blog. */
function clearFilter() {
router.push({ path: route.path })
}
/** Move within the current filter, so paging never silently widens what is being paged through. */
function goToPage(page) {
const query = { ...route.query }
if (page > 1) {
query.p = String(page)
} else {
delete query.p
}
router.push({ path: route.path, query })
}
function load() {
if (!pageStore.path && pageStore.path !== '') {
return
}
loadBlog({
siteId: siteStore.id,
path: pageStore.path,
locale: pageStore.locale,
filter: filter.value
})
}
// MOUNTED
onMounted(load)
/*
Both halves of "which listing is this": the page, because a reader can walk from one blog to another
without this component being torn down, and the filter, because narrowing a blog is a navigation
that leaves the component in place.
*/
watch(
() => [pageStore.id, route.query.tag, route.query.year, route.query.month, route.query.p],
load
)
/*
Emptied on the way out rather than left standing. The listing is a module singleton shared with the
sidebar, so a stale one would be a tag cloud drawn beside the next page the reader opens.
*/
onBeforeUnmount(resetBlog)
</script>
<style lang="scss">
.page-blog {
/*
Centred in the column rather than filling it. This is a listing and not prose: stretched across a
full-width window `is-cards` reaches four columns and `is-list` draws a one-line title on a band
the width of the screen. The cap is set so that a wide screen still gets the three columns the
360px floor was chosen for, and no more.
On the block rather than on the listing, so the intro above it and the pager below sit over the
same measure -- a paragraph starting at the column's edge over a centred grid is the half-centred
layout this is avoiding. The sidebar, where a blog asks for one, is outside this element and is
unaffected.
*/
max-width: 1200px;
margin: 0 auto;
/*
Stated per theme, for the same reason `.page-placeholder` states it: this block sits BESIDE
`.page-contents` rather than inside it, so none of the ink `_page-contents.scss` declares reaches
it and it would inherit the document's black and go invisible on the dark surface. Everything
below that carries no colour of its own -- the empty state and its icon, the filter bar, the
status line, the count under the pager -- reads off this one.
*/
@at-root .body--light & {
color: $grey-9;
/*
The hairline closing a card's gradient panel, which an ordinary card draws along its bottom and
the featured card down its right. One property rather than the pair written twice per edge: the
two edges are one line, and a value that has to agree in four places is a value that eventually
will not.
Light: white, a highlight along the edge of the gradient.
*/
--blog-lid-seam: #{'#fff'};
}
@at-root .body--dark & {
color: #fff;
/*
Dark: the page's own background behind the card -- `$dark-6`, set on `body.body--dark` in
`MainLayout.vue`, since nothing between the body and the article column paints over it. So the
line reads as a sliver of the page showing through rather than as a border drawn on the card,
which is what white was doing there: a bright rule across a dark card, and the first thing the
eye landed on in the whole listing.
*/
--blog-lid-seam: #{$dark-6};
}
/* -> The author's own words, in the article's voice rather than in the listing's */
&-intro {
margin: 0 0 24px;
white-space: pre-line;
font-size: 1rem;
line-height: 1.6;
@at-root .body--light & {
color: rgba(0, 0, 0, 0.75);
}
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.75);
}
}
&-filters {
display: flex;
align-items: center;
margin-bottom: 16px;
padding: 8px 12px;
border-radius: 4px;
font-size: 0.85rem;
@at-root .body--light & {
background-color: $grey-2;
}
@at-root .body--dark & {
background-color: $dark-4;
}
}
&-status {
padding: 48px 0;
text-align: center;
font-size: 0.875rem;
opacity: 0.6;
}
&-empty {
padding: 56px 16px;
text-align: center;
&-icon {
font-size: 64px;
opacity: 0.2;
}
}
/* ---------------------------------------------------------------- */
/* The three layouts. Same markup, three ways of arranging it -- the */
/* post is a link with an icon and a stack of text in every one. */
/* ---------------------------------------------------------------- */
&-list {
display: grid;
gap: 12px;
/*
One column per row otherwise, which is `list` and needs no rule of its own.
The floor is what decides how many columns fit, and it is deliberately high: a post entry is a
title, a blurb and a byline, all of which read badly in a narrow column, and `auto-fill` will
otherwise put four of them across a wide screen. At this width a normal window gets two and a
very wide one three, and the cards stay wide enough for a description to be worth showing.
*/
&.is-cards {
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
}
}
&-post {
display: flex;
align-items: flex-start;
padding: 14px 16px;
border-radius: 5px;
text-decoration: none;
transition:
background-color 0.2s ease,
box-shadow 0.2s ease;
@at-root .body--light & {
background-color: $grey-1;
border: 1px solid rgba(0, 0, 0, 0.07);
color: rgba(0, 0, 0, 0.87);
}
@at-root .body--dark & {
background-color: $dark-4;
border: 1px solid rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.85);
}
&:hover {
@at-root .body--light & {
background-color: $grey-2;
}
@at-root .body--dark & {
background-color: $dark-3;
}
}
/*
A card stacks its lid over its text; a row puts icon and text side by side.
The padding moves off the card and onto the body, because the lid is drawn to the card's four
edges and cannot be inset by it -- cancelling it with a negative margin instead would be the
same figure written twice, in two places that have to agree. `overflow` is what then hands the
lid the rounded corners it is sitting in.
`align-items` has to be restated: the base rule starts the icon at the TOP of a row, and the
same property means the LEFT of a column -- so left alone it sizes the lid to the icon in it
rather than to the card. Stretch is what a stacked card wants throughout: the lid spans it, and
the text below fills it instead of being as wide as its longest line.
*/
@at-root .page-blog-list.is-cards & {
flex-direction: column;
align-items: stretch;
overflow: hidden;
padding: 0;
/*
A card is lifted off the page; a list row is not. Two layers rather than one -- a tight
near-black edge that reads as the card's own weight, and a wider soft one that is the light
falling past it -- because a single blurred shadow at this opacity reads as a grey smudge
under the card rather than as depth.
Deeper in the dark theme for the same apparent subtlety: shadow is black on both, and against
`dark-4` the light theme's opacities are not a faint shadow but no shadow at all.
*/
@at-root .body--light & {
box-shadow:
0 1px 2px rgb(0 0 0 / 0.06),
0 2px 6px rgb(0 0 0 / 0.05);
}
@at-root .body--dark & {
box-shadow:
0 1px 2px rgb(0 0 0 / 0.3),
0 2px 6px rgb(0 0 0 / 0.25);
}
/*
The first post, across every column: the featured card.
`1 / -1` counts to the last line of the EXPLICIT grid, which `auto-fill` still defines -- the
track count comes from the template, so this is the whole row however many columns the window
got. Narrow enough for one column it is already the full width and the rule changes nothing.
It is the first card of whatever is on screen rather than the first post of the blog, so page
two of the listing leads with one too. That is the layout staying put as a reader pages
through it: a grid whose top row is wide on one page and not on the next reads as something
having gone wrong, and the card is a shape here rather than a claim about the post in it.
*/
&:first-child {
grid-column: 1 / -1;
/*
And it lays its panel down the LEFT rather than across the top. A full-width card is wide
enough that a band over it is a stripe with a lot of nothing under it; beside the text it
is a panel, which is what the extra width bought.
Back to a band below `xs`, where a third of a phone-width card is about 110px of gradient
and the text is left with 220px to hold a title, a blurb, a byline and its tags. The
featured card keeps its full width there -- the grid is one column anyway -- and only the
arrangement inside it goes back to the stacked one.
*/
@media (min-width: #{$breakpoint-xs-max + 0.02px}) {
flex-direction: row;
}
}
/*
Further off the page under the pointer: the soft layer alone, pushed down and spread wide,
which is what a shadow does as its caster rises.
The tight layer goes rather than growing with it. It is a contact shadow -- the dark seam
where an object meets the surface it is resting on -- and a card that has lifted is no longer
resting on anything, so kept underneath it reads as a hard line drawn under the card rather
than as height.
White with it, up from `grey-1`: a card that has risen off the page catches more light, so it
brightens as it lifts. That is the light theme's answer and not the dark theme's -- white on
`dark-4` is not a lift but an inversion, and the card's own ink is white. The dark theme keeps
its step towards the lighter surface, which is the same move in its own register.
*/
@at-root .body--light &:hover {
background-color: #fff;
box-shadow: 0 6px 16px rgb(0 0 0 / 0.08);
}
@at-root .body--dark &:hover {
box-shadow: 0 6px 16px rgb(0 0 0 / 0.32);
}
}
/*
The lid. Out of the way in the list layout: `display: contents` draws the children and no box,
so the icon below is a flex item of the card itself and every rule in this block is inert.
*/
&-media {
display: contents;
@at-root .page-blog-list.is-cards & {
display: flex;
align-items: center;
justify-content: flex-start;
/* -> 16px at the left, which is the body's own inset below: the icon sits over the title
rather than a few pixels off it */
padding: 18px 16px;
/*
Lit from the bottom-right corner: an ellipse centred there, the lighter brand shade at the
corner falling to the brand colour as it spreads back across the card.
The custom properties rather than the SCSS `$primary` beside them -- a site themes itself by
rewriting `--q-primary` at runtime (see `css/tailwind.css`), which a value compiled into the
stylesheet cannot follow. `primary-lighter` is itself a `color-mix` off the same property,
so both ends of the gradient re-theme together.
*/
background: radial-gradient(
ellipse at bottom right,
var(--color-primary-lighter),
var(--color-primary)
);
/* -> The hairline closing the lid; the colour is per theme, see `--blog-lid-seam` above */
border-bottom: 1px solid var(--blog-lid-seam);
}
/*
The featured card's panel: a third of the card, down its left, full height.
`0 0 33.333%` rather than `1 1` -- a third is the figure, not a starting point, so the panel
does not grow into the space a short title leaves. The seam turns the corner with it: the
same line, drawn on the edge that now divides the two halves.
*/
@at-root .page-blog-list.is-cards .page-blog-post:first-child & {
@media (min-width: #{$breakpoint-xs-max + 0.02px}) {
flex: 0 0 33.333%;
border-right: 1px solid var(--blog-lid-seam);
border-bottom: 0;
}
/*
And it is the SECONDARY colour, which is what separates the lead card from the listing
under it at a glance -- the width says it is different, the hue says which one it is.
Outside the media query, unlike the arrangement above: below `xs` the panel goes back to a
band across the top, and it is still the featured card while it does.
*/
background: radial-gradient(
ellipse at bottom right,
var(--color-secondary-lighter),
var(--color-secondary)
);
}
}
&-icon {
flex: 0 0 auto;
margin-right: 14px;
color: $primary;
/* -> On the gradient rather than on the card, so it is drawn in the surface's own ink */
@at-root .page-blog-list.is-cards & {
margin: 0;
color: #fff;
}
}
&-body {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
/* -> The padding the card gave up, so the text keeps the inset the lid may not have */
@at-root .page-blog-list.is-cards & {
padding: 14px 16px;
}
}
&-title {
font-weight: 500;
line-height: 1.35;
/*
A step up on a card, where the title is the thing being scanned and the rest of the card is
support: it inherits the app's 14px otherwise, which is the same size as the body text a row
in the `list` layout sits in a line of. 1rem is 16px against that 14 -- stated in `rem` like
the description and byline below it, which are 0.8 and 0.75 of the same unit.
A list row keeps the inherited size. There the title IS the row, with nothing above it to be
distinguished from.
*/
@at-root .page-blog-list.is-cards & {
font-size: 1rem;
}
/*
One step further on the featured card, which is the only entry in the listing whose title is
competing with anything: it sits beside a third of a card of colour and an icon at twice the
size, and at the same 16px as the grid below it read as a caption to the panel rather than as
the lead.
A step and not a headline -- these are all one listing, and a title that jumps to a heading
size stops reading as the first of twenty-five posts and starts reading as a section above
them.
*/
@at-root .page-blog-list.is-cards .page-blog-post:first-child & {
font-size: 1.25rem;
}
}
&-desc {
margin-top: 2px;
font-size: 0.8rem;
line-height: 1.4;
opacity: 0.7;
}
&-meta {
margin-top: 6px;
font-size: 0.75rem;
opacity: 0.55;
}
&-sep {
padding: 0 6px;
}
&-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
&-tag {
padding: 1px 7px;
border-radius: 3px;
font-size: 0.7rem;
@at-root .body--light & {
background-color: $grey-3;
}
@at-root .body--dark & {
background-color: $dark-2;
}
}
}
&-pager {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-top: 28px;
}
&-count {
font-size: 0.75rem;
opacity: 0.55;
}
&-truncated {
display: flex;
align-items: flex-start;
margin-top: 20px;
padding: 12px 16px;
border-radius: 4px;
background-color: rgba(255, 152, 0, 0.12);
font-size: 0.8rem;
line-height: 1.4;
color: $orange-9;
@at-root .body--dark & {
color: $orange-3;
}
}
}
</style>

@ -0,0 +1,318 @@
<template>
<div class="page-blog-sidebar">
<!-- Tags -->
<template v-if="showTags">
<div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:tags" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.blog.tags') }}</div>
</div>
<div class="px-4 pb-4">
<!--
A cloud rather than a list: the counts are what make a blog's tags worth scanning, and a
column of rows carrying one number each is a lot of height for that. The selected one stays
in place and reads as pressed, because it is also the way back out of itself.
-->
<router-link
class="page-blog-tag"
v-for="entry of blogState.facets.tags"
:key="entry.tag"
:class="{ 'is-active': filter.tag === entry.tag }"
:to="tagLink(entry.tag)">
{{ entry.tag }}
<span class="page-blog-tag-count">{{ entry.count }}</span>
</router-link>
</div>
</template>
<!-- Archive -->
<template v-if="showArchive">
<w-separator v-if="showTags" />
<div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:calendar-alt" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.blog.archive') }}</div>
</div>
<div class="px-4 pb-4">
<!--
Years, each opening onto its own months. The year is a filter in its own right as well as a
heading -- a reader wanting "everything from 2025" should not have to pick twelve months --
so the row is a link and the chevron beside it is what opens the list.
-->
<div class="page-blog-year" v-for="year of years" :key="year.year">
<div class="page-blog-year-row">
<!-- -> `color` and not the inherited text colour: this column states its own ink per
theme for the rows, and a button left to inherit drew the chevron in the
document's black and vanished against the dark surface -->
<w-btn
class="page-blog-year-toggle"
flat
dense
size="sm"
color="grey"
:icon="isOpen(year.year) ? `la:angle-down` : `la:angle-right`"
:aria-label="t(`common.blog.toggleYear`, { year: year.year })"
:aria-expanded="isOpen(year.year)"
@click="toggleYear(year.year)" />
<router-link
class="page-blog-month is-year"
:class="{ 'is-active': filter.year === year.year && !filter.month }"
:to="archiveLink(year.year, null)">
{{ year.year }}
<span class="page-blog-month-count">{{ year.count }}</span>
</router-link>
</div>
<div class="page-blog-months" v-if="isOpen(year.year)">
<router-link
class="page-blog-month"
v-for="entry of year.months"
:key="entry.month"
:class="{ 'is-active': filter.year === year.year && filter.month === entry.month }"
:to="archiveLink(year.year, entry.month)">
{{ monthName(entry.month) }}
<span class="page-blog-month-count">{{ entry.count }}</span>
</router-link>
</div>
</div>
</div>
</template>
<!--
A blog with nothing in it yet has neither a tag nor a month, so both sections above are empty
and the column would be a blank strip beside the listing. It says why instead.
-->
<div class="p-4 text-caption text-grey-6" v-if="!showTags && !showArchive && blogState.loaded">
{{ t('common.blog.noFacets') }}
</div>
</div>
</template>
<script setup>
import { computed, reactive } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { blogState } from '@/composables/blog'
import { blogFilterFromQuery, blogFilterQuery } from '@/helpers/blogFilter'
import { parseBlog } from '@/helpers/pageBlog'
import { usePageStore } from '@/stores/page'
/**
* The column beside a blog's listing: what the blog is about, and when it was written.
*
* It replaces the contents/tags/rating column on a blog's front page, which has none of those to
* offer — there are no headings to list, the front page's own tags are not the blog's, and a page
* with no body is not something to rate.
*
* The facets come from the same request the listing does (`composables/blog.js`), so a count here can
* never disagree with the posts next to it — and they describe the WHOLE blog rather than the
* filtered set, which is what keeps a tag from disappearing the moment it is picked and stranding the
* reader inside a filter they cannot see the way out of.
*/
// STORES
const pageStore = usePageStore()
// ROUTER
const route = useRoute()
// I18N
const { t } = useI18n()
// DATA
/**
* Which years are open, by year.
*
* Local rather than in the URL: it is how the reader is looking at the list, not what they are
* looking at, so it has no business in a link somebody else opens.
*/
const openYears = reactive(new Set())
// COMPUTED
const settings = computed(() => parseBlog(pageStore.content))
const filter = computed(() => blogFilterFromQuery(route.query))
/** Both halves are asked twice: the blog has to want the section AND have something to put in it. */
const showTags = computed(() => settings.value.sidebar.tags && blogState.facets.tags.length > 0)
const showArchive = computed(
() => settings.value.sidebar.archive && blogState.facets.archive.length > 0
)
/**
* The archive as years holding months, newest first.
*
* The server answers one row per month, which is the shape a count comes in; a reader browses by
* year, so the grouping is done here rather than sent twice. The year's own count is its months
* added up, so the two can never say different things.
*/
const years = computed(() => {
const grouped = new Map()
for (const entry of blogState.facets.archive) {
if (!grouped.has(entry.year)) {
grouped.set(entry.year, { year: entry.year, count: 0, months: [] })
}
const year = grouped.get(entry.year)
year.count += entry.count
year.months.push({ month: entry.month, count: entry.count })
}
return [...grouped.values()]
.sort((a, b) => b.year - a.year)
.map((year) => ({ ...year, months: year.months.sort((a, b) => b.month - a.month) }))
})
// METHODS
/**
* Whether a year's months are shown.
*
* The year being filtered on is always open, whether or not anybody clicked it: a reader who arrived
* on a link to March 2026 must be able to see which month of the year they are in.
*/
function isOpen(year) {
return openYears.has(year) || filter.value.year === year
}
function toggleYear(year) {
if (openYears.has(year)) {
openYears.delete(year)
} else {
openYears.add(year)
}
}
/** Picking the tag already selected clears it, so the cloud is its own way back out. */
function tagLink(tag) {
const next = filter.value.tag === tag ? null : tag
return { path: route.path, query: blogFilterQuery({ ...filter.value, tag: next }) }
}
/** Likewise for a month, and for a year picked while that year is already the whole selection. */
function archiveLink(year, month) {
const isCurrent = filter.value.year === year && (filter.value.month ?? null) === month
return {
path: route.path,
query: blogFilterQuery({
tag: filter.value.tag,
year: isCurrent ? null : year,
month: isCurrent ? null : month
})
}
}
/**
* A month's name on its own. Built from the 15th rather than the 1st: the archive counts months in
* UTC, and midnight on the first falls into the previous month for every reader west of Greenwich.
*/
function monthName(month) {
return new Date(Date.UTC(2000, month - 1, 15)).toLocaleString(undefined, {
month: 'long',
timeZone: 'UTC'
})
}
</script>
<style lang="scss">
.page-blog-sidebar {
/* -> A cloud: every tag inline, wrapping, with its count tucked behind it */
.page-blog-tag {
display: inline-flex;
align-items: baseline;
margin: 0 4px 4px 0;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.75rem;
text-decoration: none;
transition: background-color 0.2s ease;
@at-root .body--light & {
background-color: $grey-3;
color: rgba(0, 0, 0, 0.75);
}
@at-root .body--dark & {
background-color: $dark-3;
color: rgba(255, 255, 255, 0.75);
}
&:hover {
@at-root .body--light & {
background-color: $grey-4;
}
@at-root .body--dark & {
background-color: $dark-2;
}
}
&.is-active {
background-color: $primary;
color: #fff;
}
&-count {
padding-left: 5px;
font-size: 0.65rem;
opacity: 0.6;
}
}
.page-blog-year {
&-row {
display: flex;
align-items: center;
}
/* -> Squared off and small: it is a disclosure triangle, not a button in its own right */
&-toggle {
flex: 0 0 auto;
min-width: 0;
margin-right: 2px;
padding: 0 2px;
}
}
.page-blog-months {
padding-left: 26px;
}
.page-blog-month {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 3px 6px;
border-radius: 3px;
font-size: 0.78rem;
text-decoration: none;
@at-root .body--light & {
color: rgba(0, 0, 0, 0.75);
}
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.75);
}
&:hover {
@at-root .body--light & {
background-color: $grey-3;
}
@at-root .body--dark & {
background-color: $dark-3;
}
}
&.is-active {
color: $primary;
font-weight: 500;
}
&.is-year {
flex: 1 1 auto;
font-weight: 500;
}
&-count {
padding-left: 8px;
font-size: 0.68rem;
opacity: 0.55;
}
}
}
</style>

@ -6,12 +6,10 @@
the rule lives, so that what can be created here and what a search can be filtered by cannot the rule lives, so that what can be created here and what a search can be filtered by cannot
drift apart. Each still carries its own icon and wording: `redirect` makes a redirection drift apart. Each still carries its own icon and wording: `redirect` makes a redirection
rather than a page, and both Markdown and Visual say which of the two they open. rather than a page, and both Markdown and Visual say which of the two they open.
Narrowed by `only` where a caller has a reason to offer fewer -- see the prop.
--> -->
<w-item <w-item v-for="editor of offeredEditors" :key="editor" clickable @click="create(editor)">
v-for="editor of siteStore.activeEditors"
:key="editor"
clickable
@click="create(editor)">
<blueprint-icon :icon="EDITOR_ICONS[editor]" /> <blueprint-icon :icon="EDITOR_ICONS[editor]" />
<w-item-section class="pr-2">{{ t(`common.createPage.${editor}`) }}</w-item-section> <w-item-section class="pr-2">{{ t(`common.createPage.${editor}`) }}</w-item-section>
</w-item> </w-item>
@ -34,6 +32,7 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
@ -77,6 +76,19 @@ const props = defineProps({
type: String, type: String,
default: null default: null
}, },
/**
* Offer only these editors, where the menu is opened somewhere that not every kind of page makes
* sense — a blog's New Post button, which wants the editors that write an article and not the ones
* that write a redirection or a second blog.
*
* A filter over `siteStore.activeEditors` rather than a list drawn instead of it, so a site that
* has switched an editor off still does not see it here. Empty means no restriction, which is what
* every other caller wants.
*/
only: {
type: Array,
default: () => []
},
/** /**
* The locale to write the new page in. The page store's current one when absent, which is right * The locale to write the new page in. The page store's current one when absent, which is right
* from the page view and wrong from the file manager -- there the reader is looking at whichever * from the page view and wrong from the file manager -- there the reader is looking at whichever
@ -102,6 +114,15 @@ const siteStore = useSiteStore()
const { t } = useI18n() const { t } = useI18n()
// COMPUTED
/** What this menu actually offers: the site's editors, narrowed by `only` where one was given. */
const offeredEditors = computed(() =>
props.only.length > 0
? siteStore.activeEditors.filter((editor) => props.only.includes(editor))
: siteStore.activeEditors
)
// METHODS // METHODS
async function create(editor) { async function create(editor) {

@ -0,0 +1,86 @@
import { reactive } from 'vue'
/**
* The listing of the blog currently on screen.
*
* A module singleton, because the blog is drawn in two places at once and they are one answer: the
* article column shows the posts, and the column beside it shows the tags and the archive to narrow
* them by. Both come out of a single request (see `api/blogs.ts` — the facets ride along with the
* posts so that a tag cloud can never disagree with the listing next to it), so a second component
* fetching for itself would be a second request and a second chance to disagree.
*
* Only one blog is ever on screen, so there is nothing to key this by: `PageBlog.vue` loads it as the
* route settles and `reset()` empties it on the way out, the same way the page store empties itself
* for a page that turns out not to be there.
*/
export const blogState = reactive({
loading: false,
/** Whether a listing has arrived, which is what separates "no posts" from "not asked yet". */
loaded: false,
/** Set when the request failed, so the column can say so rather than show an empty blog. */
failed: false,
posts: [],
total: 0,
page: 1,
pageCount: 1,
/** Every tag and month of the WHOLE blog, not of the filtered set — see `BlogListing` on the server. */
facets: { tags: [], archive: [] },
/** Whether the blog holds more posts than one request will read. */
truncated: false
})
/** Empty the listing, for a reader leaving the blog. */
export function resetBlog() {
blogState.loading = false
blogState.loaded = false
blogState.failed = false
blogState.posts = []
blogState.total = 0
blogState.page = 1
blogState.pageCount = 1
blogState.facets = { tags: [], archive: [] }
blogState.truncated = false
}
/**
* Fetch one page of a blog's listing.
*
* Every narrowing is the server's: which posts this reader may open, what the tag cloud should say
* and which page of the result they are on are all answered there, because none of them can be
* answered here without holding posts this reader was never shown.
*
* @param filter `{ tag, year, month, page }`, each optional — the reader's selection as the URL
* carries it. See `PageBlog.vue`.
*/
export async function loadBlog({ siteId, path, locale, filter = {} }) {
blogState.loading = true
blogState.failed = false
try {
const search = new URLSearchParams({ path, locale })
if (filter.tag) {
search.set('tag', filter.tag)
}
if (filter.year) {
search.set('year', String(filter.year))
if (filter.month) {
search.set('month', String(filter.month))
}
}
if (filter.page && filter.page > 1) {
search.set('page', String(filter.page))
}
const resp = await API_CLIENT.get(`sites/${siteId}/blogs/posts?${search.toString()}`).json()
blogState.posts = resp.posts ?? []
blogState.total = resp.total ?? 0
blogState.page = resp.page ?? 1
blogState.pageCount = resp.pageCount ?? 1
blogState.facets = resp.facets ?? { tags: [], archive: [] }
blogState.truncated = resp.truncated === true
blogState.loaded = true
} catch (err) {
blogState.failed = true
console.warn(err)
}
blogState.loading = false
}

@ -112,6 +112,13 @@
*/ */
--color-primary-lighter: color-mix(in srgb, var(--color-primary) 35%, white); --color-primary-lighter: color-mix(in srgb, var(--color-primary) 35%, white);
/*
The secondary colour at that same step, so the two can be used as a pair the way a gradient needs
them -- the blog's featured card is drawn in it. Mixed rather than named for the reason above: a
site that re-themes `--q-secondary` takes its lighter shade with it.
*/
--color-secondary-lighter: color-mix(in srgb, var(--color-secondary) 35%, white);
/* /*
And the sidebar's colour a shade lighter, for the one control that sits INSIDE that column and has And the sidebar's colour a shade lighter, for the one control that sits INSIDE that column and has
to read as a button against it rather than as a second accent beside it: scroll-to-top, tucked into to read as a button against it rather than as a second accent beside it: scroll-to-top, tucked into

@ -0,0 +1,54 @@
/**
* What a blog listing is narrowed to, as the URL carries it.
*
* The selection lives in the query string rather than in the path, because the path namespace under
* a blog belongs to its posts — `/my-blog/2026/03` is a page somebody may well have written, and an
* archive addressed that way would collide with it. It also makes a narrowed blog a link somebody can
* be handed, which is what `/_tags?t=a,b` does for the same reason.
*
* Both halves live here so that the component reading a filter and the component writing one cannot
* disagree about the spelling: `PageBlog.vue` reads, `PageBlogSidebar.vue` writes.
*/
/**
* Read a filter out of a route's query.
*
* Every field is optional and anything unreadable is simply absent — a filter is a reader's
* selection, and a `?year=soon` typed into the bar should show them the blog rather than an error.
* A month without a year is dropped for the same reason the server ignores it: it does not name a
* period.
*/
export function blogFilterFromQuery(query = {}) {
const year = Number.parseInt(query.year, 10)
const month = Number.parseInt(query.month, 10)
const page = Number.parseInt(query.p, 10)
const hasYear = Number.isFinite(year) && year > 0
const hasMonth = hasYear && Number.isFinite(month) && month >= 1 && month <= 12
return {
tag: typeof query.tag === 'string' && query.tag.length > 0 ? query.tag : null,
year: hasYear ? year : null,
month: hasMonth ? month : null,
page: Number.isFinite(page) && page > 1 ? page : 1
}
}
/**
* Write a filter back into a route query, leaving out everything that is not set.
*
* The page is never carried across: every one of these changes WHICH posts there are, so staying on
* page 4 of the previous selection would land the reader somewhere arbitrary in the new one — or, for
* a narrower selection, on a page that no longer exists.
*/
export function blogFilterQuery({ tag, year, month } = {}) {
const query = {}
if (tag) {
query.tag = tag
}
if (year) {
query.year = String(year)
if (month) {
query.month = String(month)
}
}
return query
}

@ -20,6 +20,11 @@ export default {
bin: { bin: {
icon: 'img:/_assets/icons/color-binary-file.svg' icon: 'img:/_assets/icons/color-binary-file.svg'
}, },
// -> Not a file extension, like `folder`, `page` and `redirect`: the type of a page whose content
// is the posts filed under it
blog: {
icon: 'img:/_assets/icons/color-blog.svg'
},
bz2: { bz2: {
icon: 'img:/_assets/icons/color-archive.svg' icon: 'img:/_assets/icons/color-archive.svg'
}, },

@ -0,0 +1,93 @@
/**
* How a blog behaves, as its front page holds it.
*
* A blog is a page authored with the `blog` editor: it has a path, a title and a place in the tree,
* and nothing to read. What an author fills in is how the blog should look, and that is its content,
* as JSON — see `BlogContent` and `normalizeBlogContent` in the backend's `models/pages.ts`, which is
* the authority on the shape and rewrites a save into it. This file is the same reading, in front of
* the author: the editor round-trips through it and the blog view follows what it returns.
*
* Which pages are the blog's POSTS is not here and is not stored anywhere — they are the pages under
* the blog's path, worked out by the server at read time. See `models/blogs.ts`.
*/
/**
* The ways a listing can draw its posts. See `BLOG_LAYOUTS` on the server for why there are two.
*
* Order matters here and not there: the editor's layout dropdown is this array mapped to labels, so
* this is the order an author reads them in, default first.
*/
export const BLOG_LAYOUTS = ['cards', 'list']
/** Which end of the blog a listing starts at. */
export const BLOG_SORTS = ['newest', 'oldest']
/** The widest a page of the listing may be set to. Mirrors `BLOG_MAX_PER_PAGE` on the server. */
export const BLOG_MAX_PER_PAGE = 100
/** As deep below itself as a blog may collect posts from. Mirrors `BLOG_MAX_DEPTH`. */
export const BLOG_MAX_DEPTH = 10
/** As long an introduction as a front page will carry. Mirrors `BLOG_MAX_INTRO`. */
export const BLOG_MAX_INTRO = 2000
/** A blog with nothing filled in, which is what a page being created starts as. */
export function emptyBlog() {
return {
// -> Cards; see `BLOG_DEFAULTS` on the server, which is the authority on every default here
layout: 'cards',
perPage: 10,
sort: 'newest',
depth: 5,
intro: '',
show: { icon: true, description: true, author: true, date: true, tags: true },
sidebar: { tags: true, archive: true }
}
}
/**
* Read a stored blog's settings. Never throws: content that is missing or unparseable comes back as
* an empty blog, which is a working blog rather than a broken screen — every field here is a display
* decision and none of them is a destination the way a redirection's target is.
*/
export function parseBlog(content) {
let parsed = null
try {
parsed = JSON.parse(content ?? '')
} catch {
// -> An empty blog is the answer; see above
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
parsed = {}
}
const defaults = emptyBlog()
const perPage = Number.parseInt(parsed.perPage, 10)
const depth = Number.parseInt(parsed.depth, 10)
return {
layout: BLOG_LAYOUTS.includes(parsed.layout) ? parsed.layout : defaults.layout,
perPage:
Number.isFinite(perPage) && perPage > 0
? Math.min(perPage, BLOG_MAX_PER_PAGE)
: defaults.perPage,
sort: BLOG_SORTS.includes(parsed.sort) ? parsed.sort : defaults.sort,
depth: Number.isFinite(depth) && depth >= 0 ? Math.min(depth, BLOG_MAX_DEPTH) : defaults.depth,
intro: typeof parsed.intro === 'string' ? parsed.intro : defaults.intro,
show: {
icon: parsed.show?.icon ?? defaults.show.icon,
description: parsed.show?.description ?? defaults.show.description,
author: parsed.show?.author ?? defaults.show.author,
date: parsed.show?.date ?? defaults.show.date,
tags: parsed.show?.tags ?? defaults.show.tags
},
sidebar: {
tags: parsed.sidebar?.tags ?? defaults.sidebar.tags,
archive: parsed.sidebar?.archive ?? defaults.sidebar.archive
}
}
}
/** The canonical spelling of a blog's settings, which is what gets saved. */
export function serializeBlog(blog = {}) {
const value = parseBlog(JSON.stringify(blog))
return JSON.stringify(value)
}

@ -1000,3 +1000,648 @@ const res = await fetch(\`/_api/sites/\${'$'}{siteId}/pages\`, {
` `
} }
] ]
/**
* The blog the sample content writes, and the posts filed under it.
*
* Separate from {@link SAMPLE_PAGES} because a blog is not a page with a body: its front page is
* authored with the `blog` editor, and what its content column holds is a settings document rather
* than markdown — so it is created differently and cannot go through the same loop untouched. See
* `helpers/pageBlog.js` for the shape, and `models/blogs.ts` for what the server does with it.
*
* Filed at `/my-blog` rather than under `/sample`, so that the one thing on the site that is a
* DESTINATION sits where a real one would: a blog's path is its address, and a reader who has to
* walk three folders down to find it is not testing what a blog does.
*
* Twenty-five posts, which is a deliberate figure: at the blog's own `perPage` of ten it is three
* pages of listing, so the pager is exercised rather than merely present.
*
* Their dates fall in twenty distinct months across two calendar years, five of those months holding
* two posts — so the sidebar's archive has two years to fold, and counts that are not all 1. Icons
* and tags vary for the same reason: a listing whose every row carries the same icon and the same
* tag says nothing about how it draws either, and a tag cloud of one tag is not a cloud. The eight
* tags are spread unevenly on purpose, since that is what a cloud is for.
*/
export const SAMPLE_BLOG = {
path: 'my-blog',
title: 'My Blog',
description: 'A blog of release notes, tips and announcements — sample content.',
icon: 'mdi:newspaper-variant-outline',
tags: ['guide'],
/**
* How the front page is set up. Passed through `serializeBlog`, so anything left out here takes
* the same default a blog created in the editor would — see `emptyBlog` in `helpers/pageBlog.js`.
*/
settings: {
layout: 'cards',
perPage: 10,
sort: 'newest',
depth: 5,
intro: `Notes from a team that does not exist, about a wiki that does.
Everything here was written by Generate Sample Content, and every post carries the \`test\` tag — Purge Sample Content takes the whole blog away again.`,
show: { icon: true, description: true, author: true, date: true, tags: true },
sidebar: { tags: true, archive: true }
}
}
/**
* @typedef {object} SampleBlogPost
* @property {string} path Absolute from the site root, without a leading slash. Under the blog.
* @property {string} title
* @property {string} description Shown in the listing under the title.
* @property {string} icon An Iconify reference, materialized before the posts are written.
* @property {string[]} tags Beside {@link SAMPLE_CONTENT_TAG}, which is added to every page.
* @property {string} publishedAt Sent as the page's `publishStartDate`, which is what a blog orders
* and dates a post by — see `BlogPost.publishedAt` in `models/blogs.ts`. Every one is in the past:
* a future date would list the post today under tomorrow's heading, which is a real thing a blog
* does and a confusing thing for a seed to do.
* @property {string} content Markdown source. The render is produced from it at generation time.
*/
/** @type {SampleBlogPost[]} */
export const SAMPLE_BLOG_POSTS = [
{
path: 'my-blog/hello-world',
title: 'Hello, world',
description: 'A first post, which is mostly an excuse to have a second one.',
icon: 'mdi:hand-wave',
tags: ['announcement'],
publishedAt: '2025-01-14T09:00:00.000Z',
content: `# Hello, world
This is the first post on a blog that exists so there is a blog to look at. If you are reading it on
a fresh instance, somebody pressed **Generate Sample Content** in the admin area and this arrived
with everything else.
A blog in this wiki is a page like any other — it has a path, a title and a place in the tree. What
makes it a blog is the editor it was written with, and what that editor stores is not an article but
a set of decisions about how to draw the pages filed underneath it.
So there is nothing to read on the front page except what its author chose to say above the listing.
That is the paragraph at the top, and this is one of the twenty-five posts under it.
`
},
{
path: 'my-blog/why-a-wiki',
title: 'Why we went back to a wiki',
description: 'Three tools became one, and the one is the boring option.',
icon: 'mdi:lightbulb-on-outline',
tags: ['announcement', 'design'],
publishedAt: '2025-02-03T10:30:00.000Z',
content: `# Why we went back to a wiki
We had notes in three places: a chat channel nobody searched, a folder of documents nobody opened,
and a handful of READMEs that were accurate for about a week after each release.
None of those failed because the tool was bad. They failed because none of them had a **path**. A
document at \`/guides/deploying\` can be linked to, bookmarked, sent to somebody new, and corrected
by the next person who notices it is wrong. A file called \`deploy-final-v2.docx\` cannot.
> [!TIP]
> If you are picking a structure, pick one you can say out loud. If the path does not survive being
> read down the phone, it will not survive being typed from memory either.
That is the whole argument. It is not exciting, which is rather the point.
`
},
{
path: 'my-blog/markdown-habits',
title: 'Five markdown habits worth picking up',
description: 'Small things that make a page easier to edit six months later.',
icon: 'mdi:language-markdown',
tags: ['tips'],
publishedAt: '2025-02-25T08:15:00.000Z',
content: `# Five markdown habits worth picking up
1. **One sentence per line.** The rendered page is identical, and every diff becomes readable.
2. **Reference links for anything used twice.** Changing a URL in one place beats finding four.
3. **Write the heading you would search for**, not the one that sounds tidy in the outline.
4. **Tables last.** If a table is hard to write in markdown, it is usually a list wearing a costume.
5. **Leave the blank line after a heading.** Some renderers do not care. Yours will, eventually.
None of these are rules. They are the five things that kept coming up in review, which is a different
and more useful list.
`
},
{
path: 'my-blog/three-oh-alpha',
title: '3.0 alpha is out',
description: 'Early, unstable, and not to be pointed at anything you care about.',
icon: 'mdi:rocket-launch-outline',
tags: ['release'],
publishedAt: '2025-03-11T16:00:00.000Z',
content: `# 3.0 alpha is out
An alpha in the sense that means it: there is no upgrade path from 2.x, the database schema changes
without notice, and a release two weeks from now may well require starting again.
## What works
- Pages, the markdown editor, and the tree
- Authentication with local accounts
- The admin area, for most values of "the admin area"
## What does not
- Anything to do with storage targets
- Search, beyond the most literal kind
- Roughly half the things the navigation offers
If that reads as a warning, it is meant to.
`
},
{
path: 'my-blog/organising-early',
title: 'Organising pages before you have too many',
description: 'The folder structure you can still change is the one worth arguing about.',
icon: 'mdi:file-tree',
tags: ['tips', 'guide'],
publishedAt: '2025-03-29T11:45:00.000Z',
content: `# Organising pages before you have too many
At thirty pages a bad structure is an afternoon's work to fix. At three hundred it is a project
nobody will ever be given time for, so the structure you have at thirty is very likely the structure
you have for ever.
The one that keeps working: **group by what somebody is trying to do**, not by which team owns it.
Teams are reorganised roughly once a year. What somebody is trying to do changes far more slowly.
\`\`\`
guides/ — how to do a thing
reference/ — what a thing is
decisions/ — why a thing is the way it is
\`\`\`
Three folders is usually enough for the first year. Add a fourth when something genuinely will not
fit, rather than in anticipation of something that might.
`
},
{
path: 'my-blog/dark-mode',
title: 'Dark mode, and why it took a while',
description: 'Two themes is not one theme with the colours swapped.',
icon: 'mdi:weather-night',
tags: ['design'],
publishedAt: '2025-04-16T13:20:00.000Z',
content: `# Dark mode, and why it took a while
The naive version took an afternoon: invert the greys, lighten the brand colour, ship it. It looked
fine in screenshots and wrong in use, and it took a while to work out why.
Shadows are the obvious case. A shadow is black in both themes, so the same \`rgba(0, 0, 0, 0.06)\`
that reads as a card lifted off a white page reads as nothing at all against a near-black one. Every
shadow needs its own value per theme, and the value is not a formula.
The subtler case is contrast between two *lit* surfaces. In a light theme a raised element is
brighter than the page. In a dark theme it is brighter too — not darker, which is what inverting
gives you. Elevation is light, and light does not invert.
`
},
{
path: 'my-blog/search-that-finds-things',
title: 'Making search that actually finds things',
description: 'Ranking is the easy half. Knowing what a reader may see is the other one.',
icon: 'mdi:magnify',
tags: ['design', 'performance'],
publishedAt: '2025-05-02T09:10:00.000Z',
content: `# Making search that actually finds things
Full-text search over a few thousand pages is a solved problem. Postgres will do it, it will do it
quickly, and the ranking will be good enough that nobody complains.
The part that is not solved by the database is **permissions**. Which pages a given reader may open
is decided by rules that match on path, locale and tags, resolved one page at a time — which is not
something you can express as a \`WHERE\` clause. So the choice is either to filter after reading, or
to leak the existence of pages somebody was never told about.
We filter after reading. It costs more, and it is the only answer that is actually correct.
`
},
{
path: 'my-blog/permissions-once',
title: 'Permissions, explained once',
description: 'Two kinds, granted separately, checked in different places.',
icon: 'mdi:shield-key-outline',
tags: ['guide'],
publishedAt: '2025-05-27T15:40:00.000Z',
content: `# Permissions, explained once
There are two kinds, and almost every confusion about this comes from treating them as one.
**Global permissions** are held across the whole site and are bound to no path. \`read:users\`,
\`manage:sites\`, \`access:admin\` — these say what somebody may do to the wiki as an installation.
**Page rule permissions** are bound to paths. \`read:pages\`, \`write:pages\`, \`manage:comments\` — these
say what somebody may do at a place in the tree, and a group grants them through rules that match
paths and tags.
> [!IMPORTANT]
> Nothing is granted by default, and where several rules match, the most specific one wins. A rule
> over \`/guides\` does not quietly extend to \`/guides-archive\`.
The practical consequence: if a permission has a path in the question, it is a page rule, and no
amount of adding global permissions will produce it.
`
},
{
path: 'my-blog/three-oh-beta',
title: '3.0 beta, and what changed',
description: 'Still no upgrade path, but the schema has stopped moving under us.',
icon: 'mdi:rocket-launch-outline',
tags: ['release'],
publishedAt: '2025-06-18T12:00:00.000Z',
content: `# 3.0 beta, and what changed
The schema is stable enough that migrations are now written rather than regenerated, which is the
real difference between the alpha and this.
## Added since the alpha
- Storage targets, with disk and git both working
- Comments, both built in and through a provider
- The audit log, and a retention setting behind it
## Changed
- Page rules are resolved per path rather than per site
- The render is stored with the page rather than produced on read
Still no upgrade path from 2.x. That is not an oversight; it is the trade that made the rest of this
possible.
`
},
{
path: 'my-blog/writing-for-arrivals',
title: 'Writing for the person who arrives from a search',
description: 'Most readers do not start at the top. Write the page they land on.',
icon: 'mdi:account-search-outline',
tags: ['tips'],
publishedAt: '2025-07-07T10:05:00.000Z',
content: `# Writing for the person who arrives from a search
Nobody reads a wiki front to back. They arrive in the middle, from a search result or a link
somebody pasted, with one question and no context.
So the first paragraph of every page has a job: say what this page is about and who it is for. Not
"Overview", not "Introduction" — an actual sentence that somebody can read and decide from.
The test is simple. Open a page at random, read only the title and the first paragraph, and ask
whether you could tell a colleague what is on it. If not, the page starts one paragraph too late.
`
},
{
path: 'my-blog/where-content-lives',
title: 'Where your content actually lives',
description: 'Written to every target that claims it, read from exactly one.',
icon: 'mdi:database-outline',
tags: ['guide'],
publishedAt: '2025-07-24T14:25:00.000Z',
content: `# Where your content actually lives
A storage target is somewhere a site keeps its content: the database, a folder on disk, a git
repository, an object store. A site can have several at once, and this is where people trip.
**Writing and reading are two separate questions.**
- An upload goes to *every* target configured to hold that kind of content. All of them, or the
upload fails.
- A reader's request is answered from *one* — the target nominated for that content type, and the
database if nobody is nominated.
Which means enabling a target does not move anything. It changes where the next upload goes, and a
target switched on today holds nothing that was uploaded yesterday.
`
},
{
path: 'my-blog/summer-roundup',
title: 'Community roundup, summer edition',
description: 'What people built, broke and fixed over the last few months.',
icon: 'mdi:account-group-outline',
tags: ['community'],
publishedAt: '2025-08-12T08:50:00.000Z',
content: `# Community roundup, summer edition
A few things worth pointing at, none of them ours.
- Somebody wrote a block that embeds a live train departure board. It is completely impractical and
we have thought about it every day since.
- Two separate people reported the same bug in folder renaming within an hour of each other, having
found it in entirely different ways. Both reports were better than our test for it.
- A translation of the admin area into Welsh landed, which took the locale count to a number we no
longer have to round down when describing it.
Thank you, genuinely. The bug reports especially — a bug somebody bothered to describe properly is
worth more than most feature requests.
`
},
{
path: 'my-blog/faster-page-loads',
title: 'Shaving a second off every page load',
description: 'Most of it was one query, and it was not the one anybody suspected.',
icon: 'mdi:speedometer',
tags: ['performance'],
publishedAt: '2025-09-01T11:30:00.000Z',
content: `# Shaving a second off every page load
The page itself was fast. The *document* was slow, and it took an embarrassing amount of profiling to
see the difference.
Every request was resolving the site's navigation tree, and the navigation tree was being rebuilt
from the page table each time rather than read from the cache it was supposedly in. The cache key
included a timestamp. It never hit. Not once, in about fourteen months.
\`\`\`diff
- const key = \`nav:\${siteId}:\${Date.now()}\`
+ const key = \`nav:\${siteId}\`
\`\`\`
Nine hundred milliseconds, on every page, for over a year. The fix is one line and the lesson is
about instrumenting cache hit rates, which we now do.
`
},
{
path: 'my-blog/accessibility-pass',
title: 'An accessibility pass over the editor',
description: 'Keyboard traps, unlabelled controls, and one very confident toolbar.',
icon: 'mdi:human',
tags: ['design'],
publishedAt: '2025-09-23T13:15:00.000Z',
content: `# An accessibility pass over the editor
We went through the editor with a keyboard and nothing else for a day. The findings were not subtle.
- The formatting toolbar was fourteen buttons with icons and no accessible names. To a screen reader
it was fourteen buttons called "button".
- Tab order went from the title field into the preview pane and back out of the document entirely,
skipping the thing you were meant to be typing in.
- The unsaved-changes dialog could be opened by keyboard and closed by nothing.
All fixed, none of them hard. The uncomfortable part is that every one of these would have been
caught by trying it once, at any point in the preceding two years.
`
},
{
path: 'my-blog/comments-arrive',
title: 'Comments arrive',
description: 'A discussion tab, a dozen providers, and one deliberate omission.',
icon: 'mdi:comment-text-outline',
tags: ['release', 'community'],
publishedAt: '2025-10-14T09:45:00.000Z',
content: `# Comments arrive
Two things wearing one name, and you pick one per site.
The **built-in** provider stores comments in this wiki, shows them on a Talk tab beside the article,
and resolves \`@handle\` mentions against real accounts. Markdown is rendered at display time with
HTML disabled outright, so nothing anybody types is ever HTML.
A **third-party** provider — Giscus, Isso, Disqus and the rest — puts somebody else's widget under
the article instead. The discussion lives in their service and this wiki only carries the snippet.
The deliberate omission is a moderation queue. A comment is accepted or it is not; there is nowhere
for one to sit and wait. That will change, and it has not yet.
`
},
{
path: 'my-blog/backup-habits',
title: 'Backup habits for small teams',
description: 'A backup you have not restored from is a hypothesis.',
icon: 'mdi:backup-restore',
tags: ['guide', 'tips'],
publishedAt: '2025-11-04T16:20:00.000Z',
content: `# Backup habits for small teams
Three things, in order of how often they are skipped.
**Back up the database and the data path together.** The database holds the pages; the data path
holds uploaded files and, if you use the git target, a working copy. Either one alone restores to a
wiki with holes in it.
**Restore one, on purpose, into somewhere else.** Quarterly is plenty. The point is not to check the
backup is valid — it is to find out how long a restore takes before the day you need to know.
**Write down where the backups are.** Not in the wiki.
> [!CAUTION]
> That last one is not a joke. We have watched a team lose an afternoon to credentials stored in the
> system they were trying to bring back.
`
},
{
path: 'my-blog/year-in-review',
title: 'The year in review',
description: 'Twelve months, one major version, and a lot of deleted code.',
icon: 'mdi:calendar-check-outline',
tags: ['announcement', 'community'],
publishedAt: '2025-12-16T10:00:00.000Z',
content: `# The year in review
The number we are most pleased with is the amount of code removed. GraphQL went, the icon webfont
went, two of the three date libraries went, and the result is a build that is smaller than it was in
January despite doing considerably more.
What shipped: storage targets, comments, the audit log, analytics, a metrics endpoint, and the app
shell that finally lets a link to a page unfurl properly when somebody pastes it into a chat.
What did not: the upgrade path from 2.x, which remains the single most-asked question and the single
hardest thing on the list.
Next year's post will say whether that changed.
`
},
{
path: 'my-blog/three-oh-final',
title: '3.0 is here',
description: 'Two years, one rewrite, and a version number that finally means something.',
icon: 'mdi:party-popper',
tags: ['release'],
publishedAt: '2026-01-20T12:00:00.000Z',
content: `# 3.0 is here
Stable, documented, and safe to point at something you care about — which is more than any previous
post on this blog has been able to say.
## The short version
- A page stores the HTML its editor produced, sanitized against what its author may embed
- Content can live in the database, on disk, in git, or in an object store, at the same time
- Permissions are two systems that no longer pretend to be one
- Everything is REST, and browsable at \`/_api\`
## The honest version
There is still no automated upgrade from 2.x. Exporting content and importing it is the path, and it
is a real afternoon of work for a large wiki. We would rather say so than ship a migration that half
works.
`
},
{
path: 'my-blog/migrating-from-2x',
title: 'Migrating from 2.x',
description: 'Export, import, fix the links. In that order, and no shortcuts.',
icon: 'mdi:swap-horizontal',
tags: ['guide'],
publishedAt: '2026-02-10T11:15:00.000Z',
content: `# Migrating from 2.x
There is no in-place upgrade. What follows is the path that works.
1. **Export from 2.x to disk.** The storage module writes your pages as markdown files with front
matter, laid out by locale and folder.
2. **Stand up 3.0 empty**, on its own database. Do not point it at the old one.
3. **Configure a disk target** at the folder you exported to, then run **Import Everything**.
4. **Fix what moved.** Users, groups and permissions do not come across — the models are different
enough that a translation would be a guess.
Budget an afternoon for a few hundred pages, and do it twice: once to find out what breaks, and once
for real.
`
},
{
path: 'my-blog/security-notes',
title: 'Security notes for self-hosters',
description: 'Four settings that matter more than everything else on the page.',
icon: 'mdi:lock-outline',
tags: ['security', 'guide'],
publishedAt: '2026-03-05T14:00:00.000Z',
content: `# Security notes for self-hosters
**Turn on \`trustProxy\` if and only if you are behind one.** It decides whether the address in
\`X-Forwarded-For\` is believed. On, with nothing in front of the wiki, anybody can claim any address
— which defeats the rate limiter. Off, behind a proxy, every request appears to come from the proxy
— which also defeats the rate limiter, more quietly.
**Check what the guests group can do.** It is the anonymous reader, and on a public wiki that is
correct. On a private one it should deny everything, and it is worth verifying rather than assuming.
**Elevated permissions are a category, not a list.** Anything that can rewrite who holds what can
grant itself the rest, in one step or two.
**Audit log retention has a floor of thirty days**, deliberately, because the permission that
shortens it belongs to exactly the person the log exists to record.
`
},
{
path: 'my-blog/blocks-deep-dive',
title: 'A deep dive into content blocks',
description: 'Web components in a page, and why they are not plugins.',
icon: 'mdi:widgets-outline',
tags: ['guide', 'design'],
publishedAt: '2026-04-01T09:30:00.000Z',
content: `# A deep dive into content blocks
A block is a web component you can put in a page. Tabs, diagrams, a map, a set of steps — things
markdown has no syntax for and never will.
They are deliberately **not** plugins. A block cannot read the wiki's data, call its API or know who
is reading; it gets its attributes and its slotted content and draws something. That boundary is why
a block can be added without a security review of what it might reach.
\`\`\`
::block-steps
1. Write the block
2. Build it
3. Reference it in a page
::
\`\`\`
Nothing is fetched until a block's tag actually appears in a page, so a block nobody uses costs a
reader nothing at all.
`
},
{
path: 'my-blog/analytics-without-creeping',
title: 'Analytics without creeping anybody out',
description: 'What a tag can see here, and what it deliberately cannot.',
icon: 'mdi:chart-line',
tags: ['guide', 'community'],
publishedAt: '2026-05-13T10:45:00.000Z',
content: `# Analytics without creeping anybody out
A provider is turned on per site, and its snippet is served in the document rather than added by the
app afterwards. That matters for two reasons: several providers verify an installation by fetching
the page and looking for their code, and a tag that arrives after boot has already missed the page
load it exists to measure.
Two deliberate limits.
**The admin area gets no tag at all.** What happens there is the wiki being configured, not read, and
it has no business in a report about readers — still less in whatever a session-replay tool would
make of somebody typing a credential into an authentication strategy.
**Nothing here can be marked sensitive.** Every value is rendered into a document served to the
public, so a setting that had to be kept out of a browser could not be used by a provider anyway.
`
},
{
path: 'my-blog/editor-shortcuts',
title: 'Editor shortcuts worth memorising',
description: 'Six of them. The rest you will look up once and forget.',
icon: 'mdi:keyboard-outline',
tags: ['tips'],
publishedAt: '2026-06-09T08:20:00.000Z',
content: `# Editor shortcuts worth memorising
| Keys | What it does |
| ---- | ------------ |
| \`Ctrl\` + \`S\` | Save, without leaving the editor |
| \`Ctrl\` + \`B\` / \`I\` | Bold, italic |
| \`Ctrl\` + \`K\` | Link, around the selection |
| \`Ctrl\` + \`/\` | Comment out the selected lines |
| \`Alt\` + \`↑\` / \`↓\` | Move the current line |
| \`Ctrl\` + \`D\` | Select the next occurrence of the selection |
The last one is the one people are most surprised by and end up using most. Renaming a term through
a long page is four keystrokes rather than a find and replace you have to check afterwards.
`
},
{
path: 'my-blog/watching-with-prometheus',
title: 'Watching a wiki with Prometheus',
description: 'One endpoint, two registries, and a path you can move.',
icon: 'mdi:gauge',
tags: ['performance', 'guide'],
publishedAt: '2026-07-21T15:10:00.000Z',
content: `# Watching a wiki with Prometheus
The metrics endpoint is off by default and its path is a setting, which is why it is a hook rather
than a route — a route table is fixed at boot and this is not.
Two kinds of number come out of it.
**Runtime metrics** are this process: memory, event loop lag, garbage collection. In a cluster a
scrape lands on whichever instance answered, which is what the \`instance\` label is for.
**Wiki metrics** are the whole installation: pages, users, comments, assets. They are database counts
built fresh per scrape, so they cost about a dozen queries — which is why they are off unless you ask
for them.
Anonymous access is decided per address class. Local, private and external are three separate
answers, and anything that is not an IP address counts as external.
`
},
{
path: 'my-blog/whats-next',
title: "What's next",
description: 'Three things being worked on, and one that is not.',
icon: 'mdi:map-marker-path',
tags: ['announcement'],
publishedAt: '2026-08-28T13:00:00.000Z',
content: `# What's next
**A moderation queue for comments.** The column is already there; what is missing is the screen and
the decision about who may see it.
**Better conflict handling on the git target.** A pull is authoritative today, which is correct and
occasionally brutal. There is room for a middle answer.
**Real-time collaborative editing**, which is further off than anybody wants and involves rather more
than turning on a library.
And one thing that is not being worked on: server-side rendering. A page's HTML is already a string
in the database, produced once when it was saved. Rendering it again on the server would solve a
problem this schema does not have.
`
}
]

@ -5,7 +5,9 @@
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-cashbook.svg" /> <img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-cashbook.svg" />
</div> </div>
<div class="min-w-0 flex-1 pl-4"> <div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.editors.title') }}</div> <div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.editors.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s"> <div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.editors.subtitle') }} {{ t('admin.editors.subtitle') }}
</div> </div>
@ -149,8 +151,11 @@ const editors = reactive([
{ {
id: 'blog', id: 'blog',
icon: 'typewriter-with-paper', icon: 'typewriter-with-paper',
isDisabled: true, /*
useRendering: true No rendering pipeline of its own: a blog's front page has no body, and its POSTS are ordinary
pages rendered by whichever editor wrote them. See `models/blogs.ts`.
*/
useRendering: false
}, },
{ {
id: 'channel', id: 'channel',
@ -195,6 +200,7 @@ async function load() {
const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json() const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json()
const data = resp?.editors const data = resp?.editors
state.config.asciidoc = data?.asciidoc?.isActive ?? false state.config.asciidoc = data?.asciidoc?.isActive ?? false
state.config.blog = data?.blog?.isActive ?? false
state.config.markdown = data?.markdown?.isActive ?? false state.config.markdown = data?.markdown?.isActive ?? false
state.config.visual = data?.visual?.isActive ?? false state.config.visual = data?.visual?.isActive ?? false
} catch (err) { } catch (err) {
@ -215,6 +221,7 @@ async function save() {
json: { json: {
editors: { editors: {
asciidoc: { isActive: state.config.asciidoc }, asciidoc: { isActive: state.config.asciidoc },
blog: { isActive: state.config.blog },
markdown: { isActive: state.config.markdown }, markdown: { isActive: state.config.markdown },
visual: { isActive: state.config.visual } visual: { isActive: state.config.visual }
} }
@ -229,6 +236,7 @@ async function save() {
siteStore.$patch({ siteStore.$patch({
editors: { editors: {
asciidoc: state.config.asciidoc, asciidoc: state.config.asciidoc,
blog: state.config.blog,
markdown: state.config.markdown, markdown: state.config.markdown,
visual: state.config.visual visual: state.config.visual
} }

@ -559,7 +559,11 @@ function purgeRevokedKeys() {
* *
* A development convenience: a fresh instance is empty, so checking a stylesheet, a renderer or the * A development convenience: a fresh instance is empty, so checking a stylesheet, a renderer or the
* navigation against anything means writing dummy pages first. Every page is tagged so * navigation against anything means writing dummy pages first. Every page is tagged so
* {@link purgeSampleContent} can take them all away again. * {@link purgeSampleContent} can take them all away again — the blog and its posts included, since
* the purge asks about the tag and nothing else.
*
* Three kinds of page come out of it: the markdown set under `/sample`, a blog at `/my-blog`, and
* the posts filed under that blog. See `SAMPLE_PAGES` and `SAMPLE_BLOG` in `helpers/sampleContent`.
* *
* The pages are written one at a time through the ordinary create endpoint — the same one the editor * The pages are written one at a time through the ordinary create endpoint — the same one the editor
* saves through — rather than by a bulk call on the server. That is what makes the content * saves through — rather than by a bulk call on the server. That is what makes the content
@ -573,11 +577,15 @@ function purgeRevokedKeys() {
* them. * them.
*/ */
async function generateSampleContent() { async function generateSampleContent() {
const { SAMPLE_CONTENT_TAG, SAMPLE_PAGES } = await import('@/helpers/sampleContent') const { SAMPLE_CONTENT_TAG, SAMPLE_PAGES, SAMPLE_BLOG, SAMPLE_BLOG_POSTS } =
await import('@/helpers/sampleContent')
const { serializeBlog } = await import('@/helpers/pageBlog')
// -> The blog's own front page as well as its posts: it is a page that gets written like any other
const total = SAMPLE_PAGES.length + 1 + SAMPLE_BLOG_POSTS.length
confirm({ confirm({
title: t('admin.utilities.generateSample'), title: t('admin.utilities.generateSample'),
message: t('admin.utilities.generateSampleConfirm', { message: t('admin.utilities.generateSampleConfirm', {
count: SAMPLE_PAGES.length, count: total,
site: siteName.value site: siteName.value
}), }),
caption: t('admin.utilities.generateSampleConfirmWarn', { tag: SAMPLE_CONTENT_TAG }), caption: t('admin.utilities.generateSampleConfirmWarn', { tag: SAMPLE_CONTENT_TAG }),
@ -608,7 +616,9 @@ async function generateSampleContent() {
Best effort: this reaches upstream, which an offline instance does not, and an icon that could Best effort: this reaches upstream, which an offline instance does not, and an icon that could
not be fetched costs a missing picture rather than a page. not be fetched costs a missing picture rather than a page.
*/ */
const icons = new Set(SAMPLE_PAGES.map((page) => page.icon)) const icons = new Set(
[SAMPLE_BLOG, ...SAMPLE_PAGES, ...SAMPLE_BLOG_POSTS].map((page) => page.icon)
)
for (const page of SAMPLE_PAGES) { for (const page of SAMPLE_PAGES) {
for (const [, name] of page.content.matchAll(/\bicon="([a-z0-9-]+:[a-z0-9-]+)"/g)) { for (const [, name] of page.content.matchAll(/\bicon="([a-z0-9-]+:[a-z0-9-]+)"/g)) {
icons.add(name) icons.add(name)
@ -620,9 +630,41 @@ async function generateSampleContent() {
console.warn(`Could not store the sample content icons: ${apiErrorMessage(err)}`) console.warn(`Could not store the sample content icons: ${apiErrorMessage(err)}`)
} }
/*
Everything to be written, in one list, because there is one way to write a page and three
kinds of page to write: the markdown set, a blog front page, and the posts filed under it.
The blog is the odd one. Its editor is `blog` and its content column holds a settings
document rather than a body, which is why it has no `render` — there is nothing to render.
`serializeBlog` is what puts those settings into the one spelling the column holds, and is
the same call the blog editor makes on save.
Its posts are ordinary markdown pages that happen to live underneath it: nothing records that
a page is a post, and being under the blog's path is the whole of what makes it one. Their
`publishStartDate` is what the listing orders and dates them by.
*/
const documents = [
...SAMPLE_PAGES.map((page) => ({
...page,
editor: 'markdown',
render: md.render(page.content, { pagePath: page.path })
})),
{
...SAMPLE_BLOG,
editor: 'blog',
content: serializeBlog(SAMPLE_BLOG.settings)
},
...SAMPLE_BLOG_POSTS.map((post) => ({
...post,
editor: 'markdown',
render: md.render(post.content, { pagePath: post.path }),
publishStartDate: post.publishedAt
}))
]
let created = 0 let created = 0
const failures = [] const failures = []
for (const page of SAMPLE_PAGES) { for (const page of documents) {
try { try {
const resp = await API_CLIENT.post(`sites/${siteId}/pages`, { const resp = await API_CLIENT.post(`sites/${siteId}/pages`, {
json: { json: {
@ -630,9 +672,10 @@ async function generateSampleContent() {
title: page.title, title: page.title,
description: page.description, description: page.description,
icon: page.icon, icon: page.icon,
editor: 'markdown', editor: page.editor,
content: page.content, content: page.content,
render: md.render(page.content, { pagePath: page.path }), ...(page.render ? { render: page.render } : {}),
...(page.publishStartDate ? { publishStartDate: page.publishStartDate } : {}),
// -> The tag the purge looks for, first, then whatever this page is about // -> The tag the purge looks for, first, then whatever this page is about
tags: [SAMPLE_CONTENT_TAG, ...page.tags], tags: [SAMPLE_CONTENT_TAG, ...page.tags],
publishState: 'published' publishState: 'published'
@ -644,7 +687,7 @@ async function generateSampleContent() {
created++ created++
} catch (err) { } catch (err) {
// -> One page at a time, and one failure does not stop the rest: a path already taken is // -> One page at a time, and one failure does not stop the rest: a path already taken is
// the likely case, and the other twenty pages are still worth having // the likely case, and the other forty-odd pages are still worth having
failures.push(`${page.path} — ${apiErrorMessage(err)}`) failures.push(`${page.path} — ${apiErrorMessage(err)}`)
} }
} }

@ -157,12 +157,38 @@
itself and what follows. itself and what follows.
--> -->
<site-banner v-if="activeView === `article`" /> <site-banner v-if="activeView === `article`" />
<!--
That this page is a post, and of what. A wiki page and a blog post look identical
otherwise -- the breadcrumbs above name the folder rather than the blog, and nothing
else on the page says a listing somewhere is drawing it.
Above the article rather than below it, because it is context for what follows; a link
rather than a label, since the blog is where the rest of the posts are.
-->
<router-link
class="page-blog-byline"
v-if="pageStore.blog && activeView === `article`"
:to="blogHref">
<w-icon name="la:newspaper" size="sm" />
<span class="pl-2">{{
t('common.blog.postedIn', { blog: pageStore.blog.title })
}}</span>
</router-link>
<!-- <!--
`v-show` rather than `v-if` on the article below, so that leaving the discussion and `v-show` rather than `v-if` on the article below, so that leaving the discussion and
coming back does not re-run the page's own scripts or lose where the reader was in it. coming back does not re-run the page's own scripts or lose where the reader was in it.
--> -->
<page-talk v-if="activeView === `talk`" /> <page-talk v-if="activeView === `talk`" />
<page-links v-if="activeView === `links`" /> <page-links v-if="activeView === `links`" />
<!--
A blog's front page, which has no article to draw: its posts are what stands in place of
one. Inside the scrolling column and not instead of it, unlike a redirection -- a blog is
somewhere a reader stays, scrolls, and reaches the footer at the bottom of.
No `activeView` test, unlike the article below: a blog shows neither tab (see
`showTalkTab`), so there is no other view for this column to be on.
-->
<page-blog v-if="isBlog" />
<!-- <!--
Delegated rather than bound per link: the anchors are written by `v-html`, so there is Delegated rather than bound per link: the anchors are written by `v-html`, so there is
nothing here to put a handler on, and they are replaced wholesale on every render. nothing here to put a handler on, and they are replaced wholesale on every render.
@ -170,7 +196,7 @@
<div <div
class="page-contents" class="page-contents"
ref="pageContents" ref="pageContents"
v-show="activeView === `article`" v-show="activeView === `article` && !isBlog"
v-html="pageStore.render" v-html="pageStore.render"
@click="onContentClick" /> @click="onContentClick" />
<!-- <!--
@ -282,35 +308,44 @@
:class="{ 'is-open': tocPanelIsOpen }" :class="{ 'is-open': tocPanelIsOpen }"
:style="siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`" :style="siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`"
@click="onSidebarClick"> @click="onSidebarClick">
<template v-if="showToc"> <!--
<!-- TOC --> A blog's front page gets its own column. It has none of what the three sections below offer
<div class="p-4 flex items-center"> -- no headings to list, no body to rate, and its own tags are the front page's rather than
<w-icon class="mr-2" name="la:stream" color="grey" /> the blog's -- and what it does have is what the blog is about and when it was written.
<!-- -> Its own string, not `common.page.toc`: this heading labels a column beside the Inside the same element, so the slide-in panel this column becomes below 750px is inherited
rather than built a second time.
-->
<page-blog-sidebar v-if="isBlog" />
<template v-else>
<template v-if="showToc">
<!-- TOC -->
<div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:stream" color="grey" />
<!-- -> Its own string, not `common.page.toc`: this heading labels a column beside the
article and reads better short, where "Table of Contents" is the full name of the article and reads better short, where "Table of Contents" is the full name of the
thing and belongs where there is room for it --> thing and belongs where there is room for it -->
<div class="text-caption text-grey-7">{{ t('common.page.contents') }}</div> <div class="text-caption text-grey-7">{{ t('common.page.contents') }}</div>
</div> </div>
<div class="px-4 pb-2"> <div class="px-4 pb-2">
<page-toc <page-toc
:nodes="pageStore.toc" :nodes="pageStore.toc"
:min-depth="pageStore.tocDepth.min" :min-depth="pageStore.tocDepth.min"
:max-depth="pageStore.tocDepth.max" :max-depth="pageStore.tocDepth.max"
v-model:selected="state.tocSelected" /> v-model:selected="state.tocSelected" />
</div> </div>
</template> </template>
<!-- Tags --> <!-- Tags -->
<template v-if="showTags"> <template v-if="showTags">
<w-separator v-if="showToc" /> <w-separator v-if="showToc" />
<div <div
class="p-4" class="p-4"
@mouseover="state.showTagsEditBtn = true" @mouseover="state.showTagsEditBtn = true"
@mouseleave="state.showTagsEditBtn = false"> @mouseleave="state.showTagsEditBtn = false">
<div class="flex items-center"> <div class="flex items-center">
<w-icon class="mr-2" name="la:tags" color="grey" /> <w-icon class="mr-2" name="la:tags" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.page.tags') }}</div> <div class="text-caption text-grey-7">{{ t('common.page.tags') }}</div>
<w-space /> <w-space />
<!-- <!--
Rendered for whoever may save the page, and hidden with `visibility` rather than Rendered for whoever may save the page, and hidden with `visibility` rather than
removed as the pointer comes and goes: `display: none` took the row's height with it, removed as the pointer comes and goes: `display: none` took the row's height with it,
so the heading jumped 6px the moment the pointer arrived. `visibility` also keeps it so the heading jumped 6px the moment the pointer arrived. `visibility` also keeps it
@ -322,41 +357,46 @@
A reader gets no button at all -- `v-if`, not the same `visibility` treatment, because A reader gets no button at all -- `v-if`, not the same `visibility` treatment, because
for them it is not a control that happens to be out of sight. for them it is not a control that happens to be out of sight.
--> -->
<w-btn <w-btn
v-if="canEditTags" v-if="canEditTags"
class="tags-edit-btn" class="tags-edit-btn"
:class="{ 'is-hidden': !state.tagEditMode && !state.showTagsEditBtn }" :class="{ 'is-hidden': !state.tagEditMode && !state.showTagsEditBtn }"
size="sm" size="sm"
padding="none xs" padding="none xs"
:icon="state.tagEditMode ? `la:check` : `la:pen`" :icon="state.tagEditMode ? `la:check` : `la:pen`"
color="deep-orange-9" color="deep-orange-9"
flat flat
:label="state.tagEditMode ? t('common.actions.exitEdit') : t('common.actions.edit')" :label="
no-caps state.tagEditMode ? t('common.actions.exitEdit') : t('common.actions.edit')
@click="state.tagEditMode = !state.tagEditMode" /> "
no-caps
@click="state.tagEditMode = !state.tagEditMode" />
</div>
<page-tags class="mt-2" :edit="state.tagEditMode" />
</div> </div>
<page-tags class="mt-2" :edit="state.tagEditMode" /> </template>
</div> <template v-if="siteStore.features.ratingsMode !== `off` && pageStore.allowRatings">
</template> <w-separator v-if="showToc || showTags" />
<template v-if="siteStore.features.ratingsMode !== `off` && pageStore.allowRatings"> <!-- Rating -->
<w-separator v-if="showToc || showTags" /> <div class="p-4 flex items-center">
<!-- Rating --> <w-icon class="mr-2" name="la:star-half-alt" color="grey" />
<div class="p-4 flex items-center"> <div class="text-caption text-grey-7">{{ t('common.page.ratePage') }}</div>
<w-icon class="mr-2" name="la:star-half-alt" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.page.ratePage') }}</div>
</div>
<div class="px-4">
<w-rating
v-if="siteStore.features.ratingsMode === `stars`"
v-model="state.currentRating"
icon="la:star"
color="secondary"
size="sm" />
<div class="flex items-center" v-else-if="siteStore.features.ratingsMode === `thumbs`">
<w-btn class="acrylic-btn" flat icon="la:thumbs-down" color="secondary" />
<w-btn class="acrylic-btn ml-2" flat icon="la:thumbs-up" color="secondary" />
</div> </div>
</div> <div class="px-4">
<w-rating
v-if="siteStore.features.ratingsMode === `stars`"
v-model="state.currentRating"
icon="la:star"
color="secondary"
size="sm" />
<div
class="flex items-center"
v-else-if="siteStore.features.ratingsMode === `thumbs`">
<w-btn class="acrylic-btn" flat icon="la:thumbs-down" color="secondary" />
<w-btn class="acrylic-btn ml-2" flat icon="la:thumbs-up" color="secondary" />
</div>
</div>
</template>
</template> </template>
</div> </div>
<!-- -> Every action on it acts on a page: there is none here to edit, share, rate or delete --> <!-- -> Every action on it acts on a page: there is none here to edit, share, rate or delete -->
@ -418,6 +458,7 @@ import {
routableHref routableHref
} from '@/helpers/renderedContent' } from '@/helpers/renderedContent'
import { flattenToc } from '@/helpers/toc' import { flattenToc } from '@/helpers/toc'
import { parseBlog } from '@/helpers/pageBlog'
import { useCommonStore } from '@/stores/common' import { useCommonStore } from '@/stores/common'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
@ -456,6 +497,16 @@ const PageLinks = defineAsyncComponent({
loader: () => import('@/components/PageLinks.vue'), loader: () => import('@/components/PageLinks.vue'),
loadingComponent: LoadingGeneric loadingComponent: LoadingGeneric
}) })
/*
A blog's two halves, likewise on demand: a page written with the `blog` editor is one page of a
wiki, and every other reader of every other page would otherwise be downloading a listing, a tag
cloud and an archive they will never see.
*/
const PageBlog = defineAsyncComponent({
loader: () => import('@/components/PageBlog.vue'),
loadingComponent: LoadingGeneric
})
const PageBlogSidebar = defineAsyncComponent(() => import('@/components/PageBlogSidebar.vue'))
const editorComponents = { const editorComponents = {
markdown: defineAsyncComponent({ markdown: defineAsyncComponent({
@ -469,6 +520,10 @@ const editorComponents = {
redirect: defineAsyncComponent({ redirect: defineAsyncComponent({
loader: () => import('../components/EditorRedirect.vue'), loader: () => import('../components/EditorRedirect.vue'),
loadingComponent: LoadingGeneric loadingComponent: LoadingGeneric
}),
blog: defineAsyncComponent({
loader: () => import('../components/EditorBlog.vue'),
loadingComponent: LoadingGeneric
}) })
} }
@ -563,6 +618,24 @@ const tocPanelIsOpen = computed(() => tocIsPanel.value && showSidebar.value && s
*/ */
const showTocPanelBtn = computed(() => tocIsPanel.value && showSidebar.value && !state.tocPanelOpen) const showTocPanelBtn = computed(() => tocIsPanel.value && showSidebar.value && !state.tocPanelOpen)
/**
* Whether the page on screen is a blog's front page, which is drawn as its posts rather than as an
* article -- see `PageBlog.vue`. A blog POST is an ordinary page and is not this.
*/
const isBlog = computed(() => pageStore.editor === 'blog')
/**
* Whether a blog wants the column beside its listing at all.
*
* Its own question, because the blog's front page decides it rather than the page properties the
* three ordinary sections answer to: an author who turned off both the tag cloud and the archive
* asked for a blog with no sidebar, and drawing an empty strip beside the posts is not that.
*/
const blogWantsSidebar = computed(() => {
const sidebar = parseBlog(pageStore.content).sidebar
return sidebar.tags || sidebar.archive
})
const showSidebar = computed(() => { const showSidebar = computed(() => {
return ( return (
pageStore.showSidebar && pageStore.showSidebar &&
@ -572,7 +645,9 @@ const showSidebar = computed(() => {
// -> Contents, tags and a rating, all of a page that is not there // -> Contents, tags and a rating, all of a page that is not there
!pageStore.notFound && !pageStore.notFound &&
// -> Nor of one nobody stays on: a redirection has no headings to list and is gone in a moment // -> Nor of one nobody stays on: a redirection has no headings to list and is gone in a moment
pageStore.editor !== 'redirect' pageStore.editor !== 'redirect' &&
// -> A blog keeps the column but fills it with its own thing, and only where it asked for one
(!isBlog.value || blogWantsSidebar.value)
) )
}) })
/* /*
@ -661,6 +736,9 @@ const showTalkTab = computed(
pageStore.allowComments && pageStore.allowComments &&
!pageStore.notFound && !pageStore.notFound &&
!editorStore.isActive && !editorStore.isActive &&
// -> A blog's front page is a listing rather than an article: there is nothing here to discuss,
// and the discussion a reader wants belongs on the post they are reading
!isBlog.value &&
userStore.pagePermissions.includes('read:comments') userStore.pagePermissions.includes('read:comments')
) )
@ -686,6 +764,8 @@ const showLinksTab = computed(
!pageStore.notFound && !pageStore.notFound &&
!pageStore.isLocked && !pageStore.isLocked &&
!editorStore.isActive && !editorStore.isActive &&
// -> As above: the strip is gone on a blog, and a tab with no strip to sit in cannot be reached
!isBlog.value &&
Boolean(pageStore.id) Boolean(pageStore.id)
) )
@ -748,6 +828,11 @@ const lastModified = computed(() => {
* The trail the breadcrumb bar draws, root first. The Home crumb is prepended here rather than * The trail the breadcrumb bar draws, root first. The Home crumb is prepended here rather than
* written into the markup, so the bar takes a single flat list. * written into the markup, so the bar takes a single flat list.
*/ */
/** The blog this page is a post of, as a route on this site. Null-safe: the line is `v-if`'d on it. */
const blogHref = computed(
() => `${siteStore.localeUrlPrefix(pageStore.locale)}/${pageStore.blog?.path ?? ''}`
)
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [
{ key: 'home', icon: 'la:home', to: '/', ariaLabel: 'Home', tooltip: 'Home' }, { key: 'home', icon: 'la:home', to: '/', ariaLabel: 'Home', tooltip: 'Home' },
...pageStore.breadcrumbs.map((brd) => ({ ...pageStore.breadcrumbs.map((brd) => ({
@ -1225,4 +1310,27 @@ function goBack() {
transition-duration: 0.01ms; transition-duration: 0.01ms;
} }
} }
/*
The line above a blog post saying which blog it is in. Quiet and inline rather than a banner: it is
context for the article below it, not an announcement about it -- and it sits in the same 1.5rem the
site banner leaves between itself and the content.
*/
.page-blog-byline {
display: inline-flex;
align-items: center;
margin-bottom: 1.25rem;
font-size: 0.8rem;
text-decoration: none;
@at-root .body--light & {
color: rgba(0, 0, 0, 0.55);
}
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.55);
}
&:hover {
color: $primary;
}
}
</style> </style>

@ -57,6 +57,14 @@ export const usePageStore = defineStore('page', {
allowRatings: true, allowRatings: true,
authorId: 0, authorId: 0,
authorName: '', authorName: '',
/**
* The blog this page is a post of, as `{ path, title }`, or null for a page that is not in one.
*
* Answered by the server with the page, because it cannot be answered here: a post is a post by
* sitting under a blog's path, and which of this page's ancestors is a blog is a lookup. The
* NEAREST one, so a blog inside a blog owns its own posts.
*/
blog: null,
commentsCount: 0, commentsCount: 0,
content: '', content: '',
/** /**
@ -419,6 +427,7 @@ export const usePageStore = defineStore('page', {
canReview: false, canReview: false,
pendingSubmissions: [], pendingSubmissions: [],
isWatching: false, isWatching: false,
blog: null,
notFound: true notFound: true
}) })
}, },
@ -518,11 +527,29 @@ export const usePageStore = defineStore('page', {
editor editor
}) })
// -> Default Page Path /*
-> Default Page Path
A new page is a SIBLING of the one it was started from, which is what makes "New Page" from
somewhere in a section put the page in that section.
A blog's front page is the exception, and is the one place the natural default is a CHILD:
a post is a post by sitting under the blog's path, so starting a page from a blog and
having it land beside the blog rather than in it would be the one mistake the whole feature
makes easy. Held to a SAVED blog (`this.id`), since a page being created is not one yet.
Not for another BLOG, though, which is the one thing somebody standing on a blog cannot
mean to put inside it: a nested blog takes that part of the outer blog's posts with it.
The reason for the exception is that a page under a blog is a post, and a blog is not one.
A caller that named a `basePath` -- the file manager, which is looking at a folder rather
than at a page -- has already answered the question and is never second-guessed.
*/
let newPath = path let newPath = path
if (!path && path !== '') { if (!path && path !== '') {
const parentPath = const intoBlog = this.editor === 'blog' && Boolean(this.id) && editor !== 'blog'
basePath || basePath === '' ? basePath : this.path.split('/').slice(0, -1).join('/') const siblingPath = intoBlog ? this.path : this.path.split('/').slice(0, -1).join('/')
const parentPath = basePath || basePath === '' ? basePath : siblingPath
newPath = parentPath ? `${parentPath}/new-page` : 'new-page' newPath = parentPath ? `${parentPath}/new-page` : 'new-page'
} }
@ -595,6 +622,12 @@ export const usePageStore = defineStore('page', {
*/ */
isBrowsable: props.isBrowsable ?? editor !== 'redirect', isBrowsable: props.isBrowsable ?? editor !== 'redirect',
isSearchable: props.isSearchable ?? editor !== 'redirect', isSearchable: props.isSearchable ?? editor !== 'redirect',
/*
A page being created is not a post of anything yet: it has not been saved, so no blog has it
under its path. Cleared rather than left at whatever the page this one was started from
answered -- which, when that page WAS a post, is the blog it belonged to.
*/
blog: null,
// -> The page being created is very often the one that was missing, and it is not missing now // -> The page being created is very often the one that was missing, and it is not missing now
notFound: false, notFound: false,
// -> Nothing is stored for a page that does not exist, so everything about it is pending // -> Nothing is stored for a page that does not exist, so everything about it is pending

@ -138,6 +138,7 @@ export const useSiteStore = defineStore('site', {
}, },
editors: { editors: {
asciidoc: false, asciidoc: false,
blog: false,
markdown: false, markdown: false,
visual: false visual: false
}, },
@ -263,11 +264,11 @@ export const useSiteStore = defineStore('site', {
* *
* Three questions at once, and all three have to be asked or the list is fiction: whether the * Three questions at once, and all three have to be asked or the list is fiction: whether the
* site has the editor turned on (`editors`, the admin area's Editors screen), whether it is * site has the editor turned on (`editors`, the admin area's Editors screen), whether it is
* implemented at all — `channel`, `blog` and `api` are names with no editor behind them yet, and * implemented at all — `channel` and `api` are names with no editor behind them yet, and
* `asciidoc` is half-built, so all four are behind the experimental flag — and `redirect`, which * `asciidoc` is half-built, so all three are behind the experimental flag — and `redirect`, which
* no site can turn off because it authors nothing: a redirection is a page with a target instead * no site can turn off because it authors nothing: a redirection is a page with a target instead
* of a body. On a wiki with the flag off that leaves Markdown, Visual and Redirection, in that * of a body. On a wiki with the flag off that leaves Markdown, Visual, Blog and Redirection, in
* order: Markdown is what most pages are written with, so it is the one offered first. * that order: Markdown is what most pages are written with, so it is the one offered first.
* *
* Markdown and Visual are two views of the same markdown source, which is what lets a page move * Markdown and Visual are two views of the same markdown source, which is what lets a page move
* between them — see `interchangeableEditors` on the server. * between them — see `interchangeableEditors` on the server.
@ -282,7 +283,13 @@ export const useSiteStore = defineStore('site', {
...(this.editors.markdown ? ['markdown'] : []), ...(this.editors.markdown ? ['markdown'] : []),
...(this.editors.visual ? ['visual'] : []), ...(this.editors.visual ? ['visual'] : []),
...(experimental && this.editors.asciidoc ? ['asciidoc'] : []), ...(experimental && this.editors.asciidoc ? ['asciidoc'] : []),
...(experimental ? ['channel', 'blog', 'api'] : []), /*
After the two that write pages and before the one that writes none: a blog's front page is
a page somebody creates deliberately and rarely, so it does not belong at the top of the
menu, and it is not the afterthought a redirection is.
*/
...(this.editors.blog ? ['blog'] : []),
...(experimental ? ['channel', 'api'] : []),
'redirect' 'redirect'
] ]
}, },
@ -395,10 +402,18 @@ export const useSiteStore = defineStore('site', {
...this.uploads, ...this.uploads,
...siteInfo.uploads ...siteInfo.uploads
}, },
/*
Flattened to one boolean apiece: what this store is asked is whether an editor is on, and
the `config` blob beside each `isActive` belongs to the editor rather than to the site. A
key the site config has never been saved with reads as off, the same answer `features` and
`theme` get from being spread over the state defaults below -- an editor a site has never
been asked about is not one it offers.
*/
editors: { editors: {
asciidoc: siteInfo.editors.asciidoc.isActive, asciidoc: siteInfo.editors.asciidoc?.isActive ?? false,
markdown: siteInfo.editors.markdown.isActive, blog: siteInfo.editors.blog?.isActive ?? false,
visual: siteInfo.editors.visual.isActive markdown: siteInfo.editors.markdown?.isActive ?? false,
visual: siteInfo.editors.visual?.isActive ?? false
}, },
// -> Spread over the state defaults, as `features` and `theme` above do, so a key the // -> Spread over the state defaults, as `features` and `theme` above do, so a key the
// site config has never been saved with reads as its default rather than undefined // site config has never been saved with reads as its default rather than undefined

Loading…
Cancel
Save