mirror of
https://github.com/requarks/wiki.git
synced 2026-10-13 14:01:58 -05:00
feat: blog pages
This commit is contained in:
30 files changed
+4009
-182
No files matched your search
@@ -46,3 +46,4 @@ test-results/
|
||||
!/server/locales/en.json
|
||||
|
||||
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('./authentication.ts'))
|
||||
app.register(import('./blocks.ts'))
|
||||
app.register(import('./blogs.ts'))
|
||||
app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' })
|
||||
app.register(import('./comments.ts'))
|
||||
app.register(import('./groups.ts'), { prefix: '/groups' })
|
||||
|
||||
+15
-2
@@ -699,7 +699,7 @@ async function routes(app: FastifyInstance) {
|
||||
is what makes a page view one request instead of four.
|
||||
*/
|
||||
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, {
|
||||
id: page.id,
|
||||
path: page.path,
|
||||
@@ -715,11 +715,24 @@ async function routes(app: FastifyInstance) {
|
||||
*/
|
||||
WIKI.models.comments.usesBuiltIn(req.params.siteId)
|
||||
? 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 {
|
||||
...page,
|
||||
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: {
|
||||
permissions: pagePermissionsFor(req, page),
|
||||
...approvalState,
|
||||
|
||||
@@ -218,7 +218,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
content: {
|
||||
type: 'string',
|
||||
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' },
|
||||
allowComments: { type: 'boolean' },
|
||||
@@ -248,6 +248,15 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
authorName: { type: 'string' },
|
||||
createdAt: { 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: {
|
||||
type: 'object',
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -341,6 +341,53 @@ function bodyForPage(page: PageDescription): string {
|
||||
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 ? ` — ${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
|
||||
* JavaScript off.
|
||||
@@ -397,7 +444,8 @@ async function fragmentsForCrawler(
|
||||
|
||||
return {
|
||||
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,
|
||||
robots: robotsTagFor(siteId, page.isIndexable)
|
||||
}
|
||||
|
||||
+54
-41
@@ -856,44 +856,6 @@
|
||||
"admin.nav.site": "Site",
|
||||
"admin.nav.system": "System",
|
||||
"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.title": "Rendering",
|
||||
"admin.scheduler.active": "Active",
|
||||
@@ -1410,9 +1372,9 @@
|
||||
"admin.utilities.flushCacheSuccess": "The cache has been flushed.",
|
||||
"admin.utilities.generateSample": "Generate Sample Content",
|
||||
"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.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.generateSampleSuccess": "No sample pages were written. | Wrote 1 sample page. | Wrote {count} sample pages.",
|
||||
"admin.utilities.graphEndpointSubtitle": "Change the GraphQL endpoint for Wiki.js",
|
||||
@@ -1682,6 +1644,22 @@
|
||||
"common.actions.upload": "Upload",
|
||||
"common.actions.view": "View",
|
||||
"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.loadFailed": "Failed to load the contents of this folder.",
|
||||
"common.browse.openFolder": "Open the {title} folder",
|
||||
@@ -1739,7 +1717,7 @@
|
||||
"common.comments.write": "Write",
|
||||
"common.createPage.api": "New API Documentation",
|
||||
"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.markdown": "New Markdown Page",
|
||||
"common.createPage.redirect": "New Redirection",
|
||||
@@ -1972,6 +1950,40 @@
|
||||
"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.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.codeBlock.filter": "Filter languages...",
|
||||
"editor.codeBlock.noResults": "No language matches that.",
|
||||
@@ -2377,6 +2389,7 @@
|
||||
"fileman.assetRenameMove": "Rename / Move Asset",
|
||||
"fileman.aviFileType": "AVI Video File",
|
||||
"fileman.binFileType": "Binary File",
|
||||
"fileman.blogPageType": "Blog",
|
||||
"fileman.bz2FileType": "BZIP2 Archive",
|
||||
"fileman.copyURLSuccess": "URL has been copied to the clipboard.",
|
||||
"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 { authentication } from './authentication.ts'
|
||||
import { blocks } from './blocks.ts'
|
||||
import { blogs } from './blogs.ts'
|
||||
import { comments } from './comments.ts'
|
||||
import { extensions } from './extensions.ts'
|
||||
import { flags } from './flags.ts'
|
||||
@@ -41,6 +42,7 @@ export default {
|
||||
auditLog,
|
||||
authentication,
|
||||
blocks,
|
||||
blogs,
|
||||
comments,
|
||||
extensions,
|
||||
flags,
|
||||
|
||||
+299
-35
@@ -19,21 +19,8 @@ const EDITOR_CONTENT_TYPES: Record<string, string> = {
|
||||
markdown: 'markdown',
|
||||
visual: 'markdown',
|
||||
asciidoc: 'asciidoc',
|
||||
redirect: 'redirect'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
redirect: 'redirect',
|
||||
blog: 'blog'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +54,8 @@ export const PAGE_FILE_EXTENSIONS: Record<string, string> = {
|
||||
markdown: 'md',
|
||||
html: 'html',
|
||||
asciidoc: 'adoc',
|
||||
redirect: 'json'
|
||||
redirect: 'json',
|
||||
blog: 'json'
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -87,11 +100,7 @@ export function pageFileExtension(contentType: string): string {
|
||||
* reserved something this wiki has no editor for
|
||||
*/
|
||||
export function pageEditorForExtension(ext: string): string | null {
|
||||
const contentType = Object.entries(PAGE_FILE_EXTENSIONS).find(([, e]) => e === ext)?.[0]
|
||||
if (!contentType) {
|
||||
return null
|
||||
}
|
||||
return DEFAULT_EDITOR_FOR_CONTENT_TYPE[contentType] ?? null
|
||||
return EDITOR_FOR_PAGE_EXTENSION[ext] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +113,54 @@ export function pageEditorForExtension(ext: string): string | null {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -220,8 +277,8 @@ export interface Page {
|
||||
toc: TocNode[]
|
||||
render: string
|
||||
/**
|
||||
* The source. Present when the request asked for it, and always for a redirection — see `toPage`,
|
||||
* and `RedirectContent` for what a redirection's holds.
|
||||
* The source. Present when the request asked for it, and always for a bodyless editor — see
|
||||
* `toPage`, and `RedirectContent` / `BlogContent` for what those hold instead of a body.
|
||||
*/
|
||||
content?: string
|
||||
allowComments: boolean
|
||||
@@ -333,6 +390,15 @@ export interface PageDescription {
|
||||
path: string
|
||||
title: string
|
||||
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
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -478,11 +728,11 @@ class Pages {
|
||||
* being asked for a password to.
|
||||
* @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.
|
||||
* @param withContent Include the source. A redirection's comes back either way: its content is not
|
||||
* a body somebody wrote, it is where the page sends its reader — which every
|
||||
* reader is about to be shown by being taken there. Withholding it would leave
|
||||
* the page view unable to do the one thing the page is for, and the page view
|
||||
* does not ask for content.
|
||||
* @param withContent Include the source. A bodyless editor's comes back either way: its content is
|
||||
* not a body somebody wrote, it is the settings the page view needs to draw
|
||||
* anything at all — where a redirection sends its reader, how a blog lists its
|
||||
* posts. Withholding it would leave the page view unable to do the one thing the
|
||||
* page is for, and the page view does not ask for content.
|
||||
*/
|
||||
private toPage(
|
||||
row: any,
|
||||
@@ -526,7 +776,7 @@ class Pages {
|
||||
tags: row.tags ?? [],
|
||||
toc: locked ? [] : (row.toc ?? []),
|
||||
render: locked ? '' : (row.render ?? ''),
|
||||
...((withContent || row.editor === REDIRECT_EDITOR) && !locked
|
||||
...((withContent || isBodylessEditor(row.editor)) && !locked
|
||||
? { content: row.content ?? '' }
|
||||
: {}),
|
||||
/*
|
||||
@@ -1188,6 +1438,7 @@ class Pages {
|
||||
path: row.path,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
editor: row.editor,
|
||||
render: isProtected || isRedirect ? null : row.render,
|
||||
updatedAt: row.updatedAt,
|
||||
isIndexable: row.isSearchable && !isProtected && !isRedirect,
|
||||
@@ -1417,10 +1668,10 @@ class Pages {
|
||||
}
|
||||
const editor = input.editor || 'markdown'
|
||||
const isRedirect = editor === REDIRECT_EDITOR
|
||||
// -> A redirection has no body to be empty: what it holds instead is where it points, and that has
|
||||
// its own rules about being filled in
|
||||
const content = isRedirect ? normalizeRedirectContent(input.content) : input.content
|
||||
if (!isRedirect && (!content || content.trim().length < 1)) {
|
||||
// -> A bodyless editor has no body to be empty: what its content column holds instead is a
|
||||
// settings document, which has its own rules about what it may say
|
||||
const content = normalizeBodylessContent(editor, input.content)
|
||||
if (!isBodylessEditor(editor) && (!content || content.trim().length < 1)) {
|
||||
throw new CustomError('pageEmptyContent', 'A page cannot be empty.')
|
||||
}
|
||||
|
||||
@@ -1528,6 +1779,19 @@ class Pages {
|
||||
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
|
||||
// own: a relative link resolves against the page holding it
|
||||
await WIKI.models.pageLinks.refreshForPage(page, links)
|
||||
@@ -1600,7 +1864,7 @@ class Pages {
|
||||
const values: Record<string, any> = { updatedAt: sql`now()` }
|
||||
let treeTitle: string | null = null
|
||||
// -> 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
|
||||
|
||||
if (patch.title !== undefined) {
|
||||
@@ -1621,7 +1885,7 @@ class Pages {
|
||||
values.alias = await this.validateAlias(siteId, patch.alias, id)
|
||||
}
|
||||
if (patch.content !== undefined) {
|
||||
values.content = isRedirect ? normalizeRedirectContent(patch.content) : patch.content
|
||||
values.content = normalizeBodylessContent(existing.editor, patch.content)
|
||||
}
|
||||
if (patch.publishState !== undefined) {
|
||||
if (
|
||||
|
||||
@@ -203,6 +203,15 @@ class Sites {
|
||||
isActive: true,
|
||||
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: {
|
||||
isActive: true,
|
||||
config: {
|
||||
@@ -470,6 +479,15 @@ class Sites {
|
||||
isActive: true,
|
||||
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: {
|
||||
isActive: true,
|
||||
config: {
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
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.
|
||||
|
||||
271 icons.
|
||||
275 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: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},
|
||||
@@ -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: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-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: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},
|
||||
@@ -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-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: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: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},
|
||||
@@ -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: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: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: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},
|
||||
|
||||
@@ -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>{{ t(`common.actions.edit`) }}</w-item-section>
|
||||
</w-item>
|
||||
<!-- -> Nothing to render on a redirection: it has a target where a page has
|
||||
content, and the endpoint behind this refuses any editor but markdown -->
|
||||
<!-- -> Nothing to render on a page whose content is not an article: a
|
||||
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
|
||||
clickable
|
||||
v-if="item.type === `page` && item.pageType !== `redirect`"
|
||||
v-if="
|
||||
item.type === `page` && ![`redirect`, `blog`].includes(item.pageType)
|
||||
"
|
||||
@click="rerenderPage(item)">
|
||||
<w-item-section side>
|
||||
<w-icon name="la:magic" color="orange" />
|
||||
@@ -787,8 +790,13 @@ const files = computed(() => {
|
||||
break
|
||||
}
|
||||
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`)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -172,7 +172,9 @@
|
||||
><w-item-label>{{ t('convertPage.action') }}</w-item-label></w-item-section
|
||||
>
|
||||
</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-icon class="text-deep-orange-9" name="la:magic" size="sm" />
|
||||
</w-item-section>
|
||||
@@ -295,15 +297,27 @@ const canConvert = computed(() =>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* Every entry in it is behind something: Rerender Page behind `write:pages`, Convert Page and View
|
||||
* Backlinks behind the experimental flag (the first behind `manage:pages` as well). So those two tests
|
||||
* cover the whole menu -- and with neither of them true it opened an empty panel, which is what a guest
|
||||
* got on every page. Keep this in step with the entries themselves.
|
||||
* The disjunction of the three entries' own conditions, spelled out, because with none of them true
|
||||
* it opened an empty panel -- which is what a guest got on every page. Rerender Page needs
|
||||
* `write:pages` and a page with a body; Convert Page needs `write:pages` and somewhere to convert to;
|
||||
* 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
|
||||
@@ -330,7 +344,7 @@ const isSaved = computed(() => Boolean(pageStore.id))
|
||||
const showHistory = computed(
|
||||
() => !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.
|
||||
|
||||
@@ -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"
|
||||
>·</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
|
||||
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.
|
||||
|
||||
Narrowed by `only` where a caller has a reason to offer fewer -- see the prop.
|
||||
-->
|
||||
<w-item
|
||||
v-for="editor of siteStore.activeEditors"
|
||||
:key="editor"
|
||||
clickable
|
||||
@click="create(editor)">
|
||||
<w-item v-for="editor of offeredEditors" :key="editor" clickable @click="create(editor)">
|
||||
<blueprint-icon :icon="EDITOR_ICONS[editor]" />
|
||||
<w-item-section class="pr-2">{{ t(`common.createPage.${editor}`) }}</w-item-section>
|
||||
</w-item>
|
||||
@@ -34,6 +32,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { loading } from '@/composables/loading'
|
||||
@@ -77,6 +76,19 @@ const props = defineProps({
|
||||
type: String,
|
||||
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
|
||||
* 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()
|
||||
|
||||
// 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
|
||||
|
||||
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);
|
||||
|
||||
/*
|
||||
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
|
||||
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: {
|
||||
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: {
|
||||
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" />
|
||||
</div>
|
||||
<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">
|
||||
{{ t('admin.editors.subtitle') }}
|
||||
</div>
|
||||
@@ -149,8 +151,11 @@ const editors = reactive([
|
||||
{
|
||||
id: 'blog',
|
||||
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',
|
||||
@@ -195,6 +200,7 @@ async function load() {
|
||||
const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json()
|
||||
const data = resp?.editors
|
||||
state.config.asciidoc = data?.asciidoc?.isActive ?? false
|
||||
state.config.blog = data?.blog?.isActive ?? false
|
||||
state.config.markdown = data?.markdown?.isActive ?? false
|
||||
state.config.visual = data?.visual?.isActive ?? false
|
||||
} catch (err) {
|
||||
@@ -215,6 +221,7 @@ async function save() {
|
||||
json: {
|
||||
editors: {
|
||||
asciidoc: { isActive: state.config.asciidoc },
|
||||
blog: { isActive: state.config.blog },
|
||||
markdown: { isActive: state.config.markdown },
|
||||
visual: { isActive: state.config.visual }
|
||||
}
|
||||
@@ -229,6 +236,7 @@ async function save() {
|
||||
siteStore.$patch({
|
||||
editors: {
|
||||
asciidoc: state.config.asciidoc,
|
||||
blog: state.config.blog,
|
||||
markdown: state.config.markdown,
|
||||
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
|
||||
* 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
|
||||
* saves through — rather than by a bulk call on the server. That is what makes the content
|
||||
@@ -573,11 +577,15 @@ function purgeRevokedKeys() {
|
||||
* them.
|
||||
*/
|
||||
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({
|
||||
title: t('admin.utilities.generateSample'),
|
||||
message: t('admin.utilities.generateSampleConfirm', {
|
||||
count: SAMPLE_PAGES.length,
|
||||
count: total,
|
||||
site: siteName.value
|
||||
}),
|
||||
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
|
||||
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 [, name] of page.content.matchAll(/\bicon="([a-z0-9-]+:[a-z0-9-]+)"/g)) {
|
||||
icons.add(name)
|
||||
@@ -620,9 +630,41 @@ async function generateSampleContent() {
|
||||
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
|
||||
const failures = []
|
||||
for (const page of SAMPLE_PAGES) {
|
||||
for (const page of documents) {
|
||||
try {
|
||||
const resp = await API_CLIENT.post(`sites/${siteId}/pages`, {
|
||||
json: {
|
||||
@@ -630,9 +672,10 @@ async function generateSampleContent() {
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
icon: page.icon,
|
||||
editor: 'markdown',
|
||||
editor: page.editor,
|
||||
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
|
||||
tags: [SAMPLE_CONTENT_TAG, ...page.tags],
|
||||
publishState: 'published'
|
||||
@@ -644,7 +687,7 @@ async function generateSampleContent() {
|
||||
created++
|
||||
} catch (err) {
|
||||
// -> 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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
+170
-62
@@ -157,12 +157,38 @@
|
||||
itself and what follows.
|
||||
-->
|
||||
<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
|
||||
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-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
|
||||
nothing here to put a handler on, and they are replaced wholesale on every render.
|
||||
@@ -170,7 +196,7 @@
|
||||
<div
|
||||
class="page-contents"
|
||||
ref="pageContents"
|
||||
v-show="activeView === `article`"
|
||||
v-show="activeView === `article` && !isBlog"
|
||||
v-html="pageStore.render"
|
||||
@click="onContentClick" />
|
||||
<!--
|
||||
@@ -282,35 +308,44 @@
|
||||
:class="{ 'is-open': tocPanelIsOpen }"
|
||||
:style="siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`"
|
||||
@click="onSidebarClick">
|
||||
<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
|
||||
<!--
|
||||
A blog's front page gets its own column. It has none of what the three sections below offer
|
||||
-- no headings to list, no body to rate, and its own tags are the front page's rather than
|
||||
the blog's -- and what it does have is what the blog is about and when it was written.
|
||||
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
|
||||
thing and belongs where there is room for it -->
|
||||
<div class="text-caption text-grey-7">{{ t('common.page.contents') }}</div>
|
||||
</div>
|
||||
<div class="px-4 pb-2">
|
||||
<page-toc
|
||||
:nodes="pageStore.toc"
|
||||
:min-depth="pageStore.tocDepth.min"
|
||||
:max-depth="pageStore.tocDepth.max"
|
||||
v-model:selected="state.tocSelected" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- Tags -->
|
||||
<template v-if="showTags">
|
||||
<w-separator v-if="showToc" />
|
||||
<div
|
||||
class="p-4"
|
||||
@mouseover="state.showTagsEditBtn = true"
|
||||
@mouseleave="state.showTagsEditBtn = false">
|
||||
<div class="flex items-center">
|
||||
<w-icon class="mr-2" name="la:tags" color="grey" />
|
||||
<div class="text-caption text-grey-7">{{ t('common.page.tags') }}</div>
|
||||
<w-space />
|
||||
<!--
|
||||
<div class="text-caption text-grey-7">{{ t('common.page.contents') }}</div>
|
||||
</div>
|
||||
<div class="px-4 pb-2">
|
||||
<page-toc
|
||||
:nodes="pageStore.toc"
|
||||
:min-depth="pageStore.tocDepth.min"
|
||||
:max-depth="pageStore.tocDepth.max"
|
||||
v-model:selected="state.tocSelected" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- Tags -->
|
||||
<template v-if="showTags">
|
||||
<w-separator v-if="showToc" />
|
||||
<div
|
||||
class="p-4"
|
||||
@mouseover="state.showTagsEditBtn = true"
|
||||
@mouseleave="state.showTagsEditBtn = false">
|
||||
<div class="flex items-center">
|
||||
<w-icon class="mr-2" name="la:tags" color="grey" />
|
||||
<div class="text-caption text-grey-7">{{ t('common.page.tags') }}</div>
|
||||
<w-space />
|
||||
<!--
|
||||
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,
|
||||
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
|
||||
for them it is not a control that happens to be out of sight.
|
||||
-->
|
||||
<w-btn
|
||||
v-if="canEditTags"
|
||||
class="tags-edit-btn"
|
||||
:class="{ 'is-hidden': !state.tagEditMode && !state.showTagsEditBtn }"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
:icon="state.tagEditMode ? `la:check` : `la:pen`"
|
||||
color="deep-orange-9"
|
||||
flat
|
||||
:label="state.tagEditMode ? t('common.actions.exitEdit') : t('common.actions.edit')"
|
||||
no-caps
|
||||
@click="state.tagEditMode = !state.tagEditMode" />
|
||||
<w-btn
|
||||
v-if="canEditTags"
|
||||
class="tags-edit-btn"
|
||||
:class="{ 'is-hidden': !state.tagEditMode && !state.showTagsEditBtn }"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
:icon="state.tagEditMode ? `la:check` : `la:pen`"
|
||||
color="deep-orange-9"
|
||||
flat
|
||||
:label="
|
||||
state.tagEditMode ? t('common.actions.exitEdit') : t('common.actions.edit')
|
||||
"
|
||||
no-caps
|
||||
@click="state.tagEditMode = !state.tagEditMode" />
|
||||
</div>
|
||||
<page-tags class="mt-2" :edit="state.tagEditMode" />
|
||||
</div>
|
||||
<page-tags class="mt-2" :edit="state.tagEditMode" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="siteStore.features.ratingsMode !== `off` && pageStore.allowRatings">
|
||||
<w-separator v-if="showToc || showTags" />
|
||||
<!-- Rating -->
|
||||
<div class="p-4 flex items-center">
|
||||
<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" />
|
||||
</template>
|
||||
<template v-if="siteStore.features.ratingsMode !== `off` && pageStore.allowRatings">
|
||||
<w-separator v-if="showToc || showTags" />
|
||||
<!-- Rating -->
|
||||
<div class="p-4 flex items-center">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<!-- -> Every action on it acts on a page: there is none here to edit, share, rate or delete -->
|
||||
@@ -418,6 +458,7 @@ import {
|
||||
routableHref
|
||||
} from '@/helpers/renderedContent'
|
||||
import { flattenToc } from '@/helpers/toc'
|
||||
import { parseBlog } from '@/helpers/pageBlog'
|
||||
|
||||
import { useCommonStore } from '@/stores/common'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
@@ -456,6 +497,16 @@ const PageLinks = defineAsyncComponent({
|
||||
loader: () => import('@/components/PageLinks.vue'),
|
||||
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 = {
|
||||
markdown: defineAsyncComponent({
|
||||
@@ -469,6 +520,10 @@ const editorComponents = {
|
||||
redirect: defineAsyncComponent({
|
||||
loader: () => import('../components/EditorRedirect.vue'),
|
||||
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)
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
return (
|
||||
pageStore.showSidebar &&
|
||||
@@ -572,7 +645,9 @@ const showSidebar = computed(() => {
|
||||
// -> Contents, tags and a rating, all of a page that is not there
|
||||
!pageStore.notFound &&
|
||||
// -> 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.notFound &&
|
||||
!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')
|
||||
)
|
||||
|
||||
@@ -686,6 +764,8 @@ const showLinksTab = computed(
|
||||
!pageStore.notFound &&
|
||||
!pageStore.isLocked &&
|
||||
!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)
|
||||
)
|
||||
|
||||
@@ -748,6 +828,11 @@ const lastModified = computed(() => {
|
||||
* 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.
|
||||
*/
|
||||
/** 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(() => [
|
||||
{ key: 'home', icon: 'la:home', to: '/', ariaLabel: 'Home', tooltip: 'Home' },
|
||||
...pageStore.breadcrumbs.map((brd) => ({
|
||||
@@ -1225,4 +1310,27 @@ function goBack() {
|
||||
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>
|
||||
@@ -57,6 +57,14 @@ export const usePageStore = defineStore('page', {
|
||||
allowRatings: true,
|
||||
authorId: 0,
|
||||
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,
|
||||
content: '',
|
||||
/**
|
||||
@@ -419,6 +427,7 @@ export const usePageStore = defineStore('page', {
|
||||
canReview: false,
|
||||
pendingSubmissions: [],
|
||||
isWatching: false,
|
||||
blog: null,
|
||||
notFound: true
|
||||
})
|
||||
},
|
||||
@@ -518,11 +527,29 @@ export const usePageStore = defineStore('page', {
|
||||
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
|
||||
if (!path && path !== '') {
|
||||
const parentPath =
|
||||
basePath || basePath === '' ? basePath : this.path.split('/').slice(0, -1).join('/')
|
||||
const intoBlog = this.editor === 'blog' && Boolean(this.id) && editor !== 'blog'
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -595,6 +622,12 @@ export const usePageStore = defineStore('page', {
|
||||
*/
|
||||
isBrowsable: props.isBrowsable ?? 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
|
||||
notFound: false,
|
||||
// -> 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: {
|
||||
asciidoc: false,
|
||||
blog: false,
|
||||
markdown: 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
|
||||
* 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
|
||||
* `asciidoc` is half-built, so all four are behind the experimental flag — and `redirect`, which
|
||||
* implemented at all — `channel` and `api` are names with no editor behind them yet, and
|
||||
* `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
|
||||
* of a body. On a wiki with the flag off that leaves Markdown, Visual and Redirection, in that
|
||||
* order: Markdown is what most pages are written with, so it is the one offered first.
|
||||
* of a body. On a wiki with the flag off that leaves Markdown, Visual, Blog and Redirection, in
|
||||
* 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
|
||||
* between them — see `interchangeableEditors` on the server.
|
||||
@@ -282,7 +283,13 @@ export const useSiteStore = defineStore('site', {
|
||||
...(this.editors.markdown ? ['markdown'] : []),
|
||||
...(this.editors.visual ? ['visual'] : []),
|
||||
...(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'
|
||||
]
|
||||
},
|
||||
@@ -395,10 +402,18 @@ export const useSiteStore = defineStore('site', {
|
||||
...this.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: {
|
||||
asciidoc: siteInfo.editors.asciidoc.isActive,
|
||||
markdown: siteInfo.editors.markdown.isActive,
|
||||
visual: siteInfo.editors.visual.isActive
|
||||
asciidoc: siteInfo.editors.asciidoc?.isActive ?? false,
|
||||
blog: siteInfo.editors.blog?.isActive ?? false,
|
||||
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
|
||||
// site config has never been saved with reads as its default rather than undefined
|
||||
|
||||
Reference in new issue
Block a user