feat: insert blocks + 9 prebuilt blocks

scarlett
NGPixel 1 month ago
parent 9408accc00
commit 3d13e50be8
No known key found for this signature in database

@ -238,6 +238,25 @@ the frontend adds the `vue` plugin and the `API_CLIENT` / `EVENT_BUS` / `Tempora
Both tools handle `.ts` with no extra configuration, and the backend's oxlint config already enables
the `typescript` plugin. oxlint does not type-check — run `npm run typecheck` for that.
**Never put two statements in a Vue template attribute.** `@click="doOne(); doTwo()"` builds today
and is a build error the moment the file is formatted, because `semi: false` and Vue disagree about
the same character. Vue's `transformOn` decides whether an inline handler is a statement block or an
expression from `exp.content.includes(';')` — with the semicolon it emits `$event => { … }`,
without it `$event => ( … )`. oxfmt breaks the handler across lines and drops the semicolon, so Vue
parenthesises two statements and the template fails to compile (`Error parsing JavaScript
expression: Unexpected token`). Write a named handler instead — `@click="closeAndRefresh"` — as
`EditorMarkdown.vue` and `PageRelationDialog.vue` do.
Neither side of that is worth reconfiguring, so don't try: the `includes(';')` check has no compiler
option behind it, and the parse error is raised by the built-in `transformExpression`, which
`baseCompile` runs *before* any `nodeTransforms` you could add — and Volar runs the same compiler,
so a build-time workaround would still leave the editor showing errors. On the formatter side,
`embeddedLanguageFormatting: "off"` does leave attribute expressions alone but also stops formatting
every `<script>` and `<style>` block in every SFC. This is not an oxfmt quirk either: Prettier with
`--no-semi` produces identical output. For a one-off where the inline form genuinely reads better,
`<!-- prettier-ignore -->` on the preceding line works (oxfmt honors Prettier's marker; there is no
`oxfmt-ignore`).
### Utilities and dates
These apply to **every workspace**, `frontend/` included — not just the backend.

@ -463,7 +463,7 @@ async function routes(app: FastifyInstance) {
'A module can be configured more than once, so that two instances of the same provider can coexist. A new strategy is not offered by any site until that site adds it to its login screen.',
tags: ['Authentication'],
body: {
allOf: [{ $ref: 'AuthStrategyInput#' }, { required: ['module'] }]
allOf: [{ $ref: 'AuthStrategyInput#' }, { type: 'object', required: ['module'] }]
},
response: {
200: {

@ -157,7 +157,7 @@ async function routes(app: FastifyInstance) {
tags: ['Webhooks'],
// -> The same shape as an update, with the three fields a webhook cannot exist without
body: {
allOf: [{ $ref: 'HookInput#' }, { required: ['name', 'events', 'url'] }]
allOf: [{ $ref: 'HookInput#' }, { type: 'object', required: ['name', 'events', 'url'] }]
},
response: {
200: {

@ -2,6 +2,7 @@ import { validate as uuidValidate } from 'uuid'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { PageActor, PageInput } from '../models/pages.ts'
import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts'
import { generatePathHash } from '../helpers/common.ts'
/** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */
function splitList(value?: string): string[] {
@ -270,6 +271,64 @@ async function routes(app: FastifyInstance) {
}
)
/**
* GET PAGE FOR INCLUSION
*/
app.get<{ Params: { siteId: string }; Querystring: { path: string; locale?: string } }>(
'/sites/:siteId/pages/include',
{
schema: {
summary: 'Get a page for inclusion',
description:
"What an include block needs to draw another page inside the one being read: its title and its stored render, addressed by path rather than by ID, since a path is what an author writes into the page.\n\nThe reader's own access decides the answer, exactly as it would if they opened the page themselves — an anonymous request only ever sees published pages, and a password-protected page comes back with `isLocked: true` and no body unless this session has already unlocked it. So an include can never show content its reader could not have reached on their own.",
tags: ['Pages'],
params: siteIdParam,
querystring: {
type: 'object',
required: ['path'],
properties: {
path: {
type: 'string',
maxLength: 2048,
description: 'Slash-separated path of the page to include. The home page when empty.'
},
locale: {
type: 'string',
maxLength: 10,
description: "The site's primary locale when absent."
}
}
},
response: {
200: { $ref: 'IncludedPage#' }
}
}
},
async (req, reply) => {
const actor = actorFrom(req)
// -> The stored path: no wrapping slashes, lowercase, and the site root is the `home` page
const path = req.query.path.trim().replace(/^\/+/, '').replace(/\/+$/, '').toLowerCase()
const page = await WIKI.models.pages.getPage({
siteId: req.params.siteId,
hash: generatePathHash(path || 'home'),
locale: req.query.locale,
publicOnly: !actor,
unlocked: (pageId) => unlockedFor(req, pageId),
withPassword: false
})
if (!page) {
return reply.notFound('This page does not exist.')
}
return {
path: page.path,
locale: page.locale,
title: page.title,
isLocked: page.isLocked,
render: page.render
}
}
)
/**
* GET PAGE
*/
@ -434,7 +493,10 @@ async function routes(app: FastifyInstance) {
tags: ['Pages'],
params: siteIdParam,
body: {
allOf: [{ $ref: 'PageInput#' }, { required: ['path', 'title', 'editor', 'content'] }]
allOf: [
{ $ref: 'PageInput#' },
{ type: 'object', required: ['path', 'title', 'editor', 'content'] }
]
},
response: {
200: {

@ -36,6 +36,47 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
config: {
type: 'object',
additionalProperties: true
},
template: {
type: 'string',
description:
'Body the editor writes between the opening and closing lines when inserting the block, for a block whose content is other blocks. Empty for a block that takes none.'
},
props: {
type: 'array',
description:
"The block's authorable attributes, as its component declares them — what the editor's block picker turns into a form. Read from the compiled manifest rather than the database, so it describes the code that is installed. Empty for a custom block, which has no manifest entry.",
items: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Attribute name, as written on the element.'
},
type: {
type: 'string',
enum: ['string', 'number', 'boolean', 'select'],
description: 'What kind of field to offer for it.'
},
label: {
type: 'string'
},
hint: {
type: 'string'
},
required: {
type: 'boolean'
},
options: {
type: 'array',
description: 'Allowed values, for `select`.',
items: { type: 'string' }
},
default: {
description: 'Value the field starts on, and the one worth leaving out of the markup.'
}
}
}
}
}
})

@ -165,7 +165,8 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
tags: { type: 'array', items: { type: 'string' } },
toc: {
type: 'array',
description: 'Nested headings, derived from the stored render.',
description:
'Nested headings, derived from the stored render. Each carries its own `level` — the heading tag it came from — as well as its place in the tree, since which headings a contents list shows is a question about the tag rather than about the nesting.',
items: { type: 'object', additionalProperties: true }
},
render: { type: 'string' },
@ -197,4 +198,33 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
updatedAt: { type: 'string', format: 'date-time' }
}
})
/**
* INCLUDED PAGE - Another page's render, as an include block draws it inside the page being read
*/
app.addSchema({
$id: 'IncludedPage',
type: 'object',
properties: {
path: {
type: 'string',
description: 'Slash-separated path of the page that was included.'
},
locale: {
type: 'string'
},
title: {
type: 'string'
},
isLocked: {
type: 'boolean',
description:
'The page is password protected and this reader has not entered it, so `render` is empty. An include does not offer the unlock prompt: the reader unlocks the page by opening it.'
},
render: {
type: 'string',
description: 'The stored HTML, already sanitised when the page was saved.'
}
}
})
}

@ -111,6 +111,34 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
})
/**
* LISTED PAGE - One page of a reader-facing listing, as an index block draws it
*/
app.addSchema({
$id: 'ListedPage',
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
path: {
type: 'string',
description: 'Slash-separated path of the page, i.e. its URL within the site.'
},
title: {
type: 'string'
},
description: {
type: 'string'
},
icon: {
type: 'string',
description: "The page's icon, as an Iconify reference. Empty when it has none."
}
}
})
/**
* FOLDER INPUT - The writable fields of a folder, used for both create and rename
*/

@ -262,6 +262,98 @@ async function routes(app: FastifyInstance) {
}
)
/**
* LIST PAGES AS A READER
*/
app.get<{
Params: { siteId: string }
Querystring: {
path?: string
locale?: string
tags?: string
limit?: number
orderBy?: TreeOrderBy
orderByDirection?: 'asc' | 'desc'
depth?: number
}
}>(
'/sites/:siteId/tree/pages',
{
schema: {
summary: 'List pages as a reader',
description:
"Lists the pages under a path, ordered and limited, for an index block drawn inside a page. Folders are not part of the answer — this is a list of pages, at `depth` folders below the path when asked for.\n\nReadable without a session, because the page holding the block is: an anonymous request sees only published pages, the same set the page view would serve it. Unlike `/tree/browse` it is not gated on the site's `browse` feature, which governs the sidebar's browse menu rather than what a page may render.",
tags: ['Tree'],
params: siteIdParam,
querystring: {
type: 'object',
properties: {
path: {
type: 'string',
maxLength: 2048,
description: 'Slash-separated path to list. The site root when absent.'
},
locale: {
type: 'string',
maxLength: 10,
description: "The site's primary locale when absent."
},
tags: {
type: 'string',
description: 'Comma-separated list of tags a page must carry all of.'
},
limit: {
type: 'integer',
minimum: 1,
maximum: 1000,
default: 10
},
orderBy: {
type: 'string',
enum: TREE_ORDER_BY,
default: 'title'
},
orderByDirection: {
type: 'string',
enum: ['asc', 'desc'],
default: 'asc'
},
depth: {
type: 'integer',
minimum: 0,
maximum: 10,
default: 0,
description: 'How many folders below the path to include. 0 is the path itself.'
}
}
},
response: {
200: {
description: 'The pages found',
type: 'array',
items: { $ref: 'ListedPage#' }
}
}
}
},
async (req, reply) => {
if (!WIKI.sites[req.params.siteId]) {
return reply.notFound('This site does not exist.')
}
return WIKI.models.tree.listPages({
siteId: req.params.siteId,
path: req.query.path,
locale: req.query.locale ?? defaultLocale(req.params.siteId),
tags: splitList(req.query.tags),
limit: req.query.limit,
orderBy: req.query.orderBy,
orderByDirection: req.query.orderByDirection,
depth: req.query.depth,
publicOnly: !req.session?.authenticated
})
}
)
/**
* GET FOLDER
*/
@ -311,7 +403,7 @@ async function routes(app: FastifyInstance) {
body: {
allOf: [
{ $ref: 'FolderInput#' },
{ required: ['pathName', 'title'] },
{ type: 'object', required: ['pathName', 'title'] },
{
type: 'object',
properties: {
@ -388,7 +480,7 @@ async function routes(app: FastifyInstance) {
tags: ['Tree'],
params: folderIdParam,
body: {
allOf: [{ $ref: 'FolderInput#' }, { required: ['pathName', 'title'] }]
allOf: [{ $ref: 'FolderInput#' }, { type: 'object', required: ['pathName', 'title'] }]
},
response: {
200: {

@ -1589,6 +1589,14 @@
"editor.assets.uploadAssetsDropZone": "Browse or Drop files here...",
"editor.assets.uploadFailed": "File upload failed.",
"editor.backToEditor": "Back to Editor",
"editor.blockPicker.blockUnavailable": "This block is not available on this site. Blocks are managed in the administration area.",
"editor.blockPicker.insert": "Insert Block",
"editor.blockPicker.loadFailed": "Failed to load the list of blocks.",
"editor.blockPicker.markdown": "Markdown",
"editor.blockPicker.noBlocks": "No blocks are enabled for this site. They are managed in the administration area.",
"editor.blockPicker.noProps": "This block takes no properties — insert it as it is.",
"editor.blockPicker.selectHint": "Pick a block on the left to fill in its properties.",
"editor.blockPicker.title": "Insert Block",
"editor.ckeditor.stats": "{chars} chars, {words} words",
"editor.codeBlock.filter": "Filter languages...",
"editor.codeBlock.noResults": "No language matches that.",

@ -1,17 +1,39 @@
import { readFile } from 'node:fs/promises'
import { readdir, readFile, stat } from 'node:fs/promises'
import path from 'node:path'
import { and, eq, inArray } from 'drizzle-orm'
import { blocks as blocksTable, sites as sitesTable } from '../db/schema.ts'
/** One authorable attribute of a block, as its `static definition` describes it. */
export interface BlockProp {
name: string
type: 'string' | 'number' | 'boolean' | 'select'
label?: string
hint?: string
required?: boolean
options?: string[]
default?: string | number | boolean
}
/** A block as declared by its component's `static definition`. */
export interface BlockDefinition {
block: string
name: string
description: string
icon: string
props?: BlockProp[]
/**
* A block that only ever appears inside another one, such as a single tab of a set of tabs.
*
* It is never registered for a site: not something to insert on its own, and not something to
* switch off separately from its parent. It is still declared here, because that is what lets its
* tag and attributes survive a page being saved.
*/
isChild?: boolean
/** Body the editor writes between the opening and closing lines when inserting the block. */
template?: string
}
/** A block row as exposed by the API. */
/** A block row as exposed by the API, with what its component says it can be given. */
export interface SiteBlock {
id: string
block: string
@ -21,6 +43,8 @@ export interface SiteBlock {
isEnabled: boolean
isCustom: boolean
config: Record<string, any>
props: BlockProp[]
template: string
}
const blockSelection = {
@ -46,11 +70,22 @@ class Blocks {
/** Definitions read from the compiled manifest, refreshed by `refreshFromDisk()`. */
definitions: BlockDefinition[] = []
/**
* Whether the last read of the manifest succeeded.
*
* Told apart from "the manifest lists nothing", because the two mean opposite things to a sync: an
* empty manifest says every built-in block has been removed, a missing one says nothing at all.
*/
private manifestLoaded = false
/**
* Load the built-in block definitions from the compiled manifest.
*
* A missing manifest is not fatal: it just means `blocks` has not been built yet, in which case
* only custom blocks are available.
* Read on every boot, so a block whose name, description or icon changed on disk is picked up by
* restarting the server `syncAllSites` is what writes the difference to each site.
*
* A missing manifest is not fatal: `blocks/compiled` is a build output and is not in the
* repository, so a fresh checkout has none until `npm run build` has been run in `blocks/`.
*/
async refreshFromDisk(): Promise<void> {
const manifestPath = path.join(WIKI.ROOTPATH, 'blocks/compiled/blocks.manifest.json')
@ -60,9 +95,12 @@ class Blocks {
throw new TypeError('Manifest is not an array.')
}
this.definitions = manifest
this.manifestLoaded = true
WIKI.logger.info(`Found ${this.definitions.length} blocks [ OK ]`)
await this.warnIfStale(manifestPath)
} catch (err: any) {
this.definitions = []
this.manifestLoaded = false
WIKI.logger.warn(
`Could not read the blocks manifest at ${manifestPath} — run "npm run build" in blocks/. [ SKIPPED ]`
)
@ -71,31 +109,75 @@ class Blocks {
}
/**
* Register any built-in block missing from a site, and drop rows for built-ins that no longer
* exist on disk. Existing rows are updated in place so that `isEnabled` and `config` survive.
* Say so when the manifest is older than the components it was built from.
*
* The manifest is a build output, and nothing rebuilds it on the way in here so editing a block
* and restarting the server looks like the change was ignored, when what happened is that the
* server read a manifest describing the previous version of the block.
*
* Only in a source tree: a packaged instance ships `blocks/compiled` without the sources beside it,
* where there is nothing to compare against and nothing anybody could rebuild.
*/
private async warnIfStale(manifestPath: string): Promise<void> {
try {
const sourcePath = path.join(WIKI.ROOTPATH, 'blocks')
const builtAt = (await stat(manifestPath)).mtimeMs
const entries = await readdir(sourcePath, { withFileTypes: true })
const stale: string[] = []
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith('block-')) {
continue
}
const component = path.join(sourcePath, entry.name, 'component.js')
const changedAt = await stat(component).then(
(info) => info.mtimeMs,
() => 0
)
if (changedAt > builtAt) {
stale.push(entry.name)
}
}
if (stale.length > 0) {
WIKI.logger.warn(
`${stale.join(', ')} changed since the blocks manifest was built — run "npm run build" in blocks/ and restart to pick that up.`
)
}
} catch {
// -> No sources to compare against, which is the normal state of a packaged instance
}
}
/**
* Bring a site's block rows in line with what is installed on disk.
*
* Registers what is missing, writes back a name, description or icon that changed, and drops rows
* for built-ins that are no longer there. `isEnabled` and `config` are the site's own and are never
* touched which is why an existing row is updated rather than replaced.
*
* Custom blocks are left alone entirely: they have no on-disk counterpart to compare against.
*
* Custom blocks are never touched they have no on-disk counterpart to compare against.
* @returns How many rows were added, changed and removed
*/
async syncSite(siteId: string): Promise<void> {
async syncSite(siteId: string): Promise<{ added: number; updated: number; removed: number }> {
const existing = await WIKI.db
.select({ id: blocksTable.id, block: blocksTable.block })
.select({
block: blocksTable.block,
name: blocksTable.name,
description: blocksTable.description,
icon: blocksTable.icon
})
.from(blocksTable)
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.isCustom, false)))
const existingKeys = existing.map((b: any) => b.block)
const definedKeys = this.definitions.map((d) => d.block)
// -> Child blocks are part of their parent, so they get no row of their own — and a block that
// becomes one is cleaned up by the orphan pass below, since it is no longer a defined key
const registrable = this.definitions.filter((d) => !d.isChild)
const definedKeys = registrable.map((d) => d.block)
let added = 0
let updated = 0
for (const definition of this.definitions) {
if (existingKeys.includes(definition.block)) {
// -> Metadata may have changed on disk; state and config belong to the site
await WIKI.db
.update(blocksTable)
.set({
name: definition.name,
description: definition.description,
icon: definition.icon
})
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.block, definition.block)))
} else {
for (const definition of registrable) {
const row = existing.find((entry: any) => entry.block === definition.block)
if (!row) {
await WIKI.db.insert(blocksTable).values({
siteId,
block: definition.block,
@ -106,11 +188,32 @@ class Blocks {
isCustom: false,
config: {}
})
added++
continue
}
// -> Written only when it would change something, so that a boot that found nothing new is a
// boot that wrote nothing — and the count below means what it says
if (
row.name !== definition.name ||
row.description !== definition.description ||
row.icon !== definition.icon
) {
await WIKI.db
.update(blocksTable)
.set({
name: definition.name,
description: definition.description,
icon: definition.icon
})
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.block, definition.block)))
updated++
}
}
// -> A built-in that has been removed from disk should not linger in the admin list
const orphaned = existingKeys.filter((key: string) => !definedKeys.includes(key))
const orphaned = existing
.map((entry: any) => entry.block)
.filter((key: string) => !definedKeys.includes(key))
if (orphaned.length > 0) {
await WIKI.db
.delete(blocksTable)
@ -122,18 +225,37 @@ class Blocks {
)
)
}
return { added, updated, removed: orphaned.length }
}
/**
* Register the built-in blocks for every site. Called at boot, after the sites cache is loaded.
*
* Skipped outright when the manifest could not be read, rather than run against an empty list of
* definitions: that would read as "every built-in block has been uninstalled" and delete each
* site's rows, taking which blocks it had switched on with them.
*/
async syncAllSites(): Promise<void> {
if (!this.manifestLoaded) {
WIKI.logger.warn('Skipping block registration: the manifest could not be read. [ SKIPPED ]')
return
}
WIKI.logger.info('Registering blocks for all sites...')
const sites = await WIKI.db.select({ id: sitesTable.id }).from(sitesTable)
const total = { added: 0, updated: 0, removed: 0 }
for (const site of sites) {
await WIKI.models.blocks.syncSite(site.id)
const counts = await WIKI.models.blocks.syncSite(site.id)
total.added += counts.added
total.updated += counts.updated
total.removed += counts.removed
}
WIKI.logger.info(`Registered blocks for ${sites.length} sites [ OK ]`)
if (total.added || total.updated || total.removed) {
WIKI.logger.info(
`Blocks changed on disk: ${total.added} added, ${total.updated} updated, ${total.removed} removed.`
)
}
}
/**
@ -145,7 +267,20 @@ class Blocks {
.from(blocksTable)
.where(eq(blocksTable.siteId, siteId))
.orderBy(blocksTable.isCustom, blocksTable.name)
return results as SiteBlock[]
/*
`props` come from the manifest rather than the row: they describe the component's own attributes,
so they belong to the installed code and not to a site's copy of it. Reading them here means an
updated block's props are correct the moment it is deployed, with nothing to migrate and a
custom block, having no manifest entry, simply reports none.
*/
return (results as SiteBlock[]).map((row) => {
const definition = this.definitions.find((d) => d.block === row.block)
return {
...row,
props: definition?.props ?? [],
template: definition?.template ?? ''
}
})
}
/**

@ -29,6 +29,14 @@ import { CustomError } from '../helpers/common.ts'
export interface TocNode {
key: string
label: string
/**
* The heading's own level, 1 to 6.
*
* Kept alongside the nesting because the two say different things: a contents list is asked to show
* "H1 to H2", which is about the tag an author reached for, and an `h3` written under an `h1` is
* still an `h3` however few levels sit above it.
*/
level: number
children: TocNode[]
}
@ -273,13 +281,36 @@ class Rendering {
}
}
/**
* The block elements a page may carry, and what each of them may be given.
*
* A block is the one thing in a page that is not HTML, so sanitising against a list of HTML tags
* drops every one of them and no block ever survives being saved. The list is built from the
* compiled manifest a block that is installed may be embedded, one that is not may not and
* each tag gets exactly the attributes its component declares as props, which is the same set the
* editor's block picker offers. The markup is inert either way: what makes a block do anything is
* the component fetched from `/_blocks` at view time.
*/
private blockAllowances(): { tags: string[]; attributes: Record<string, string[]> } {
const tags: string[] = []
const attributes: Record<string, string[]> = {}
for (const definition of WIKI.models.blocks.definitions) {
const tag = `block-${definition.block}`
tags.push(tag)
attributes[tag] = (definition.props ?? []).map((prop) => prop.name)
}
return { tags, attributes }
}
/**
* Strip everything the author is not allowed to embed.
*/
private sanitize(html: string, permissions: RenderPermissions): string {
const allowedTags = [...BASE_ALLOWED_TAGS]
const blocks = this.blockAllowances()
const allowedTags = [...BASE_ALLOWED_TAGS, ...blocks.tags]
const allowedAttributes: Record<string, string[]> = {
...BASE_ALLOWED_ATTRIBUTES,
...blocks.attributes,
'*': [...BASE_ALLOWED_ATTRIBUTES['*']]
}
@ -373,9 +404,10 @@ class Rendering {
}
heading.attr('id', key)
const level = Number.parseInt(el.tagName.slice(1), 10)
flat.push({
level: Number.parseInt(el.tagName.slice(1), 10),
node: { key: `#${key}`, label, children: [] }
level,
node: { key: `#${key}`, label, level, children: [] }
})
})

