feat: view page backlinks

pull/8104/head
NGPixel 2 weeks ago
parent bf889ecfb2
commit 0385c375c9
No known key found for this signature in database

@ -1614,6 +1614,198 @@ async function routes(app: FastifyInstance) {
} }
) )
/**
* LIST BACKLINKS
*
* What links to this page.
*
* Asked by the page's ADDRESS rather than by its id, which is the whole reason the table stores an
* address: a link says where it points, so the pages pointing at one that has moved still point at
* where it was. The two short links are folded in by id and by alias, since those two survive a move
* by design and belong in the same list.
*/
app.get<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/backlinks',
{
/*
No route-level `permissions`: reading a page's backlinks is `read:pages` ON THAT PAGE, and on
every page in the answer — see below.
*/
schema: {
summary: 'List the pages linking to a page',
description:
"Every page of this instance whose content, redirection target or sidebar relations point at this one, with the href each of them wrote.\n\nAnswers 404 where the site has `features.backlinks` off, which is the switch under General → Features. A page that has turned its own Links tab off with `allowBacklinks` still answers here — that flag hides the tab, and what it hides is not a secret.\n\nFiltered by what the caller may read, one page at a time: a backlink names a page, its title and where it sits, so listing one the caller has no `read:pages` rule for would hand them the existence of a page they cannot open. A link written on another SITE of this instance is included, since a move here breaks it just the same.\n\nNote that links pointing at a page's OLD path are not listed here — after a move they address a path this page no longer has, which is what makes them findable as the links that move broke.",
tags: ['Pages'],
params: pageIdParam,
response: {
200: {
description: 'Pages linking here',
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
siteId: { type: 'string', format: 'uuid' },
locale: { type: 'string' },
path: { type: 'string' },
title: { type: 'string' },
description: { type: 'string' },
icon: {
type: 'string',
description:
"The page's own icon as an Iconify reference, or empty where it has none."
},
url: {
type: 'string',
description: 'Where that page is, as a path on its own site.'
},
hostname: {
type: 'string',
nullable: true,
description:
'The host its site answers on, for a page on another site of this instance. Null when it is on the site being read, or on the catch-all site.'
},
href: {
type: 'string',
description: 'The link as that page writes it, which is what a repair would edit.'
},
kind: {
type: 'string',
description: 'How the link addresses this page: by path, by alias or by id.'
}
}
}
}
}
}
},
async (req, reply) => {
// -> The site-wide switch, as `requireBuiltInPage` checks the comments one: with the feature
// off there is nothing here to answer, however the page itself is set
if (!WIKI.models.pageLinks.isAllowed(req.params.siteId)) {
return reply.notFound('This site does not show what links to a page.')
}
const page = await loadReadablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')
}
// -> As the history route does: a page withheld until its password is entered withholds what is
// derived from it too, and its link graph names it in one direction and reads its content in
// the other
if (page.isLocked) {
return reply.forbidden('This page is password protected.')
}
const backlinks = await WIKI.models.pageLinks.backlinksFor(
{ siteId: req.params.siteId, locale: page.locale, path: page.path },
{ pageId: page.id, alias: page.alias }
)
return backlinks
.filter((link) =>
mayOnPage(req, 'read:pages', {
siteId: link.siteId,
locale: link.locale,
path: link.path,
tags: link.tags
})
)
.map((link) => ({
id: link.pageId,
siteId: link.siteId,
locale: link.locale,
path: link.path,
title: link.title,
description: link.description ?? '',
icon: link.icon ?? '',
url: link.url,
hostname: link.hostname,
href: link.href,
kind: link.kind
}))
}
)
/**
* LIST OUTBOUND LINKS
*
* What this page links to, and whether anything is there.
*
* The red-link question. Resolution is a join rather than a stored flag, so a page created at a path
* somebody had already linked to makes that link good without anything having to notice.
*/
app.get<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/links',
{
// -> No route-level `permissions`: `read:pages` on the page itself, as above
schema: {
summary: 'List the links written on a page',
description:
"Every link this page's content, redirection target or sidebar relations point at, each with the href as written and what it resolves to. `exists` is false for a link addressing a page that is not there — the state a wiki draws as a red link.\n\nLinks leaving the wiki are not recorded and are not listed. Uploaded files are listed but never resolved: they are tracked so a moved file can be traced back to what pointed at it, and answering whether one exists is a different lookup.",
tags: ['Pages'],
params: pageIdParam,
response: {
200: {
description: 'Links written on this page',
type: 'array',
items: {
type: 'object',
properties: {
href: { type: 'string' },
kind: { type: 'string' },
targetSiteId: { type: 'string', format: 'uuid' },
targetLocale: { type: 'string', nullable: true },
targetPath: { type: 'string', nullable: true },
targetId: { type: 'string', format: 'uuid', nullable: true },
targetTitle: { type: 'string', nullable: true },
exists: { type: 'boolean' }
}
}
}
}
}
},
async (req, reply) => {
const page = await loadReadablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')
}
// -> These come out of the page's own body, which is the half a password withholds
if (page.isLocked) {
return reply.forbidden('This page is password protected.')
}
const links = await WIKI.models.pageLinks.outboundFor(page.id)
return links.map((link) => ({
href: link.href,
kind: link.kind,
targetSiteId: link.targetSiteId,
targetLocale: link.targetLocale,
targetPath: link.targetPath,
targetId: link.targetPageId,
/*
Only what the caller may read, for the reason the backlinks route filters: whether a page
exists at a path is something a rule decides they may know. A link to a page they may not
read reads as a link to nothing, which is also what clicking it would give them.
Judged on where the RESOLVED page sits rather than on what the link said about it — an alias
or an id link names no address at all — and with its tags, since a rule may be written
against those.
*/
...(link.targetPageId &&
!mayOnPage(req, 'read:pages', {
siteId: link.targetSiteId,
locale: link.targetPageLocale ?? undefined,
path: link.targetPagePath ?? '',
tags: link.targetPageTags ?? []
})
? { targetTitle: null, exists: false }
: { targetTitle: link.targetTitle, exists: Boolean(link.targetPageId) })
}))
}
)
/** /**
* PAGE USER PERMISSIONS * PAGE USER PERMISSIONS
*/ */

@ -119,6 +119,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'string' type: 'string'
} }
}, },
allowBacklinks: { type: 'boolean' },
allowComments: { type: 'boolean' }, allowComments: { type: 'boolean' },
allowContributions: { type: 'boolean' }, allowContributions: { type: 'boolean' },
allowRatings: { type: 'boolean' }, allowRatings: { type: 'boolean' },
@ -217,6 +218,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
description: description:
'Only present when the request asked for it — except on a redirection, whose content is where it sends its reader rather than a body, and comes back either way.' 'Only present when the request asked for it — except on a redirection, whose content is where it sends its reader rather than a body, and comes back either way.'
}, },
allowBacklinks: { type: 'boolean' },
allowComments: { type: 'boolean' }, allowComments: { type: 'boolean' },
allowContributions: { type: 'boolean' }, allowContributions: { type: 'boolean' },
allowRatings: { type: 'boolean' }, allowRatings: { type: 'boolean' },

@ -80,6 +80,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
features: { features: {
type: 'object', type: 'object',
properties: { properties: {
backlinks: {
type: 'boolean',
description:
'Whether a page shows what links to it. Off hides the Links tab across the site and closes the route behind it; a page can still opt out on its own with `allowBacklinks`. Nothing about how links are recorded changes either way — the table is written by every save regardless, so turning this back on shows a list rather than an empty tab.'
},
browse: { browse: {
type: 'boolean' type: 'boolean'
}, },

@ -1439,6 +1439,64 @@ async function routes(app: FastifyInstance) {
} }
) )
/**
* REBUILD PAGE LINKS
*
* What every page points at, worked out again from the render each one already stores.
*
* Not a repair anybody should need after an ordinary edit: a page's links are rewritten every time
* its render is. It is for the two cases where there was nothing to derive them from at the time —
* content that predates the table, and a site whose locale prefixes or page extensions changed,
* which changes what a link already written in a page addresses without touching the page.
*/
app.post(
'/page-links/rebuild',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Rebuild the page link index',
description:
'Queues a job that re-reads the stored render of every page on every site and records what each one links to. Runs in a worker thread because it touches all the content in the instance, so the response only says the job was queued.',
tags: ['System'],
response: {
200: {
description: 'Rebuild queued successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid',
description: 'ID of the queued job, which the scheduler view lists.'
}
}
}
}
}
},
async (req, reply) => {
const added = await WIKI.scheduler.addJob({ task: 'rebuildPageLinks' })
if (!added?.id) {
return reply.internalServerError('The scheduler could not queue the rebuild.')
}
await audit(req, 'admin', 'rebuildPageLinks', { jobId: added.id })
return {
ok: true,
message: 'Page link rebuild queued successfully.',
id: added.id
}
}
)
/** /**
* CHECK FOR UPDATE * CHECK FOR UPDATE
*/ */

@ -0,0 +1,22 @@
CREATE TABLE "pageLinks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"kind" varchar(16) NOT NULL,
"href" varchar(2048) NOT NULL,
"targetSiteId" uuid NOT NULL,
"targetLocale" varchar(255),
"targetPath" varchar(255),
"targetRef" varchar(255),
"createdAt" timestamp DEFAULT now() NOT NULL,
"pageId" uuid NOT NULL,
"siteId" uuid NOT NULL
);
--> statement-breakpoint
CREATE INDEX "pageLinks_target_idx" ON "pageLinks" ("targetSiteId","targetLocale","targetPath");--> statement-breakpoint
CREATE INDEX "pageLinks_targetRef_idx" ON "pageLinks" ("targetSiteId","kind","targetRef");--> statement-breakpoint
CREATE INDEX "pageLinks_pageId_idx" ON "pageLinks" ("pageId");--> statement-breakpoint
CREATE INDEX "pageLinks_siteId_idx" ON "pageLinks" ("siteId");--> statement-breakpoint
CREATE UNIQUE INDEX "pageLinks_pageId_href_idx" ON "pageLinks" ("pageId","href");--> statement-breakpoint
CREATE UNIQUE INDEX "pages_siteId_locale_path_idx" ON "pages" ("siteId","locale","path");--> statement-breakpoint
ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_targetSiteId_sites_id_fkey" FOREIGN KEY ("targetSiteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");

File diff suppressed because it is too large Load Diff

