import { pageHistoryActions } from '../../models/pageHistory.ts' import type { FastifyInstance } from 'fastify' /** * A date that may not be set. * * An empty string counts as unset alongside null, because that is how the editor holds a date nobody * has filled in — rejecting it would fail every save of a page that is not scheduled. */ const optionalDateTime = { anyOf: [ { type: 'string', format: 'date-time' }, { type: 'string', maxLength: 0 }, { type: 'null' } ], description: 'Empty or null when there is no date.' } export async function registerSchemas(app: FastifyInstance): Promise { /** * PAGE INPUT - The writable fields, used for both create and update */ app.addSchema({ $id: 'PageInput', type: 'object', properties: { path: { type: 'string', maxLength: 255, pattern: '^/?[a-zA-Z0-9-_/]*$', description: 'Where the page lives, without a leading slash. Lowercased when stored.' }, title: { type: 'string', minLength: 1, maxLength: 255 }, description: { type: 'string', maxLength: 255 }, icon: { type: 'string', maxLength: 255 }, alias: { type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9-_]*$' }, locale: { type: 'string', maxLength: 10, description: "The site's primary locale when absent." }, editor: { type: 'string', maxLength: 255, description: 'Which editor authored the content, e.g. `markdown`. `redirect` is a page with no body at all: it sends its reader elsewhere, is never searchable, and its content is the JSON below rather than a document.' }, content: { type: 'string', description: 'The source, in whatever the editor writes. For a `redirect` page, `{ "kind": "page" | "url", "target": string, "showInterstitial": boolean }` — a page target is a rooted path within this wiki, a URL target a complete http(s) address.' }, render: { type: 'string', description: "The HTML the editor produced. Sanitized against the author's permissions before it is stored, and the table of contents and search text are derived from the result — so what comes back may differ from what was sent." }, publishState: { type: 'string', enum: ['draft', 'published', 'scheduled'] }, publishStartDate: optionalDateTime, publishEndDate: optionalDateTime, isBrowsable: { type: 'boolean' }, isSearchable: { type: 'boolean' }, password: { type: 'string', maxLength: 255 }, relations: { type: 'array', items: { type: 'object', additionalProperties: true } }, tags: { type: 'array', items: { type: 'string' } }, allowComments: { type: 'boolean' }, allowContributions: { type: 'boolean' }, allowRatings: { type: 'boolean' }, showSidebar: { type: 'boolean' }, showTags: { type: 'boolean' }, showToc: { type: 'boolean' }, tocDepth: { type: 'object', properties: { min: { type: 'integer', minimum: 1, maximum: 6 }, max: { type: 'integer', minimum: 1, maximum: 6 } } }, scriptJsLoad: { type: 'string', description: 'Requires the `write:scripts` permission. Ignored without it.' }, scriptJsUnload: { type: 'string', description: 'Requires the `write:scripts` permission. Ignored without it.' }, scriptCss: { type: 'string', description: 'Requires the `write:styles` permission. Ignored without it.' }, reasonForChange: { type: 'string', maxLength: 255, description: "Why this save is being made, as the editor's reason-for-change prompt collected it. Not stored on the page: it is recorded on the history version this save produces." } } }) /** * PAGE - A page as it is served back */ app.addSchema({ $id: 'Page', type: 'object', properties: { id: { type: 'string', format: 'uuid' }, path: { type: 'string' }, hash: { type: 'string', description: 'Hash of the path, which is how a page is addressed by URL.' }, alias: { type: ['string', 'null'] }, title: { type: 'string' }, description: { type: ['string', 'null'] }, icon: { type: ['string', 'null'] }, locale: { type: 'string' }, editor: { type: 'string' }, contentType: { type: 'string' }, publishState: { type: 'string', enum: ['draft', 'published', 'scheduled'] }, publishStartDate: { type: ['string', 'null'], format: 'date-time' }, publishEndDate: { type: ['string', 'null'], format: 'date-time' }, isBrowsable: { type: 'boolean' }, isSearchable: { type: 'boolean' }, password: { type: ['string', 'null'], description: 'Only present for a requester who may edit the page — whoever can take the password off it. Absent otherwise, protected page or not.' }, isLocked: { type: 'boolean', description: 'The page is password protected and this requester has not entered it, so `content`, `render` and `toc` were withheld. Unlock it with `POST …/unlock`.' }, relations: { type: 'array', items: { type: 'object', additionalProperties: true } }, tags: { type: 'array', items: { type: 'string' } }, toc: { type: 'array', description: 'Nested headings, derived from the stored render. Each carries its own `level` — the heading tag it came from — as well as its place in the tree, since which headings a contents list shows is a question about the tag rather than about the nesting.', items: { type: 'object', additionalProperties: true } }, render: { type: 'string' }, 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.' }, allowComments: { type: 'boolean' }, allowContributions: { type: 'boolean' }, allowRatings: { type: 'boolean' }, showSidebar: { type: 'boolean' }, showTags: { type: 'boolean' }, showToc: { type: 'boolean' }, tocDepth: { type: 'object', properties: { min: { type: 'integer' }, max: { type: 'integer' } } }, scriptJsLoad: { type: 'string' }, scriptJsUnload: { type: 'string' }, scriptCss: { type: 'string' }, navigationId: { type: ['string', 'null'] }, navigationMode: { type: 'string' }, authorId: { type: 'string', format: 'uuid' }, authorName: { type: 'string' }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' }, viewer: { type: 'object', description: 'Where the requester stands on this page: what they may do to it, whether they may suggest an edit, and whether they review it. Present when a page is fetched on its own — the page view draws its controls from this, rather than asking three further endpoints about a page it already has. Absent from a page returned by a save.', properties: { permissions: { type: 'array', items: { type: 'string' }, description: 'The page permissions held AT THIS PATH, as this reader’s groups’ rules decide. The same answer `pages/userPermissions` gives for the path.' }, canSuggestEdits: { type: 'boolean', description: 'An enabled approval rule covers this page and names a group the requester is in, and the page allows contributions.' }, hasOpenSuggestion: { type: 'boolean', description: 'The requester already has a suggestion waiting on this page, which they would carry on with rather than start again. Always false for a guest, whose suggestions are attributed to nobody.' }, canReview: { type: 'boolean', description: 'The requester reviews this page. Always false without an account.' }, isWatching: { type: 'boolean', description: 'The requester has asked to be told about changes to this page. Always false without an account, since a watch belongs to one.' }, pendingSubmissions: { type: 'array', items: { $ref: 'PageEditSubmission#' }, description: 'What is waiting on this page, oldest first. Empty unless `canReview`.' } } } } }) /** * WATCHED PAGE - A page somebody asked to be told about, as their inbox lists it */ app.addSchema({ $id: 'WatchedPage', type: 'object', properties: { pageId: { type: 'string', format: 'uuid' }, path: { type: 'string' }, locale: { type: 'string' }, title: { type: 'string' }, description: { type: ['string', 'null'] }, icon: { type: ['string', 'null'] }, updatedAt: { type: 'string', format: 'date-time', description: 'When the page last changed, which is what watching it is about.' }, watchedAt: { type: 'string', format: 'date-time', description: 'When the caller started watching it.' } } }) /** * INCLUDED PAGE - Another page's render, as an include block draws it inside the page being read */ app.addSchema({ $id: 'IncludedPage', type: 'object', properties: { path: { type: 'string', description: 'Slash-separated path of the page that was included.' }, locale: { type: 'string' }, title: { type: 'string' }, isLocked: { type: 'boolean', description: 'The page is password protected and this reader has not entered it, so `render` is empty. An include does not offer the unlock prompt: the reader unlocks the page by opening it.' }, render: { type: 'string', description: 'The stored HTML, already sanitised when the page was saved.' } } }) /** * PAGE HISTORY ENTRY - One version of a page, as the history timeline lists it */ app.addSchema({ $id: 'PageHistoryEntry', type: 'object', properties: { id: { type: 'string', format: 'uuid' }, action: { type: 'string', enum: [...pageHistoryActions], description: 'What happened to the page. `moved` is a change of path or title.' }, changedFields: { type: 'array', description: 'Which page fields the change touched, named as the page stores them. Empty for a creation or a deletion, where the whole page is the change.', items: { type: 'string' } }, reason: { type: 'string', description: "Why the change was made, in the author's words. Empty when the site does not ask for a reason — see the `reasonForChange` site feature — or asked and was not answered." }, versionDate: { type: 'string', format: 'date-time', description: 'RFC 3339 Date Time' }, path: { type: 'string', description: 'Where the page was at the time, which is not necessarily where it is now.' }, title: { type: 'string' }, author: { type: 'object', description: 'Who made the change. Null id and empty name once that account is deleted.', properties: { id: { type: ['string', 'null'], format: 'uuid' }, name: { type: 'string' }, email: { type: 'string' } } } } }) /** * PAGE HISTORY VERSION - The same, with the source it held: one side of a diff */ app.addSchema({ $id: 'PageHistoryVersion', type: 'object', allOf: [ { $ref: 'PageHistoryEntry#' }, { type: 'object', properties: { content: { type: 'string', description: 'The page source as of this version.' }, meta: { type: 'object', additionalProperties: true, description: 'The rest of the page as it stood: description, icon, tags, publish state and dates, relations, scripts, config, editor and content type.' } } } ] }) }