@ -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;
|
||||
@ -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()
|
||||
@ -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
|
||||
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 562 B |
|
After Width: | Height: | Size: 633 B |
|
After Width: | Height: | Size: 434 B |
|
After Width: | Height: | Size: 697 B |
|
After Width: | Height: | Size: 558 B |
|
After Width: | Height: | Size: 403 B |
|
After Width: | Height: | Size: 381 B |
@ -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">
|
||||
· {{ 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>
|
||||
@ -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>
|
||||
@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
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
|
||||