@ -556,7 +556,15 @@ export const pages = pgTable(
index('pages_isSearchableComputed_idx').on(table.isSearchableComputed), index('pages_isSearchableComputed_idx').on(table.isSearchableComputed),
// -> One page per locale in a group, enforced here rather than in the model: a group is edited // -> One page per locale in a group, enforced here rather than in the model: a group is edited
// from any of its members, so two saves racing each other are two writers of the same set // from any of its members, so two saves racing each other are two writers of the same set
uniqueIndex('pages_localeGroupId_locale_idx').on(table.localeGroupId, table.locale) uniqueIndex('pages_localeGroupId_locale_idx').on(table.localeGroupId, table.locale),
/*
Where a page sits, which is what a path addresses it by. Unique because two pages at one path in
one locale is the thing `createPage` and `movePage` both check for and neither can actually
prevent -- their check and their write are two statements, so two saves racing each other both
see a clear path. It is also the index the link table resolves against: "is there a page at this
address" is asked once per link on a page.
*/
uniqueIndex('pages_siteId_locale_path_idx').on(table.siteId, table.locale, table.path)
] ]
) )
@ -634,6 +642,95 @@ export const pageHistory = pgTable(
] ]
) )
// PAGE LINKS --------------------------
/**
* One row per distinct link written on a page, resolved to what it addresses.
*
* Derived from the stored render the way `toc` and `searchContent` are, and rewritten wholesale
* whenever that render is — see `models/pageLinks.ts`. Three questions are asked of it: what links to
* the page being read, whether what a page links to exists, and which pages have to be revisited when
* something they point at moves.
*
* **The address is what is stored, not a resolved page id.** A link is written as a path, and after a
* move the pages pointing at the old one still say the old one — which is precisely the thing worth
* knowing, and precisely what a foreign key would erase by following the page. It also lets a link to
* a page that does not exist yet be a row like any other, which is what a red link is. Whether a
* target resolves is a join against `pages` on `(siteId, locale, path)` at read time.
*
* `/a/<alias>` and `/i/<id>` are the exception and are stored as the reference they carry: those two
* survive a move by design, so resolving them to a path at write time would record the opposite of
* what they mean.
*/
export const pageLinks = pgTable(
'pageLinks',
{
id: uuid().primaryKey().defaultRandom(),
/**
* What the link addresses: a page by path (`page`), by alias (`alias`) or by id (`pageId`), or an
* uploaded file (`asset`).
*
* A varchar rather than an enum, for the reason `pageHistory.action` is one — naming another kind
* of target later should not need a migration. Links leaving the wiki are not recorded at all:
* nothing asks a question about them until there is a link checker to answer it, and they would
* be the bulk of the rows on a wiki that cites its sources.
*/
kind: varchar({ length: 16 }).notNull(),
/**
* The href exactly as it appears in the page.
*
* Kept because resolving is lossy and the source is what a repair would have to edit: `../two`,
* `/en/one/two` and `/one/two.md` are one target and three strings, and only the string that was
* written can be found in the markdown again.
*
* Bounded rather than `text` because it is half of a unique btree index below, and a btree entry
* has a hard ceiling of about 2700 bytes — an href past it would fail the INSERT, and that INSERT
* is part of saving a page. 2048 is the conventional cap for a URL and far past anything a link
* in a wiki page is; `resolveLink` drops the ones that would not fit rather than truncating them
* into a different address.
*/
href: varchar({ length: 2048 }).notNull(),
/**
* Which site the target belongs to. Usually the source's own, and another one for a link written
* as an absolute URL to a second site of this instance — those are worth following rather than
* writing off as external, since a move on either site breaks them just the same.
*/
targetSiteId: uuid()
.notNull()
.references(() => sites.id),
// -> Both null for `alias` and `pageId`, which address a page without saying where it is
targetLocale: varchar({ length: 255 }),
targetPath: varchar({ length: 255 }),
/** The alias or the page id, for the two kinds that carry one. Null for the rest. */
targetRef: varchar({ length: 255 }),
createdAt: timestamp().notNull().defaultNow(),
// -> The page the link is written on. Its rows go with it: a link is part of a page's content,
// and nothing is left to point at once the page is gone
pageId: uuid()
.notNull()
.references(() => pages.id, { onDelete: 'cascade' }),
siteId: uuid()
.notNull()
.references(() => sites.id)
},
(table) => [
// -> "What links here", and "what points at this path" for a page about to be moved. Leading with
// the site because every one of those questions is asked within one
index('pageLinks_target_idx').on(table.targetSiteId, table.targetLocale, table.targetPath),
// -> The same question for the two kinds that address a page without a path
index('pageLinks_targetRef_idx').on(table.targetSiteId, table.kind, table.targetRef),
// -> Every link on one page, which is both the read for "do these targets exist" and the delete
// half of rewriting a page's links
index('pageLinks_pageId_idx').on(table.pageId),
index('pageLinks_siteId_idx').on(table.siteId),
/*
One row per spelling, not per target: a page linking to the same place as `../two` and as
`/one/two` has two links to repair and two rows saying so. Two identical hrefs on one page are
one row, since there is nothing to tell them apart and nothing that would ask.
*/
uniqueIndex('pageLinks_pageId_href_idx').on(table.pageId, table.href)
]
)
// PAGE EDIT SUBMISSIONS --------------- // PAGE EDIT SUBMISSIONS ---------------
/** /**
* An edit suggested by somebody who may read a page but not change it, waiting to be reviewed. * An edit suggested by somebody who may read a page but not change it, waiting to be reviewed.

@ -0,0 +1,272 @@
import * as cheerio from 'cheerio'
import { isPageUrl, normalizePagePath, splitLocalePath, stripPageExtension } from './common.ts'
/**
* Reading a link the way the reader's browser will.
*
* A page stores the HTML its editor produced, and that HTML carries the href the author wrote:
* `fileSrc` rewrites image sources and deliberately leaves links alone, because a relative link means
* exactly what it says. So `../two`, `/en/one/two`, `/one/two.md` and
* `https://wiki.example.com/en/one/two` can all be the same page, and something has to say so before
* any question about links can be answered.
*
* That makes this the THIRD copy of the rules for reading a page URL, after `helpers/common.ts` —
* which it composes rather than reimplements — and `frontend/src/helpers/pagePaths.js`. It has to
* agree with both: a link this reads differently from the router is a backlink pointing somewhere the
* reader does not land.
*/
/**
* Every href in a render, exactly as written and with repeats left in.
*
* A page's links are read from its stored HTML rather than from its source, because there is no
* markdown parser on this side at all: the render is produced in the editor's browser and sent up,
* and it is the one settled form of a page the server holds. It is also what makes rebuilding a
* wiki's links cost a parse per page instead of a headless browser per page.
*
* @param $ A document already loaded, for the caller that has one — `postProcess` is holding the very
* tree this would otherwise re-parse.
*/
export function hrefsFrom($: cheerio.CheerioAPI): string[] {
const hrefs: string[] = []
for (const el of $('a[href]')) {
const href = $(el).attr('href')
if (href) {
hrefs.push(href)
}
}
return hrefs
}
/** The same, for a stored render with nothing loaded — which is every page but the one being saved. */
export function linksFromRender(html?: string | null): string[] {
if (!html) {
return []
}
return hrefsFrom(cheerio.load(html, null, false))
}
/** What a link addresses. See the `kind` column. */
export type PageLinkKind = 'page' | 'alias' | 'pageId' | 'asset'
export interface ResolvedLink {
kind: PageLinkKind
/** The href as written, which is what a repair has to find in the source again. */
href: string
/** Which site the target is on — the source's own, unless the href named another one. */
targetSiteId: string
/** Where the target sits, for the two kinds that say. Null for `alias` and `pageId`. */
targetLocale: string | null
targetPath: string | null
/** The alias or the page id, for the two kinds that carry one. */
targetRef: string | null
}
/** The page a link is written on, which is what a relative href resolves against. */
export interface LinkSource {
siteId: string
locale: string
path: string
}
/**
* The origin every href is resolved against.
*
* Nothing here cares what the host is, only whether two of them are the same, and a render is
* processed outside any request — so there is no real origin to reach for. A placeholder that cannot
* collide with a hostname anybody has configured is what makes `new URL()` usable on a relative href,
* and comparing what comes out against this is how "did this link stay on this site" is asked.
*/
const LOCAL_ORIGIN = 'https://page-links.invalid'
/** Where uploaded files are served from, and the one non-page path worth recording. */
const FILES_PREFIX = '/_files/'
/** The short links to a page that survive a move, and the segment each is addressed by. */
const ALIAS_PREFIX = '/a/'
const PAGE_ID_PREFIX = '/i/'
/**
* The longest href that gets a row, matching the column it is stored in.
*
* Not a judgement about what a link may be — it is what a unique btree index can hold, and the insert
* is part of saving a page. Past it the link is not recorded, which costs a row in a derived table
* and never the save.
*/
const MAX_HREF_LENGTH = 2048
/**
* Which site an href ended up on, or null when it left the instance.
*
* A link written as an absolute URL to another site of this wiki is followed rather than written off:
* `WIKI.sitesMappings` is the same lookup the request hooks do, so a second site's hostname resolves
* to that site and a move there breaks the link just as visibly.
*
* **The catch-all mapping is deliberately not consulted.** `*` answers for every hostname that
* reaches this server, so honouring it here would make every external link a page link — every URL a
* page cites would resolve to a path on the site that happens to be bound to `*`. The cost is the
* other half of that: on a site with no hostname of its own, a link somebody wrote by pasting the
* address out of their browser is not recognised as internal, because there is nothing configured to
* recognise it against. Links written as paths — which is every link the link picker produces — never
* reach this at all, and are unaffected.
*/
function siteForHost(hostname: string, sourceSiteId: string): string | null {
if (hostname === new URL(LOCAL_ORIGIN).hostname) {
return sourceSiteId
}
return WIKI.sitesMappings?.[hostname] ?? null
}
/**
* Read one href as the address of something in this wiki, or null when it is not one.
*
* Null covers rather a lot, and all of it on purpose: an empty href, a bare fragment, a `mailto:` or
* `tel:`, a link to another server, and every path the wiki serves for itself — `/_admin`, `/login`,
* `/_api` — none of which is content this can be asked a question about. Links leaving the wiki are
* not recorded at all; see the `kind` column.
*
* @param source The page the link is written on. A relative href resolves against the URL that page
* is served at, locale prefix and all, which is what the browser following it does.
*/
export function resolveLink(href: string, source: LinkSource): ResolvedLink | null {
const raw = (href ?? '').trim()
if (raw.length < 1 || raw.length > MAX_HREF_LENGTH || raw.startsWith('#')) {
return null
}
/*
A scheme that is not http(s) is not a page under any reading -- `mailto:`, `tel:`, `data:`, and
the `javascript:` a sanitised render should not be carrying anyway. Tested before `new URL()`
rather than after, since those parse perfectly well and would otherwise have to be excluded by
protocol afterwards.
*/
if (/^[a-z][a-z\d+.-]*:/i.test(raw) && !/^https?:/i.test(raw)) {
return null
}
let url: URL
try {
// -> The page's own address as the base, so `../two` from `one/deep/three` lands where a reader
// clicking it lands. `urlFor` is what puts the site's locale prefix on it, if it uses one
url = new URL(
raw,
`${LOCAL_ORIGIN}${WIKI.models.pages.urlFor(source.siteId, source.locale, source.path)}`
)
} catch {
return null
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null
}
const targetSiteId = siteForHost(url.hostname, source.siteId)
if (!targetSiteId) {
return null
}
// -> The query and the fragment address a part of the target, never a different one, so what is
// left is the whole of where the link goes
const urlPath = url.pathname
if (urlPath.startsWith(ALIAS_PREFIX)) {
const alias = decodeURIComponent(urlPath.slice(ALIAS_PREFIX.length)).replace(/\/+$/, '')
return alias.length > 0
? {
kind: 'alias',
href: raw,
targetSiteId,
targetLocale: null,
targetPath: null,
targetRef: alias
}
: null
}
if (urlPath.startsWith(PAGE_ID_PREFIX)) {
const id = decodeURIComponent(urlPath.slice(PAGE_ID_PREFIX.length)).replace(/\/+$/, '')
return id.length > 0
? {
kind: 'pageId',
href: raw,
targetSiteId,
targetLocale: null,
targetPath: null,
targetRef: id
}
: null
}
if (urlPath.startsWith(FILES_PREFIX)) {
return resolveFile(raw, urlPath, targetSiteId)
}
if (!isPageUrl(urlPath)) {
return null
}
return resolvePage(raw, urlPath, targetSiteId)
}
/**
* A page address, as the SEO hook and the router between them read one.
*
* Same order as `index.ts`: the extension comes off first, since `/en/one/two.md` carries both, and
* the locale prefix second. A path arriving without a prefix is the site's primary locale, which is
* where the server redirects it and what the app loads for it.
*/
function resolvePage(href: string, urlPath: string, targetSiteId: string): ResolvedLink | null {
const site = WIKI.sites?.[targetSiteId]
const trimmed = urlPath.length > 1 && urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath
const withoutExtension = stripPageExtension(trimmed, site?.config?.pageExtensions) ?? trimmed
const locales = site?.config?.locales
const split = splitLocalePath(
withoutExtension,
WIKI.models.locales.urlPrefixesFor(locales?.active)
)
return {
kind: 'page',
href,
targetSiteId,
// -> A path with no prefix names the primary locale, which is where the SEO hook redirects it
// and what the app loads for it. Same fallback as `pages.defaultLocale`
targetLocale: split?.locale ?? locales?.primary ?? 'en',
// -> The site root is the page stored at the empty path, which is what `normalizePagePath` makes
// of `/` -- and of `/fr` once its prefix has come off above
targetPath: normalizePagePath(split?.path ?? withoutExtension),
targetRef: null
}
}
/**
* An uploaded file, addressed the way `controllers/files.ts` serves one.
*
* `/_files/<folders…>/<name.ext>`, and no locale segment: a file URL names a path and nothing else,
* and `getAssetByPath` picks the site's primary locale among the translations filed under it. So
* unlike a page link there is no locale to record — the path IS the address.
*
* Lowercased whole, which is how the lookup reads it: the tree stores a file name in lower case and
* `resolveAssetPath` keys its cache on the path in lower case, so two spellings of one file must not
* become two targets here.
*
* Recorded for the same reason a page link is: a file moves when the folder holding it is renamed,
* and what pointed at it is the thing worth knowing.
*/
function resolveFile(href: string, urlPath: string, targetSiteId: string): ResolvedLink | null {
const rest = decodeURIComponent(urlPath.slice(FILES_PREFIX.length))
.split('/')
.filter(Boolean)
.join('/')
.toLowerCase()
if (rest.length < 1) {
return null
}
return {
kind: 'asset',
href,
targetSiteId,
targetLocale: null,
targetPath: rest,
targetRef: null
}
}

