feat: add page locale relations + various fixes

scarlett
NGPixel 1 month ago
parent fdd7945a6d
commit b16402ae0f
No known key found for this signature in database

@ -46,7 +46,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Fetch the latest locales from the Wiki.js repository', summary: 'Fetch the latest locales from the Wiki.js repository',
description: description:
'Reads the published locale metadata and records any locale not seen before as available. An installed locale is re-downloaded only when its published hash differs from the one stored, so a run that finds nothing new costs a single request.', "Reads the published locale metadata and records any locale not seen before as available. An installed locale is re-downloaded only when its published hash differs from the one stored, so a run that finds nothing new costs a single request.\n\n`en` is never fetched: it is the locale the interface is written in and ships with the wiki, loaded from `locales/en.json` on every boot. It counts as unchanged.",
tags: ['Locales'], tags: ['Locales'],
response: { response: {
200: { 200: {
@ -86,7 +86,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Download the strings of an available locale', summary: 'Download the strings of an available locale',
description: description:
'Downloads the published strings file for a locale that has a row but no strings, making it installable on a site. Fetch the locale list first: a locale nobody has heard of yet has no row to install.', "Downloads the published strings file for a locale that has a row but no strings, making it installable on a site. Fetch the locale list first: a locale nobody has heard of yet has no row to install.\n\nRefused for `en`, which ships with the wiki and is always installed.",
tags: ['Locales'], tags: ['Locales'],
params: { params: {
type: 'object', type: 'object',

@ -413,6 +413,79 @@ async function routes(app: FastifyInstance) {
} }
) )
/**
* GET LOCALE RELATIONS OF A PAGE, BY PATH
*
* No route-level permissions: this answers about one page, so what governs it is `read:pages` on
* that page rather than anything site-wide.
*/
app.get<{ Params: { siteId: string }; Querystring: { path: string; locale?: string } }>(
'/sites/:siteId/pages/locale-relations',
{
schema: {
summary: 'Get the translation set a page belongs to',
description:
'The pages that are the same page as this one, in other locales, addressed by path rather than by ID — which is what a page picker has in hand.\n\nWhat the page properties panel asks before it accepts a chosen page. A page that is already part of a set answers with the rest of it, so the panel can fill its other rows in and say what set is being joined; a page with no counterparts answers with an empty list. Saving is what actually joins them — see `localeRelations` on `PageInput`.\n\nThe set comes back whole, unfiltered by publish state: an author choosing a translation has to be told about a draft one, or they would be shown an empty row and refused on save.',
tags: ['Pages'],
params: siteIdParam,
querystring: {
type: 'object',
required: ['path'],
properties: {
path: {
type: 'string',
maxLength: 255,
description:
'Slash-separated path of the page to ask about. The home page when empty.'
},
locale: {
type: 'string',
maxLength: 10,
description: "The site's primary locale when absent."
}
}
},
response: {
200: {
description: 'The page, and the rest of its translation set',
type: 'object',
properties: {
page: { $ref: 'PageLocaleRelation#' },
relations: {
type: 'array',
description: 'Every other page of the set. Empty when the page is in none.',
items: { $ref: 'PageLocaleRelation#' }
}
}
}
}
}
},
async (req, reply) => {
const path = normalizePagePath(req.query.path)
const group = await WIKI.models.pages.localeGroupAt(req.params.siteId, {
locale: req.query.locale,
path
})
if (!group) {
return reply.notFound('This page does not exist.')
}
if (!mayOnPage(req, 'read:pages', { path: group.page.path, locale: group.page.locale })) {
return reply.forbidden('You are not allowed to read this page.')
}
return {
page: { locale: group.page.locale, path: group.page.path, title: group.page.title },
/*
Filtered by what the asker may read, one page at a time: the set is a list of pages, and a
page they have no access to is not one to name at them — even to explain a refusal.
*/
relations: group.relations.filter((rel) =>
mayOnPage(req, 'read:pages', { path: rel.path, locale: rel.locale })
)
}
}
)
/** /**
* GET PAGE * GET PAGE
*/ */

@ -92,6 +92,27 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
additionalProperties: true additionalProperties: true
} }
}, },
localeRelations: {
type: 'array',
description:
"This page's counterparts in other locales — the same page in another language, which is what the locale selector sends a reader to.\n\nThe list states the WHOLE set rather than adding to it: a locale left out has no counterpart, and a page that was in the set and is not listed leaves it. Leave the field out to keep the set as it is.\n\nNaming a page that already belongs to a set joins that set, bringing its other members along. It is refused with a 409 when that set already holds a page for a locale this list speaks for — including this page's own locale, which is the case of a page that is already another page's translation.",
items: {
type: 'object',
required: ['locale', 'path'],
properties: {
locale: {
type: 'string',
maxLength: 10,
description: 'The locale this counterpart is written in.'
},
path: {
type: 'string',
maxLength: 255,
description: 'Path of the page in that locale, without a leading slash.'
}
}
}
},
tags: { tags: {
type: 'array', type: 'array',
items: { items: {
@ -171,6 +192,12 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'array', type: 'array',
items: { type: 'object', additionalProperties: true } items: { type: 'object', additionalProperties: true }
}, },
localeRelations: {
type: 'array',
description:
'The same page in other locales, one entry per locale, as far as this requester may see them — a draft translation is not offered to a reader who could not open it. Empty for a page with no counterparts.',
items: { $ref: 'PageLocaleRelation#' }
},
tags: { type: 'array', items: { type: 'string' } }, tags: { type: 'array', items: { type: 'string' } },
toc: { toc: {
type: 'array', type: 'array',
@ -272,6 +299,29 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
} }
}) })
/**
* PAGE LOCALE RELATION - One page of a translation set: this page, in another language
*/
app.addSchema({
$id: 'PageLocaleRelation',
type: 'object',
properties: {
locale: {
type: 'string',
description: 'The locale this counterpart is written in.'
},
path: {
type: 'string',
description:
"Slash-separated path of the page in that locale, without a leading slash and without the locale's URL prefix."
},
title: {
type: 'string',
description: 'Its title, for a surface that lists the set rather than navigating to it.'
}
}
})
/** /**
* INCLUDED PAGE - Another page's render, as an include block draws it inside the page being read * INCLUDED PAGE - Another page's render, as an include block draws it inside the page being read
*/ */

