@beforesemicolon/site-builder
v1.14.1
Published
JSON and Markdown static site builder
Maintainers
Readme
BeforeSemicolon Site Builder
JSON-first component runtime and static site builder with a Markdown documentation adapter.
Pages and reusable components are complete JSON definitions. Components can own
their content, JSON CSS, external stylesheets, and scripts. The page compiler
collects dependencies from rendered components, deduplicates them, emits them in
the document head, and extends the page CSP for external script and stylesheet
origins. It also detects external resources in rendered templates and component
CSS and adds each exact origin to the narrow matching CSP directive. Generated
inline style and script blocks are authorized with exact SHA-256 hashes instead
of unsafe CSP keywords. Static template style attributes are compiled into
generated CSS rules before output, preserving CSP-safe page documents and
standalone component fragments.
Installation
Requires Node.js 22.13.0 or later and npm 10 or later. Both ESM and CommonJS entrypoints are supported.
npm install @beforesemicolon/site-builderBuild a Project
import { buildProject } from '@beforesemicolon/site-builder'
await buildProject({
srcDir: process.cwd(),
publicDir: 'public',
prod: true,
})Page data sources
Page and route data can load project-local Markdown or JSON files at build time. Source paths are relative to the project root and may use the effective page locale:
{
"data": {
"post": {
"$source": "blogs/example.{locale}.md"
}
}
}Locale-aware sources try the exact locale, its base locale, and then the
configured default locale. Route data overrides page data, allowing many routes
to reuse one page with different sources. Resolved values remain available
through existing expressions such as {{data.post.metadata.title}}.
Markdown sources expose front matter as metadata, the Markdown body as raw,
the selected locale and sourcePath, and sanitized content for bfs-html.
Fenced code is syntax-highlighted while compiling; no browser highlighter is
required. The builder also calculates metadata.readingMinutes from the
localized Markdown prose and code. Templates can render it directly:
{
"children": ["{{data.post.metadata.readingMinutes}}", " min read"]
}Compiler-generated readingMinutes overrides any same-named front-matter
field. JSON sources resolve to their parsed value and may contain nested
$source descriptors. This makes a list index capable of loading the same
Markdown metadata used by an article page without copying derived values:
{
"posts": [
{
"href": "/blog/example",
"article": {
"$source": "blogs/example.{locale}.md"
}
}
]
}Literal data continues to work unchanged. Circular nested sources produce a build diagnostic.
A source may define a build-time $mapper. The mapper is a keyed output
object, so components continue to receive ordinary page data without knowing
the source's original shape:
{
"data": {
"faq": {
"$source": "assets/services/business-faq.json",
"$mapper": {
"groups": {
"$map": "categories",
"as": "category",
"to": {
"id": "{{category.id}}",
"title": "{{category.name}}",
"items": {
"$filter": "faqs",
"where": "{{item.categoryId === category.id}}",
"$map": {
"question": "{{item.question}}",
"answer": "{{item.answer}}"
}
}
}
}
}
}
}
}Mapper strings resolve source paths with {{path.to.value}}. $map projects
an array through to and may name its current value with as. $filter
selects array values with a boolean path or strict ===/!== comparison, then
projects each match through $map. Mapping is completed before static page
rendering, and the result replaces the source descriptor at its existing data
key (for example, data.faq). Mapper expressions never execute JavaScript.
Build-time expression utilities keep deterministic presentation out of browser
scripts. date.long, date.medium, and date.short format values with the
compile locale and UTC; url.encode prepares values for static URLs. The build
year is available as {{build.year}}:
{
"name": "time",
"attributes": { "datetime": "{{data.post.metadata.createdAt}}" },
"children": ["{{data.post.metadata.createdAt | date.long}}"]
}Pages can provide arbitrary resolved metadata without a post-build HTML pass:
{
"metadata": {
"meta": [
{
"name": "author",
"content": "{{data.post.metadata.author.name}}"
},
{
"property": "article:published_time",
"content": "{{data.post.metadata.createdAt}}"
}
]
}
}buildProject discovers the project, validates definitions, compiles all
targets, copies public assets, and writes the generated site to publicDir.
It generates an llms.txt page index from routed page titles, descriptions,
optional summaries, source paths, and canonical URLs. Add assets/llms.txt to
provide a custom index. Package-owned assets/llms-full.txt content is copied
to the public root when present.
Build Markdown Documentation
buildDocs turns Markdown files into the same locale-aware static-site output
used by buildProject:
import { buildDocs } from '@beforesemicolon/site-builder'
await buildDocs()Configure the documentation source alongside the shared site and locale fields
in site.config.json:
{
"schemaVersion": "1.0",
"mode": "static",
"siteUrl": "https://docs.example.com",
"defaultLocale": "en",
"locales": ["en", "pt"],
"docs": {
"sourceDir": "docs",
"outputDir": "website",
"template": "fading-citrus"
},
"deployment": {
"targets": {
"netlify": true,
"cloudflareWorkers": {
"name": "example-docs",
"compatibilityDate": "2026-08-11"
}
}
}
}Use document for head configuration shared by every JSON and Markdown page:
{
"document": {
"metadata": { "robots": "index, follow" },
"analytics": {
"providers": [
{
"type": "google-analytics",
"tagId": "G-EXAMPLE",
"enabled": true,
"config": { "send_page_view": true }
}
]
}
}
}document accepts the document-producing subset of a page definition:
metadata, links, scripts, styles, csp, and analytics. Shared links,
scripts, and styles are emitted before page entries. Page metadata, CSP
directives, and analytics override the corresponding shared values. JSON
extends remains the page-specific inheritance mechanism; document is the
site-wide baseline.
Each page has one canonical Markdown source. Put translatable copy in the
locale catalogs and reference it with {{t.*}} placeholders in front matter,
prose, headings, and Markdown layout options:
docs/documentation/get-started.md
docs/locale/en.json
docs/locale/pt.json---
title: '{{t.pages.getStarted.title}}'
description: '{{t.pages.getStarted.description}}'
layout: default
---
# {{t.pages.getStarted.heading}}
{{t.pages.getStarted.introduction}}Catalog values inserted into prose may contain Markdown. Translation placeholders inside fenced or inline code are preserved as code. A missing non-default translation falls back to the default locale with a warning; a missing default translation is a production build error.
Put reusable navigation, action, label, and footer copy under common and
reference it as {{t.common.*}}. Keep only page-specific copy under pages so
the same translation is not repeated across Markdown files.
Page identity comes from front matter id, or from the Markdown path without
.md. Catalog _routes entries use that identity to translate public routes.
The documentation build generates a concise llms.txt index and a complete
llms-full.txt context file for every locale. The full file preserves code
fences and uses the package's resolved Markdown documentation as its source of
truth. Add docs/llms.txt or docs/llms-full.txt to replace either generated
artifact with package-authored content. Both buildDocs and buildProject
share locale target expansion, catalog fallback, interpolation, static locale
resources, and deployment negotiation.
Authored AI files may use {{project.name}}, {{project.version}}, and the
same {{t.*}} locale templates as Markdown pages. Site Builder resolves them
for each locale without rewriting the package's remaining content.
Project Structure
assets/
components/
forms/
locale/
modals/
pages/
router/
scripts/
services/
state-stores/
stylesheets/
themes/
utils/
site.config.jsonFiles are the registry. Locale messages belong in locale/. Authored browser
scripts belong in scripts/, which buildProject copies verbatim to every
generated locale root. Keep Node.js authoring, migration, validation, and other
build tools outside scripts/ so they are not deployed as browser assets.
Analytics services
An analytics service provides a provider-neutral website analytics interface.
It exposes a public configuration object plus the standard config, page,
track, identify, and consent actions. The service emits generic browser
events so a website-owned analytics integration can supply its provider-specific
behavior without adding provider logic to Site Builder.
Use id as the stable service reference in state-store actions, such as
services.google-analytics.track. name is an optional display label. When an
id is omitted, discovery continues to use name, then the service filename,
for compatibility with existing definitions.
Services can declare a csp object alongside their configuration. At build
time, Site Builder merges those directives into a page only when that page
reaches the service through one of its state stores. This keeps each page's CSP
limited to the providers it actually uses.
{
"id": "website-analytics",
"name": "Website Analytics",
"source": "analytics",
"config": {
"siteId": "public-site-identifier"
},
"csp": {
"connect-src": ["https://analytics-provider.example"]
},
"methods": {
"config": {},
"page": {},
"track": {},
"identify": {},
"consent": {}
}
}Analytics services can be started and stopped with their lifecycle methods.
Each non-configuration action is dispatched as a bfs:analytics browser event;
website-owned integration code is responsible for consuming it.
Declare page-specific scripts on the page that uses them:
{
"id": "blog-post",
"type": "page",
"scripts": [
{
"src": "/scripts/blog-post.js",
"defer": true
}
]
}Script descriptors emit script tags, participate in dependency deduplication,
and inform CSP generation. They do not bundle npm packages or transform
JavaScript imports. Bundle npm dependencies into a browser-compatible file in
scripts/ before calling buildProject, or reference an external
browser-compatible URL. Put a script on a component only when the component
owns that behavior and must load it wherever the component is rendered; keep
page-only orchestration on the page definition.
Name scripts for the behavior they implement, not the page where they first
appeared. For example, a script that only submits contact-form should be
named contact-form-submission.js and declared by that form component. A page
name such as contact-page.js implies broader page ownership and makes reuse
and dependency auditing harder.
Themes
Theme files live in themes/ and expose nested design tokens as CSS custom
properties. See the complete theme reference for every
standard token and a copyable full JSON example. Select a theme with
defaultTheme in site.config.json:
{
"schemaVersion": "1.0",
"mode": "static",
"defaultTheme": "brand",
"themes": ["brand"]
}defaultTheme is the one theme compiled into the project. The optional
themes array exposes theme names to runtime context; it does not merge or
compile additional theme files. A single theme can define both light and dark
values.
New themes can start with the standard token namespaces while adding arbitrary palette entries, component values, and project-specific values:
{
"schemaVersion": "1.0",
"name": "brand",
"tokens": {
"color": {
"background": {
"default": "#ffffff",
"dark": "#111111"
},
"foreground": {
"default": "#172033",
"dark": "#f7f9fc"
},
"primary": {
"default": "#165ef0",
"light": "#165ef0",
"dark": "#8ab4ff"
},
"primaryForeground": "#ffffff",
"success": {
"default": "#16803c",
"dark": "#4ade80"
},
"brand": {
"50": "#eef4ff",
"500": "#165ef0",
"900": "#102a56"
}
},
"typography": {
"h1": {
"fontFamily": "Inter, system-ui, sans-serif",
"fontSize": "1.25rem",
"fontWeight": 700,
"lineHeight": 1.2,
"letterSpacing": "-0.025em",
"color": "var(--color-foreground)"
},
"paragraph": {
"fontFamily": "Inter, system-ui, sans-serif",
"fontSize": "1rem",
"lineHeight": 1.625,
"color": "var(--color-text)"
}
},
"spacing": {
"sm": "0.5rem",
"md": "0.75rem",
"lg": "1rem"
},
"radius": {
"sm": "0.25rem",
"md": "0.5rem",
"full": "9999px"
},
"shadow": {
"md": "0 10px 15px -3px rgb(0 0 0 / 0.1)"
},
"motion": {
"duration": { "fast": "150ms", "normal": "250ms" },
"easing": { "standard": "cubic-bezier(0.2, 0, 0, 1)" }
},
"component": {
"button": { "height": "2.5rem" }
},
"custom": {
"brand": { "logoSize": "2rem" }
}
}
}A scalar value applies in both modes. A mode-aware value requires default
and at least one of light or dark; the missing mode falls back to
default. The selected theme automatically emits light and dark
prefers-color-scheme rules plus explicit [data-theme="light"] and
[data-theme="dark"] overrides.
Token paths are camel-case normalized and joined with hyphens:
| Token path | CSS custom property |
| ---------------------------- | ------------------------------- |
| color.primaryForeground | --color-primary-foreground |
| color.foreground.DEFAULT | --color-foreground |
| color.foreground.100 | --color-foreground-100 |
| typography.body.fontFamily | --typography-body-font-family |
| typography.h1.fontSize | --typography-h1-font-size |
| motion.duration.fast | --motion-duration-fast |
| component.button.height | --component-button-height |
| custom.brand.logoSize | --custom-brand-logo-size |
The standard namespaces are color, typography, spacing, size,
breakpoint, container, borderWidth, borderStyle, radius, shadow,
insetShadow, dropShadow, opacity, blur, perspective, aspectRatio,
zIndex, gradient, motion, component, and custom. color supplies
semantic roles for surfaces, actions, focus, links, selection, code, and
success/info/warning/danger feedback. Every group accepts additional nested
values. Within color, an uppercase DEFAULT base plus positive integer keys
creates an unsuffixed semantic property and an ordered scale; for example,
color.foreground.DEFAULT and color.foreground.100 emit
--color-foreground and --color-foreground-100.
The built-in light, dark, and adaptive system themes include a complete
starter set. A TypeScript-authored adaptive theme can reuse the same values
with createDefaultThemeTokens('system'):
import {
createDefaultThemeTokens,
type ThemeDefinitionV1,
} from '@beforesemicolon/site-builder'
const defaults = createDefaultThemeTokens('system')
export const theme: ThemeDefinitionV1 = {
schemaVersion: '1.0',
name: 'brand',
tokens: {
...defaults,
color: {
...defaults.color,
primary: {
default: '#0f766e',
dark: '#5eead4',
},
},
custom: {
chart: { positive: '#16a34a' },
},
},
}Existing v1 themes remain valid: arbitrary top-level groups, scalar values,
and the legacy theme-level mode continue to compile with the same variable
names and selectors when no value-level modes are present. New themes should
omit theme-level mode. A nested object is treated as a mode value only when
its keys are limited to default, light, and dark, and it contains the
required scalar default; other objects remain ordinary token groups. Arrays
and null are retained for input compatibility but do not emit declarations.
breakpoint variables are useful as shared references and from JavaScript,
but native CSS custom properties cannot be substituted into media-query
conditions. Author media queries with literal conditions or a CSS build step.
Forms, Modals, and Flashbars
See the forms guide for the complete form envelope, form-control composition, validation, conditional rendering, submission, script/style ownership, and page-scoping model.
Definitions in forms/ and modals/ use the same component envelope with
kind: "form" or kind: "modal"; those folders are also their build-time
registries. Render a form with FormRenderer; open a modal through a page
handler. Components emit the page handler name and do not need to import or
know about a modal controller:
{
"handlers": {
"openQuote": [
{
"use": "modal.open",
"with": {
"modal": "request-quote",
"data": { "plan": "{{event.detail.plan}}" }
}
}
]
},
"content": [
{
"name": "FormRenderer",
"id": "primary-contact",
"attributes": {
"form": "contact",
"values": { "source": "pricing" }
}
},
{
"name": "PricingCard",
"events": { "quote": "openQuote" }
}
]
}Forms support native server submission, emitted data, or action pipelines;
field rules, conditional visibility/enabled/required state, computed output,
localized labels, file limits, error summaries, and feedback are declarative.
Related fields can be repeated through template repeat; the older additive
groups registry remains readable during project migration. Form-control
components can compose the form template and own their styles, scripts,
props, events, and validation-facing native controls.
A form-specific submission adapter belongs on the form envelope, even when a
page or layout places the form through FormRenderer:
{
"schemaVersion": "1.1",
"name": "contact-form",
"kind": "form",
"category": "form",
"scripts": [
{
"src": "https://cdn.example.com/form-api.js",
"defer": true
},
{
"src": "/scripts/contact-form-submission.js",
"defer": true
}
],
"content": {
"fields": [],
"submit": {
"mode": "emit",
"event": "contact-form-submit"
}
}
}The descriptor order is preserved, so a third-party browser dependency may be
listed before its adapter. Direct form placement and FormRenderer both carry
the form's scripts and styles into the page dependency collector. Identical
URLs are emitted once, and pages that cannot reach the form receive none of
its resources.
Modal requests accept runtime data and resolve to either a resolved or
dismissed outcome. globalThis.BFS.openModal(id, data) exposes the same
promise controller for authorized page scripts.
Flashbars are page actions with optional header, description, action, type,
group, position, priority, and auto-close duration. Page ui.flashbars.groups
configures stacking and collapsing. A flashbar action may open a modal; the
builder follows that dependency automatically.
The renderer computes a feature manifest per page. Reachable components,
forms, actions, modals, triggers, and flashbars are recorded. Related browser
code is emitted only when the page's reachable JSON uses it. Unreferenced files
do not add HTML, CSS, JavaScript, or default UI-store artifacts. Dynamic modal
names must declare their finite allowlist in page.capabilities.modals.
Locale Builds
Locale builds are opt-in. With neither defaultLocale nor a non-empty
locales array in site.config.json, the existing output layout is preserved
and the effective document language is page.lang ?? "en".
Declaring either field builds the complete site once per locale:
{
"schemaVersion": "1.0",
"mode": "static",
"siteUrl": "https://example.com",
"defaultLocale": "en",
"locales": ["en", "pt"]
}public/
_redirects
en/
index.html
about.html
components/
scripts/
stylesheets/
assets/
llms.txt
llms-full.txt
robots.txt
sitemap.xml
data/locale.json
pt/
index.html
sobre.html
components/
scripts/
stylesheets/
assets/
llms.txt
llms-full.txt
robots.txt
sitemap.xml
data/locale.jsonThe compile locale is passed explicitly through compiledLocale; document
language resolution is compiledLocale ?? page.lang ?? "en". The builder adds
portable URL rewrites to the root _redirects file. Browser language and
cookies do not select content: each public URL always resolves to one compiled
locale. Authored _redirects rules remain before the generated rules, so
specific project redirects can override the defaults.
Static deployment targets
Configure Netlify, Cloudflare Workers Static Assets, or both under the shared
deployment field:
{
"deployment": {
"targets": {
"netlify": true,
"cloudflareWorkers": {
"name": "example-static-site",
"compatibilityDate": "2026-08-11",
"notFoundHandling": "404-page",
"htmlHandling": "none",
"previewUrls": true
}
},
"headers": [
{
"path": "/assets/*",
"values": {
"Access-Control-Allow-Origin": "https://cms.example.com"
}
}
]
}
}Each target is independent. Set netlify to false for Cloudflare-only
output, keep it true during a parallel proof of concept, or omit
cloudflareWorkers to preserve Netlify-only behavior. The deprecated
docs.generatedFiles.netlify flag remains a compatibility alias for docs
projects; new configuration should use deployment.targets.netlify.
Cloudflare Worker names use lowercase letters, numbers, and interior dashes,
with a maximum of 63 characters. compatibilityDate must be a real calendar
date in YYYY-MM-DD format. A production build reports invalid values before
deployment.
The generated output uses files understood by both static-asset platforms:
public/
_redirects
_headers
.assetsignore
wrangler.jsonc
netlify.toml_redirects and _headers are portable. wrangler.jsonc is generated only
for a Cloudflare target and configures an assets-only Worker whose asset
directory is the generated output itself; no Worker script or main entry is
needed. .assetsignore keeps the Wrangler and Netlify metadata out of the
uploaded asset namespace while preserving project-authored exclusions.
netlify.toml is copied or generated only when Netlify is enabled.
The generated route rules proxy to exact .html files, so Cloudflare
htmlHandling is fixed to none. This preserves the public route instead of
letting Cloudflare canonicalize an internal locale path into a visible 307
redirect. Locale builds also expand configured public header paths to their
internal locale-storage variants so the same rule is applied by both hosts.
The build creates deployment artifacts but does not authenticate, create a Cloudflare project, change DNS, or publish. Inspect locally with:
npx wrangler dev --config public/wrangler.jsonc --persist-to .wrangler-state
npx netlify dev --dir publicKeep .wrangler-state out of source control. Supplying a persistence directory
outside public/ also prevents Wrangler's local state files from triggering the
static-asset watcher.
When the output has been verified, an external deploy workflow can publish it
with npx wrangler deploy --config public/wrangler.jsonc. Keeping deploy
execution outside the builder lets the CMS use the same build result while
choosing Netlify, Cloudflare, or both per project.
Redirect compatibility
When locale builds are enabled, pages exist below locale directories such as
public/en/index.html; the builder does not emit a duplicate
public/index.html. Do not keep redirects from a non-localized deployment that
target root page files:
# Remove these when enabling locale builds.
/ /index.html 200
/about /about.html 200
/* /index.html 200Keep only project-specific exceptions in the source _redirects file. They
will be copied ahead of the generated locale rewrites:
# Project-specific redirects must precede the locale rewrites generated by
# @beforesemicolon/site-builder.
/admin https://cms.example.com 301Keep portable header rules in deployment.headers or a source _headers
file. Do not duplicate page or catch-all redirects in netlify.toml.
Cloudflare does not support Netlify's forced status suffix (200! or 301!),
domain-level source redirects, source query matching, or country, language,
and cookie conditions in _redirects. When Cloudflare is enabled, a production
build fails with a targeted diagnostic if an authored rule uses those forms.
After buildProject or buildDocs, the emitted public/_redirects contains
the authored exceptions followed by internal 200 rewrites from every public
route to its file below public/[locale]/. It contains no generated
cross-locale redirects or request-language conditions. Edit the source
_redirects, not the generated copy in public/.
If multiple locales declare the same route, the default locale keeps the clean
path and non-default locales receive an unambiguous locale-prefixed path. For
example, a shared /blog route becomes /blog for English and /pt/blog for
Portuguese. A translated route such as /sobre remains unprefixed.
Set siteUrl to the public origin used for canonical URLs, alternate-language
links, sitemaps, and locale-specific robots.txt sitemap references. Every
localized document receives its own canonical URL plus hreflang links for all
configured locales and x-default. Standalone component artifacts are compiled
with the same selected catalog as their locale root.
Each locale root also receives data/locale.json as an optional static locale
resource. Locale messages used by HTML are resolved during compilation. If an
authored browser interaction needs localized copy, render only those values into
its component attributes instead of shipping the complete catalog globally.
Locale catalogs may define translated public routes by stable route ID:
{
"_routes": {
"about": "/sobre",
"contact": "/contacto"
}
}Missing entries fall back to the route definition's path. Templates should use the injected route map instead of hardcoded internal paths, including inside nested attribute data:
{
"name": "a",
"attributes": {
"href": "{{routes.about}}"
},
"children": ["About"]
}Components
Every component has a required name. kind, category, and runtime
default to custom, layout, and static. Kinds describe renderer semantics;
categories are open classification metadata for libraries and authoring tools.
The built-in kinds are custom, form, form-control, and modal, but a
project can register another kind parser.
Props use the shared data-type model. A finite choice uses a first-class enum so renderers and visual editors can expose the valid options:
{
"type": {
"type": "enum",
"options": ["button", "submit", "reset"],
"default": "button"
}
}Static components render their content without an invented wrapper. Client
components get one configurable host and server-render their content inside
it. Web Components compile to @beforesemicolon/web-component; prefix plus
name produces the tag (bfs + Button becomes bfs-button). Only pages that
render a Web Component receive its module tag. Generated modules share one
bundled browser runtime instead of duplicating the library.
{
"schemaVersion": "1.1",
"name": "Button",
"description": "Submits a form or triggers an action.",
"icon": "/assets/icons/button.svg",
"prefix": "bfs",
"aliases": ["ActionButton"],
"kind": "form-control",
"category": "form",
"runtime": "web-component",
"config": {
"role": "button",
"webComponent": {
"shadow": { "mode": "open", "delegatesFocus": true },
"formAssociated": true
},
"formControl": { "valueProp": "value" }
},
"props": {
"label": { "type": "string", "default": "Submit" },
"value": { "type": "string", "default": "" }
},
"state": {
"busy": { "type": "boolean", "default": false }
},
"slots": {
"default": {
"description": "Button label",
"multiple": true,
"fallback": [{ "name": "span", "children": ["{{props.label}}"] }]
}
},
"events": {
"activate": { "type": "CustomEvent", "bubbles": true, "composed": true }
},
"handlers": {
"activate": [
{
"use": "emit",
"with": { "name": "activate", "detail": "{{input}}" }
}
]
},
"listeners": {
"keydown": {
"handler": "activate",
"options": { "capture": true }
}
},
"lifecycle": {
"mount": [{ "use": "debug.log", "with": "mounted" }],
"destroy": [{ "use": "debug.log", "with": "destroyed" }],
"formReset": [
{ "use": "component.state.set", "path": "busy", "value": false }
]
},
"styles": [
{
":host": { "display": "inline-block" },
"button": {
"display": "inline-flex",
"align-items": "center"
}
}
],
"dependencies": { "components": ["Icon"] },
"content": [
{
"name": "button",
"attributes": { "type": "button", "value": "{{props.value}}" },
"events": { "click": "activate" },
"children": [{ "name": "slot" }]
}
]
}Lifecycle callbacks are available only for the web-component target:
mount, update, destroy, adopt, error, formAssociated,
formDisabled, formReset, and formStateRestore. Form callbacks require
config.webComponent.formAssociated: true. Native listeners are separate from
emitted custom-event definitions. JSON CSS supports declarations, nesting,
conditional rules, keyframes, descriptor rules, and repeated values.
content remains kind-owned and open. Custom and form-control components use
template-node arrays. Form and modal components use their specialized content
objects and automatically receive the semantic <form> or <dialog> base.
Open config namespaces allow libraries to add metadata without changing the
builder schema. config.host supports a client host tag and default
attributes; config.webComponent.importSource can override the generated
shared runtime import when a project supplies its own module.
Parse APIs
import { parseComponent, parsePage } from '@beforesemicolon/site-builder'parsePage renders a complete page document.
parseComponent accepts output: 'both' | 'document' | 'fragment' and defaults
to both. Document output places dependencies in the document head. Fragment
output emits dependency tags immediately before the rendered component.
Both parsers can lazily load locale catalogs alongside their other resources. The hook returns parsed catalog JSON and automatically loads the default, base, and exact locale fallback chain before rendering:
await parsePage(page, {
graph,
locale: 'pt-BR',
hooks: {
fetchLocale: async (locale) => {
const response = await fetch(`/locale/${locale}.json`)
return response.ok ? response.json() : null
},
},
})buildProject and buildDocs accept the same hooks.fetchLocale option.
Catalogs already discovered from locale/ or docs/locale/ take precedence;
the hook only fetches missing catalogs and each fetched catalog is cached in the
project graph.
Canonical subpath imports are also available:
import { parsePage } from '@beforesemicolon/site-builder/parse-page'
import { parseComponent } from '@beforesemicolon/site-builder/parse-component'Browser Bundle
The browser build exposes the promoted parser API without a version namespace:
<script src="https://unpkg.com/@beforesemicolon/site-builder/dist/client.js"></script>
<script>
const { parsePage, parseComponent } = window.BFS.SITE_BUILDER
</script>Runtime Specification
See docs/spec.md for page and component definitions, data flow, compile targets, dependency handling, and CSP behavior.
Development
Use Node.js ^22.13.0 or >=24 for development (required by ESLint 10),
with npm 10 or later. The published package requires Node.js 22.13.0 or later.
After building, run npm run test:package to verify a clean, engine-strict
production install, public ESM/CommonJS exports, and browser CSP hashes.
CI runs these checks on the exact Node.js 22.13.0 minimum and current Node 22/24.
npm ci
npm run test
npm run test:watch
npm run lint
npm run buildLicense
BSD-3-Clause. See LICENSE.