@ -180,6 +180,7 @@
"admin.audit.actions.purgeEmptyFolders": "Deleted the empty folders", "admin.audit.actions.purgeEmptyFolders": "Deleted the empty folders",
"admin.audit.actions.purgePageHistory": "Purged page history", "admin.audit.actions.purgePageHistory": "Purged page history",
"admin.audit.actions.purgeSampleContent": "Purged the sample content", "admin.audit.actions.purgeSampleContent": "Purged the sample content",
"admin.audit.actions.rebuildPageLinks": "Rebuilt the page link index",
"admin.audit.actions.rebuildSearchIndex": "Rebuilt the search index", "admin.audit.actions.rebuildSearchIndex": "Rebuilt the search index",
"admin.audit.actions.refreshIconSets": "Refreshed the icon sets", "admin.audit.actions.refreshIconSets": "Refreshed the icon sets",
"admin.audit.actions.regenerateCertificates": "Regenerated the API key certificates", "admin.audit.actions.regenerateCertificates": "Regenerated the API key certificates",
@ -464,6 +465,8 @@
"admin.flags.title": "Flags", "admin.flags.title": "Flags",
"admin.flags.warn.hint": "Doing so may result in data loss, performance issues or a broken installation!", "admin.flags.warn.hint": "Doing so may result in data loss, performance issues or a broken installation!",
"admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!", "admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!",
"admin.general.allowBacklinks": "Allow Backlinks",
"admin.general.allowBacklinksHint": "Give every page a Links tab listing the pages that point at it. A page can still hide its own from its properties, and links keep being recorded either way.",
"admin.general.allowBrowse": "Allow Browsing", "admin.general.allowBrowse": "Allow Browsing",
"admin.general.allowBrowseHint": "Can users browse using the tree structure of the site to pages they have access to?", "admin.general.allowBrowseHint": "Can users browse using the tree structure of the site to pages they have access to?",
"admin.general.allowCollaborativeEditing": "Allow Collaborative Editing", "admin.general.allowCollaborativeEditing": "Allow Collaborative Editing",
@ -1445,6 +1448,12 @@
"admin.utilities.purgeSampleFailed": "The sample content could not be deleted.", "admin.utilities.purgeSampleFailed": "The sample content could not be deleted.",
"admin.utilities.purgeSampleHint": "Delete every page on this site tagged \"test\", which is what Generate Sample Content tags the pages it writes.", "admin.utilities.purgeSampleHint": "Delete every page on this site tagged \"test\", which is what Generate Sample Content tags the pages it writes.",
"admin.utilities.purgeSampleSuccess": "There was no sample content to delete. | Deleted 1 page. | Deleted {count} pages.", "admin.utilities.purgeSampleSuccess": "There was no sample content to delete. | Deleted 1 page. | Deleted {count} pages.",
"admin.utilities.rebuildPageLinks": "Rebuild Page Links",
"admin.utilities.rebuildPageLinksConfirm": "Every page on every site will be read again to work out what it links to.",
"admin.utilities.rebuildPageLinksConfirmWarn": "Nothing about your content is changed — only the record of which page points at which. This normally keeps itself up to date, so it is worth running after importing content written elsewhere, or after changing a site's locale prefixes or page extensions.",
"admin.utilities.rebuildPageLinksFailed": "Failed to queue a page link rebuild.",
"admin.utilities.rebuildPageLinksHint": "Work out again what every page links to, from the content already stored. Runs in the background.",
"admin.utilities.rebuildPageLinksSuccess": "A page link rebuild has been initiated and will start shortly.",
"admin.utilities.scanPageProblems": "Scan for Page Problems", "admin.utilities.scanPageProblems": "Scan for Page Problems",
"admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.", "admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.",
"admin.utilities.subtitle": "Maintenance and miscellaneous tools", "admin.utilities.subtitle": "Maintenance and miscellaneous tools",
@ -1793,6 +1802,12 @@
"common.license.ccbynd": "Creative Commons Attribution-NoDerivs License", "common.license.ccbynd": "Creative Commons Attribution-NoDerivs License",
"common.license.ccbysa": "Creative Commons Attribution-ShareAlike License", "common.license.ccbysa": "Creative Commons Attribution-ShareAlike License",
"common.license.none": "None", "common.license.none": "None",
"common.links.count": "1 page links here | {count} pages link here",
"common.links.loadFailed": "Could not load the pages linking here.",
"common.links.loading": "Loading links…",
"common.links.none": "No page links here yet.",
"common.links.noneHint": "Pages that link to this one will be listed here.",
"common.links.title": "Pages linking here",
"common.loading": "Loading...", "common.loading": "Loading...",
"common.modernBrowser": "modern browser", "common.modernBrowser": "modern browser",
"common.newpage.create": "Create Page", "common.newpage.create": "Create Page",
@ -1833,6 +1848,7 @@
"common.page.suggestSubmitted": "Your suggested edits have been submitted.", "common.page.suggestSubmitted": "Your suggested edits have been submitted.",
"common.page.suggestSubmittedHint": "They are pending review. You will be able to keep editing them until a reviewer accepts or declines them.", "common.page.suggestSubmittedHint": "They are pending review. You will be able to keep editing them until a reviewer accepts or declines them.",
"common.page.suggestSubmittedHintGuest": "They are pending review by this site's editors.", "common.page.suggestSubmittedHintGuest": "They are pending review by this site's editors.",
"common.page.tabLinks": "Links",
"common.page.tags": "Tags", "common.page.tags": "Tags",
"common.page.tagsMatching": "Pages matching tags", "common.page.tagsMatching": "Pages matching tags",
"common.page.toc": "Table of Contents", "common.page.toc": "Table of Contents",
@ -2108,6 +2124,7 @@
"editor.pendingAssetsNotInSuggestions": "Images and files cannot be attached to a suggested edit.", "editor.pendingAssetsNotInSuggestions": "Images and files cannot be attached to a suggested edit.",
"editor.pendingAssetsUploading": "Uploading assets...", "editor.pendingAssetsUploading": "Uploading assets...",
"editor.props.alias": "Alias", "editor.props.alias": "Alias",
"editor.props.allowBacklinks": "Show Links Tab",
"editor.props.allowComments": "Allow Comments", "editor.props.allowComments": "Allow Comments",
"editor.props.allowCommentsHint": "Enable commenting abilities on this page.", "editor.props.allowCommentsHint": "Enable commenting abilities on this page.",
"editor.props.allowContributions": "Allow Contributions", "editor.props.allowContributions": "Allow Contributions",

