From 11cba3577723972a5f3056c73a4ad4a8343c59dc Mon Sep 17 00:00:00 2001 From: NGPixel Date: Thu, 17 Sep 2026 22:50:01 -0400 Subject: [PATCH] feat: blog pages --- .gitignore | 1 + backend/api/blogs.ts | 201 +++++ backend/api/index.ts | 1 + backend/api/pages.ts | 17 +- backend/api/schemas/page.ts | 11 +- backend/api/schemas/site.ts | 12 + backend/helpers/appShell.ts | 50 +- backend/locales/en.json | 95 ++- backend/models/blogs.ts | 507 +++++++++++++ backend/models/index.ts | 2 + backend/models/pages.ts | 334 ++++++++- backend/models/sites.ts | 18 + frontend/src/assets/icons.generated.js | 6 +- frontend/src/components/EditorBlog.vue | 489 ++++++++++++ frontend/src/components/FileManager.vue | 18 +- frontend/src/components/PageActionsCol.vue | 28 +- frontend/src/components/PageBlog.vue | 787 ++++++++++++++++++++ frontend/src/components/PageBlogSidebar.vue | 318 ++++++++ frontend/src/components/PageNewMenu.vue | 31 +- frontend/src/composables/blog.js | 86 +++ frontend/src/css/tailwind.css | 7 + frontend/src/helpers/blogFilter.js | 54 ++ frontend/src/helpers/fileTypes.js | 5 + frontend/src/helpers/pageBlog.js | 93 +++ frontend/src/helpers/sampleContent.js | 645 ++++++++++++++++ frontend/src/pages/AdminEditors.vue | 14 +- frontend/src/pages/AdminUtilities.vue | 59 +- frontend/src/pages/Index.vue | 232 ++++-- frontend/src/stores/page.js | 39 +- frontend/src/stores/site.js | 31 +- 30 files changed, 4009 insertions(+), 182 deletions(-) create mode 100644 backend/api/blogs.ts create mode 100644 backend/models/blogs.ts create mode 100644 frontend/src/components/EditorBlog.vue create mode 100644 frontend/src/components/PageBlog.vue create mode 100644 frontend/src/components/PageBlogSidebar.vue create mode 100644 frontend/src/composables/blog.js create mode 100644 frontend/src/helpers/blogFilter.js create mode 100644 frontend/src/helpers/pageBlog.js diff --git a/.gitignore b/.gitignore index 5ab9382f9..63ae925d6 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ test-results/ !/server/locales/en.json cloudflared.deb +/.claude diff --git a/backend/api/blogs.ts b/backend/api/blogs.ts new file mode 100644 index 000000000..2f885fea2 --- /dev/null +++ b/backend/api/blogs.ts @@ -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 diff --git a/backend/api/index.ts b/backend/api/index.ts index dcbb0dca6..ba2f855f1 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -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' }) diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 1f51394c7..e666ca97e 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -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, diff --git a/backend/api/schemas/page.ts b/backend/api/schemas/page.ts index 78564d4d0..294a80b66 100644 --- a/backend/api/schemas/page.ts +++ b/backend/api/schemas/page.ts @@ -218,7 +218,7 @@ export async function registerSchemas(app: FastifyInstance): Promise { 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 { 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: diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index 07b2d4bfd..59a030b90 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -287,6 +287,18 @@ export async function registerSchemas(app: FastifyInstance): Promise { } } }, + blog: { + type: 'object', + properties: { + isActive: { + type: 'boolean' + }, + config: { + type: 'object', + additionalProperties: true + } + } + }, markdown: { type: 'object', properties: { diff --git a/backend/helpers/appShell.ts b/backend/helpers/appShell.ts index c7b310c64..236a9dbf5 100644 --- a/backend/helpers/appShell.ts +++ b/backend/helpers/appShell.ts @@ -341,6 +341,53 @@ function bodyForPage(page: PageDescription): string { return `
${stripActiveMarkup(page.render)}
` } +/** + * 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 { + 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 ? `

${htmlEscape(blog.settings.intro)}

