mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
346 lines
13 KiB
346 lines
13 KiB
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, with what its component says it can be given. */
|
|
export interface SiteBlock {
|
|
id: string
|
|
block: string
|
|
name: string
|
|
description: string
|
|
icon: string
|
|
isEnabled: boolean
|
|
isCustom: boolean
|
|
config: Record<string, any>
|
|
props: BlockProp[]
|
|
template: string
|
|
}
|
|
|
|
const blockSelection = {
|
|
id: blocksTable.id,
|
|
block: blocksTable.block,
|
|
name: blocksTable.name,
|
|
description: blocksTable.description,
|
|
icon: blocksTable.icon,
|
|
isEnabled: blocksTable.isEnabled,
|
|
isCustom: blocksTable.isCustom,
|
|
config: blocksTable.config
|
|
}
|
|
|
|
/**
|
|
* Blocks model
|
|
*
|
|
* Built-in blocks live in the `blocks/` workspace, one directory per block. Their metadata is
|
|
* declared as a `static definition` on each Lit component and collected into
|
|
* `blocks/compiled/blocks.manifest.json` by the rollup build, which is what this model reads —
|
|
* the components themselves cannot be imported outside a browser.
|
|
*/
|
|
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.
|
|
*
|
|
* 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')
|
|
try {
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
if (!Array.isArray(manifest)) {
|
|
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 ]`
|
|
)
|
|
WIKI.logger.warn(err.message)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @returns How many rows were added, changed and removed
|
|
*/
|
|
async syncSite(siteId: string): Promise<{ added: number; updated: number; removed: number }> {
|
|
const existing = await WIKI.db
|
|
.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)))
|
|
// -> 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 registrable) {
|
|
const row = existing.find((entry: any) => entry.block === definition.block)
|
|
if (!row) {
|
|
await WIKI.db.insert(blocksTable).values({
|
|
siteId,
|
|
block: definition.block,
|
|
name: definition.name,
|
|
description: definition.description,
|
|
icon: definition.icon,
|
|
isEnabled: true,
|
|
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 = existing
|
|
.map((entry: any) => entry.block)
|
|
.filter((key: string) => !definedKeys.includes(key))
|
|
if (orphaned.length > 0) {
|
|
await WIKI.db
|
|
.delete(blocksTable)
|
|
.where(
|
|
and(
|
|
eq(blocksTable.siteId, siteId),
|
|
eq(blocksTable.isCustom, false),
|
|
inArray(blocksTable.block, orphaned)
|
|
)
|
|
)
|
|
}
|
|
|
|
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) {
|
|
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.`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the blocks available to a site, built-in first, then by name
|
|
*/
|
|
async getSiteBlocks(siteId: string): Promise<SiteBlock[]> {
|
|
const results = await WIKI.db
|
|
.select(blockSelection)
|
|
.from(blocksTable)
|
|
.where(eq(blocksTable.siteId, siteId))
|
|
.orderBy(blocksTable.isCustom, blocksTable.name)
|
|
/*
|
|
`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 ?? ''
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The keys of the blocks a site has switched on.
|
|
*
|
|
* Read from the database on every call rather than kept in a cache like this model's definitions.
|
|
* What this answer gates is which blocks survive a page being saved, and a stale `false` silently
|
|
* strips an author's block out of their page — a wrong answer here destroys content rather than
|
|
* merely showing the wrong list. One indexed read of a handful of rows, on a path that has just
|
|
* sanitised a whole document, is not worth that risk.
|
|
*
|
|
* Child blocks never appear: they have no row of their own, and follow the block they sit in.
|
|
*/
|
|
async getEnabledKeys(siteId: string): Promise<Set<string>> {
|
|
const rows = await WIKI.db
|
|
.select({ block: blocksTable.block })
|
|
.from(blocksTable)
|
|
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.isEnabled, true)))
|
|
return new Set(rows.map((row) => row.block))
|
|
}
|
|
|
|
/**
|
|
* Enable or disable blocks in bulk.
|
|
*
|
|
* @param states Block IDs with their desired state
|
|
* @returns The number of block rows written — a block already in the requested state still counts
|
|
*/
|
|
async setBlocksState(
|
|
siteId: string,
|
|
states: { id: string; isEnabled: boolean }[]
|
|
): Promise<number> {
|
|
let changed = 0
|
|
for (const isEnabled of [true, false]) {
|
|
const ids = states.filter((s) => s.isEnabled === isEnabled).map((s) => s.id)
|
|
if (ids.length < 1) {
|
|
continue
|
|
}
|
|
const result = await WIKI.db
|
|
.update(blocksTable)
|
|
.set({ isEnabled })
|
|
.where(and(eq(blocksTable.siteId, siteId), inArray(blocksTable.id, ids)))
|
|
changed += result.rowCount ?? 0
|
|
}
|
|
return changed
|
|
}
|
|
|
|
/**
|
|
* Delete a custom block. Built-in blocks are rejected, since the next sync would recreate them.
|
|
*
|
|
* @returns Whether a block was deleted
|
|
*/
|
|
async deleteCustomBlock(siteId: string, id: string): Promise<boolean> {
|
|
const result = await WIKI.db
|
|
.delete(blocksTable)
|
|
.where(
|
|
and(eq(blocksTable.siteId, siteId), eq(blocksTable.id, id), eq(blocksTable.isCustom, true))
|
|
)
|
|
return (result.rowCount ?? 0) > 0
|
|
}
|
|
}
|
|
|
|
export const blocks = new Blocks()
|