@ -519,45 +519,39 @@ export default {
.toString({ smallestUnit: 'millisecond' }), .toString({ smallestUnit: 'millisecond' }),
tz: 'UTC' tz: 'UTC'
}) })
// -> Add a maximum of 10 future iterations for a single task /*
let addedFutureJobs = 0 At most 6 iterations of a task are queued ahead, and `take` is what stops there --
while (true) { and stops early where the window above holds fewer, since it gives back what it
try { reached rather than throwing at the end of it.
// FIXME: pre-existing bug — cron-parser v5's `next()` returns a `CronDate`, not an
// ES iterator result, so `next.value` and `next.done` below are both `undefined`. The cap is a horizon rather than a quota: an iteration already queued still spends
// `next.value.getTime()` therefore throws (swallowed by the `catch { break }`) one of the six, so what is pending for a task never runs further ahead than its next
// whenever `existingJobs` is non-empty, and `next.done` is never true so the loop six runs however often this runs. Counting only the ones added would push that
// only ever stops at the 10-iteration cap. Cast to `any` to keep the migration horizon out on every pass, until a minute-by-minute task had the whole window queued.
// behavior-neutral; the fix is `next.getTime()` + `plannedIterations.hasNext()`. */
const next = plannedIterations.next() as any for (const iteration of plannedIterations.take(6)) {
// -> Ensure this iteration isn't already scheduled const waitUntil = iteration.toDate()
if ( // -> Ensure this iteration isn't already scheduled
!existingJobs.some( if (
(j: any) => existingJobs.some(
j.task === job.task && j.waitUntil.getTime() === next.value.getTime() (j) => j.task === job.task && j.waitUntil?.getTime() === waitUntil.getTime()
) )
) { ) {
// FIXME: `useWorker` is not an `addJob` option (it is derived inside `addJob`) continue
// and `waitUntil` is handed an ISO string rather than a Date. Cast preserves
// the existing call verbatim.
this.addJob({
task: job.task,
useWorker: !(typeof this.tasks![job.task] === 'function'),
payload: job.payload,
isScheduled: true,
waitUntil: next.toISOString(),
notify: false
} as any)
addedFutureJobs++
totalAdded++
}
// -> No more iterations for this period or max iterations count reached
if (next.done || addedFutureJobs >= 10) {
break
}
} catch {
break
} }
/*
Awaited, because `totalAdded` is reported as what was scheduled: left to run on its
own, the line below says so before the rows exist, and a failure to insert one is
swallowed inside `addJob` with nothing to correct the count.
*/
await this.addJob({
task: job.task,
payload: job.payload,
isScheduled: true,
waitUntil,
notify: false
})
totalAdded++
} }
} }
if (totalAdded > 0) { if (totalAdded > 0) {

@ -243,6 +243,7 @@ CREATE TABLE "pageWatching" (
CREATE TABLE "pages" ( CREATE TABLE "pages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"locale" varchar(255) NOT NULL, "locale" varchar(255) NOT NULL,
"localeGroupId" uuid,
"path" varchar(255) NOT NULL, "path" varchar(255) NOT NULL,
"hash" varchar(255) NOT NULL, "hash" varchar(255) NOT NULL,
"alias" varchar(255), "alias" varchar(255),
@ -412,6 +413,7 @@ CREATE INDEX "pages_siteId_idx" ON "pages" ("siteId");--> statement-breakpoint
CREATE INDEX "pages_ts_idx" ON "pages" USING gin ("ts");--> statement-breakpoint CREATE INDEX "pages_ts_idx" ON "pages" USING gin ("ts");--> statement-breakpoint
CREATE INDEX "pages_tags_idx" ON "pages" USING gin ("tags");--> statement-breakpoint CREATE INDEX "pages_tags_idx" ON "pages" USING gin ("tags");--> statement-breakpoint
CREATE INDEX "pages_isSearchableComputed_idx" ON "pages" ("isSearchableComputed");--> statement-breakpoint CREATE INDEX "pages_isSearchableComputed_idx" ON "pages" ("isSearchableComputed");--> statement-breakpoint
CREATE UNIQUE INDEX "pages_localeGroupId_locale_idx" ON "pages" ("localeGroupId","locale");
CREATE INDEX "rateLimits_updatedAt_idx" ON "rateLimits" ("updatedAt");--> statement-breakpoint CREATE INDEX "rateLimits_updatedAt_idx" ON "rateLimits" ("updatedAt");--> statement-breakpoint
CREATE INDEX "sessions_userId_idx" ON "sessions" ("userId");--> statement-breakpoint CREATE INDEX "sessions_userId_idx" ON "sessions" ("userId");--> statement-breakpoint
CREATE UNIQUE INDEX "storage_composite_idx" ON "storage" ("siteId","module");--> statement-breakpoint CREATE UNIQUE INDEX "storage_composite_idx" ON "storage" ("siteId","module");--> statement-breakpoint

@ -2744,6 +2744,19 @@
"schema": "public", "schema": "public",
"table": "pages" "table": "pages"
}, },
{
"type": "uuid",
"typeSchema": null,
"notNull": false,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "localeGroupId",
"entityType": "columns",
"schema": "public",
"table": "pages"
},
{ {
"type": "text", "type": "text",
"typeSchema": null, "typeSchema": null,
@ -4521,6 +4534,34 @@
"schema": "public", "schema": "public",
"table": "pages" "table": "pages"
}, },
{
"nameExplicit": true,
"columns": [
{
"value": "localeGroupId",
"isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
},
{
"value": "locale",
"isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
}
],
"isUnique": true,
"where": null,
"with": "",
"method": "btree",
"concurrently": false,
"name": "pages_localeGroupId_locale_idx",
"entityType": "indexes",
"schema": "public",
"table": "pages"
},
{ {
"nameExplicit": true, "nameExplicit": true,
"columns": [ "columns": [

@ -366,6 +366,15 @@ export const pages = pgTable(
publishEndDate: timestamp(), publishEndDate: timestamp(),
config: jsonb().notNull().default({}), config: jsonb().notNull().default({}),
relations: jsonb().notNull().default([]), relations: jsonb().notNull().default([]),
/**
* The set of pages this one is a translation of: every page sharing this id is the same page in
* another locale, and the locale selector uses it to send a reader to the right one.
*
* Null for a page with no counterparts, which is most of them — a null is what makes the unique
* index below tolerate any number of unrelated pages, since postgres counts nulls as distinct.
* The group has no row of its own: it is an identity, and its membership IS this column.
*/
localeGroupId: uuid(),
content: text(), content: text(),
render: text(), render: text(),
searchContent: text(), searchContent: text(),
@ -411,7 +420,10 @@ export const pages = pgTable(
index('pages_siteId_idx').on(table.siteId), index('pages_siteId_idx').on(table.siteId),
index('pages_ts_idx').using('gin', table.ts), index('pages_ts_idx').using('gin', table.ts),
index('pages_tags_idx').using('gin', table.tags), index('pages_tags_idx').using('gin', table.tags),
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
// 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)
] ]
) )

@ -1720,6 +1720,18 @@
"editor.emoji.smileysEmotion": "Smileys & Emotion", "editor.emoji.smileysEmotion": "Smileys & Emotion",
"editor.emoji.symbols": "Symbols", "editor.emoji.symbols": "Symbols",
"editor.emoji.travelPlaces": "Travel & Places", "editor.emoji.travelPlaces": "Travel & Places",
"editor.localeRel.appliesOnSave": "Locale relations are stored when the page is saved.",
"editor.localeRel.checkFailed": "Could not check the existing relations of that page.",
"editor.localeRel.clear": "Remove this relation",
"editor.localeRel.conflictOwnLocale": "{path} is already related to {other}, which is in this page’s locale. A page can only belong to one set of locale relations.",
"editor.localeRel.conflictRow": "{path} is already related to {other} for {locale}, which this page relates to a different page. A page can only belong to one set of locale relations.",
"editor.localeRel.intro": "Define the alternate versions of this page in other locales. A reader switching language is taken to it instead of to the same path.",
"editor.localeRel.joinedSet": "Added {count} more page(s) already related to that one.",
"editor.localeRel.notSet": "No related page",
"editor.localeRel.pickerTitle": "Select the {locale} page",
"editor.localeRel.selectPage": "Select Page",
"editor.localeRel.thisPage": "This page",
"editor.localeRel.title": "Locale Relations",
"editor.markup.admonitionDanger": "Caution Admonition", "editor.markup.admonitionDanger": "Caution Admonition",
"editor.markup.admonitionImportant": "Important Admonition", "editor.markup.admonitionImportant": "Important Admonition",
"editor.markup.admonitionInfo": "Note Admonition", "editor.markup.admonitionInfo": "Note Admonition",
@ -1831,13 +1843,15 @@
"editor.props.jsLoadHint": "Execute javascript once the page is loaded", "editor.props.jsLoadHint": "Execute javascript once the page is loaded",
"editor.props.jsUnload": "Javascript - On Unload", "editor.props.jsUnload": "Javascript - On Unload",
"editor.props.jsUnloadHint": "Execute javascript before the page content is destroyed", "editor.props.jsUnloadHint": "Execute javascript before the page content is destroyed",
"editor.props.localeRelations": "Set Locale Relations",
"editor.props.localeRelationsHint": "Link this page to the same page in other locales, so a reader switching language lands on it",
"editor.props.pageProperties": "Page Properties", "editor.props.pageProperties": "Page Properties",
"editor.props.password": "Password", "editor.props.password": "Password",
"editor.props.passwordHint": "The page must be published and the user must have read access rights.", "editor.props.passwordHint": "The page must be published and the user must have read access rights.",
"editor.props.publishState": "Publishing State", "editor.props.publishState": "Publishing State",
"editor.props.published": "Published", "editor.props.published": "Published",
"editor.props.publishedHint": "Visible to all users with read access.", "editor.props.publishedHint": "Visible to all users with read access.",
"editor.props.relationAdd": "Add Relation...", "editor.props.relationAdd": "Add Page Relation...",
"editor.props.relationAddHint": "Add links to other pages in the footer (e.g. as part of a series of articles)", "editor.props.relationAddHint": "Add links to other pages in the footer (e.g. as part of a series of articles)",
"editor.props.relations": "Relations", "editor.props.relations": "Relations",
"editor.props.requirePassword": "Require Password", "editor.props.requirePassword": "Require Password",

@ -6,6 +6,21 @@ import { eq, sql } from 'drizzle-orm'
/** Where the locale packages published for this major version live. */ /** Where the locale packages published for this major version live. */
const REMOTE_BASE_URL = 'https://github.com/requarks/wiki-locales/raw/main' const REMOTE_BASE_URL = 'https://github.com/requarks/wiki-locales/raw/main'
/**
* The locale this wiki's strings are WRITTEN in, as opposed to translated into.
*
* `locales/en.json` is the source: every string in the interface is added there first, and every
* other locale is a translation of it. That makes it the one locale the copy on disk is more
* authoritative than anything else — so it is loaded on every boot rather than only when the file
* looks newer, and it is never fetched from upstream, where it would be whatever was published for
* the last release rather than what this build actually says.
*
* The practical failure this avoids: a string added to `en.json` in a build was invisible to a wiki
* whose `en` row had been touched more recently — by an earlier update run, or by an administrator
* naming it — leaving the interface showing raw keys for anything new.
*/
const SOURCE_LOCALE = 'en'
/** One entry of the remote `metadata.json`: a strings file and the hash of its contents. */ /** One entry of the remote `metadata.json`: a strings file and the hash of its contents. */
interface RemoteLocale { interface RemoteLocale {
file: string file: string
@ -111,6 +126,10 @@ function resolveDisplayNames(locales: NameableLocale[]) {
* **available**, which is a row the update task created from the remote metadata so that the locale * **available**, which is a row the update task created from the remote metadata so that the locale
* can be offered without its strings having been downloaded. `isInstalled` is the difference, and * can be offered without its strings having been downloaded. `isInstalled` is the difference, and
* only an installed locale may be activated on a site. * only an installed locale may be activated on a site.
*
* Two sources fill those rows, and they do not overlap: `locales/en.json` on disk, which is where the
* interface's strings are written, and the published packages every translation comes from. See
* `SOURCE_LOCALE` for why the first is never asked of the second.
*/ */
class Locales { class Locales {
/** /**
@ -125,6 +144,10 @@ class Locales {
* updated in the db — by the update task, or by an administrator — must not be overwritten by the * updated in the db — by the update task, or by an administrator — must not be overwritten by the
* copy that shipped with the release. The `hash` is left empty either way, since these strings did * copy that shipped with the release. The `hash` is left empty either way, since these strings did
* not come from the remote metadata; that is what makes the first update run consider them stale. * not come from the remote metadata; that is what makes the first update run consider them stale.
*
* **`en` is exempt and always loaded**, because it is not a translation of anything — see
* `SOURCE_LOCALE`. Only the strings are written, so an administrator's `customName` or `customCode`
* survives the reload.
*/ */
async refreshFromDisk({ force = false }: { force?: boolean } = {}): Promise<false | void> { async refreshFromDisk({ force = false }: { force?: boolean } = {}): Promise<false | void> {
try { try {
@ -152,13 +175,19 @@ class Locales {
continue continue
} }
// -> Skip a locale that was updated in the DB after the file was last written /*
Skip a locale that was updated in the DB after the file was last written -- except the
source locale, whose file is the authority on what the interface says and is therefore
loaded every time. Its mtime is not even read: a checkout, a container build or a copy can
leave the shipped file older than a row it must still replace.
*/
const flPath = path.join(localesPath, localeFile) const flPath = path.join(localesPath, localeFile)
const flUpdatedAt = (await stat(flPath)).mtime.toTemporalInstant() const flUpdatedAt = (await stat(flPath)).mtime.toTemporalInstant()
const dbLang = dbLocales.find((l) => l.code === code) const dbLang = dbLocales.find((l) => l.code === code)
if ( if (
dbLang && dbLang &&
!force && !force &&
code !== SOURCE_LOCALE &&
Temporal.Instant.compare(dbLang.updatedAt.toTemporalInstant(), flUpdatedAt) >= 0 Temporal.Instant.compare(dbLang.updatedAt.toTemporalInstant(), flUpdatedAt) >= 0
) { ) {
WIKI.logger.info(`Locale ${code} is newer in the DB. Skipping disk version. [ OK ]`) WIKI.logger.info(`Locale ${code} is newer in the DB. Skipping disk version. [ OK ]`)
@ -244,6 +273,12 @@ class Locales {
* *
* A locale that fails is counted and logged rather than taking the rest of the run down with it: * A locale that fails is counted and logged rather than taking the rest of the run down with it:
* one unreachable file should not leave the other fifty stale. * one unreachable file should not leave the other fifty stale.
*
* **The source locale is left alone entirely** — see `SOURCE_LOCALE`. Upstream publishes an `en`
* package like any other, and it holds the strings of whatever release it was built from; taking it
* would overwrite the ones this build ships with, which is how an interface ends up missing the
* strings for its own features. It is counted as unchanged, because from the run's point of view
* there was nothing to do.
*/ */
async updateFromRemote(): Promise<LocaleUpdateResult> { async updateFromRemote(): Promise<LocaleUpdateResult> {
WIKI.logger.info('Fetching latest localization data...') WIKI.logger.info('Fetching latest localization data...')
@ -260,6 +295,10 @@ class Locales {
const result: LocaleUpdateResult = { added: 0, updated: 0, unchanged: 0, failed: 0 } const result: LocaleUpdateResult = { added: 0, updated: 0, unchanged: 0, failed: 0 }
for (const entry of metadata) { for (const entry of metadata) {
const code = path.basename(entry.file, '.json') const code = path.basename(entry.file, '.json')
if (code === SOURCE_LOCALE) {
result.unchanged++
continue
}
try { try {
const localeInfo = localeInfoFor(code) const localeInfo = localeInfoFor(code)
const dbLang = dbLocales.find((l) => l.code === code) const dbLang = dbLocales.find((l) => l.code === code)
@ -303,6 +342,11 @@ class Locales {
* hash recorded is the one the downloaded file was published with. * hash recorded is the one the downloaded file was published with.
*/ */
async install(code: string): Promise<void> { async install(code: string): Promise<void> {
// -> There is nothing to install: it ships with the wiki and is loaded from disk on every boot,
// and downloading over it would replace this build's strings with an older release's
if (code === SOURCE_LOCALE) {
throw new Error(`Locale ${code} ships with the wiki and cannot be downloaded.`)
}
const metadata = await this.fetchRemoteMetadata() const metadata = await this.fetchRemoteMetadata()
const entry = metadata.find((e) => path.basename(e.file, '.json') === code) const entry = metadata.find((e) => path.basename(e.file, '.json') === code)
if (!entry) { if (!entry) {

@ -1,4 +1,4 @@
import { and, eq, inArray, ne, sql } from 'drizzle-orm' import { and, eq, inArray, ne, notInArray, sql } from 'drizzle-orm'
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts' import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
import { import {
CustomError, CustomError,
@ -82,6 +82,31 @@ const CONFIG_FIELDS = [
'tocDepth' 'tocDepth'
] as const ] as const
/**
* One counterpart of a page in another locale: the same page, written in a different language.
*
* A page holds at most one per locale, and the whole set is what `pages.localeGroupId` names. What is
* carried is what a reader being sent there needs — the locale to prefix with and the path to go to —
* plus the title, which is what an editing surface lists.
*/
export interface PageLocaleRelation {
locale: string
path: string
title: string
}
/**
* A counterpart as it is asked for: a locale and the path of the page in it.
*
* By path rather than by id because that is what a page picker answers with, and because a path is
* what an API client writing a translation set already has in hand. Resolved to a page on the way in —
* see `applyLocaleRelations`.
*/
export interface PageLocaleRelationInput {
locale: string
path: string
}
/** A page as the API exposes it: the columns and both blobs, flattened into one object. */ /** A page as the API exposes it: the columns and both blobs, flattened into one object. */
export interface Page { export interface Page {
id: string id: string
@ -108,6 +133,11 @@ export interface Page {
/** 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
relations: any[] relations: any[]
/**
* This page in the other locales, as far as the requester may see them. Empty for a page that is
* not part of a locale group, and for one whose whole group is pages this requester may not read.
*/
localeRelations: PageLocaleRelation[]
tags: string[] tags: string[]
toc: TocNode[] toc: TocNode[]
render: string render: string
@ -153,6 +183,13 @@ export interface PageInput {
isSearchable?: boolean isSearchable?: boolean
password?: string password?: string
relations?: any[] relations?: any[]
/**
* The page's counterparts in other locales, stating the whole set rather than adding to it: a locale
* left out of the list is a locale this page has no counterpart in, and a page that was in the group
* and is not in the list leaves it. Absent means "leave the group alone", which is what a save that
* is not about translations sends.
*/
localeRelations?: PageLocaleRelationInput[]
tags?: string[] tags?: string[]
allowComments?: boolean allowComments?: boolean
allowContributions?: boolean allowContributions?: boolean
@ -314,6 +351,9 @@ class Pages {
...(withPassword ? { password: row.password } : {}), ...(withPassword ? { password: row.password } : {}),
isLocked: locked, isLocked: locked,
relations: locked ? [] : (row.relations ?? []), relations: locked ? [] : (row.relations ?? []),
// -> Not withheld from a locked page: which languages a page exists in is not what a password
// covers, and the lock screen is where a reader most needs to be able to switch to one
localeRelations: row.localeRelations ?? [],
tags: row.tags ?? [], tags: row.tags ?? [],
toc: locked ? [] : (row.toc ?? []), toc: locked ? [] : (row.toc ?? []),
render: locked ? '' : (row.render ?? ''), render: locked ? '' : (row.render ?? ''),
@ -339,6 +379,295 @@ class Pages {
} }
} }
/**
* The other pages of a locale group: this page, written in every other language it exists in.
*
* Answered from the group id the page already carries, so a page that is not part of one costs no
* query at all — which is nearly every page on nearly every site.
*
* `publicOnly` narrows it the same way it narrows the page itself: a draft translation is not
* something to offer a reader who could not open it. It is what makes the locale selector's list of
* languages the list of languages this reader can actually reach.
*/
async localeRelationsFor(
siteId: string,
{
localeGroupId,
id,
publicOnly = false
}: { localeGroupId: string | null; id: string; publicOnly?: boolean }
): Promise<PageLocaleRelation[]> {
if (!localeGroupId) {
return []
}
const conditions = [
eq(pagesTable.siteId, siteId),
eq(pagesTable.localeGroupId, localeGroupId),
ne(pagesTable.id, id)
]
if (publicOnly) {
conditions.push(eq(pagesTable.publishState, 'published'))
}
return await WIKI.db
.select({ locale: pagesTable.locale, path: pagesTable.path, title: pagesTable.title })
.from(pagesTable)
.where(and(...conditions))
.orderBy(pagesTable.locale)
}
/**
* The locale group a page addressed by path belongs to, for a client about to join it.
*
* What the page properties panel asks before it accepts a chosen page: a page that is already the
* French version of something comes back with the rest of that set, which is what the panel fills
* its other rows in from — and what tells it, before anything is saved, that the set already has a
* page for a locale it has spoken for.
*
* @returns The whole group INCLUDING the page asked about, or null when there is no page there.
* A page with no counterparts answers with just itself.
*/
async localeGroupAt(
siteId: string,
{ locale, path }: { locale?: string; path: string }
): Promise<{
page: PageLocaleRelation & { id: string }
relations: PageLocaleRelation[]
} | null> {
// -> The site's own default when the caller did not say, as everything else addressing a page by
// path does: a path alone names the primary locale's page
const page = await this.findByPath(siteId, locale || this.defaultLocale(siteId), path)
if (!page) {
return null
}
return {
page: { id: page.id, locale: page.locale, path: page.path, title: page.title },
relations: await this.localeRelationsFor(siteId, {
localeGroupId: page.localeGroupId,
id: page.id
})
}
}
/** One page of a site, by the locale and path that address it. */
private async findByPath(
siteId: string,
locale: string,
path: string
): Promise<{
id: string
locale: string
path: string
title: string
localeGroupId: string | null
} | null> {
const rows = await WIKI.db
.select({
id: pagesTable.id,
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
localeGroupId: pagesTable.localeGroupId
})
.from(pagesTable)
.where(
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.locale, locale),
// -> By hash, which is the indexed way a page is addressed by path everywhere else here
eq(pagesTable.hash, generatePathHash(path || 'home'))
)
)
.limit(1)
return rows[0] ?? null
}
/**
* Set the locale group a page belongs to: which pages are this page in another language.
*
* **The list states the whole group, not this page's half of it.** The panel that sends it shows
* every active locale with the page filling that slot, so a locale left empty is a locale the set
* has no page for — and a page that was in the group and is not in the list leaves it. There is one
* group per set of translations and any member edits it, which is what makes "the same page in
* French" mean the same thing read from either side.
*
* **Picking a page that already belongs to a set joins that set**, bringing its other members with
* it: attaching an English page to an existing French/German pair is how the third language gets
* added, and requiring the pair to be broken up first would be a worse way to say it. The client is
* expected to have asked `localeGroupAt` and shown the author what they are joining.
*
* Which leaves one thing that cannot be reconciled and is refused: a set being joined that already
* holds a page for a locale this request speaks for — most importantly for the saving page's OWN
* locale, which is the case of a French page that is already some other English page's translation.
*
* @param page The page being saved, as it stands in the database.
* @param wanted Its counterparts. Empty dissolves the group, leaving every member unrelated.
*/
private async applyLocaleRelations(
siteId: string,
page: { id: string; locale: string; localeGroupId: string | null },
wanted: PageLocaleRelationInput[]
): Promise<void> {
/*
The group as it will stand, by locale. Seeded with the saving page, which occupies its own
locale's slot and is why nothing else may claim it.
*/
const desired = new Map<string, { id: string; path: string; localeGroupId: string | null }>()
desired.set(page.locale, { id: page.id, path: '', localeGroupId: page.localeGroupId })
for (const entry of wanted ?? []) {
const locale = (entry?.locale ?? '').trim()
// -> A page is its own entry for its own locale, so a row naming it is agreement rather than an
// instruction. The panel sends that row read-only for exactly this reason.
if (!locale || locale === page.locale) {
continue
}
const path = normalizePagePath(entry?.path ?? '')
const target = await this.findByPath(siteId, locale, path)
if (!target) {
throw new CustomError(
'pageLocaleRelationNotFound',
`There is no page at "${path}" in locale ${locale} to relate this page to.`
)
}
if (target.id === page.id) {
continue
}
const claimed = desired.get(locale)
if (claimed && claimed.id !== target.id) {
throw new CustomError(
'pageLocaleRelationDuplicate',
`Two different pages were given for locale ${locale}. A page has one counterpart per locale.`
)
}
desired.set(locale, { id: target.id, path: target.path, localeGroupId: target.localeGroupId })
}
/*
Every group this touches: the one the page is in, and the one behind each page it names. They are
about to become one group, or — where the page is leaving — none.
Held against the page that brought each one in, so a set that cannot be joined is refused by
naming the page the author actually chose rather than some third page they have never heard of.
Empty for the group this page is already in, which nobody chose.
*/
const involved = new Map<string, string>()
if (page.localeGroupId) {
involved.set(page.localeGroupId, '')
}
for (const member of desired.values()) {
if (member.localeGroupId && !involved.has(member.localeGroupId)) {
involved.set(member.localeGroupId, member.path)
}
}
for (const [groupId, chosenPath] of involved) {
const members = await WIKI.db
.select({
id: pagesTable.id,
locale: pagesTable.locale,
path: pagesTable.path,
localeGroupId: pagesTable.localeGroupId
})
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.localeGroupId, groupId)))
for (const member of members) {
const claimed = desired.get(member.locale)
if (claimed?.id === member.id) {
continue
}
if (groupId === page.localeGroupId) {
// -> A member of this page's own group that the request does not name: the author took it
// out of the set, or pointed that locale at a different page, so it is dropped below
continue
}
if (claimed) {
throw new CustomError(
'pageLocaleRelationConflict',
`"${chosenPath}" already belongs to a set of translations whose ${member.locale} page is "${member.path}". A page can belong to only one set.`,
409
)
}
// -> The rest of the set being joined comes along: these pages and this one are the same page
desired.set(member.locale, {
id: member.id,
path: member.path,
localeGroupId: member.localeGroupId
})
}
}
/*
A group of one is no group. Written as null rather than left standing so that "this page has no
counterparts" is one state in the database instead of two, and so the unique index above never
has to hold a row nothing else can join.
*/
const dissolve = desired.size < 2
const groupId = dissolve
? null
: (page.localeGroupId ??
[...desired.values()].find((m) => m.localeGroupId)?.localeGroupId ??
crypto.randomUUID())
const memberIds = [...desired.values()].map((m) => m.id)
// -> Everything that was in one of these groups and is not in the set any more, first: the unique
// index is on (group, locale), so a page has to vacate a slot before another can take it
const evicted = [...involved.keys()]
if (evicted.length > 0) {
await WIKI.db
.update(pagesTable)
.set({ localeGroupId: null })
.where(
and(
eq(pagesTable.siteId, siteId),
inArray(pagesTable.localeGroupId, evicted),
memberIds.length > 0 && !dissolve ? notInArray(pagesTable.id, memberIds) : sql`true`
)
)
}
if (!dissolve) {
await WIKI.db
.update(pagesTable)
.set({ localeGroupId: groupId })
.where(and(eq(pagesTable.siteId, siteId), inArray(pagesTable.id, memberIds)))
}
}
/**
* Take one page out of its locale group, leaving the rest of the set related to each other.
*
* For a page that stops being the version it was: a move across locales, where what it is the
* translation OF is no longer a question this group answers. Distinct from setting the relations to
* nothing, which is the group being dissolved by whoever was editing it.
*/
private async detachFromLocaleGroup(siteId: string, id: string): Promise<void> {
const rows = await WIKI.db
.select({ localeGroupId: pagesTable.localeGroupId })
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.id, id)))
.limit(1)
const groupId = rows[0]?.localeGroupId
if (!groupId) {
return
}
await WIKI.db
.update(pagesTable)
.set({ localeGroupId: null })
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.id, id)))
// -> And the group goes with it if this was the last relation anybody had, for the same reason
// `applyLocaleRelations` never writes a group of one
const remaining = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.localeGroupId, groupId)))
if (remaining.length < 2) {
await WIKI.db
.update(pagesTable)
.set({ localeGroupId: null })
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.localeGroupId, groupId)))
}
}
/** /**
* A single page, by ID or by the hash of its path. * A single page, by ID or by the hash of its path.
* *
@ -423,7 +752,13 @@ class Pages {
...row.page, ...row.page,
authorName: row.authorName, authorName: row.authorName,
navigationId: row.navigationId, navigationId: row.navigationId,
navigationMode: row.navigationMode navigationMode: row.navigationMode,
// -> A second query only for a page that is part of a set, which is a column read away
localeRelations: await this.localeRelationsFor(siteId, {
localeGroupId: row.page.localeGroupId,
id: row.page.id,
publicOnly
})
}, },
{ withContent, withPassword, locked: Boolean(row.page.password) && !isUnlocked } { withContent, withPassword, locked: Boolean(row.page.password) && !isUnlocked }
) )
@ -583,6 +918,18 @@ class Pages {
const page = inserted[0] const page = inserted[0]
try { try {
/*
Before the tree entry, so that both live inside the same rollback: a translation set that
cannot be joined -- the French page is already somebody else's French version -- has to refuse
the whole save, and an author told their page was not created must not find it created.
*/
if (input.localeRelations !== undefined) {
await this.applyLocaleRelations(
siteId,
{ id: page.id, locale, localeGroupId: null },
input.localeRelations
)
}
await WIKI.models.tree.addPage({ await WIKI.models.tree.addPage({
id: page.id, id: page.id,
parentPath: pathParts.slice(0, -1).join('/'), parentPath: pathParts.slice(0, -1).join('/'),
@ -643,6 +990,19 @@ class Pages {
return null return null
} }
/*
First, before a single column is written: joining a set can be refused, and a save that is going
to fail has to fail before it has changed the page. The two are independent otherwise -- a locale
group is not a field of the page it relates.
*/
if (patch.localeRelations !== undefined) {
await this.applyLocaleRelations(
siteId,
{ id: existing.id, locale: existing.locale, localeGroupId: existing.localeGroupId },
patch.localeRelations
)
}
const values: Record<string, any> = { updatedAt: sql`now()` } const values: Record<string, any> = { updatedAt: sql`now()` }
let treeTitle: string | null = null let treeTitle: string | null = null
// -> Which editor authored a page is not something a save may change, so the row is the authority // -> Which editor authored a page is not something a save may change, so the row is the authority
@ -835,6 +1195,16 @@ class Pages {
}) })
} }
/*
A page that changes locale stops being the version it was, so it leaves its translation set --
and has to, before the write: the set holds one page per locale, and the page arriving in a
locale another member already covers is a pair the unique index would refuse. The rest of the
set stays related to each other.
*/
if (newLocale !== page.locale) {
await this.detachFromLocaleGroup(siteId, id)
}
await WIKI.db await WIKI.db
.update(pagesTable) .update(pagesTable)
.set({ .set({

@ -338,6 +338,7 @@ import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/compo
import { dialog } from '@/composables/dialog' import { dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { useMinWidth } from '@/composables/screen' import { useMinWidth } from '@/composables/screen'
import { isVisible } from '@/helpers/anchors'
import { assetPath } from '@/helpers/assets' import { assetPath } from '@/helpers/assets'
import { blockMarkdown } from '@/helpers/blocks' import { blockMarkdown } from '@/helpers/blocks'
import { blockOpeningLine, blockValues, findBlocks } from '@/helpers/markdownBlocks' import { blockOpeningLine, blockValues, findBlocks } from '@/helpers/markdownBlocks'
@ -1085,29 +1086,80 @@ function markDisabledBlock(el) {
} }
/** /**
* Open the tabset panel the caret is in. * Open the tabset panel the caret is in, and every panel that one sits inside.
* *
* Which is the useful answer, and a different question from "which panel was open before": an author * Which is the useful answer, and a different question from "which panel was open before": an author
* writing inside the second panel of a tabset is telling us plainly which one they are looking at. The * writing inside the second panel of a tabset is telling us plainly which one they are looking at. The
* source line is matched against the panel ranges of the same parse that built the preview -- see * source line is matched against the panel ranges of the same parse that built the preview -- see
* `getTabAtLine` -- and the panel is opened through the block's own `active` property. * `getTabsAtLine` -- and each panel is opened through its block's own `active` property.
* *
* Silent about everything it does not find: a caret outside every tabset leaves them all as they were, * Silent about everything it does not find: a caret outside every tabset leaves them all as they were,
* and so does a render that has not landed yet. * and so does a render that has not landed yet.
*
* @returns A promise settling once the blocks have drawn the panels. A block applies which panel is
* open on its own update, so a caller about to scroll to something inside one has to wait:
* until then the panel is still `display: none` and there is no box to scroll to.
*/ */
function syncPreviewTabs() { function syncPreviewTabs() {
const container = editorPreviewContainerRef.value const container = editorPreviewContainerRef.value
if (!container) { if (!container) {
return return Promise.resolve()
} }
const at = md.getTabAtLine(editor.getPosition().lineNumber) const tabsets = container.querySelectorAll('block-tabs')
if (!at) { const drawn = []
return for (const at of md.getTabsAtLine(editor.getPosition().lineNumber)) {
const tabset = tabsets[at.tabset]
if (tabset) {
tabset.active = at.tab
// -> Absent until the component has been fetched and the element upgraded; setting `active` on
// one that has not been is still worth doing, and is why `processContent` asks again later
drawn.push(tabset.updateComplete ?? Promise.resolve())
}
} }
const tabset = container.querySelectorAll('block-tabs')[at.tabset] return Promise.all(drawn)
if (tabset) { }
tabset.active = at.tab
/**
* The element in the preview to scroll to for a source line.
*
* Read off the DOM rather than from a map collected during the render, because what matters is what
* was actually drawn and where it ended up: a token can be parsed and then not rendered (a tight
* list's paragraphs), and a footnote's definition is rendered at the foot of the page rather than on
* the line it was written on. Lines are compared as numbers for the same reason -- the greatest line
* at or before the caret, wherever in the page its element happens to sit.
*
* Two things narrow the search, and both are about tabs:
*
* - **Scoped to the panel the line is in**, where it is in one. Every panel of a tabset covers the
* same lines of the preview, and the panels that are closed hold most of the page's lines -- so a
* search across the whole preview would answer a line inside a panel with an element outside the
* tabset entirely, and scrolling there is what took the author away from what they were writing.
* - **Only what has a box.** A closed panel's content is stamped with its lines like everything else
* and cannot be scrolled to; picking it would mean scrolling nowhere at all, having passed over the
* element that could have been reached.
*
* The panel itself stands in when nothing inside it carries a line -- a lone paragraph, a table, a
* list -- which puts the top of the tabset in view, and is as close as there is to get.
*/
function previewAnchorFor(container, line) {
// -> The last of the chain is the panel the line is written in; the ones before it are its ancestors,
// which `syncPreviewTabs` has already opened
const at = md.getTabsAtLine(line).at(-1)
const tabset = at ? container.querySelectorAll('block-tabs')[at.tabset] : null
const panel = tabset?.querySelectorAll(':scope > block-tab')[at.tab] ?? null
let best = null
let bestLine = 0
for (const el of (panel ?? container).querySelectorAll('[data-line]')) {
const elLine = Number(el.dataset.line)
// -> Strictly greater, so that of two elements starting on the same line -- a blockquote and the
// paragraph opening it -- the outer one wins, which is the one whose top is the section's top
if (elLine <= line && elLine > bestLine && isVisible(el)) {
best = el
bestLine = elLine
}
} }
return best ?? panel
} }
function processContent(newContent) { function processContent(newContent) {
@ -1496,33 +1548,28 @@ onMounted(async () => {
// -> Handle cursor movement // -> Handle cursor movement
editor.onDidChangeCursorPosition( editor.onDidChangeCursorPosition(
debounce((ev) => { debounce(async (ev) => {
if (!state.previewScrollSync || !state.previewShown) { if (!state.previewScrollSync || !state.previewShown) {
return return
} }
// -> Moving the caret into another panel opens it, the same as typing in one does /*
syncPreviewTabs() Moving the caret into another panel opens it, the same as typing in one does -- and is AWAITED,
because until the block has drawn it the panel is still `display: none`, and everything below
aims at an element inside it. Scrolling to something with no box does nothing whatsoever, which
is what made the sync appear to ignore a caret inside a tab that was not already open.
*/
await syncPreviewTabs()
// -> Read again rather than reused from before the await: the caret is where it is now
const currentLine = editor.getPosition().lineNumber const currentLine = editor.getPosition().lineNumber
const container = editorPreviewContainerRef.value
if (!container) {
return
}
if (currentLine < 3) { if (currentLine < 3) {
editorPreviewContainerRef.value.scrollTo({ top: 0, behavior: 'smooth' }) container.scrollTo({ top: 0, behavior: 'smooth' })
} else { return
const exactEl = editorPreviewContainerRef.value.querySelector(
`[data-line='${currentLine}']`
)
if (exactEl) {
exactEl.scrollIntoView(SYNC_SCROLL)
} else {
const closestLine = md.getClosestPreviewLine(currentLine)
if (closestLine) {
const closestEl = editorPreviewContainerRef.value.querySelector(
`[data-line='${closestLine}']`
)
if (closestEl) {
closestEl.scrollIntoView(SYNC_SCROLL)
}
}
}
} }
previewAnchorFor(container, currentLine)?.scrollIntoView(SYNC_SCROLL)
}, 500) }, 500)
) )

@ -6,9 +6,10 @@
<span>{{ props.title ?? t('linkPicker.title') }}</span> <span>{{ props.title ?? t('linkPicker.title') }}</span>
<w-space /> <w-space />
<!-- -> Only where there is a choice to make: one active locale is most wikis, and a button <!-- -> Only where there is a choice to make: one active locale is most wikis, and a button
that can only say `en` is noise on all of them --> that can only say `en` is noise on all of them -- and never when the caller has asked
for one locale, where the picker is filling a slot that belongs to it -->
<w-btn <w-btn
v-if="siteStore.locales.active.length > 1" v-if="siteStore.locales.active.length > 1 && !props.lockLocale"
class="acrylic-btn -my-2" class="acrylic-btn -my-2"
flat flat
dense dense
@ -201,6 +202,15 @@ const props = defineProps({
locale: { locale: {
type: String, type: String,
default: null default: null
},
/**
* Whether the locale above is fixed. For a caller picking the page that fills one locale's slot —
* the translation set in the page properties panel — where a page from another locale is not a
* different answer but a wrong one, so the switcher is not offered at all.
*/
lockLocale: {
type: Boolean,
default: false
} }
}) })
@ -404,6 +414,13 @@ function selectItem(item) {
function submit() { function submit() {
onDialogOK({ onDialogOK({
href: href.value, href: href.value,
/*
The page as the wiki addresses it, beside the href built from it: a caller storing a relation to
a page needs the path and the locale as two values, and taking them back apart from an href means
knowing which prefixes are locales. Empty for the URL tab, which names no page of this wiki.
*/
path: state.currentTab === 'page' ? state.path : '',
locale: state.currentTab === 'page' ? state.locale : '',
/* /*
Which tab answered, so a caller that stores the two kinds differently does not have to work it Which tab answered, so a caller that stores the two kinds differently does not have to work it
out from the string afterwards. It cannot be worked out reliably: `/help` is a page of this wiki out from the string afterwards. It cannot be worked out reliably: `/help` is a page of this wiki

@ -38,6 +38,7 @@ import { useRoute, useRouter } from 'vue-router'
import { splitLocalePath } from '@/helpers/pagePaths' import { splitLocalePath } from '@/helpers/pagePaths'
import { useCommonStore } from '@/stores/common' import { useCommonStore } from '@/stores/common'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
// PROPS // PROPS
@ -81,6 +82,7 @@ const emit = defineEmits(['select'])
// STORES // STORES
const commonStore = useCommonStore() const commonStore = useCommonStore()
const pageStore = usePageStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
// ROUTER // ROUTER
@ -96,6 +98,18 @@ const { t } = useI18n()
const currentLocale = computed(() => props.selected ?? commonStore.locale) const currentLocale = computed(() => props.selected ?? commonStore.locale)
/**
* Whether the route is the page the store holds, which is what makes its relations this reader's.
*
* Compared as the wiki stores a path rather than as the router hands it over: the URL carries a locale
* prefix and a leading slash, and the site root is the `home` page.
*/
const onCurrentPage = computed(() => {
const current = splitLocalePath(route.path, siteStore.localePrefixes)
const path = (current?.path ?? route.path).replace(/^\/+/, '').replace(/\/+$/, '')
return (path || 'home') === (pageStore.path || 'home')
})
// METHODS // METHODS
/** /**
@ -106,6 +120,12 @@ const currentLocale = computed(() => props.selected ?? commonStore.locale)
* it: that setting decides whether the PRIMARY locale's pages carry a prefix, not whether the others * it: that setting decides whether the PRIMARY locale's pages carry a prefix, not whether the others
* can be reached, and with it off switching to French did nothing at all. * can be reached, and with it off switching to French did nothing at all.
* *
* **Where the page says which page it is in that locale, that is where the reader goes.** A
* translation rarely lives at the same path as its original -- a wiki writes its French pages in
* French -- so the path is only a guess, and one the author can correct: see `localeRelations` on the
* page store, set from the page properties panel. Without one the path is carried across unchanged,
* which is the best guess available and is what a site whose translations are filed alike relies on.
*
* A menu choosing WITHIN a screen says `navigate: false` and listens for `select` instead: there the * A menu choosing WITHIN a screen says `navigate: false` and listens for `select` instead: there the
* locale is what a listing is filtered by rather than where the reader is going, and the interface * locale is what a listing is filtered by rather than where the reader is going, and the interface
* language is not the picker's to change. * language is not the picker's to change.
@ -121,8 +141,21 @@ function pick(code) {
// -> An empty prefix is the primary locale on a site that does not bracket its URLs, where the path // -> An empty prefix is the primary locale on a site that does not bracket its URLs, where the path
// alone IS the address; anything else carries its short code in front // alone IS the address; anything else carries its short code in front
const prefix = siteStore.localeUrlPrefix(code) const prefix = siteStore.localeUrlPrefix(code)
/*
Only when the reader is on the page the relations belong to. The menu is in the shell, so it is on
screen over the search results and the profile as well, where the store still holds whichever page
was read last -- and being sent to that page's translation instead of to this screen in the other
language would be the wrong kind of helpful.
*/
const related = onCurrentPage.value
? pageStore.localeRelations.find((rel) => rel.locale === code)
: null
router.push({ router.push({
path: prefix ? `${prefix}${path === '/' ? '' : path}` : path, path: related
? `${prefix}/${related.path}`
: prefix
? `${prefix}${path === '/' ? '' : path}`
: path,
query: route.query, query: route.query,
hash: route.hash hash: route.hash
}) })

@ -58,7 +58,7 @@
<router-link <router-link
v-if="item.isPage" v-if="item.isPage"
class="browse-menu-target" class="browse-menu-target"
:to="`/${item.path}`" :to="`${localePrefix}/${item.path}`"
@click="menu?.hide()"> @click="menu?.hide()">
<w-icon :name="item.icon || `la:file-alt`" size="xs" class="shrink-0 opacity-70" /> <w-icon :name="item.icon || `la:file-alt`" size="xs" class="shrink-0 opacity-70" />
<span class="truncate">{{ item.title }}</span> <span class="truncate">{{ item.title }}</span>
@ -152,6 +152,11 @@ const EMPTY_LEVEL = { title: '', items: [], truncated: false }
const state = reactive({ const state = reactive({
/** Slash-separated path of the folder being listed. Empty at the site root. */ /** Slash-separated path of the folder being listed. Empty at the site root. */
path: '', path: '',
/**
* The locale the levels held here were listed in. Captured when the menu opens rather than read per
* row: a level is one locale's tree, so what it lists and where its rows lead have to agree on which.
*/
locale: '',
/** Which way the next level slides in from. */ /** Which way the next level slides in from. */
direction: 'forward', direction: 'forward',
isLoading: false, isLoading: false,
@ -165,12 +170,23 @@ const level = computed(() => state.levels[state.path] ?? EMPTY_LEVEL)
const isRoot = computed(() => !state.path) const isRoot = computed(() => !state.path)
/**
* What a row's link starts with, for the locale this level was listed in.
*
* `tree/browse` answers for one locale, so every page here is read at that locale's address — which on
* a site that brackets its URLs by locale is a prefixed one. Without it each row pointed at the
* primary locale's copy of the path: a reader browsing the French tree was sent to the English page,
* or to a path that has no page in the primary locale at all.
*/
const localePrefix = computed(() => siteStore.localeUrlPrefix(state.locale))
// METHODS // METHODS
/** Opens on the folder holding the current page, with whatever was cached from last time dropped. */ /** Opens on the folder holding the current page, with whatever was cached from last time dropped. */
function onShow() { function onShow() {
state.levels = {} state.levels = {}
state.direction = 'forward' state.direction = 'forward'
state.locale = pageStore.locale
state.path = pageStore.folderPath state.path = pageStore.folderPath
load(state.path) load(state.path)
} }
@ -192,7 +208,7 @@ async function load(path) {
const data = await API_CLIENT.get(`sites/${siteStore.id}/tree/browse`, { const data = await API_CLIENT.get(`sites/${siteStore.id}/tree/browse`, {
searchParams: { searchParams: {
path, path,
locale: pageStore.locale locale: state.locale
} }
}).json() }).json()
state.levels[path] = { state.levels[path] = {

@ -648,6 +648,9 @@ function branchFrom(version) {
throw new Error(resp?.message || 'An unexpected error occured.') throw new Error(resp?.message || 'An unexpected error occured.')
} }
notify({ type: 'positive', message: t('history.branchSuccess') }) notify({ type: 'positive', message: t('history.branchSuccess') })
// -> This page went up with the version's tags, so a tag no page carried any more is back; the
// tag fields have to hear about it, the same as after a save
siteStore.staleTags()
close() close()
router.push(`/${page.path}`) router.push(`/${page.path}`)
} catch (err) { } catch (err) {

@ -0,0 +1,319 @@
<template>
<w-card class="page-locale-relations" style="width: 620px; max-width: 92vw">
<w-toolbar class="bg-primary text-white">
<div class="text-subtitle2">{{ t('editor.localeRel.title') }}</div>
</w-toolbar>
<w-card-section>
<div class="text-caption pb-4 text-black/60 dark:text-white/70">
{{ t('editor.localeRel.intro') }}
</div>
<w-list class="rounded bg-white dark:bg-black/20" separator bordered>
<w-item v-for="row of state.rows" :key="`loc-` + row.locale">
<w-item-section side>
<!-- -> The short code as an avatar, as the locale selector draws it, so a row here is
recognisable as the same locale the reader picks in the sidebar -->
<w-avatar
rounded
:color="row.isSelf ? `secondary` : `primary`"
text-color="white"
size="sm">
<div class="text-caption uppercase">
<strong>{{ row.language }}</strong>
</div>
</w-avatar>
</w-item-section>
<w-item-section>
<w-item-label
><strong>{{ row.displayName }}</strong></w-item-label
>
<!-- -> The page's own row says which slot it fills rather than offering to change it: it
IS the entry for its locale, and pointing that slot elsewhere would be a move -->
<w-item-label caption v-if="row.isSelf">{{
t('editor.localeRel.thisPage')
}}</w-item-label>
<w-item-label caption v-else-if="row.title">{{ row.title }}</w-item-label>
<w-item-label caption v-else>{{ t('editor.localeRel.notSet') }}</w-item-label>
</w-item-section>
<w-item-section side>
<div class="text-caption font-robotomono max-w-56 truncate">
{{ row.path ? `/${row.path}` : '—' }}
</div>
</w-item-section>
<w-item-section side v-if="!row.isSelf">
<div class="flex flex-nowrap items-center gap-1">
<w-btn
:label="t(`editor.localeRel.selectPage`)"
color="primary"
outline
dense
no-caps
padding="xs sm"
:loading="state.checking === row.locale"
@click="selectPage(row)" />
<!-- -> Only where there is something to take away, so the control appears with the
relation it clears rather than sitting dead on every empty row -->
<w-btn
v-if="row.path"
icon="la:times"
dense
flat
padding="none"
:aria-label="t(`editor.localeRel.clear`)"
@click="clearRow(row)">
<w-tooltip>{{ t('editor.localeRel.clear') }}</w-tooltip>
</w-btn>
</div>
</w-item-section>
</w-item>
</w-list>
<div class="text-caption pt-4 text-black/60 dark:text-white/70">
{{ t('editor.localeRel.appliesOnSave') }}
</div>
</w-card-section>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
icon="la:times"
:label="t(`common.actions.discard`)"
color="grey-7"
padding="xs md"
flat
@click="$emit('close')" />
<w-btn
icon="la:check"
:label="t(`common.actions.apply`)"
unelevated
color="primary"
padding="xs md"
@click="saveAndClose" />
</w-card-actions>
</w-card>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { onMounted, reactive } from 'vue'
import { dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import LinkPickerDialog from './LinkPickerDialog.vue'
/**
* The set of pages this page belongs to: itself, and the same page written in the other locales.
*
* One row per active locale, because the question is asked of the locales a site HAS rather than of
* the relations it happens to hold -- an empty row is a language this page has no counterpart in, and
* is how one is added. The row for the page's own locale is the page itself and is not a choice.
*
* Nothing here writes to the server. The set is staged on the page store and goes up with the page,
* so discarding the edit discards the relations with it -- which is what every other field in the
* properties panel does. What IS asked of the server is whether a chosen page is free to be related:
* see `selectPage`.
*/
// STORES
const editorStore = useEditorStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
/** One row per locale: `{ locale, language, displayName, isSelf, path, title }`. */
rows: [],
/** The locale whose row is waiting on the server, which is what puts that button in a spinner. */
checking: null
})
// METHODS
/** The row for a locale, or undefined for a locale this site does not have. */
function rowFor(locale) {
return state.rows.find((r) => r.locale === locale)
}
/**
* Put a page in a locale's row.
*
* The picker is opened ON that locale and locked to it: a page from another language is not a
* different answer to "which page is the French one", it is a wrong one.
*/
function selectPage(row) {
dialog({
component: LinkPickerDialog,
componentProps: {
title: t('editor.localeRel.pickerTitle', { locale: row.displayName }),
okLabel: t('common.actions.select'),
locale: row.locale,
lockLocale: true,
newTabOption: false,
initialHref: row.path ? `/${row.path}` : ''
}
}).onOk(({ path, locale }) => {
if (!path || locale !== row.locale) {
return
}
adopt(row, path)
})
}
/**
* Take a chosen page, and with it whatever set it already belongs to.
*
* The server is asked before the row is filled in, because a page that is already some other page's
* counterpart cannot become this one's: a page belongs to one set. Where the set it is in can be
* joined, its other members are filled in here too -- they are the same page as well, and the author
* should see what they are joining before they save rather than discover it afterwards.
*/
async function adopt(row, path) {
state.checking = row.locale
let group
try {
group = await API_CLIENT.get(`sites/${siteStore.id}/pages/locale-relations`, {
searchParams: { path, locale: row.locale }
}).json()
} catch (err) {
notify({
type: 'negative',
message: t('editor.localeRel.checkFailed'),
caption: apiErrorMessage(err)
})
return
} finally {
state.checking = null
}
/*
Every page of the set being joined, checked against what this dialog already says before anything
is changed: a set that cannot be joined must leave the rows as they were, or the author would be
left holding a selection the save is going to refuse.
*/
const joining = group?.relations ?? []
for (const rel of joining) {
if (rel.locale === pageStore.locale && rel.path !== pageStore.path) {
// -> The case this check exists for: the chosen page is already the translation of another page
// in THIS page's language, so there is no slot here for this page to take
notify({
type: 'negative',
message: t('editor.localeRel.conflictOwnLocale', {
path: `/${path}`,
locale: row.displayName,
other: `/${rel.path}`
})
})
return
}
const existing = rowFor(rel.locale)
if (existing && !existing.isSelf && existing.path && existing.path !== rel.path) {
notify({
type: 'negative',
message: t('editor.localeRel.conflictRow', {
path: `/${path}`,
locale: existing.displayName,
other: `/${rel.path}`
})
})
return
}
}
row.path = path
row.title = group?.page?.title ?? ''
// -> The rest of the set, into the rows that have nothing in them. A locale the set covers and this
// site no longer has is left out: there is no row to put it in, and the save states rows.
let adopted = 0
for (const rel of joining) {
const target = rowFor(rel.locale)
if (!target || target.isSelf || target.path === rel.path) {
continue
}
target.path = rel.path
target.title = rel.title ?? ''
adopted++
}
if (adopted > 0) {
notify({
type: 'info',
message: t('editor.localeRel.joinedSet', { count: adopted })
})
}
}
function clearRow(row) {
row.path = ''
row.title = ''
}
/**
* Hand the set to the page store, which is what the save sends.
*
* Every row that names a page, the page's own excepted: it occupies its own locale's slot by being the
* page being saved, and sending it back would be sending the server its own answer.
*
* And the editor is told it has an unsaved change, which is not automatic: `hasPendingChanges` is what
* enables Save Changes, and writing the store alone leaves that button disabled -- so the set was
* staged, could not be saved, and was thrown away when the editor closed. The same two steps the icon
* and the title take in `PageHeader`.
*/
function saveAndClose() {
pageStore.localeRelations = state.rows
.filter((row) => !row.isSelf && row.path)
.map((row) => ({ locale: row.locale, path: row.path, title: row.title }))
editorStore.lastChangeTimestamp = Temporal.Now.instant()
emit('close')
}
// EMITS
const emit = defineEmits(['close'])
// MOUNTED
onMounted(() => {
const held = pageStore.localeRelations ?? []
const rows = siteStore.locales.active.map((locale) => {
const relation = held.find((rel) => rel.locale === locale.code)
const isSelf = locale.code === pageStore.locale
return {
locale: locale.code,
language: locale.language,
displayName: locale.displayName,
isSelf,
path: isSelf ? pageStore.path : (relation?.path ?? ''),
title: isSelf ? pageStore.title : (relation?.title ?? '')
}
})
/*
And a relation to a locale the site no longer has active, which would otherwise be dropped by the
save without anybody saying so -- the rows ARE the set. It gets a row like any other, so it can be
seen and taken off deliberately.
*/
for (const relation of held) {
if (!rows.some((row) => row.locale === relation.locale)) {
rows.push({
locale: relation.locale,
language: relation.locale.split('-')[0],
displayName: relation.locale,
isSelf: false,
path: relation.path,
title: relation.title ?? ''
})
}
}
state.rows = rows
})
</script>

@ -150,6 +150,21 @@
@click="newRelation"> @click="newRelation">
<w-tooltip>{{ t('editor.props.relationAddHint') }}</w-tooltip> <w-tooltip>{{ t('editor.props.relationAddHint') }}</w-tooltip>
</w-btn> </w-btn>
<!--
A different kind of relation, which is why it is its own button rather than another position
in the one above: a page relation is a link this page draws in its footer, while a locale
relation says that another page IS this page, written in another language.
-->
<w-btn
class="mt-2 w-full"
:label="t(`editor.props.localeRelations`)"
icon="la:language"
no-caps
unelevated
color="secondary"
@click="state.showLocaleRelationsDialog = true">
<w-tooltip>{{ t('editor.props.localeRelationsHint') }}</w-tooltip>
</w-btn>
</w-card-section> </w-card-section>
<w-card-section class="alt-card" id="refCardScripts"> <w-card-section class="alt-card" id="refCardScripts">
<div class="w-section-header">{{ t('editor.props.scripts') }}</div> <div class="w-section-header">{{ t('editor.props.scripts') }}</div>
@ -318,6 +333,9 @@
</w-form> </w-form>
</w-card-section> </w-card-section>
</w-scroll-area> </w-scroll-area>
<w-dialog v-model="state.showLocaleRelationsDialog">
<page-locale-relations-dialog @close="state.showLocaleRelationsDialog = false" />
</w-dialog>
<w-dialog v-model="state.showRelationDialog"> <w-dialog v-model="state.showRelationDialog">
<page-relation-dialog <page-relation-dialog
:edit-id="state.editRelationId" :edit-id="state.editRelationId"
@ -338,6 +356,7 @@ import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import IconPickerDialog from './IconPickerDialog.vue' import IconPickerDialog from './IconPickerDialog.vue'
import PageLocaleRelationsDialog from './PageLocaleRelationsDialog.vue'
import PageRelationDialog from './PageRelationDialog.vue' import PageRelationDialog from './PageRelationDialog.vue'
import PageScriptsDialog from './PageScriptsDialog.vue' import PageScriptsDialog from './PageScriptsDialog.vue'
import PageTags from './PageTags.vue' import PageTags from './PageTags.vue'
@ -356,6 +375,7 @@ const { t } = useI18n()
const state = reactive({ const state = reactive({
showRelationDialog: false, showRelationDialog: false,
showLocaleRelationsDialog: false,
showScriptsDialog: false, showScriptsDialog: false,
requirePassword: false, requirePassword: false,
editRelationId: null, editRelationId: null,

@ -26,7 +26,7 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { scrollToAnchor } from '@/helpers/anchors' import { isVisible, scrollToAnchor } from '@/helpers/anchors'
import { flattenToc } from '@/helpers/toc' import { flattenToc } from '@/helpers/toc'
/** /**
@ -81,6 +81,12 @@ const SPY_LINE = 120
*/ */
const CLICK_SETTLE_MS = 1200 const CLICK_SETTLE_MS = 1200
/*
The tab block, named here because the spy has to leave its rows alone — see `spyableRows`. Uppercase
because `tagName` is, for an HTML element in an HTML document.
*/
const TAB_TAG = 'BLOCK-TAB'
let spyFrame = null let spyFrame = null
let spySuspendedUntil = 0 let spySuspendedUntil = 0
@ -134,6 +140,34 @@ function scrollportTop(heading) {
return 0 return 0
} }
/**
* The rows the spy may mark, paired with the element each one points at.
*
* Two kinds are left out, both because where they sit says nothing about what is being read:
*
* - **A row pointing at a tab.** A tab is somewhere a reader is sent, not somewhere they arrive by
* scrolling: every panel of a block starts at the same offset, so a reader passing the block passes
* all of them at once and the marker has no reason to prefer any one. `block-tab` is also the only
* row whose target is the container of a whole section of page rather than a line at the top of one.
* - **Anything inside a panel that is not showing.** A closed tab is `display: none`, so its contents
* measure nothing at all — `top: 0`, which reads as a heading the reader has long since passed
* rather than as one that is not on the page. Since the last match in document order wins, the
* deepest hidden row held the marker whatever the reader did.
*
* Neither is excluded from the list itself: both are still drawn, and clicking either still opens the
* panel and scrolls to it, which is `scrollToAnchor`'s job rather than the spy's.
*/
function spyableRows() {
const rows = []
for (const item of visibleItems.value) {
const heading = headingFor(item.key)
if (heading && heading.tagName !== TAB_TAG && isVisible(heading)) {
rows.push({ key: item.key, heading })
}
}
return rows
}
/** /**
* Mark whichever heading the reader has reached. * Mark whichever heading the reader has reached.
* *
@ -141,25 +175,27 @@ function scrollportTop(heading) {
* editing, and images settling in shift every heading below them. * editing, and images settling in shift every heading below them.
*/ */
function syncSpy() { function syncSpy() {
if (performance.now() < spySuspendedUntil || visibleItems.value.length === 0) { if (performance.now() < spySuspendedUntil) {
return
}
const rows = spyableRows()
if (rows.length === 0) {
// -> Nothing on the page to measure against — a page that is all tabs, or a render still arriving.
// The marker stays where it was, rather than moving to a row nobody is reading.
return return
} }
const line = scrollportTop(rows[0].heading) + SPY_LINE
let current = null let current = null
let line = null for (const row of rows) {
for (const item of visibleItems.value) { if (row.heading.getBoundingClientRect().top <= line) {
const heading = headingFor(item.key) current = row.key
if (!heading) {
continue
}
line ??= scrollportTop(heading) + SPY_LINE
if (heading.getBoundingClientRect().top <= line) {
current = item.key
} }
} }
// -> Above the first heading, the first section is still the one being read // -> Above the first heading, the first section is still the one being read
const next = current ?? visibleItems.value[0].key const next = current ?? rows[0].key
if (next !== props.selected) { if (next !== props.selected) {
emit('update:selected', next) emit('update:selected', next)
} }

@ -58,8 +58,14 @@ export function anchorTarget(hash) {
return id ? document.getElementById(id) : null return id ? document.getElementById(id) : null
} }
/** Whether an element has a box on the page — false while it sits in a panel that is not showing. */ /**
function isVisible(el) { * Whether an element has a box on the page — false while it sits in a panel that is not showing.
*
* Exported because the contents list asks the same question of a heading before measuring where it is:
* one inside a closed tab measures nothing at all, which reads as the top of the page rather than as
* an absence. See `PageToc`.
*/
export function isVisible(el) {
return Boolean(el.offsetParent ?? el.getClientRects().length) return Boolean(el.offsetParent ?? el.getClientRects().length)
} }

@ -184,7 +184,7 @@
> >
</div> </div>
<w-list separator> <w-list separator>
<w-item v-for="item of state.results" clickable :to="`/` + item.path"> <w-item v-for="item of state.results" clickable :to="pageUrl(item)">
<w-item-section avatar> <w-item-section avatar>
<w-avatar color="primary" text-color="white" rounded> <w-avatar color="primary" text-color="white" rounded>
<w-icon :name="item.icon || defaultPageIcon" size="24px" /> <w-icon :name="item.icon || defaultPageIcon" size="24px" />
@ -193,7 +193,9 @@
<w-item-section> <w-item-section>
<w-item-label>{{ item.title }}</w-item-label> <w-item-label>{{ item.title }}</w-item-label>
<w-item-label v-if="item.description" caption>{{ item.description }}</w-item-label> <w-item-label v-if="item.description" caption>{{ item.description }}</w-item-label>
<w-item-label class="text-grey" caption>/{{ item.path }}</w-item-label> <!-- -> The address it leads to, not the bare tree path: on a site with locales
the prefix is what tells two hits on the same path apart -->
<w-item-label class="text-grey" caption>{{ pageUrl(item) }}</w-item-label>
<w-item-label class="text-highlight" v-if="item.highlight" caption> <w-item-label class="text-highlight" v-if="item.highlight" caption>
<span v-html="item.highlight" /> <span v-html="item.highlight" />
</w-item-label> </w-item-label>
@ -368,6 +370,18 @@ watch(() => state.params, debounce(performSearch, 500), { deep: true })
// METHODS // METHODS
/**
* Where a result leads.
*
* Worked out per row rather than once for the list: a search covers every locale unless one is
* filtered for, so two hits in the same set can be in different languages -- and a bare path is the
* primary locale's address, which is either the wrong page or no page at all. Each result carries the
* locale it was found in, so that is what the prefix comes from.
*/
function pageUrl(item) {
return `${siteStore.localeUrlPrefix(item.locale)}/${item.path}`
}
function humanizeDate(val) { function humanizeDate(val) {
return userStore.formatDateTime(t, val) return userStore.formatDateTime(t, val)
} }

@ -466,14 +466,24 @@ export class MarkdownRenderer {
// Inject line numbers for preview scroll sync // Inject line numbers for preview scroll sync
// -------------------------------- // --------------------------------
this.linesMap = [] /*
Stamped at every depth, not only on the tokens sitting at the top level of the document. The
editor finds the line the caret is on by this attribute and nothing else, so a token without one
is a line the preview cannot be scrolled to -- and everything written inside a block was such a
line, `::block-tab` panels most of all. What that looked like: the nearest line the editor could
find for a caret inside a panel was the last paragraph ABOVE the whole tabset, so the preview
scrolled up out of the panel being written in, however long its content was.
A hidden token is skipped. A tight list's paragraphs are parsed and then not rendered, and a
single-paragraph block body is unwrapped the same way, so stamping one would leave the editor
looking for an element that was never drawn -- worse than not knowing the line, since it would
stop looking for something it could have scrolled to instead.
*/
const injectLineNumbers = (tokens, idx, options, env, slf) => { const injectLineNumbers = (tokens, idx, options, env, slf) => {
let line const token = tokens[idx]
if (tokens[idx].map && tokens[idx].level === 0) { if (token.map && !token.hidden) {
line = tokens[idx].map[0] + 1 token.attrJoin('class', 'line')
tokens[idx].attrJoin('class', 'line') token.attrSet('data-line', String(token.map[0] + 1))
tokens[idx].attrSet('data-line', String(line))
this.linesMap.push(line)
} }
return slf.renderToken(tokens, idx, options, env, slf) return slf.renderToken(tokens, idx, options, env, slf)
} }
@ -481,6 +491,29 @@ export class MarkdownRenderer {
this.md.renderer.rules.heading_open = injectLineNumbers this.md.renderer.rules.heading_open = injectLineNumbers
this.md.renderer.rules.blockquote_open = injectLineNumbers this.md.renderer.rules.blockquote_open = injectLineNumbers
/*
A fence carries its line in the markup rather than on the token, because the `highlight` option
above returns the whole `<pre>` and markdown-it hands a highlighted fence straight back: the
token's attributes are rendered by nobody, so `attrSet` would reach nothing.
Worth the string surgery for the case this whole mechanism exists for. A tabset whose panels each
hold one long code sample -- per language, per platform -- is the commonest tabset there is, and
it is exactly the one where the caret has no anchor of its own to be scrolled to.
The attribute only, without the `line` class the tokens above join: the class would have to go on
a tag that already carries one, and two `class` attributes on the same tag is not markup.
*/
const renderFence =
this.md.renderer.rules.fence ??
((tokens, idx, options, env, slf) => slf.renderToken(tokens, idx, options, env, slf))
this.md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const html = renderFence(tokens, idx, options, env, slf)
const map = tokens[idx].map
// -> Every branch of `highlight` opens with `<pre`, and so does the wrapper markdown-it puts
// around a fence it did not highlight
return map ? html.replace(/^<pre/, `<pre data-line="${map[0] + 1}"`) : html
}
// -------------------------------- // --------------------------------
// Where the tabsets are, for the editor's preview // Where the tabsets are, for the editor's preview
// -------------------------------- // --------------------------------
@ -527,28 +560,28 @@ export class MarkdownRenderer {
* a relative image resolves against -- see `fileSrc`. * a relative image resolves against -- see `fileSrc`.
*/ */
render(src, { pagePath = '' } = {}) { render(src, { pagePath = '' } = {}) {
this.linesMap = []
// -> A fresh env every time, whatever the caller passed: markdown-it keeps per-render state in it // -> A fresh env every time, whatever the caller passed: markdown-it keeps per-render state in it
// (footnotes and references), and one shared between renders would carry the last one's // (footnotes and references), and one shared between renders would carry the last one's
return this.md.render(src, { pagePath }) return this.md.render(src, { pagePath })
} }
getClosestPreviewLine(line) {
return this.linesMap.findLast((n) => n <= line)
}
/** /**
* Which tabset panel a source line is inside, as the pair of indices that finds it in the render. * Which tabset panels a source line is inside, as the pairs of indices that find them in the render.
*
* Every panel that contains the line, outermost first, rather than only the one it is written in: a
* tabset nested in another one is drawn inside a panel, and opening the inner panel while the panel
* holding it stays closed reveals nothing at all. The last entry is therefore the panel the line is
* actually in, and the ones before it are what has to be opened for it to be on the page.
* *
* The innermost panel wins, so a tabset within a tabset answers for its own lines: the map is built * Ordered by construction: `tabsMap` is built in document order, so a tabset that encloses another
* outermost-first, and a later match is therefore a deeper one. * is always the earlier of the two.
* *
* @param {number} line A 1-based editor line, as Monaco counts them. * @param {number} line A 1-based editor line, as Monaco counts them.
* @returns {{tabset: number, tab: number}|null} Indices among the document's tabsets and that * @returns {Array<{tabset: number, tab: number}>} Indices among the document's tabsets and each
* tabset's panels, or null when the line is not inside one. * tabset's panels, outermost first. Empty when the line is not inside one.
*/ */
getTabAtLine(line) { getTabsAtLine(line) {
let found = null const found = []
for (const [tabset, tabs] of this.tabsMap.entries()) { for (const [tabset, tabs] of this.tabsMap.entries()) {
for (const [tab, map] of tabs.entries()) { for (const [tab, map] of tabs.entries()) {
/* /*
@ -557,7 +590,7 @@ export class MarkdownRenderer {
a panel still counts as being in it. a panel still counts as being in it.
*/ */
if (line - 1 >= map[0] && line - 1 <= map[1]) { if (line - 1 >= map[0] && line - 1 <= map[1]) {
found = { tabset, tab } found.push({ tabset, tab })
} }
} }
} }

@ -61,6 +61,12 @@ export const usePageStore = defineStore('page', {
publishStartDate: '', publishStartDate: '',
publishState: '', publishState: '',
relations: [], relations: [],
/**
* The same page in other locales: `{ locale, path, title }`, one entry per locale, as the server
* knows them. What the locale selector sends a reader to instead of guessing at the same path in
* another language, and what the page properties panel edits as one set.
*/
localeRelations: [],
render: '', render: '',
scriptJsLoad: '', scriptJsLoad: '',
scriptJsUnload: '', scriptJsUnload: '',
@ -174,6 +180,9 @@ export const usePageStore = defineStore('page', {
relations: pageData.relations.map((r) => relations: pageData.relations.map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target']) pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
), ),
localeRelations: (pageData.localeRelations ?? []).map((r) =>
pick(r, ['locale', 'path', 'title'])
),
tocDepth: pick(pageData.tocDepth, ['min', 'max']) tocDepth: pick(pageData.tocDepth, ['min', 'max'])
}) })
this.applyViewerState(pageData.viewer) this.applyViewerState(pageData.viewer)
@ -226,6 +235,9 @@ export const usePageStore = defineStore('page', {
relations: pageData.relations.map((r) => relations: pageData.relations.map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target']) pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
), ),
localeRelations: (pageData.localeRelations ?? []).map((r) =>
pick(r, ['locale', 'path', 'title'])
),
tocDepth: pick(pageData.tocDepth, ['min', 'max']) tocDepth: pick(pageData.tocDepth, ['min', 'max'])
}) })
}, },
@ -312,6 +324,7 @@ export const usePageStore = defineStore('page', {
toc: [], toc: [],
tags: [], tags: [],
relations: [], relations: [],
localeRelations: [],
scriptJsLoad: '', scriptJsLoad: '',
scriptJsUnload: '', scriptJsUnload: '',
scriptCss: '', scriptCss: '',
@ -416,6 +429,9 @@ export const usePageStore = defineStore('page', {
alias: '', alias: '',
publishState: 'published', publishState: 'published',
relations: [], relations: [],
// -> A page being created is in no translation set yet, whatever the page it was started from
// belonged to
localeRelations: [],
tags: [], tags: [],
content: content ?? '', content: content ?? '',
// -> A page being created has no stored source to lose: whatever it starts with IS the source // -> A page being created has no stored source to lose: whatever it starts with IS the source
@ -654,6 +670,7 @@ export const usePageStore = defineStore('page', {
'icon', 'icon',
'isBrowsable', 'isBrowsable',
'isSearchable', 'isSearchable',
'localeRelations',
'password', 'password',
'publishEndDate', 'publishEndDate',
'publishStartDate', 'publishStartDate',
@ -729,9 +746,26 @@ export const usePageStore = defineStore('page', {
relations: (pageData.relations ?? []).map((r) => relations: (pageData.relations ?? []).map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target']) pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
), ),
/*
What the server made of the set, not what was sent: joining another page's translations
brings its other members along, so the panel's own list is a request and this is the answer.
*/
localeRelations: (pageData.localeRelations ?? []).map((r) =>
pick(r, ['locale', 'path', 'title'])
),
tocDepth: pick(pageData.tocDepth, ['min', 'max']) tocDepth: pick(pageData.tocDepth, ['min', 'max'])
}) })
/*
The site's tags are what its pages carry, so this save is what just changed them -- a tag
typed into the properties panel exists from here on, and one taken off the last page carrying
it does not. Unconditional: the tags that went up cannot be compared with the ones that were
on the page before, since the store held the author's edits long before the save.
Marked rather than fetched, so the cost falls on the next thing that actually wants the list.
*/
siteStore.staleTags()
if (editorStore.mode === 'create') { if (editorStore.mode === 'create') {
editorStore.$patch({ mode: 'edit' }) editorStore.$patch({ mode: 'edit' })
/* /*

@ -123,7 +123,13 @@ export const useSiteStore = defineStore('site', {
} }
] ]
}, },
/**
* Every tag any page of this site carries, most used first — the suggestions a tag field offers
* and the list the search page filters by. Derived from the pages rather than stored, so it goes
* out of date whenever one is saved; see `fetchTags` and `staleTags`.
*/
tags: [], tags: [],
/** Whether `tags` holds a list that is known to be current. */
tagsLoaded: false, tagsLoaded: false,
theme: { theme: {
dark: false, dark: false,
@ -301,6 +307,13 @@ export const useSiteStore = defineStore('site', {
} }
}) })
}, },
/**
* Load the site's tags, unless a current list is already held.
*
* Cached because it is not a cheap answer — the server counts every tag over every page the
* asker may read, page rules and all — and because the surfaces that want it (a tag field, the
* search filter, the header's popular tags) are opened over and over in one session.
*/
async fetchTags(forceRefresh = false) { async fetchTags(forceRefresh = false) {
if (this.tagsLoaded && !forceRefresh) { if (this.tagsLoaded && !forceRefresh) {
return return
@ -316,6 +329,24 @@ export const useSiteStore = defineStore('site', {
throw err throw err
} }
}, },
/**
* Say that the tag list is out of date, so the next thing that wants it asks the server again.
*
* A tag is not a record anybody creates: it exists because a page carries it, so saving a page is
* what brings one into being — and the list cached here was fetched before that happened. Without
* this, a tag invented in the page properties panel was missing from every tag field for the rest
* of the session, and only a full reload of the application brought it back.
*
* The tags themselves are left in place rather than emptied: what is held is the last known list,
* which is a better thing to show than nothing while the next fetch is in flight.
*
* Deleting a page, or a folder of them, moves this list too and does not say so. That direction is
* survivable in a way this one is not: a tag no page carries any more is a suggestion that leads to
* an empty search, where a tag that cannot be suggested at all cannot be picked.
*/
staleTags() {
this.tagsLoaded = false
},
/** /**
* Load the sidebar menu a page resolves to. * Load the sidebar menu a page resolves to.
* *

Loading…
Cancel
Save