mirror of https://github.com/requarks/wiki
parent
bf889ecfb2
commit
0385c375c9
@ -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
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
@ -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>
|
||||
Loading…
Reference in new issue