@ -117,6 +117,7 @@ export const AUDIT_ACTIONS = {
'updateSecurity', 'updateSecurity',
'updateSearchConfig', 'updateSearchConfig',
'rebuildSearchIndex', 'rebuildSearchIndex',
'rebuildPageLinks',
'installExtension', 'installExtension',
'updateApiState', 'updateApiState',
'updateMetricsState', 'updateMetricsState',

@ -17,6 +17,7 @@ import { mail } from './mail.ts'
import { metrics } from './metrics.ts' import { metrics } from './metrics.ts'
import { navigation } from './navigation.ts' import { navigation } from './navigation.ts'
import { pageHistory } from './pageHistory.ts' import { pageHistory } from './pageHistory.ts'
import { pageLinks } from './pageLinks.ts'
import { pages } from './pages.ts' import { pages } from './pages.ts'
import { pageWatching } from './pageWatching.ts' import { pageWatching } from './pageWatching.ts'
import { passkeys } from './passkeys.ts' import { passkeys } from './passkeys.ts'
@ -52,6 +53,7 @@ export default {
metrics, metrics,
navigation, navigation,
pageHistory, pageHistory,
pageLinks,
pages, pages,
pageWatching, pageWatching,
passkeys, passkeys,

@ -0,0 +1,416 @@
import { and, eq, ne, or, sql } from 'drizzle-orm'
import { pageLinks as pageLinksTable, pages as pagesTable } from '../db/schema.ts'
import { linksFromRender, resolveLink } from '../helpers/pageLinks.ts'
import type { LinkSource, PageLinkKind, ResolvedLink } from '../helpers/pageLinks.ts'
/**
* Page links model
*
* What every page points at, kept in step with the page.
*
* The rows are derived, exactly as `toc` and `searchContent` are, and from the same thing: the stored
* render. There is no markdown parser on this side — a page's HTML is produced in the editor's
* browser and sent up — so the render is the only settled form of a page the server has, and
* `rendering.linksFromRender` is what reads the anchors out of it. Two link sources are not in the
* render at all and are collected by the pages model instead: a redirection's target lives in its
* content, and a page's relations are stored as their own column.
*
* Nothing here decides what a link MEANS — `helpers/pageLinks.ts` does that, and is where the rules
* for reading a page URL live. This model is the storage half: swap a page's rows, and answer the
* three questions the table exists for.
*/
/**
* The editor whose content IS a link.
*
* Spelled out here rather than imported from `models/pages.ts`, which pulls in the storage, search and
* tree models behind it — this model is one of the few a worker thread loads, and the rebuild utility
* runs in one. See `worker.ts` on what an import costs there.
*/
const REDIRECT_EDITOR = 'redirect'
/** The columns of a page that say what it links to. */
export interface PageLinkSource {
id: string
siteId: string
locale: string
path: string
editor: string
content: string | null
relations: unknown
render: string | null
}
/** A link on a page, with the page it was found on. */
export interface PageLinkRow {
id: string
pageId: string
kind: PageLinkKind
href: string
targetSiteId: string
targetLocale: string | null
targetPath: string | null
targetRef: string | null
}
/** One page linking to another, as a "what links here" list shows it. */
export interface Backlink {
pageId: string
siteId: string
locale: string
path: string
title: string
description: string | null
/** The page's own icon, as an Iconify reference. Empty where it has none. */
icon: string | null
/** How the link is written, since the same page may point at a target more than one way. */
href: string
kind: PageLinkKind
tags: string[]
publishState: string
/** Where that page is, as a path on ITS OWN site — locale prefix included only where that site
brackets its URLs by one. */
url: string
/**
* The host that site answers on, so a reader looking at one site can still follow a link back to a
* page on another. Null for the catch-all site, which has no host of its own to name.
*/
hostname: string | null
}
/** A link on a page, and whether anything is actually at the other end. */
export interface OutboundLink extends PageLinkRow {
/** The page it resolves to, or null when nothing is there — a red link. */
targetPageId: string | null
targetTitle: string | null
/*
Where the resolved page actually sits, and what it is tagged with, rather than what the link said
about it. Both are here so the caller can ask whether this reader may know the page exists: an
alias or an id link carries no address at all, and a page rule can be written against tags.
*/
targetPageLocale: string | null
targetPagePath: string | null
targetPageTags: string[] | null
}
/** Where a page sits, which is how a link addresses it. */
export interface LinkTarget {
siteId: string
locale: string
path: string
}
class PageLinks {
/**
* Whether this site shows what links to a page.
*
* **Display only.** Off, the Links tab is not drawn and the route behind it answers 404 — and every
* save still records what it links to, exactly as before. That is the whole point of putting the
* switch here rather than around `refreshForPage`: a site that turns this off for a year and back
* on again has a complete answer waiting, where one that stopped writing the table would have a
* year of pages to rebuild before the tab said anything true.
*
* `!== false` rather than a truth test, so a site whose config blob has never been written with the
* key reads as on — which is what the default in `sites.createSite` says it is.
*/
isAllowed(siteId: string | undefined): boolean {
return siteId ? WIKI.sites[siteId]?.config?.features?.backlinks !== false : false
}
/**
* Work out everything a page points at, and store it.
*
* Three sources, because a link is not only a thing in an article:
*
* - **The render**, which is where an author's own links are, and where nearly all of them are.
* - **A redirection's target**, which is not in a render at all — a redirect page has no body, it
* has a destination, and it is the strongest link in the wiki: a reader following one never sees
* that it broke, they just land nowhere.
* - **The page's relations**, the sidebar links its properties dialog collects. Stored as their own
* column, written by the same link picker that writes one into content, and just as breakable.
*
* @param renderHrefs What `postProcess` already read out of the render it just produced, for a save
* that is storing one. Absent, the stored render is parsed instead — which is the
* case for a page that MOVED: nothing about it changed, but every relative link
* on it now resolves somewhere else.
*/
async refreshForPage(page: PageLinkSource, renderHrefs?: string[]): Promise<void> {
const hrefs = renderHrefs ?? linksFromRender(page.render)
if (page.editor === REDIRECT_EDITOR && page.content) {
try {
const redirect = JSON.parse(page.content)
// -> `kind: 'url'` is a destination off this wiki, which `resolveLink` would discard anyway.
// Passed through regardless, so that the one place deciding what is a link stays the one
// place deciding it
if (typeof redirect?.target === 'string') {
hrefs.push(redirect.target)
}
} catch {
// -> A redirection whose content will not parse points nowhere, which is no links rather than
// a failed save. `normalizeRedirectContent` is what stops one being written in the first
// place
}
}
for (const relation of (Array.isArray(page.relations) ? page.relations : []) as any[]) {
if (typeof relation?.target === 'string') {
hrefs.push(relation.target)
}
}
await this.replaceFor(
{ pageId: page.id, siteId: page.siteId, locale: page.locale, path: page.path },
hrefs
)
}
/**
* The same, for a caller holding an id rather than a row.
*
* What everything but a create reaches for: a save, a move, a folder rename and the rebuild utility
* all need the page as it stands AFTER their write, so reading it back is the point rather than an
* overhead.
*/
async refreshById(siteId: string, pageId: string, renderHrefs?: string[]): Promise<void> {
const rows = await WIKI.db
.select({
id: pagesTable.id,
siteId: pagesTable.siteId,
locale: pagesTable.locale,
path: pagesTable.path,
editor: pagesTable.editor,
content: pagesTable.content,
relations: pagesTable.relations,
render: pagesTable.render
})
.from(pagesTable)
.where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId)))
.limit(1)
// -> Gone while the save that asked for this was in flight. Its rows went with it
if (rows[0]) {
await this.refreshForPage(rows[0] as PageLinkSource, renderHrefs)
}
}
/**
* Replace every link recorded for a page.
*
* Wholesale rather than differential: a save rewrites the page, and working out which links
* survived it costs more than the handful of rows it would save. In one transaction so that a page
* is never momentarily linkless — the backlinks of everything it points at would flicker.
*
* Resolution happens here rather than at extraction time because it needs the page's own address:
* `../two` means a different page depending on where the page holding it sits, which is also why
* this has to run again when a page MOVES even though nothing about its content changed.
*
* Unresolvable hrefs are dropped silently — an external link, a `mailto:`, a bare fragment, an
* admin path. See `resolveLink` for the whole list; none of them is a link this can be asked a
* question about.
*
* @param source The page the links were found on
* @param hrefs Every href the page carries, in any order and with repeats
*/
async replaceFor(source: LinkSource & { pageId: string }, hrefs: string[]): Promise<void> {
const resolved = new Map<string, ResolvedLink>()
for (const href of hrefs) {
const link = resolveLink(href, source)
if (link) {
// -> One row per spelling, so the same href written twice on a page is one link. Keyed on the
// href as written, which is what the unique index holds
resolved.set(link.href, link)
}
}
await WIKI.db.transaction(async (trx: any) => {
await trx.delete(pageLinksTable).where(eq(pageLinksTable.pageId, source.pageId))
if (resolved.size < 1) {
return
}
await trx.insert(pageLinksTable).values(
[...resolved.values()].map((link) => ({
pageId: source.pageId,
siteId: source.siteId,
kind: link.kind,
href: link.href,
targetSiteId: link.targetSiteId,
targetLocale: link.targetLocale,
targetPath: link.targetPath,
targetRef: link.targetRef
}))
)
})
}
/** Forget everything a page pointed at, for a page that is going. */
async removeFor(pageId: string): Promise<void> {
await WIKI.db.delete(pageLinksTable).where(eq(pageLinksTable.pageId, pageId))
}
/**
* Every page linking to a target, as "what links here" asks it.
*
* Addressed by locale and path rather than by page id, and deliberately: a link says where it
* points, so the pages pointing at a page that has moved are still pointing at where it WAS. Asking
* by address is what lets a move ask about the path it is leaving, which is the only time that
* question has an answer.
*
* The two id-addressed kinds are folded in by page id, since those survive a move by design and
* would otherwise be missing from the one list anybody reads.
*
* Nothing here filters by what the caller may read: a backlink names a page, and which of them this
* reader is allowed to know about is a page rule the route resolves — see `api/pages.ts`.
*/
async backlinksFor(
target: LinkTarget,
{ pageId, alias }: { pageId?: string; alias?: string | null } = {}
): Promise<Backlink[]> {
const addressedByPath = and(
eq(pageLinksTable.kind, 'page'),
eq(pageLinksTable.targetLocale, target.locale),
eq(pageLinksTable.targetPath, target.path)
)
const addressedById = pageId
? and(eq(pageLinksTable.kind, 'pageId'), eq(pageLinksTable.targetRef, pageId))
: undefined
const addressedByAlias = alias
? and(eq(pageLinksTable.kind, 'alias'), eq(pageLinksTable.targetRef, alias))
: undefined
const rows = await WIKI.db
.select({
pageId: pageLinksTable.pageId,
href: pageLinksTable.href,
kind: pageLinksTable.kind,
siteId: pagesTable.siteId,
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
description: pagesTable.description,
icon: pagesTable.icon,
tags: pagesTable.tags,
publishState: pagesTable.publishState
})
.from(pageLinksTable)
.innerJoin(pagesTable, eq(pagesTable.id, pageLinksTable.pageId))
.where(
and(
eq(pageLinksTable.targetSiteId, target.siteId),
// -> A page linking to itself is not a backlink, it is a page mentioning where it already is
...(pageId ? [ne(pageLinksTable.pageId, pageId)] : []),
or(addressedByPath, addressedById, addressedByAlias)
)
)
.orderBy(pagesTable.locale, pagesTable.path)
return rows.map((row: any) => ({
...row,
tags: row.tags ?? [],
// -> A backlink may be written on another site of this instance, and a bare path would resolve
// against the host the reader is already on. Same pair as `getRecentlyEdited`, for the same
// reason: `*` is the catch-all rather than a host, and a link to it is wherever you are
url: WIKI.models.pages.urlFor(row.siteId, row.locale, row.path),
hostname:
WIKI.sites[row.siteId]?.hostname && WIKI.sites[row.siteId].hostname !== '*'
? WIKI.sites[row.siteId].hostname
: null
})) as Backlink[]
}
/**
* Every link written on a page, with whether the far end exists.
*
* The join is what answers the red-link question, and it is a join rather than a stored flag for
* the reason the whole table is addressed by path: a page created at a path somebody had already
* linked to makes that link good, without anything having to notice.
*
* Assets are left unresolved here — they are recorded so that a moved file can be traced back to
* what pointed at it, and answering whether one exists means a tree lookup per link.
*/
async outboundFor(pageId: string): Promise<OutboundLink[]> {
const rows = await WIKI.db
.select({
id: pageLinksTable.id,
pageId: pageLinksTable.pageId,
kind: pageLinksTable.kind,
href: pageLinksTable.href,
targetSiteId: pageLinksTable.targetSiteId,
targetLocale: pageLinksTable.targetLocale,
targetPath: pageLinksTable.targetPath,
targetRef: pageLinksTable.targetRef,
targetPageId: pagesTable.id,
targetTitle: pagesTable.title,
targetPageLocale: pagesTable.locale,
targetPagePath: pagesTable.path,
targetPageTags: pagesTable.tags
})
.from(pageLinksTable)
.leftJoin(
pagesTable,
or(
and(
eq(pageLinksTable.kind, 'page'),
eq(pagesTable.siteId, pageLinksTable.targetSiteId),
eq(pagesTable.locale, pageLinksTable.targetLocale),
eq(pagesTable.path, pageLinksTable.targetPath)
),
and(
eq(pageLinksTable.kind, 'pageId'),
sql`${pagesTable.id}::text = ${pageLinksTable.targetRef}`
),
and(
eq(pageLinksTable.kind, 'alias'),
eq(pagesTable.siteId, pageLinksTable.targetSiteId),
eq(pagesTable.alias, pageLinksTable.targetRef)
)
)
)
.where(eq(pageLinksTable.pageId, pageId))
.orderBy(pageLinksTable.href)
return rows as OutboundLink[]
}
/**
* The pages that will be left pointing at nothing if these targets move or go.
*
* The bulk form of `backlinksFor`, for a folder rename — which moves every page under it at once —
* and for anything else that has to warn about a change before making it. One query for the lot,
* since a rename can move hundreds of pages and asking per page would be hundreds of round trips.
*
* Only path-addressed links: the id and alias kinds survive a move, so they are not affected by
* one and do not belong in a list of what it breaks.
*
* @returns The id of every page holding such a link, without repeats
*/
async dependentsOf(
siteId: string,
targets: { locale: string; path: string }[]
): Promise<string[]> {
if (targets.length < 1) {
return []
}
const rows = await WIKI.db
.selectDistinct({ pageId: pageLinksTable.pageId })
.from(pageLinksTable)
.where(
and(
eq(pageLinksTable.targetSiteId, siteId),
eq(pageLinksTable.kind, 'page'),
or(
...targets.map((target) =>
and(
eq(pageLinksTable.targetLocale, target.locale),
eq(pageLinksTable.targetPath, target.path)
)
)
)
)
)
return rows.map((row: any) => row.pageId as string)
}
}
export const pageLinks = new PageLinks()

