@beforesemicolon/site-builder
v0.40.0
Published
Site builder based on JSON files
Maintainers
Readme
BeforeSemicolon Site Builder
JSON-first static site builder with TypeScript APIs for parsing templates, widgets, and components.
Installation
npm install @beforesemicolon/site-builderQuick Start
import { buildTemplates } from '@beforesemicolon/site-builder'
await buildTemplates({
srcDir: './src',
publicDir: './public',
prod: true,
})Project Structure
src/
templates/ # *.json templates
components/ # *.json components
widgets/ # *.js widget modules
scripts/ # JS files referenced by templates/widgets
stylesheets/ # CSS files referenced by templates/widgets
locales/ # i18n JSON files (flattened at build time)
assets/ # copied to public/assets
data/ # copied to public/data
public/ # generated outputTemplates
Templates are page/base documents and map to the Template type in src/types.ts.
Template fields (core)
id: stringname: stringtype: 'base' | 'page'pathname?: stringpath used for routing/sitemap URL generationextends?: stringinherit from another template idexcluded?: booleanskip page generation whentruecontent?: string | TemplateContent
Template example
{
"id": "home",
"name": "Home",
"type": "page",
"lang": "en",
"title": "My Site",
"description": "Home page",
"domain": "https://example.com",
"pathname": "/",
"stylesheets": ["main.css"],
"scripts": ["main.js"],
"content": [
{
"name": "main",
"class": "page",
"children": [
{ "name": "h1", "children": ["{{title}}"] },
{
"name": "component",
"id": "hero",
"headline": "Ship faster"
},
{ "name": "widget", "id": "newsletter", "theme": "light" }
]
}
],
"widgetsData": {
"newsletter-home-1": { "cta": "Subscribe now" }
}
}Template inheritance example
{
"id": "about",
"name": "About",
"type": "page",
"extends": "base",
"pathname": "/about",
"title": "About Us",
"content": [{ "name": "h1", "children": ["About"] }]
}Components
Components map to the Component type:
interface Component {
id: string
stylesheets?: Stylesheet[]
content: TemplateContent | string
}They are static reusable blocks rendered inside template/widget content using name: 'component' + id.
Component example
{
"id": "hero",
"stylesheets": ["hero.css"],
"content": [
{
"name": "section",
"class": "hero",
"children": [
{ "name": "h2", "children": ["{{headline}}"] },
{ "name": "p", "children": ["{{subhead}}"] }
]
}
]
}Using a component in template content
{
"name": "component",
"id": "hero",
"headline": "Build with JSON",
"subhead": "Typed and composable"
}Widgets
Widgets map to the Widget type and are loaded via fetchWidget (during build, this comes from src/widgets/<id>.js).
interface Widget {
id: string
name: string
cssSelector?: string
scripts?: Script[]
style?: Style | ((props?: Record<string, unknown>) => Style)
stylesheets?: Stylesheet[]
inputs?: InputDefinition[]
render?: (props) => string
content?: TemplateContent | string
}Widget props merge order
When rendering, props are merged in this order:
- defaults from
inputs - template-level
widgetsData[data-render-id] - inline attributes on the
<widget>node envobject (assetsOrigin,prod, and template data)
Later entries override earlier ones.
Widget example
// src/widgets/newsletter.js
export default {
id: 'newsletter',
name: 'Newsletter',
inputs: [
{ name: 'title', type: 'text', value: 'Join our list' },
{ name: 'cta', type: 'text', value: 'Subscribe' },
],
style: {
'.newsletter': {
padding: '1rem',
border: '1px solid #ddd',
},
},
render: ({ title, cta, $msg }) => {
return `<section class="newsletter"><h3>${title}</h3><button>${cta}</button><small>${$msg('newsletter.disclaimer')}</small></section>`
},
scripts: ['newsletter.js'],
stylesheets: ['newsletter.css'],
}Using a widget in template content
{
"name": "widget",
"id": "newsletter",
"title": "Weekly updates"
}And template-level overrides:
{
"widgetsData": {
"newsletter-home-1": {
"cta": "Get updates"
}
}
}InputDefinitionType
InputDefinitionType defines how widget inputs are declared and converted into runtime props.
Enum values
enum InputDefinitionType {
Text = 'text',
Html = 'html',
Markdown = 'markdown',
Number = 'number',
Code = 'code',
Image = 'image',
Video = 'video',
Audio = 'audio',
File = 'file',
Font = 'font',
Pdf = 'pdf',
Icon = 'icon',
Embed = 'embed',
Favicon = 'favicon',
Email = 'email',
Password = 'password',
Url = 'url',
Tel = 'tel',
Color = 'color',
Date = 'date',
DateTimeLocal = 'datetime-local',
Month = 'month',
Time = 'time',
Week = 'week',
Textarea = 'textarea',
Boolean = 'boolean',
Options = 'options',
Group = 'group',
List = 'list',
}Input definition shape
interface InputDefinition<T = unknown> {
type: InputDefinitionType
readonly?: boolean
name?: string
value?: T
label?: string
dir?: string
description?: string
definitions?: InputDefinition[]
}How it works in practice
- Primitive-like types (
text,number,boolean, etc.) resolve toname: value. groupresolves to a nested object based ondefinitions.listresolves to an array fromdefinitions.optionsresolves to:value: selected valueoptions: mapped option objects fromdefinitions
This conversion is performed by inputDefinitionsToObject.
InputDefinitionType example
import {
InputDefinitionType,
inputDefinitionsToObject,
} from '@beforesemicolon/site-builder'
const defs = [
{ name: 'title', type: InputDefinitionType.Text, value: 'Welcome' },
{
name: 'theme',
type: InputDefinitionType.Options,
value: 'dark',
definitions: [
{
type: InputDefinitionType.Group,
definitions: [
{
name: 'label',
type: InputDefinitionType.Text,
value: 'Dark',
},
{
name: 'value',
type: InputDefinitionType.Text,
value: 'dark',
},
],
},
{
type: InputDefinitionType.Group,
definitions: [
{
name: 'label',
type: InputDefinitionType.Text,
value: 'Light',
},
{
name: 'value',
type: InputDefinitionType.Text,
value: 'light',
},
],
},
],
},
{
name: 'seo',
type: InputDefinitionType.Group,
definitions: [
{
name: 'description',
type: InputDefinitionType.Text,
value: 'Landing page',
},
{
name: 'indexable',
type: InputDefinitionType.Boolean,
value: true,
},
],
},
]
const props = inputDefinitionsToObject(defs)
// {
// title: 'Welcome',
// theme: { value: 'dark', options: [{label: 'Dark', value: 'dark'}, {label: 'Light', value: 'light'}] },
// seo: { description: 'Landing page', indexable: true }
// }Updating definitions from data
Use mergeDataIntoInputDefinition(data, definition) when you need to merge user/content data back into an existing schema. This keeps group and list structures typed and synchronized.
Parse APIs
parseTemplate(template, options)
Returns a full HTML document string (<!doctype html>...) with metadata, OG tags, scripts, styles, optional CSP, preload links, and structured data.
parseTemplateContent({ content, data, opt })
Parses TemplateContent arrays and recursively resolves HTML nodes, widgets, and components.
parseWidget({ node, data, opt })
Renders a single widget node and collects widget scripts/styles.
parseComponent(component, props)
Renders component content with placeholder replacement.
Parse options
interface parseOptions {
useCache?: boolean
prod?: boolean
assetsOrigin?: string
components?: Record<string, Component>
locales?: Record<string, ObjectLiteral>
fetchWidget?: (id: string) => Widget | null | Promise<Widget | null>
fetchTemplate?: (id: string) => Template | null | Promise<Template | null>
}Build Behavior (buildTemplates)
buildTemplates({ srcDir, publicDir, prod }) does the following:
- clears and recreates
publicDir - copies
src/assetstopublic/assets - copies
src/datatopublic/data - loads and flattens
src/locales/*.json - loads templates/components
- builds only
type: 'page'templates whereexcluded !== true - resolves
extendsinheritance - bundles local scripts with
esbuild - minifies local stylesheets with
clean-css - parses templates to HTML and minifies output
- writes fallback
robots.txtandsitemap.xmlwhen missing insrc/assets
Development
npm run test
npm run test:watch
npm run lint
npm run buildLicense
BSD-3-Clause. See LICENSE.
