mirror of https://github.com/requarks/wiki
parent
023711cd1a
commit
c2dc0a1fcb
@ -0,0 +1,75 @@
|
|||||||
|
import fastifyStatic from '@fastify/static'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* _blocks Routes — the compiled web components a page's blocks are drawn by.
|
||||||
|
*
|
||||||
|
* Two places answer from here and the URL does not say which: `/_blocks/block-diagram.js` is a file
|
||||||
|
* in `blocks/compiled` when the block ships with the wiki, and a file unpacked from a row in the
|
||||||
|
* database when somebody imported it. The first segment of the path names the block, and that is the
|
||||||
|
* whole of the decision — everything a block brings is under `block-<key>.js`,
|
||||||
|
* `block-<key>.worker.js` or `block-<key>/`, which is the namespace `helpers/wkblock.ts` holds an
|
||||||
|
* imported package to precisely so that this can be settled by looking at one segment.
|
||||||
|
*
|
||||||
|
* Which means the answer depends on WHICH SITE was asked, since a custom block belongs to one site,
|
||||||
|
* and two sites on an instance may each have imported a different block under the same key. The
|
||||||
|
* frontend has no site in hand when it loads a block — it reads a tag out of the page and asks for it
|
||||||
|
* — so the hostname is what resolves it, the same lookup every request hook does.
|
||||||
|
*
|
||||||
|
* This is a plain route rather than a second `@fastify/static` registration because a static plugin
|
||||||
|
* claims `/_blocks/*` outright, leaving nothing to ask the question in front of it. The plugin is
|
||||||
|
* still registered, with `serve: false`, for `reply.sendFile` and everything it knows about ranges,
|
||||||
|
* conditional requests and content types.
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
const builtInRoot = path.join(WIKI.ROOTPATH, 'blocks/compiled')
|
||||||
|
|
||||||
|
app.register(fastifyStatic, {
|
||||||
|
root: builtInRoot,
|
||||||
|
serve: false
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{ Params: { '*': string } }>('/*', async (req, reply) => {
|
||||||
|
const filePath = req.params['*'] ?? ''
|
||||||
|
const block = blockKeyOf(filePath)
|
||||||
|
const siteId = WIKI.sitesMappings[req.hostname] || WIKI.sitesMappings['*']
|
||||||
|
|
||||||
|
const customRoot =
|
||||||
|
block && siteId ? await WIKI.models.blocks.servingPathFor(siteId, block) : null
|
||||||
|
|
||||||
|
if (!customRoot) {
|
||||||
|
return reply.sendFile(filePath, builtInRoot, { maxAge: '1h' })
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
Revalidated rather than held for an hour like a built-in. The file names are the same across
|
||||||
|
versions — `block-xyz.js` is `block-xyz.js` however many times it has been re-imported — and
|
||||||
|
the whole point of uploading a fixed block is that the fix is live. An ETag turns nearly every
|
||||||
|
one of these into an empty 304, which is what makes that affordable.
|
||||||
|
*/
|
||||||
|
reply.header('Cache-Control', 'public, no-cache')
|
||||||
|
return reply.sendFile(filePath, customRoot, { cacheControl: false })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The block a served path belongs to, or null for a path that names no block.
|
||||||
|
*
|
||||||
|
* `block-pdf.js`, `block-pdf.worker.js` and `block-pdf/cmaps/Adobe-Japan1-0.bcmap` are all the pdf
|
||||||
|
* block. Anything else — `blocks.manifest.json`, a shared chunk of the built-in build — is nobody's,
|
||||||
|
* and is a built-in file by elimination.
|
||||||
|
*/
|
||||||
|
function blockKeyOf(filePath: string): string | null {
|
||||||
|
const first = filePath.split('/')[0]
|
||||||
|
if (!first?.startsWith('block-')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const stem = first.endsWith('.worker.js')
|
||||||
|
? first.slice(0, -'.worker.js'.length)
|
||||||
|
: first.endsWith('.js')
|
||||||
|
? first.slice(0, -'.js'.length)
|
||||||
|
: first
|
||||||
|
return /^block-[a-z0-9][a-z0-9-]*$/.test(stem) ? stem.slice('block-'.length) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "blocks" ADD COLUMN "definition" jsonb DEFAULT '{}' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "blocks" ADD COLUMN "packageData" bytea;--> statement-breakpoint
|
||||||
|
ALTER TABLE "blocks" ADD COLUMN "checksum" varchar(64) DEFAULT '' NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,322 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { gunzipSync } from 'node:zlib'
|
||||||
|
import { CustomError } from './common.ts'
|
||||||
|
import type { BlockDefinition, BlockProp } from '../models/blocks.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading a `.wkblock` — the single file a block is distributed as.
|
||||||
|
*
|
||||||
|
* `blocks/package.mjs` is the other half of this, and the two have to agree. There is no module to
|
||||||
|
* share between them: `blocks/` and `backend/` are separately installed workspaces and the backend
|
||||||
|
* does not type-check JavaScript, so the format is written twice and stated in full in both places.
|
||||||
|
*
|
||||||
|
* magic 8 bytes "WKBLOCK\0"
|
||||||
|
* version uint32be format version, 1
|
||||||
|
* headerLen uint32be byte length of the header that follows
|
||||||
|
* header gzip'd JSON — see `PackageHeader`
|
||||||
|
* payload each file's gzip'd bytes, concatenated in the header's order
|
||||||
|
*
|
||||||
|
* Everything here treats the package as something a person uploaded, because that is what it is:
|
||||||
|
* `manage:sites` is the trust boundary for the CODE in it — which runs in every reader's browser on
|
||||||
|
* that site, and is no more and no less than what the raw head and body fields under Theme already
|
||||||
|
* allow — but the container itself is parsed before anybody has vouched for anything. So every
|
||||||
|
* length is bounded before it is acted on, every digest is checked, and every path has to fall inside
|
||||||
|
* the block's own namespace.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAGIC = Buffer.from('WKBLOCK\0', 'latin1')
|
||||||
|
const FORMAT_VERSION = 1
|
||||||
|
const PREAMBLE_SIZE = 16
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most a `.wkblock` may weigh, and the body limit of the route that takes one.
|
||||||
|
*
|
||||||
|
* Deliberately not the site's asset upload limit: that one is about what readers may attach to
|
||||||
|
* pages and is usually turned down, while a block carrying a PDF engine and its character maps is
|
||||||
|
* legitimately a couple of dozen megabytes.
|
||||||
|
*/
|
||||||
|
export const MAX_PACKAGE_SIZE = 32 * 1024 * 1024
|
||||||
|
|
||||||
|
/** Bounds on what the container may claim, all checked before anything is decompressed. */
|
||||||
|
const MAX_HEADER_SIZE = 4 * 1024 * 1024
|
||||||
|
const MAX_UNPACKED_SIZE = 128 * 1024 * 1024
|
||||||
|
const MAX_FILE_COUNT = 4096
|
||||||
|
|
||||||
|
/** A block key, which is also a file name and the suffix of an element. */
|
||||||
|
const BLOCK_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prop name, which becomes an attribute the sanitiser allows on the block's tag.
|
||||||
|
*
|
||||||
|
* Attribute names are what the allow list is built from, so a name that is not one would either be
|
||||||
|
* dropped silently or widen that list in a way nobody wrote down.
|
||||||
|
*/
|
||||||
|
const PROP_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,63}$/
|
||||||
|
|
||||||
|
const PROP_TYPES = new Set(['string', 'number', 'boolean', 'select', 'icon'])
|
||||||
|
|
||||||
|
interface PackageFileEntry {
|
||||||
|
path: string
|
||||||
|
size: number
|
||||||
|
compressedSize: number
|
||||||
|
sha256: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageHeader {
|
||||||
|
block: string
|
||||||
|
definition: BlockDefinition
|
||||||
|
packagedAt?: string
|
||||||
|
packagedWith?: string
|
||||||
|
files: PackageFileEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A package read, checked and unpacked in memory, ready to be stored and written to the cache. */
|
||||||
|
export interface BlockPackage {
|
||||||
|
/** The block key — the suffix of `<block-xyz>`, and the stem of every path below. */
|
||||||
|
block: string
|
||||||
|
definition: BlockDefinition
|
||||||
|
/** The files to serve, keyed by the path they are served at, relative to `/_blocks/`. */
|
||||||
|
files: Map<string, Buffer>
|
||||||
|
packagedAt: string
|
||||||
|
packagedWith: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function refuse(message: string): never {
|
||||||
|
throw new CustomError('blockPackageInvalid', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a path is one this package is allowed to bring.
|
||||||
|
*
|
||||||
|
* Two things at once, and both matter. It has to be a plain relative path, since it is joined onto a
|
||||||
|
* cache directory — no root, no `..`, no backslashes (a Windows instance would read one as a
|
||||||
|
* separator where this check would not). And it has to sit inside the block's own namespace, which
|
||||||
|
* is what stops an imported block from standing on a built-in one: the serving route decides which
|
||||||
|
* root answers a request from the first segment of the path alone, so a package holding
|
||||||
|
* `block-diagram/foo.js` would answer for a block it is not.
|
||||||
|
*/
|
||||||
|
function isServablePath(filePath: string, blockDir: string): boolean {
|
||||||
|
if (
|
||||||
|
!filePath ||
|
||||||
|
filePath.length > 255 ||
|
||||||
|
filePath.includes('\\') ||
|
||||||
|
filePath.startsWith('/') ||
|
||||||
|
/(^|\/)\.\.?(\/|$)/.test(filePath) ||
|
||||||
|
filePath.endsWith('/') ||
|
||||||
|
filePath.includes('//') ||
|
||||||
|
[...filePath].some((char) => char.codePointAt(0)! < 0x20)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
filePath === `${blockDir}.js` ||
|
||||||
|
filePath === `${blockDir}.worker.js` ||
|
||||||
|
filePath.startsWith(`${blockDir}/`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The definition as the package declares it, with everything the wiki will act on checked.
|
||||||
|
*
|
||||||
|
* Read rather than trusted, and rebuilt key by key rather than spread: what comes back is stored on
|
||||||
|
* the block's row, handed to the editor to build a form from, and turned into the sanitiser's
|
||||||
|
* allow list for the block's tag. An unknown key would travel all of that way meaning nothing.
|
||||||
|
*/
|
||||||
|
function readDefinition(raw: any, block: string): BlockDefinition {
|
||||||
|
if (!raw || typeof raw !== 'object' || raw.block !== block) {
|
||||||
|
refuse("The package's definition does not describe the block it claims to be.")
|
||||||
|
}
|
||||||
|
if (raw.isChild) {
|
||||||
|
refuse(
|
||||||
|
'This is a child block — one that only ever appears inside another. It has nothing to be installed or switched on separately from whatever holds it.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const text = (value: unknown, field: string, max: number): string => {
|
||||||
|
if (typeof value !== 'string' || value.length > max) {
|
||||||
|
refuse(`The package's definition has no usable "${field}".`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const definition: BlockDefinition = {
|
||||||
|
block,
|
||||||
|
name: text(raw.name, 'name', 255),
|
||||||
|
description: text(raw.description ?? '', 'description', 255),
|
||||||
|
icon: text(raw.icon ?? '', 'icon', 255),
|
||||||
|
props: readProps(raw.props)
|
||||||
|
}
|
||||||
|
if (raw.template) {
|
||||||
|
definition.template = text(raw.template, 'template', 8192)
|
||||||
|
}
|
||||||
|
if (raw.asciidocTemplate) {
|
||||||
|
definition.asciidocTemplate = text(raw.asciidocTemplate, 'asciidocTemplate', 8192)
|
||||||
|
}
|
||||||
|
if (raw.contentEditor) {
|
||||||
|
definition.contentEditor = text(raw.contentEditor, 'contentEditor', 64)
|
||||||
|
}
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProps(raw: any): BlockProp[] {
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (!Array.isArray(raw) || raw.length > 64) {
|
||||||
|
refuse('The package\'s definition declares an unusable "props" list.')
|
||||||
|
}
|
||||||
|
return raw.map((prop: any) => {
|
||||||
|
if (!prop || typeof prop !== 'object' || !PROP_NAME_PATTERN.test(prop.name ?? '')) {
|
||||||
|
refuse(`"${prop?.name}" is not a usable prop name — it becomes an attribute on the block.`)
|
||||||
|
}
|
||||||
|
if (!PROP_TYPES.has(prop.type)) {
|
||||||
|
refuse(`Prop "${prop.name}" has no usable type.`)
|
||||||
|
}
|
||||||
|
const checked: BlockProp = { name: prop.name, type: prop.type }
|
||||||
|
for (const field of ['label', 'hint'] as const) {
|
||||||
|
if (typeof prop[field] === 'string') {
|
||||||
|
checked[field] = prop[field].slice(0, 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prop.required === true) {
|
||||||
|
checked.required = true
|
||||||
|
}
|
||||||
|
if (['string', 'number', 'boolean'].includes(typeof prop.default)) {
|
||||||
|
checked.default = prop.default
|
||||||
|
}
|
||||||
|
if (Array.isArray(prop.options)) {
|
||||||
|
checked.options = prop.options
|
||||||
|
.slice(0, 128)
|
||||||
|
.map((option: any) =>
|
||||||
|
typeof option === 'string'
|
||||||
|
? option
|
||||||
|
: { label: String(option?.label ?? ''), value: String(option?.value ?? '') }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return checked
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a `.wkblock` file.
|
||||||
|
*
|
||||||
|
* Throws a `CustomError` naming what is wrong with it, since every one of these is something the
|
||||||
|
* administrator who uploaded the file can act on — a truncated download, the wrong file, a package
|
||||||
|
* built by a newer wiki.
|
||||||
|
*/
|
||||||
|
export function readBlockPackage(data: Buffer): BlockPackage {
|
||||||
|
if (!Buffer.isBuffer(data) || data.length < PREAMBLE_SIZE || !data.subarray(0, 8).equals(MAGIC)) {
|
||||||
|
refuse(
|
||||||
|
'Not a Wiki.js block package. A packaged block is a .wkblock file built by "npm run package" in blocks/.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const version = data.readUInt32BE(8)
|
||||||
|
if (version !== FORMAT_VERSION) {
|
||||||
|
refuse(
|
||||||
|
`This package is in block format ${version}, and this wiki reads format ${FORMAT_VERSION}. It was most likely built by a different version of Wiki.js.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerLength = data.readUInt32BE(12)
|
||||||
|
if (
|
||||||
|
headerLength < 1 ||
|
||||||
|
headerLength > MAX_HEADER_SIZE ||
|
||||||
|
PREAMBLE_SIZE + headerLength > data.length
|
||||||
|
) {
|
||||||
|
refuse('The package is damaged: its table of contents does not fit inside it.')
|
||||||
|
}
|
||||||
|
|
||||||
|
let header: PackageHeader
|
||||||
|
try {
|
||||||
|
header = JSON.parse(
|
||||||
|
gunzipSync(data.subarray(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength), {
|
||||||
|
maxOutputLength: MAX_HEADER_SIZE
|
||||||
|
}).toString('utf8')
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
refuse('The package is damaged: its table of contents could not be read.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = header.block
|
||||||
|
if (typeof block !== 'string' || !BLOCK_KEY_PATTERN.test(block)) {
|
||||||
|
refuse(
|
||||||
|
`"${block}" is not a usable block key — it has to be lowercase letters, digits and dashes.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const blockDir = `block-${block}`
|
||||||
|
const definition = readDefinition(header.definition, block)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Array.isArray(header.files) ||
|
||||||
|
header.files.length < 1 ||
|
||||||
|
header.files.length > MAX_FILE_COUNT
|
||||||
|
) {
|
||||||
|
refuse('The package lists no files, or more than a block can hold.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> Everything the header CLAIMS is checked before a byte of the payload is touched, so that a
|
||||||
|
// package cannot talk this into decompressing more than it is prepared to hold
|
||||||
|
let unpackedSize = 0
|
||||||
|
let payloadSize = 0
|
||||||
|
for (const entry of header.files) {
|
||||||
|
if (!isServablePath(entry?.path, blockDir)) {
|
||||||
|
refuse(
|
||||||
|
`The package holds "${entry?.path}", which is outside ${blockDir}'s own files. A block may only bring ${blockDir}.js, ${blockDir}.worker.js and ${blockDir}/**.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(entry.size) ||
|
||||||
|
entry.size < 0 ||
|
||||||
|
!Number.isInteger(entry.compressedSize) ||
|
||||||
|
entry.compressedSize < 0 ||
|
||||||
|
typeof entry.sha256 !== 'string' ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(entry.sha256)
|
||||||
|
) {
|
||||||
|
refuse(`The package's entry for "${entry.path}" is damaged.`)
|
||||||
|
}
|
||||||
|
unpackedSize += entry.size
|
||||||
|
payloadSize += entry.compressedSize
|
||||||
|
}
|
||||||
|
if (unpackedSize > MAX_UNPACKED_SIZE) {
|
||||||
|
refuse('The package unpacks to more than a block is allowed to hold.')
|
||||||
|
}
|
||||||
|
if (PREAMBLE_SIZE + headerLength + payloadSize !== data.length) {
|
||||||
|
refuse(
|
||||||
|
'The package is damaged: its contents do not match the length its table of contents states.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = new Map<string, Buffer>()
|
||||||
|
let offset = PREAMBLE_SIZE + headerLength
|
||||||
|
for (const entry of header.files) {
|
||||||
|
if (files.has(entry.path)) {
|
||||||
|
refuse(`The package holds "${entry.path}" twice.`)
|
||||||
|
}
|
||||||
|
let bytes: Buffer
|
||||||
|
try {
|
||||||
|
bytes = gunzipSync(data.subarray(offset, offset + entry.compressedSize), {
|
||||||
|
maxOutputLength: Math.max(entry.size, 1)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
refuse(`The package is damaged: "${entry.path}" could not be decompressed.`)
|
||||||
|
}
|
||||||
|
offset += entry.compressedSize
|
||||||
|
if (
|
||||||
|
bytes.length !== entry.size ||
|
||||||
|
crypto.createHash('sha256').update(bytes).digest('hex') !== entry.sha256
|
||||||
|
) {
|
||||||
|
refuse(`The package is damaged: "${entry.path}" is not what its checksum says it is.`)
|
||||||
|
}
|
||||||
|
files.set(entry.path, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!files.has(`${blockDir}.js`)) {
|
||||||
|
refuse(`The package has no ${blockDir}.js, which is the file the wiki loads the block from.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
block,
|
||||||
|
definition,
|
||||||
|
files,
|
||||||
|
packagedAt: typeof header.packagedAt === 'string' ? header.packagedAt : '',
|
||||||
|
packagedWith: typeof header.packagedWith === 'string' ? header.packagedWith : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
compiled
|
compiled
|
||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
|
packages
|
||||||
|
.package
|
||||||
|
|||||||
@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Package one block into a single `.wkblock` file, for importing into a Wiki.js instance.
|
||||||
|
*
|
||||||
|
* npm run package -- block-xyz
|
||||||
|
*
|
||||||
|
* The block is compiled on its own — see `buildConfig({ only })` in `rollup.config.mjs` for why that
|
||||||
|
* is not simply a slice of the normal build — and everything the compile emitted goes into the
|
||||||
|
* package, together with the `static definition` read off the component. The result lands in
|
||||||
|
* `packages/block-xyz.wkblock` and is uploaded from the instance's Administration → Content Blocks.
|
||||||
|
*
|
||||||
|
* ## The container
|
||||||
|
*
|
||||||
|
* `backend/helpers/wkblock.ts` is the other half of this and the two have to agree. There is no
|
||||||
|
* module to share between them: `blocks/` and `backend/` are separately installed workspaces and the
|
||||||
|
* backend does not type-check JavaScript, so the format is written twice and stated in full in both
|
||||||
|
* places.
|
||||||
|
*
|
||||||
|
* magic 8 bytes "WKBLOCK\0"
|
||||||
|
* version uint32be format version, 1
|
||||||
|
* headerLen uint32be byte length of the header that follows
|
||||||
|
* header gzip'd JSON — see below
|
||||||
|
* payload each file's gzip'd bytes, concatenated in the header's order
|
||||||
|
*
|
||||||
|
* The header:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* "block": "xyz", // the key, i.e. the <block-xyz> element's suffix
|
||||||
|
* "definition": { ... }, // the component's `static definition`, verbatim
|
||||||
|
* "packagedAt": "2026-09-19T...Z",
|
||||||
|
* "packagedWith": "3.0.0", // the wiki the packager came from, for diagnostics only
|
||||||
|
* "files": [
|
||||||
|
* { "path": "block-xyz.js", "size": 12345, "compressedSize": 4321, "sha256": "..." }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Per-file gzip rather than one stream over the lot: a block's assets are often already-compressed
|
||||||
|
* images and fonts sitting beside a bundle that compresses four to one, and a file at a time means
|
||||||
|
* the reader can check a digest as it goes rather than after holding the whole package twice.
|
||||||
|
*
|
||||||
|
* Every path is relative to where the block is served from, and the reader refuses anything outside
|
||||||
|
* the block's own namespace — `block-<key>.js`, `block-<key>.worker.js` and `block-<key>/**`. That
|
||||||
|
* namespace is the whole of what keeps an imported block from overwriting a built-in one, so it is
|
||||||
|
* checked here as well, where the author can still do something about it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { gzipSync } from 'node:zlib'
|
||||||
|
import { rollup } from 'rollup'
|
||||||
|
|
||||||
|
import { buildConfig } from './rollup.config.mjs'
|
||||||
|
|
||||||
|
const MAGIC = Buffer.from('WKBLOCK\0', 'latin1')
|
||||||
|
const FORMAT_VERSION = 1
|
||||||
|
|
||||||
|
const STAGING_DIR = '.package'
|
||||||
|
const OUTPUT_DIR = 'packages'
|
||||||
|
|
||||||
|
/** Emitted by the manifest plugin for the server to read; the package carries the definition itself. */
|
||||||
|
const MANIFEST_FILE = 'blocks.manifest.json'
|
||||||
|
|
||||||
|
function fail (message) {
|
||||||
|
console.error(`\n ✖ ${message}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The block directory named on the command line, as `block-<key>`.
|
||||||
|
*
|
||||||
|
* Both spellings are taken, since half of what is on screen while working on a block says one and
|
||||||
|
* half says the other: the directory is `block-countdown` and the definition's key is `countdown`.
|
||||||
|
*/
|
||||||
|
function resolveBlockDir (argument) {
|
||||||
|
if (!argument) {
|
||||||
|
fail('Which block? Usage: npm run package -- block-xyz')
|
||||||
|
}
|
||||||
|
const dir = argument.replace(/\/+$/, '')
|
||||||
|
const candidate = dir.startsWith('block-') ? dir : `block-${dir}`
|
||||||
|
if (!/^block-[a-z0-9][a-z0-9-]*$/.test(candidate)) {
|
||||||
|
fail(`"${argument}" is not a block directory name — expected something like "block-xyz".`)
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(path.join(candidate, 'component.js'))) {
|
||||||
|
fail(`${candidate}/component.js does not exist. A block is a directory under blocks/ with a component in it.`)
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every file under a directory, as paths relative to it, in a stable order. */
|
||||||
|
function collectFiles (root) {
|
||||||
|
return fs.readdirSync(root, { recursive: true, withFileTypes: true })
|
||||||
|
.filter(entry => entry.isFile())
|
||||||
|
.map(entry => path.relative(root, path.join(entry.parentPath, entry.name)).split(path.sep).join('/'))
|
||||||
|
.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse a compile that put something outside the block's own namespace.
|
||||||
|
*
|
||||||
|
* Nothing in the current build does — the config names chunks into `<block>/` and assets are copied
|
||||||
|
* there — so this is about a change to that config, or to a block's `assets.json`, quietly producing
|
||||||
|
* a package that stands on a file the importing instance already has.
|
||||||
|
*/
|
||||||
|
function assertNamespaced (files, blockDir) {
|
||||||
|
const allowed = [`${blockDir}.js`, `${blockDir}.worker.js`]
|
||||||
|
const stray = files.filter(file => !allowed.includes(file) && !file.startsWith(`${blockDir}/`))
|
||||||
|
if (stray.length > 0) {
|
||||||
|
fail(
|
||||||
|
`The compile emitted ${stray.length} file(s) outside ${blockDir}'s namespace:\n` +
|
||||||
|
stray.map(file => ` ${file}`).join('\n') +
|
||||||
|
`\n\n A package may only hold ${blockDir}.js, ${blockDir}.worker.js and ${blockDir}/**.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize (bytes) {
|
||||||
|
return bytes < 1024 * 1024
|
||||||
|
? `${(bytes / 1024).toFixed(1)} kB`
|
||||||
|
: `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main () {
|
||||||
|
const blockDir = resolveBlockDir(process.argv[2])
|
||||||
|
|
||||||
|
fs.rmSync(STAGING_DIR, { recursive: true, force: true })
|
||||||
|
|
||||||
|
console.log(`\n Compiling ${blockDir}...\n`)
|
||||||
|
const config = buildConfig({ only: blockDir, outputDir: STAGING_DIR })
|
||||||
|
const bundle = await rollup(config)
|
||||||
|
await bundle.write(config.output)
|
||||||
|
await bundle.close()
|
||||||
|
|
||||||
|
// -- The definition, which the manifest plugin has just read off the component's AST
|
||||||
|
|
||||||
|
const manifestPath = path.join(STAGING_DIR, MANIFEST_FILE)
|
||||||
|
const definitions = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||||
|
if (definitions.length !== 1) {
|
||||||
|
fail(`${blockDir}/component.js declares no "static definition" — there is nothing to package.`)
|
||||||
|
}
|
||||||
|
const definition = definitions[0]
|
||||||
|
|
||||||
|
if (`block-${definition.block}` !== blockDir) {
|
||||||
|
fail(
|
||||||
|
`${blockDir} declares itself as "${definition.block}", so it renders as <block-${definition.block}>.\n` +
|
||||||
|
` The directory name and the key have to match: a package is served as block-<key>.js.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (definition.isChild) {
|
||||||
|
fail(
|
||||||
|
`${blockDir} is a child block — it only ever appears inside another one, and has nothing to be\n` +
|
||||||
|
' installed or switched on separately from it. Package the block that holds it instead.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Everything the compile emitted, minus the manifest, which the package states for itself
|
||||||
|
|
||||||
|
const files = collectFiles(STAGING_DIR).filter(file => file !== MANIFEST_FILE)
|
||||||
|
assertNamespaced(files, blockDir)
|
||||||
|
if (!files.includes(`${blockDir}.js`)) {
|
||||||
|
fail(`The compile produced no ${blockDir}.js, which is the file the wiki loads the block from.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
const payload = []
|
||||||
|
for (const file of files) {
|
||||||
|
const bytes = fs.readFileSync(path.join(STAGING_DIR, file))
|
||||||
|
const compressed = gzipSync(bytes, { level: 9 })
|
||||||
|
entries.push({
|
||||||
|
path: file,
|
||||||
|
size: bytes.length,
|
||||||
|
compressedSize: compressed.length,
|
||||||
|
sha256: crypto.createHash('sha256').update(bytes).digest('hex')
|
||||||
|
})
|
||||||
|
payload.push(compressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = gzipSync(Buffer.from(JSON.stringify({
|
||||||
|
block: definition.block,
|
||||||
|
definition,
|
||||||
|
packagedAt: new Date().toISOString(),
|
||||||
|
packagedWith: JSON.parse(fs.readFileSync('../backend/package.json', 'utf8')).version,
|
||||||
|
files: entries
|
||||||
|
}), 'utf8'), { level: 9 })
|
||||||
|
|
||||||
|
const preamble = Buffer.alloc(16)
|
||||||
|
MAGIC.copy(preamble, 0)
|
||||||
|
preamble.writeUInt32BE(FORMAT_VERSION, 8)
|
||||||
|
preamble.writeUInt32BE(header.length, 12)
|
||||||
|
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
|
||||||
|
const packagePath = path.join(OUTPUT_DIR, `${blockDir}.wkblock`)
|
||||||
|
fs.writeFileSync(packagePath, Buffer.concat([preamble, header, ...payload]))
|
||||||
|
|
||||||
|
fs.rmSync(STAGING_DIR, { recursive: true, force: true })
|
||||||
|
|
||||||
|
const uncompressed = entries.reduce((total, entry) => total + entry.size, 0)
|
||||||
|
console.log(` ${definition.name} — <block-${definition.block}>`)
|
||||||
|
console.log(` ${entries.length} file(s), ${formatSize(uncompressed)} uncompressed`)
|
||||||
|
console.log(`\n → ${packagePath} (${formatSize(fs.statSync(packagePath).size)})\n`)
|
||||||
|
console.log(' Import it from Administration → Content Blocks → Import Block.\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
Loading…
Reference in new issue