feat: comments modules + built-in talk feature

scarlett
NGPixel 11 hours ago
parent 9ec8070c68
commit 007e8e9745
No known key found for this signature in database

@ -940,6 +940,100 @@ instead. A `num` placeholder that is not a number drops its whole snippet for th
a prop that had to be kept out of a browser could not be used by a provider in the first place. This
is why `api/analytics.ts` is the one module-prop surface with no `maskSensitiveProps` on the way out.
### Comments
Two things wearing one name, and `models/comments.ts` is the seam between them. **Only one provider
is in use per site** — two comment widgets on a page are two separate discussions of it, and neither
of them is the discussion. That is what makes this screen different from Analytics, where several
providers may be on at once.
**A third-party provider is two YAML files**, exactly as an analytics provider is: a
`definition.yml` (what it is, what it needs configured, the same `props` shape read through
`parseModuleProps`) and a `code.yml` with the markup it contributes. Nine ship — Artalk, Comentario,
Discourse, Disqus, Giscus, Hyvor Talk, Isso, Remark42, Waline. There is no `comments.ts` beside them
and nothing to load: the discussion lives in somebody else's service. A directory that cannot be read
is skipped with a warning rather than emptying the list, as under `modules/analytics/`.
**The built-in provider is this wiki**, and deliberately has no module directory: its comments are
rows in the `comments` table, served by `api/comments.ts` and drawn on a Talk tab beside the article.
Its settings are declared as `BUILTIN_DEFINITION` in the model so that the admin screen renders one
kind of form for every provider rather than two.
**Where the markup goes is the one thing that is not like Analytics.** An analytics tag is served in
the document; a comment widget belongs at the bottom of the *article*, and moving between wiki pages
is a router transition and not a document load — a snippet baked into the shell would initialise once
and then show the first page's discussion for ever. So the rendered snippet rides along on the site
payload (`comments.publicConfigFor`, narrow on purpose — the stored configuration holds an Akismet
key) and `PageCommentsEmbed.vue` mounts it per page. `code.yml` has three slots: `head` (added once
per document and awaited), `main` (the container), `body` (the init script, run after both). Scripts
are re-created as real elements — one that arrived through `innerHTML` never runs — and go INSIDE the
container, which giscus and Isso depend on.
**Placeholders are split between the two sides.** `{{js:prop}}` / `{{attr:prop}}` / `{{num:prop}}` /
`{{bool:prop}}` are resolved on the server as they are for analytics; `{{js:page.url}}` and the rest
of the `page.*` family are left in the string for `helpers/commentsEmbed.js` to fill in per page,
escaping by the same rules. A provider that is selected but missing a required prop serves nothing at
all rather than a widget pointed at no account.
**The built-in provider's permissions are PAGE rules**, not the group-wide list, so none of its routes
declares `config.permissions` — every one resolves the page and asks `mayOnPage`. `read:comments` to
see a discussion, `write:comments` to post and to edit or delete your own, `manage:comments` to edit or
delete anybody's. The two are not interchangeable and neither implies the other.
- **Guests can take part**, where a rule grants them `write:comments` — that is how a public wiki opens
a discussion. A name and an email are required of them; the email is stored and never served, and is
what the spam check is given. A guest cannot edit or delete, because there is no session that
identifies them as the author and "their own" has nothing to mean.
- **Two things stand between a comment and the table.** The site's **posting cooldown** (`30s` by
default, `0` for none) is counted per account and per address for a guest, through the same
postgres-backed counter the login limit uses, so instances behind a load balancer agree about it;
`manage:comments` on the page is exempt, since answering five threads in a row is what moderating
looks like. And an optional **Akismet key**, which is the one `sensitive` prop here: masked at the
API boundary like every other module secret. Akismet **fails open** — a timeout or a revoked key
lets the comment through and logs it, because a wiki that silently stops accepting comments is worse
than one that lets a spam comment past. A comment it calls spam is refused outright; there is no
moderation queue yet, which is what the `meta` column is room for.
- **Replies are one level deep, enforced in the model**: a `parentId` naming a comment that is itself a
reply is rewritten to that reply's own parent, so answering the third message in a thread puts the
answer at the bottom of the thread. Deleting a comment takes its replies with it, by the foreign
key's own cascade — half a conversation is not worth keeping.
- **Markdown is rendered in the browser, at display time**, by `frontend/src/renderers/comment.js`
a second, much smaller renderer than the page pipeline. `html: false` is the whole security
boundary: markdown-it escapes every `<` it is given, so nothing stored is ever HTML and no
sanitizer's older rules can be served back. No headings, no images, no tables; every link leaves
with `rel="nofollow ugc noopener"`. Rendering at display rather than at write is also what lets a
mention re-resolve instead of freezing whatever a handle pointed at on the day it was written.
- **A mention is `@handle`**, and `users.handle` is a column with a unique index on `lower(handle)`
`@ana` means one person or it means nothing. It is null until somebody picks one, and a user without
one is simply not mentionable; nothing is derived from a display name on anybody's behalf. It is
edited under **Profile → Info** and is NOT gated on `allowProfileEditing`, because no identity
provider owns a wiki mention handle. The comments endpoint resolves the handles of a whole page in
one query and the renderer links only those, so a mention never points at whoever took the handle
later.
- **The Talk tab is for the built-in provider alone.** `Article` / `Talk` above the content, as on
Wikipedia, with a count badge that comes with the page (`commentsCount` on the page payload) rather
than with the comments — it has to be there before the tab is opened. Every other provider draws
itself under the article instead. Both respect the page's own `allowComments`, which is the switch
in its properties dialog.
**Configuration lives in the site's config blob** under `comments``provider` plus a `providers` map
keyed by module — for the same reasons the analytics configuration does. The settings of the providers
that are not in use are kept, so trying another one and coming back finds a form still filled in.
**Three settings, and they answer different questions.** `features.comments` (**General → Features**,
on by default) is whether the site has comments at all; `comments.provider` is which one handles them;
and a page's own `allowComments` (its properties dialog) is whether this page takes them. Enabling and
disabling is General's job alone — **the Comments screen only picks which provider**, which is why it
offers a radio per provider and no way to choose none. A site starts on the built-in provider, so a
wiki with comments switched on has somewhere for them to go without anybody choosing first.
The master switch is checked by `comments.isAllowed`, which `publicConfigFor` and `usesBuiltIn` both go
through — and deliberately NOT by `selectedProvider`, which the admin screen reads to show what is
selected: a screen reporting "no provider in use" because the master switch is off would then save that
back as the truth. It says so in a banner instead. `selectedProvider` still answers empty for a stored
key whose module has been dropped from the installation, which is the one case the screen cannot
produce and has to describe.
### Audit log
Every action a **person** takes is one row in `auditLog``userId`, `clientIP`, `ts`, `kind`

@ -75,7 +75,10 @@ async function routes(app: FastifyInstance) {
...site.config,
id: site.id,
hostname: site.hostname,
isEnabled: site.isEnabled
isEnabled: site.isEnabled,
// -> Never the stored `comments` block, which holds an Akismet key: what a browser is told
// is the selected provider and the markup to mount, and nothing else
comments: WIKI.models.comments.publicConfigFor(site.id)
},
flags: WIKI.models.flags.getFlags(),
auth: WIKI.models.authentication.getConfig(),