` : '' + 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 `
  • ${title}${description}
  • ` + }) + .join('') + if (!intro && items.length < 1) { + return '' + } + return `
    ${intro}
      ${items}
    ` +} + /** * 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) } diff --git a/backend/locales/en.json b/backend/locales/en.json index 9a5371e14..685797d46 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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.", diff --git a/backend/models/blogs.ts b/backend/models/blogs.ts new file mode 100644 index 000000000..ee2a61166 --- /dev/null +++ b/backend/models/blogs.ts @@ -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 { + 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 { + 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 { + 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`: 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`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() + const monthCounts = new Map() + 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 { + 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() diff --git a/backend/models/index.ts b/backend/models/index.ts index 06872d04f..83167f006 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -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, diff --git a/backend/models/pages.ts b/backend/models/pages.ts index f4bf94737..13d52e75c 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -19,21 +19,8 @@ const EDITOR_CONTENT_TYPES: Record = { 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 = { - markdown: 'markdown', - asciidoc: 'asciidoc', - redirect: 'redirect' + redirect: 'redirect', + blog: 'blog' } /** @@ -67,7 +54,8 @@ export const PAGE_FILE_EXTENSIONS: Record = { 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 = { + 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 = { 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 ( diff --git a/backend/models/sites.ts b/backend/models/sites.ts index f80257971..b53f08442 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -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: { diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 11bc31f02..83ea3b075 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -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":"","width":32,"height":32}, "la:angle-right": {"body":"","width":32,"height":32}, "la:arrow-circle-left": {"body":"","width":32,"height":32}, "la:arrow-circle-right": {"body":"","width":32,"height":32}, @@ -24,6 +25,7 @@ export const BUNDLED_ICONS = { "la:broadcast-tower": {"body":"","width":32,"height":32}, "la:broom": {"body":"","width":32,"height":32}, "la:calendar": {"body":"","width":32,"height":32}, + "la:calendar-alt": {"body":"","width":32,"height":32}, "la:caret-square-right": {"body":"","width":32,"height":32}, "la:chalkboard": {"body":"","width":32,"height":32}, "la:chart-area": {"body":"","width":32,"height":32}, @@ -67,6 +69,7 @@ export const BUNDLED_ICONS = { "la:file-invoice": {"body":"","width":32,"height":32}, "la:file-upload": {"body":"","width":32,"height":32}, "la:fill": {"body":"","width":32,"height":32}, + "la:filter": {"body":"","width":32,"height":32}, "la:fingerprint": {"body":"","width":32,"height":32}, "la:fire-alt": {"body":"","width":32,"height":32}, "la:folder-open": {"body":"","width":32,"height":32}, @@ -102,6 +105,7 @@ export const BUNDLED_ICONS = { "la:microchip": {"body":"","width":32,"height":32}, "la:minus": {"body":"","width":32,"height":32}, "la:mountain": {"body":"","width":32,"height":32}, + "la:newspaper": {"body":"","width":32,"height":32}, "la:otter": {"body":"","width":32,"height":32}, "la:paper-plane": {"body":"","width":32,"height":32}, "la:pen": {"body":"","width":32,"height":32}, diff --git a/frontend/src/components/EditorBlog.vue b/frontend/src/components/EditorBlog.vue new file mode 100644 index 000000000..855ced0ce --- /dev/null +++ b/frontend/src/components/EditorBlog.vue @@ -0,0 +1,489 @@ + + + + + diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index 07822f61f..ed0a81671 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -410,11 +410,14 @@ {{ t(`common.actions.edit`) }} - + @@ -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 } diff --git a/frontend/src/components/PageActionsCol.vue b/frontend/src/components/PageActionsCol.vue index 0817680d4..d8038a159 100644 --- a/frontend/src/components/PageActionsCol.vue +++ b/frontend/src/components/PageActionsCol.vue @@ -172,7 +172,9 @@ >{{ t('convertPage.action') }} - + + @@ -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. diff --git a/frontend/src/components/PageBlog.vue b/frontend/src/components/PageBlog.vue new file mode 100644 index 000000000..fb613e93e --- /dev/null +++ b/frontend/src/components/PageBlog.vue @@ -0,0 +1,787 @@ + + + + + diff --git a/frontend/src/components/PageBlogSidebar.vue b/frontend/src/components/PageBlogSidebar.vue new file mode 100644 index 000000000..b33587332 --- /dev/null +++ b/frontend/src/components/PageBlogSidebar.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/frontend/src/components/PageNewMenu.vue b/frontend/src/components/PageNewMenu.vue index a12c27715..6e945dbb6 100644 --- a/frontend/src/components/PageNewMenu.vue +++ b/frontend/src/components/PageNewMenu.vue @@ -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. --> - + {{ t(`common.createPage.${editor}`) }} @@ -34,6 +32,7 @@