@ -72,6 +72,17 @@ export interface BrowseLevel {
truncated: boolean
}
/** One page of a reader-facing listing, as the index block draws it. */
export interface ListedPage {
id: string
/** Slash-separated path of the page, i.e. its URL within the site. */
path: string
title: string
description: string
/** The page's icon, as an Iconify reference. Empty when it has none. */
icon: string
}
/** A raw `tree` row, as the model passes it around internally. */
export interface TreeRow {
id: string
@ -279,7 +290,9 @@ class Tree {
conditions.push(inArray(treeTable.type, types))
}
if (tags && tags.length > 0) {
conditions.push(sql`${treeTable.tags} @> ${tags}`)
// -> `sql.param`, because a bare array in a template is read as a parameter *list* — the
// comma-separated form `inArray` needs — and `@>` wants one array-typed parameter
conditions.push(sql`${treeTable.tags} @> ${sql.param(tags)}`)
}
const direction = orderByDirection === 'desc' ? desc : asc
@ -297,6 +310,87 @@ class Tree {
return rows.map(({ row, depth: rowDepth }) => toTreeItem(row as TreeRow, rowDepth, path))
}
/**
* List the pages under a path, the way an index block on a page lists them.
*
* Between `getTree()` and `browse()`: it recurses and sorts like the first and hides like the
* second. Folders are left out entirely the block draws a list of pages, not a file browser
* and so is any page the reader may not open, by the same rule the page view applies.
*
* @param path Slash-separated path to list. The site root when empty.
* @param depth How many folders below the path to include. 0, the default, is the path itself.
* @param tags Only pages carrying every one of these tags.
* @param publicOnly Restrict to what a reader with no session may see. See `pageIsVisible`.
*/
async listPages({
siteId,
path,
locale,
tags,
limit = 10,
orderBy = 'title',
orderByDirection = 'asc',
depth = 0,
publicOnly = true
}: {
siteId: string
path?: string | null
locale: string
tags?: string[] | null
limit?: number
orderBy?: TreeOrderBy
orderByDirection?: 'asc' | 'desc'
depth?: number
publicOnly?: boolean
}): Promise<ListedPage[]> {
if (limit < 1 || limit > MAX_LIMIT) {
throw new CustomError('treeInvalidLimit', `The limit must be between 1 and ${MAX_LIMIT}.`)
}
if (depth < 0 || depth > MAX_DEPTH) {
throw new CustomError('treeInvalidDepth', `The depth must be between 0 and ${MAX_DEPTH}.`)
}
const encodedPath = encodeTreePath(path)
const levels = depth > 0 ? `*{,${depth}}` : '*{0}'
const pathQuery = encodedPath ? `${encodedPath}.${levels}` : levels
const direction = orderByDirection === 'desc' ? desc : asc
const rows = await WIKI.db
.select({
id: treeTable.id,
folderPath: treeTable.folderPath,
fileName: treeTable.fileName,
title: treeTable.title,
description: pagesTable.description,
icon: pagesTable.icon
})
.from(treeTable)
.innerJoin(pagesTable, eq(pagesTable.id, treeTable.id))
.where(
and(
eq(treeTable.siteId, siteId),
eq(treeTable.locale, locale),
eq(treeTable.type, 'page'),
sql`${treeTable.folderPath} ~ ${pathQuery}::lquery`,
...(tags && tags.length > 0 ? [sql`${treeTable.tags} @> ${sql.param(tags)}`] : []),
...pageIsVisible(pagesTable, publicOnly)
)
)
.orderBy(direction(treeTable[orderBy]))
.limit(limit)
return rows.map((row) => {
const folderPath = decodeTreePath(row.folderPath ?? '') ?? ''
return {
id: row.id,
path: folderPath ? `${folderPath}/${row.fileName}` : row.fileName,
title: row.title,
description: row.description ?? '',
icon: row.icon ?? ''
}
})
}
/**
* List one folder the way a reader browses it: the pages they may open and the folders worth
* opening, and nothing else.

@ -0,0 +1,289 @@
import { LitElement, html, css } from 'lit'
/**
* Block Countdown
*/
export class BlockCountdownElement extends LitElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*/
static definition = {
block: 'countdown',
name: 'Countdown',
description: 'Counts down to a date and time.',
icon: 'timer',
props: [
{
name: 'date',
type: 'string',
label: 'Target Date',
hint: 'ISO date and time, e.g. 2026-12-25T09:00. Read in the timezone below unless it carries an offset of its own.',
required: true
},
{
name: 'timezone',
type: 'string',
label: 'Timezone',
hint: "IANA name, e.g. Europe/Paris. The reader's own timezone when empty.",
default: 'UTC'
},
{
name: 'label',
type: 'string',
label: 'Label',
hint: 'What is being counted down to. Shown above the numbers.'
},
{
name: 'expiredMsg',
type: 'string',
label: 'Ended Message',
hint: 'Shown once the target has passed.',
default: 'The countdown has ended.'
}
]
}
static get styles() {
return css`
:host {
display: block;
}
/*
The gap below a block lives on this element, not on :host.
The app resets the margin on every element, and a rule in the page beats a :host rule in the
shadow tree whatever its specificity -- so a margin set on the host is simply dropped. Set
inside the shadow root it is out of that rule's reach, and collapses out through the host,
which carries no padding or border of its own.
*/
.countdown,
.error {
margin-bottom: 16px;
}
.countdown {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 5px;
padding: 1rem;
text-align: center;
background-image: linear-gradient(to bottom, #fff, #fafafa);
}
:host-context(body.body--dark) .countdown {
border-color: rgba(255, 255, 255, 0.15);
background-image: linear-gradient(to bottom, #161b22, #0d1117);
}
.label {
font-weight: 500;
font-size: 1.1em;
margin-bottom: 0.75rem;
}
.segments {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.segment {
min-width: 72px;
padding: 0.5rem 0.75rem;
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.04);
}
:host-context(body.body--dark) .segment {
background-color: rgba(255, 255, 255, 0.06);
}
.value {
font-size: 2rem;
font-weight: 500;
line-height: 1.1;
font-variant-numeric: tabular-nums;
color: var(--q-primary, #1976d2);
}
.unit {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.7;
}
.target {
margin-top: 0.75rem;
font-size: 0.8em;
opacity: 0.7;
}
.ended {
font-weight: 500;
}
.error {
color: var(--q-negative, #c10015);
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
border-radius: 5px;
padding: 1rem;
}
`
}
static get properties() {
return {
/**
* Target date and time, ISO 8601
* @type {string}
*/
date: { type: String },
/**
* IANA timezone the target is expressed in
* @type {string}
*/
timezone: { type: String },
/**
* What the countdown is for
* @type {string}
*/
label: { type: String },
/**
* Shown once the target has passed
* @type {string}
*/
expiredMsg: { type: String },
// Internal Properties
_remaining: { state: true },
_error: { state: true }
}
}
constructor() {
super()
this.date = ''
this.timezone = 'UTC'
this.label = ''
this.expiredMsg = 'The countdown has ended.'
this._remaining = null
this._error = ''
this._target = null
this._timer = null
}
/**
* Resolve the target into a zoned instant.
*
* A date carrying its own offset `2026-12-25T09:00-05:00`, or a trailing `Z` is an exact moment
* and the timezone only decides how it is displayed. Without one it is a wall-clock time, which is
* what an author writing "the ninth of December at nine" means, and the timezone is what turns it
* into a moment. Both then count down to the same instant for every reader, wherever they are.
*/
_resolveTarget(zone) {
try {
return Temporal.Instant.from(this.date).toZonedDateTimeISO(zone)
} catch {
return Temporal.PlainDateTime.from(this.date).toZonedDateTime(zone)
}
}
_tick() {
const now = Temporal.Now.zonedDateTimeISO(this._target.timeZoneId)
if (Temporal.ZonedDateTime.compare(now, this._target) >= 0) {
this._remaining = null
this._stop()
return
}
// -> Through ZonedDateTime rather than Instant, so that a day is a day across a DST change and
// not always exactly 24 hours
this._remaining = now.until(this._target, { largestUnit: 'day', smallestUnit: 'second' })
}
_stop() {
clearInterval(this._timer)
this._timer = null
}
connectedCallback() {
super.connectedCallback()
// -> An empty timezone means the reader's own, which is also what an unknown one must not
// silently become: a countdown to the wrong moment is worse than a visible mistake
const zone = this.timezone?.trim() || Temporal.Now.timeZoneId()
try {
Temporal.Now.zonedDateTimeISO(zone)
} catch {
this._error = `"${zone}" is not a known timezone.`
return
}
try {
this._target = this._resolveTarget(zone)
} catch {
this._error = `"${this.date}" is not a date this can count down to.`
return
}
this._tick()
if (this._remaining) {
this._timer = setInterval(() => this._tick(), 1000)
}
}
disconnectedCallback() {
super.disconnectedCallback()
this._stop()
}
_segment(value, unit) {
return html`
<div class="segment">
<div class="value">${value}</div>
<div class="unit">${value === 1 ? unit : `${unit}s`}</div>
</div>
`
}
render() {
if (this._error) {
return html`<div class="error">${this._error}</div>`
}
if (!this._target) {
return null
}
/*
Spelled out field by field rather than with `dateStyle` / `timeStyle`, which cannot be combined
with `timeZoneName` and the zone is the point: a reader in another country needs to see which
clock the target is on, not just a time that does not match their own.
*/
const at = this._target.toLocaleString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
})
return html`
<div class="countdown">
${this.label ? html`<div class="label">${this.label}</div>` : null}
${this._remaining
? html`
<div class="segments">
${this._remaining.days > 0 ? this._segment(this._remaining.days, 'Day') : null}
${this._segment(this._remaining.hours, 'Hour')}
${this._segment(this._remaining.minutes, 'Minute')}
${this._segment(this._remaining.seconds, 'Second')}
</div>
`
: html`<div class="ended">${this.expiredMsg}</div>`}
<div class="target">${at}</div>
</div>
`
}
}
window.customElements.define('block-countdown', BlockCountdownElement)