@ -0,0 +1,641 @@
import { audit } from '../helpers/audit.ts'
import { maskSensitiveProps } from '../helpers/common.ts'
import { mayOnPage } from './pages.ts'
import { COMMENT_MAX_LENGTH, COMMENT_MIN_LENGTH } from '../models/comments.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import type { CommentsProviderInput } from '../models/comments.ts'
const siteIdParam = {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
}
const pageIdParam = {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
pageId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'pageId']
}
const commentIdParam = {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
commentId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'commentId']
}
/**
* A roughly-shaped email address, for the one a guest has to leave.
*
* Deliberately not a proof that the address exists nothing here sends to it. It is what Akismet is
* given and what a future moderation screen would show, and the check is here so that a required
* field cannot be satisfied with a space.
*/
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
* Comments API Routes
*
* Two halves, and they answer to different permissions.
*
* **The configuration half** `GET`/`PUT /sites/:siteId/comments` is the admin area's Comments
* screen: which provider this site uses and how it is configured. `manage:sites`, like every other
* per-site configuration screen.
*
* **The comments themselves** are the built-in provider, and carry NO route-level `permissions`: what
* governs them is `read:comments`, `write:comments` and `manage:comments`, which are PAGE rule
* permissions and cannot be enforced by a hook that only reads the group-wide list. Every one of these
* routes resolves the page first and asks `mayOnPage` about it see `helpers/pageRules.ts` for how a
* rule is chosen.
*/
async function routes(app: FastifyInstance) {
/**
* GET SITE COMMENTS CONFIGURATION
*/
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/comments',
{
config: {
permissions: ['manage:sites']
},
schema: {
summary: 'Get the comments configuration of a site',
description:
'The provider this site uses, plus one entry per provider that could be selected — the wikis own first, then every module installed in `modules/comments` — each with the values this site has configured for it.\n\nSensitive props are masked: the built-in providers Akismet key comes back as a fixed placeholder, and sending that placeholder back keeps the stored key.',
tags: ['Comments'],
params: siteIdParam,
response: {
200: {
description: 'Comments configuration of the site',
type: 'object',
properties: {
provider: {
type: 'string',
description:
'Key of the selected provider, or an empty string when this site has picked none. This is what is stored rather than what is in force: `isAllowed` is the other half.'
},
isAllowed: {
type: 'boolean',
description:
'Whether the site allows comments at all — the switch under General → Features. False makes the selection below have no effect, which is worth saying on the screen that edits it.'
},
providers: {
type: 'array',
items: { $ref: 'CommentsProvider#' }
}
}
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
const providers = WIKI.models.comments.getSiteProviders(req.params.siteId)
return {
// -> What is STORED. The screen edits the selection whether or not the site-wide switch is
// on, and says so through `isAllowed` rather than by reporting nothing selected.
provider: WIKI.models.comments.selectedProvider(req.params.siteId),
isAllowed: WIKI.models.comments.isAllowed(req.params.siteId),
// -> Masking is the last thing that happens on the way out, here and nowhere earlier: the
// model hands out the real values because that is what the spam check reads its key from
providers: providers.map((provider) => ({
...provider,
config: maskSensitiveProps(provider.props, provider.config)
}))
}
}
)
/**
* UPDATE SITE COMMENTS CONFIGURATION
*/
app.put<{
Params: { siteId: string }
Body: { provider?: string; providers?: CommentsProviderInput[] }
}>(
'/sites/:siteId/comments',
{
config: {
permissions: ['manage:sites']
},
schema: {
summary: 'Update the comments configuration of a site',
description:
'Selects the provider and writes whatever configuration came with it. The providers not mentioned keep what they had, so trying another one and coming back finds a form that is still filled in.\n\nEverything is validated before any of it is written, so a rejected request changes nothing. A saved change applies to the next page view, on every instance.',
tags: ['Comments'],
params: siteIdParam,
body: {
type: 'object',
properties: {
provider: {
type: 'string',
maxLength: 255,
description:
'Key of the provider to use, or an empty string to turn comments off for this site.'
},
providers: {
type: 'array',
items: { $ref: 'CommentsProviderInput#' }
}
}
},
response: {
200: {
description: 'Comments configuration updated successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
if (
req.body.provider !== undefined &&
req.body.provider.length > 0 &&
!WIKI.models.comments.getDefinition(req.body.provider)
) {
return reply.badRequest(`There is no comments provider called "${req.body.provider}".`)
}
// -> Validated as a whole first: the screen saves every provider at once, and a partially
// applied configuration is worse than a refused one
for (const patch of req.body.providers ?? []) {
const invalid = WIKI.models.comments.validateProvider(patch)
if (invalid) {
return reply.badRequest(invalid)
}
}
await WIKI.models.comments.updateSiteConfig(req.params.siteId, req.body)
/*
Which provider was selected and which fields were touched never the values. An Akismet key
is exactly the kind of thing `meta` must not carry, and the rest of it names an account at a
third party rather than anything about this wiki.
*/
await audit(req, 'admin', 'updateComments', {
siteId: req.params.siteId,
provider: req.body.provider,
changedProviders: (req.body.providers ?? []).map((patch) => ({
key: patch.key,
changedFields: Object.keys(patch.config ?? {})
}))
})
return { ok: true, message: 'Comments configuration updated successfully.' }
}
)
/**
* LIST COMMENTS OF A PAGE
*/
/*
No route-level `permissions`: `read:comments` is granted by a group's page RULES, which the hook
in `index.ts` cannot see. Checked against this page below instead.
*/
app.get<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/comments',
{
schema: {
summary: 'List the comments of a page',
description:
'Every comment on the page, oldest first and flat — the one level of nesting is assembled by the client from `parentId`, which keeps a reply beside the comment it answers however old that comment is.\n\nOnly for a site using the built-in provider; a site whose discussions live at a third party answers 404 here. Needs `read:comments` on the page, which the guests group may hold.\n\n`mentions` resolves the handles written in these comments, so that a mention is drawn as a link to the right person without a lookup per `@`.',
tags: ['Comments'],
params: pageIdParam,
response: {
200: {
description: 'The comments on this page',
type: 'object',
properties: {
comments: {
type: 'array',
items: { $ref: 'Comment#' }
},
mentions: {
type: 'array',
items: { $ref: 'MentionTarget#' }
},
total: {
type: 'integer',
description:
'How many comments the page has. The same as the length of `comments` unless the page has more than the list caps at, which is the one case where the count beside the Talk tab must not be taken from the list.'
}
}
}
}
}
},
async (req, reply) => {
const page = await requireBuiltInPage(req, reply)
if (!page) {
return reply
}
if (!mayOnPage(req, 'read:comments', page)) {
return reply.forbidden('You are not allowed to read the comments of this page.')
}
const comments = await WIKI.models.comments.listForPage(page.id)
const [mentions, total] = await Promise.all([
WIKI.models.comments.resolveMentions(comments.map((c) => c.content)),
WIKI.models.comments.countForPage(page.id)
])
return { comments, mentions, total }
}
)
/**
* POST A COMMENT
*/
// -> No route-level permissions: `write:comments` is a page rule. See the note above.
app.post<{
Params: { siteId: string; pageId: string }
Body: { content: string; parentId?: string; authorName?: string; authorEmail?: string }
}>(
'/sites/:siteId/pages/:pageId/comments',
{
schema: {
summary: 'Post a comment on a page',
description:
'Needs `write:comments` on the page. A rule may grant it to the guests group, in which case a name and an email address are required of whoever is posting — the email is stored but never served, and is what the spam check is given.\n\nThe sites posting cooldown applies to everybody who is not a moderator, counted per account and per address for a guest; going over it answers 429 with `Retry-After`. With an Akismet key configured, a comment Akismet calls spam is refused.',
tags: ['Comments'],
params: pageIdParam,
body: {
type: 'object',
required: ['content'],
properties: {
content: {
type: 'string',
minLength: COMMENT_MIN_LENGTH,
maxLength: COMMENT_MAX_LENGTH,
description: 'Markdown source. Raw HTML in it is escaped rather than rendered.'
},
parentId: {
type: 'string',
format: 'uuid',
description:
'The comment being answered. Replying to a reply attaches the answer to that replys own parent — the thread is one level deep.'
},
authorName: {
type: 'string',
minLength: 1,
maxLength: 255,
description:
'Required of a guest, ignored for a signed-in author (the account has one).'
},
authorEmail: {
type: 'string',
maxLength: 255,
description: 'Required of a guest. Stored, never served.'
}
}
},
response: {
201: {
description: 'The comment as it was stored',
$ref: 'Comment#'
}
}
}
},
async (req, reply) => {
const page = await requireBuiltInPage(req, reply)
if (!page) {
return reply
}
if (!page.allowComments) {
return reply.forbidden('Comments are turned off for this page.')
}
if (!mayOnPage(req, 'write:comments', page)) {
return reply.forbidden('You are not allowed to comment on this page.')
}
const user = req.session?.authenticated ? req.session.user : null
let authorName = user?.name ?? ''
let authorEmail = user?.email ?? ''
if (!user) {
/*
A guest. Both fields are required here rather than left to the schema, because they are
required only of a guest: a signed-in author has a name and an address on their account, and
a form that asked them for both again would be asking them to type something the wiki
already knows and would then have two answers for.
*/
authorName = (req.body.authorName ?? '').trim()
authorEmail = (req.body.authorEmail ?? '').trim()
if (authorName.length < 1) {
return reply.badRequest('A name is required to comment as a guest.')
}
if (!EMAIL_PATTERN.test(authorEmail)) {
return reply.badRequest('A valid email address is required to comment as a guest.')
}
}
const cooldown = await consumeCooldown(req, page)
if (cooldown > 0) {
reply.header('Retry-After', String(cooldown))
return reply.tooManyRequests(
`You are posting too quickly. Try again in ${cooldown} second(s).`
)
}
const origin = `${req.protocol}://${req.hostname}`
const isSpam = await WIKI.models.comments.isSpam(req.params.siteId, {
content: req.body.content,
authorName,
authorEmail,
authorIP: req.ip,
userAgent: req.headers['user-agent'] ?? '',
referrer: req.headers.referer ?? '',
permalink: `${origin}/${page.path}`,
isGuest: !user
})
if (isSpam) {
/*
Refused rather than held: there is no moderation queue yet, and a comment nobody can see
and nobody is told about is worse than one that was turned away with a reason. `meta` on
the row is where a queue would go when there is one.
*/
return reply.badRequest('This comment was flagged as spam and was not posted.')
}
let comment
try {
comment = await WIKI.models.comments.create({
pageId: page.id,
parentId: req.body.parentId ?? null,
content: req.body.content,
authorId: user?.id ?? null,
authorName,
authorEmail: user ? '' : authorEmail,
authorIP: req.ip
})
} catch (err: any) {
return reply.badRequest(err.message)
}
await audit(req, 'comment', 'createComment', {
commentId: comment.id,
pageId: page.id,
path: page.path,
locale: page.locale,
isReply: Boolean(comment.parentId),
isGuest: comment.isGuest
})
reply.code(201)
return comment
}
)
/**
* EDIT A COMMENT
*/
// -> No route-level permissions: the two that matter here are page rules. See the note above.
app.put<{ Params: { siteId: string; commentId: string }; Body: { content: string } }>(
'/sites/:siteId/comments/:commentId',
{
schema: {
summary: 'Edit a comment',
description:
'Whoever holds `manage:comments` on the page may edit any comment on it; everybody else may edit their own, and only while they still hold `write:comments` there.\n\nA guest cannot edit at all: there is no session that identifies them as the author, so `their own` has nothing to mean.',
tags: ['Comments'],
params: commentIdParam,
body: {
type: 'object',
required: ['content'],
properties: {
content: {
type: 'string',
minLength: COMMENT_MIN_LENGTH,
maxLength: COMMENT_MAX_LENGTH
}
}
},
response: {
200: {
description: 'The comment as it now stands',
$ref: 'Comment#'
}
}
}
},
async (req, reply) => {
const comment = await requireWritableComment(req, reply)
if (!comment) {
return reply
}
const updated = await WIKI.models.comments.update(comment.id, req.body.content)
if (!updated) {
return reply.notFound('This comment does not exist.')
}
await audit(req, 'comment', 'updateComment', {
commentId: comment.id,
pageId: comment.pageId,
path: comment.path,
isOwn: comment.authorId === req.session?.user?.id
})
return updated
}
)
/**
* DELETE A COMMENT
*/
// -> No route-level permissions: the two that matter here are page rules. See the note above.
app.delete<{ Params: { siteId: string; commentId: string } }>(
'/sites/:siteId/comments/:commentId',
{
schema: {
summary: 'Delete a comment',
description:
'Same rule as editing: `manage:comments` on the page deletes any comment, `write:comments` deletes your own.\n\nThe replies underneath go with it. A reply exists to answer something, and left behind it is half of a conversation nobody can read.',
tags: ['Comments'],
params: commentIdParam,
response: {
200: {
description: 'Comment deleted successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
deleted: {
type: 'integer',
description: 'How many comments went, the replies underneath included.'
}
}
}
}
}
},
async (req, reply) => {
const comment = await requireWritableComment(req, reply)
if (!comment) {
return reply
}
const deleted = await WIKI.models.comments.remove(comment.id)
await audit(req, 'comment', 'deleteComment', {
commentId: comment.id,
pageId: comment.pageId,
path: comment.path,
deleted,
isOwn: comment.authorId === req.session?.user?.id
})
return { ok: true, deleted }
}
)
/**
* SEARCH MENTIONABLE USERS
*/
app.get<{ Params: { siteId: string }; Querystring: { q?: string } }>(
'/sites/:siteId/comments/mentions',
{
schema: {
summary: 'Find users to mention in a comment',
description:
'What the `@` in a comment box completes against: users who have set a handle, matched on the handle or the display name.\n\nNeeds a signed-in session, and nothing else — a handle and a display name are what every comment already shows, but answering this to anybody at all would make it a way to enumerate the wikis users. A guest who knows a handle can still type it; it resolves when the comment is drawn.',
tags: ['Comments'],
params: siteIdParam,
querystring: {
type: 'object',
properties: {
q: {
type: 'string',
maxLength: 64,
description: 'What has been typed after the `@`. Empty lists the first few handles.'
}
}
},
response: {
200: {
description: 'Users that can be mentioned',
type: 'array',
items: { $ref: 'MentionTarget#' }
}
}
}
},
async (req, reply) => {
if (!req.session?.authenticated) {
return reply.unauthorized('You must be signed in to look up users to mention.')
}
return WIKI.models.comments.searchHandles(req.query.q ?? '')
}
)
}
/**
* The page a request is about, once it is established that this site's comments are the wiki's own.
*
* Both questions answer 404 rather than anything more specific. A site using Disqus has no comments
* here to have an opinion about, and a page id that is not on this site is not this caller's to be
* told about.
*
* @returns The page, or null once it has sent the reply itself
*/
async function requireBuiltInPage(
req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>,
reply: FastifyReply
) {
if (!WIKI.models.comments.usesBuiltIn(req.params.siteId)) {
reply.notFound('This site does not use the built-in comments provider.')
return null
}
const page = await WIKI.models.comments.pageRef(req.params.siteId, req.params.pageId)
if (!page) {
reply.notFound('This page does not exist.')
return null
}
return page
}
/**
* The comment a request is about, once it is established that the caller may change it.
*
* `manage:comments` on the page is the moderator's answer and covers anything on it. Otherwise it has
* to be the caller's own comment AND they have to still hold `write:comments` there a rule that was
* taken away takes the editing of what was written under it with it.
*
* @returns The comment, or null once it has sent the reply itself
*/
async function requireWritableComment(
req: FastifyRequest<{ Params: { siteId: string; commentId: string } }>,
reply: FastifyReply
) {
if (!WIKI.models.comments.usesBuiltIn(req.params.siteId)) {
reply.notFound('This site does not use the built-in comments provider.')
return null
}
const comment = await WIKI.models.comments.getWithPage(req.params.commentId, req.params.siteId)
if (!comment) {
reply.notFound('This comment does not exist.')
return null
}
const page = { path: comment.path, locale: comment.locale, tags: comment.tags ?? [] }
if (mayOnPage(req, 'manage:comments', page)) {
return comment
}
const userId = req.session?.authenticated ? req.session.user?.id : null
if (!userId || comment.authorId !== userId || !mayOnPage(req, 'write:comments', page)) {
reply.forbidden('You are not allowed to modify this comment.')
return null
}
return comment
}
/**
* Count this post against the site's cooldown, and say how long is left of it.
*
* Per account, and per address for a guest an office behind one address shares a counter only where
* the wiki has no better way of telling two people apart, which is exactly the case the cooldown is
* for. The counter is the same postgres-backed one the login limit uses, so two instances behind a
* load balancer agree about it.
*
* Moderators are exempt, along with `manage:system` as everywhere: `manage:comments` on this page is
* the permission to clean up after other people, and answering five threads in a row is what that
* looks like.
*
* @returns Seconds the caller must wait, or 0 when the post may go ahead
*/
async function consumeCooldown(
req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>,
page: { path: string; locale: string; tags: string[] }
): Promise<number> {
const seconds = WIKI.models.comments.cooldownFor(req.params.siteId)
if (seconds < 1 || mayOnPage(req, 'manage:comments', page)) {
return 0
}
// -> The address is the fallback for a session with no user on it as well as for a guest: a key
// ending in `undefined` would be one counter shared by everybody it happened to
const who = (req.session?.authenticated ? req.session.user?.id : null) ?? `ip:${req.ip}`
const verdict = await WIKI.models.rateLimits.consume(`comment:${req.params.siteId}:${who}`, {
/*
One post per window, and a ban as long as the window. Two attempts inside the cooldown are one
post and one refusal, and the refusal does not push the ban further out a banned key stops
counting, so the wait is measured from the last post that was actually accepted plus whatever
the client spent retrying.
*/
max: 1,
windowSeconds: seconds,
banSeconds: seconds
})
return verdict.allowed ? 0 : verdict.retryAfter
}
export default routes

@ -12,6 +12,7 @@ async function routes(app: FastifyInstance) {
await import('./schemas/audit.ts').then((m) => m.registerSchemas(app))
await import('./schemas/authentication.ts').then((m) => m.registerSchemas(app))
await import('./schemas/block.ts').then((m) => m.registerSchemas(app))
await import('./schemas/comments.ts').then((m) => m.registerSchemas(app))
await import('./schemas/extension.ts').then((m) => m.registerSchemas(app))
await import('./schemas/flags.ts').then((m) => m.registerSchemas(app))
await import('./schemas/group.ts').then((m) => m.registerSchemas(app))
@ -37,6 +38,7 @@ async function routes(app: FastifyInstance) {
app.register(import('./authentication.ts'))
app.register(import('./blocks.ts'))
app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' })
app.register(import('./comments.ts'))
app.register(import('./groups.ts'), { prefix: '/groups' })
app.register(import('./hooks.ts'), { prefix: '/hooks' })
app.register(import('./icons.ts'), { prefix: '/icons' })

@ -693,7 +693,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] = await Promise.all([
const [approvalState, isWatching, commentsCount] = await Promise.all([
WIKI.models.approvals.pageViewerState(req, req.params.siteId, {
id: page.id,
path: page.path,
@ -701,10 +701,19 @@ async function routes(app: FastifyInstance) {
allowContributions: page.allowContributions
}),
// -> One indexed lookup on (pageId, userId), and none at all for a reader with no account
WIKI.models.pageWatching.isWatching(page.id, actorId)
WIKI.models.pageWatching.isWatching(page.id, actorId),
/*
The badge on the Talk tab, which has to be there before the tab is opened so it comes with
the page rather than with the comments. One indexed count, and not even that for a site
whose discussions live at a third party or that has comments turned off.
*/
WIKI.models.comments.usesBuiltIn(req.params.siteId)
? WIKI.models.comments.countForPage(page.id)
: 0
])
return {
...page,
commentsCount,
viewer: {
permissions: pagePermissionsFor(req, page),
...approvalState,

@ -0,0 +1,139 @@
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* COMMENTS PROVIDER - A comments module as configured for a site
*/
app.addSchema({
$id: 'CommentsProvider',
type: 'object',
properties: {
key: {
type: 'string',
description:
'Directory name under `modules/comments`, or `default` for the provider the wiki implements itself.'
},
title: {
type: 'string'
},
description: {
type: 'string'
},
website: {
type: 'string',
description: "The provider's own site."
},
icon: {
type: 'string'
},
isBuiltIn: {
type: 'boolean',
description:
'Whether this is the wikis own provider. It stores comments here, in the `comments` table, and draws them on a Talk tab beside the article rather than under it.'
},
isSelected: {
type: 'boolean',
description:
'Whether this is the provider the site uses. At most one provider is: two comment widgets on a page are two separate discussions of it, and neither is the discussion.'
},
requires: {
type: 'array',
items: { type: 'string' },
description:
'The config keys that must hold a value before the provider can be used. A selected provider missing one of these contributes nothing rather than a widget pointed at no account.'
},
props: {
type: 'object',
additionalProperties: true,
description:
'The configuration fields the module declares, as the admin area renders them. Read-only: what a module needs configured is a property of the module, not of the site.'
},
config: {
type: 'object',
additionalProperties: true,
description:
"The stored value of each prop, completed from the module's defaults. A prop marked sensitive — the built-in provider's Akismet key — is replaced by a fixed mask, and sending that mask back means “leave it as it is”."
}
}
})
/**
* COMMENTS PROVIDER INPUT - What a client may change about one provider
*/
app.addSchema({
$id: 'CommentsProviderInput',
type: 'object',
properties: {
key: {
type: 'string'
},
config: {
type: 'object',
additionalProperties: true,
description:
'Values for the props the module declares. Unknown keys are dropped, read-only props are ignored, and a sensitive prop sent back as the mask keeps the value already stored.'
}
},
required: ['key']
})
/**
* COMMENT - One comment on one page, from the built-in provider
*
* Neither the email a guest typed nor the address it was posted from is here. Both are stored, for
* the spam check and for whatever moderation grows out of it, and neither is anybody's to read from
* an API.
*/
app.addSchema({
$id: 'Comment',
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
parentId: {
type: 'string',
format: 'uuid',
nullable: true,
description:
'The comment this one answers, or null for one that starts a thread. Replies are one level deep: answering a reply attaches the answer to that replys own parent.'
},
content: {
type: 'string',
description:
'Markdown source, as it was typed. There is no stored HTML — a comment is rendered in the readers browser with raw HTML disabled.'
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: {
type: 'string',
format: 'date-time',
description: 'Later than `createdAt` for a comment that has been edited.'
},
authorId: {
type: 'string',
format: 'uuid',
nullable: true,
description: 'Null for a guest, and null again once the account behind it is deleted.'
},
authorName: { type: 'string' },
authorHasAvatar: { type: 'boolean' },
authorHandle: {
type: 'string',
nullable: true,
description: 'The handle this author is mentioned by, or null if they have not set one.'
},
isGuest: { type: 'boolean' }
}
})
/**
* MENTION TARGET - A handle that resolved to somebody
*/
app.addSchema({
$id: 'MentionTarget',
type: 'object',
properties: {
handle: { type: 'string' },
id: { type: 'string', format: 'uuid' },
name: { type: 'string' }
}
})
}

@ -214,6 +214,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
allowComments: { type: 'boolean' },
allowContributions: { type: 'boolean' },
allowRatings: { type: 'boolean' },
commentsCount: {
type: 'integer',
description:
'How many comments this page has, which is what the Talk tabs badge counts. Always 0 unless the site uses the built-in comments provider. Present when a page is fetched on its own.'
},
showSidebar: { type: 'boolean' },
showTags: { type: 'boolean' },
showToc: { type: 'boolean' },

@ -94,7 +94,9 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
enum: ['off', 'stars', 'thumbs']
},
comments: {
type: 'boolean'
type: 'boolean',
description:
'Whether this site has comments at all. Which provider handles them is `comments.provider`; this turns every one of them off without losing that choice, and a page can still opt out on its own with `allowComments`.'
},
reasonForChange: {
type: 'string',
@ -105,6 +107,42 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
}
},
comments: {
type: 'object',
description:
'What a browser is told about this sites comments, and all it is told: which provider is selected, and — for a third-party one — the markup to mount under the article. Built by `models/comments.ts`; the stored configuration behind it is not serialized anywhere, because the built-in providers holds an Akismet key.',
properties: {
provider: {
type: 'string',
description:
'Key of the selected provider, or an empty string when this site has comments turned off — which is also the answer for a provider that is selected but not finished being configured.'
},
isBuiltIn: {
type: 'boolean',
description:
'Whether the provider is the wikis own. Only for that one is the Talk tab drawn beside the article; every other provider is mounted under it.'
},
code: {
type: 'object',
description:
'The third-party markup, with everything but the page placeholders already substituted. Empty for the built-in provider.',
properties: {
head: { type: 'string' },
main: { type: 'string' },
body: { type: 'string' }
}
},
cooldownSeconds: {
type: 'integer',
description:
'How long the composer makes a reader wait between two comments. Built-in only, and 0 when there is no cooldown.'
},
maxLength: {
type: 'integer',
description: 'The longest a comment may be, in characters of markdown source.'
}
}
},
uploads: {
type: 'object',
properties: {

@ -133,6 +133,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
hasAvatar: {
type: 'boolean'
},
handle: {
type: 'string',
description:
'The name this user is mentioned by in a comment, without the `@`. An empty string for somebody who has not set one, who is therefore not mentionable — nothing is derived from a display name on anybodys behalf.'
},
location: {
type: 'string'
},
@ -183,6 +188,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
hasAvatar: {
type: 'boolean'
},
handle: {
type: 'string',
description:
'The handle this user is mentioned by, without the `@`, or an empty string if they have not set one. Public because it is written into every comment that mentions them.'
},
location: {
type: 'string'
},
@ -221,6 +231,13 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
minLength: 1,
maxLength: 255
},
handle: {
type: 'string',
maxLength: 32,
pattern: '^$|^[A-Za-z0-9_-]{3,32}$',
description:
'The name to be mentioned by in comments, without the `@`. Unique across the wiki, case-insensitively; an empty string takes it off. 409 if somebody else already has it.'
},
location: {
type: 'string',
maxLength: 255

@ -74,7 +74,9 @@ async function routes(app: FastifyInstance) {
...s.config,
id: s.id,
hostname: s.hostname,
isEnabled: s.isEnabled
isEnabled: s.isEnabled,
// -> See the note in `api/bootstrap.ts`: the stored block is not the one that is served
comments: WIKI.models.comments.publicConfigFor(s.id)
}))
}
)
@ -143,7 +145,8 @@ async function routes(app: FastifyInstance) {
...site.config,
id: site.id,
hostname: site.hostname,
isEnabled: site.isEnabled
isEnabled: site.isEnabled,
comments: WIKI.models.comments.publicConfigFor(site.id)
}
} else {
return reply.notFound('Site does not exist.')

@ -90,6 +90,13 @@ const IDENTITY_PROFILE_FIELDS = ['name', 'location', 'jobTitle', 'pronouns'] as
* a directory must not take somebody's accessibility settings away with it.
*/
const PERSONAL_PROFILE_FIELDS = [
/*
The handle is here rather than among the identity fields on purpose. No identity provider owns a
wiki mention handle there is nothing in a directory for it to be kept in step with so a wiki
that turned profile editing off to keep names authoritative would otherwise have taken away the
one field that lets anybody be mentioned in a comment.
*/
'handle',
'timezone',
'dateFormat',
'timeFormat',

@ -0,0 +1,22 @@
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"pageId" uuid NOT NULL,
"parentId" uuid,
"content" text NOT NULL,
"authorId" uuid,
"authorName" varchar(255) NOT NULL,
"authorEmail" varchar(255) DEFAULT '' NOT NULL,
"authorIP" varchar(255) DEFAULT '' NOT NULL,
"meta" jsonb DEFAULT '{}' NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "handle" varchar(64);--> statement-breakpoint
CREATE INDEX "comments_page_created_idx" ON "comments" ("pageId","createdAt");--> statement-breakpoint
CREATE INDEX "comments_parentId_idx" ON "comments" ("parentId");--> statement-breakpoint
CREATE INDEX "comments_authorId_idx" ON "comments" ("authorId");--> statement-breakpoint
CREATE UNIQUE INDEX "users_handle_idx" ON "users" (lower("handle"));--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_parentId_comments_id_fkey" FOREIGN KEY ("parentId") REFERENCES "comments"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE SET NULL;

File diff suppressed because it is too large Load Diff

@ -16,6 +16,7 @@ import {
uuid,
varchar
} from 'drizzle-orm/pg-core'
import type { AnyPgColumn } from 'drizzle-orm/pg-core'
// == CUSTOM TYPES =====================
@ -209,6 +210,74 @@ export const blocks = pgTable(
(table) => [index('blocks_siteId_idx').on(table.siteId)]
)
// COMMENTS ----------------------------
/**
* One comment on one page, for the BUILT-IN comments provider.
*
* The other providers are a snippet of markup and an account somewhere else, so nothing about them
* reaches this table it exists for the provider that is this wiki. See `models/comments.ts`.
*
* Replies are one level deep and that is enforced in the model: a reply names the comment it answers
* in `parentId`, and a reply to a reply is attached to that reply's own parent rather than nesting
* further. The foreign key is self-referential and cascades, so deleting a comment takes the replies
* under it which is the whole of what a thread is here.
*
* `content` is markdown source and there is no stored render. It is turned into HTML in the reader's
* browser (`frontend/src/renderers/comment.js`) with raw HTML disabled, the same way a page's
* markdown becomes HTML in the browser which also means a mention re-resolves every time it is
* drawn rather than freezing whatever a handle pointed at on the day it was written.
*/
export const comments = pgTable(
'comments',
{
id: uuid().primaryKey().defaultRandom(),
pageId: uuid()
.notNull()
.references(() => pages.id, { onDelete: 'cascade' }),
/**
* The comment this one answers, or null for one that starts a thread.
*
* The annotation breaks the circular inference a self-reference would otherwise cause
* (TS7022/TS7024), the same way the generated column on `pages` does.
*/
parentId: uuid().references((): AnyPgColumn => comments.id, { onDelete: 'cascade' }),
/** Markdown source as it was typed. Never HTML — see the note above. */
content: text().notNull(),
/**
* The account that wrote it, or null for a guest and also null once that account is deleted,
* which is why the name below is kept alongside rather than only joined for.
*/
authorId: uuid().references(() => users.id, { onDelete: 'set null' }),
/**
* Who it says wrote it. What a guest typed into the form, and for a signed-in author a copy of
* their display name as it stood used only when the account behind `authorId` is gone, since a
* rename should show through everywhere else.
*/
authorName: varchar({ length: 255 }).notNull(),
/**
* A guest's email address. Required of a guest, empty for a signed-in author (the account has
* one), and never sent to a client: it is here for the spam check and for whatever moderation
* grows out of it.
*/
authorEmail: varchar({ length: 255 }).notNull().default(''),
/** The address it was posted from, kept for the same reasons as the audit log's. Never served. */
authorIP: varchar({ length: 255 }).notNull().default(''),
/**
* Room for what a comment may grow: votes, a pin, a moderation state. Nothing reads it yet, and
* nothing should write a key into it without deciding what an absent one means.
*/
meta: jsonb().notNull().default({}),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow()
},
(table) => [
// -> The talk view's own query: every comment on a page, oldest first
index('comments_page_created_idx').on(table.pageId, table.createdAt),
index('comments_parentId_idx').on(table.parentId),
index('comments_authorId_idx').on(table.authorId)
]
)
// GROUPS ------------------------------
export const groups = pgTable('groups', {
id: uuid().primaryKey().defaultRandom(),
@ -886,6 +955,15 @@ export const users = pgTable(
id: uuid().primaryKey().defaultRandom(),
email: varchar({ length: 255 }).notNull().unique(),
name: varchar({ length: 255 }).notNull(),
/**
* The name this user is mentioned by in a comment, without the `@`.
*
* Null until they pick one, and a user without one is simply not mentionable nothing is
* derived from their name on their behalf. Unique case-insensitively: `@Ana` and `@ana` have to
* be the same person for a mention to mean anything, so the index below is on the folded form
* while the column keeps the capitalization that was typed.
*/
handle: varchar({ length: 64 }),
auth: jsonb().notNull().default({}),
meta: jsonb().notNull().default({}),
passkeys: jsonb().notNull().default({}),
@ -898,7 +976,12 @@ export const users = pgTable(
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow()
},
(table) => [index('users_lastLoginAt_idx').on(table.lastLoginAt)]
(table) => [
index('users_lastLoginAt_idx').on(table.lastLoginAt),
// -> Folded, so that two handles differing only in case cannot both exist. Nulls are distinct to
// postgres, which is what lets any number of users have no handle at all.
uniqueIndex('users_handle_idx').on(sql`lower(${table.handle})`)
]
)
// == RELATION TABLES ==================

@ -208,6 +208,7 @@ async function postBoot() {
// -> No per-site rows to create: what a site has turned on lives in its own config blob, which the
// sites cache above already holds
await WIKI.models.analytics.refreshFromDisk()
await WIKI.models.comments.refreshFromDisk()
// -> Optional third-party tooling: report what is available, since features silently degrade
// without it

@ -136,6 +136,7 @@
"admin.audit.actions.createApiKey": "Created an API key",
"admin.audit.actions.createApprovalRule": "Created an approval rule",
"admin.audit.actions.createAuthStrategy": "Added an authentication strategy",
"admin.audit.actions.createComment": "Posted a comment",
"admin.audit.actions.createFolder": "Created a folder",
"admin.audit.actions.createGroup": "Created a group",
"admin.audit.actions.createHook": "Created a webhook",
@ -147,6 +148,7 @@
"admin.audit.actions.deleteAuthStrategy": "Deleted an authentication strategy",
"admin.audit.actions.deleteAvatar": "Removed their avatar",
"admin.audit.actions.deleteBlock": "Deleted a custom block",
"admin.audit.actions.deleteComment": "Deleted a comment",
"admin.audit.actions.deleteFolder": "Deleted a folder",
"admin.audit.actions.deleteGroup": "Deleted a group",
"admin.audit.actions.deleteHook": "Deleted a webhook",
@ -207,6 +209,8 @@
"admin.audit.actions.updateAuthStrategy": "Updated an authentication strategy",
"admin.audit.actions.updateAvatar": "Changed their avatar",
"admin.audit.actions.updateBlock": "Changed the blocks of a site",
"admin.audit.actions.updateComment": "Edited a comment",
"admin.audit.actions.updateComments": "Changed the comments configuration",
"admin.audit.actions.updateEditorSettings": "Changed their editor settings",
"admin.audit.actions.updateFlags": "Changed the system flags",
"admin.audit.actions.updateFolder": "Renamed a folder",
@ -247,6 +251,7 @@
"admin.audit.kinds.admin": "Admin",
"admin.audit.kinds.asset": "File",
"admin.audit.kinds.auth": "Sign-in",
"admin.audit.kinds.comment": "Comment",
"admin.audit.kinds.page": "Page",
"admin.audit.kinds.profile": "Profile",
"admin.audit.loadActionsFailed": "Could not load the list of actions.",
@ -351,11 +356,24 @@
"admin.blocks.saveSuccess": "Blocks state saved successfully.",
"admin.blocks.subtitle": "Manage dynamic components available for use inside pages.",
"admin.blocks.title": "Content Blocks",
"admin.comments.active": "In use",
"admin.comments.builtInInfo": "Comments are stored in this wiki and shown on a Talk tab beside the article. Who may read, write and moderate them is decided by the read, write and manage comments page rules.",
"admin.comments.disabledWarn": "Comments are switched off for this site under General → Features, so none of this has any effect until they are switched back on.",
"admin.comments.inactive": "Not in use",
"admin.comments.incomplete": "Incomplete",
"admin.comments.loadFailed": "Could not load the comments configuration.",
"admin.comments.missingFields": "Fill in {fields} — until then this provider shows nothing at all.",
"admin.comments.noneWarn": "No provider is in use, so pages on this site have no comments.",
"admin.comments.provider": "Provider",
"admin.comments.providerConfig": "Provider Configuration",
"admin.comments.providerNoConfig": "This provider has no configuration options you can modify.",
"admin.comments.subtitle": "Add discussions to your wiki pages",
"admin.comments.providerConfiguration": "Provider Configuration",
"admin.comments.providerNoConfiguration": "This provider has nothing to configure.",
"admin.comments.saveFailed": "Could not save the comments configuration.",
"admin.comments.saveSuccess": "Comments configuration saved successfully.",
"admin.comments.subtitle": "Choose how readers discuss the pages of this site",
"admin.comments.thirdPartyInfo": "Comments are handled by this provider and shown under the article. The page rules of this wiki do not apply to them — the provider decides who may take part.",
"admin.comments.title": "Comments",
"admin.comments.useProvider": "Use {provider} for comments",
"admin.comments.website": "Visit Website",
"admin.contribute.title": "Donate",
"admin.dashboard.activeWorkers": "Active Workers",
"admin.dashboard.contributeHelp": "We need your help!",
@ -1648,28 +1666,50 @@
"common.clipboard.uuidFailure": "Failed to copy UUID to clipboard.",
"common.clipboard.uuidSuccess": "Copied UUID to clipboard successfully.",
"common.comments.beFirst": "Be the first to comment.",
"common.comments.charsLeft": "{count} left",
"common.comments.closed": "Comments are turned off for this page.",
"common.comments.contentMissingError": "Comment is empty or too short!",
"common.comments.deleteConfirmTitle": "Confirm Delete",
"common.comments.deleteConfirmTitle": "Delete Comment",
"common.comments.deleteFailed": "Could not delete the comment.",
"common.comments.deletePermanentWarn": "This action cannot be undone!",
"common.comments.deleteSuccess": "Comment was deleted successfully.",
"common.comments.deleteWarn": "Are you sure you want to permanently delete this comment?",
"common.comments.deleteSuccess": "Comment deleted.",
"common.comments.deleteWarn": "Delete this comment? Any replies to it go with it, and neither can be brought back.",
"common.comments.edited": "edited",
"common.comments.fieldContent": "Comment Content",
"common.comments.fieldEmail": "Your Email Address",
"common.comments.fieldEmailHint": "Never shown to anybody. It is used to check the comment for spam.",
"common.comments.fieldName": "Your Name",
"common.comments.loading": "Loading comments...",
"common.comments.guest": "Guest",
"common.comments.loadFailed": "Could not load the comments.",
"common.comments.loading": "Loading comments…",
"common.comments.markdownFormat": "Markdown Format",
"common.comments.markdownHint": "Basic markdown, and {'@'}handle to mention somebody.",
"common.comments.mentionNoMatch": "No one by that handle.",
"common.comments.modified": "modified {reldate}",
"common.comments.newComment": "New Comment",
"common.comments.newPlaceholder": "Write a new comment...",
"common.comments.newPlaceholder": "Write a comment…",
"common.comments.none": "No comments yet.",
"common.comments.notAllowed": "You are not allowed to comment on this page.",
"common.comments.postComment": "Post Comment",
"common.comments.postSuccess": "New comment posted successfully.",
"common.comments.postFailed": "Could not post the comment.",
"common.comments.postReply": "Post Reply",
"common.comments.postSuccess": "Comment posted.",
"common.comments.postingAs": "Posting as {name}",
"common.comments.preview": "Preview",
"common.comments.previewEmpty": "Nothing to preview yet.",
"common.comments.reply": "Reply",
"common.comments.replyPlaceholder": "Write a reply…",
"common.comments.replyingTo": "Replying to {name}",
"common.comments.sdTitle": "Talk",
"common.comments.title": "Comments",
"common.comments.updateComment": "Update Comment",
"common.comments.updateSuccess": "Comment was updated successfully.",
"common.comments.signInToComment": "Sign in to join the discussion.",
"common.comments.tabArticle": "Article",
"common.comments.tabTalk": "Talk",
"common.comments.title": "Discussion",
"common.comments.updateComment": "Save Changes",
"common.comments.updateFailed": "Could not update the comment.",
"common.comments.updateSuccess": "Comment updated.",
"common.comments.viewDiscussion": "View Discussion",
"common.comments.write": "Write",
"common.datetime": "{date} at {time}",
"common.duration.days": "Day(s)",
"common.duration.every": "Every",
@ -2534,6 +2574,8 @@
"profile.groupsInfo": "You're currently part of the following groups:",
"profile.groupsLoadingFailed": "Failed to load groups.",
"profile.groupsNone": "You're not part of any group.",
"profile.handle": "Handle",
"profile.handleHint": "The unique {'@'}name people mention you by in comments. 3 to 32 letters, digits, hyphens or underscores. Leave it empty to not be mentionable.",
"profile.infoLoadingFailed": "Failed to load your profile.",
"profile.jobTitle": "Job Title",
"profile.jobTitleHint": "Your position in your organization; shown on your profile page.",

@ -9,7 +9,7 @@ import { sanitizeMeta } from '../helpers/audit.ts'
* migration. The admin area's filter is built from this list, and `admin.audit.kinds.<kind>` is the
* translation of each.
*/
export const AUDIT_KINDS = ['page', 'asset', 'auth', 'profile', 'admin'] as const
export const AUDIT_KINDS = ['page', 'asset', 'comment', 'auth', 'profile', 'admin'] as const
export type AuditKind = (typeof AUDIT_KINDS)[number]
/**
@ -45,6 +45,7 @@ export const AUDIT_ACTIONS = {
'deleteFolder'
],
asset: ['uploadAsset', 'updateAsset', 'deleteAsset'],
comment: ['createComment', 'updateComment', 'deleteComment'],
auth: [
'login',
'logout',
@ -108,6 +109,7 @@ export const AUDIT_ACTIONS = {
'deleteSiteImage',
'updateStorage',
'updateAnalytics',
'updateComments',
'runStorageAction',
'updateFlags',
'updateSecurity',

@ -0,0 +1,981 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { load } from 'js-yaml'
import { and, asc, count, eq, inArray, sql } from 'drizzle-orm'
import {
comments as commentsTable,
pages as pagesTable,
users as usersTable
} from '../db/schema.ts'
import {
durationToSeconds,
htmlEscape,
isSensitiveMask,
parseModuleProps
} from '../helpers/common.ts'
import type { ModuleProp } from '../helpers/common.ts'
/**
* The key of the provider that IS this wiki, as opposed to the ones that are somebody else's service.
*
* It has no directory under `modules/comments` and never will: what the other providers declare in
* two YAML files, this one implements in a table, a set of routes and a view. Its definition is the
* constant below, so that the admin screen can render its settings through exactly the same form as
* everything else rather than growing a branch for it.
*/
export const BUILTIN_PROVIDER = 'default'
/**
* The three places a provider's markup goes, and the order they are used in.
*
* `head` is loaded once per document a stylesheet, an SDK `main` is the container the widget
* draws itself into, and `body` is the script that starts it, run after the container exists. Unlike
* an analytics tag none of this is served in the HTML: a comment widget belongs at the bottom of the
* article, and moving between wiki pages is a router transition rather than a document load, so a
* snippet baked into the shell would initialise once and then show the first page's discussion for
* ever. `frontend/src/components/PageCommentsEmbed.vue` is what mounts these, per page.
*/
const SLOTS = ['head', 'main', 'body'] as const
type Slot = (typeof SLOTS)[number]
/**
* A placeholder in a provider's code template: `{{<context>:<name>}}`.
*
* The context says how the value is written into the snippet rather than what the value is, because
* the same value goes into different places and escapes differently in each the same contract
* `models/analytics.ts` uses, and the same four contexts.
*
* A name of the form `page.<field>` is NOT resolved here. Those are the placeholders whose value is
* different for every page (`page.url`, `page.id`, `page.path`, `page.title`, `page.locale`), and
* they are left in the rendered string for the browser to fill in as the reader moves from page to
* page see `renderPlaceholder` in `frontend/src/helpers/commentsEmbed.js`, which reads this same
* pattern and escapes by the same rules.
*/
const PLACEHOLDER = /\{\{(js|attr|num|bool):([A-Za-z0-9_.]+)\}\}/g
/** The prefix that marks a placeholder as the browser's to resolve. See `PLACEHOLDER`. */
const PAGE_PREFIX = 'page.'
/** What a character becomes inside a JavaScript string literal. As `models/analytics.ts`, verbatim. */
const JS_ESCAPES: Record<string, string> = {
'\\': '\\\\',
"'": "\\'",
'"': '\\"',
'`': '\\`',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
'<': '\\u003C',
'>': '\\u003E',
'&': '\\u0026',
'\u2028': '\\u2028',
'\u2029': '\\u2029'
}
const JS_ESCAPE_PATTERN = /[\\'"`\n\r\t<>&\u2028\u2029]/g
/**
* The longest a single comment may be, in characters of markdown source.
*
* Not a setting: this is a comment box, and the number is here to keep a page of discussion from
* becoming a page of content. It is enforced by the route schema and repeated to the client so that
* the composer can count down to it rather than discovering it on submit.
*/
export const COMMENT_MAX_LENGTH = 8000
/** The shortest a comment may be, so that an empty box and a stray keystroke are both refused. */
export const COMMENT_MIN_LENGTH = 2
/** How long a client waits between posts when nothing is configured, in seconds. */
const DEFAULT_POST_COOLDOWN = 30
/** What a handle may be made of. Mentions are matched against exactly this. */
export const HANDLE_PATTERN = /^[A-Za-z0-9_-]{3,32}$/
/**
* A mention as it is written in a comment: `@handle`.
*
* The lookbehind is what keeps an email address and a path from being read as one `a@b.com` and
* `docs/@handle` mention nobody. A handle that matches no user is left as the text that was typed,
* here and in the renderer, so a mention never silently becomes a link to the wrong person.
*/
const MENTION_PATTERN = /(?<![\w@/])@([A-Za-z0-9_-]{3,32})/g
/** How long the spam check gets before the comment is let through, in milliseconds. */
const AKISMET_TIMEOUT = 5000
/** A comments module, as declared by its `definition.yml` and `code.yml`. */
export interface CommentsDefinition {
/** Directory name under `modules/comments`, or `default` for the built-in provider. */
key: string
title: string
description: string
/** The provider's own site, linked from the panel beside its configuration. */
website: string
icon: string
props: Record<string, ModuleProp>
/**
* The props that must hold a value before this provider can be used at all.
*
* A comment widget pointed at no account renders an error where the discussion should be, so a
* selected provider missing one of these contributes nothing and the admin screen names the empty
* field instead.
*/
requires: string[]
/** The markup each slot contributes, before any value is substituted into it. */
code: Record<Slot, string>
/** Whether this is the provider implemented by the wiki itself. See `BUILTIN_PROVIDER`. */
isBuiltIn: boolean
}
/** One provider as a site has it configured, which is what the admin area edits. */
export interface CommentsProvider {
key: string
title: string
description: string
website: string
icon: string
isBuiltIn: boolean
/** Whether this is the one provider the site is using. At most one provider is. */
isSelected: boolean
requires: string[]
props: Record<string, ModuleProp>
config: Record<string, any>
}
/** What a client may change about one provider. */
export interface CommentsProviderInput {
key: string
config?: Record<string, any>
}
/**
* What a browser is told about this site's comments, and all it is told.
*
* Carried on the site payload rather than fetched, because every page view needs it and the site
* configuration is already in memory on every instance the same reasoning as the analytics tags.
* Deliberately narrow: the stored configuration of the built-in provider holds an Akismet key, and
* nothing that a `Site` response serializes may go anywhere near it.
*/
export interface CommentsPublicConfig {
/** The selected provider's key, or an empty string when this site has comments turned off. */
provider: string
/** True when `provider` is the wiki's own. The talk view is drawn only for this one. */
isBuiltIn: boolean
/** The third-party markup, with everything but the page placeholders already substituted. */
code: Record<Slot, string>
/** Seconds a client must wait between posts. Built-in only; 0 when there is no cooldown. */
cooldownSeconds: number
/** The cap the composer counts down to. See `COMMENT_MAX_LENGTH`. */
maxLength: number
}
/** One comment as the API answers with it. Neither the email nor the address is ever in here. */
export interface CommentEntry {
id: string
parentId: string | null
content: string
createdAt: Date
updatedAt: Date
/** Null for a guest, and for an author whose account has since been deleted. */
authorId: string | null
authorName: string
/** Whether an avatar can be fetched for `authorId`. False whenever there is no account. */
authorHasAvatar: boolean
/** The author's handle, so a reply can address them without the reader looking it up. */
authorHandle: string | null
/** Whether the comment was written by somebody with no account. */
isGuest: boolean
}
/** A handle that resolved to somebody, as the renderer needs it to draw the mention as a link. */
export interface MentionTarget {
handle: string
id: string
name: string
}
/** What a comment is created with. */
export interface CommentInput {
pageId: string
parentId?: string | null
content: string
authorId: string | null
authorName: string
authorEmail: string
authorIP: string
}
/** The definition of the provider the wiki implements itself. See `BUILTIN_PROVIDER`. */
const BUILTIN_DEFINITION = {
title: 'Built-in Comments',
description:
'Discussions that belong to this wiki: no third-party service, no second account for a reader to create, and nothing leaving the instance. Markdown, one level of replies, and @mentions of anybody who has set a handle.',
/*
Empty on purpose, which is what keeps the "Visit Website" button off this provider's panel. Every
other provider is a service with a site to go and read about; this one is the wiki the
administrator is already looking at.
*/
website: '',
icon: '/_assets/icons/ultraviolet-comments2.svg',
requires: [] as string[],
props: {
postCooldown: {
type: 'String',
title: 'Posting Cooldown',
default: '30s',
hint: 'How long somebody must wait between two comments, counted per account and per address for a guest. Set to 0 for no cooldown.',
icon: 'timer',
order: 1
},
akismetApiKey: {
type: 'String',
title: 'Akismet API Key',
default: '',
sensitive: true,
hint: 'Optional. With a key, every comment is checked against Akismet before it is stored and a comment it calls spam is refused. Left empty, nothing is sent anywhere.',
icon: 'key',
order: 2
}
}
}
/**
* The built-in provider as a definition, built once.
*
* Once rather than per access because `getDefinition` is on the path of `buildConfig`, which the
* public site payload goes through on every bootstrap and re-parsing a constant's props and
* re-sorting them for each of those is work with a known answer.
*/
const BUILTIN: CommentsDefinition = {
key: BUILTIN_PROVIDER,
...BUILTIN_DEFINITION,
props: sortProps(parseModuleProps(BUILTIN_DEFINITION.props)),
code: { head: '', main: '', body: '' },
isBuiltIn: true
}
/** A site with comments turned off, which is every site until somebody picks a provider. */
const NO_PUBLIC_CONFIG: CommentsPublicConfig = {
provider: '',
isBuiltIn: false,
code: { head: '', main: '', body: '' },
cooldownSeconds: 0,
maxLength: COMMENT_MAX_LENGTH
}
/**
* Comments model
*
* Two things wearing one name, and the whole of this file is the seam between them.
*
* **A provider is one module from `modules/comments/<key>/`**, two YAML files exactly as an analytics
* provider is: a `definition.yml` saying what it is and what it needs configured, and a `code.yml`
* holding the markup it contributes. Nothing about such a provider reaches this server at read time
* the discussion lives in somebody else's service and the wiki's only job is to put the right snippet
* at the bottom of the right page.
*
* **The built-in provider is this wiki**, and has no module directory: comments are rows in
* `comments`, served by `api/comments.ts`, drawn on a Talk tab beside the article. Its settings are
* declared in `BUILTIN_DEFINITION` above so that the admin screen renders one kind of form for every
* provider rather than two.
*
* **Only one provider is selected at a time**, which is what makes this different from analytics: two
* analytics tags count the same visit twice and that is a mistake worth warning about, but two comment
* widgets are two separate discussions of the same page, and neither of them is the discussion. The
* configuration of the providers that are NOT selected is kept all the same, so that trying one and
* going back does not mean typing the first one's settings in again.
*
* **Configuration lives in the site's config blob**, under `comments`, for the same reasons the
* analytics configuration does: every page view needs it, `WIKI.sites` already holds the site
* configurations in memory on every instance, and `sites.updateSite` already reloads them across the
* cluster. What a browser is given of it is `publicConfigFor` and nothing else the built-in
* provider's stored configuration holds an Akismet key.
*/
class Comments {
/** Definitions read from disk, refreshed by `refreshFromDisk()`. The built-in one is not among them. */
moduleDefinitions: CommentsDefinition[] = []
/**
* Load the comments module definitions from disk.
*
* One directory per provider, each with both files. A directory missing either is skipped with a
* warning rather than emptying the list, as in `models/analytics.ts`: a provider that cannot be
* read is one provider nobody can select, where an empty list would take down the discussions of
* every site that had already selected one.
*/
async refreshFromDisk(): Promise<void> {
const modulesPath = path.join(WIKI.SERVERPATH, 'modules/comments')
const definitions: CommentsDefinition[] = []
try {
for (const dir of await fs.readdir(modulesPath)) {
try {
const parsed = load(
await fs.readFile(path.join(modulesPath, dir, 'definition.yml'), 'utf8')
) as Record<string, any>
const code = load(
await fs.readFile(path.join(modulesPath, dir, 'code.yml'), 'utf8')
) as Record<string, any>
definitions.push({
key: dir,
title: parsed.title ?? dir,
description: parsed.description ?? '',
website: parsed.website ?? '',
icon: parsed.icon ?? '',
props: sortProps(parseModuleProps(parsed.props ?? {})),
requires: parsed.requires ?? [],
code: {
head: typeof code?.head === 'string' ? code.head.trim() : '',
main: typeof code?.main === 'string' ? code.main.trim() : '',
body: typeof code?.body === 'string' ? code.body.trim() : ''
},
isBuiltIn: false
})
} catch (err: any) {
WIKI.logger.warn(`Skipping comments module ${dir}: ${err.message}`)
}
}
this.moduleDefinitions = definitions.sort((a, b) => a.title.localeCompare(b.title))
WIKI.logger.info(`Found ${this.moduleDefinitions.length} comments modules [ OK ]`)
} catch (err: any) {
this.moduleDefinitions = []
WIKI.logger.error(
`Could not read the comments module definitions at ${modulesPath} [ FAILED ]`
)
WIKI.logger.error(err.message)
}
}
/**
* Every provider that can be selected, the wiki's own first.
*
* First rather than sorted in with the rest because it is the one that needs nothing set up, and
* because it is what an administrator opening this screen is most likely to be looking for.
*/
get definitions(): CommentsDefinition[] {
return [BUILTIN, ...this.moduleDefinitions]
}
/** A single definition, or null when nothing declares that key. */
getDefinition(key: string): CommentsDefinition | null {
return this.definitions.find((d) => d.key === key) ?? null
}
/** What a site has stored under `comments`. Empty for a site that has never saved this screen. */
storedConfig(siteId: string): { provider?: string; providers?: Record<string, any> } {
return WIKI.sites[siteId]?.config?.comments ?? {}
}
/**
* The key of the provider this site uses, or an empty string when it uses none.
*
* A key that no longer names anything on disk reads as none: a module removed from an installation
* must not leave the site serving the snippet of a provider that is no longer there.
*/
selectedProvider(siteId: string | undefined): string {
if (!siteId) {
return ''
}
const key = this.storedConfig(siteId).provider ?? ''
return key && this.getDefinition(key) ? key : ''
}
/**
* Whether this site has comments at all the switch under **General Features**.
*
* Separate from which provider is selected, and checked separately: the provider is a choice an
* administrator made and must survive being turned off, which is the whole point of having a
* switch rather than expecting them to clear the selection. Absent reads as on, since a site
* configuration saved before this key existed has no opinion about it.
*
* Deliberately NOT folded into `selectedProvider`, which the admin screen reads to show what is
* selected: a screen that reported "no provider in use" because the master switch is off would
* then save that back as the truth.
*/
isAllowed(siteId: string | undefined): boolean {
return siteId ? WIKI.sites[siteId]?.config?.features?.comments !== false : false
}
/** Whether this site's comments are the wiki's own, which is what the talk view is drawn for. */
usesBuiltIn(siteId: string | undefined): boolean {
return this.isAllowed(siteId) && this.selectedProvider(siteId) === BUILTIN_PROVIDER
}
/**
* Every provider installed, with what this site has configured for it merged in.
*
* Driven by the definitions rather than by what is stored, so a provider nobody has touched is
* listed with its defaults and one dropped from disk simply stops appearing its stored values
* stay in the site config, ignored, until the screen is next saved.
*/
getSiteProviders(siteId: string): CommentsProvider[] {
const stored = this.storedConfig(siteId)
const selected = this.selectedProvider(siteId)
return this.definitions.map((definition) => ({
key: definition.key,
title: definition.title,
description: definition.description,
website: definition.website,
icon: definition.icon,
isBuiltIn: definition.isBuiltIn,
isSelected: definition.key === selected,
requires: definition.requires,
props: definition.props,
config: this.buildConfig(definition.key, {}, stored.providers?.[definition.key]?.config ?? {})
}))
}
/**
* Merge incoming config values onto the ones already stored, keeping only what the module declares.
*
* Unknown keys are dropped rather than refused, so a provider that loses a prop does not make the
* screen unsaveable. Read-only props are never taken from the client, and a sensitive prop sent
* back as the mask means "leave it alone" which is the whole reason the mask exists.
*/
buildConfig(
moduleKey: string,
incoming: Record<string, any> = {},
existing: Record<string, any> = {}
): Record<string, any> {
const props = this.getDefinition(moduleKey)?.props ?? {}
const config: Record<string, any> = {}
for (const [key, prop] of Object.entries(props)) {
const current = existing[key] !== undefined ? existing[key] : prop.default
const keep =
prop.readOnly || incoming[key] === undefined || isSensitiveMask(prop, incoming[key])
config[key] = keep ? current : incoming[key]
}
return config
}
/**
* Check an incoming provider patch against what the module declares.
*
* The props are a runtime declaration read from a YAML file, so no JSON Schema can cover them.
*
* @returns The reason it is invalid, or null when it is fine
*/
validateProvider(patch: CommentsProviderInput): string | null {
const definition = this.getDefinition(patch.key)
if (!definition) {
return `There is no comments provider called "${patch.key}".`
}
for (const [key, value] of Object.entries(patch.config ?? {})) {
const prop = definition.props[key]
if (!prop || prop.readOnly || value === undefined) {
continue
}
if (prop.enum) {
// -> Enum entries are declared as `value` or `value|label`
const allowed = prop.enum.map((entry) => entry.split('|')[0])
if (!allowed.includes(`${value}`)) {
return `"${value}" is not a valid value for ${prop.title}.`
}
continue
}
switch (prop.type) {
case 'boolean':
if (typeof value !== 'boolean') {
return `${prop.title} must be true or false.`
}
break
case 'number':
if (typeof value !== 'number' || !Number.isFinite(value)) {
return `${prop.title} must be a number.`
}
break
default:
if (typeof value !== 'string') {
return `${prop.title} must be a string.`
}
}
}
if (patch.key === BUILTIN_PROVIDER) {
const cooldown = `${patch.config?.postCooldown ?? ''}`.trim()
if (cooldown.length > 0 && cooldown !== '0' && durationToSeconds(cooldown, 0) < 1) {
return 'The posting cooldown must be a duration such as 30s, 2m or 1h — or 0 for none.'
}
}
return null
}
/**
* Which of a provider's required props are empty, in declaration order.
*
* The same question the admin area asks of the form in front of it, so that "Giscus is selected but
* has no repository" is something an administrator reads on the screen rather than discovering from
* a widget that draws an error where the discussion should be.
*/
missingRequired(definition: CommentsDefinition, config: Record<string, any>): string[] {
return definition.requires.filter((key) => {
const value = config[key]
return value === undefined || value === null || `${value}`.trim().length < 1
})
}
/**
* Write the selected provider and whatever configuration came with it.
*
* One write for the lot, through `sites.updateSite`, which is what reloads the cached configuration
* on every instance without which a provider switched over would not take effect until a restart.
* The providers a client did not mention keep what they had, which is what lets an administrator
* try another one and come back to a form that is still filled in.
*/
async updateSiteConfig(
siteId: string,
input: { provider?: string; providers?: CommentsProviderInput[] }
): Promise<void> {
const stored = this.storedConfig(siteId)
const providers: Record<string, { config: Record<string, any> }> = {}
for (const [key, value] of Object.entries(stored.providers ?? {})) {
providers[key] = { config: (value as any)?.config ?? {} }
}
for (const patch of input.providers ?? []) {
providers[patch.key] = {
config: this.buildConfig(patch.key, patch.config ?? {}, providers[patch.key]?.config ?? {})
}
}
const provider = input.provider !== undefined ? input.provider : (stored.provider ?? '')
await WIKI.models.sites.updateSite(siteId, { config: { comments: { provider, providers } } })
}
/**
* The stored configuration of one provider, completed from its defaults.
*
* This is the real thing, secrets and all the mask is applied at the API boundary and nowhere
* earlier, exactly as it is for storage targets and authentication strategies.
*/
configFor(siteId: string | undefined, key: string): Record<string, any> {
if (!siteId) {
return {}
}
return this.buildConfig(key, {}, this.storedConfig(siteId).providers?.[key]?.config ?? {})
}
/**
* What a browser is told about this site's comments. See `CommentsPublicConfig`.
*
* Built per call rather than cached: it is a handful of string substitutions over a configuration
* already in memory, and the answer has to change the moment the admin screen is saved.
*/
publicConfigFor(siteId: string | undefined): CommentsPublicConfig {
const key = this.isAllowed(siteId) ? this.selectedProvider(siteId) : ''
if (!key) {
return NO_PUBLIC_CONFIG
}
const definition = this.getDefinition(key)!
const config = this.configFor(siteId, key)
if (this.missingRequired(definition, config).length > 0) {
// -> Selected but not finished. Nothing is drawn rather than a widget pointed at no account.
return NO_PUBLIC_CONFIG
}
if (definition.isBuiltIn) {
return {
provider: key,
isBuiltIn: true,
code: { head: '', main: '', body: '' },
cooldownSeconds: this.cooldownFor(siteId),
maxLength: COMMENT_MAX_LENGTH
}
}
const code: Record<Slot, string> = { head: '', main: '', body: '' }
for (const slot of SLOTS) {
code[slot] = renderTemplate(definition.code[slot], config) ?? ''
}
return {
provider: key,
isBuiltIn: false,
code,
cooldownSeconds: 0,
maxLength: COMMENT_MAX_LENGTH
}
}
/**
* How long this site makes a client wait between two comments, in seconds.
*
* `0` is no cooldown at all, and so is a value that will not parse the setting is a duration an
* administrator typed, and a limit nobody can explain is worse than none.
*/
cooldownFor(siteId: string | undefined): number {
const raw = `${this.configFor(siteId, BUILTIN_PROVIDER).postCooldown ?? ''}`.trim()
if (raw === '0' || raw.length < 1) {
return 0
}
return durationToSeconds(raw, DEFAULT_POST_COOLDOWN)
}
// == BUILT-IN PROVIDER ===============
//
// Everything below is the wiki's own comments. None of it is reachable for a site that has selected
// one of the module providers: the routes check `usesBuiltIn` before anything else, because a
// comment stored here for a site whose discussions live at Disqus is a comment nobody will ever see.
/**
* Every comment on a page, oldest first, with its author.
*
* One query with a left join rather than a fetch per author: a talk page is a list, and the author
* of each row is part of what a list of comments IS. The join is left because `authorId` is null
* for a guest and null again once an account is deleted, and in both cases the name stored on the
* row is what stands in.
*
* Ordering is flat and by time; the one level of nesting is assembled by the view from `parentId`,
* which keeps a reply beside the comment it answers however old that comment is.
*/
async listForPage(pageId: string, limit = 500): Promise<CommentEntry[]> {
const rows = await WIKI.db
.select({
id: commentsTable.id,
parentId: commentsTable.parentId,
content: commentsTable.content,
createdAt: commentsTable.createdAt,
updatedAt: commentsTable.updatedAt,
authorId: commentsTable.authorId,
storedName: commentsTable.authorName,
userName: usersTable.name,
userHandle: usersTable.handle,
userHasAvatar: usersTable.hasAvatar
})
.from(commentsTable)
.leftJoin(usersTable, eq(usersTable.id, commentsTable.authorId))
.where(eq(commentsTable.pageId, pageId))
.orderBy(asc(commentsTable.createdAt))
.limit(limit)
return rows.map((row) => ({
id: row.id,
parentId: row.parentId,
content: row.content,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
authorId: row.authorId,
// -> The live name where there is still an account behind it, so that a rename shows through
// everywhere; the copy taken at the time is what is left when there is not
authorName: row.userName ?? row.storedName,
authorHasAvatar: row.userHasAvatar ?? false,
authorHandle: row.userHandle ?? null,
isGuest: row.authorId === null
}))
}
/**
* The page a comment is about, as everything that guards one needs it.
*
* Its path, locale and tags because that is what a page rule is matched against, and
* `allowComments` because a page can be closed to discussion from its own properties dialog
* whatever the site has configured. Deliberately not `pages.getPage` that assembles a page for
* reading, and this is four columns and a scoping check.
*
* @returns The reference, or null when no such page exists on this site
*/
async pageRef(siteId: string, pageId: string) {
const [row] = await WIKI.db
.select({
id: pagesTable.id,
path: pagesTable.path,
locale: pagesTable.locale,
title: pagesTable.title,
tags: pagesTable.tags,
allowComments: sql<boolean>`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`
})
.from(pagesTable)
.where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId)))
return row ?? null
}
/** How many comments a page has. What the Talk tab's badge counts. */
async countForPage(pageId: string): Promise<number> {
const [row] = await WIKI.db
.select({ total: count() })
.from(commentsTable)
.where(eq(commentsTable.pageId, pageId))
return Number(row?.total ?? 0)
}
/** One comment with the page it is on, which is what every permission check on it needs. */
async getWithPage(commentId: string, siteId: string) {
const [row] = await WIKI.db
.select({
id: commentsTable.id,
parentId: commentsTable.parentId,
content: commentsTable.content,
authorId: commentsTable.authorId,
pageId: commentsTable.pageId,
path: pagesTable.path,
locale: pagesTable.locale,
tags: pagesTable.tags,
allowComments: sql<boolean>`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`
})
.from(commentsTable)
.innerJoin(pagesTable, eq(pagesTable.id, commentsTable.pageId))
.where(and(eq(commentsTable.id, commentId), eq(pagesTable.siteId, siteId)))
return row ?? null
}
/**
* Store a comment.
*
* Replies are one level deep, and this is where that is true: a `parentId` naming a comment that is
* itself a reply is rewritten to that reply's own parent, so answering the third message in a thread
* puts the answer at the bottom of the thread rather than starting a fourth level of indentation.
* A `parentId` on another page is refused outright that is not a thread, it is a mistake.
*/
async create(input: CommentInput): Promise<CommentEntry> {
let parentId: string | null = null
if (input.parentId) {
const [parent] = await WIKI.db
.select({ id: commentsTable.id, parentId: commentsTable.parentId })
.from(commentsTable)
.where(and(eq(commentsTable.id, input.parentId), eq(commentsTable.pageId, input.pageId)))
if (!parent) {
throw new Error('The comment being replied to is not on this page.')
}
parentId = parent.parentId ?? parent.id
}
const [row] = await WIKI.db
.insert(commentsTable)
.values({
pageId: input.pageId,
parentId,
content: input.content,
authorId: input.authorId,
authorName: input.authorName,
authorEmail: input.authorEmail,
authorIP: input.authorIP
})
.returning()
return this.describe(row!)
}
/** Replace the text of a comment. Who may is decided by the route; this only writes. */
async update(commentId: string, content: string): Promise<CommentEntry | null> {
const [row] = await WIKI.db
.update(commentsTable)
.set({ content, updatedAt: new Date() })
.where(eq(commentsTable.id, commentId))
.returning()
return row ? this.describe(row) : null
}
/**
* Delete a comment, and with it any replies underneath.
*
* The replies go by the foreign key's own cascade rather than by a second statement: a reply exists
* to answer something, and left behind it would be half of a conversation nobody can read.
*
* @returns How many rows went, replies included
*/
async remove(commentId: string): Promise<number> {
const replies = await WIKI.db
.select({ total: count() })
.from(commentsTable)
.where(eq(commentsTable.parentId, commentId))
const result = await WIKI.db.delete(commentsTable).where(eq(commentsTable.id, commentId))
return (result.rowCount ?? 0) > 0 ? 1 + Number(replies[0]?.total ?? 0) : 0
}
/**
* The users that the handles written in these comments point at.
*
* Resolved per response rather than per comment, and as one query: a talk page is a list of comments
* that mention each other, and asking the database once per `@` would be one query per mention. What
* comes back is only the handles that exist the renderer leaves the rest as the text that was
* typed, which is what keeps a mention from ever linking to the wrong person.
*/
async resolveMentions(contents: string[]): Promise<MentionTarget[]> {
const handles = new Set<string>()
for (const content of contents) {
for (const match of content.matchAll(MENTION_PATTERN)) {
handles.add(match[1]!.toLowerCase())
}
}
if (handles.size < 1) {
return []
}
const rows = await WIKI.db
.select({ id: usersTable.id, name: usersTable.name, handle: usersTable.handle })
.from(usersTable)
.where(
and(
eq(usersTable.isActive, true),
eq(usersTable.isSystem, false),
inArray(sql`lower(${usersTable.handle})`, [...handles])
)
)
return rows.map((row) => ({ id: row.id, name: row.name, handle: row.handle! }))
}
/**
* Users whose handle or name starts with what has been typed after an `@`.
*
* Only users who have set a handle, because a handle is what a mention is written with there is
* nothing to insert for anybody else. Ordered by handle so that the list is stable as it narrows.
*/
async searchHandles(query: string, limit = 8): Promise<MentionTarget[]> {
// -> `%` and `_` are wildcards to LIKE and ordinary characters to somebody typing a name, so
// they are escaped rather than passed through: `@%` is a search for a handle containing a
// percent sign, not a request for every user on the wiki
const term = query
.trim()
.toLowerCase()
.replace(/[\\%_]/g, '\\$&')
const rows = await WIKI.db
.select({ id: usersTable.id, name: usersTable.name, handle: usersTable.handle })
.from(usersTable)
.where(
and(
eq(usersTable.isActive, true),
eq(usersTable.isSystem, false),
sql`${usersTable.handle} is not null`,
term.length > 0
? sql`(lower(${usersTable.handle}) like ${term + '%'} or lower(${usersTable.name}) like ${'%' + term + '%'})`
: sql`true`
)
)
.orderBy(asc(sql`lower(${usersTable.handle})`))
.limit(limit)
return rows.map((row) => ({ id: row.id, name: row.name, handle: row.handle! }))
}
/**
* Ask Akismet whether a comment is spam.
*
* Only when a key is configured; with none, nothing is sent anywhere, which is the default and is
* what a wiki that never opened its comments to the public wants.
*
* **It fails open.** A network blip, a revoked key or a timeout answers "not spam" and logs it,
* because the alternative is a wiki that silently stops accepting comments for a reason nobody can
* see from the inside. A key that is wrong is a configuration problem to be found on the admin
* screen, not a reason to lose a reader's paragraph.
*
* @returns Whether the comment should be refused
*/
async isSpam(
siteId: string,
comment: {
content: string
authorName: string
authorEmail: string
authorIP: string
userAgent: string
referrer: string
permalink: string
isGuest: boolean
}
): Promise<boolean> {
const key = `${this.configFor(siteId, BUILTIN_PROVIDER).akismetApiKey ?? ''}`.trim()
if (key.length < 1) {
return false
}
const site = WIKI.sites[siteId]
const blog = site?.hostname ? `https://${site.hostname}` : comment.permalink
const body = new URLSearchParams({
blog,
user_ip: comment.authorIP,
user_agent: comment.userAgent,
referrer: comment.referrer,
permalink: comment.permalink,
comment_type: 'comment',
comment_author: comment.authorName,
comment_author_email: comment.authorEmail,
comment_content: comment.content,
// -> Akismet weighs a signed-in commenter differently from an anonymous one, and this is the
// only place that distinction is worth passing on
...(comment.isGuest ? {} : { user_role: 'subscriber' })
})
try {
const resp = await fetch(
`https://${encodeURIComponent(key)}.rest.akismet.com/1.1/comment-check`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
signal: AbortSignal.timeout(AKISMET_TIMEOUT)
}
)
const text = (await resp.text()).trim()
if (text !== 'true' && text !== 'false') {
// -> Akismet says what is wrong in a header rather than in the body, and an invalid key comes
// back as `invalid` with the reason there
WIKI.logger.warn(
`Akismet answered "${text}" (${resp.headers.get('x-akismet-debug-help') ?? 'no detail'}); the comment was let through.`
)
return false
}
return text === 'true'
} catch (err: any) {
WIKI.logger.warn(
`Akismet could not be reached (${err.message}); the comment was let through.`
)
return false
}
}
/** One stored row as the API answers with it, for a write that already knows its author. */
private describe(row: typeof commentsTable.$inferSelect): CommentEntry {
return {
id: row.id,
parentId: row.parentId,
content: row.content,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
authorId: row.authorId,
authorName: row.authorName,
authorHasAvatar: false,
authorHandle: null,
isGuest: row.authorId === null
}
}
}
/** Props in the order the module meant them to be shown in, applied once so every consumer agrees. */
function sortProps(props: Record<string, ModuleProp>): Record<string, ModuleProp> {
return Object.fromEntries(Object.entries(props).sort(([, a], [, b]) => a.order - b.order))
}
/** A value as it is written into a JavaScript string literal. See `JS_ESCAPES`. */
function jsEscape(value: string): string {
return value.replace(JS_ESCAPE_PATTERN, (char) => JS_ESCAPES[char]!)
}
/**
* Substitute a provider's configured values into one of its templates.
*
* Page placeholders are left exactly as they were written, for the browser to resolve per page see
* `PLACEHOLDER`.
*
* @returns The markup, or null where a placeholder could not be resolved to something that would
* parse: a `num` slot is a bare numeric literal, and a value that is not a number would be a syntax
* error taking the whole snippet with it.
*/
function renderTemplate(template: string, config: Record<string, any>): string | null {
if (!template) {
return ''
}
let usable = true
const rendered = template.replace(PLACEHOLDER, (match, context: string, key: string) => {
if (key.startsWith(PAGE_PREFIX)) {
return match
}
const value = config[key]
switch (context) {
case 'num': {
const num = Number(value)
if (!Number.isFinite(num)) {
usable = false
return '0'
}
return `${num}`
}
case 'bool':
return value === true ? 'true' : 'false'
case 'attr':
return htmlEscape(`${value ?? ''}`)
default:
return jsEscape(`${value ?? ''}`)
}
})
return usable ? rendered : null
}
export const comments = new Comments()

@ -250,9 +250,16 @@ class Groups {
permissions: ['read:pages', 'read:assets', 'read:comments'],
rules: [
{
/*
`write:comments` is granted here while the group-wide list above leaves it out, and the
two lists are answering different questions: the rule is what `checkAccess` reads for a
page permission, and the list above is checked by the route hook, which only understands
global permissions. Without it in the rule, a wiki that turns comments on has a
discussion nobody but an administrator can join.
*/
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments'],
roles: ['read:pages', 'read:assets', 'read:comments', 'write:comments'],
match: 'START',
mode: 'ALLOW',
path: '',
@ -270,7 +277,13 @@ class Groups {
{
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments'],
/*
Named in a rule that DENIES them, which is a fresh install being private rather than a
statement about comments: an operator opening the wiki up flips this one rule to ALLOW,
and what they get is the set the guests group is allowed to hold (`GUEST_ROLES`) rather
than a public wiki whose readers still cannot say anything.
*/
roles: ['read:pages', 'read:assets', 'read:comments', 'write:comments'],
match: 'START',
mode: 'DENY',
path: '',
@ -325,6 +338,9 @@ class Groups {
async createGroup(name: string): Promise<string> {
const startingPermissions = ['read:pages', 'read:assets', 'read:comments']
// -> The rule grants one more than the group-wide list does: see the note on the Users group in
// `init()` for why the two differ
const startingRoles = [...startingPermissions, 'write:comments']
const result = await WIKI.db
.insert(groupsTable)
.values({
@ -336,7 +352,7 @@ class Groups {
{
id: uuid(),
name: 'Default Rule',
roles: startingPermissions,
roles: startingRoles,
match: 'START',
mode: 'ALLOW',
path: '',

@ -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 { comments } from './comments.ts'
import { extensions } from './extensions.ts'
import { flags } from './flags.ts'
import { groups } from './groups.ts'
@ -39,6 +40,7 @@ export default {
auditLog,
authentication,
blocks,
comments,
extensions,
flags,
groups,

@ -129,10 +129,23 @@ class Sites {
collaborativeEditing: true,
ratings: false,
ratingsMode: 'off',
comments: false,
// -> On, because what decides whether a site has comments is whether a provider has
// been picked. This is the switch that turns them all off without losing that
// choice, which is only useful to somebody who has already made it.
comments: true,
reasonForChange: 'optional',
search: true
},
/*
The wiki's own provider, so that a site with comments turned on has somewhere for them
to go without an administrator having to choose first. Every alternative is somebody
else's service with an account to open; this one needs nothing set up. Whether there
are comments at all is `features.comments` above -- see `models/comments.ts`.
*/
comments: {
provider: 'default',
providers: {}
},
logoUrl: '',
logoText: true,
sitemap: true,
@ -399,10 +412,14 @@ class Sites {
collaborativeEditing: true,
ratings: false,
ratingsMode: 'off',
comments: false,
comments: true,
reasonForChange: 'optional',
search: true
},
comments: {
provider: 'default',
providers: {}
},
logoText: true,
sitemap: true,
robots: {

@ -15,6 +15,8 @@ import { nanoid } from 'nanoid'
import { flatten, uniq } from 'es-toolkit/array'
import { detectImageMime, resizeImageToSquareJpeg } from '../helpers/images.ts'
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from '../helpers/totp.ts'
import { CustomError } from '../helpers/common.ts'
import { HANDLE_PATTERN } from './comments.ts'
import type { AuthStrategy, ProviderProfile } from './authentication.ts'
import type { SystemIds } from './types.ts'
@ -87,6 +89,8 @@ export interface UserProfileAuthMethod {
export interface UserPatch {
name?: string
email?: string
/** The mention handle, or null to take it off. See the column in `db/schema.ts`. */
handle?: string | null
isActive?: boolean
isVerified?: boolean
meta?: Record<string, any>
@ -102,6 +106,8 @@ export interface UserProfile {
name: string
email: string
hasAvatar: boolean
/** The name this user is mentioned by in a comment, without the `@`. Empty when they have none. */
handle: string
location: string
jobTitle: string
pronouns: string
@ -125,6 +131,7 @@ export interface PublicUserProfile {
id: string
name: string
hasAvatar: boolean
handle: string
location: string
jobTitle: string
pronouns: string
@ -135,6 +142,7 @@ export interface PublicUserProfile {
/** The fields a user may change on its own profile. Notably not the email, nor any admin flag. */
export interface UserProfilePatch {
name?: string
handle?: string
location?: string
jobTitle?: string
pronouns?: string
@ -577,6 +585,9 @@ class Users {
name: user.name,
email: user.email,
hasAvatar: user.hasAvatar,
// -> A column of its own rather than a `meta` key, because it has to be unique across the
// wiki: `@ana` means one person or it means nothing
handle: user.handle ?? '',
location: meta.location ?? '',
jobTitle: meta.jobTitle ?? '',
pronouns: meta.pronouns ?? '',
@ -611,6 +622,8 @@ class Users {
id: user.id,
name: user.name,
hasAvatar: user.hasAvatar,
// -> Public on purpose: it is written into every comment that mentions them
handle: user.handle ?? '',
location: meta.location ?? '',
jobTitle: meta.jobTitle ?? '',
pronouns: meta.pronouns ?? '',
@ -692,7 +705,29 @@ class Users {
if (patch.name !== undefined) {
values.name = patch.name
}
await this.updateUser(id, values)
if (patch.handle !== undefined) {
const handle = patch.handle.trim()
if (handle.length > 0 && !HANDLE_PATTERN.test(handle)) {
throw new CustomError(
'userHandleInvalid',
'A handle is 3 to 32 characters of letters, digits, hyphens and underscores.'
)
}
// -> Empty is how a handle is taken off, and null rather than '' is what the unique index
// needs: postgres counts nulls as distinct, so any number of users may have none
values.handle = handle.length > 0 ? handle : null
}
try {
await this.updateUser(id, values)
} catch (err: any) {
// -> 23505 is the unique index on `lower(handle)`. It is the one failure here a user can fix,
// and the only one worth turning into a sentence rather than a 500.
if (err.code === '23505') {
throw new CustomError('userHandleTaken', 'That handle is already taken.', 409)
}
throw err
}
return this.getProfile(id)
}

@ -0,0 +1,16 @@
head: |
<link href="{{attr:server}}/dist/Artalk.css" rel="stylesheet">
<script src="{{attr:server}}/dist/Artalk.js"></script>
main: |
<div class="artalk-container"></div>
body: |
<script>
Artalk.init({
el: '.artalk-container',
pageKey: '{{js:page.path}}',
pageTitle: '{{js:page.title}}',
server: '{{js:server}}',
site: '{{js:siteName}}',
darkMode: {{bool:darkMode}} ? 'auto' : false
})
</script>

@ -0,0 +1,27 @@
title: Artalk
description: A light, self-hosted comment system with its own moderation dashboard, notifications and captcha. One Artalk instance can serve several sites.
website: https://artalk.js.org
icon: '/_assets/icons/ultraviolet-artalk.svg'
requires: ['server']
props:
server:
type: String
title: Server URL
default: ''
hint: 'Publicly reachable URL of your Artalk instance, with the scheme and without a trailing slash, e.g. https://artalk.example.com'
icon: dns
order: 1
siteName:
type: String
title: Site Name
default: ''
hint: The site as it is named in the Artalk dashboard. Leave empty to use its default site.
icon: rename
order: 2
darkMode:
type: Boolean
title: Follow Dark Mode
default: true
hint: Let Artalk follow the reader's colour scheme instead of always drawing itself light.
icon: 3d-touch
order: 3

@ -0,0 +1,4 @@
head: |
<script defer src="{{attr:instanceUrl}}/comentario.js"></script>
main: |
<comentario-comments page-id="/{{attr:page.path}}" auto-init="{{bool:autoInit}}"></comentario-comments>

@ -0,0 +1,20 @@
title: Comentario
description: A privacy-friendly, self-hosted comment engine, and the maintained successor to Commento. No tracking, optional anonymous comments, and moderation built in.
website: https://comentario.app
icon: '/_assets/icons/ultraviolet-comentario.svg'
requires: ['instanceUrl']
props:
instanceUrl:
type: String
title: Instance URL
default: ''
hint: 'URL of your Comentario instance, with the scheme and without a trailing slash, e.g. https://comentario.example.com'
icon: dns
order: 1
autoInit:
type: Boolean
title: Auto Initialize
default: true
hint: Let Comentario set itself up as soon as its script loads. Turn this off only if you are driving it yourself from the theme's custom code.
icon: apply
order: 2

@ -0,0 +1,14 @@
main: |
<div id="discourse-comments"></div>
body: |
<script>
window.DiscourseEmbed = {
discourseUrl: '{{js:discourseUrl}}',
discourseEmbedUrl: '{{js:page.url}}',
discourseUserName: '{{js:discourseUserName}}'
}
var s = document.createElement('script')
s.src = window.DiscourseEmbed.discourseUrl + 'javascripts/embed.js'
s.async = true
document.head.appendChild(s)
</script>

@ -0,0 +1,20 @@
title: Discourse
description: Turn a Discourse forum into the comments of your wiki. Each page gets a topic in the category you choose, and the discussion carries on in the forum itself.
website: https://www.discourse.org
icon: '/_assets/icons/ultraviolet-discourse.svg'
requires: ['discourseUrl']
props:
discourseUrl:
type: String
title: Forum URL
default: ''
hint: 'URL of your Discourse forum, with the scheme and a trailing slash, e.g. https://forum.example.com/ . The wiki''s hostname must be listed under its Embedding settings.'
icon: discussion-forum
order: 1
discourseUserName:
type: String
title: Posting Username
default: ''
hint: Discourse account new topics are created as. Leave empty to use the one set as the embeddable host's default.
icon: contact
order: 2

@ -0,0 +1,20 @@
main: |
<div id="disqus_thread"></div>
body: |
<script>
window.disqus_config = function () {
this.page.url = '{{js:page.url}}'
this.page.identifier = '{{js:page.id}}'
this.page.title = '{{js:page.title}}'
}
if (window.DISQUS) {
// -> Already loaded by an earlier page: Disqus will not re-read the config on its own, and
// reset is what makes it look at the thread the reader is on now
window.DISQUS.reset({ reload: true, config: window.disqus_config })
} else {
var s = document.createElement('script')
s.src = 'https://{{js:shortname}}.disqus.com/embed.js'
s.setAttribute('data-timestamp', +new Date())
document.head.appendChild(s)
}
</script>

@ -0,0 +1,13 @@
title: Disqus
description: The largest hosted commenting service, with a shared identity across every site that uses it. Free with advertising; paid plans remove it.
website: https://disqus.com
icon: '/_assets/icons/ultraviolet-disqus.svg'
requires: ['shortname']
props:
shortname:
type: String
title: Shortname
default: ''
hint: The unique identifier Disqus gave your site, as it appears in its admin under Settings → General.
icon: rename
order: 1

@ -0,0 +1,17 @@
body: |
<script src="https://giscus.app/client.js"
data-repo="{{attr:repo}}"
data-repo-id="{{attr:repoId}}"
data-category="{{attr:category}}"
data-category-id="{{attr:categoryId}}"
data-mapping="{{attr:mapping}}"
data-term="{{attr:page.path}}"
data-reactions-enabled="{{bool:reactionsEnabled}}"
data-emit-metadata="0"
data-input-position="top"
data-theme="{{attr:theme}}"
data-lang="{{attr:lang}}"
data-loading="lazy"
crossorigin="anonymous"
async>
</script>

@ -0,0 +1,71 @@
title: Giscus
description: Comments backed by GitHub Discussions, in the repository of your choice. Readers comment with their GitHub account, and every discussion stays in a repository you own.
website: https://giscus.app
icon: '/_assets/icons/ultraviolet-giscus.svg'
requires: ['repo', 'repoId', 'categoryId']
props:
repo:
type: String
title: Repository
default: ''
hint: 'Owner and name of the repository discussions are stored in, e.g. requarks/wiki. It must be public, with the giscus app installed and Discussions turned on.'
icon: github
order: 1
repoId:
type: String
title: Repository ID
default: ''
hint: The repository identifier giscus.app generates for you, starting with R_.
icon: rename
order: 2
category:
type: String
title: Discussion Category
default: 'Announcements'
hint: Name of the category new discussions are created in.
icon: list
order: 3
categoryId:
type: String
title: Category ID
default: ''
hint: The category identifier giscus.app generates for you, starting with DIC_.
icon: rename
order: 4
mapping:
type: String
title: Page Mapping
default: 'pathname'
enum:
- 'pathname|Page path'
- 'url|Full page URL'
- 'title|Page title'
- 'og:title|Open Graph title'
hint: What ties a wiki page to its discussion. The page path is the stable choice; a page moved to another path starts a new discussion under any of them.
icon: link
order: 5
theme:
type: String
title: Theme
default: 'preferred_color_scheme'
enum:
- 'preferred_color_scheme|Follow the reader'
- 'light|Light'
- 'dark|Dark'
- 'transparent_dark|Transparent dark'
icon: 3d-touch
order: 6
reactionsEnabled:
type: Boolean
title: Reactions
default: true
hint: Show the reaction buttons for the discussion itself above the comments.
icon: apply
order: 7
lang:
type: String
title: Language
default: 'en'
hint: Two-letter code giscus draws its own interface in.
icon: geography
order: 8

@ -0,0 +1,4 @@
head: |
<script async type="module" src="https://talk.hyvor.com/embed/embed.js"></script>
main: |
<hyvor-talk-comments website-id="{{attr:websiteId}}" page-id="{{attr:page.path}}" colors="{{attr:colorScheme}}"></hyvor-talk-comments>

@ -0,0 +1,23 @@
title: Hyvor Talk
description: A hosted, privacy-first commenting platform with no ads and no tracking. Paid, with moderation, notifications and single sign-on included.
website: https://talk.hyvor.com
icon: '/_assets/icons/ultraviolet-hyvortalk.svg'
requires: ['websiteId']
props:
websiteId:
type: Number
title: Website ID
default: 0
hint: The numeric identifier of your website in the Hyvor Talk console.
icon: rename
order: 1
colorScheme:
type: String
title: Colour Scheme
default: 'os'
enum:
- 'os|Follow the reader'
- 'light|Light'
- 'dark|Dark'
icon: 3d-touch
order: 2

@ -0,0 +1,6 @@
main: |
<section id="isso-thread" data-isso-id="/{{attr:page.path}}"></section>
body: |
<script data-isso="{{attr:server}}/"
data-isso-require-author="{{bool:requireAuthor}}"
src="{{attr:server}}/js/embed.min.js"></script>

@ -0,0 +1,20 @@
title: Isso
description: A tiny self-hosted comment server written in Python, storing everything in one SQLite file. Comments are anonymous by default and can be edited for a while after posting.
website: https://isso-comments.de
icon: '/_assets/icons/ultraviolet-isso.svg'
requires: ['server']
props:
server:
type: String
title: Server URL
default: ''
hint: 'Publicly reachable URL of your Isso server, with the scheme and without a trailing slash, e.g. https://isso.example.com'
icon: dns
order: 1
requireAuthor:
type: Boolean
title: Require a Name
default: false
hint: Ask for a name before a comment can be posted. This has to match the server's own configuration to take effect.
icon: contact
order: 2

@ -0,0 +1,24 @@
main: |
<div id="remark42"></div>
body: |
<script>
window.remark_config = {
host: '{{js:host}}',
site_id: '{{js:siteId}}',
url: '{{js:page.url}}',
theme: '{{js:theme}}',
max_shown_comments: {{num:maxShownComments}},
components: ['embed']
}
if (window.REMARK42) {
// -> Loaded by an earlier page. Remark42 keeps one instance per document, so the old one is
// torn down and a new one created against the config just written above.
window.REMARK42.destroy()
window.REMARK42.createInstance(window.remark_config)
} else {
var s = document.createElement('script')
s.src = window.remark_config.host + '/web/embed.js'
s.defer = true
document.head.appendChild(s)
}
</script>

@ -0,0 +1,36 @@
title: Remark42
description: A small, self-hosted comment engine in Go. Anonymous or social sign-in, threaded replies, votes and an admin interface, with no database to run beside it.
website: https://remark42.com
icon: '/_assets/icons/ultraviolet-remark42.svg'
requires: ['host', 'siteId']
props:
host:
type: String
title: Server URL
default: ''
hint: 'Publicly reachable URL of your Remark42 server, with the scheme and without a trailing slash, e.g. https://remark42.example.com'
icon: dns
order: 1
siteId:
type: String
title: Site ID
default: 'remark'
hint: The site identifier Remark42 was started with (its SITE environment variable).
icon: rename
order: 2
theme:
type: String
title: Theme
default: 'light'
enum:
- 'light|Light'
- 'dark|Dark'
icon: 3d-touch
order: 3
maxShownComments:
type: Number
title: Comments Shown
default: 15
hint: How many comments are drawn before the reader has to ask for more.
icon: list
order: 4

@ -0,0 +1,15 @@
head: |
<link rel="stylesheet" href="{{attr:styleUrl}}">
main: |
<div class="waline-container"></div>
body: |
<script type="module">
import { init } from '{{js:clientUrl}}'
init({
el: '.waline-container',
serverURL: '{{js:serverURL}}',
path: '/{{js:page.path}}',
lang: '{{js:lang}}',
reaction: {{bool:reaction}}
})
</script>

@ -0,0 +1,41 @@
title: Waline
description: A self-hosted comment system that runs on a serverless function and a database you already have. Markdown, reactions, anonymous comments and a light client.
website: https://waline.js.org
icon: '/_assets/icons/ultraviolet-waline.svg'
requires: ['serverURL']
props:
serverURL:
type: String
title: Server URL
default: ''
hint: 'URL of your Waline server, with the scheme and without a trailing slash, e.g. https://waline.example.com'
icon: dns
order: 1
clientUrl:
type: String
title: Client Script URL
default: 'https://unpkg.com/@waline/client@v3/dist/waline.js'
hint: Where the Waline browser client is loaded from. Change it to pin a version, or to serve it from your own host.
icon: link
order: 2
styleUrl:
type: String
title: Client Stylesheet URL
default: 'https://unpkg.com/@waline/client@v3/dist/waline.css'
hint: Where the Waline stylesheet is loaded from. It has to match the client version above.
icon: link
order: 3
lang:
type: String
title: Language
default: 'en'
hint: Locale code Waline draws its own interface in, e.g. en, fr, zh-CN.
icon: geography
order: 4
reaction:
type: Boolean
title: Reactions
default: false
hint: Show the reaction buttons above the comment box.
icon: apply
order: 5

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M5 3h14a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3h-8l-5 4v-4H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3z"/><path fill="none" stroke-width="1.3333" d="M6 8h12M6 11.5h8"/></g></svg>

After

Width:  |  Height:  |  Size: 395 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(0.0703)" stroke="#4788c7" stroke-width="14.2222" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M256 24.7c135 0 244.4 82.1 244.5 183.3c0 101.3-109.4 183.3-244.4 183.3q-5.55 0-11.1-.3c-7.1 55-53.9 96.2-109.3 96.2c-1.7 0-3.3-.1-5-.2c24.2-17.1 38.6-44.9 38.6-74.6c-.1-12.2-2.5-24.3-7.3-35.5C70.9 348.6 11.6 282 11.5 208C11.5 106.8 121 24.7 256 24.7"/><path fill="#dff0fe" d="M135.6 498.9c-2 0-3.8-.1-5.7-.2l-33-1.9l27-19.1c21.1-14.9 33.7-39.3 33.8-65.1c0-8.9-1.6-17.7-4.5-26.2C60 355.4.1 285.9 0 208c0-52.7 27.1-102 76.2-138.9c48.2-36.1 112-56 179.8-56c141.2 0 256 87.4 256 194.9S397.2 402.9 256 402.9h-1.5c-12 55.3-61.4 95.9-118.9 96M256 36.2c-62.8 0-121.7 18.3-165.9 51.4c-43.2 32.4-67 75.2-67 120.4c.1 69 55.9 131 142.2 158.1l5.1 1.6l2.1 4.9c5.4 12.7 8.2 26.1 8.3 40c0 21.7-6.9 42.5-19.1 59.7c37.5-10.3 66.7-42.5 71.8-82.7l1.4-10.5l10.6.5c3.5.2 7.1.2 10.7.3c128.3 0 232.8-77.1 232.8-171.8c-.1-94.8-104.6-171.9-233-171.9"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#98ccfd" d="M38,38.5c-2.1-0.2-4.5-0.8-6.8-1.9L31,36.5l-0.2,0.1c-1.7,0.6-3.5,1-5.3,1c-7.2,0-13-4.9-13-11 s5.8-11,13-11s13,4.9,13,11c0,2.5-1,5-2.9,7l-0.2,0.3l0.2,0.3C36.3,35.7,37.1,37.2,38,38.5z"/><path fill="#4788c7" d="M25.5,16C32.4,16,38,20.7,38,26.5c0,2.4-1,4.7-2.8,6.6l-0.5,0.5l0.3,0.6c0.6,1.3,1.3,2.5,2,3.6 c-1.8-0.3-3.7-0.9-5.6-1.8L31,35.9l-0.4,0.2C29,36.7,27.3,37,25.5,37C18.6,37,13,32.3,13,26.5S18.6,16,25.5,16 M25.5,15 C18,15,12,20.1,12,26.5S18,38,25.5,38c2,0,3.8-0.4,5.5-1c2.5,1.2,5.4,2,8,2c-1.2-1.5-2.2-3.4-3.1-5.2c1.9-2,3.1-4.5,3.1-7.3 C39,20.1,33,15,25.5,15L25.5,15z"/><path fill="#dff0fe" d="M2,24.5c0.9-1.2,1.7-2.7,2.5-4.4l0.2-0.3l-0.2-0.3c-1.9-2-2.9-4.4-2.9-7c0-6.1,5.8-11,13-11 s13,4.9,13,11s-5.8,11-13,11c-1.8,0-3.6-0.3-5.3-1l-0.3,0l-0.2,0.1C6.5,23.6,4.1,24.3,2,24.5z"/><path fill="#4788c7" d="M14.5,2C21.4,2,27,6.7,27,12.5S21.4,23,14.5,23c-1.8,0-3.5-0.3-5.1-0.9L9,21.9l-0.4,0.2 C6.7,23,4.8,23.6,3,23.8c0.7-1.1,1.3-2.3,2-3.6l0.3-0.6l-0.5-0.5C3,17.2,2,14.9,2,12.5C2,6.7,7.6,2,14.5,2 M14.5,1C7,1,1,6.1,1,12.5 c0,2.8,1.2,5.3,3.1,7.3C3.2,21.6,2.2,23.5,1,25c2.6,0,5.5-0.8,8-2c1.7,0.6,3.5,1,5.5,1C22,24,28,18.9,28,12.5S22,1,14.5,1L14.5,1z"/><path fill="#4788c7" d="M21.6,11h-14c-0.552,0-1-0.448-1-1s0.448-1,1-1h14c0.552,0,1,0.448,1,1S22.152,11,21.6,11z"/><path fill="#4788c7" d="M19.6,16h-12c-0.552,0-1-0.448-1-1s0.448-1,1-1h12c0.552,0,1,0.448,1,1S20.152,16,19.6,16z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M12.103 0C18.666 0 24 5.485 24 11.997c0 6.51-5.33 11.99-11.9 11.99L0 24V11.79C0 5.28 5.532 0 12.103 0zm.116 4.563c-2.593-.003-4.996 1.352-6.337 3.57-1.33 2.208-1.387 4.957-.148 7.22L4.4 19.61l4.794-1.074c2.745 1.225 5.965.676 8.136-1.39 2.17-2.054 2.86-5.228 1.737-7.997-1.135-2.778-3.84-4.59-6.84-4.585h-.008z"/></g></svg>

After

Width:  |  Height:  |  Size: 562 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M12.438 23.654c-2.853 0-5.46-1.04-7.476-2.766L0 21.568l1.917-4.733C1.25 15.36.875 13.725.875 12 .875 5.564 6.05.346 12.44.346 18.82.346 24 5.564 24 12c0 6.438-5.176 11.654-11.562 11.654zm6.315-11.687v-.033c0-3.363-2.373-5.76-6.462-5.76H7.877V17.83h4.35c4.12 0 6.525-2.5 6.525-5.863h.004zm-6.415 2.998h-1.29V9.04h1.29c1.897 0 3.157 1.08 3.157 2.945v.03c0 1.884-1.26 2.95-3.157 2.95z"/></g></svg>

After

Width:  |  Height:  |  Size: 633 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><rect fill="#98ccfd" x="9" y="1.5" width="14" height="10" rx="2.5"/><path fill="#dff0fe" d="M3.5 9h10a2.5 2.5 0 0 1 2.5 2.5v5a2.5 2.5 0 0 1-2.5 2.5H9l-4 3v-3H3.5A2.5 2.5 0 0 1 1 16.5v-5A2.5 2.5 0 0 1 3.5 9z"/></g></svg>

After

Width:  |  Height:  |  Size: 434 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(0.2721)" stroke="#4788c7" stroke-width="3.6748" stroke-linejoin="round" stroke-linecap="round"><rect fill="#dff0fe" width="77.87" height="123.577" x="26.535" y="5.026" ry="38.935"/><path fill="#98ccfd" d="M105.079 65.75l.232 22.852c.219 21.569-16.969 39.11-38.538 39.329s-39.11-16.969-39.329-38.538l-.232-22.852z"/><path fill="none" stroke-width="8.0845" d="M59.3 31.89c0-2.555-3.289-4.625-7.346-4.625s-7.346 2.071-7.346 4.625m43.076 0c0-2.555-3.289-4.625-7.346-4.625s-7.346 2.071-7.346 4.625m.271 15.859c0 2.555-3.289 4.625-7.346 4.625s-7.346-2.071-7.346-4.625"/></g></svg>

After

Width:  |  Height:  |  Size: 697 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M12 2c5.5 0 10 3.8 10 8.5S17.5 19 12 19c-.9 0-1.8-.1-2.6-.3L4 21.5l1.6-4.3C3.4 15.7 2 13.2 2 10.5 2 5.8 6.5 2 12 2z"/><circle fill="#4788c7" stroke="none" cx="8" cy="10.5" r="1.2"/><circle fill="#4788c7" stroke="none" cx="12" cy="10.5" r="1.2"/><circle fill="#4788c7" stroke="none" cx="16" cy="10.5" r="1.2"/></g></svg>

After

Width:  |  Height:  |  Size: 558 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M5 3h14a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3h-1v4l-5-4H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3z"/><path fill="none" stroke-width="1.3333" d="M16 10H8m0 0 3-3m-3 3 3 3"/></g></svg>

After

Width:  |  Height:  |  Size: 403 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M4 4h11a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H8l-5 4v-4a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3z"/><circle fill="#98ccfd" cx="19.5" cy="4.5" r="3.5"/></g></svg>

After

Width:  |  Height:  |  Size: 381 B

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
282 icons.
281 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -41,6 +41,7 @@ export const BUNDLED_ICONS = {
"la:code": {"body":"<path fill=\"currentColor\" d=\"m18 5l-6 22h2l6-22zM7.937 6.406l-6.75 9L.75 16l.438.594l6.75 9l1.625-1.188L3.25 16l6.313-8.406zm16.125 0l-1.625 1.188L28.75 16l-6.313 8.406l1.625 1.188l6.75-9L31.25 16l-.438-.594z\"/>","width":32,"height":32},
"la:code-branch": {"body":"<path fill=\"currentColor\" d=\"M11 4C9.355 4 8 5.355 8 7c0 1.293.844 2.395 2 2.813v12.374c-1.156.418-2 1.52-2 2.813c0 1.645 1.355 3 3 3s3-1.355 3-3c0-1.27-.816-2.344-1.938-2.781c.145-1.23.622-1.836 1.376-2.344c.898-.605 2.277-.965 3.78-1.313c1.505-.347 3.118-.707 4.47-1.656c1.187-.832 2.085-2.195 2.28-4.093C25.142 12.402 26 11.3 26 10c0-1.645-1.355-3-3-3s-3 1.355-3 3c0 1.277.832 2.352 1.969 2.781c-.137 1.313-.645 1.965-1.407 2.5c-.898.63-2.285 1-3.78 1.344c-1.497.344-3.118.648-4.47 1.563c-.109.074-.21.167-.312.25V9.813c1.156-.418 2-1.52 2-2.813c0-1.645-1.355-3-3-3m0 2c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1m12 3c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1M11 24c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1\"/>","width":32,"height":32},
"la:cog": {"body":"<path fill=\"currentColor\" d=\"m13.188 3l-.157.813l-.594 2.968a10 10 0 0 0-2.593 1.532l-2.906-1l-.782-.25l-.406.718l-2 3.438l-.406.719l.594.53l2.25 1.97C6.104 14.948 6 15.46 6 16s.105 1.05.188 1.563l-2.25 1.968l-.594.532l.406.718l2 3.438l.406.718l.782-.25l2.906-1a10 10 0 0 0 2.594 1.532l.593 2.968l.156.813h5.626l.156-.813l.593-2.968a10 10 0 0 0 2.594-1.532l2.907 1l.78.25l.407-.718l2-3.438l.406-.718l-.593-.532l-2.25-1.968C25.895 17.05 26 16.538 26 16c0-.54-.105-1.05-.188-1.563l2.25-1.968l.594-.531l-.406-.72l-2-3.437l-.406-.718l-.782.25l-2.906 1a10 10 0 0 0-2.593-1.532l-.594-2.968L18.812 3zm1.624 2h2.376l.5 2.594l.125.593l.562.188a8 8 0 0 1 3.031 1.75l.438.406l.562-.187l2.532-.875l1.187 2.031l-2 1.781l-.469.375l.157.594c.128.57.187 1.152.187 1.75s-.059 1.18-.188 1.75l-.125.594l.438.375l2 1.781l-1.188 2.031l-2.53-.875l-.563-.187l-.438.406a8 8 0 0 1-3.031 1.75l-.563.188l-.125.593l-.5 2.594h-2.375l-.5-2.594l-.124-.593l-.563-.188a8 8 0 0 1-3.031-1.75l-.438-.406l-.562.187l-2.531.875L5.875 20.5l2-1.781l.469-.375l-.156-.594A8 8 0 0 1 8 16c0-.598.059-1.18.188-1.75l.156-.594l-.469-.375l-2-1.781l1.188-2.031l2.53.875l.563.187l.438-.406a8 8 0 0 1 3.031-1.75l.563-.188l.124-.593zM16 11c-2.75 0-5 2.25-5 5s2.25 5 5 5s5-2.25 5-5s-2.25-5-5-5m0 2c1.668 0 3 1.332 3 3s-1.332 3-3 3s-3-1.332-3-3s1.332-3 3-3\"/>","width":32,"height":32},
"la:comment": {"body":"<path fill=\"currentColor\" d=\"M3 6v20h9.586L16 29.414L19.414 26H29V6zm2 2h22v16h-8.414L16 26.586L13.414 24H5zm4 3v2h14v-2zm0 4v2h14v-2zm0 4v2h10v-2z\"/>","width":32,"height":32},
"la:comments": {"body":"<path fill=\"currentColor\" d=\"M2 5v16h4v5.094l1.625-1.313L12.344 21H22V5zm2 2h16v12h-8.344l-.281.219L8 21.906V19H4zm20 2v2h4v12h-4v2.906L20.344 23h-7.5l-2.5 2h9.312L26 30.094V25h4V9z\"/>","width":32,"height":32},
"la:copy": {"body":"<path fill=\"currentColor\" d=\"M4 4v20h7v-2H6V6h12v1h2V4zm8 4v20h16V8zm2 2h12v16H14z\"/>","width":32,"height":32},
"la:crop": {"body":"<path fill=\"currentColor\" d=\"M8 4v4H4v2h4v14h14v4h2v-4h4v-2H11.437L22 11.437V21h2V9.437l3.719-3.718L26.28 4.28L22.563 8H11v2h9.563L10 20.563V4z\"/>","width":32,"height":32},
@ -112,6 +113,7 @@ export const BUNDLED_ICONS = {
"la:question-circle": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m0 4c-2.2 0-4 1.8-4 4h2c0-1.117.883-2 2-2s2 .883 2 2a1.78 1.78 0 0 1-1.219 1.688l-.406.124A2.02 2.02 0 0 0 15 17.72V19h2v-1.281l.406-.125A3.81 3.81 0 0 0 20 14c0-2.2-1.8-4-4-4m-1 10v2h2v-2z\"/>","width":32,"height":32},
"la:redo": {"body":"<path fill=\"currentColor\" d=\"M19.219 5.281L17.78 6.72L24.063 13H11c-3.844 0-7 3.156-7 7v7h2v-7c0-2.754 2.246-5 5-5h13.063l-6.282 6.281l1.438 1.438l8-8l.687-.719l-.687-.719z\"/>","width":32,"height":32},
"la:redo-alt": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13h-2c0 6.086-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5c3.875 0 7.262 1.984 9.219 5H20v2h8V4h-2v3.719C23.617 4.844 20.02 3 16 3\"/>","width":32,"height":32},
"la:reply": {"body":"<path fill=\"currentColor\" d=\"m12.281 5.281l-8 8l-.687.719l.687.719l8 8l1.438-1.438L7.438 15H21c2.773 0 5 2.227 5 5s-2.227 5-5 5v2c3.855 0 7-3.145 7-7s-3.145-7-7-7H7.437l6.282-6.281z\"/>","width":32,"height":32},
"la:ruler-vertical": {"body":"<path fill=\"currentColor\" d=\"M8 0v32h16V0zm2 2h12v3h-7v2h7v2h-4v2h4v2h-7v2h7v2h-4v2h4v2h-7v2h7v2h-4v2h4v3H10z\"/>","width":32,"height":32},
"la:search": {"body":"<path fill=\"currentColor\" d=\"M19 3C13.488 3 9 7.488 9 13c0 2.395.84 4.59 2.25 6.313L3.281 27.28l1.439 1.44l7.968-7.969A9.92 9.92 0 0 0 19 23c5.512 0 10-4.488 10-10S24.512 3 19 3m0 2c4.43 0 8 3.57 8 8s-3.57 8-8 8s-8-3.57-8-8s3.57-8 8-8\"/>","width":32,"height":32},
"la:search-minus": {"body":"<path fill=\"currentColor\" d=\"M19 3C13.488 3 9 7.488 9 13c0 2.395.84 4.59 2.25 6.313L3.281 27.28l1.439 1.44l7.968-7.969A9.92 9.92 0 0 0 19 23c5.512 0 10-4.488 10-10S24.512 3 19 3m0 2c4.43 0 8 3.57 8 8s-3.57 8-8 8s-8-3.57-8-8s3.57-8 8-8m-4 7v2h8v-2z\"/>","width":32,"height":32},
@ -184,7 +186,6 @@ export const BUNDLED_ICONS = {
"mdi:code-json": {"body":"<path fill=\"currentColor\" d=\"M5 3h2v2H5v5a2 2 0 0 1-2 2a2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2a2 2 0 0 1-2-2V5h-2V3zm-7 12a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1\"/>","width":24,"height":24},
"mdi:code-tags": {"body":"<path fill=\"currentColor\" d=\"m14.6 16.6l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6zm-5.2 0L4.8 12l4.6-4.6L8 6l-6 6l6 6z\"/>","width":24,"height":24},
"mdi:cog": {"body":"<path fill=\"currentColor\" d=\"M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z\"/>","width":24,"height":24},
"mdi:cog-box": {"body":"<path fill=\"currentColor\" d=\"M17.25 12c0 .23-.02.46-.05.68l1.48 1.16c.13.11.17.29.08.45l-1.4 2.42c-.09.15-.27.21-.43.15l-1.74-.7c-.36.28-.76.51-1.19.69l-.25 1.85c-.03.17-.18.3-.35.3h-2.8c-.17 0-.32-.13-.35-.3L10 16.85c-.44-.18-.83-.41-1.19-.69l-1.74.7c-.16.06-.34 0-.43-.15l-1.4-2.42a.35.35 0 0 1 .08-.45l1.48-1.16c-.03-.22-.05-.45-.05-.68s.02-.46.05-.68l-1.48-1.16a.35.35 0 0 1-.08-.45l1.4-2.42c.09-.16.27-.22.43-.16l1.74.71c.36-.28.75-.52 1.19-.69l.25-1.86c.03-.16.18-.29.35-.29h2.8c.17 0 .32.13.35.29L14 7.15c.43.17.83.41 1.19.69l1.74-.71c.16-.06.34 0 .43.16l1.4 2.42c.09.15.05.34-.08.45l-1.48 1.16c.03.22.05.45.05.68M19 3H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 7c-1.11 0-2 .89-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2\"/>","width":24,"height":24},
"mdi:crop-square": {"body":"<path fill=\"currentColor\" d=\"M18 18H6V6h12m0-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2\"/>","width":24,"height":24},
"mdi:database-refresh": {"body":"<path fill=\"currentColor\" d=\"M12 3c4.42 0 8 1.79 8 4s-3.58 4-8 4s-8-1.79-8-4s3.58-4 8-4M4 9c0 2.21 3.58 4 8 4c1.11 0 2.18-.11 3.14-.32c-.95.86-1.64 1.99-1.96 3.28L12 16c-4.42 0-8-1.79-8-4zm16 0v2h-.5l-.6.03c.7-.6 1.1-1.29 1.1-2.03M4 14c0 2.21 3.58 4 8 4l1-.03c.09 1.06.42 2.03.95 2.91L12 21c-4.42 0-8-1.79-8-4zm15-.5c1.11 0 2.11.45 2.83 1.17L23 13.5v4h-4l1.77-1.77A2.5 2.5 0 1 0 21 19h1.71A3.99 3.99 0 0 1 19 21.5c-2.21 0-4-1.79-4-4s1.79-4 4-4\"/>","width":24,"height":24},
"mdi:dice-5": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m2 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m10 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m0-10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-5 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-5 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2\"/>","width":24,"height":24},
@ -249,7 +250,6 @@ export const BUNDLED_ICONS = {
"mdi:link-variant": {"body":"<path fill=\"currentColor\" d=\"M10.59 13.41c.41.39.41 1.03 0 1.42c-.39.39-1.03.39-1.42 0a5.003 5.003 0 0 1 0-7.07l3.54-3.54a5.003 5.003 0 0 1 7.07 0a5.003 5.003 0 0 1 0 7.07l-1.49 1.49c.01-.82-.12-1.64-.4-2.42l.47-.48a2.98 2.98 0 0 0 0-4.24a2.98 2.98 0 0 0-4.24 0l-3.53 3.53a2.98 2.98 0 0 0 0 4.24m2.82-4.24c.39-.39 1.03-.39 1.42 0a5.003 5.003 0 0 1 0 7.07l-3.54 3.54a5.003 5.003 0 0 1-7.07 0a5.003 5.003 0 0 1 0-7.07l1.49-1.49c-.01.82.12 1.64.4 2.43l-.47.47a2.98 2.98 0 0 0 0 4.24a2.98 2.98 0 0 0 4.24 0l3.53-3.53a2.98 2.98 0 0 0 0-4.24a.973.973 0 0 1 0-1.42\"/>","width":24,"height":24},
"mdi:link-variant-plus": {"body":"<path fill=\"currentColor\" d=\"M10.6 13.4a1 1 0 0 1-1.4 1.4a4.8 4.8 0 0 1 0-7l3.5-3.6a5.1 5.1 0 0 1 7.1 0a5.1 5.1 0 0 1 0 7.1l-1.5 1.5a6.4 6.4 0 0 0-.4-2.4l.5-.5a3.2 3.2 0 0 0 0-4.3a3.2 3.2 0 0 0-4.3 0l-3.5 3.6a2.9 2.9 0 0 0 0 4.2M23 18v2h-3v3h-2v-3h-3v-2h3v-3h2v3m-3.8-4.3a4.8 4.8 0 0 0-1.4-4.5a1 1 0 0 0-1.4 1.4a2.9 2.9 0 0 1 0 4.2l-3.5 3.6a3.2 3.2 0 0 1-4.3 0a3.2 3.2 0 0 1 0-4.3l.5-.4a7.3 7.3 0 0 1-.4-2.5l-1.5 1.5a5.1 5.1 0 0 0 0 7.1a5.1 5.1 0 0 0 7.1 0l1.8-1.8a6 6 0 0 1 3.1-4.3\"/>","width":24,"height":24},
"mdi:logout": {"body":"<path fill=\"currentColor\" d=\"m17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5M4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4z\"/>","width":24,"height":24},
"mdi:magnify": {"body":"<path fill=\"currentColor\" d=\"M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5l-1.5 1.5l-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16A6.5 6.5 0 0 1 3 9.5A6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14S14 12 14 9.5S12 5 9.5 5\"/>","width":24,"height":24},
"mdi:marker": {"body":"<path fill=\"currentColor\" d=\"M18.5 1.15c-.53 0-1.04.19-1.43.58l-5.81 5.82l5.65 5.65l5.82-5.81c.77-.78.77-2.04 0-2.83l-2.84-2.83c-.39-.39-.89-.58-1.39-.58M10.3 8.5l-5.96 5.96c-.78.78-.78 2.04.02 2.85C3.14 18.54 1.9 19.77.67 21h5.66l.86-.86c.78.76 2.03.75 2.81-.02l5.95-5.96\"/>","width":24,"height":24},
"mdi:marker-cancel": {"body":"<path fill=\"currentColor\" d=\"M17.5 13c2.5 0 4.5 2 4.5 4.5S20 22 17.5 22S13 20 13 17.5s2-4.5 4.5-4.5m0 1.5c-.56 0-1.08.15-1.5.42L20.08 19c.27-.42.42-.94.42-1.5a3 3 0 0 0-3-3m-3 3a3 3 0 0 0 3 3c.56 0 1.08-.15 1.5-.42L14.92 16c-.27.42-.42.94-.42 1.5m4-16.35c.5 0 1 .19 1.39.58l2.84 2.83c.77.79.77 2.05 0 2.83l-3.78 3.77a6.54 6.54 0 0 0-3.8.28l-3.89-3.89l5.81-5.82c.39-.39.9-.58 1.43-.58M10.3 8.5l3.59 3.6A6.49 6.49 0 0 0 11 17.5c0 .5.06 1 .16 1.45L10 20.12c-.78.77-2.03.78-2.81.02l-.86.86H.67l3.69-3.69c-.8-.81-.8-2.07-.02-2.85z\"/>","width":24,"height":24},
"mdi:menu-down": {"body":"<path fill=\"currentColor\" d=\"m7 10l5 5l5-5z\"/>","width":24,"height":24},
@ -281,7 +281,6 @@ export const BUNDLED_ICONS = {
"mdi:table-row-plus-before": {"body":"<path fill=\"currentColor\" d=\"M22 14a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7h2v-2h4v2h2v-2h4v2h2v-2h4v2h2zM4 14h4v3H4zm6 0h4v3h-4zm10 0v3h-4v-3zm-9-4h2V7h3V5h-3V2h-2v3H8v2h3z\"/>","width":24,"height":24},
"mdi:table-row-remove": {"body":"<path fill=\"currentColor\" d=\"M9.41 13L12 15.59L14.59 13L16 14.41L13.41 17L16 19.59L14.59 21L12 18.41L9.41 21L8 19.59L10.59 17L8 14.41zM22 9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2zM4 9h4V6H4zm6 0h4V6h-4zm6 0h4V6h-4z\"/>","width":24,"height":24},
"mdi:table-split-cell": {"body":"<path fill=\"currentColor\" d=\"M19 14h2v6H3v-6h2v4h14zM3 4v6h2V6h14v4h2V4zm8 7v2H8v2l-3-3l3-3v2zm5 0V9l3 3l-3 3v-2h-3v-2z\"/>","width":24,"height":24},
"mdi:tag": {"body":"<path fill=\"currentColor\" d=\"M5.5 7A1.5 1.5 0 0 1 4 5.5A1.5 1.5 0 0 1 5.5 4A1.5 1.5 0 0 1 7 5.5A1.5 1.5 0 0 1 5.5 7m15.91 4.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.11 0-2 .89-2 2v7c0 .55.22 1.05.59 1.41l8.99 9c.37.36.87.59 1.42.59s1.05-.23 1.41-.59l7-7c.37-.36.59-.86.59-1.41c0-.56-.23-1.06-.59-1.42\"/>","width":24,"height":24},
"mdi:text-box-outline": {"body":"<path fill=\"currentColor\" d=\"M5 3c-1.11 0-2 .89-2 2v14c0 1.11.89 2 2 2h14c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 2h14v14H5zm2 2v2h10V7zm0 4v2h10v-2zm0 4v2h7v-2z\"/>","width":24,"height":24},
"mdi:tooltip-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M4 2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4l-4 4l-4-4H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m0 2v12h4.83L12 19.17L15.17 16H20V4zm7 2h2v3h3v2h-3v3h-2v-3H8V9h3z\"/>","width":24,"height":24},
"mdi:toy-brick-plus": {"body":"<path fill=\"currentColor\" d=\"M19 6V5a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v1h-2V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v1H3v14h10.09a5.5 5.5 0 0 1-.09-1a6 6 0 0 1 8-5.66V6m-1 9v3h3v2h-3v3h-2v-3h-3v-2h3v-3Z\"/>","width":24,"height":24},

@ -0,0 +1,312 @@
<template>
<div class="page-comment" :class="{ 'is-reply': Boolean(comment.parentId) }">
<div class="page-comment-avatar">
<w-avatar :size="comment.parentId ? `28px` : `36px`" color="primary" text-color="white">
<img v-if="comment.authorHasAvatar" :src="`/_user/${comment.authorId}/avatar`" alt="" />
<span v-else>{{ initial }}</span>
</w-avatar>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-2">
<!--
A link only where there is a profile to open. A guest has no account behind the name, and
neither has a comment whose author was deleted -- in both cases the name is a copy the row
kept, and nothing is there to link to.
-->
<router-link
v-if="comment.authorId"
class="text-body2 font-medium page-comment-author"
:to="`/_user/${comment.authorId}`">
{{ comment.authorName }}
</router-link>
<span class="text-body2 font-medium" v-else>{{ comment.authorName }}</span>
<span class="text-caption text-grey-6" v-if="comment.authorHandle">
@{{ comment.authorHandle }}
</span>
<w-chip v-if="comment.isGuest" size="xs" color="grey-4" text-color="grey-8">
{{ t('common.comments.guest') }}
</w-chip>
<span class="text-caption text-grey-6">{{ relativeDate(comment.createdAt) }}</span>
<!-- -> Only when it is actually true of this comment, and without repeating the date: what
a reader needs to know is that what they are reading is not what was first posted -->
<span class="text-caption text-grey-6" v-if="wasEdited">
&middot; {{ t('common.comments.edited') }}
</span>
</div>
<page-comment-editor
class="pt-2"
v-if="editing"
v-model="draft"
cancelable
:rows="3"
:busy="busy"
:submit-label="t(`common.comments.updateComment`)"
@submit="$emit(`save`, { id: comment.id, content: draft })"
@cancel="$emit(`cancel-edit`)" />
<template v-else>
<!--
`v-html` on output this app rendered a moment ago, from markdown with raw HTML disabled --
see `renderers/comment.js`, where that is the whole security boundary. Nothing stored is
HTML, so there is no older sanitizer's work being trusted here.
-->
<div class="page-comment-body" v-html="rendered" />
<div class="flex flex-wrap items-center gap-1 pt-1">
<w-btn
v-if="canReply"
size="sm"
padding="none xs"
flat
no-caps
color="primary"
icon="la:reply"
:label="t(`common.comments.reply`)"
@click="$emit(`reply`, comment)" />
<w-btn
v-if="canEdit"
size="sm"
padding="none xs"
flat
no-caps
color="grey"
icon="la:pen"
:label="t(`common.actions.edit`)"
@click="$emit(`edit`, comment)" />
<w-btn
v-if="canDelete"
size="sm"
padding="none xs"
flat
no-caps
color="grey"
icon="la:trash"
:label="t(`common.actions.delete`)"
@click="$emit(`delete`, comment)" />
</div>
</template>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { relativeDate } from '@/helpers/datetime'
import { renderComment } from '@/renderers/comment'
import PageCommentEditor from '@/components/PageCommentEditor.vue'
/**
* One comment, in the list or being edited in place.
*
* Which of the three actions it offers is decided by whoever owns the list -- who may moderate this
* page, and whose comment this is, are questions about the reader rather than about the comment, so
* they arrive as props and nothing is worked out here.
*/
const props = defineProps({
comment: {
type: Object,
required: true
},
/** The handles that resolved to somebody, for the whole page. See `renderers/comment.js`. */
mentions: {
type: Array,
default: () => []
},
canReply: {
type: Boolean,
default: false
},
canEdit: {
type: Boolean,
default: false
},
canDelete: {
type: Boolean,
default: false
},
busy: {
type: Boolean,
default: false
},
/**
* Whether this comment is the one being edited.
*
* Owned by the list rather than by the comment, because only the list knows when an edit is over:
* a save is a request, and the box has to stay open and keep what was typed when one fails.
*/
editing: {
type: Boolean,
default: false
}
})
defineEmits(['reply', 'edit', 'cancel-edit', 'save', 'delete'])
// I18N
const { t } = useI18n()
// DATA
const draft = ref('')
// COMPUTED
const rendered = computed(() => renderComment(props.comment.content, props.mentions))
/**
* Whether this comment has been changed since it was posted.
*
* A second of slack, because the two timestamps are written by two statements: the row is inserted
* with both defaulting to `now()`, and a comment that was never touched should not read as edited
* because those two calls landed on either side of a microsecond.
*/
const wasEdited = computed(() => {
const created = Temporal.Instant.from(props.comment.createdAt)
const updated = Temporal.Instant.from(props.comment.updatedAt)
return created.until(updated).total('seconds') > 1
})
const initial = computed(() => (props.comment.authorName || '?').trim().charAt(0).toUpperCase())
// WATCHERS
// -> The box is filled from the comment as it stands the moment it opens, and emptied when it closes
// so that re-opening it never shows a draft from an edit that was abandoned
watch(
() => props.editing,
(isEditing) => {
draft.value = isEditing ? props.comment.content : ''
},
{ immediate: true }
)
</script>
<style lang="scss">
/*
Stated again here rather than left to `.page-talk`, so that a comment drawn anywhere else -- a
moderation screen, a notification -- carries its own ink. See the note in `PageTalk.vue`.
*/
.page-comment {
display: flex;
gap: 12px;
padding: 12px 0;
color: #26292e;
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.87);
}
&.is-reply {
padding-left: 24px;
border-left: 2px solid rgba(0, 0, 0, 0.08);
margin-left: 18px;
@at-root .body--dark & {
border-left-color: rgba(255, 255, 255, 0.12);
}
}
}
.page-comment-avatar {
flex: none;
padding-top: 2px;
}
.page-comment-author {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
/*
The typography of a comment, which is deliberately NOT the article's.
`_page-contents.scss` styles what a page author writes -- headings that join the page outline,
tables, admonitions -- and a comment has none of that available to it (see `renderers/comment.js`).
What is left is prose, quotes, lists and code, at the size of the surrounding interface rather than
of an article.
*/
.page-comment-body {
font-size: 14px;
line-height: 1.55;
word-break: break-word;
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
p {
margin: 0 0 8px;
}
ul,
ol {
margin: 0 0 8px;
padding-left: 24px;
list-style: revert;
}
blockquote {
margin: 0 0 8px;
padding: 2px 0 2px 12px;
border-left: 3px solid rgba(0, 0, 0, 0.12);
color: rgba(0, 0, 0, 0.66);
@at-root .body--dark & {
border-left-color: rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.7);
}
}
code {
padding: 1px 4px;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.06);
font-family: var(--font-mono, monospace);
font-size: 0.9em;
@at-root .body--dark & {
background-color: rgba(255, 255, 255, 0.1);
}
}
pre {
margin: 0 0 8px;
padding: 8px 10px;
border-radius: 4px;
overflow-x: auto;
background-color: rgba(0, 0, 0, 0.06);
@at-root .body--dark & {
background-color: rgba(255, 255, 255, 0.08);
}
code {
padding: 0;
background: none;
}
}
a {
color: $primary;
}
.comment-mention {
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
</style>

@ -0,0 +1,421 @@
<template>
<div class="page-comment-editor">
<!--
Write / Preview, as a pair of small toggles rather than a WTabs strip: the strip is a segmented
control sized for navigating a view, and this switches what one box shows.
-->
<div class="flex items-center gap-1 pb-2">
<w-btn
size="sm"
padding="none sm"
no-caps
:flat="state.tab !== `write`"
:outline="state.tab === `write`"
color="primary"
:label="t(`common.comments.write`)"
@click="showWrite" />
<w-btn
size="sm"
padding="none sm"
no-caps
:flat="state.tab !== `preview`"
:outline="state.tab === `preview`"
color="primary"
:label="t(`common.comments.preview`)"
@click="showPreview" />
<w-space />
<!--
Only once it matters. A counter that starts at "8000 left" is a warning about a limit nobody
is near; what a reader needs is to be told before they lose a paragraph to it.
-->
<div
class="text-caption"
:class="charsLeft < 0 ? `text-negative` : `text-grey-6`"
v-if="showCounter">
{{ t('common.comments.charsLeft', { count: charsLeft }) }}
</div>
</div>
<!--
`position: relative` so the mention menu can be pinned to the box. The menu is positioned
against the editor rather than the caret: a popup that follows the caret through a wrapping
textarea needs a mirrored copy of it to measure against, which is a great deal of machinery for
a list of eight names.
-->
<div class="relative" v-show="state.tab === `write`">
<w-input
ref="inputEl"
type="textarea"
outlined
hide-bottom-space
:rows="rows"
:model-value="modelValue"
:placeholder="placeholder"
:aria-label="placeholder"
:disable="busy"
@update:model-value="onInput"
@keydown="onKeydown" />
<div class="page-comment-mentions" v-if="state.mentions.length > 0">
<button
v-for="(target, idx) of state.mentions"
:key="target.id"
type="button"
class="page-comment-mention"
:class="{ 'is-active': idx === state.mentionIndex }"
@mousedown.prevent="pickMention(target)">
<span class="font-medium">@{{ target.handle }}</span>
<span class="text-caption text-grey-6">{{ target.name }}</span>
</button>
</div>
</div>
<div class="page-comment-preview page-comment-body" v-show="state.tab === `preview`">
<div v-if="modelValue.trim().length > 0" v-html="preview" />
<div class="text-body2 text-grey-6" v-else>{{ t('common.comments.previewEmpty') }}</div>
</div>
<!--
The two fields a guest has to fill in, under the box rather than over it: what somebody came
here to do is write, and being asked for a name before they have written anything is a form
standing between them and the thing they meant to do.
-->
<div class="flex flex-wrap gap-2 pt-2" v-if="guest">
<div class="flex-1" style="min-width: min(220px, 100%)">
<w-input
outlined
dense
hide-bottom-space
:model-value="authorName"
:label="t(`common.comments.fieldName`)"
:disable="busy"
@update:model-value="$emit(`update:authorName`, $event)" />
</div>
<div class="flex-1" style="min-width: min(220px, 100%)">
<w-input
outlined
dense
type="email"
:model-value="authorEmail"
:label="t(`common.comments.fieldEmail`)"
:hint="t(`common.comments.fieldEmailHint`)"
:disable="busy"
@update:model-value="$emit(`update:authorEmail`, $event)" />
</div>
</div>
<div class="flex items-center gap-2 pt-2">
<div class="text-caption text-grey-6 hidden sm:block">
{{ t('common.comments.markdownHint') }}
</div>
<w-space />
<w-btn
v-if="cancelable"
flat
no-caps
color="grey"
:label="t(`common.actions.cancel`)"
:disable="busy"
@click="$emit(`cancel`)" />
<w-btn
unelevated
no-caps
color="primary"
icon="la:comment"
:label="submitLabel"
:loading="busy"
:disable="!canSubmit"
@click="$emit(`submit`)" />
</div>
</div>
</template>
<script setup>
import { computed, nextTick, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { debounce } from 'es-toolkit/function'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import { renderComment } from '@/renderers/comment'
/**
* The box a comment is written in: a markdown textarea, a preview of it, and the `@` completion.
*
* Used three times over on a talk page the new comment at the bottom, a reply under a thread, and
* a comment being edited in place so everything about which of those it is comes in as a prop and
* nothing about it is decided here.
*
* The preview renders through the same function the comments themselves do, with no mentions
* resolved: the server is what knows which handles exist, and it has not been asked about this draft.
* So a mention shows in the preview as the text that was typed and becomes a link once posted, which
* is the honest answer rather than a guess.
*/
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
submitLabel: {
type: String,
required: true
},
/** Shows the name and email fields, which are required of somebody with no account. */
guest: {
type: Boolean,
default: false
},
authorName: {
type: String,
default: ''
},
authorEmail: {
type: String,
default: ''
},
/** A reply and an edit can be abandoned; the box at the bottom of the page cannot. */
cancelable: {
type: Boolean,
default: false
},
busy: {
type: Boolean,
default: false
},
rows: {
type: [String, Number],
default: 4
}
})
const emit = defineEmits([
'update:modelValue',
'update:authorName',
'update:authorEmail',
'submit',
'cancel'
])
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// DATA
const inputEl = ref(null)
const state = reactive({
tab: 'write',
/** The handles offered for the `@` being typed, empty whenever the menu is closed. */
mentions: [],
mentionIndex: 0,
/** Where in the text the `@` of the word being completed sits. */
mentionStart: -1
})
// COMPUTED
const preview = computed(() => renderComment(props.modelValue))
const maxLength = computed(() => siteStore.comments.maxLength || 8000)
const charsLeft = computed(() => maxLength.value - props.modelValue.length)
/** Shown for the last tenth of the allowance, and from then on. See the template. */
const showCounter = computed(() => charsLeft.value <= maxLength.value / 10)
const canSubmit = computed(() => {
if (props.busy || props.modelValue.trim().length < 2 || charsLeft.value < 0) {
return false
}
// -> A guest has two more fields to fill in, and the button says so by staying off until they are
return !props.guest || (props.authorName.trim().length > 0 && props.authorEmail.trim().length > 0)
})
// METHODS
function showWrite() {
state.tab = 'write'
}
function showPreview() {
closeMentions()
state.tab = 'preview'
}
function closeMentions() {
state.mentions = []
state.mentionIndex = 0
state.mentionStart = -1
}
/**
* The `@word` the caret is sitting in, if it is sitting in one.
*
* Read off the element rather than the model, because which word is being completed is a question
* about the caret and the model does not carry one. The word has to start at the beginning of the
* text or after a character that is not part of a word the same rule the renderer matches by, so
* that what completes here is what resolves there.
*/
function mentionUnderCaret() {
const el = inputEl.value?.el
if (!el || el.selectionStart !== el.selectionEnd) {
return null
}
const upToCaret = props.modelValue.slice(0, el.selectionStart)
const match = /(?:^|[^\w@/])@([A-Za-z0-9_-]{0,32})$/.exec(upToCaret)
if (!match) {
return null
}
return { query: match[1], start: el.selectionStart - match[1].length - 1 }
}
/**
* Ask the server which handles start with what has been typed.
*
* Debounced, and never asked at all for somebody who is not signed in: the endpoint needs a session,
* since a list of handles answered to anybody would be a way to enumerate the wiki's users. A guest
* can still type a handle they know it resolves when the comment is drawn.
*/
const fetchMentions = debounce(async (query, start) => {
try {
const results = await API_CLIENT.get(`sites/${siteStore.id}/comments/mentions`, {
searchParams: { q: query }
}).json()
// -> The caret may have moved on while this was in flight, in which case its answer is stale
if (state.mentionStart !== start) {
return
}
state.mentions = results ?? []
state.mentionIndex = 0
} catch {
closeMentions()
}
}, 200)
function onInput(value) {
emit('update:modelValue', value)
if (!userStore.authenticated) {
return
}
// -> After the model has been written, so that the caret and the text agree about what was typed
nextTick(() => {
const mention = mentionUnderCaret()
if (!mention) {
closeMentions()
return
}
state.mentionStart = mention.start
fetchMentions(mention.query, mention.start)
})
}
/** Put a handle into the text in place of the `@word` that was being typed. */
function pickMention(target) {
const el = inputEl.value?.el
if (!el || state.mentionStart < 0) {
return
}
const before = props.modelValue.slice(0, state.mentionStart)
const after = props.modelValue.slice(el.selectionStart)
const inserted = `@${target.handle} `
emit('update:modelValue', `${before}${inserted}${after}`)
const caret = before.length + inserted.length
closeMentions()
nextTick(() => {
el.focus()
el.setSelectionRange(caret, caret)
})
}
/**
* The keys the mention menu owns while it is open, and nothing else.
*
* `preventDefault` only where the menu actually acts, so that a reader who is not completing
* anything keeps every key the textarea normally has Enter above all, which in a comment box is a
* new line and not a submit.
*/
function onKeydown(ev) {
if (state.mentions.length < 1) {
return
}
switch (ev.key) {
case 'ArrowDown':
ev.preventDefault()
state.mentionIndex = (state.mentionIndex + 1) % state.mentions.length
break
case 'ArrowUp':
ev.preventDefault()
state.mentionIndex = (state.mentionIndex - 1 + state.mentions.length) % state.mentions.length
break
case 'Enter':
case 'Tab':
ev.preventDefault()
pickMention(state.mentions[state.mentionIndex])
break
case 'Escape':
ev.preventDefault()
closeMentions()
break
}
}
// EXPOSED
defineExpose({
focus: () => inputEl.value?.focus()
})
</script>
<style lang="scss">
.page-comment-mentions {
position: absolute;
z-index: 10;
left: 8px;
right: 8px;
top: calc(100% - 4px);
max-width: 320px;
max-height: 240px;
overflow-y: auto;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
.body--dark & {
background-color: $grey-9;
}
}
.page-comment-mention {
display: flex;
width: 100%;
align-items: baseline;
gap: 8px;
padding: 6px 12px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
font: inherit;
&:hover,
&.is-active {
background-color: rgba($primary, 0.1);
}
}
.page-comment-preview {
min-height: 96px;
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.24);
border-radius: 4px;
.body--dark & {
border-color: rgba(255, 255, 255, 0.28);
}
}
</style>

@ -0,0 +1,89 @@
<template>
<div class="page-comments-embed">
<w-separator class="my-6" />
<div class="flex items-center pb-3">
<w-icon class="mr-2" name="la:comments" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.comments.title') }}</div>
</div>
<!--
The provider draws itself in here. Keyed by the page, so that a router transition destroys the
container and builds a new one rather than handing the old one to a widget that has no idea the
reader has moved -- several of the providers cache what they drew against the element they were
given.
-->
<div :key="pageStore.id" ref="hostEl" />
</div>
</template>
<script setup>
import { nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { mountCommentsEmbed } from '@/helpers/commentsEmbed'
/**
* The comments of a third-party provider, under the article.
*
* Under it rather than on a tab, which is the opposite of what the built-in provider does and is
* deliberate: this is somebody else's widget with its own accounts, its own moderation and its own
* idea of what a discussion looks like, so it sits where every site that uses one of these puts it.
* The Talk tab is for the discussion that is part of this wiki.
*
* Everything about the markup comes from the site payload, already rendered by the server bar the
* placeholders about the page -- see `helpers/commentsEmbed.js`, which is also where the reason this
* is mounted in the browser at all is written down.
*/
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const hostEl = ref(null)
// WATCHERS
// -> A page change is what a comment widget has to be told about, since nothing here reloads
watch(
() => pageStore.id,
// -> After the DOM has caught up: the container is keyed by the page, so the element this mounts
// into does not exist yet at the moment the id changes
() => nextTick(mount)
)
// METHODS
function mount() {
const code = siteStore.comments.code
if (!hostEl.value || !pageStore.id || !code) {
return
}
/*
What a provider is allowed to know about the page, and the whole of it. The URL is built from the
location the reader is at rather than from the path alone, because that is what a provider keys a
discussion on and what it links back to from its own moderation screens.
*/
mountCommentsEmbed(hostEl.value, code, {
id: pageStore.id,
path: pageStore.path,
title: pageStore.title,
locale: pageStore.locale,
url: `${window.location.origin}/${pageStore.path}`
})
}
// MOUNTED
onMounted(() => {
mount()
})
</script>

@ -262,7 +262,7 @@
<w-card-section class="alt-card pb-6" id="refCardSocial">
<div class="w-section-header">{{ t('editor.props.social') }}</div>
<w-form class="gap-4 pt-2">
<div v-if="flagsStore.experimental">
<div>
<w-toggle
v-model="pageStore.allowComments"
dense

@ -0,0 +1,415 @@
<template>
<div class="page-talk">
<div class="flex items-center pb-2">
<w-icon class="mr-2" name="la:comments" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.comments.title') }}</div>
<w-space />
<w-spinner v-if="state.loading" color="primary" size="sm" />
</div>
<w-separator />
<div class="py-6 text-center text-body2 text-grey-6" v-if="state.loading && !state.loaded">
{{ t('common.comments.loading') }}
</div>
<template v-else>
<div class="py-6 text-center" v-if="threads.length < 1">
<div class="text-body2 text-grey-6">{{ t('common.comments.none') }}</div>
<!-- -> Only to somebody who can take it up: to a reader who may not comment here, an
invitation to be the first is an invitation to a button they do not have -->
<div class="text-caption text-grey-6 pt-1" v-if="canWrite && isOpen">
{{ t('common.comments.beFirst') }}
</div>
</div>
<template v-for="thread of threads" :key="thread.id">
<div class="page-talk-thread">
<page-comment
:comment="thread"
:mentions="state.mentions"
:can-reply="canWrite && isOpen"
:can-edit="mayModify(thread)"
:can-delete="mayModify(thread)"
:busy="state.busy === thread.id"
:editing="state.editingId === thread.id"
@reply="startReply"
@edit="startEdit"
@cancel-edit="state.editingId = null"
@save="saveComment"
@delete="confirmDelete" />
<page-comment
v-for="reply of thread.replies"
:key="reply.id"
:comment="reply"
:mentions="state.mentions"
:can-reply="canWrite && isOpen"
:can-edit="mayModify(reply)"
:can-delete="mayModify(reply)"
:busy="state.busy === reply.id"
:editing="state.editingId === reply.id"
@reply="startReply"
@edit="startEdit"
@cancel-edit="state.editingId = null"
@save="saveComment"
@delete="confirmDelete" />
<!--
The reply box, under the thread it answers rather than under the comment inside it that
was clicked: replies are one level deep, so every one of them lands at the bottom of this
thread whichever message prompted it, and putting the box anywhere else would promise a
nesting that does not exist.
-->
<div class="page-talk-reply" v-if="state.replyTo === thread.id">
<div class="text-caption text-grey-6 pb-1">
{{ t('common.comments.replyingTo', { name: state.replyToName }) }}
</div>
<page-comment-editor
ref="replyEditor"
v-model="state.replyDraft"
v-model:author-name="state.authorName"
v-model:author-email="state.authorEmail"
cancelable
:rows="3"
:guest="isGuest"
:busy="state.busy === `reply`"
:placeholder="t(`common.comments.replyPlaceholder`)"
:submit-label="t(`common.comments.postReply`)"
@submit="postComment(thread.id)"
@cancel="cancelReply" />
</div>
</div>
<w-separator />
</template>
<!--
The four states the bottom of a talk page can be in, in the order they rule each other out:
the page is closed to comments, the reader may not write here, they are not signed in on a
wiki that does not take anonymous ones, or there is a box.
-->
<div class="py-4">
<w-banner v-if="!isOpen" :class="bannerClass">
{{ t('common.comments.closed') }}
</w-banner>
<w-banner v-else-if="!canWrite && !isGuest" :class="bannerClass">
{{ t('common.comments.notAllowed') }}
</w-banner>
<div class="text-center py-2" v-else-if="!canWrite">
<div class="text-body2 text-grey-6">{{ t('common.comments.signInToComment') }}</div>
<w-btn
class="mt-3"
unelevated
no-caps
color="primary"
icon="la:sign-in-alt"
:label="t(`common.header.login`)"
:to="`/login`" />
</div>
<page-comment-editor
v-else
v-model="state.draft"
v-model:author-name="state.authorName"
v-model:author-email="state.authorEmail"
:guest="isGuest"
:busy="state.busy === `new`"
:placeholder="t(`common.comments.newPlaceholder`)"
:submit-label="t(`common.comments.postComment`)"
@submit="postComment(null)" />
</div>
</template>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
import { confirm } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import { apiErrorMessage } from '@/helpers/apiError'
import PageComment from '@/components/PageComment.vue'
import PageCommentEditor from '@/components/PageCommentEditor.vue'
/**
* The Talk tab: the discussion of one page, for the built-in comments provider.
*
* Mounted beside the article rather than under it (`pages/Index.vue`), which is what separates this
* from every other provider a wiki page and its talk page are two views of the same thing, as they
* are on Wikipedia, and a discussion long enough to be worth having is one nobody would reach by
* scrolling past the article.
*
* **The permissions read here are the PAGE ones**, `userStore.pagePermissions`, which the server
* refreshed for this path. Not `userStore.can()`: that ORs the group-wide list in and answers "may do
* this somewhere", where every button below has to mean "may do this here" the endpoint behind each
* one asks exactly that.
*/
// COMPOSABLES
const dark = useDark()
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// DATA
const replyEditor = ref(null)
const state = reactive({
loading: false,
loaded: false,
/** Which box is in flight: `new`, `reply`, or the id of the comment being saved. */
busy: '',
comments: [],
mentions: [],
draft: '',
replyTo: null,
replyToName: '',
replyDraft: '',
/**
* The comment being edited, if any.
*
* Held here rather than inside the comment, because only this component knows when an edit is
* over: a save is a request, and a box that closed itself on submit would throw away what was
* typed the moment one failed.
*/
editingId: null,
/** What a guest fills in. Kept here rather than per box, so it survives moving between them. */
authorName: '',
authorEmail: ''
})
// COMPUTED
const isGuest = computed(() => !userStore.authenticated)
const canWrite = computed(() => userStore.pagePermissions.includes('write:comments'))
const canModerate = computed(() => userStore.pagePermissions.includes('manage:comments'))
/** Whether this page takes comments at all — the switch in its own properties dialog. */
const isOpen = computed(() => pageStore.allowComments)
const bannerClass = computed(() =>
dark.isActive ? 'bg-grey-9 text-grey-4' : 'bg-grey-2 text-grey-8'
)
/**
* The comments as threads: each top-level one with its replies under it.
*
* Assembled here rather than served nested, because the server answers with them flat and ordered by
* time which is what keeps a reply beside the comment it answers however long afterwards it was
* written, and what makes the one level of depth a property of the view rather than of the data.
*/
const threads = computed(() => {
const byId = new Map()
const roots = []
for (const comment of state.comments) {
if (comment.parentId) {
continue
}
const thread = { ...comment, replies: [] }
byId.set(comment.id, thread)
roots.push(thread)
}
for (const comment of state.comments) {
// -> A reply whose parent is not in this page's list cannot happen (they are deleted together),
// but dropping one is better than drawing an orphan under the wrong thread
byId.get(comment.parentId)?.replies.push(comment)
}
return roots
})
// WATCHERS
// -> The talk of the page in front of the reader, so moving to another one reloads rather than
// leaving the previous discussion under the new article
watch(
() => pageStore.id,
() => load()
)
// METHODS
/** Whether this reader may edit or delete a given comment. See the note on permissions above. */
function mayModify(comment) {
if (canModerate.value) {
return true
}
// -> A guest has no session to be recognized by, so "their own" has nothing to mean for them
return canWrite.value && Boolean(comment.authorId) && comment.authorId === userStore.id
}
async function load() {
if (!pageStore.id || !siteStore.comments.isBuiltIn) {
return
}
state.loading = true
try {
const resp = await API_CLIENT.get(`sites/${siteStore.id}/pages/${pageStore.id}/comments`).json()
state.comments = resp?.comments ?? []
state.mentions = resp?.mentions ?? []
state.loaded = true
// -> The badge on the tab is the count the page came with, and this is the same number after
// whatever has happened since -- from the server's own count rather than from the length of
// the list, which is capped
pageStore.commentsCount = resp?.total ?? state.comments.length
} catch (err) {
notify({
type: 'negative',
message: t('common.comments.loadFailed'),
caption: apiErrorMessage(err)
})
}
state.loading = false
}
function startEdit(comment) {
// -> One box at a time, and never two: a reply box open under a comment that is itself being
// edited is two drafts of the same thing on screen
cancelReply()
state.editingId = comment.id
}
function startReply(comment) {
state.editingId = null
// -> A reply always attaches to the thread, so the box opens under it whichever message was
// clicked -- but it is addressed to whoever was actually being answered
state.replyTo = comment.parentId ?? comment.id
state.replyToName = comment.authorName
state.replyDraft = ''
nextTick(() => {
// -> A ref inside a `v-for` collects into an array, and only one reply box is ever rendered
const box = Array.isArray(replyEditor.value) ? replyEditor.value[0] : replyEditor.value
box?.focus()
})
}
function cancelReply() {
state.replyTo = null
state.replyToName = ''
state.replyDraft = ''
}
async function postComment(parentId) {
const isReply = Boolean(parentId)
state.busy = isReply ? 'reply' : 'new'
try {
await API_CLIENT.post(`sites/${siteStore.id}/pages/${pageStore.id}/comments`, {
json: {
content: isReply ? state.replyDraft : state.draft,
...(isReply && { parentId }),
...(isGuest.value && {
authorName: state.authorName,
authorEmail: state.authorEmail
})
}
}).json()
if (isReply) {
cancelReply()
} else {
state.draft = ''
}
notify({ type: 'positive', message: t('common.comments.postSuccess') })
await load()
} catch (err) {
/*
The message is worth showing in full here rather than reduced to "could not post": what comes
back is a cooldown with a number of seconds on it, or a spam refusal, and both are things the
reader can do something about.
*/
notify({
type: 'negative',
message: t('common.comments.postFailed'),
caption: apiErrorMessage(err),
timeout: 10000
})
}
state.busy = ''
}
async function saveComment({ id, content }) {
state.busy = id
try {
await API_CLIENT.put(`sites/${siteStore.id}/comments/${id}`, { json: { content } }).json()
// -> Only once it has actually been saved. A failure leaves the box open with the text still in
// it, which is the whole reason this state is up here rather than inside the comment.
state.editingId = null
notify({ type: 'positive', message: t('common.comments.updateSuccess') })
await load()
} catch (err) {
notify({
type: 'negative',
message: t('common.comments.updateFailed'),
caption: apiErrorMessage(err)
})
}
state.busy = ''
}
function confirmDelete(comment) {
confirm({
title: t('common.comments.deleteConfirmTitle'),
message: t('common.comments.deleteWarn'),
persistent: true,
cancel: true,
color: 'negative',
okLabel: t('common.actions.delete')
}).onOk(async () => {
state.busy = comment.id
try {
await API_CLIENT.delete(`sites/${siteStore.id}/comments/${comment.id}`).json()
notify({ type: 'positive', message: t('common.comments.deleteSuccess') })
await load()
} catch (err) {
notify({
type: 'negative',
message: t('common.comments.deleteFailed'),
caption: apiErrorMessage(err)
})
}
state.busy = ''
})
}
// MOUNTED
onMounted(() => {
load()
})
</script>
<style lang="scss">
/*
The ink of the whole talk view, stated here because nothing else states it for this column.
The article beside it gets its colour from `--content-ink` in `_page-contents.scss`, declared on
`.page-contents` -- a talk page is not page content and is deliberately not styled by that sheet,
so it would otherwise inherit whatever the shell happens to leave on `<body>`: legible in the light
theme and dark-on-dark in the dark one. The two values are the same pair the content sheet uses, so
the article and its discussion read as one column.
*/
.page-talk {
max-width: 900px;
color: #26292e;
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.87);
}
}
.page-talk-thread {
padding: 4px 0;
}
.page-talk-reply {
padding: 8px 0 12px 42px;
}
</style>

@ -0,0 +1,355 @@
<template>
<!--
`aria-orientation` is stated because the tabs are at the RIGHT end of the bar and the arrow keys
below move along it -- a reader on a screen reader is told which way the strip runs rather than
inferring it from where the labels landed.
-->
<div
ref="listEl"
class="page-view-tabs"
role="tablist"
aria-orientation="horizontal"
@keydown="onKeydown">
<button
v-for="tab of tabs"
:key="tab.name"
type="button"
role="tab"
class="page-view-tab"
:class="{ 'is-active': tab.name === modelValue }"
:aria-selected="String(tab.name === modelValue)"
:tabindex="tab.name === modelValue ? 0 : -1"
@click="emit('update:modelValue', tab.name)">
<w-icon :name="tab.icon" size="sm" />
<span>{{ tab.label }}</span>
<!-- -> Only once there is something to count: a zero beside the tab says the same thing the
empty talk page does, and says it on every page of the wiki -->
<span class="page-view-tab-count" v-if="tab.count > 0">{{ tab.count }}</span>
</button>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePageStore } from '@/stores/page'
/**
* Article / Talk, above the content of a page that has a discussion beside it.
*
* Its own strip rather than `WTabs`, which is a segmented control: a tinted track with the active
* tab raised out of it as a pill, drawn wherever a caller puts it. What this location wants is the
* opposite shape -- chrome flush to the top and sides of the article column, with the active tab cut
* out of it in the article's own colour so the two read as one surface. A pill floating in padding
* above the article says "a control", where this says "you are looking at one of these two".
*
* The tabs are at the RIGHT end: the article's first heading is what a reader is here for and it
* starts at the left, so the switch stays out of the way of the column's own beginning.
*/
const props = defineProps({
/** Which view is on screen: `article` or `talk`. */
modelValue: {
type: String,
required: true
}
})
const emit = defineEmits(['update:modelValue'])
// STORES
const pageStore = usePageStore()
// I18N
const { t } = useI18n()
// DATA
const listEl = ref(null)
// COMPUTED
const tabs = computed(() => [
{
name: 'article',
icon: 'la:file-alt',
label: t('common.comments.tabArticle'),
count: 0
},
{
name: 'talk',
icon: 'la:comments',
label: t('common.comments.tabTalk'),
/*
The count the page came with, not the length of a list this strip does not have: the badge has
to be there before the discussion is ever opened, which is the whole reason it rides along on
the page payload.
*/
count: pageStore.commentsCount
}
])
// METHODS
/**
* Arrow keys move between the tabs, which is what a tablist is expected to do. Selecting as it moves
* (rather than requiring a second key) is the automatic-activation pattern, and is right here: both
* views are already loaded, so arriving at one costs nothing.
*/
function onKeydown(ev) {
const keys = { ArrowRight: 1, ArrowLeft: -1, Home: 'first', End: 'last' }
const move = keys[ev.key]
if (move === undefined) {
return
}
const btns = [...listEl.value.querySelectorAll('[role="tab"]')]
if (btns.length === 0) {
return
}
ev.preventDefault()
const at = btns.indexOf(document.activeElement)
const next =
move === 'first'
? 0
: move === 'last'
? btns.length - 1
: (Math.max(at, 0) + move + btns.length) % btns.length
btns[next].focus()
emit('update:modelValue', tabs.value[next].name)
}
</script>
<style lang="scss">
/*
The strip itself: chrome, flush to the top and both sides of the article column, with nothing
around it -- it is the lid of the column rather than something placed in it.
Flat, in the contents column's own grey (`.page-sidebar` in `_page-chrome.scss`: `$grey-2` light,
`$dark-5` dark). The two meet along the article's right-hand edge, so one value across both reads as
a single piece of chrome bent round the top and the side of the column -- which is what a gradient
could not do, matching the column beside it at one height and missing it everywhere else.
The line along the bottom is a step further up the same greys than the gradient now starts at, which
is what makes it read as the strip closing on itself rather than as a border drawn under it. It runs
the full width and the unselected tabs run UNDER it: the line is their bottom edge, and a tab tucked
beneath it is one that has not been opened. Only the selected tab is above the line -- it is the
front edge of the article below, so nothing may be drawn across the join.
*/
.page-view-tabs {
position: relative;
display: flex;
align-items: flex-end;
/* -> Right-aligned: see the component note */
justify-content: flex-end;
gap: 2px;
height: 44px;
/*
Shorter on a phone, along with the tabs themselves (see below): the strip is chrome above an
article on a screen that has 800 pixels of height for all of it, and 44 of them spent on a switch
between two views is a bar the reader has to scroll past before the page starts. The clearance
above the tabs comes down with it -- 8px of gradient over a 44px strip reads as a strip with tabs
cut out of it, and over a 36px one it reads as padding.
*/
@media (max-width: $breakpoint-xs-max) {
height: 36px;
}
/* -> Enough that the last tab's corner reads as a corner, and not so much that the strip stops
being flush with the side */
padding-right: 0.5rem;
flex: none;
/*
The line, as an overlay rather than as a border on this box: a border would be laid out UNDER the
tabs (they end where the content box does), and what is wanted is a line drawn OVER them, which
only something painted later can be. Positioned, so it paints above the tabs, which are not --
and the selected tab then takes a `z-index` of its own to come back out on top of it.
A pseudo-element rather than markup because a `tablist` takes tabs as its children and a line is
not one of them; this way there is nothing in the accessibility tree to hide from it again.
*/
&::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
/*
`--page-chrome-rule` is the edge this line belongs to, declared per theme on `.page-container`
(`_page-chrome.scss`): the same value continues down the right-hand side of the article as
`.page-article-col`, so the two are stated once and turn the corner together.
*/
border-bottom: 1px solid var(--page-chrome-rule);
}
@at-root .body--light & {
background-color: $grey-2;
}
@at-root .body--dark & {
background-color: $dark-5;
}
}
/*
One tab, sitting on the bottom edge of the strip rather than filling it: the 8px of gradient left
above is what makes the strip chrome that the tabs are cut out of, and it keeps every label down in
the pale end of the ramp where it can be read.
*/
.page-view-tab {
display: flex;
align-items: center;
gap: 0.5rem;
height: 36px;
padding: 0 1rem;
/* -> Top corners only: the bottom of a tab is not an edge, it is the article */
border-radius: 6px 6px 0 0;
/*
Drawn on every tab and transparent until the tab is the selected one, rather than added to that
one alone: the box is then the same size in both states, so no label shifts by a pixel as the
strip is switched. None along the bottom in either state -- that edge is the strip's own line,
which the selected tab is deliberately drawn over.
*/
border: 1px solid transparent;
border-bottom: 0;
background-color: transparent;
font-size: 0.8125rem;
/* -> One weight for both states. What marks the selected tab out is the surface it is drawn in and
the ink on it; setting the label heavier as well makes the strip twitch as it is switched,
every label being a different width in the two states. */
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition:
background-color 0.15s var(--ease-standard),
box-shadow 0.15s var(--ease-standard),
color 0.15s var(--ease-standard);
/*
An unselected tab sits flat in the strip and is drawn by its label alone: what says it is a tab is
the selected one beside it, which has a surface, an edge and a shadow, and the line along the
bottom that it alone breaks. Nothing is spent on saying twice that the other tab is the other tab.
Hover is then the only fill here, which is why it is a translucent black rather than a colour: it
takes its shade from the strip behind it, so one value answers in both themes without either of
them stating a second grey.
*/
@at-root .body--light & {
color: rgb(0 0 0 / 0.7);
&:hover:not(.is-active) {
background-color: rgb(0 0 0 / 0.09);
color: rgb(0 0 0 / 0.9);
}
}
@at-root .body--dark & {
color: rgb(255 255 255 / 0.55);
&:hover:not(.is-active) {
background-color: rgb(0 0 0 / 0.3);
color: rgb(255 255 255 / 0.85);
}
}
/*
The active tab is the article: the surface it is drawn in is the one the column is drawn in, so
the two meet with nothing between them and the tab reads as the front edge of what is below.
Which is why these are the document's own background values rather than a token -- `body` is what
paints the article column, and this is that same fill brought up 36px into the chrome.
Its three sides carry the strip's own line colour, so the line the tab interrupts turns the corner
and goes round it: what is drawn is one continuous edge with a tab raised out of it.
*/
&.is-active {
/* -> Above the line that crosses every other tab; see the strip's `::after` */
position: relative;
z-index: 1;
/*
Lifted out of the strip, and cut off flat where it meets the article: a shadow that reached past
the bottom edge would be drawn ON the content, and the join between the two has to be nothing at
all -- that is the whole of what makes the tab and the article one surface.
`clip-path` rather than a shadow shaped to fall short of the edge, because no offset and blur
can promise that: the clip region is grown past the top and the sides, where the shadow is
wanted, and cut exactly at the bottom, where it is not.
*/
box-shadow: 0 -2px 6px rgb(0 0 0 / 0.09);
clip-path: inset(-8px -8px 0);
/* -> The strip's own line, carried round the three sides the tab shows; see its `::after` */
border-color: var(--page-chrome-rule);
/*
A little light along the top, falling away to nothing by the bottom: the tab keeps the article's
exact colour where the two meet -- which is the whole of the merge -- and lifts away from it as
it rises out of the strip.
A white wash over whatever `background-color` the theme set, rather than a gradient stated twice
in the two themes' own values: it says "this colour, a little lighter at the top" once, and each
theme keeps one statement of what the article's surface is. In the light theme that surface is
already white and there is nowhere lighter to go, so the wash is invisible there and the tab
stays flat -- which is correct rather than a shortcoming, white being the end of the ramp.
*/
background-image: linear-gradient(to bottom, rgb(255 255 255 / 0.06) 0%, transparent 100%);
@at-root .body--light & {
background-color: #fff;
color: $grey-9;
}
@at-root .body--dark & {
background-color: $dark-6;
color: #fff;
/* -> Harder, because a soft black on a dark strip is nothing at all */
box-shadow: 0 -2px 6px rgb(0 0 0 / 0.4);
}
}
/* -> The strip is a small target on a phone and the labels are what make it one; the icons go
rather than the words */
@media (max-width: $breakpoint-xs-max) {
/* -> 32px in a 36px strip, which keeps the 4px of chrome above that makes it a strip. Under the
44px a touch target is usually drawn to, deliberately: what is being tapped is a full-width
label in a bar with nothing else in it, not a control with neighbours to hit by mistake. */
height: 32px;
padding: 0 0.75rem;
.w-icon {
display: none;
}
}
}
/*
The count, which belongs to the tab rather than to the strip: it is primary in both themes and on
both states, so it reads the same whether the discussion is open or not.
*/
.page-view-tab-count {
min-width: 18px;
padding: 0 5px;
border-radius: 999px;
background-color: $primary;
color: #fff;
font-size: 0.6875rem;
font-weight: 600;
line-height: 18px;
text-align: center;
/*
A little of its own colour cast around it, which is what makes a count of something WAITING read
as one -- the same trick `_page-contents.scss` uses for a step's glow, and the same way of writing
it (`color-mix` to an alpha rather than a second blue to keep in step with the first).
Static, not a pulse: the number is there on every page of the wiki that has a discussion, and a
thing that moves in the corner of the eye all day is a thing a reader learns to look away from.
It stays inside the tab: the glow is 6px on a badge with 8px of tab below it, so the selected
tab's `clip-path` -- which cuts everything at the join with the article -- never reaches it.
*/
box-shadow: 0 0 6px color-mix(in srgb, $primary 50%, transparent);
/* -> Further on a dark ground, where a glow has somewhere to fall */
@at-root .body--dark & {
box-shadow: 0 0 8px color-mix(in srgb, $primary 65%, transparent);
}
}
</style>

@ -599,6 +599,14 @@ registerWithForm?.({ validate })
defineExpose({
validate,
focus: () => inputEl.value?.focus(),
/**
* The underlying `<input>` or `<textarea>`.
*
* For the few callers that need the control itself rather than its value: a caret position, a
* selection range, a scroll offset. The comment composer's mention autocomplete is what asked for
* it where the `@` is in the text is a property of the element, not of the model.
*/
el: inputEl,
/**
* Show the value of a `revealable` password field, as if the eye had been clicked.
*

@ -10,7 +10,7 @@
@click="select">
<span
class="inline-flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors"
:class="isOn ? '' : 'border-black/54 dark:border-white/70'"
:class="isOn ? '' : ringClass"
:style="isOn ? { borderColor: `var(--color-${color})` } : undefined">
<!-- The inner dot is scaled rather than toggled, so selecting animates instead of snapping -->
<span
@ -62,12 +62,28 @@ const props = defineProps({
disabled: {
type: Boolean,
default: false
},
/**
* Renders for a dark surface regardless of the app theme, as `WList` does.
*
* Needed where a radio sits on a panel that is dark in both themes -- the provider list on the
* admin Comments screen -- because the `dark:` variant keys off the app theme and an unselected
* ring would otherwise be drawn near-black on a dark card in light mode. Only the UNSELECTED ring
* needs it: the selected one is drawn in `color`, which is legible on either surface.
*/
dark: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const isOn = computed(() => props.modelValue === props.val)
const ringClass = computed(() =>
props.dark ? 'border-white/70' : 'border-black/54 dark:border-white/70'
)
const isDisabled = computed(() => props.disable || props.disabled)
function select() {

@ -293,6 +293,28 @@ $toc-overlay-max: 749.98px;
the light theme, which is the sort of difference that reads as a bug in whichever one you see second.
*/
.page-container {
/*
The colour of the edge drawn round the article: the line along the bottom of the Article / Talk
strip, the three sides of the tab raised out of it, and the rule down the right of the column
below -- see `.page-article-col` and `PageViewTabs.vue`. One custom property because they are one
edge: two of them are drawn by a component and one by this sheet, and a pair of values that have
to agree across two files is a pair of values that eventually will not.
Each theme's is the actions rail's own colour (`.page-actions` in `PageActionsCol.vue`: `$grey-3`
light, `$dark-4` dark) or a step off it: the rail closes the far side of the page, so drawing this
edge in what the rail is drawn in is what makes the two read as the same piece of chrome rather
than as two edges of slightly different greys. The light one stays a step down at `$grey-4`, where
the rail's own value is too near the strip to be seen against it.
*/
@at-root .body--light & {
--page-chrome-rule: #{$grey-4};
}
@at-root .body--dark & {
/* -> Half a step above the rail's own `$dark-4`, which the edge was drawn in exactly and read a
shade too close to the strip it divides */
--page-chrome-rule: #{color-mix(in srgb, $dark-3 50%, $dark-4)};
}
@at-root .body--light & {
border-top: 1px solid #fff;
}
@ -301,6 +323,24 @@ $toc-overlay-max: 749.98px;
}
}
/*
The article column's own right-hand edge, which is the strip's rule turned the corner: the strip
closes the top of the column, this closes its side, and the two meet at the strip's bottom corner so
that what is drawn reads as one line round the content rather than as two borders that happen to
touch.
On the scrolling box rather than on the column that holds it, so it starts UNDER the strip: the
strip is the same grey as the contents column beside it and the two are meant to read as one
surface, which a line drawn between them for 44px would cut in half.
Worn whether or not there is a strip above it. What is on the other side is the contents column or
the actions rail, both of them chrome, and a column edge that appeared only on pages that take
comments would be a difference a reader cannot account for.
*/
.page-article-col {
border-right: 1px solid var(--page-chrome-rule);
}
.page-sidebar {
flex: 0 0 300px;

@ -0,0 +1,195 @@
/**
* Mounting the markup of a third-party comments provider.
*
* The server renders each provider's snippet with everything it knows (`models/comments.ts`) and
* leaves the placeholders it cannot know behind: the ones about the page, which is different on every
* router transition. Those are what this file fills in, by the same rules and with the same escaping
* the server uses -- `{{js:page.url}}` is a JavaScript string literal, `{{attr:page.path}}` is an
* attribute value, and the two escape differently.
*
* Why this is done in the browser at all, rather than served in the document the way an analytics tag
* is: a comment widget belongs at the bottom of the article, and moving between wiki pages here is a
* router transition and not a document load. A snippet baked into the shell would initialise once and
* then show the first page's discussion for ever.
*/
/** The placeholder pattern, identical to `PLACEHOLDER` in `backend/models/comments.ts`. */
const PLACEHOLDER = /\{\{(js|attr|num|bool):page\.([A-Za-z0-9_]+)\}\}/g
/** What a character becomes inside a JavaScript string literal. As `models/comments.ts`, verbatim. */
const JS_ESCAPES = {
'\\': '\\\\',
"'": "\\'",
'"': '\\"',
'`': '\\`',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
'<': '\\u003C',
'>': '\\u003E',
'&': '\\u0026',
'\u2028': '\\u2028',
'\u2029': '\\u2029'
}
const JS_ESCAPE_PATTERN = /[\\'"`\n\r\t<>&\u2028\u2029]/g
const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
/**
* Head elements already added to this document, keyed by the markup that produced them.
*
* A provider's `head` slot is a stylesheet and an SDK: the same for every page, and expensive to
* re-fetch and re-evaluate on each router transition. So it is added once and left, which also means
* a provider's own globals survive the move from one page to the next -- which is exactly what
* Disqus's `reset` and Remark42's `createInstance` are written to be called against.
*/
const mountedHead = new Map()
function jsEscape(value) {
return `${value}`.replace(JS_ESCAPE_PATTERN, (char) => JS_ESCAPES[char])
}
function htmlEscape(value) {
return `${value}`.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char])
}
/**
* Fill a provider's remaining placeholders in with the page in front of the reader.
*
* @param {string} template Markup as the server rendered it
* @param {object} page `{ id, path, title, locale, url }`
* @returns {string|null} The markup, or null where a `num` placeholder could not be resolved to a
* number -- a bare numeric literal that is not one is a syntax error taking the whole snippet with
* it, so the snippet is dropped rather than emitted broken. The server does the same.
*/
export function resolvePageTemplate(template, page) {
if (!template) {
return ''
}
let usable = true
const rendered = template.replace(PLACEHOLDER, (_match, context, key) => {
const value = page?.[key]
switch (context) {
case 'num': {
const num = Number(value)
if (!Number.isFinite(num)) {
usable = false
return '0'
}
return `${num}`
}
case 'bool':
return value === true ? 'true' : 'false'
case 'attr':
return htmlEscape(value ?? '')
default:
return jsEscape(value ?? '')
}
})
return usable ? rendered : null
}
/**
* Parse a fragment of markup into nodes, without running or fetching anything.
*
* `<template>` rather than `innerHTML` on a live element: its contents are inert, so a `<script>` in
* here is a node to be looked at rather than one the browser has already decided not to run.
*/
function parseFragment(markup) {
const tpl = document.createElement('template')
tpl.innerHTML = markup
return [...tpl.content.childNodes]
}
/**
* A `<script>` the browser will actually run.
*
* A script node that arrived through `innerHTML` is inert for ever -- the HTML parser marks it
* "already started" -- so the only way to run one is to build a fresh element and copy the original
* over, attributes and all. `type` matters as much as `src`: Waline's snippet is an ES module.
*/
function executableScript(original) {
const script = document.createElement('script')
for (const attr of original.attributes) {
script.setAttribute(attr.name, attr.value)
}
script.textContent = original.textContent
return script
}
/** A script that has to be fetched, resolved once it has run or once it has failed to. */
function whenLoaded(script) {
if (!script.src) {
return Promise.resolve()
}
return new Promise((resolve) => {
script.addEventListener('load', resolve, { once: true })
// -> A provider that cannot be reached must not leave the rest of the snippet unrun for ever:
// its own init script is usually what draws the error the reader is owed
script.addEventListener('error', resolve, { once: true })
})
}
/**
* Add a provider's `head` slot to the document, once per document.
*
* Awaited, because the `body` slot is the init call and the thing it initialises is what these load.
*/
async function mountHead(head) {
if (!head || mountedHead.has(head)) {
return
}
const pending = []
const nodes = []
for (const node of parseFragment(head)) {
const element =
node.nodeName === 'SCRIPT' && node.nodeType === Node.ELEMENT_NODE
? executableScript(node)
: node
document.head.appendChild(element)
nodes.push(element)
if (element.nodeName === 'SCRIPT') {
pending.push(whenLoaded(element))
}
}
mountedHead.set(head, nodes)
await Promise.all(pending)
}
/**
* Draw one provider's comment widget into a container.
*
* The three slots in order: whatever the document needs loaded, the container markup, and then the
* script that starts the widget -- which is run only once the first has finished, since it is the
* call into what was loaded.
*
* Scripts go INSIDE the container rather than into the head, which several providers depend on:
* giscus and Isso draw themselves where their own script tag sits.
*
* @param {HTMLElement} container Emptied first, so that mounting twice draws once
* @param {{head: string, main: string, body: string}} code As the site payload carries it
* @param {object} page `{ id, path, title, locale, url }`
* @returns {Promise<void>}
*/
export async function mountCommentsEmbed(container, code, page) {
container.textContent = ''
await mountHead(resolvePageTemplate(code.head, page))
const main = resolvePageTemplate(code.main, page)
if (main) {
container.innerHTML = main
}
const body = resolvePageTemplate(code.body, page)
if (!body) {
return
}
for (const node of parseFragment(body)) {
if (node.nodeName === 'SCRIPT' && node.nodeType === Node.ELEMENT_NODE) {
container.appendChild(executableScript(node))
} else {
container.appendChild(node)
}
}
}

@ -147,17 +147,15 @@
</w-item-section>
<w-item-section>{{ t('admin.approval.title') }}</w-item-section>
</w-item>
<template v-if="flagsStore.experimental">
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/comments`"
active-class="bg-primary text-white"
disabled>
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-comments.svg" />
</w-item-section>
<w-item-section>{{ t('admin.comments.title') }}</w-item-section>
</w-item>
</template>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/comments`"
active-class="bg-primary text-white"
v-if="userStore.can(`manage:sites`)">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-comments.svg" />
</w-item-section>
<w-item-section>{{ t('admin.comments.title') }}</w-item-section>
</w-item>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/blocks`"
active-class="bg-primary text-white"

@ -1,218 +1,535 @@
<template lang="pug">
v-container(fluid, grid-list-lg)
v-layout(row, wrap)
v-flex(xs12)
.admin-header
img.animated.fadeInUp(src='/_assets/svg/icon-chat-bubble.svg', alt='Comments', style='width: 80px;')
.admin-header-title
.headline.primary--text.animated.fadeInLeft {{$t('admin.comments.title')}}
.subtitle-1.grey--text.animated.fadeInLeft.wait-p2s {{$t('admin.comments.subtitle')}}
v-spacer
v-btn.animated.fadeInDown.wait-p3s(icon, outlined, color='grey', href='https://docs.requarks.io/comments', target='_blank')
v-icon mdi-help-circle
v-btn.mx-3.animated.fadeInDown.wait-p2s(icon, outlined, color='grey', @click='refresh')
v-icon mdi-refresh
v-btn.animated.fadeInDown(color='success', @click='save', depressed, large)
v-icon(left) mdi-check
span {{$t('common.actions.apply')}}
v-flex(lg3, xs12)
v-card.animated.fadeInUp
v-toolbar(flat, color='primary', dark, dense)
.subtitle-1 {{$t('admin.comments.provider')}}
v-list.py-0(two-line, dense)
template(v-for='(provider, idx) in providers')
v-list-item(:key='provider.key', @click='selectedProvider = provider.key', :disabled='!provider.isAvailable')
v-list-item-avatar(size='24')
v-icon(color='grey', v-if='!provider.isAvailable') mdi-minus-box-outline
v-icon(color='primary', v-else-if='provider.key === selectedProvider') mdi-checkbox-marked-circle-outline
v-icon(color='grey', v-else) mdi-checkbox-blank-circle-outline
v-list-item-content
v-list-item-title.body-2(:class='!provider.isAvailable ? `grey--text` : (selectedProvider === provider.key ? `primary--text` : ``)') {{ provider.title }}
v-list-item-subtitle: .caption(:class='!provider.isAvailable ? `grey--text text--lighten-1` : (selectedProvider === provider.key ? `blue--text ` : ``)') {{ provider.description }}
v-list-item-avatar(v-if='selectedProvider === provider.key', size='24')
v-icon.animated.fadeInLeft(color='primary', large) mdi-chevron-right
v-divider(v-if='idx < providers.length - 1')
v-flex(lg9, xs12)
v-card.animated.fadeInUp.wait-p2s
v-toolbar(color='primary', dense, flat, dark)
.subtitle-1 {{provider.title}}
v-card-info(color='blue')
div
div {{provider.description}}
span.caption: a(:href='provider.website') {{provider.website}}
v-spacer
.admin-providerlogo
img(:src='provider.logo', :alt='provider.title')
v-card-text
.overline.my-5 {{$t('admin.comments.providerConfig')}}
.body-2.ml-3(v-if='!provider.config || provider.config.length < 1'): em {{$t('admin.comments.providerNoConfig')}}
template(v-else, v-for='cfg in provider.config')
v-select.mb-3(
v-if='cfg.value.type === "string" && cfg.value.enum'
outlined
:items='cfg.value.enum'
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
prepend-icon='mdi:cog-box'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
:class='cfg.value.hint ? "mb-2" : ""'
:style='cfg.value.maxWidth > 0 ? `max-width:` + cfg.value.maxWidth + `px;` : ``'
)
v-switch.mb-6(
v-else-if='cfg.value.type === "boolean"'
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
color='primary'
prepend-icon='mdi:cog-box'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
inset
)
v-textarea.mb-3(
v-else-if='cfg.value.type === "string" && cfg.value.multiline'
outlined
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
prepend-icon='mdi:cog-box'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
:class='cfg.value.hint ? "mb-2" : ""'
)
v-text-field.mb-3(
v-else
outlined
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
prepend-icon='mdi:cog-box'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
:class='cfg.value.hint ? "mb-2" : ""'
:style='cfg.value.maxWidth > 0 ? `max-width:` + cfg.value.maxWidth + `px;` : ``'
)
<template>
<w-page class="admin-comments">
<div class="flex flex-wrap p-4 items-center">
<div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-comments.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.comments.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.comments.subtitle') }}
</div>
</div>
<div class="flex-none flex items-center">
<w-spinner class="mr-4" v-show="state.loading > 0" color="accent" size="sm" />
<w-btn
class="mr-2 acrylic-btn"
icon="la:question-circle"
flat
color="grey"
:aria-label="t(`common.actions.viewDocs`)"
:href="siteStore.docsBase + `/admin/comments`"
target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<w-btn
unelevated
icon="mdi:check"
:label="t(`common.actions.apply`)"
color="secondary"
@click="save"
:loading="state.loading > 0" />
</div>
</div>
<w-separator inset />
<!--
The same shape as the storage and analytics screens: a list as wide as it needs to be, the panel
taking what is left, and the panel wrapping onto its own row rather than narrowing for ever. The
explicit floors are what make the wrapping real -- see the note in `AdminStorage.vue`.
-->
<div class="flex flex-wrap p-4 gap-4">
<div class="flex-none">
<w-card class="rounded bg-dark">
<w-list style="min-width: 300px" padding dark>
<w-item
v-for="prv of state.providers"
:key="prv.key"
active-class="bg-primary text-white"
:active="state.selectedProvider === prv.key"
:to="`/_admin/` + adminStore.currentSiteId + `/comments/` + prv.key"
clickable>
<!--
Which provider is in use, and the only control that sets it. `.stop.prevent` because
the row itself is a link to that provider's settings: without them the click would
reach the anchor and navigate, and `.stop` alone would leave the browser to follow
the href as a full page load -- router-link's own handler having been cut off.
Choosing is separate from looking, which is why the radio is here rather than in the
panel: comparing two providers means opening each in turn, and a screen where that
also switched the live one would be a trap.
White on the row being LOOKED at, which is the one filled with `bg-primary`: a
selected radio draws itself in its colour, so the default primary would be a blue
dot inside a blue ring on a blue row -- invisible on exactly the row most likely to
be both.
-->
<w-item-section side>
<w-radio
dark
:model-value="state.selected"
:val="prv.key"
:color="state.selectedProvider === prv.key ? `white` : `primary`"
:aria-label="t(`admin.comments.useProvider`, { provider: prv.title })"
@click.stop.prevent="selectProvider(prv.key)" />
</w-item-section>
<w-item-section side><w-icon :name="`img:` + prv.icon" /></w-item-section>
<w-item-section>
<w-item-label>{{ prv.title }}</w-item-label>
<w-item-label caption :class="subtitleColor(prv)">{{
providerState(prv).label
}}</w-item-label>
</w-item-section>
<w-item-section side>
<status-light :color="providerState(prv).light" :pulse="providerState(prv).pulse" />
</w-item-section>
</w-item>
</w-list>
</w-card>
</div>
<div class="flex-1" style="min-width: min(480px, 100%)" v-if="state.provider">
<div class="flex flex-wrap gap-4">
<div class="flex-1" style="min-width: min(420px, 100%)">
<!-- ----------------------- -->
<!-- Provider Configuration -->
<!-- ----------------------- -->
<w-card class="pb-2">
<w-card-header>{{ t('admin.comments.providerConfiguration') }}</w-card-header>
<!--
The condition belongs on the section rather than on the text inside it: a section is
a padded band whether or not anything renders in it, so an unconditional one would
leave 32px of empty space under the toggle on every provider that does have props.
-->
<w-card-section
v-if="!state.provider.config || Object.keys(state.provider.config).length < 1">
<div class="text-body2 text-grey">
{{ t('admin.comments.providerNoConfiguration') }}
</div>
</w-card-section>
<template v-for="(cfg, cfgKey) in state.provider.config" :key="cfgKey">
<w-separator class="my-2" inset />
<w-item v-if="cfg.type === `boolean`" tag="label">
<blueprint-icon class="self-start" :icon="cfg.icon" />
<w-item-section>
<w-item-label>{{ cfg.title }}</w-item-label>
<w-item-label caption>{{ cfg.hint }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle v-model="cfg.value" :aria-label="cfg.title" />
</w-item-section>
</w-item>
<w-item v-else>
<blueprint-icon class="self-start" :icon="cfg.icon" />
<w-item-section>
<w-item-label>{{ cfg.title }}</w-item-label>
<w-item-label caption>{{ cfg.hint }}</w-item-label>
</w-item-section>
<w-item-section :style="cfg.type === `number` ? `flex: 0 0 150px;` : ``">
<w-select
v-if="cfg.enum"
outlined
v-model="cfg.value"
:options="cfg.enum"
emit-value
map-options
dense
options-dense
:aria-label="cfg.title" />
<!-- -> `no-autofill` on every field, as on the other module forms: a password
manager offers to fill whatever LOOKS like an account field, and an API key
beside a server URL is exactly that shape. -->
<w-input
v-else
outlined
v-model="cfg.value"
dense
no-autofill
:type="inputTypeFor(cfg)"
:revealable="cfg.sensitive"
:aria-label="cfg.title" />
</w-item-section>
</w-item>
</template>
<!--
Two states worth saying out loud, and the site-wide one first because it overrules
the other: picking a provider here does nothing at all while comments are switched
off under General, and an administrator who has just done so is owed that sentence
rather than a screen that looks saved and changes nothing.
-->
<w-card-section v-if="!state.isAllowed">
<w-banner
:class="dark.isActive ? `bg-orange-9 text-white` : `bg-orange-1 text-orange-9`">
{{ t('admin.comments.disabledWarn') }}
</w-banner>
</w-card-section>
<!-- -> Only of the provider actually in use, and only once a required field is
genuinely empty: a provider is chosen and then filled in, and saying this before
either has happened would be scolding somebody for not having finished yet. -->
<w-card-section v-else-if="missingLabels.length > 0">
<w-banner
:class="dark.isActive ? `bg-orange-9 text-white` : `bg-orange-1 text-orange-9`">
{{ t('admin.comments.missingFields', { fields: missingLabels.join(', ') }) }}
</w-banner>
</w-card-section>
<!--
No provider in use at all. Not something this screen can produce any more -- the
radios have no "none" -- but a stored key stops resolving when its module is dropped
from the installation, and a site in that state has no comments anywhere.
-->
<w-card-section v-else-if="!state.selected">
<w-banner
:class="dark.isActive ? `bg-orange-9 text-white` : `bg-orange-1 text-orange-9`">
{{ t('admin.comments.noneWarn') }}
</w-banner>
</w-card-section>
</w-card>
</div>
<div class="flex-none" style="width: 300px">
<!-- ----------------------- -->
<!-- Infobox -->
<!-- ----------------------- -->
<w-card class="rounded">
<w-card-section class="text-center">
<!-- -> The module's own icon, the same one the list on the left draws it with, so a
provider looks the same wherever this screen shows it -->
<w-icon :name="`img:` + state.provider.icon" size="100px" />
<div class="text-subtitle2 mt-2">{{ state.provider.title }}</div>
<div class="text-caption mt-2">{{ state.provider.description }}</div>
</w-card-section>
<w-separator />
<!--
What using this provider means for the wiki, which is the one thing that genuinely
differs between the two kinds and is not obvious from the settings: whether the page
rules govern the discussion, or whether somebody else's service does.
-->
<w-card-section>
<div class="text-caption">
{{
state.provider.isBuiltIn
? t('admin.comments.builtInInfo')
: t('admin.comments.thirdPartyInfo')
}}
</div>
</w-card-section>
</w-card>
<w-btn
v-if="state.provider.website"
class="w-full mt-4 acrylic-btn"
icon="la:external-link-alt"
flat
color="primary"
:label="t(`admin.comments.website`)"
:href="state.provider.website"
target="_blank"
rel="noopener" />
</div>
</div>
</div>
</div>
</w-page>
</template>
<script>
import _ from 'lodash'
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, nextTick, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
/**
* Admin > Comments: which provider this site's discussions are handled by.
*
* Laid out as the storage and analytics screens are -- providers on the left, the configuration in
* the middle, what the provider is on the right -- and differs from them in one way that shapes the
* whole screen: **only one provider is in use at a time**. Two analytics tags count the same visit
* twice, which is a mistake to warn about; two comment widgets are two separate discussions of the
* same page, and neither of them is the discussion.
*
* So the toggle is a choice rather than a switch, and `state.selected` -- the one that is in use --
* is separate from `state.selectedProvider`, which is merely the one being looked at.
*
* The configuration of the providers that are not in use is kept and saved all the same, so that
* trying another one and coming back finds a form still filled in.
*/
// COMPOSABLES
const dark = useDark()
// STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore()
// ROUTER
const router = useRouter()
const route = useRoute()
// I18N
const { t } = useI18n()
// META
useMeta(() => ({
title: t('admin.comments.title')
}))
export default {
data() {
return {
providers: [],
selectedProvider: '',
provider: {}
// DATA
const state = reactive({
loading: 0,
/** The provider whose settings are on screen. */
selectedProvider: '',
desiredProvider: '',
/** The provider the site uses, which is what the toggle sets. Empty means none is in use. */
selected: '',
/** Whether the site allows comments at all, which is the switch under General → Features. */
isAllowed: true,
provider: null,
providers: []
})
// COMPUTED
/**
* The titles of the selected provider's required fields that are still empty.
*
* Read off the form rather than off what the server last sent, so that filling the last empty field
* clears the warning as it is typed. The server asks the same question of the stored values before it
* tells a browser anything: a provider in use but missing one of these is served as no provider at
* all, which is the whole reason this is worth saying on the screen.
*/
const missingLabels = computed(() => {
const provider = state.provider
if (!provider || state.selected !== provider.key) {
return []
}
return (provider.requires ?? [])
.filter((key) => `${provider.config?.[key]?.value ?? ''}`.trim().length < 1)
.map((key) => provider.config?.[key]?.title ?? key)
})
// WATCHERS
watch(
() => adminStore.currentSiteId,
async (newValue) => {
await load()
nextTick(() => {
router.replace(`/_admin/${newValue}/comments/${state.selectedProvider}`)
})
}
)
watch(
() => state.selectedProvider,
(newValue) => {
state.provider = state.providers.find((prv) => prv.key === newValue) || null
}
)
watch(
() => state.providers,
(newValue) => {
if (newValue && newValue.length > 0) {
if (state.desiredProvider) {
state.selectedProvider = state.desiredProvider
state.desiredProvider = ''
} else if (newValue.some((prv) => prv.key === state.selectedProvider)) {
// -> Keep the current selection across a reload, since saving reloads the providers
state.provider = newValue.find((prv) => prv.key === state.selectedProvider)
} else {
state.selectedProvider = newValue[0].key
if (!route.params.id) {
router.replace(`/_admin/${adminStore.currentSiteId}/comments/${state.selectedProvider}`)
}
}
}
},
watch: {
selectedProvider(newValue, oldValue) {
this.provider = _.find(this.providers, ['key', newValue]) || {}
},
providers(newValue, oldValue) {
this.selectedProvider = _.get(_.find(this.providers, 'isEnabled'), 'key', 'db')
}
)
watch(
() => route.params.id,
(to) => {
if (!to) {
return
}
},
methods: {
async refresh() {
await this.$apollo.queries.providers.refetch()
this.$store.commit('showNotification', {
message: this.$t('admin.comments.listRefreshSuccess'),
style: 'success',
icon: 'cached'
})
},
async save() {
this.$store.commit(`loadingStart`, 'admin-comments-saveproviders')
try {
const resp = await this.$apollo.mutate({
mutation: `
mutation($providers: [CommentProviderInput]!) {
comments {
updateProviders(providers: $providers) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
`,
variables: {
providers: this.providers.map((tgt) => ({
isEnabled: tgt.key === this.selectedProvider,
key: tgt.key,
config: tgt.config.map((cfg) => ({
...cfg,
value: JSON.stringify({ v: cfg.value.value })
}))
}))
}
if (state.providers.length < 1) {
state.desiredProvider = to
} else {
state.selectedProvider = to
}
}
)
// METHODS
/**
* What a provider is doing, in the order the two questions matter.
*
* Not in use first, since nothing else about it applies. Then whether it has what it needs: a
* provider missing a required field shows nothing at all rather than showing less -- the server
* skips it -- so it gets the amber light that means "go and look at this one" here and on the
* storage and analytics screens.
*/
function providerState(prv) {
if (state.selected !== prv.key) {
return { label: t('admin.comments.inactive'), light: 'negative', pulse: false }
}
const missing = (prv.requires ?? []).some(
(key) => `${prv.config?.[key]?.value ?? ''}`.trim().length < 1
)
if (missing) {
return { label: t('admin.comments.incomplete'), light: 'warning', pulse: true }
}
return { label: t('admin.comments.active'), light: 'positive', pulse: true }
}
function subtitleColor(prv) {
if (state.selectedProvider === prv.key) {
return 'text-blue-2'
} else if (state.selected === prv.key) {
return 'text-positive'
} else {
return 'text-grey-7'
}
}
/**
* The field a prop is edited in.
*
* A sensitive prop gets a password field with a reveal, so that an API key is not read over
* somebody's shoulder from an admin screen -- and the value in it is the mask until it is typed over,
* since the server never sends a stored secret back out.
*/
function inputTypeFor(cfg) {
if (cfg.sensitive) {
return 'password'
}
return cfg.type === 'number' ? 'number' : 'text'
}
/**
* Put a provider in use, which is the same act as taking whichever one was in use out of it.
*
* Only changes what is SELECTED, not what is on screen: the row's own link does that, and a radio
* that also navigated would make comparing two providers impossible without switching the live one.
*/
function selectProvider(key) {
state.selected = key
}
/**
* Turn a module prop declaration and its stored value into the shape the config editor renders,
* expanding `value|label` enum entries into options.
*/
function buildConfigEditor(props, values) {
const config = {}
for (const [key, prop] of Object.entries(props ?? {})) {
config[key] = {
...prop,
value: values?.[key] ?? prop.default,
...(prop.enum && {
enum: prop.enum.map((entry) => {
const [value, label] = entry.split('|')
return { value, label: label ?? value }
})
if (_.get(resp, 'data.comments.updateProviders.responseResult.succeeded', false)) {
this.$store.commit('showNotification', {
message: this.$t('admin.comments.configSaveSuccess'),
style: 'success',
icon: 'check'
})
} else {
throw new Error(
_.get(
resp,
'data.comments.updateProviders.responseResult.message',
this.$t('common.error.unexpected')
)
)
}
} catch (err) {
this.$store.commit('pushGraphError', err)
}
this.$store.commit(`loadingStop`, 'admin-comments-saveproviders')
})
}
},
apollo: {
providers: {
query: `
query {
comments {
providers {
isEnabled
key
title
description
logo
website
isAvailable
config {
key
value
}
}
}
}
`,
fetchPolicy: 'network-only',
update: (data) =>
_.cloneDeep(data.comments.providers).map((str) => ({
...str,
config: _.sortBy(
str.config.map((cfg) => ({
...cfg,
value: JSON.parse(cfg.value)
})),
[(t) => t.value.order]
)
})),
watchLoading(isLoading) {
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-comments-refresh')
}
return config
}
async function load() {
state.loading++
loading.show()
try {
const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}/comments`).json()
state.selected = resp?.provider ?? ''
state.isAllowed = resp?.isAllowed !== false
state.providers = (resp?.providers ?? []).map((prv) => ({
...prv,
config: buildConfigEditor(prv.props, prv.config)
}))
} catch (err) {
notify({
type: 'negative',
message: t('admin.comments.loadFailed'),
caption: apiErrorMessage(err),
timeout: 20000
})
}
loading.hide()
state.loading--
}
/** A provider as the API expects it. Read-only props are left out — the server keeps what it holds. */
function payloadFor(prv) {
const config = {}
for (const [key, cfg] of Object.entries(prv.config ?? {})) {
if (cfg.readOnly) {
continue
}
config[key] = cfg.type === 'number' ? Number(cfg.value) : cfg.value
}
return { key: prv.key, config }
}
/**
* Save the selection and every provider's settings at once.
*
* All of them rather than the one on screen: switching providers to compare two of them is exactly
* what this screen is for, and a save that took only the visible one would quietly discard whatever
* was typed into the other before the switch.
*
* A sensitive value goes back up as the mask it came down as, which the server reads as "leave it as
* it is" -- so saving this screen never overwrites a stored key with dots.
*/
async function save() {
state.loading++
loading.show()
try {
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}/comments`, {
json: {
provider: state.selected,
providers: state.providers.map(payloadFor)
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('admin.comments.saveSuccess')
})
await load()
} catch (err) {
notify({
type: 'negative',
message: t('admin.comments.saveFailed'),
caption: apiErrorMessage(err)
})
}
loading.hide()
state.loading--
}
// MOUNTED
onMounted(() => {
if (!state.selectedProvider && route.params.id) {
state.desiredProvider = route.params.id
}
if (adminStore.currentSiteId) {
load()
}
})
</script>

@ -187,21 +187,24 @@
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<template v-if="flagsStore.experimental">
<w-item tag="label">
<blueprint-icon icon="discussion-forum" />
<w-item-section>
<w-item-label>{{ t(`admin.general.allowComments`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowCommentsHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.features.comments"
:aria-label="t(`admin.general.allowComments`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
</template>
<!--
The site-wide switch. Which provider handles the discussion is the Comments screen's
question; this one is whether there is a discussion at all, and it is here because it
reads as a feature of the site alongside browsing, ratings and search.
-->
<w-item tag="label">
<blueprint-icon icon="discussion-forum" />
<w-item-section>
<w-item-label>{{ t(`admin.general.allowComments`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowCommentsHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.features.comments"
:aria-label="t(`admin.general.allowComments`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<template v-if="flagsStore.experimental">
<w-item>
<blueprint-icon icon="star-half-empty" />
@ -651,7 +654,7 @@ function defaultConfig() {
features: {
ratings: false,
ratingsMode: 'off',
comments: false,
comments: true,
reasonForChange: 'required'
},
discoverable: false,
@ -778,7 +781,7 @@ async function save() {
},
features: {
browse: state.config.features?.browse ?? false,
comments: state.config.features?.comments ?? false,
comments: state.config.features?.comments ?? true,
ratingsMode: state.config.features?.ratingsMode ?? 'off',
reasonForChange: state.config.features?.reasonForChange ?? 'required',
search: state.config.features?.search ?? false

@ -41,9 +41,26 @@
<page-header v-if="!pageStore.notFound" />
<!-- -> `min-h-0` so the columns inside can be shorter than their content and scroll -->
<div class="page-container flex min-h-0 flex-nowrap items-stretch" style="flex: 1 1 100%">
<!--
`flex flex-col min-h-0`: the strip below is fixed to the top of this column and the scrolling
article takes what is left, which is what keeps a tab bar at the top of the view rather than
at the top of the content -- a strip that scrolled away would take with it the only way back
from the discussion to the page.
-->
<div
class="min-w-0 flex-1"
class="min-w-0 flex-1 flex flex-col min-h-0"
:style="siteStore.theme.tocPosition === `left` ? `order: 2;` : `order: 1;`">
<!--
Article / Talk, above the content and only where there is a talk page to go to: the
built-in comments provider, on a page that takes comments, for a reader the page rules let
read them. Every other provider draws itself UNDER the article instead -- see
`PageCommentsEmbed.vue` -- so there is nothing to switch between and no strip.
Outside the scrolling box rather than at the top of it, which is also what makes an anchor
land where it should: the scrollport starts under the strip, so a heading jumped to is not
jumped to underneath it.
-->
<page-view-tabs v-if="showTalkTab" v-model="state.view" />
<component :is="editorComponents[editorStore.editor]" v-if="editorStore.isActive" />
<!--
The lock screen, in place of the article. There is nothing to hide here: the server sent no
@ -111,7 +128,12 @@
that is not there at all, has no target to have been given yet.
-->
<page-redirect v-else-if="pageStore.editor === `redirect`" />
<w-scroll-area class="page-container-scrl" ref="pageScroller" v-else style="height: 100%">
<!-- -> No height of its own any more: it is the flexible half of the column above, so what
is left under the strip is exactly what it gets -->
<w-scroll-area
class="page-container-scrl page-article-col flex-1 min-h-0"
ref="pageScroller"
v-else>
<!-- -> Half the padding on a phone, where 16px a side is 8% of the window spent on margin;
the stylesheet has `--content-bleed` to match -->
<div class="page-container-body p-2 sm:p-4">
@ -120,6 +142,11 @@
about is the content, and this is where a reader is already looking.
-->
<site-banner />
<!--
`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="showTalkTab && state.view === `talk`" />
<!--
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.
@ -127,6 +154,7 @@
<div
class="page-contents"
ref="pageContents"
v-show="!showTalkTab || state.view === `article`"
v-html="pageStore.render"
@click="onContentClick" />
<!--
@ -137,7 +165,11 @@
-->
<div
class="page-relations"
v-if="pageStore.relations && pageStore.relations.length > 0">
v-if="
pageStore.relations &&
pageStore.relations.length > 0 &&
(!showTalkTab || state.view === `article`)
">
<w-separator class="my-6" />
<div class="flex flex-wrap">
<div class="min-w-0 flex-1 text-left" v-if="relationsLeft.length > 0">
@ -194,6 +226,12 @@
</div>
</div>
</div>
<!--
Every provider that is not this wiki's own: at the bottom of the article, which is where
a site that uses one of them puts its comments and where a reader who uses that provider
elsewhere expects to find them.
-->
<page-comments-embed v-if="showCommentsEmbed" />
</div>
<!--
Inside the scrolling column, and last: this is the bottom of the PAGE, so it is reached by
@ -382,9 +420,21 @@ import PageRedirect from '@/components/PageRedirect.vue'
import PageTags from '@/components/PageTags.vue'
import PageToc from '@/components/PageToc.vue'
import PageUnlockDialog from '@/components/PageUnlockDialog.vue'
import PageViewTabs from '@/components/PageViewTabs.vue'
import SideDialog from '@/components/SideDialog.vue'
import SiteBanner from '@/components/SiteBanner.vue'
/*
Neither of these is wanted by a page view that has no comments, and the talk view brings a markdown
renderer with it -- so they are fetched when a site actually uses a provider rather than shipped in
the chunk every reader downloads to read a page.
*/
const PageTalk = defineAsyncComponent({
loader: () => import('@/components/PageTalk.vue'),
loadingComponent: LoadingGeneric
})
const PageCommentsEmbed = defineAsyncComponent(() => import('@/components/PageCommentsEmbed.vue'))
const editorComponents = {
markdown: defineAsyncComponent({
loader: () => import('../components/EditorMarkdown.vue'),
@ -455,7 +505,16 @@ const state = reactive({
* panel over the article rather than a column beside it.
*/
tocPanelOpen: false,
currentRating: 3
currentRating: 3,
/**
* Which of the two views the reader is on, `article` or `talk`.
*
* Local to the view rather than in the store, and re-read from the URL on every page change:
* arriving at a page means arriving at what it says, and a reader who went to read one discussion
* has not asked to land on the discussion of the next page they open -- unless the link they
* followed said so, which is what `#talk` is (see `viewFromHash`).
*/
view: viewFromHash()
})
const pageContents = ref(null)
/** The article column, which is what scrolls -- see `scrollPageToTop`. */
@ -552,6 +611,42 @@ const canCreatePage = computed(
() => userStore.pagePermissions.includes('write:pages') && siteStore.editors.markdown
)
/*
Whether this page has a talk page to switch to.
Four things, and all four have to hold. The site must be using the wiki's OWN comments provider --
every other one is a widget under the article, not a second view of the page. The page must take
comments at all, which is the switch in its properties dialog. The reader must hold `read:comments`
HERE, from the page rules rather than from the group-wide list, since that is what the endpoint
behind the tab will check. And the page has to exist: an empty path has nothing to discuss.
With no tab, the article is simply the view, which is why everything below tests
`!showTalkTab || state.view === 'article'` rather than the view alone.
*/
const showTalkTab = computed(
() =>
siteStore.comments.isBuiltIn &&
pageStore.allowComments &&
!pageStore.notFound &&
!editorStore.isActive &&
userStore.pagePermissions.includes('read:comments')
)
/*
The other providers, at the bottom of the article. No permission check: what a third-party widget
shows and to whom is that provider's own business, and this wiki's page rules say nothing about an
account somewhere else. The page's own switch still applies -- an author who turned comments off
meant it whichever provider is in use.
*/
const showCommentsEmbed = computed(
() =>
Boolean(siteStore.comments.provider) &&
!siteStore.comments.isBuiltIn &&
pageStore.allowComments &&
!pageStore.notFound &&
!editorStore.isActive
)
const relationsLeft = computed(() => {
return pageStore.relations ? pageStore.relations.filter((r) => r.position === 'left') : []
})
@ -604,6 +699,19 @@ watch(
{ immediate: true }
)
/*
Back to whatever the URL asks for on every page change, which is the article unless the link named
the discussion. Reading a discussion is something a reader asked for on ONE page; carrying the view
over by itself would mean that following a link out of a talk page lands on the next page's talk
page rather than on the page itself.
*/
watch(
() => pageStore.id,
() => {
state.view = viewFromHash()
}
)
/*
A protected page asks for its password the moment it arrives: the reader followed a link to read it,
and making them press a button first would only add a step. Keyed on the page rather than on the
@ -640,9 +748,28 @@ onBeforeUnmount(() => {
})
function onHashChange() {
// -> A fragment can ask for the discussion as well as for a heading, and one that asks for a
// heading while the discussion is open has to put the article back or there is nothing to
// scroll to: `v-show` leaves the hidden column with no layout, so the anchor is unreachable
state.view = viewFromHash()
scrollToAnchorWhenReady(window.location.hash)
}
/**
* The view the current URL asks for.
*
* `#talk` opens the discussion instead of the article -- what a link to a comment, or to the talk
* page of an article, has to be able to say. Read from `window.location` rather than from the route,
* so that it answers the same before the router has resolved anything and when the fragment is
* changed from outside the app.
*
* A page with no discussion to show simply stays on the article: `showTalkTab` gates what is drawn,
* so a fragment naming a view this reader does not have is ignored rather than blanking the column.
*/
function viewFromHash() {
return window.location.hash === '#talk' ? 'talk' : 'article'
}
watch(
() => route.path,
async (newValue, oldValue) => {

@ -49,6 +49,30 @@
</w-item-section>
</w-item>
<w-separator inset spaced="sm" />
<!--
The handle, which is NOT gated on `canEdit` like the name and the fields under it. No identity
provider owns a wiki mention handle, so a wiki that keeps its names in step with a directory has
no reason to stop anybody choosing one -- and with profile editing off this would otherwise be
the one field nobody could ever fill in.
-->
<w-item>
<blueprint-icon icon="rename" />
<w-item-section>
<w-item-label>{{ t(`profile.handle`) }}</w-item-label>
<w-item-label caption>{{ t(`profile.handleHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
v-model="state.config.handle"
outlined
dense
hide-bottom-space
prefix="@"
maxlength="32"
:aria-label="t(`profile.handle`)" />
</w-item-section>
</w-item>
<w-separator inset spaced="sm" />
<w-item>
<blueprint-icon icon="address" />
<w-item-section>
@ -214,6 +238,8 @@ import { useI18n } from 'vue-i18n'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { loading } from '@/composables/loading'
import { computed, onMounted, reactive } from 'vue'
@ -241,6 +267,7 @@ const state = reactive({
config: {
name: '',
email: '',
handle: '',
location: '',
jobTitle: '',
pronouns: '',
@ -305,6 +332,7 @@ async function fetchProfile() {
function applyProfile(profile) {
state.config.name = profile.name || ''
state.config.email = profile.email || ''
state.config.handle = profile.handle || ''
state.config.location = profile.location || ''
state.config.jobTitle = profile.jobTitle || ''
state.config.pronouns = profile.pronouns || ''
@ -337,6 +365,7 @@ async function save() {
pronouns: state.config.pronouns
}
: {}),
handle: state.config.handle.trim(),
timezone: state.config.timezone,
dateFormat: state.config.dateFormat,
timeFormat: state.config.timeFormat,
@ -368,7 +397,9 @@ async function save() {
notify({
type: 'negative',
message: t('profile.saveFailed'),
caption: err.message
// -> The server's own message, not ky's: a handle somebody else already has comes back as a
// sentence the person can act on, and `err.message` would replace it with "Request failed"
caption: apiErrorMessage(err)
})
}
loading.hide()

@ -0,0 +1,144 @@
import MarkdownIt from 'markdown-it'
/**
* The markdown a comment may be written in, and the whole of it.
*
* Deliberately not the page renderer. A page is written by somebody who was granted `write:pages` and
* goes through a pipeline of a dozen plugins, a syntax highlighter and a sanitizer; a comment is two
* paragraphs typed into a box by whoever may `write:comments`, which on a public wiki is anybody at
* all. So this is a second, much smaller renderer with a different question behind it -- what is the
* least that still reads as prose.
*
* **`html: false` is the security boundary**, not an afterthought. With raw HTML disabled markdown-it
* escapes every `<` it is given, so there is no markup in the output that this file did not put
* there and there is nothing for a sanitizer to do afterwards. That is also why the source is what
* gets stored: no HTML is ever written to the database, so nothing can be served that was sanitized
* by an older set of rules than the ones in force today.
*
* What is left out is as deliberate as what is in: no headings (a comment is not a document), no
* images (a comment box is not an upload form, and a remote image in one is a tracking pixel), no
* tables, no footnotes, no HTML. Links are rendered but every one of them leaves with
* `rel="nofollow ugc noopener"` and opens in a new tab.
*/
const md = new MarkdownIt('zero', {
html: false,
linkify: true,
breaks: true,
typographer: false
})
.enable([
'blockquote',
'code',
'emphasis',
'entity',
'escape',
'fence',
'linkify',
'list',
'newline',
'backticks',
'link',
'strikethrough'
])
// -> A comment is prose, and a rule across it is furniture; a heading in one would outrank the
// page's own headings in the outline of the view it sits in
.disable([
'heading',
'lheading',
'hr',
'image',
'table',
'reference',
'html_block',
'html_inline'
])
/**
* Every link a comment carries, whoever wrote it.
*
* `nofollow ugc` because a comment box on a public wiki is a link farm otherwise -- that is what the
* two attributes exist to say -- and `noopener` because the tab is opened by the wiki and must not
* hand the opened page a handle back to it.
*/
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx]
token.attrSet('rel', 'nofollow ugc noopener')
token.attrSet('target', '_blank')
return self.renderToken(tokens, idx, options)
}
/**
* A mention as it is written in a comment: `@handle`.
*
* The same pattern the server matches with (`models/comments.ts`), including the lookbehind that
* keeps an email address and a path from being read as one -- `a@b.com` and `docs/@handle` mention
* nobody.
*/
const MENTION_PATTERN = /(?<![\w@/])@([A-Za-z0-9_-]{3,32})/g
/** What a character becomes in the HTML this file writes around the markdown it rendered. */
const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
function htmlEscape(value) {
return `${value}`.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char])
}
/**
* Turn the mentions in rendered HTML into links to the people they name.
*
* Run over the OUTPUT rather than the source, and only over its text: `@handle` inside a code span or
* a fenced block is a piece of code somebody is quoting, not somebody being addressed, and rewriting
* it would corrupt what they were quoting. So the scan skips anything between `<` and `>` (an
* attribute could hold an `@`, in a `mailto:` link most obviously) and anything inside a `<code>`.
*
* A handle nobody holds is left as the text that was typed. A mention that linked to whoever happened
* to take the handle later would be worse than no link at all.
*
* @param {string} html Rendered markdown
* @param {Map<string, {id: string, name: string, handle: string}>} targets Handles, folded to lower
* case, that resolved to somebody
*/
function linkMentions(html, targets) {
if (targets.size < 1) {
return html
}
let out = ''
let index = 0
// -> One pass, splitting on the two things that must not be rewritten: tags, and code elements
// with everything between them
const skip = /<code[\s>][\s\S]*?<\/code>|<[^>]*>/gi
let match
while ((match = skip.exec(html)) !== null) {
out += replaceMentions(html.slice(index, match.index), targets)
out += match[0]
index = match.index + match[0].length
}
return out + replaceMentions(html.slice(index), targets)
}
function replaceMentions(text, targets) {
return text.replace(MENTION_PATTERN, (written, handle) => {
const target = targets.get(handle.toLowerCase())
if (!target) {
return written
}
return `<a class="comment-mention" href="/_user/${target.id}" title="${htmlEscape(target.name)}">@${htmlEscape(target.handle)}</a>`
})
}
/**
* Render one comment.
*
* @param {string} source Markdown as it was typed
* @param {Array<{id: string, name: string, handle: string}>} mentions Handles that resolved to
* somebody, as the comments endpoint answered with them for this page. Absent, mentions are drawn
* as the plain text they were written as -- which is what the composer's preview does, since it has
* not asked the server about anything yet.
* @returns {string} HTML, safe to hand to `v-html`: nothing in it came from the source unescaped
*/
export function renderComment(source, mentions = []) {
const targets = new Map(mentions.map((m) => [m.handle.toLowerCase(), m]))
return linkMentions(md.render(source ?? ''), targets)
}
export default renderComment

@ -78,6 +78,7 @@ const routes = [
{ path: ':siteid/approvals', component: () => import('@/pages/AdminApprovals.vue') },
{ path: ':siteid/analytics/:id?', component: () => import('@/pages/AdminAnalytics.vue') },
{ path: ':siteid/blocks', component: () => import('@/pages/AdminBlocks.vue') },
{ path: ':siteid/comments/:id?', component: () => import('@/pages/AdminComments.vue') },
{ path: ':siteid/editors', component: () => import('@/pages/AdminEditors.vue') },
{ path: ':siteid/locale', component: () => import('@/pages/AdminLocale.vue') },
{ path: ':siteid/login', component: () => import('@/pages/AdminLogin.vue') },

@ -96,6 +96,22 @@ export const useSiteStore = defineStore('site', {
reasonForChange: 'required',
search: false
},
/**
* How this site handles comments, as the server describes it -- `models/comments.ts`.
*
* `provider` empty is a site with comments turned off, which is also what a provider that is
* selected but not finished being configured looks like from here. `isBuiltIn` decides which of
* the two things the page view draws: the Talk tab beside the article, or the third-party markup
* in `code` mounted under it. Nothing of the stored configuration reaches this -- the built-in
* provider's holds an Akismet key, and the server never serializes it.
*/
comments: {
provider: '',
isBuiltIn: false,
code: { head: '', main: '', body: '' },
cooldownSeconds: 0,
maxLength: 8000
},
/**
* What this site does with uploads. Set in the admin area's General section; only the parts the
* app itself acts on are carried here, which is where a pasted file goes -- the conflict behavior
@ -357,6 +373,10 @@ export const useSiteStore = defineStore('site', {
...this.features,
...siteInfo.features
},
comments: {
...this.comments,
...siteInfo.comments
},
auth: {
...this.auth,
...siteInfo.auth

Loading…
Cancel
Save