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
|
||||
dist
|
||||
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