@ -0,0 +1,217 @@
import { LitElement, html } from 'lit'
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
/** How many includes may nest before the chain is treated as a mistake. */
const MAX_DEPTH = 3
/**
* Strip a path down to the form the server stores, so that `/Foo/Bar/` and `foo/bar` are one page
* when the chain below is checked for a cycle.
*/
function normalizePath(path) {
return (path ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '').toLowerCase() || 'home'
}
/**
* Block Include
*/
export class BlockIncludeElement extends LitElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*/
static definition = {
block: 'include',
name: 'Include',
description: 'Transclude the contents of another page inside this one.',
icon: 'duplicate',
props: [
{
name: 'path',
type: 'string',
label: 'Page Path',
hint: 'Path of the page to include, without a leading slash.',
required: true
},
{
name: 'locale',
type: 'string',
label: 'Locale',
hint: "Locale of the page to include. This page's own locale when empty."
},
{
name: 'showTitle',
type: 'boolean',
label: 'Show Title',
hint: "Draw the included page's title above it."
}
]
}
static get properties() {
return {
/**
* Path of the page to include
* @type {string}
*/
path: { type: String },
/**
* Locale of the page to include
* @type {string}
*/
locale: { type: String },
/**
* Whether to draw the included page's title above it
* @type {boolean}
*/
showTitle: { type: Boolean },
// Internal Properties
_loading: { state: true },
_title: { state: true },
_render: { state: true },
_error: { state: true }
}
}
/*
Rendered into the light DOM, unlike every other block: what comes back is page content, and page
content is styled by the stylesheet the article itself is drawn with. In a shadow root it would
arrive unstyled, and the whole point is that an included page reads as part of the page including
it. It also puts nested blocks where the DOM walk below can see them.
*/
createRenderRoot() {
// -> A box of its own, set inline because the page resets the margin and display of everything
// in it and a light-DOM block has no `:host` rule to be styled by. The spacing below comes
// from the included content's own last element, which is page content like any other.
this.style.display = 'block'
return this
}
constructor() {
super()
this._loading = true
this._title = ''
this._render = ''
this._error = ''
this.path = ''
this.locale = ''
this.showTitle = false
}
/**
* Every page already on screen above this element, innermost first.
*
* A loop a page including itself, or two pages including each other would otherwise fetch and
* draw forever, since each copy arrives carrying the element that fetched it.
*
* The page being read counts as the outermost link, and that is the part that matters: without it
* a mutual pair only trips on the second lap, after fetching and drawing one pointless extra copy
* of each page. With it, the loop is refused at the exact point it would close, before a request
* goes out.
*/
_ancestorPaths() {
const paths = []
let parent = this.parentElement?.closest('block-include')
while (parent) {
paths.push(normalizePath(parent.getAttribute('path')))
parent = parent.parentElement?.closest('block-include')
}
paths.push(normalizePath(WIKI_STATE.page.path))
return paths
}
/**
* Fetch the components for any block the included page brought with it.
*
* The page view scans for undefined elements once, when it loads a page, so anything arriving
* afterwards has to ask for itself. Same contract: the element's tag names the file to fetch.
*/
async _loadNestedBlocks() {
for (const el of this.querySelectorAll(':not(:defined)')) {
const tag = el.tagName.toLowerCase()
if (!tag.startsWith('block-')) {
continue
}
try {
await import(/* @vite-ignore */ `/_blocks/${tag}.js`)
} catch (err) {
console.warn(`Failed to load ${tag}: ${err.message}`)
}
}
}
async connectedCallback() {
super.connectedCallback()
const path = normalizePath(this.path)
const chain = this._ancestorPaths()
if (chain.includes(path)) {
// -> A page naming itself is its author's own doing; anything longer went round other pages,
// and saying which one closes the loop is the part that helps
this._error =
chain.length === 1
? 'This page includes itself.'
: `Including "${path}" here would loop: it is already open above.`
} else if (chain.length > MAX_DEPTH) {
this._error = `Includes are nested more than ${MAX_DEPTH} pages deep.`
} else {
try {
const page = await API_CLIENT.get(`sites/${WIKI_STATE.site.id}/pages/include`, {
searchParams: {
path,
locale: this.locale || WIKI_STATE.page.locale
}
}).json()
if (page.isLocked) {
// -> Withheld by the server, which is the same answer this reader gets by opening the page.
// The unlock prompt lives there, so this points at it rather than asking for a password.
this._error = `The page "${path}" is password protected. Open it to enter the password.`
} else {
this._title = page.title
this._render = page.render
}
} catch (err) {
this._error =
err.response?.status === 404
? `There is no page at "${path}".`
: `The page "${path}" could not be included.`
}
}
this._loading = false
if (this._render) {
// -> After the render lands in the DOM, since that is what it walks
await this.updateComplete
await this._loadNestedBlocks()
}
}
render() {
if (this._loading) {
return null
}
if (this._error) {
return html`
<div
style="
color: var(--q-negative, #c10015);
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
border-radius: 5px;
padding: 1rem;
margin-bottom: 16px;
">
${this._error}
</div>
`
}
return html`
${this.showTitle ? html`<h2>${this._title}</h2>` : null}${unsafeHTML(this._render)}
`
}
}
window.customElements.define('block-include', BlockIncludeElement)