@ -134,6 +134,7 @@ const reAlias = /^[a-zA-Z0-9-_]*$/
/** Fields kept in the `config` blob rather than as columns, and flattened again on the way out. */ /** Fields kept in the `config` blob rather than as columns, and flattened again on the way out. */
const CONFIG_FIELDS = [ const CONFIG_FIELDS = [
'allowBacklinks',
'allowComments', 'allowComments',
'allowContributions', 'allowContributions',
'allowRatings', 'allowRatings',
@ -207,6 +208,8 @@ export interface Page {
password?: string | null password?: string | null
/** Whether the body was withheld because the page is password protected. See `getPage`. */ /** Whether the body was withheld because the page is password protected. See `getPage`. */
isLocked: boolean isLocked: boolean
/** Whether this page shows what links to it. The site-wide switch is `features.backlinks`. */
allowBacklinks: boolean
relations: any[] relations: any[]
/** /**
* This page in the other locales, as far as the requester may see them. Empty for a page that is * This page in the other locales, as far as the requester may see them. Empty for a page that is
@ -257,6 +260,7 @@ export interface PageInput {
isBrowsable?: boolean isBrowsable?: boolean
isSearchable?: boolean isSearchable?: boolean
password?: string password?: string
allowBacklinks?: boolean
relations?: any[] relations?: any[]
/** /**
* The page's counterparts in other locales, stating the whole set rather than adding to it: a locale * The page's counterparts in other locales, stating the whole set rather than adding to it: a locale
@ -525,6 +529,12 @@ class Pages {
...((withContent || row.editor === REDIRECT_EDITOR) && !locked ...((withContent || row.editor === REDIRECT_EDITOR) && !locked
? { content: row.content ?? '' } ? { content: row.content ?? '' }
: {}), : {}),
/*
Absent means yes, as every one of these does: the key is only written once somebody has
edited the page's properties, and a page nobody has touched shows what links to it. The
site-wide switch is `features.backlinks` -- see `pageLinks.isAllowed`.
*/
allowBacklinks: config.allowBacklinks ?? true,
allowComments: config.allowComments ?? true, allowComments: config.allowComments ?? true,
allowContributions: config.allowContributions ?? true, allowContributions: config.allowContributions ?? true,
allowRatings: config.allowRatings ?? true, allowRatings: config.allowRatings ?? true,
@ -1436,7 +1446,7 @@ class Pages {
}) })
const alias = await this.validateAlias(siteId, input.alias) const alias = await this.validateAlias(siteId, input.alias)
const { render, toc, text } = await WIKI.models.rendering.postProcess( const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
siteId, siteId,
input.render ?? '', input.render ?? '',
{ {
@ -1518,6 +1528,10 @@ class Pages {
throw err throw err
} }
// -> What this page points at, which is only knowable once it has an id and an address of its
// own: a relative link resolves against the page holding it
await WIKI.models.pageLinks.refreshForPage(page, links)
const versionId = await WIKI.models.pageHistory.record({ const versionId = await WIKI.models.pageHistory.record({
siteId, siteId,
pageId: page.id, pageId: page.id,
@ -1646,14 +1660,20 @@ class Pages {
} }
// -> A render only means anything next to the content it came from, so the two move together // -> A render only means anything next to the content it came from, so the two move together
let renderHrefs: string[] | undefined
if (patch.render !== undefined) { if (patch.render !== undefined) {
const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, patch.render, { const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
siteId,
patch.render,
{
scripts: hasPermission(actor, 'write:scripts'), scripts: hasPermission(actor, 'write:scripts'),
styles: hasPermission(actor, 'write:styles') styles: hasPermission(actor, 'write:styles')
}) }
)
values.render = render values.render = render
values.toc = toc values.toc = toc
values.searchContent = text values.searchContent = text
renderHrefs = links
} }
if (CONFIG_FIELDS.some((field) => patch[field] !== undefined)) { if (CONFIG_FIELDS.some((field) => patch[field] !== undefined)) {
@ -1678,6 +1698,14 @@ class Pages {
const updated = (await this.getPage({ siteId, id })) as Page const updated = (await this.getPage({ siteId, id })) as Page
/*
Unconditionally, and not only for a save that carried a render: a redirection's destination is
its content, and the sidebar relations are a column of their own, so a save touching either of
those changes what the page links to without the render moving at all. Read back rather than
assembled from the patch, for the same reason `changedFields` is worked out against the row.
*/
await WIKI.models.pageLinks.refreshById(siteId, id, renderHrefs)
const versionId = await WIKI.models.pageHistory.record({ const versionId = await WIKI.models.pageHistory.record({
siteId, siteId,
pageId: id, pageId: id,
@ -1906,6 +1934,14 @@ class Pages {
*/ */
await WIKI.models.search.indexPage(id, newLocale) await WIKI.models.search.indexPage(id, newLocale)
/*
Not the pages pointing AT this one -- those still say where it was, which is the thing worth
knowing and exactly what the table is for. This is the other direction: every relative link the
moved page itself carries resolved against the folder it used to sit in, and now resolves
against a different one. Nothing about its content changed and every one of its links may have.
*/
await WIKI.models.pageLinks.refreshById(siteId, id)
// -> Moved and then rewritten, rather than deleted and written afresh: the move is what keeps a // -> Moved and then rewritten, rather than deleted and written afresh: the move is what keeps a
// versioned target's history of the file attached to it, and the rewrite is because a move may // versioned target's history of the file attached to it, and the rewrite is because a move may
// carry a new title and always carries a new modification time, both of which are in the copy // carry a new title and always carries a new modification time, both of which are in the copy
@ -2432,7 +2468,11 @@ class Pages {
html: string, html: string,
permissions: RenderPermissions permissions: RenderPermissions
): Promise<void> { ): Promise<void> {
const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, html, permissions) const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
siteId,
html,
permissions
)
const updated = await WIKI.db const updated = await WIKI.db
.update(pagesTable) .update(pagesTable)
@ -2443,6 +2483,9 @@ class Pages {
// -> Nothing was updated when the page went while it sat in the queue // -> Nothing was updated when the page went while it sat in the queue
if (updated[0]) { if (updated[0]) {
await WIKI.models.search.indexPage(id, updated[0].locale) await WIKI.models.search.indexPage(id, updated[0].locale)
// -> An imported page is saved with no render at all and gets one from here, so this is where
// its links first appear
await WIKI.models.pageLinks.refreshById(siteId, id, links)
// -> This is the column the injected copy of a page IS, so a re-render changes it // -> This is the column the injected copy of a page IS, so a re-render changes it
invalidateAppShellCache() invalidateAppShellCache()
} }
@ -2547,6 +2590,7 @@ class Pages {
): Record<string, any> { ): Record<string, any> {
const defaults = WIKI.sites[siteId]?.config?.defaults ?? {} const defaults = WIKI.sites[siteId]?.config?.defaults ?? {}
return { return {
allowBacklinks: input.allowBacklinks ?? existing.allowBacklinks ?? true,
allowComments: input.allowComments ?? existing.allowComments ?? true, allowComments: input.allowComments ?? existing.allowComments ?? true,
allowContributions: input.allowContributions ?? existing.allowContributions ?? true, allowContributions: input.allowContributions ?? existing.allowContributions ?? true,
allowRatings: input.allowRatings ?? existing.allowRatings ?? true, allowRatings: input.allowRatings ?? existing.allowRatings ?? true,

@ -4,6 +4,7 @@ import { eq, inArray, sql } from 'drizzle-orm'
import { flipFromString, rotateFromString } from '@iconify/utils' import { flipFromString, rotateFromString } from '@iconify/utils'
import { jobs as jobsTable, pageRenderQueue as renderQueueTable } from '../db/schema.ts' import { jobs as jobsTable, pageRenderQueue as renderQueueTable } from '../db/schema.ts'
import { CustomError } from '../helpers/common.ts' import { CustomError } from '../helpers/common.ts'
import { hrefsFrom } from '../helpers/pageLinks.ts'
import type { IconifyIcon } from '@iconify/types' import type { IconifyIcon } from '@iconify/types'
import type { IconifyIconCustomisations } from '@iconify/utils' import type { IconifyIconCustomisations } from '@iconify/utils'
@ -64,6 +65,15 @@ export interface PostProcessResult {
toc: TocNode[] toc: TocNode[]
/** Plain text, for the search index. */ /** Plain text, for the search index. */
text: string text: string
/**
* Every href the page carries, exactly as written and with repeats left in.
*
* Raw on purpose: what an href ADDRESSES depends on where the page holding it sits, on the site's
* locale prefixes and on its page extensions, none of which is rendering's business. See
* `helpers/pageLinks.ts`, which reads them, and `models/pageLinks.ts`, which stores what it makes
* of them.
*/
links: string[]
} }
/** /**
@ -376,7 +386,10 @@ class Rendering {
return { return {
render: $.html(), render: $.html(),
toc, toc,
text: this.extractText($) text: this.extractText($),
// -> Read off the tree that is already loaded rather than by re-parsing the output, which is
// what `linksFromRender` has to do for a page nobody is saving
links: hrefsFrom($)
} }
} }

@ -126,6 +126,11 @@ class Sites {
} }
}, },
features: { features: {
// -> On, because what a wiki is for is pages that point at each other, and what points at
// the page in front of you is worth knowing by default. It gates the TAB and the route
// behind it; links are recorded either way, so turning it off and on again shows the
// same list rather than an empty one.
backlinks: true,
browse: true, browse: true,
collaborativeEditing: true, collaborativeEditing: true,
ratings: false, ratings: false,
@ -421,6 +426,7 @@ class Sites {
} }
}, },
features: { features: {
backlinks: true,
browse: true, browse: true,
collaborativeEditing: true, collaborativeEditing: true,
ratings: false, ratings: false,

@ -1001,6 +1001,13 @@ class Tree {
// -> A folder rename never crosses locales, so the page's own is where it came from too // -> A folder rename never crosses locales, so the page's own is where it came from too
{ locale: page.locale, path: page.previousPath } { locale: page.locale, path: page.previousPath }
) )
/*
And the links each page WRITES, which is the other thing a path carries: a relative link
resolved against the folder these pages used to sit in, and resolves against the renamed one
now. The links pointing at them are deliberately left alone -- they say where the pages were,
which is what makes them findable as the ones this rename has broken.
*/
await WIKI.models.pageLinks.refreshById(folder.siteId, page.id)
} }
// -> A storage target that lays its content out by path has every one of those files to move. // -> A storage target that lays its content out by path has every one of those files to move.
@ -1205,6 +1212,7 @@ class Tree {
password: page.password ?? '', password: page.password ?? '',
relations: page.relations, relations: page.relations,
tags: page.tags, tags: page.tags,
allowBacklinks: page.allowBacklinks,
allowComments: page.allowComments, allowComments: page.allowComments,
allowContributions: page.allowContributions, allowContributions: page.allowContributions,
allowRatings: page.allowRatings, allowRatings: page.allowRatings,
@ -1470,6 +1478,9 @@ class Tree {
}, },
{ locale: folder.locale, path: page.previousPath } { locale: folder.locale, path: page.previousPath }
) )
// -> As in `renameFolder`: every relative link these pages carry resolved against where they
// were. This move can cross locales as well, which changes what a link's prefix means too
await WIKI.models.pageLinks.refreshById(siteId, page.id)
} }
// -> A storage target that lays its content out by path has every one of those files to move. // -> A storage target that lays its content out by path has every one of those files to move.

@ -0,0 +1,106 @@
import NodeCache from 'node-cache'
import { asc, gt } from 'drizzle-orm'
import { pages as pagesTable } from '../../db/schema.ts'
import { locales } from '../../models/locales.ts'
import { pageLinks } from '../../models/pageLinks.ts'
import { pages } from '../../models/pages.ts'
import { settings } from '../../models/settings.ts'
import { sites } from '../../models/sites.ts'
/**
* Work out what every page on the wiki links to, from scratch.
*
* The links of a page are derived from its stored render and rewritten whenever that render is, so
* ordinary editing keeps them current on its own. This is for the cases where there was nothing to
* derive them from at the time: pages that predate the table, and a wiki whose locale prefixes or
* page extensions changed, which silently changes what a link that was already written ADDRESSES.
*
* Offered under Admin → Utilities and never run on its own. It is not a migration and not a boot step:
* a wiki with no links recorded works, it simply has no backlinks to show yet, and a rebuild that ran
* itself on every start would re-read every render in the wiki to produce what is usually the same
* rows.
*
* **In a worker thread**, which is what this file being here means — `addJob` sends a task with no
* in-process implementation to the pool. A wiki's pages are its whole content, and parsing every
* render in the instance is minutes of CPU on a large one: on the main thread that is the event loop
* not serving pages for the duration.
*
* The price is that a worker starts with nothing but config and a logger (see `worker.ts`), and
* resolving a link needs rather more than that — which is what `prepare` below is for.
*/
/** How many pages are read at once. A render is a whole page of HTML, so this is a memory ceiling. */
const BATCH_SIZE = 50
/**
* Put back the parts of the `WIKI` global that reading a link needs.
*
* A worker thread is deliberately bare, and what an href means is decided against a good deal of
* instance state: which hostname belongs to which site, what a site's locale prefixes and page
* extensions are, and what short code each locale answers to. All of it is loaded here exactly as
* `postBoot` loads it on the main thread.
*
* Only the four models involved, rather than the whole registry — `WIKI.models` is what `reloadCache`
* and the resolver reach through, and importing the rest would pull the storage, search and mail
* models into a thread that is reading HTML.
*/
async function prepare(): Promise<void> {
await WIKI.ensureDb!()
WIKI.cache = new NodeCache({ checkperiod: 0 })
WIKI.models = { settings, locales, pages, pageLinks } as typeof WIKI.models
// -> Locales first: the site cache reads nothing from them, but a site's URL prefixes are resolved
// through the locale cache the moment the first link is read
await locales.reloadCache()
await sites.reloadCache()
}
export async function task(): Promise<void> {
await prepare()
let cursor: string | null = null
let processed = 0
WIKI.logger.info('Rebuilding page links...')
for (;;) {
/*
Walked by id rather than by offset: this reads every page in the wiki one batch at a time, and a
paged read that re-counts its way to each batch gets slower as it goes. Nothing is being written
to `pages` here, so the set is stable underneath it.
*/
const rows = await WIKI.db
.select({
id: pagesTable.id,
siteId: pagesTable.siteId,
locale: pagesTable.locale,
path: pagesTable.path,
editor: pagesTable.editor,
content: pagesTable.content,
relations: pagesTable.relations,
render: pagesTable.render
})
.from(pagesTable)
.where(cursor ? gt(pagesTable.id, cursor) : undefined)
.orderBy(asc(pagesTable.id))
.limit(BATCH_SIZE)
if (rows.length < 1) {
break
}
for (const row of rows) {
/*
One page at a time, and one failure does not stop the rest: this is a repair, and stopping at
the first page whose render will not parse would leave the wiki with the half of its links it
already had plus however far this got.
*/
try {
await pageLinks.refreshForPage(row)
} catch (err: any) {
WIKI.logger.warn(`Could not rebuild the links of page ${row.id}: ${err.message}`)
}
processed++
}
cursor = rows.at(-1)!.id
}
WIKI.logger.info(`Rebuilt the links of ${processed} page(s) [ COMPLETED ]`)
}

@ -0,0 +1,352 @@
<template>
<div class="page-links">
<div class="flex items-center pb-2">
<w-icon class="mr-2" name="la:link" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.links.title') }}</div>
<w-space />
<!-- -> Up here rather than over the list: the heading already says what is below it, and a
second line repeating that in order to carry a number is a line spent on the number -->
<div class="text-caption text-grey-6" v-if="state.loaded && pages.length > 0">
{{ t('common.links.count', pages.length, { count: pages.length }) }}
</div>
<w-spinner class="ml-2" 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.links.loading') }}
</div>
<template v-else>
<div class="py-6 text-center" v-if="pages.length < 1">
<div class="text-body2 text-grey-6">{{ t('common.links.none') }}</div>
<div class="text-caption text-grey-6 pt-1">{{ t('common.links.noneHint') }}</div>
</div>
<div class="page-links-grid" v-else>
<!--
Two kinds of link, as the admin dashboard's recent list does it: a page on the site being
read is a route this app can take itself, and one on another site of this instance is a
plain href to that site's own host, which the router cannot resolve. `url` is built by the
server because whether a path carries a locale prefix is a per-site setting.
-->
<component
:is="isCurrentSite(pg) ? 'router-link' : 'a'"
v-for="pg of pages"
:key="pg.id"
class="page-links-card"
v-bind="isCurrentSite(pg) ? { to: pg.url } : { href: externalPageUrl(pg) }">
<!-- -> The size is a prop rather than a rule: `WIcon` writes it as an inline style, which
beats anything a class here could say -->
<w-icon class="page-links-card-icon" size="28px" :name="pg.icon || defaultPageIcon" />
<span class="page-links-card-text">
<span class="page-links-card-title">{{ pg.title }}</span>
<span class="page-links-card-desc" v-if="pg.description">{{ pg.description }}</span>
<!--
Under the description rather than over it: the path is how the page is addressed and the
description is what it is about, and what a reader scans a card for is the latter. It
stays on the card because two pages can wear one title, and on a wiki of any size they
do.
-->
<span class="page-links-card-path">{{ pg.url }}</span>
<!-- -> Only where it says something the path does not: a page on this site is on the
host the reader is already looking at -->
<span class="page-links-card-host" v-if="!isCurrentSite(pg)">{{ pg.hostname }}</span>
</span>
<w-icon class="page-links-card-arrow" size="28px" name="la:arrow-circle-right" />
</component>
</div>
</template>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/notify'
import { DEFAULT_PAGE_ICON, usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
/**
* The Links tab: every page of this wiki that points at the one being read.
*
* Beside the article rather than under it, for the reason the talk page is (`pages/Index.vue`): what
* links here is a view OF the page, and a list that could run to hundreds of rows is not something to
* put below the content somebody came to read.
*
* **The filtering is the server's, not this component's.** `…/backlinks` drops every page the reader
* holds no `read:pages` rule for before it answers, because a backlink carries a title and a path —
* listing one would hand over the existence of a page they cannot open. So there is nothing to hide
* here, and nothing here may be relied on to hide anything.
*/
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
loading: false,
loaded: false,
/** One row per LINK, which is what the endpoint answers with. See `pages` below. */
links: []
})
const defaultPageIcon = DEFAULT_PAGE_ICON
// COMPUTED
/**
* The pages, once each and in alphabetical order.
*
* The endpoint answers per link rather than per page, since a page may point here more than one way
* and a future repair has each of those to fix separately — but this is a list of pages, so the
* first row of each wins and the rest are the same page again.
*
* Sorted by what is on screen, which is the title: the server orders by locale and path because that
* is what its index is in, and neither is what a reader is reading down. `localeCompare` rather than
* a plain comparison so that an accented or non-Latin title files where the reader expects it.
*/
const pages = computed(() => {
const seen = new Map()
for (const link of state.links) {
if (!seen.has(link.id)) {
seen.set(link.id, link)
}
}
return [...seen.values()].sort((a, b) =>
a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
)
})
// METHODS
/**
* Whether a page is on the site being read.
*
* Only then can the router take the reader there — a link written on another site of this instance is
* a different host, and a route this app pushes would resolve against the wrong one.
*/
function isCurrentSite(pg) {
return !pg.hostname || pg.siteId === siteStore.id
}
/** A page on another site, as an absolute URL on that site's own host. */
function externalPageUrl(pg) {
return `${window.location.protocol}//${pg.hostname}${pg.url}`
}
async function load() {
if (!pageStore.id) {
return
}
state.loading = true
try {
state.links = await API_CLIENT.get(
`sites/${siteStore.id}/pages/${pageStore.id}/backlinks`
).json()
state.loaded = true
} catch (err) {
notify({
type: 'negative',
message: t('common.links.loadFailed'),
caption: apiErrorMessage(err)
})
}
state.loading = false
}
// MOUNTED
onMounted(load)
/*
The tab is mounted on demand and torn down when the reader leaves it, so this only fires for a page
that changes underneath an open list -- a move, or a save from another window. Cheap, and a stale
list of what points at a page that is no longer there is worse than a second request.
*/
watch(
() => pageStore.id,
() => load()
)
</script>
<style lang="scss">
/*
The ink of the list, stated here for the reason `.page-talk` states its own: the article beside it
takes its colour from `--content-ink` on `.page-contents`, and this column is not page content, so
without this it inherits whatever the shell leaves on `<body>` -- legible in the light theme and
dark-on-dark in the other. Same pair as the content sheet, so the two read as one column.
*/
.page-links {
color: #26292e;
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.87);
}
}
/*
The cards, laid out to fill whatever width the tab has.
`auto-fill` with a minimum rather than a column count at breakpoints, which is what `block-index`
does: that block is drawn inside an article whose width it cannot know, and neither can this -- the
column here is the window less the navigation sidebar and less the contents panel, either of which
may or may not be there. A track minimum answers all of those without a media query per combination,
and gives one column on a phone for the same reason the block falls back to one.
19rem, because what has to fit across a card is an icon, a title and a path in monospace; below that
the path wraps on nearly every page and the grid has bought narrower cards at the cost of taller
ones. It is also what decides when the third column arrives, since the article column is the window
less the navigation sidebar and the contents panel: a wider minimum here means a common desktop
never reaches three at all.
Three per row at the most, which is the `max()`: a track is never allowed to be narrower than a
third of the row, so `auto-fill` can never make room for a fourth however wide the column gets. The
ceiling is the same one `block-index` tops out at, and for the same reason -- past three, a title
and its description have nowhere to go but one word a line. `$gap * 2` is what the two gaps between
three tracks take out of the width before it is divided.
*/
.page-links-grid {
$gap: 0.5rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(max(19rem, calc((100% - #{$gap * 2}) / 3)), 1fr));
gap: $gap;
padding: 0.75rem 0;
}
/*
One card. The shape `block-index` gives a page in a listing, so that a page pointing at this one
looks the way a page listed in the content does -- a pale panel with a heavy left edge that takes
the brand colour on hover, and the circled arrow at its right.
Ported rather than shared: that one is a Lit component styling its own shadow tree off `--q-` custom
properties, and this is an app component in a stylesheet with `$primary` and the theme classes in
scope. What is worth keeping identical is what a reader sees.
*/
.page-links-card {
display: flex;
position: relative;
align-items: center;
gap: 14px;
padding: 0.75rem 1rem;
/* -> Room for the arrow, which is positioned against the right edge rather than laid out */
padding-right: 3.5rem;
border-radius: 5px;
/*
The card's own ink, named so the arrow below can mix against it. `currentColor` would not do the
job there -- the arrow sets a colour of its own for its resting state, so `currentColor` inside it
resolves to that grey rather than to the card's.
*/
--page-links-ink: #{$primary};
color: var(--page-links-ink);
text-decoration: none;
font-weight: 500;
transition:
background-color 0.15s var(--ease-standard),
border-color 0.15s var(--ease-standard);
@at-root .body--light & {
background-color: #fafafa;
background-image: linear-gradient(to bottom, #fff, #fafafa);
border-right: 1px solid rgba(0, 0, 0, 0.05);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
border-left: 5px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 3px 8px 0 rgba(116, 129, 141, 0.1);
&:hover {
background-image: linear-gradient(to bottom, #fff, rgba(255, 255, 255, 0.95));
border-left-color: $primary;
}
}
@at-root .body--dark & {
background-color: #222;
background-image: linear-gradient(to bottom, #161b22, #0d1117);
border-right: 1px solid rgba(0, 0, 0, 0.5);
border-bottom: 1px solid rgba(0, 0, 0, 0.5);
border-left: 5px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 3px 8px 0 rgba(0, 0, 0, 0.25);
/* -> The app's lightened brand shade, as the block uses on a dark row: primary is picked to read
on white and is too dim against #161b22 */
--page-links-ink: var(--color-primary-light);
&:hover {
background-image: linear-gradient(to bottom, #1e232a, #161b22);
border-left-color: $primary;
}
}
}
.page-links-card-icon {
flex: none;
}
.page-links-card-text {
display: flex;
flex-direction: column;
/* -> The row less the icon. `min-width` is what lets a long title wrap inside the card rather than
pushing the card wider than its track. */
flex: 1;
min-width: 0;
gap: 1px;
}
.page-links-card-title {
/* -> Two lines at most, then an ellipsis: a card in a grid is one cell of a row, and one page with
a sentence for a title would otherwise set the height of every card beside it */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.page-links-card-desc,
.page-links-card-path,
.page-links-card-host {
font-size: 0.8em;
font-weight: normal;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.5);
}
}
.page-links-card-path {
font-family: var(--font-mono);
font-size: 0.75em;
opacity: 0.85;
}
/*
The circled arrow, against the card's right edge rather than in the flow: the text column is what
takes the leftover width, and an arrow laid out after it would be pushed about by however long the
longest line happens to be.
*/
.page-links-card-arrow {
position: absolute;
right: 0.75rem;
pointer-events: none;
color: rgba(0, 0, 0, 0.2);
transition: color 0.15s var(--ease-standard);
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.2);
}
@at-root .page-links-card:hover & {
color: color-mix(in srgb, var(--page-links-ink) 50%, transparent);
}
}
</style>

@ -165,6 +165,22 @@
@click="state.showLocaleRelationsDialog = true"> @click="state.showLocaleRelationsDialog = true">
<w-tooltip>{{ t('editor.props.localeRelationsHint') }}</w-tooltip> <w-tooltip>{{ t('editor.props.localeRelationsHint') }}</w-tooltip>
</w-btn> </w-btn>
<!--
The third kind of relation, and the only one nobody writes: what links to this page is read
off the content of every other page, so this is a switch rather than a list. Hidden where
the site has the feature off altogether -- a toggle for a tab that cannot appear is a
setting that does nothing, and the reason it does nothing is on a screen this author may
well not be able to reach.
-->
<div class="pt-4" v-if="siteStore.features.backlinks">
<w-toggle
v-model="pageStore.allowBacklinks"
dense
:label="t(`editor.props.allowBacklinks`)"
color="primary"
checked-icon="la:check"
unchecked-icon="la:times" />
</div>
</w-card-section> </w-card-section>
<!-- <!--
Only for an author who may actually write them: the server drops a script or a stylesheet from Only for an author who may actually write them: the server drops a script or a stylesheet from

@ -36,22 +36,37 @@ import { useI18n } from 'vue-i18n'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
/** /**
* Article / Talk, above the content of a page that has a discussion beside it. * Article / Talk / Links, above the content of a page that has more than one view of itself.
* *
* Its own strip rather than `WTabs`, which is a segmented control: a tinted track with the active * 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 * 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 * 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 * 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". * above the article says "a control", where this says "you are looking at one of these".
* *
* The tabs are at the RIGHT end: the article's first heading is what a reader is here for and it * 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. * starts at the left, so the switch stays out of the way of the column's own beginning.
*
* Which tabs there are is the caller's decision, not this component's -- each one is gated on things
* only `pages/Index.vue` knows (the comments provider in use, the page's own switches, the rules this
* reader holds here). Article is always the first, and the strip is not drawn at all when it is the
* only one.
*/ */
const props = defineProps({ const props = defineProps({
/** Which view is on screen: `article` or `talk`. */ /** Which view is on screen: `article`, `talk` or `links`. */
modelValue: { modelValue: {
type: String, type: String,
required: true required: true
},
/** Whether the built-in discussion is one of the views. */
talk: {
type: Boolean,
default: false
},
/** Whether the list of pages linking here is one of the views. */
links: {
type: Boolean,
default: false
} }
}) })
@ -78,25 +93,46 @@ const tabs = computed(() => [
label: t('common.comments.tabArticle'), label: t('common.comments.tabArticle'),
count: 0 count: 0
}, },
...(props.talk
? [
{ {
name: 'talk', name: 'talk',
icon: 'la:comments', icon: 'la:comments',
label: t('common.comments.tabTalk'), 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 The count the page came with, not the length of a list this strip does not have: the badge
to be there before the discussion is ever opened, which is the whole reason it rides along on has to be there before the discussion is ever opened, which is the whole reason it rides
the page payload. along on the page payload.
*/ */
count: pageStore.commentsCount count: pageStore.commentsCount
} }
]
: []),
...(props.links
? [
{
name: 'links',
icon: 'la:link',
label: t('common.page.tabLinks'),
/*
No badge, and not for want of a number to put in one. What links here is filtered by what
THIS reader may read, so a count that did not run the same filter would announce pages
they cannot open -- and one that did would have to fetch the whole list on every page view
to show a digit above a tab nobody has clicked. The list itself is a click away and says
how many there are.
*/
count: 0
}
]
: [])
]) ])
// METHODS // METHODS
/** /**
* Arrow keys move between the tabs, which is what a tablist is expected to do. Selecting as it moves * 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 * (rather than requiring a second key) is the automatic-activation pattern, and is right here: the
* views are already loaded, so arriving at one costs nothing. * strip has three tabs at most and each is a view of the page the reader is already on.
*/ */
function onKeydown(ev) { function onKeydown(ev) {
const keys = { ArrowRight: 1, ArrowLeft: -1, Home: 'first', End: 'last' } const keys = { ArrowRight: 1, ArrowLeft: -1, Home: 'first', End: 'last' }

@ -159,6 +159,24 @@
<!-- ----------------------- --> <!-- ----------------------- -->
<w-card class="pb-2 mt-4"> <w-card class="pb-2 mt-4">
<w-card-header>{{ t('admin.general.features') }}</w-card-header> <w-card-header>{{ t('admin.general.features') }}</w-card-header>
<!--
The site-wide switch for the Links tab. It hides the tab and closes the route behind it;
what links to what goes on being recorded regardless, so turning this back on finds a
complete answer rather than an empty list.
-->
<w-item tag="label">
<blueprint-icon icon="link" />
<w-item-section>
<w-item-label>{{ t(`admin.general.allowBacklinks`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowBacklinksHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.features.backlinks"
:aria-label="t(`admin.general.allowBacklinks`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label"> <w-item tag="label">
<blueprint-icon icon="tree-structure" /> <blueprint-icon icon="tree-structure" />
<w-item-section> <w-item-section>
@ -652,6 +670,15 @@ function defaultConfig() {
follow: false follow: false
}, },
features: { features: {
backlinks: true,
/*
False, where the site's own default is true, because this is what the SERVER makes of the key
being absent: `controllers/collab.ts` tests it for truthiness, so a config blob without it is
a site where collaboration is off, and the toggle has to say so rather than offer to turn off
something that already is. `backlinks` and `comments` above default the other way for the same
reason read the other way -- both are `!== false` on the server.
*/
collaborativeEditing: false,
ratings: false, ratings: false,
ratingsMode: 'off', ratingsMode: 'off',
comments: true, comments: true,
@ -780,7 +807,12 @@ async function save() {
follow: state.config.robots?.follow ?? false follow: state.config.robots?.follow ?? false
}, },
features: { features: {
backlinks: state.config.features?.backlinks ?? true,
browse: state.config.features?.browse ?? false, browse: state.config.features?.browse ?? false,
// -> The site config is deep-merged on the server, so a key left out of this object keeps
// whatever is stored rather than being dropped. That is what made leaving this one out
// silent: the toggle moved, the save succeeded, and the next load put it back.
collaborativeEditing: state.config.features?.collaborativeEditing ?? false,
comments: state.config.features?.comments ?? true, comments: state.config.features?.comments ?? true,
ratingsMode: state.config.features?.ratingsMode ?? 'off', ratingsMode: state.config.features?.ratingsMode ?? 'off',
reasonForChange: state.config.features?.reasonForChange ?? 'required', reasonForChange: state.config.features?.reasonForChange ?? 'required',

@ -225,6 +225,22 @@
:label="t(`common.actions.proceed`)" /> :label="t(`common.actions.proceed`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-item>
<blueprint-icon icon="link" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{ t(`admin.utilities.rebuildPageLinks`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.utilities.rebuildPageLinksHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
class="acrylic-btn"
flat
icon="la:arrow-circle-right"
color="primary"
@click="rebuildPageLinks"
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
<w-item> <w-item>
<blueprint-icon icon="rescan-document" :hue-rotate="45" /> <blueprint-icon icon="rescan-document" :hue-rotate="45" />
<w-item-section> <w-item-section>
@ -702,6 +718,44 @@ async function purgeSampleContent() {
}) })
} }
/**
* Work out again what every page on every wiki links to, from the render each page already stores.
*
* Queued rather than done in the request: it reads every page in the instance, so it runs in a worker
* thread and the response only says it has been scheduled. Confirmed even so — it is a lot of work on
* a large wiki — but not coloured as a destruction, since nothing about the content changes and the
* rows it rewrites are derived from that content anyway.
*/
function rebuildPageLinks() {
confirm({
title: t('admin.utilities.rebuildPageLinks'),
message: t('admin.utilities.rebuildPageLinksConfirm'),
caption: t('admin.utilities.rebuildPageLinksConfirmWarn'),
cancel: true,
persistent: true,
okLabel: t('common.actions.proceed')
}).onOk(async () => {
loading.show()
try {
const resp = await API_CLIENT.post('system/page-links/rebuild').json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('admin.utilities.rebuildPageLinksSuccess')
})
} catch (err) {
notify({
type: 'negative',
message: t('admin.utilities.rebuildPageLinksFailed'),
caption: apiErrorMessage(err)
})
}
loading.hide()
})
}
/** /**
* Throw away everything the wiki has cached off the database — files, icons, and the site, group and * Throw away everything the wiki has cached off the database — files, icons, and the site, group and
* locale state read on every request. Not confirmed: nothing is lost and nothing stops working, the * locale state read on every request. Not confirmed: nothing is lost and nothing stops working, the

@ -51,16 +51,25 @@
class="min-w-0 flex-1 flex flex-col min-h-0" class="min-w-0 flex-1 flex flex-col min-h-0"
:style="siteStore.theme.tocPosition === `left` ? `order: 2;` : `order: 1;`"> :style="siteStore.theme.tocPosition === `left` ? `order: 2;` : `order: 1;`">
<!-- <!--
Article / Talk, above the content and only where there is a talk page to go to: the Article / Talk / Links, above the content and only where there is somewhere else to go: the
built-in comments provider, on a page that takes comments, for a reader the page rules let talk page needs the built-in comments provider on a page that takes comments, for a reader
read them. Every other provider draws itself UNDER the article instead -- see the page rules let read them, and the links list needs a page that exists. Every other
`PageCommentsEmbed.vue` -- so there is nothing to switch between and no strip. comments provider draws itself UNDER the article instead -- see `PageCommentsEmbed.vue` --
so on those sites the strip is Article and Links.
`activeView` and not `state.view`: what the strip highlights has to be what is drawn below
it, and a `#links` fragment followed to a page with no such tab is drawn as the article.
Outside the scrolling box rather than at the top of it, which is also what makes an anchor Outside the scrolling box rather than at the top of it, which is also what makes an anchor
land where it should: the scrollport starts under the strip, so a heading jumped to is not land where it should: the scrollport starts under the strip, so a heading jumped to is not
jumped to underneath it. jumped to underneath it.
--> -->
<page-view-tabs v-if="showTalkTab" v-model="state.view" /> <page-view-tabs
v-if="showTalkTab || showLinksTab"
:model-value="activeView"
:talk="showTalkTab"
:links="showLinksTab"
@update:model-value="state.view = $event" />
<component :is="editorComponents[editorStore.editor]" v-if="editorStore.isActive" /> <component :is="editorComponents[editorStore.editor]" v-if="editorStore.isActive" />
<!-- <!--
The lock screen, in place of the article. There is nothing to hide here: the server sent no The lock screen, in place of the article. There is nothing to hide here: the server sent no
@ -146,7 +155,8 @@
`v-show` rather than `v-if` on the article below, so that leaving the discussion and `v-show` rather than `v-if` on the article below, so that leaving the discussion and
coming back does not re-run the page's own scripts or lose where the reader was in it. coming back does not re-run the page's own scripts or lose where the reader was in it.
--> -->
<page-talk v-if="showTalkTab && state.view === `talk`" /> <page-talk v-if="activeView === `talk`" />
<page-links v-if="activeView === `links`" />
<!-- <!--
Delegated rather than bound per link: the anchors are written by `v-html`, so there is Delegated rather than bound per link: the anchors are written by `v-html`, so there is
nothing here to put a handler on, and they are replaced wholesale on every render. nothing here to put a handler on, and they are replaced wholesale on every render.
@ -154,7 +164,7 @@
<div <div
class="page-contents" class="page-contents"
ref="pageContents" ref="pageContents"
v-show="!showTalkTab || state.view === `article`" v-show="activeView === `article`"
v-html="pageStore.render" v-html="pageStore.render"
@click="onContentClick" /> @click="onContentClick" />
<!-- <!--
@ -166,9 +176,7 @@
<div <div
class="page-relations" class="page-relations"
v-if=" v-if="
pageStore.relations && pageStore.relations && pageStore.relations.length > 0 && activeView === `article`
pageStore.relations.length > 0 &&
(!showTalkTab || state.view === `article`)
"> ">
<w-separator class="my-6" /> <w-separator class="my-6" />
<div class="flex flex-wrap"> <div class="flex flex-wrap">
@ -434,6 +442,14 @@ const PageTalk = defineAsyncComponent({
loadingComponent: LoadingGeneric loadingComponent: LoadingGeneric
}) })
const PageCommentsEmbed = defineAsyncComponent(() => import('@/components/PageCommentsEmbed.vue')) const PageCommentsEmbed = defineAsyncComponent(() => import('@/components/PageCommentsEmbed.vue'))
/*
Likewise on demand: what links to a page is a list nobody is looking at until they ask for it, and
it is a request as well as a chunk -- see the note on the tab's missing badge in `PageViewTabs.vue`.
*/
const PageLinks = defineAsyncComponent({
loader: () => import('@/components/PageLinks.vue'),
loadingComponent: LoadingGeneric
})
const editorComponents = { const editorComponents = {
markdown: defineAsyncComponent({ markdown: defineAsyncComponent({
@ -507,7 +523,7 @@ const state = reactive({
tocPanelOpen: false, tocPanelOpen: false,
currentRating: 3, currentRating: 3,
/** /**
* Which of the two views the reader is on, `article` or `talk`. * Which view the reader is on: `article`, `talk` or `links`.
* *
* Local to the view rather than in the store, and re-read from the URL on every page change: * Local to the view rather than in the store, and re-read from the URL on every page change:
* arriving at a page means arriving at what it says, and a reader who went to read one discussion * arriving at a page means arriving at what it says, and a reader who went to read one discussion
@ -621,7 +637,7 @@ const canCreatePage = computed(
behind the tab will check. And the page has to exist: an empty path has nothing to discuss. behind the tab will check. And the page has to exist: an empty path has nothing to discuss.
With no tab, the article is simply the view, which is why everything below tests With no tab, the article is simply the view, which is why everything below tests
`!showTalkTab || state.view === 'article'` rather than the view alone. `activeView` rather than the view alone -- see the computed below it.
*/ */
const showTalkTab = computed( const showTalkTab = computed(
() => () =>
@ -632,6 +648,49 @@ const showTalkTab = computed(
userStore.pagePermissions.includes('read:comments') userStore.pagePermissions.includes('read:comments')
) )
/*
Whether this page has a list of what points at it to switch to.
The same two switches the talk tab has, and in the same order: the site's own, under General →
Features, and then the page's, from its properties dialog. Both only decide whether the tab is
DRAWN -- what links to what is recorded by every save regardless, so either one going back on shows
a complete list rather than an empty one.
No permission to ask about beyond that: a reader looking at the page already holds `read:pages` on
it, which is the whole of what the endpoint behind the tab wants, and which pages are IN the list is
filtered per reader by the server.
A page still waiting for its password is excluded: the server refuses this for a locked page, as it
refuses its history, and a tab that answers 403 when it is opened is worse than no tab.
*/
const showLinksTab = computed(
() =>
siteStore.features.backlinks &&
pageStore.allowBacklinks &&
!pageStore.notFound &&
!pageStore.isLocked &&
!editorStore.isActive &&
Boolean(pageStore.id)
)
/*
The view actually on screen, which is the one asked for only where it exists.
`state.view` is what the URL and the strip say; this is what is drawn. The two differ whenever a
fragment names a view this page does not have -- `#talk` on a page with comments switched off,
`#links` while the editor is open -- and without the coercion that is a column with nothing in it,
since each view is drawn on its own condition and none of them would match.
*/
const activeView = computed(() => {
if (state.view === 'talk' && showTalkTab.value) {
return 'talk'
}
if (state.view === 'links' && showLinksTab.value) {
return 'links'
}
return 'article'
})
/* /*
The other providers, at the bottom of the article. No permission check: what a third-party widget The other providers, at the bottom of the article. No permission check: what a third-party widget
shows and to whom is that provider's own business, and this wiki's page rules say nothing about an shows and to whom is that provider's own business, and this wiki's page rules say nothing about an
@ -759,15 +818,16 @@ function onHashChange() {
* The view the current URL asks for. * The view the current URL asks for.
* *
* `#talk` opens the discussion instead of the article -- what a link to a comment, or to the talk * `#talk` opens the discussion instead of the article -- what a link to a comment, or to the talk
* page of an article, has to be able to say. Read from `window.location` rather than from the route, * page of an article, has to be able to say -- and `#links` opens the list of what points here. Read
* so that it answers the same before the router has resolved anything and when the fragment is * from `window.location` rather than from the route, so that it answers the same before the router
* changed from outside the app. * has resolved anything and when the fragment is changed from outside the app.
* *
* A page with no discussion to show simply stays on the article: `showTalkTab` gates what is drawn, * A page with no such view to show simply stays on the article: `activeView` is what is drawn, so a
* so a fragment naming a view this reader does not have is ignored rather than blanking the column. * fragment naming a view this reader does not have is ignored rather than blanking the column.
*/ */
function viewFromHash() { function viewFromHash() {
return window.location.hash === '#talk' ? 'talk' : 'article' const view = window.location.hash.slice(1)
return view === 'talk' || view === 'links' ? view : 'article'
} }
watch( watch(

@ -25,6 +25,7 @@ export const DEFAULT_PAGE_ICON = 'mdi:file-document-outline'
* page for this locale -- refused the same way. Both are left for the author to fill in on the copy. * page for this locale -- refused the same way. Both are left for the author to fill in on the copy.
*/ */
const DUPLICATED_PAGE_PROPS = [ const DUPLICATED_PAGE_PROPS = [
'allowBacklinks',
'allowComments', 'allowComments',
'allowContributions', 'allowContributions',
'allowRatings', 'allowRatings',
@ -49,6 +50,8 @@ const DUPLICATED_PAGE_PROPS = [
export const usePageStore = defineStore('page', { export const usePageStore = defineStore('page', {
state: () => ({ state: () => ({
alias: '', alias: '',
/** Whether this page shows its Links tab. The site-wide switch is `siteStore.features.backlinks`. */
allowBacklinks: true,
allowComments: false, allowComments: false,
allowContributions: true, allowContributions: true,
allowRatings: true, allowRatings: true,
@ -552,6 +555,7 @@ export const usePageStore = defineStore('page', {
// belonged to -- a copy included, whose set already holds a page for this locale // belonged to -- a copy included, whose set already holds a page for this locale
localeRelations: [], localeRelations: [],
tags: props.tags ?? [], tags: props.tags ?? [],
allowBacklinks: props.allowBacklinks ?? true,
allowComments: props.allowComments ?? false, allowComments: props.allowComments ?? false,
allowContributions: props.allowContributions ?? true, allowContributions: props.allowContributions ?? true,
allowRatings: props.allowRatings ?? true, allowRatings: props.allowRatings ?? true,
@ -848,6 +852,7 @@ export const usePageStore = defineStore('page', {
const body = { const body = {
...pick(this, [ ...pick(this, [
'alias', 'alias',
'allowBacklinks',
'allowComments', 'allowComments',
'allowContributions', 'allowContributions',
'allowRatings', 'allowRatings',

@ -90,6 +90,13 @@ export const useSiteStore = defineStore('site', {
overlay: null, overlay: null,
overlayOpts: {}, overlayOpts: {},
features: { features: {
/*
The one default here that is not the conservative one, and deliberately: the rest read false
until the server answers because showing a feature that turns out to be off is worse than the
other way round, while a site config blob written before this key existed has to mean "on" --
that is what `pageLinks.isAllowed` decides on the server, and the two have to agree.
*/
backlinks: true,
browse: false, browse: false,
collaborativeEditing: false, collaborativeEditing: false,
ratingsMode: 'off', ratingsMode: 'off',

Loading…
Cancel
Save