@ -1,31 +1,97 @@
import { LitElement, html, css } from 'lit'
import treeQuery from './tree.graphql'
/**
* Block Index
*/
export class BlockIndexElement extends LitElement {
/**
* Metadata for the admin area. Collected at build time into `compiled/blocks.manifest.json`,
* which the server reads to register the block. Values must be plain literals.
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals.
*
* `props` is what the picker turns into a form, and therefore what an author can set from the
* editor: one entry per attribute worth writing into the page, in the order they should be asked
* for. It mirrors `static get properties()` below that one tells Lit how to read an attribute at
* runtime, this one describes it to a person so a property meant to be authored belongs in both.
* It is also what survives being saved: the renderer strips any attribute a block does not declare.
*
* A `boolean` prop must default to false. MDC writes attributes as strings and Lit reads any
* attribute that is present as true, so `showThing="false"` would come out true the picker leaves
* a prop out entirely when it still holds its default, which is what keeps false meaning false.
*/
static definition = {
block: 'index',
name: 'Index',
description: 'Displays a list of pages contained in a folder.',
icon: 'index'
icon: 'index',
props: [
{
name: 'path',
type: 'string',
label: 'Path',
hint: 'Folder to list pages from, without a leading slash. Empty means the site root.'
},
{
name: 'tags',
type: 'string',
label: 'Tags',
hint: 'Comma-separated list of tags a page must carry.'
},
{
name: 'limit',
type: 'number',
label: 'Limit',
hint: 'Maximum number of pages to list.',
default: 10
},
{
name: 'orderBy',
type: 'select',
label: 'Order By',
options: ['title', 'fileName', 'createdAt', 'updatedAt'],
default: 'title'
},
{
name: 'orderByDirection',
type: 'select',
label: 'Direction',
options: ['asc', 'desc'],
default: 'asc'
},
{
name: 'depth',
type: 'number',
label: 'Depth',
hint: 'How many folders below the path to include. 0 is the folder itself.',
default: 0
},
{
name: 'noResultMsg',
type: 'string',
label: 'Empty Message',
hint: 'Shown when the query matches no pages.',
default: 'No pages matching your query.'
}
]
}
static get styles() {
return css`
:host {
display: block;
margin-bottom: 16px;
}
/*
The gap below a block lives on this element, not on :host.
The app resets the margin on every element, and a rule in the page beats a :host rule in the
shadow tree whatever its specificity -- so a margin set on the host is simply dropped. Set
inside the shadow root it is out of that rule's reach, and collapses out through the host,
which carries no padding or border of its own.
*/
ul {
padding: 0;
margin: 0;
margin: 0 0 16px;
list-style: none;
display: grid;
grid-auto-flow: row;
@ -104,6 +170,7 @@ export class BlockIndexElement extends LitElement {
}
.no-links {
margin-bottom: 16px;
color: var(--q-negative);
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
border-radius: 5px;
@ -178,23 +245,20 @@ export class BlockIndexElement extends LitElement {
async connectedCallback() {
super.connectedCallback()
try {
const resp = await APOLLO_CLIENT.query({
query: treeQuery,
variables: {
siteId: WIKI_STATE.site.id,
// -> The app's own HTTP client, so a signed-in reader's token comes along and the listing is
// the one they would get anywhere else. Only pages they may open come back.
const pages = await API_CLIENT.get(`sites/${WIKI_STATE.site.id}/tree/pages`, {
searchParams: {
locale: WIKI_STATE.page.locale,
parentPath: this.path,
path: this.path,
limit: this.limit,
orderBy: this.orderBy,
orderByDirection: this.orderByDirection,
depth: this.depth,
...this.tags && { tags: this.tags.split(',').map(t => t.trim()).filter(t => t) },
tags: this.tags
}
})
this._pages = resp.data.tree.map(p => ({
...p,
href: p.folderPath ? `/${p.folderPath}/${p.fileName}` : `/${p.fileName}`
}))
}).json()
this._pages = pages.map((p) => ({ ...p, href: `/${p.path}` }))
} catch (err) {
console.warn(err)
}
@ -202,26 +266,31 @@ export class BlockIndexElement extends LitElement {
}
render() {
return this._pages.length > 0 || this._loading ? html`
<ul>
${this._pages.map(p =>
html`<li>
<a href="${p.href}" @click="${this._navigate}">
${p.title}
${p.description ? html`<span>${p.description}</span>` : null}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48px" height="48px">
<path d="M 24 4 C 12.972292 4 4 12.972292 4 24 C 4 32.465211 9.2720863 39.722981 16.724609 42.634766 A 1.50015 1.50015 0 1 0 17.816406 39.841797 C 11.48893 37.369581 7 31.220789 7 24 C 7 14.593708 14.593708 7 24 7 A 1.50015 1.50015 0 1 0 24 4 z M 32.734375 6.1816406 A 1.50015 1.50015 0 0 0 32.033203 9.0136719 C 37.368997 11.880008 41 17.504745 41 24 C 41 33.406292 33.406292 41 24 41 A 1.50015 1.50015 0 1 0 24 44 C 35.027708 44 44 35.027708 44 24 C 44 16.385255 39.733331 9.7447579 33.453125 6.3710938 A 1.50015 1.50015 0 0 0 32.734375 6.1816406 z M 25.484375 16.484375 A 1.50015 1.50015 0 0 0 24.439453 19.060547 L 27.878906 22.5 L 16.5 22.5 A 1.50015 1.50015 0 1 0 16.5 25.5 L 27.878906 25.5 L 24.439453 28.939453 A 1.50015 1.50015 0 1 0 26.560547 31.060547 L 32.560547 25.060547 A 1.50015 1.50015 0 0 0 32.560547 22.939453 L 26.560547 16.939453 A 1.50015 1.50015 0 0 0 25.484375 16.484375 z"/>
</svg>
</a>
</li>`
)}
</ul>
` : html`
<div class="no-links">${this.noResultMsg}</div>
`
return this._pages.length > 0 || this._loading
? html`
<ul>
${this._pages.map(
(p) =>
html`<li>
<a href="${p.href}" @click="${this._navigate}">
${p.title} ${p.description ? html`<span>${p.description}</span>` : null}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width="48px"
height="48px">
<path
d="M 24 4 C 12.972292 4 4 12.972292 4 24 C 4 32.465211 9.2720863 39.722981 16.724609 42.634766 A 1.50015 1.50015 0 1 0 17.816406 39.841797 C 11.48893 37.369581 7 31.220789 7 24 C 7 14.593708 14.593708 7 24 7 A 1.50015 1.50015 0 1 0 24 4 z M 32.734375 6.1816406 A 1.50015 1.50015 0 0 0 32.033203 9.0136719 C 37.368997 11.880008 41 17.504745 41 24 C 41 33.406292 33.406292 41 24 41 A 1.50015 1.50015 0 1 0 24 44 C 35.027708 44 44 35.027708 44 24 C 44 16.385255 39.733331 9.7447579 33.453125 6.3710938 A 1.50015 1.50015 0 0 0 32.734375 6.1816406 z M 25.484375 16.484375 A 1.50015 1.50015 0 0 0 24.439453 19.060547 L 27.878906 22.5 L 16.5 22.5 A 1.50015 1.50015 0 1 0 16.5 25.5 L 27.878906 25.5 L 24.439453 28.939453 A 1.50015 1.50015 0 1 0 26.560547 31.060547 L 32.560547 25.060547 A 1.50015 1.50015 0 0 0 32.560547 22.939453 L 26.560547 16.939453 A 1.50015 1.50015 0 0 0 25.484375 16.484375 z" />
</svg>
</a>
</li>`
)}
</ul>
`
: html` <div class="no-links">${this.noResultMsg}</div> `
}
_navigate (e) {
_navigate(e) {
e.preventDefault()
WIKI_ROUTER.push(e.target.getAttribute('href'))
}

@ -1,30 +0,0 @@
query blockIndexFetchPages (
$siteId: UUID!
$locale: String
$parentPath: String
$tags: [String]
$limit: Int
$orderBy: TreeOrderBy
$orderByDirection: OrderByDirection
$depth: Int
) {
tree(
siteId: $siteId
locale: $locale
parentPath: $parentPath
tags: $tags
limit: $limit
types: [page]
orderBy: $orderBy
orderByDirection: $orderByDirection
depth: $depth
) {
id
folderPath
fileName
title
...on TreeItemPage {
description
}
}
}

@ -0,0 +1,360 @@
import { LitElement, html, css } from 'lit'
import { load as parseYaml } from 'js-yaml'
/**
* Yes and no, drawn rather than spelled out.
*
* A column of "true"/"false" is read word by word; a tick and a cross are read at a glance, which is
* what an infobox is for. Inline, because they are the same two pictures on every infobox there is,
* and labelled, since the shape alone means nothing to a screen reader.
*/
const YES_SVG = html`
<svg viewBox="0 0 24 24" width="18" height="18" role="img" aria-label="Yes" class="yes">
<path fill="currentColor" d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
`
const NO_SVG = html`
<svg viewBox="0 0 24 24" width="18" height="18" role="img" aria-label="No" class="no">
<path
fill="currentColor"
d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
`
/**
* One value, as it is shown.
*
* A list reads as one line, since an infobox row is a line: "French, English" rather than a bullet
* list squeezed into half a column.
*/
function valueOf(value) {
if (typeof value === 'boolean') {
return value ? YES_SVG : NO_SVG
}
if (Array.isArray(value)) {
// -> Joined by hand rather than with `join`, so that a boolean among them is still drawn
return value.map((entry, index) => html`${index > 0 ? ', ' : ''}${valueOf(entry)}`)
}
return String(value)
}
/**
* The rows a value turns into.
*
* A nested mapping becomes a group of its own with a heading, which is how an infobox shows a cluster
* of related facts. Anything else is a single row.
*/
function rowsOf(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.entries(value).map(([label, nested]) => ({ label, value: nested }))
}
return [{ value }]
}
/**
* Block Infobox
*/
export class BlockInfoboxElement extends LitElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*/
static definition = {
block: 'infobox',
name: 'Infobox',
description: 'A summary box beside the text, filled in from a list of facts.',
icon: 'data-sheet',
template: `City: Montreal
Country: Canada
Metro: true
"Key with space": foo-bar`,
props: [
{
name: 'name',
type: 'string',
label: 'Name',
hint: 'Heading at the top of the box.',
required: true
},
{
name: 'image',
type: 'string',
label: 'Image URL',
hint: 'Path or URL of a picture to show under the heading.'
},
{
name: 'imageCaption',
type: 'string',
label: 'Image Caption',
hint: 'Shown under the picture.'
}
]
}
static get styles() {
return css`
/*
Floated, so the article runs down its left and closes under it the whole point of an
infobox. The margin carries !important because the app resets the margin of everything in a
page, and a rule in the page beats a :host rule however specific; a declaration marked
important in a shadow tree is the one thing that outranks it. See block-index for the usual
way round this, which does not work on a float: a float collapses no margins.
*/
:host {
display: block;
float: right;
clear: right;
width: 320px;
max-width: 100%;
margin: 4px 0 16px 24px !important;
/*
A layer of its own, above the article's own decoration. A heading draws its rule as an
absolutely positioned pseudo-element spanning the whole column, and a positioned element
paints over a float whichever way round the two are written so the rule ran straight
across the box. This is the right way round anyway: the box is a card sitting on the page,
and the rule belongs to the text it is sitting on.
*/
position: relative;
z-index: 1;
}
/* -> Below a certain width the column cannot spare 320px, and a full-width card reads better */
@media (max-width: 800px) {
:host {
float: none;
width: auto;
margin: 0 0 16px !important;
}
}
.infobox {
border: 1px solid var(--infobox-border);
border-radius: 6px;
background-color: var(--infobox-bg);
font-size: 0.85em;
line-height: 1.45;
overflow: hidden;
}
.name {
padding: 10px 12px;
border-bottom: 1px solid var(--infobox-border);
background-color: var(--infobox-head);
font-size: 1.1em;
font-weight: 600;
text-align: center;
}
figure {
margin: 0;
padding: 12px 12px 0;
text-align: center;
}
img {
display: block;
width: 100%;
height: auto;
border-radius: 4px;
}
figcaption {
padding-top: 6px;
font-size: 0.9em;
opacity: 0.75;
}
dl {
display: grid;
grid-template-columns: minmax(6em, auto) 1fr;
gap: 0;
margin: 0;
padding: 0;
}
dt,
dd {
margin: 0;
padding: 7px 12px;
border-top: 1px solid var(--infobox-rule);
}
dl > :is(dt, dd):is(:first-child, :nth-child(2)) {
border-top: 0;
}
dt {
font-weight: 600;
overflow-wrap: anywhere;
}
dd {
overflow-wrap: anywhere;
}
/* -> A nested mapping: its own heading across both columns, then its rows under it */
.group {
grid-column: 1 / -1;
padding: 7px 12px;
border-top: 1px solid var(--infobox-rule);
background-color: var(--infobox-head);
font-weight: 600;
text-align: center;
}
.yes {
color: var(--q-positive, #02c39a);
vertical-align: -3px;
}
.no {
color: var(--q-negative, #c10015);
vertical-align: -3px;
}
.error {
padding: 10px 12px;
color: var(--q-negative, #c10015);
}
:host {
--infobox-border: #d5d5d5;
--infobox-bg: #f8f9fa;
--infobox-head: #eaecf0;
--infobox-rule: #e3e5e8;
}
:host-context(body.body--dark) {
--infobox-border: rgba(255, 255, 255, 0.15);
--infobox-bg: #161b22;
--infobox-head: #1e232a;
--infobox-rule: rgba(255, 255, 255, 0.1);
}
`
}
static get properties() {
return {
/**
* Heading at the top of the box
* @type {string}
*/
name: { type: String },
/**
* Path or URL of a picture
* @type {string}
*/
image: { type: String },
/**
* Caption under the picture
* @type {string}
*/
imageCaption: { type: String },
// Internal Properties
_entries: { state: true },
_error: { state: true }
}
}
constructor() {
super()
this.name = ''
this.image = ''
this.imageCaption = ''
this._entries = []
this._error = ''
}
/**
* Read the facts out of the block's body.
*
* The body has been through markdown by the time it gets here, so what is left of `city: Montreal`
* is its text which is all YAML needs. Markdown does leave its mark: a value written with
* emphasis or a link keeps the words and loses the markup, and anything markdown reads as
* structure of its own (a line opening with `-`, `#` or `>`) arrives rearranged. A fenced code
* block is the way out of that, since its contents reach here exactly as they were typed.
*/
/**
* Hand the first line of the column back its place at the top.
*
* The content stylesheet drops the top margin of the first element in a page, because the space
* above it belongs to the container. A floated infobox at the very top takes that reset with it and
* leaves the heading behind it holding a full margin so the heading, which is what a reader sees
* as the start of the page, sits an inch below the box beside it. Passed on to whatever follows,
* since that is the element the rule was written for.
*
* Two pixels rather than none: the box's own top margin and border sit in that space, and the two
* together put the rule under a page title on the rule under the box's name the line the eye
* follows across from one to the other.
*/
_alignWithTop() {
if (this.previousElementSibling) {
return
}
this.nextElementSibling?.style.setProperty('margin-top', '2px')
}
connectedCallback() {
super.connectedCallback()
this._alignWithTop()
const source = (this.querySelector('pre') ?? this).textContent ?? ''
if (!source.trim()) {
return
}
let parsed
try {
parsed = parseYaml(source)
} catch (err) {
// -> Naming the fence, because it is the answer nine times out of ten: markdown reads an
// indented line as structure of its own and hands this the text without the indentation
this._error = `This infobox could not be read: ${err.reason ?? err.message}. Anything indented — a list, or a nested group — has to go inside a fenced code block.`
return
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
this._error = 'An infobox is a list of "key: value" lines.'
return
}
this._entries = Object.entries(parsed)
}
render() {
return html`
<aside class="infobox">
<div class="name">${this.name}</div>
${this.image
? html`
<figure>
<img src="${this.image}" alt="${this.imageCaption || this.name}" />
${this.imageCaption ? html`<figcaption>${this.imageCaption}</figcaption>` : null}
</figure>
`
: null}
${this._error ? html`<div class="error">${this._error}</div>` : null}
${this._entries.length > 0
? html`
<dl>
${this._entries.map(([label, value]) => {
const rows = rowsOf(value)
const isGroup = rows.length > 1 || rows[0].label !== undefined
return html`
${isGroup ? html`<div class="group">${label}</div>` : null}
${rows.map(
(row) => html`
<dt>${isGroup ? row.label : label}</dt>
<dd>${valueOf(row.value)}</dd>
`
)}
`
})}
</dl>
`
: null}
</aside>
`
}
}
window.customElements.define('block-infobox', BlockInfoboxElement)

@ -0,0 +1,228 @@
import { LitElement, html, css, unsafeCSS } from 'lit'
// -> The ESM build by name: leaflet's `main` is still the UMD bundle, which rollup can only take
// apart with a commonjs plugin, and it has no `exports` map to pick the module build for us
import * as L from 'leaflet/dist/leaflet-src.esm.js'
import leafletCss from 'leaflet/dist/leaflet.css'
/**
* The marker, drawn rather than fetched.
*
* Leaflet's default icon is a pair of PNGs it builds a URL for at runtime, which does not survive
* bundling and an inline pin is one less request for a block that already asks for map tiles.
*/
const MARKER_SVG = `
<svg viewBox="0 0 24 36" width="24" height="36" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24c0-6.6-5.4-12-12-12z" fill="#c62828"/>
<circle cx="12" cy="12" r="4.5" fill="#fff"/>
</svg>
`
/**
* Block Map
*/
export class BlockMapElement extends LitElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*/
static definition = {
block: 'map',
name: 'Map',
description: 'Shows a location on an OpenStreetMap map.',
icon: 'geography',
props: [
{
name: 'lat',
type: 'number',
label: 'Latitude',
hint: 'Decimal degrees, e.g. 45.5019.',
required: true
},
{
name: 'lon',
type: 'number',
label: 'Longitude',
hint: 'Decimal degrees, e.g. -73.5674.',
required: true
},
{
name: 'zoom',
type: 'number',
label: 'Zoom',
hint: '1 is the whole world, 19 is a single building.',
default: 13
},
{
name: 'height',
type: 'number',
label: 'Height',
hint: 'Height of the map in pixels.',
default: 400
},
{
name: 'label',
type: 'string',
label: 'Marker Label',
hint: 'Shown in a popup when the marker is clicked. The marker is drawn either way.'
}
]
}
static get styles() {
return [
unsafeCSS(leafletCss),
css`
:host {
display: block;
}
/*
The gap below a block lives on this element, not on :host.
The app resets the margin on every element, and a rule in the page beats a :host rule in the
shadow tree whatever its specificity -- so a margin set on the host is simply dropped. Set
inside the shadow root it is out of that rule's reach, and collapses out through the host,
which carries no padding or border of its own.
*/
.map,
.error {
margin-bottom: 16px;
}
.map {
width: 100%;
border-radius: 5px;
border: 1px solid rgba(0, 0, 0, 0.1);
background-color: #f2efe9;
}
:host-context(body.body--dark) .map {
border-color: rgba(255, 255, 255, 0.15);
background-color: #16130f;
}
/* -> The tiles are somebody else's work and the licence asks for the credit to be visible */
.leaflet-container .leaflet-control-attribution {
font-size: 10px;
}
.error {
color: var(--q-negative, #c10015);
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
border-radius: 5px;
padding: 1rem;
}
`
]
}
static get properties() {
return {
/**
* Latitude in decimal degrees
* @type {number}
*/
lat: { type: Number },
/**
* Longitude in decimal degrees
* @type {number}
*/
lon: { type: Number },
/**
* Zoom level, 1 (world) to 19 (building)
* @type {number}
*/
zoom: { type: Number },
/**
* Height of the map in pixels
* @type {number}
*/
height: { type: Number },
/**
* Popup text for the marker
* @type {string}
*/
label: { type: String },
// Internal Properties
_error: { state: true }
}
}
constructor() {
super()
this.lat = null
this.lon = null
this.zoom = 13
this.height = 400
this.label = ''
this._error = ''
this._map = null
}
firstUpdated() {
const lat = Number(this.lat)
const lon = Number(this.lon)
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
) {
this._error =
'This map needs a latitude between -90 and 90 and a longitude between -180 and 180.'
return
}
const container = this.renderRoot.querySelector('.map')
this._map = L.map(container, {
center: [lat, lon],
zoom: Math.min(Math.max(Number(this.zoom) || 13, 1), 19),
// -> A map in the middle of an article must not swallow the wheel while the reader is scrolling
// past it. Clicking the map is the reader saying they meant to use it.
scrollWheelZoom: false
})
this._map.on('click', () => this._map.scrollWheelZoom.enable())
this._map.on('mouseout', () => this._map.scrollWheelZoom.disable())
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(this._map)
const marker = L.marker([lat, lon], {
icon: L.divIcon({
html: MARKER_SVG,
className: '',
iconSize: [24, 36],
iconAnchor: [12, 36],
popupAnchor: [0, -32]
}),
// -> The map is a picture of a place, not a form: there is nothing to be gained by moving it
keyboard: false
}).addTo(this._map)
if (this.label) {
marker.bindPopup(this.label)
}
}
disconnectedCallback() {
super.disconnectedCallback()
// -> Leaflet keeps listeners on window and a resize observer, which outlive the element otherwise
this._map?.remove()
this._map = null
}
render() {
if (this._error) {
return html`<div class="error">${this._error}</div>`
}
return html`<div class="map" style="height: ${Number(this.height) || 400}px"></div>`
}
}
window.customElements.define('block-map', BlockMapElement)

@ -5,14 +5,24 @@ import { LitElement, html, css } from 'lit'
*/
export class BlockMediaPlayerElement extends LitElement {
/**
* Metadata for the admin area. Collected at build time into `compiled/blocks.manifest.json`,
* which the server reads to register the block. Values must be plain literals.
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*/
static definition = {
block: 'media-player',
name: 'Media Player',
description: 'Plays an audio or video file inline.',
icon: 'widescreen'
icon: 'widescreen',
props: [
{
name: 'src',
type: 'string',
label: 'Source URL',
hint: 'Path or URL of the audio or video file to play.',
required: true
}
]
}
static get styles() {
@ -21,7 +31,9 @@ export class BlockMediaPlayerElement extends LitElement {
display: block;
}
/* -> The gap below the block. On this element rather than :host: see block-index. */
.container {
margin-bottom: 16px;
overflow: hidden;
border-radius: 5px;
position: relative;
@ -35,7 +47,7 @@ export class BlockMediaPlayerElement extends LitElement {
* Source URL
* @type {string}
*/
src: { type: String },
src: { type: String }
// Internal Properties
// _loading: { state: true }
@ -74,7 +86,7 @@ export class BlockMediaPlayerElement extends LitElement {
return html`
<div class="container">
<video class="video-display" controls>
<source src="${this.src}" type="video/mp4">
<source src="${this.src}" type="video/mp4" />
</video>
</div>
`

@ -0,0 +1,165 @@
import { LitElement, html, css } from 'lit'
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
import { renderSVG } from 'uqr'
/**
* Block QR Code
*/
export class BlockQrCodeElement extends LitElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*/
static definition = {
block: 'qr-code',
name: 'QR Code',
description: 'Shows a QR code for a link or a piece of text.',
icon: 'scan-stock',
props: [
{
name: 'value',
type: 'string',
label: 'Content',
hint: 'Text or URL to encode. The address of this page when left empty.'
},
{
name: 'size',
type: 'number',
label: 'Size',
hint: 'Width of the code in pixels.',
default: 180
},
{
name: 'caption',
type: 'string',
label: 'Caption',
hint: 'Shown under the code.'
}
]
}
static get styles() {
return css`
:host {
display: block;
}
/* -> The gap below the block. On this element rather than :host: see block-index. */
.qr {
margin-bottom: 16px;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 5px;
/*
White in both themes, and padded: a code is read by a camera looking for dark squares on a
light field, so inverting it for dark mode would make it harder to scan, not easier.
*/
background-color: #fff;
}
:host-context(body.body--dark) .qr {
border-color: rgba(255, 255, 255, 0.15);
}
/* -> The drawing is sized here, so the box grows by its own padding rather than eating into it */
.qr svg {
display: block;
width: var(--qr-size);
height: auto;
}
.caption {
max-width: var(--qr-size);
color: #424242;
font-size: 0.8em;
text-align: center;
overflow-wrap: anywhere;
}
.error {
color: var(--q-negative, #c10015);
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
border-radius: 5px;
padding: 1rem;
margin-bottom: 16px;
}
`
}
static get properties() {
return {
/**
* Text or URL to encode
* @type {string}
*/
value: { type: String },
/**
* Width of the code in pixels
* @type {number}
*/
size: { type: Number },
/**
* Text shown under the code
* @type {string}
*/
caption: { type: String },
// Internal Properties
_svg: { state: true },
_error: { state: true }
}
}
constructor() {
super()
this.value = ''
this.size = 180
this.caption = ''
this._svg = ''
this._error = ''
}
/**
* What the code stands for.
*
* An empty `value` means this page, which is the common case a printed page, or a screen someone
* wants to carry on their phone. Taken from the address bar rather than built from the site config,
* so it is the URL the reader is actually looking at, and without the fragment, which points at a
* place on the page rather than at the page.
*/
_encoded() {
return this.value?.trim() || `${window.location.origin}${window.location.pathname}`
}
connectedCallback() {
super.connectedCallback()
try {
// -> Drawn at a fixed scale and sized by CSS, so the same markup is crisp at any width
this._svg = renderSVG(this._encoded(), { border: 1, pixelSize: 8 })
} catch {
// -> Every symbol size has a ceiling, and a long enough string clears the largest of them
this._error = 'This is too long to fit in a QR code.'
}
}
render() {
if (this._error) {
return html`<div class="error">${this._error}</div>`
}
const size = `${Math.min(Math.max(Number(this.size) || 180, 80), 600)}px`
return html`
<div class="qr" style="--qr-size: ${size}">
${unsafeSVG(this._svg)}
${this.caption ? html`<div class="caption">${this.caption}</div>` : null}
</div>
`
}
}
window.customElements.define('block-qr-code', BlockQrCodeElement)

@ -0,0 +1,186 @@
import { LitElement, html, css } from 'lit'
/** A crossed-out eye, drawn rather than fetched: it is the same picture on every spoiler there is. */
const EYE_OFF_SVG = html`
<svg viewBox="0 0 24 24" width="32" height="32" aria-hidden="true">
<path
fill="currentColor"
d="M2 5.27 3.28 4 20 20.72 18.73 22l-3.08-3.08A11.4 11.4 0 0 1 12 19.5c-5 0-9.27-3.11-11-7.5a12.2 12.2 0 0 1 4.06-5.17zm10 3.23a3.5 3.5 0 0 1 3.5 3.5c0 .47-.1.92-.27 1.33l-4.56-4.56c.41-.17.86-.27 1.33-.27M12 4.5c5 0 9.27 3.11 11 7.5a12.1 12.1 0 0 1-3.19 4.53l-2.72-2.72c.26-.55.41-1.16.41-1.81a5.5 5.5 0 0 0-5.5-5.5c-.65 0-1.26.15-1.81.41L7.96 4.96A11.4 11.4 0 0 1 12 4.5M6.5 12a5.5 5.5 0 0 0 5.5 5.5c.42 0 .83-.05 1.22-.14l-6.58-6.58c-.09.39-.14.8-.14 1.22" />
</svg>
`
/**
* Block Spoiler
*/
export class BlockSpoilerElement extends LitElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*/
static definition = {
block: 'spoiler',
name: 'Spoiler',
description: 'Hides content behind a cover until it is clicked.',
icon: 'visualy-impaired',
template: 'The content to hide.',
props: [
{
name: 'label',
type: 'string',
label: 'Label',
hint: 'Heading on the cover.',
default: 'Spoiler'
},
{
name: 'hint',
type: 'string',
label: 'Hint',
hint: 'Line under the label.',
default: 'Click to show content'
}
]
}
static get styles() {
return css`
:host {
display: block;
}
/*
The content is laid out either way and only hidden from view, so the box is exactly as tall
covered as it is revealed and nothing below it moves when a reader opens it. Hiding it by
visibility is what does that: display:none would collapse the box, and a blur or a mask leaves
the text on screen for anyone who looks closely enough at the pixels.
*/
.spoiler {
position: relative;
margin-bottom: 16px;
min-height: 76px;
padding: 16px 20px;
border: 1px solid var(--spoiler-border);
border-radius: 6px;
background-color: var(--spoiler-bg);
}
.spoiler.is-covered .content {
visibility: hidden;
}
.cover {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
width: 100%;
padding: 8px;
border: 0;
border-radius: 5px;
background-color: transparent;
color: var(--spoiler-fg);
font: inherit;
text-align: center;
cursor: pointer;
transition: background-color 0.15s ease;
}
.cover:hover {
background-color: var(--spoiler-hover);
}
.cover:focus-visible {
outline: 2px solid var(--q-primary, #1976d2);
outline-offset: -4px;
}
.label {
font-weight: 500;
letter-spacing: 0.02em;
}
.hint {
font-size: 0.8em;
opacity: 0.75;
}
:host {
--spoiler-border: #e0e0e0;
--spoiler-bg: #f5f5f5;
--spoiler-fg: #424242;
--spoiler-hover: rgba(0, 0, 0, 0.04);
}
:host-context(body.body--dark) {
--spoiler-border: rgba(255, 255, 255, 0.15);
--spoiler-bg: #161b22;
--spoiler-fg: rgba(255, 255, 255, 0.75);
--spoiler-hover: rgba(255, 255, 255, 0.06);
}
`
}
static get properties() {
return {
/**
* Heading on the cover
* @type {string}
*/
label: { type: String },
/**
* Line under the label
* @type {string}
*/
hint: { type: String },
// Internal Properties
_covered: { state: true }
}
}
constructor() {
super()
this.label = 'Spoiler'
this.hint = 'Click to show content'
this._covered = true
}
/**
* Drop the outermost margins of the content, the way `block-tabs` does: the box supplies the
* padding, and a heading adding its own on top of it would push the cover's text off centre.
*/
_trimEdgeMargins() {
this.firstElementChild?.style.setProperty('margin-top', '0')
this.lastElementChild?.style.setProperty('margin-bottom', '0')
}
connectedCallback() {
super.connectedCallback()
this._trimEdgeMargins()
}
render() {
return html`
<div class="spoiler ${this._covered ? 'is-covered' : ''}">
<div class="content"><slot></slot></div>
${this._covered
? html`
<button
type="button"
class="cover"
aria-expanded="false"
@click="${() => {
this._covered = false
}}">
${EYE_OFF_SVG}
<span class="label">${this.label}</span>
<span class="hint">${this.hint}</span>
</button>
`
: null}
</div>
`
}
}
window.customElements.define('block-spoiler', BlockSpoilerElement)

@ -0,0 +1,61 @@
/**
* Block Tab
*
* One panel of a `block-tabs`. It draws nothing and knows nothing: the parent reads its `label` and
* `icon`, builds the strip from them and shows or hides it. Its content is ordinary page content,
* left in the light DOM so the article's own stylesheet reaches it.
*
* It is registered as an element of its own so that the page view, which fetches a component for
* every undefined element it finds in a page, has something to fetch.
*/
export class BlockTabElement extends HTMLElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*
* `isChild` keeps it out of both: a tab on its own is not something to insert into a page, and not
* something to switch off separately from the tabs it belongs to. The definition is still what
* lets the tag and its attributes survive being saved, which is the reason it is declared at all.
*/
static definition = {
block: 'tab',
name: 'Tab',
description: 'One panel of a set of tabs.',
icon: 'subtitles',
isChild: true,
props: [
{
name: 'label',
type: 'string',
label: 'Label',
hint: 'What the tab is called in the strip.',
required: true
},
{
name: 'icon',
type: 'string',
label: 'Icon',
hint: 'Iconify reference drawn to the left of the label, e.g. mdi:language-python.'
}
]
}
connectedCallback() {
/*
A box of its own, set inline because the app resets the display of everything in a page.
Only when nothing has been set already: the two components arrive in separate files and in
either order, and whichever runs second must not undo the first. The parent hides the panels it
is not showing, so overwriting that here would leave every panel on screen at once.
Visible rather than hidden by default, so a page whose tab strip never arrives is a page with
all its content stacked up and readable, rather than a page with none of it.
*/
if (!this.style.display) {
this.style.display = 'block'
}
}
}
window.customElements.define('block-tab', BlockTabElement)

@ -0,0 +1,359 @@
import { LitElement, html, css } from 'lit'
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
/**
* Asked of a block that might be hiding the element the event was dispatched on.
*
* The app sends it at a heading before scrolling to it see `helpers/anchors.js` so that a heading
* inside a panel that is not showing is opened rather than scrolled at. Matched by name only: a block
* answers it or ignores it, and neither side has to know about the other.
*/
const REVEAL_EVENT = 'block-reveal'
/** Icons already fetched, by `prefix:name`, so a page of tabs asks for each one once. */
const iconCache = new Map()
/**
* Fetch an icon as inline SVG.
*
* Inline rather than an `<img>` so the drawing takes the colour of the tab it sits in Iconify's
* SVGs paint with `currentColor`, which an image cannot see. The instance serves them from its own
* `/_icons`, cached hard, so this is a local request.
*/
async function fetchIcon(reference) {
if (iconCache.has(reference)) {
return iconCache.get(reference)
}
const [prefix, name] = reference.split(':')
if (!prefix || !name) {
return ''
}
const promise = fetch(`/_icons/${encodeURIComponent(prefix)}/${encodeURIComponent(name)}.svg`)
.then((resp) => (resp.ok ? resp.text() : ''))
.catch(() => '')
iconCache.set(reference, promise)
return promise
}
/**
* Block Tabs
*/
export class BlockTabsElement extends LitElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*
* `template` is the body the picker writes into the page along with the opening line. A block that
* has one is fenced with `:::`, so that the `::block-tab` children inside it are read as blocks of
* their own rather than as the end of this one.
*/
static definition = {
block: 'tabs',
name: 'Tabs',
description: 'Groups content into tabbed panels.',
icon: 'right-navigation-toolbar',
template: `::block-tab{label="First tab"}
Content of the first tab.
::
::block-tab{label="Second tab"}
Content of the second tab.
::`
}
static get styles() {
return css`
:host {
display: block;
}
/*
One raised card: the border and the rounded corners belong to the outer box, and clipping to
it is what rounds the strip's top corners and the panel's bottom ones without either of them
having to know where it sits.
-> It also carries the gap below the block. On this element rather than :host: see block-index.
*/
.tabs {
margin-bottom: 16px;
border: 1px solid var(--tabs-border);
border-radius: 6px;
overflow: hidden;
box-shadow:
0 1px 3px rgb(0 0 0 / 0.1),
0 1px 2px rgb(0 0 0 / 0.06);
}
:host-context(body.body--dark) .tabs {
box-shadow:
0 1px 3px rgb(0 0 0 / 0.5),
0 1px 2px rgb(0 0 0 / 0.35);
}
/*
The whole row is the unselected surface, tabs and the space past the last one alike, so the
gradient is drawn once here and the tabs sit on it rather than repeating it. The line along
the bottom is the panel's top edge; the tabs are pulled down onto it so the active one can
paint over its own stretch and open the seam into the panel.
*/
.strip {
display: flex;
flex-wrap: wrap;
margin: 0;
padding: 0;
border-bottom: 1px solid var(--tabs-border);
background-image: var(--tabs-strip-bg);
}
.tab {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: -1px;
padding: 10px 18px;
border: 0;
border-right: 1px solid var(--tabs-border);
border-bottom: 1px solid transparent;
border-top: 3px solid transparent;
background-color: transparent;
color: var(--tabs-inactive-fg);
font: inherit;
font-weight: 500;
line-height: 1.4;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.tab:hover:not(.is-active) {
background-color: rgb(255 255 255 / 0.5);
color: var(--tabs-active-fg);
}
:host-context(body.body--dark) .tab:hover:not(.is-active) {
background-color: rgb(255 255 255 / 0.05);
}
.tab:focus-visible {
outline: 2px solid var(--tabs-active-fg);
outline-offset: -3px;
}
/* -> Flat panel colour, which is what lifts it out of the row's gradient */
.tab.is-active {
border-top-color: var(--tabs-active-fg);
border-bottom-color: var(--tabs-panel-bg);
background-color: var(--tabs-panel-bg);
background-image: none;
color: var(--tabs-active-fg);
}
.tab svg {
width: 1.15em;
height: 1.15em;
flex-shrink: 0;
}
.panel {
padding: 16px 20px;
background-color: var(--tabs-panel-bg);
}
/* -> The panel owns the spacing, so the content inside it does not add its own at the edges */
::slotted(block-tab) {
margin-bottom: 0;
}
:host {
--tabs-border: #e0e0e0;
--tabs-strip-bg: linear-gradient(to bottom, #fdfdfd, #eeeeee);
--tabs-inactive-fg: #424242;
--tabs-active-fg: var(--q-primary, #1976d2);
--tabs-panel-bg: #fff;
}
:host-context(body.body--dark) {
--tabs-border: rgba(255, 255, 255, 0.15);
--tabs-strip-bg: linear-gradient(to bottom, #1b212a, #12161d);
--tabs-inactive-fg: rgba(255, 255, 255, 0.7);
--tabs-panel-bg: #1e232a;
}
`
}
static get properties() {
return {
_tabs: { state: true },
_active: { state: true }
}
}
constructor() {
super()
this._tabs = []
this._active = 0
// -> Bound once, so that removing the listener later takes the same function that was added
this._onReveal = this._onReveal.bind(this)
}
/**
* Read the panels the page gave this block, and start showing the first.
*
* The panels stay in the light DOM, slotted in below the strip: their content is page content and
* is styled by the article's own stylesheet, the way an included page is.
*/
_collectTabs() {
const panels = [...this.querySelectorAll(':scope > block-tab')]
this._tabs = panels.map((panel, index) => {
this._trimEdgeMargins(panel)
return {
panel,
label: panel.getAttribute('label') || `Tab ${index + 1}`,
icon: panel.getAttribute('icon') || '',
svg: ''
}
})
this._showActive()
this._loadIcons()
}
/**
* Drop the outermost margins of a panel's content.
*
* The panel supplies the padding; the content adding its own on top of it leaves a gap under the
* strip that reads as a mistake a heading, whose margin is the largest of any element, most of
* all. Set on the element rather than in the stylesheet because the content is slotted: it lives in
* the page, styled by the page, and `::slotted()` reaches only the panel itself, never inside it.
*/
_trimEdgeMargins(panel) {
panel.firstElementChild?.style.setProperty('margin-top', '0')
panel.lastElementChild?.style.setProperty('margin-bottom', '0')
}
/**
* Keep the strip on screen when something inside a panel is scrolled to.
*
* A heading carries a `scroll-margin-top` so it does not land flush against the top edge, but that
* margin knows nothing about the strip standing above it following a link to a heading in a tab
* would scroll the tabs themselves out of view, leaving the reader in a panel with no way to see
* which one they were in. Set on the elements because the content is slotted, and measured because
* the strip is as tall as the labels wrapped onto however many rows.
*/
_applyScrollMargin() {
const strip = this.renderRoot.querySelector('.strip')
if (!strip) {
return
}
const margin = `${strip.offsetHeight + 20}px`
for (const { panel } of this._tabs) {
for (const child of panel.children) {
child.style.setProperty('scroll-margin-top', margin)
}
}
}
_showActive() {
this._tabs.forEach(({ panel }, index) => {
panel.style.display = index === this._active ? 'block' : 'none'
})
}
async _loadIcons() {
for (const tab of this._tabs.filter((t) => t.icon)) {
tab.svg = await fetchIcon(tab.icon)
this.requestUpdate()
}
}
_select(index) {
this._active = index
this._showActive()
}
/**
* Open the panel holding a given node, if it is one of these.
*
* Both ways in end up here: the app asking for a heading it is about to scroll to, and the reader
* arriving on a URL whose fragment names a heading in a panel that is not the first.
*/
_reveal(node) {
const index = this._tabs.findIndex(({ panel }) => panel.contains(node))
if (index >= 0 && index !== this._active) {
this._select(index)
}
return index >= 0
}
_onReveal(event) {
this._reveal(event.target)
}
/** The panel holding the heading the URL points at, if the URL points at one. */
_revealFromHash() {
const id = decodeURIComponent(window.location.hash.replace(/^#/, ''))
const target = id ? document.getElementById(id) : null
if (target) {
this._reveal(target)
}
}
/**
* Left and right walk the strip, as they do in every other set of tabs the panels are a single
* stop in the tab order, so the arrow keys are how a keyboard reaches the other ones.
*/
_onKeydown(event) {
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
if (!step) {
return
}
event.preventDefault()
const next = (this._active + step + this._tabs.length) % this._tabs.length
this._select(next)
this.renderRoot.querySelectorAll('.tab')[next]?.focus()
}
updated() {
this._applyScrollMargin()
}
connectedCallback() {
super.connectedCallback()
this._collectTabs()
// -> On arrival, and again whenever the fragment changes under a reader using back and forward
this._revealFromHash()
this._onHashChange = () => this._revealFromHash()
window.addEventListener('hashchange', this._onHashChange)
this.addEventListener(REVEAL_EVENT, this._onReveal)
}
disconnectedCallback() {
super.disconnectedCallback()
window.removeEventListener('hashchange', this._onHashChange)
this.removeEventListener(REVEAL_EVENT, this._onReveal)
}
render() {
if (this._tabs.length < 1) {
return html`<slot></slot>`
}
return html`
<div class="tabs">
<div class="strip" role="tablist" @keydown="${this._onKeydown}">
${this._tabs.map(
(tab, index) => html`
<button
type="button"
role="tab"
class="tab ${index === this._active ? 'is-active' : ''}"
aria-selected="${index === this._active}"
tabindex="${index === this._active ? 0 : -1}"
@click="${() => this._select(index)}">
${tab.svg ? unsafeSVG(tab.svg) : null}${tab.label}
</button>
`
)}
</div>
<div class="panel" role="tabpanel"><slot></slot></div>
</div>
`
}
}
window.customElements.define('block-tabs', BlockTabsElement)

@ -9,10 +9,12 @@
"version": "1.0.0",
"license": "AGPL-3.0",
"dependencies": {
"lit": "3.2.1"
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
"lit": "3.2.1",
"uqr": "0.1.3"
},
"devDependencies": {
"@rollup/plugin-graphql": "2.0.5",
"@rollup/plugin-node-resolve": "15.3.0",
"@rollup/plugin-terser": "0.4.4",
"glob": "11.0.0",
@ -106,29 +108,6 @@
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@rollup/plugin-graphql": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@rollup/plugin-graphql/-/plugin-graphql-2.0.5.tgz",
"integrity": "sha512-NMx9uVIheYMOQHV9Aann0sk/2+henT8T5JUbHneOvbD3iUrVUC7AaZ1B1ELJ/1vhqUe2OQIkRtGHzOQwBLoCvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"graphql-tag": "^2.12.6"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"graphql": ">=0.9.0",
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz",
@ -467,6 +446,12 @@
"node": ">=8"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -655,33 +640,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/graphql": {
"version": "16.13.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.0.tgz",
"integrity": "sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/graphql-tag": {
"version": "2.12.6",
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz",
"integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
}
},
"node_modules/gzip-size": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-7.0.0.tgz",
@ -774,6 +732,24 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lit": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.2.1.tgz",
@ -1147,12 +1123,11 @@
"node": ">=10"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"node_modules/uqr": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz",
"integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",

@ -11,10 +11,12 @@
"author": "Nicolas Giard",
"license": "AGPL-3.0",
"dependencies": {
"lit": "3.2.1"
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
"lit": "3.2.1",
"uqr": "0.1.3"
},
"devDependencies": {
"@rollup/plugin-graphql": "2.0.5",
"@rollup/plugin-node-resolve": "15.3.0",
"@rollup/plugin-terser": "0.4.4",
"glob": "11.0.0",

@ -1,7 +1,6 @@
import summary from 'rollup-plugin-summary'
import terser from '@rollup/plugin-terser'
import resolve from '@rollup/plugin-node-resolve'
import graphql from '@rollup/plugin-graphql'
import * as glob from 'glob'
@ -15,6 +14,13 @@ function literalToValue (node, blockDir) {
switch (node.type) {
case 'Literal':
return node.value
// A backtick string with nothing interpolated is still a plain value, and the readable way to
// write the multi-line ones -- a starter body for a block, say.
case 'TemplateLiteral':
if (node.expressions.length > 0) {
throw new Error(`${blockDir}: "static definition" must contain only plain literals, got an interpolated template.`)
}
return node.quasis[0].value.cooked
case 'ArrayExpression':
return node.elements.map(el => literalToValue(el, blockDir))
case 'ObjectExpression':
@ -27,6 +33,24 @@ function literalToValue (node, blockDir) {
}
}
/**
* Loads a `.css` import as a string.
*
* A block styles itself from inside its shadow root, which a `<link>` in the page cannot reach so a
* library's stylesheet has to be part of the component. Rollup has no notion of CSS on its own.
*/
function cssAsString () {
return {
name: 'css-as-string',
transform (code, id) {
if (!id.endsWith('.css')) {
return null
}
return { code: `export default ${JSON.stringify(code)}`, map: { mappings: '' } }
}
}
}
/**
* Collects each block's `static definition` into `compiled/blocks.manifest.json`.
*
@ -90,15 +114,12 @@ export default {
),
output: {
dir: 'compiled',
format: 'es',
globals: {
APOLLO_CLIENT: 'APOLLO_CLIENT'
}
format: 'es'
},
plugins: [
blocksManifest(),
cssAsString(),
resolve(),
graphql(),
terser({
ecma: 2019,
module: true

@ -0,0 +1,363 @@
<template>
<w-layout view="hHh lpR fFf" container>
<w-header class="card-header px-4 py-2">
<w-icon name="img:/_assets/icons/fluent-rfid-tag.svg" left size="md" />
<span>{{ t('editor.blockPicker.title') }}</span>
<w-space />
<w-btn
class="mr-2"
flat
rounded
color="white"
:aria-label="t(`common.actions.viewDocs`)"
icon="la:question-circle"
:href="siteStore.docsBase + `/editor/markdown`"
target="_blank"
type="a" />
<w-btn-group push>
<w-btn
push
color="white"
text-color="grey-7"
:label="t(`common.actions.cancel`)"
:aria-label="t(`common.actions.cancel`)"
icon="la:times"
@click="close" />
<w-btn
push
color="positive"
text-color="white"
:label="t(`editor.blockPicker.insert`)"
:aria-label="t(`editor.blockPicker.insert`)"
icon="la:check"
:disabled="!canInsert"
@click="insert" />
</w-btn-group>
</w-header>
<w-page-container>
<w-page class="block-picker flex flex-nowrap items-stretch">
<!-- ----------------------- -->
<!-- The blocks -->
<!-- ----------------------- -->
<div class="block-picker-catalog w-2/3">
<w-scroll-area style="height: 100%">
<div class="p-4">
<w-inner-loading :showing="state.isLoading" size="32px" />
<div
v-if="!state.isLoading && blocks.length < 1"
class="text-caption p-6 text-center text-black/60 dark:text-white/70">
{{ t('editor.blockPicker.noBlocks') }}
</div>
<div class="block-picker-grid">
<button
v-for="block of blocks"
:key="block.id"
type="button"
class="block-picker-card"
:class="{ 'is-selected': state.selected?.id === block.id }"
@click="select(block)">
<w-icon
:name="`img:/_assets/icons/ultraviolet-${block.isCustom ? 'plugin' : block.icon}.svg`"
size="40px" />
<div class="min-w-0 flex-1 text-left">
<div class="text-body2">
<strong>{{ block.name }}</strong>
</div>
<div class="text-caption opacity-70">{{ block.description }}</div>
<div class="text-caption font-robotomono mt-1 opacity-60">
&lt;block-{{ block.block }}&gt;
</div>
</div>
</button>
</div>
</div>
</w-scroll-area>
</div>
<w-separator vertical />
<!-- ----------------------- -->
<!-- Its properties -->
<!-- ----------------------- -->
<div class="block-picker-form w-1/3">
<w-scroll-area style="height: 100%">
<!-- A section header draws its own horizontal inset, so this pads vertically only -->
<div class="py-4">
<div
v-if="!state.selected"
class="text-caption p-6 text-center text-black/60 dark:text-white/70">
{{ t('editor.blockPicker.selectHint') }}
</div>
<template v-else>
<div class="w-section-header">{{ state.selected.name }}</div>
<!--
A block with nothing to fill in is not a broken form: it is inserted as it stands. A
custom block reports no props at all, since only the compiled manifest carries them.
-->
<div
v-if="state.selected.props.length < 1"
class="text-caption mt-4 px-4 text-black/60 dark:text-white/70">
{{ t('editor.blockPicker.noProps') }}
</div>
<w-form v-else class="gap-4 px-4 pt-4">
<template v-for="prop of state.selected.props" :key="prop.name">
<w-select
v-if="prop.type === `select`"
v-model="state.values[prop.name]"
:options="prop.options ?? []"
outlined
dense
options-dense
:label="prop.label ?? prop.name"
:aria-label="prop.label ?? prop.name"
:required="prop.required"
:hint="prop.hint" />
<w-toggle
v-else-if="prop.type === `boolean`"
v-model="state.values[prop.name]"
dense
:label="prop.label ?? prop.name" />
<w-input
v-else
v-model="state.values[prop.name]"
outlined
dense
:type="prop.type === `number` ? `number` : `text`"
:label="prop.label ?? prop.name"
:aria-label="prop.label ?? prop.name"
:required="prop.required"
:hint="prop.hint" />
</template>
</w-form>
<!-- -> The markup itself, since that is what lands in the page -->
<div class="w-section-header mt-6">{{ t('editor.blockPicker.markdown') }}</div>
<!-- The same 16px all round, so it sits inside the panel the way the fields do -->
<pre class="block-picker-output m-4">{{ markdown }}</pre>
</template>
</div>
</w-scroll-area>
</div>
</w-page>
</w-page-container>
</w-layout>
</template>
<script setup>
import { computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/notify'
import { blockMarkdown } from '@/helpers/blocks'
import { useSiteStore } from '@/stores/site'
/**
* Picks a block and what to give it, and hands the editor the MDC markup for it.
*
* Only metadata is used here the name, the icon, and the props the block declares. The component
* itself is never imported: a block's code is fetched when its tag turns up in a page (see
* `commonStore.loadBlocks`), and a picker that pulled in every block to show a list of them would
* defeat that.
*
* `::block-name{prop="value"}` is MDC block syntax, which the renderer turns into
* `<block-name prop="value">` the element the component registers itself as.
*/
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
blocks: [],
selected: null,
/** Field values for the selected block, by prop name. */
values: {},
isLoading: false
})
// COMPUTED
/** Only blocks this site has switched on: the rest cannot render, so offering them is a trap. */
const blocks = computed(() => state.blocks.filter((block) => block.isEnabled))
const markdown = computed(() => (state.selected ? blockMarkdown(state.selected, state.values) : ''))
const canInsert = computed(() => {
if (!state.selected) {
return false
}
// -> A required prop with nothing in it would insert a block that cannot draw anything
return state.selected.props
.filter((prop) => prop.required)
.every((prop) => String(state.values[prop.name] ?? '').length > 0)
})
// METHODS
function select(block) {
state.selected = block
// -> Started at the block's own defaults, so the form shows what it would do if left alone
state.values = Object.fromEntries(block.props.map((prop) => [prop.name, prop.default ?? '']))
}
function insert() {
EVENT_BUS.emit('insertBlock', markdown.value)
close()
}
function close() {
siteStore.$patch({ overlay: '' })
}
// MOUNTED
onMounted(async () => {
state.isLoading = true
try {
state.blocks = (await API_CLIENT.get(`sites/${siteStore.id}/blocks`).json()) ?? []
} catch (err) {
notify({
type: 'negative',
message: t('editor.blockPicker.loadFailed'),
caption: err.message
})
}
state.isLoading = false
})
</script>
<style lang="scss">
.block-picker {
height: 100%;
padding: 0;
/*
Nothing here sits on a `w-card`, and that is where the app's dark text colour comes from -- so the
panels have to state it themselves or everything inheriting `color` stays black on a dark surface.
*/
@at-root .body--light & {
color: $grey-9;
}
@at-root .body--dark & {
color: #fff;
}
/*
In dark mode the catalog is the darkest surface in the pair, so the cards read as lifted off it,
and the form is the lighter panel beside it. Stated outright rather than left to whatever sits
behind the overlay, since the two panels are only legible relative to each other.
*/
&-catalog {
height: 100%;
@at-root .body--dark & {
background-color: $dark-6;
}
}
&-form {
height: 100%;
@at-root .body--light & {
background-color: $grey-1;
}
@at-root .body--dark & {
background-color: $dark-4;
}
}
/*
Two columns at most, however wide the overlay gets: a card carries a name, a sentence and a tag
name, so it reads better wide than tiled. The `max()` is what caps the count -- a track asking
for half the row (less its share of the gap) can only ever fit twice -- while the 280px floor
takes over on a panel too narrow for two of them and drops the grid to a single column.
*/
&-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(max(280px, calc(50% - 6px)), 1fr));
}
/*
-> A card is the whole hit target, so the icon and the text are both part of choosing it
It floats on its shadow rather than sitting in a border: deeper on hover, and ringed by a glow of
the site's primary colour once picked. Selection is a shadow too, so nothing reflows as it moves
between cards. Dark mode takes the raised surface `w-card` uses instead of staying white, which at
this size would glare and would need its own text colour to stay readable.
*/
&-card {
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-radius: 6px;
background-color: #fff;
color: inherit;
text-align: left;
cursor: pointer;
box-shadow:
0 1px 3px rgb(0 0 0 / 0.12),
0 1px 2px rgb(0 0 0 / 0.06);
transition: box-shadow 0.15s var(--ease-standard);
&:hover {
box-shadow:
0 5px 12px rgb(0 0 0 / 0.16),
0 2px 4px rgb(0 0 0 / 0.08);
}
&.is-selected,
&.is-selected:hover {
box-shadow:
0 0 0 2px var(--color-primary),
0 0 14px 2px color-mix(in srgb, var(--color-primary) 45%, transparent);
}
@at-root .body--dark & {
background-color: $dark-3;
box-shadow:
0 1px 3px rgb(0 0 0 / 0.5),
0 1px 2px rgb(0 0 0 / 0.35);
&:hover {
box-shadow:
0 5px 14px rgb(0 0 0 / 0.6),
0 2px 5px rgb(0 0 0 / 0.4);
}
&.is-selected,
&.is-selected:hover {
box-shadow:
0 0 0 2px var(--color-primary),
0 0 16px 3px color-mix(in srgb, var(--color-primary) 55%, transparent);
}
}
}
&-output {
padding: 10px;
border-radius: 4px;
font-family: 'Roboto Mono', Consolas, 'Liberation Mono', Courier, monospace;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
@at-root .body--light & {
background-color: $grey-3;
color: $grey-9;
}
@at-root .body--dark & {
background-color: $dark-6;
color: #fff;
}
}
}
</style>

@ -29,12 +29,12 @@
t('editor.markup.insertTable')
}}</w-tooltip>
</w-btn>
<w-btn icon="mdi:tab-plus" padding="sm sm" flat @click="notImplemented">
<w-btn icon="mdi:tab-plus" padding="sm sm" flat @click="insertTabset">
<w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertTabset')
}}</w-tooltip>
</w-btn>
<w-btn icon="mdi:toy-brick-plus" padding="sm sm" flat @click="notImplemented">
<w-btn icon="mdi:toy-brick-plus" padding="sm sm" flat @click="insertBlock">
<w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertBlock')
}}</w-tooltip>
@ -44,7 +44,7 @@
t('editor.markup.insertDiagram')
}}</w-tooltip>
</w-btn>
<w-btn icon="mdi:book-plus" padding="sm sm" flat @click="notImplemented">
<w-btn icon="mdi:book-plus" padding="sm sm" flat @click="insertFootnote">
<w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertFootnote')
}}</w-tooltip>
@ -276,6 +276,7 @@ import { useI18n } from 'vue-i18n'
import { dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { blockMarkdown } from '@/helpers/blocks'
import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue'
import EditorEmojiMenu from '@/components/EditorEmojiMenu.vue'
@ -398,6 +399,55 @@ function insertEmoji(shortcode) {
insertAtCursor({ content: `:${shortcode}:` })
}
function insertBlock() {
siteStore.$patch({
overlay: 'BlockPicker'
})
}
/**
* The tabset, without going through the picker.
*
* A shortcut to picking Tabs from the block list and inserting it as it stands, so the markup is
* built from the same definition rather than written out a second time here a change to the block's
* starter body reaches both. It still asks the server which blocks this site has: a shortcut to a
* block an administrator switched off would insert something the page cannot draw.
*/
async function insertTabset() {
try {
const blocks = (await API_CLIENT.get(`sites/${siteStore.id}/blocks`).json()) ?? []
const tabs = blocks.find((block) => block.block === `tabs` && block.isEnabled)
if (!tabs) {
notify({
type: 'warning',
message: t('editor.blockPicker.blockUnavailable')
})
return
}
insertBlockClb(blockMarkdown(tabs))
} catch (err) {
notify({
type: 'negative',
message: t('editor.blockPicker.loadFailed'),
caption: err.message
})
}
}
/**
* The block the picker built, on its own lines.
*
* MDC's block syntax only opens a component when `::` starts a line, so a cursor mid-sentence breaks
* out of it first the same rule the table follows.
*/
function insertBlockClb(markdown) {
const position = editor.getPosition()
const line = editor.getModel().getLineContent(position.lineNumber)
const before = line.slice(0, position.column - 1).trim().length > 0 ? '\n\n' : ''
const after = line.slice(position.column - 1).trim().length > 0 ? '\n\n' : '\n'
insertAtCursor({ content: `${before}${markdown}${after}` })
}
function insertTable() {
siteStore.$patch({
overlay: 'TableEditor'
@ -429,6 +479,59 @@ function insertTableClb(markdown) {
* `{target="_blank"}` is markdown-it-attrs syntax, and `target` is one of the three attributes the
* stored render is allowed to keep see `renderers/markdown.js` and `models/rendering.ts`.
*/
/**
* The number to give the next footnote.
*
* Markdown numbers footnotes in the order they are referenced, not by their labels, so these are
* names rather than positions but an author reading the source expects them to count up, and two
* notes sharing a name would collapse into one. Anything the author named themselves is left alone
* and simply counted past.
*/
function nextFootnoteLabel(text) {
let highest = 0
for (const [, label] of text.matchAll(/\[\^([^\]\s]+)\]/g)) {
if (/^\d+$/.test(label)) {
highest = Math.max(highest, Number.parseInt(label, 10))
}
}
return String(highest + 1)
}
/**
* A footnote: the marker where the cursor is, and the note itself at the foot of the source.
*
* Both halves in one edit, because either alone is broken a marker with no note renders as literal
* text, and a note nothing refers to renders as nothing at all. The cursor ends on the note, since
* writing it is what the author was about to do; the marker is already where they left it.
*/
function insertFootnote() {
const model = editor.getModel()
const label = nextFootnoteLabel(model.getValue())
const cursor = editor.getPosition()
const lastLine = model.getLineCount()
const lastLineLength = model.getLineContent(lastLine).length
// -> On a line of its own at the end, one blank line clear of whatever the page ends with
const lead = lastLineLength > 0 ? `\n\n` : ``
editor.executeEdits('', [
{
range: new Range(cursor.lineNumber, cursor.column, cursor.lineNumber, cursor.column),
text: `[^${label}]`,
forceMoveMarkers: true
},
{
range: new Range(lastLine, lastLineLength + 1, lastLine, lastLineLength + 1),
text: `${lead}[^${label}]: `,
forceMoveMarkers: true
}
])
const noteLine = model.getLineCount()
editor.setPosition({ lineNumber: noteLine, column: model.getLineContent(noteLine).length + 1 })
editor.revealLineInCenterIfOutsideViewport(noteLine)
editor.focus()
}
function insertLink() {
dialog({ component: LinkPickerDialog }).onOk(({ href, openInNewTab, title }) => {
const selection = editor.getSelection()
@ -920,6 +1023,7 @@ onMounted(async () => {
EVENT_BUS.on('insertAsset', insertAssetClb)
EVENT_BUS.on('insertTable', insertTableClb)
EVENT_BUS.on('insertBlock', insertBlockClb)
EVENT_BUS.on('openEditorSettings', openEditorSettings)
EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
@ -959,6 +1063,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
EVENT_BUS.off('insertAsset', insertAssetClb)
EVENT_BUS.off('insertTable', insertTableClb)
EVENT_BUS.off('insertBlock', insertBlockClb)
EVENT_BUS.off('openEditorSettings', openEditorSettings)
EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true)

@ -17,6 +17,10 @@ import { useSiteStore } from '../stores/site'
import LoadingGeneric from './LoadingGeneric.vue'
const overlays = {
BlockPicker: defineAsyncComponent({
loader: () => import('./BlockPickerOverlay.vue'),
loadingComponent: LoadingGeneric
}),
EditorMarkdownConfig: defineAsyncComponent({
loader: () => import('./EditorMarkdownUserSettingsOverlay.vue'),
loadingComponent: LoadingGeneric

@ -26,6 +26,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { scrollToAnchor } from '@/helpers/anchors'
import { flattenToc } from '@/helpers/toc'
/**
@ -105,8 +106,7 @@ function headingFor(key) {
}
function onClick(ev, item) {
const heading = headingFor(item.key)
if (!heading) {
if (!headingFor(item.key)) {
// -> Nothing to scroll to; let the browser do whatever it can with the href
return
}
@ -114,8 +114,8 @@ function onClick(ev, item) {
emit('update:selected', item.key)
spySuspendedUntil = performance.now() + CLICK_SETTLE_MS
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
heading.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth', block: 'start' })
// -> Through the helper, so that a heading inside a closed tab is revealed rather than scrolled at
scrollToAnchor(item.key, { smooth: true })
}
/**

@ -13,6 +13,7 @@
:for="inputId"
class="mb-1 block text-caption text-black/60 dark:text-white/70">
{{ label }}
<span v-if="required" class="text-negative pr-1" aria-hidden="true">&nbsp;*</span>
</label>
<div
@ -37,7 +38,12 @@
class="w-input-outline"
:style="outlineStyle">
<legend :class="isFloating ? 'w-input-outline-notch--open' : ''">
<span>{{ label }}</span>
<span
>{{ label
}}<span v-if="required" class="text-negative pr-1" aria-hidden="true"
>&nbsp;*</span
></span
>
</legend>
</fieldset>
@ -47,6 +53,7 @@
class="w-input-float"
:class="[isFloating ? 'w-input-float--up' : '', floatColorClass]">
{{ label }}
<span v-if="required" class="text-negative pr-1" aria-hidden="true">&nbsp;*</span>
</label>
<slot name="prepend" />
@ -75,6 +82,7 @@
:autocomplete="autocomplete"
:rows="type === 'textarea' ? rows : undefined"
:aria-invalid="hasError || undefined"
:aria-required="required || undefined"
:aria-describedby="describedBy"
class="w-unstyled min-w-0 flex-1 bg-transparent pt-0.5 outline-none placeholder:text-black/40 dark:placeholder:text-white/40"
:class="monospaced ? 'font-mono text-[13px] leading-[1.4] font-semibold' : ''"
@ -164,6 +172,17 @@ const props = defineProps({
type: String,
default: null
},
/**
* Marks the field as one that has to be filled in.
*
* Draws a red asterisk beside the label and tells assistive technology the same thing through
* `aria-required`; it does not validate anything or set the native `required` attribute, since the
* form around it owns when and how it complains.
*/
required: {
type: Boolean,
default: false
},
/** Helper text below the control, replaced by the error message when invalid. */
hint: {
type: String,

@ -14,6 +14,7 @@
:for="selectId"
class="mb-1 block text-caption text-black/60 dark:text-white/70">
{{ label }}
<span v-if="required" class="text-negative pr-1" aria-hidden="true">&nbsp;*</span>
</label>
<!--
@ -51,7 +52,12 @@
class="w-input-outline"
:style="outlineStyle">
<legend :class="isFloating ? 'w-input-outline-notch--open' : ''">
<span>{{ label }}</span>
<span
>{{ label
}}<span v-if="required" class="text-negative pr-1" aria-hidden="true"
>&nbsp;*</span
></span
>
</legend>
</fieldset>
@ -67,6 +73,7 @@
class="w-input-float"
:class="[isFloating ? 'w-input-float--up' : '', floatColorClass]">
{{ label }}
<span v-if="required" class="text-negative pr-1" aria-hidden="true">&nbsp;*</span>
</span>
<slot name="prepend" />
@ -100,6 +107,7 @@
role="combobox"
autocomplete="off"
:aria-expanded="String(isOpen)"
:aria-required="required || undefined"
aria-haspopup="listbox"
:aria-label="label ? undefined : ariaLabel"
:aria-labelledby="hasFloatingLabel ? `${selectId}-label` : undefined"
@ -308,6 +316,17 @@ const props = defineProps({
type: Boolean,
default: false
},
/**
* Marks the field as one that has to be filled in.
*
* Draws a red asterisk beside the label and tells assistive technology the same thing through
* `aria-required`; it does not validate anything or set the native `required` attribute, since the
* form around it owns when and how it complains.
*/
required: {
type: Boolean,
default: false
},
hint: {
type: String,
default: null

@ -644,28 +644,27 @@
soft rules trailing off beneath it. Lifted from the profile pages, where it was scoped to that
layout, so the admin cards can use the same treatment.
`--w-section-header-surface` is the colour the wash fades INTO, so the same recipe works on a
page and on a card; both happen to be white / dark-3 today, but a surface that is neither only
has to set the variable.
Every layer fades to `transparent`, never to the colour of the surface behind it, so the heading
blends into whatever it is drawn on -- a page, a card, a tinted panel. Fading in two directions
at once is what the ellipse is for: it is anchored to the corner the wash is strongest at, and
falls off towards both far edges. Fading a flat wash out sideways the other way round means
laying an opaque copy of the surface over it, which lines up on exactly one surface.
Colours resolve through `--color-primary`, so a re-themed site recolours these too -- the
original hard-coded the palette's blue.
*/
.w-section-header {
--w-section-header-surface: var(--color-white);
position: relative;
margin-bottom: 10px;
padding: 0 16px 6px;
font-size: 17px;
font-weight: 500;
color: var(--color-primary);
background:
linear-gradient(to left, var(--w-section-header-surface), transparent),
linear-gradient(
to top,
color-mix(in srgb, var(--color-primary) 7.5%, transparent),
transparent
);
background: radial-gradient(
farthest-side at left bottom,
color-mix(in srgb, var(--color-primary) 7.5%, transparent),
transparent
);
}
/* The wider, fainter band trailing below the heading */
@ -677,13 +676,11 @@
z-index: 0;
width: 100%;
height: 10px;
background:
linear-gradient(to left, var(--w-section-header-surface), transparent),
linear-gradient(
to bottom,
color-mix(in srgb, var(--color-primary) 5%, transparent),
transparent
);
background: radial-gradient(
farthest-side at left top,
color-mix(in srgb, var(--color-primary) 5%, transparent),
transparent
);
}
/*
@ -711,7 +708,6 @@
}
body.body--dark .w-section-header {
--w-section-header-surface: var(--color-dark-3);
color: var(--color-primary-light);
}

@ -0,0 +1,185 @@
/**
* Getting to a heading inside a rendered page.
*
* Three things make this more than `scrollIntoView`. The render arrives after the browser has already
* tried the fragment in the URL, so an anchor a reader followed from elsewhere lands nowhere; a
* heading can sit inside a block that is not showing it a tab that is not the open one where it
* has no box to scroll to; and the page goes on changing height for a while after it is drawn, as
* each block fetches its component and settles into its real size.
*/
/**
* Asked of a block that might be hiding the element the event was dispatched on.
*
* Bubbles and crosses shadow boundaries, so the block that answers is whichever one happens to be
* above the heading: the app does not need to know which kinds of block can hide things, and a new
* one only has to listen. `block-tabs` answers it by opening the panel the heading is in.
*/
export const REVEAL_EVENT = 'block-reveal'
/** How often the heading's position is sampled while waiting for the page to stop moving. */
const SAMPLE_MS = 60
/** How many samples in a row must agree before the page counts as settled. */
const STABLE_SAMPLES = 3
/** How long to wait for a smooth scroll to finish, where the browser cannot say when it has. */
const SETTLE_MS = 1200
/** How far the heading may sit from where it was aimed before it is worth correcting, in pixels. */
const DRIFT_TOLERANCE = 4
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/** The heading a `#slug` refers to, or null. */
export function anchorTarget(hash) {
const id = decodeURIComponent(String(hash ?? '').replace(/^#/, ''))
// -> `getElementById` rather than a selector, which would have to escape a slug that is not a
// valid CSS identifier
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) {
return Boolean(el.offsetParent ?? el.getClientRects().length)
}
/** Ask whatever is above the element to bring it into view. */
function reveal(el) {
el.dispatchEvent(new CustomEvent(REVEAL_EVENT, { bubbles: true, composed: true }))
}
/**
* The box the element actually scrolls in.
*
* The article has its own scroller rather than the window the shell stays put and the column moves
* so the position of the heading has to be read against that box, not the viewport.
*/
function scrollerOf(el) {
for (let node = el.parentElement; node; node = node.parentElement) {
const { overflowY } = getComputedStyle(node)
if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight + 1) {
return node
}
}
return document.scrollingElement ?? document.documentElement
}
/** Where the heading sits in the document, independent of how far the page is scrolled. */
function positionOf(el, scroller) {
return Math.round(el.getBoundingClientRect().top + scroller.scrollTop)
}
/** How far the heading is from where a scroll aiming at it would put it. */
function driftOf(el, scroller) {
const margin = Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0
const wanted = scroller.getBoundingClientRect().top + margin
return el.getBoundingClientRect().top - wanted
}
function scrollTo(el, smooth) {
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
el.scrollIntoView({ behavior: smooth && !reduceMotion ? 'smooth' : 'auto', block: 'start' })
}
/**
* Wait until the heading stops moving.
*
* Blocks land after the page is drawn and change its height as they do a set of tabs is at its
* tallest before its component arrives, with every panel stacked up, and collapses to one when it
* does. Scrolling into that leaves the reader somewhere below the heading they asked for, so this
* waits for the page to hold still before aiming at anything.
*/
async function whenStill(el, scroller, deadline) {
let previous = null
let agreed = 0
while (performance.now() < deadline) {
const position = positionOf(el, scroller)
agreed = position === previous ? agreed + 1 : 0
if (agreed >= STABLE_SAMPLES) {
return
}
previous = position
await delay(SAMPLE_MS)
}
}
/** Wait for a scroll to come to rest, by the event where there is one and by the clock where not. */
function whenScrollEnded(scroller) {
if (!('onscrollend' in window)) {
return delay(SETTLE_MS)
}
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer)
scroller.removeEventListener('scrollend', done)
resolve()
}
const timer = setTimeout(done, SETTLE_MS)
scroller.addEventListener('scrollend', done, { once: true })
})
}
/**
* Scroll a heading into view, asking whatever is above it to reveal it first.
*
* For a page that is already settled a click on the contents list, say. See
* `scrollToAnchorWhenReady` for one that has only just been rendered.
*
* @returns Whether there was a heading to scroll to
*/
export function scrollToAnchor(hash, { smooth = false } = {}) {
const target = anchorTarget(hash)
if (!target) {
return false
}
reveal(target)
if (!isVisible(target)) {
return false
}
scrollTo(target, smooth)
return true
}
/**
* The same, for a page that has only just been rendered: wait for the heading, then for the page to
* settle, then animate to it and check afterwards, in case something arrived late enough to move
* it while the scroll was under way.
*
* Animated rather than jumped, so a reader who followed a link into the middle of a long page sees
* where they were taken instead of being asked to work out where the top went.
*/
export async function scrollToAnchorWhenReady(hash, { timeout = 5000 } = {}) {
if (!hash) {
return
}
const deadline = performance.now() + timeout
// -> The heading itself may not exist yet: a block fetches its component, and an included page its
// content, after the page around them is drawn
let target = anchorTarget(hash)
while (performance.now() < deadline) {
if (target) {
reveal(target)
if (isVisible(target)) {
break
}
}
await delay(SAMPLE_MS)
target = anchorTarget(hash)
}
if (!target || !isVisible(target)) {
return
}
const scroller = scrollerOf(target)
await whenStill(target, scroller, deadline)
scrollTo(target, true)
// -> One correction, without animation: the reader has already watched the page travel, and what
// is left is a few pixels of something that loaded on the way
await whenScrollEnded(scroller)
if (Math.abs(driftOf(target, scroller)) > DRIFT_TOLERANCE && isVisible(target)) {
scrollTo(target, false)
}
}

@ -0,0 +1,44 @@
/**
* The MDC markup for a block, as the editor writes it into a page.
*
* Shared rather than living in the block picker, because the picker is not the only way a block gets
* inserted the toolbar has a shortcut for the tabset, which has to produce exactly what picking
* Tabs from the list would have produced.
*
* `::block-name{prop="value"}` is what the renderer turns into `<block-name prop="value">`, the
* element the component registers itself as.
*
* @param {{ block: string, props?: Array, template?: string }} block A block as the API describes it.
* @param {Record<string, unknown>} [values] What the author filled in, by prop name.
* @returns {string} The markup, opening and closing lines included.
*/
export function blockMarkdown(block, values = {}) {
const attributes = (block.props ?? [])
.filter((prop) => {
/*
Only what is worth writing out: anything given a value that is not already the block's own
default. A block reading its default from its own code does not need to be told it in every
page.
*/
const value = values[prop.name]
if (value === undefined || value === null || value === '') {
return false
}
return String(value) !== String(prop.default ?? '')
})
// -> A double quote in a value would close the attribute; MDC has no escape for it, so it goes
.map((prop) => `${prop.name}="${String(values[prop.name]).replaceAll('"', "'")}"`)
.join(' ')
const suffix = attributes ? `{${attributes}}` : ''
/*
A block that comes with a body to start from writes it between the two lines. One holding blocks
of its own is fenced with three colons rather than two, since against a two-colon fence the first
`::` inside it would read as the end of this one.
*/
if (block.template) {
const fence = /^::/m.test(block.template) ? ':::' : '::'
return `${fence}block-${block.block}${suffix}\n${block.template}\n${fence}`
}
return `::block-${block.block}${suffix}\n::`
}

@ -6,35 +6,46 @@
* separators between the sidebar's sections are the caller's markup, so the caller has to be able to
* ask the same question and get the same answer.
*
* `minDepth` and `maxDepth` are levels counting from 1, matching how the page properties panel labels
* them (`H{min} → H{max}`). A row's `depth` is rebased on `minDepth`, so skipped levels give up their
* indentation with them and the list opens at its own top tier; skipped headings are still walked
* through, since it is their subheadings that are being asked for.
* `minDepth` and `maxDepth` are heading levels, matching how the page properties panel labels them
* (`H{min} → H{max}`) and what that plainly says: `H1 → H2` shows `h1` and `h2` and nothing else. A
* heading's own level decides that, never its place in the tree an `h3` written straight under an
* `h1` is still an `h3`, and an author reaching for a smaller heading to get smaller text is not
* asking for a row in the contents.
*
* @param {Array<{ key: string, label: string, children?: Array }>} nodes The contents tree.
* Indentation is rebased on the shallowest level that survived, so a page whose headings start at
* `h2` opens at the list's own top tier rather than spending one on a level it never uses.
*
* @param {Array<{ key: string, label: string, level: number, children?: Array }>} nodes The tree.
* @param {object} [opts]
* @param {number} [opts.minDepth] Shallowest level to include, from 1.
* @param {number} [opts.maxDepth] Deepest level to include, from 1.
* @param {number} [opts.minDepth] Shallowest heading level to include, from 1.
* @param {number} [opts.maxDepth] Deepest heading level to include, from 1.
* @returns {Array<{ key: string, label: string, depth: number }>} Rows, in document order.
*/
export function flattenToc(nodes, { minDepth = 1, maxDepth = 2 } = {}) {
const rows = []
const skipped = Math.max(minDepth - 1, 0)
const included = []
const walk = (level, depth) => {
if (depth >= maxDepth) {
return
}
// -> Every node is walked, shown or not: a heading outside the range can still hold the ones that
// are, which is all the nesting is used for here
const walk = (level) => {
for (const node of level) {
if (depth >= skipped) {
rows.push({ key: node.key, label: node.label, depth: depth - skipped })
if (node.level >= minDepth && node.level <= maxDepth) {
included.push(node)
}
if (node.children?.length) {
walk(node.children, depth + 1)
walk(node.children)
}
}
}
walk(nodes ?? [], 0)
return rows
walk(nodes ?? [])
if (included.length < 1) {
return []
}
const base = Math.min(...included.map((node) => node.level))
return included.map((node) => ({
key: node.key,
label: node.label,
depth: node.level - base
}))
}

@ -196,7 +196,16 @@
</template>
<script setup>
import { computed, defineAsyncComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'
import {
computed,
defineAsyncComponent,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch
} from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@ -205,6 +214,7 @@ import { dialog } from '@/composables/dialog'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { scrollToAnchorWhenReady } from '@/helpers/anchors'
import { enhanceRenderedContent } from '@/helpers/renderedContent'
import { flattenToc } from '@/helpers/toc'
@ -394,6 +404,23 @@ watch(
}
)
/*
A fragment that changes without the page doing so: a link inside the content, or the reader going
back to one. The browser tries it natively and gets nowhere when the heading is inside a panel that
is not open, so the same routine runs here where the heading is revealed first.
*/
onMounted(() => {
window.addEventListener('hashchange', onHashChange)
})
onBeforeUnmount(() => {
window.removeEventListener('hashchange', onHashChange)
})
function onHashChange() {
scrollToAnchorWhenReady(window.location.hash)
}
watch(
() => route.path,
async (newValue) => {
@ -451,10 +478,17 @@ watch(
}
// -> Load Blocks. `?.` because a locked page draws its lock screen in place of the article, so
// there is no content element to scan -- and nothing in it to scan for.
nextTick(() => {
nextTick(async () => {
for (const block of pageContents.value?.querySelectorAll(':not(:defined)') ?? []) {
commonStore.loadBlocks([block.tagName.toLowerCase()])
}
/*
Then the heading in the URL, if there is one. The browser tried it the moment it had the
document, which was long before this render existed, so nothing happened following a link
to `#a-heading` left the reader at the top of the page. Done here rather than on mount
because a route change within the app renders a new page the same way.
*/
scrollToAnchorWhenReady(route.hash)
})
} catch (err) {
if (err.message === 'ERR_PAGE_NOT_FOUND') {

@ -131,6 +131,40 @@ export class MarkdownRenderer {
.use(mdFootnote)
.use(mdImsize)
/*
MDC's slot syntax, off for the same reason as inline components: it takes a line the author
meant as something else.
Inside a block body it claims every line starting with `#` whose second character is not a
space -- which is every markdown heading from `##` down. `::block-tabs` with a `### Step` in it
threw `Invalid block params: # Step` out of the renderer, leaving the editor's preview frozen on
the last good render with only a console error to say why, and a save then storing that stale
HTML. Nothing is lost by turning it off: a slot renders as `<template #name>`, and `template` is
not a tag a page may carry, so the server stripped every one of them anyway.
*/
this.md.block.ruler.disable('mdc_block_slots')
/*
MDC's inline span, `[text]{.class}`, claims every `[` it meets including the `[^1]` of a
footnote reference, which came out as `<span>^1</span>`. The note itself then vanished too,
since a definition nothing refers to is dropped. Rule order settles it whatever order the
plugins are added in: the span rule is registered before `link`, the footnote rule after
`image`, so the span always gets there first.
Wrapped rather than turned off, because the span is worth keeping and the two are only ever
confusable at `[^` which is a footnote reference and nothing else. Reaching into `__rules__`
is the only way to get hold of the original: markdown-it can replace a rule by name but has no
way to read one back out.
*/
const spanRule = this.md.inline.ruler.__rules__.find((rule) => rule.name === 'mdc_inline_span')
const inlineSpan = spanRule.fn
this.md.inline.ruler.at('mdc_inline_span', (state, silent) => {
if (state.src[state.pos] === '[' && state.src[state.pos + 1] === '^') {
return false
}
return inlineSpan(state, silent)
})
if (config.underline) {
this.md.use(mdUnderline)
}

Loading…
Cancel
Save