297 lines
11 KiB
Plaintext
297 lines
11 KiB
Plaintext
-- allium: 1
|
|
-- bDS Liquid Template System
|
|
-- Scope: core (Wave 1 data, Wave 4 rendering)
|
|
-- Distilled from: src/main/engine/TemplateEngine.ts, PageRenderer.ts, schema.ts,
|
|
-- bundled starter templates in src/main/engine/templates/
|
|
|
|
enum TemplateStatus {
|
|
draft
|
|
published
|
|
}
|
|
|
|
entity Template {
|
|
project_id: String
|
|
slug: String
|
|
title: String
|
|
kind: post | list | not_found | partial
|
|
enabled: Boolean
|
|
status: TemplateStatus
|
|
content: String?
|
|
version: Integer
|
|
file_path: String
|
|
created_at: Timestamp
|
|
updated_at: Timestamp
|
|
|
|
-- Derived
|
|
content_location: if status = published: file_path else: content
|
|
referencing_posts: Posts where template_slug = this.slug and project_id = this.project_id
|
|
referencing_tags: Tags where post_template_slug = this.slug and project_id = this.project_id
|
|
|
|
transitions status {
|
|
draft -> published
|
|
published -> draft
|
|
}
|
|
}
|
|
|
|
surface TemplateManagementSurface {
|
|
facing _: TemplateOperator
|
|
|
|
provides:
|
|
CreateTemplateRequested(project, title, kind, content)
|
|
CreateAndPublishTemplateRequested(project, title, kind, content)
|
|
UpdateTemplateRequested(template, changes)
|
|
PublishTemplateRequested(template)
|
|
DeleteTemplateRequested(template)
|
|
ForceDeleteTemplateRequested(template)
|
|
RebuildTemplatesFromFilesRequested(project)
|
|
}
|
|
|
|
invariant UniqueTemplateSlug {
|
|
-- Slug uniqueness is scoped per project, not globally.
|
|
for a in Templates:
|
|
for b in Templates:
|
|
a != b and a.project_id = b.project_id implies a.slug != b.slug
|
|
}
|
|
|
|
invariant TemplateFrontmatter {
|
|
-- .liquid files use standard --- YAML frontmatter
|
|
-- Fields: id, slug, title, kind, enabled, version, createdAt, updatedAt
|
|
-- Field order also includes projectId after id when present. Scalars use
|
|
-- the canonical post frontmatter quoting rules, timestamps are single-quoted
|
|
-- ISO values, and the complete file ends in one newline.
|
|
for t in Templates where status = published:
|
|
parse_frontmatter(read_file(t.file_path)).slug = t.slug
|
|
}
|
|
|
|
invariant TemplateFileLayout {
|
|
for t in Templates where file_path != "":
|
|
t.file_path = format("templates/{slug}.liquid", slug: t.slug)
|
|
}
|
|
|
|
invariant BundledDefaultTemplatesExistOutsideProjectData {
|
|
-- The application ships bundled default templates for:
|
|
-- single-post
|
|
-- post-list
|
|
-- not-found
|
|
-- plus supporting partials and macros.
|
|
-- These bundled templates are available for rendering even when the project
|
|
-- has no template files and no Template rows for those defaults.
|
|
}
|
|
|
|
invariant UserTemplateDirectoryOverridesBundledDefaults {
|
|
-- When a project template file or published Template row resolves the same slug
|
|
-- as a bundled template or partial, the project-owned template wins.
|
|
-- Bundled templates are fallback roots, not copied seed data.
|
|
}
|
|
|
|
invariant RebuildTemplatesIndexesOnlyProjectTemplates {
|
|
-- Rebuild-from-files scans only project.public_dir/templates.
|
|
-- Bundled defaults are render-time fallbacks and are not indexed into Templates
|
|
-- unless the user has created matching project files.
|
|
}
|
|
|
|
rule CreateTemplate {
|
|
when: CreateTemplateRequested(project, title, kind, content)
|
|
let slug = slugify(title)
|
|
-- Creates a draft template: content stored in DB, no file written yet
|
|
ensures:
|
|
let new_template = Template.created(
|
|
project_id: project.id,
|
|
slug: slug,
|
|
title: title,
|
|
kind: kind,
|
|
content: content,
|
|
status: draft,
|
|
enabled: true,
|
|
version: 1,
|
|
file_path: ""
|
|
)
|
|
new_template.status = draft
|
|
}
|
|
|
|
rule CreateAndPublishTemplate {
|
|
-- Alternative creation path: create + immediately publish (file written)
|
|
-- Some implementations may expose this as a single user action
|
|
when: CreateAndPublishTemplateRequested(project, title, kind, content)
|
|
let slug = slugify(title)
|
|
requires: ValidateLiquid(content) = valid
|
|
ensures:
|
|
let new_template = Template.created(
|
|
project_id: project.id,
|
|
slug: slug,
|
|
title: title,
|
|
kind: kind,
|
|
content: null,
|
|
status: published,
|
|
enabled: true,
|
|
version: 1,
|
|
file_path: format("templates/{slug}.liquid", slug: slug)
|
|
)
|
|
TemplateFileWritten(new_template)
|
|
}
|
|
|
|
rule UpdateTemplate {
|
|
when: UpdateTemplateRequested(template, changes)
|
|
ensures: TemplateFieldsUpdated(template, changes)
|
|
if changes.slug != null:
|
|
ensures: template.slug = unique_slug(slugify(changes.slug), excluding: template)
|
|
if template.file_path != "":
|
|
ensures: template.file_path = format("templates/{slug}.liquid", slug: template.slug)
|
|
ensures: TemplateFileWritten(template)
|
|
ensures: PreviousTemplateFileDeleted(template)
|
|
if template.status = published and not template_changes_affect_rendered_output(changes):
|
|
ensures: template.status = published
|
|
ensures: TemplateFileWritten(template)
|
|
-- Keep published frontmatter synchronized for metadata-only and no-op content updates.
|
|
ensures: template.updated_at = now
|
|
ensures: template.version = template.version + 1
|
|
}
|
|
|
|
rule ReopenPublishedTemplate {
|
|
when: UpdateTemplateRequested(template, changes)
|
|
requires: template.status = published
|
|
requires: changes.content != null
|
|
requires: changes.content != effective_template_content(template)
|
|
ensures: template.status = draft
|
|
}
|
|
|
|
rule PublishTemplate {
|
|
when: PublishTemplateRequested(template)
|
|
requires: template.status = draft
|
|
requires: ValidateLiquid(template.content) = valid
|
|
-- The template parser must accept the template
|
|
ensures: template.status = published
|
|
ensures: TemplateFileWritten(template)
|
|
-- Writes frontmatter + liquid to templates/{slug}.liquid
|
|
ensures: template.content = null
|
|
}
|
|
|
|
rule DeleteTemplate {
|
|
when: DeleteTemplateRequested(template)
|
|
requires: template.referencing_posts.count = 0
|
|
requires: template.referencing_tags.count = 0
|
|
-- Cannot delete a template still referenced by posts or tags
|
|
ensures: not exists template
|
|
ensures: TemplateFileDeleted(template)
|
|
}
|
|
|
|
rule ForceDeleteTemplate {
|
|
when: ForceDeleteTemplateRequested(template)
|
|
-- Force deletion clears every project-scoped post and tag reference before
|
|
-- removing the template. A same-slug template in another project is unrelated.
|
|
for post in template.referencing_posts:
|
|
ensures: post.template_slug = null
|
|
ensures: post.updated_at = now
|
|
if post.status = published:
|
|
ensures: PublishedPostFrontmatterRewritten(post)
|
|
-- Published files drop templateSlug while retaining their body.
|
|
for tag in template.referencing_tags:
|
|
ensures: tag.post_template_slug = null
|
|
ensures: tag.updated_at = now
|
|
ensures: TagsMetadataWritten(template.project_id)
|
|
ensures: not exists template
|
|
ensures: TemplateFileDeleted(template)
|
|
}
|
|
|
|
rule CascadeSlugUpdate {
|
|
when: template: Template.slug transitions_to new_slug
|
|
-- When a template slug changes, update all references
|
|
for p in template.referencing_posts:
|
|
ensures: p.template_slug = new_slug
|
|
for t in template.referencing_tags:
|
|
ensures: t.post_template_slug = new_slug
|
|
}
|
|
|
|
rule RebuildTemplatesFromFiles {
|
|
when: RebuildTemplatesFromFilesRequested(project)
|
|
for file in scan_directory(project.public_dir + "/templates", "*.liquid"):
|
|
let parsed = parse_template_file(file)
|
|
ensures: Template.created(parsed)
|
|
-- or updated if slug already exists
|
|
-- Prune stale published templates: a published Template whose file_path is
|
|
-- neither among the scanned files nor present on disk is deleted, clearing
|
|
-- its references first (posts' template_slug, tags' post_template_slug).
|
|
for template in project.templates where status = published and file_path != "":
|
|
if template.file_path not in scanned_files and not file_exists(template.file_path):
|
|
ensures: not exists template
|
|
}
|
|
|
|
-- ─── Liquid subset (compatibility contract) ─────────────────
|
|
--
|
|
-- Exact Liquid subset required (distilled from bundled starter templates).
|
|
-- No features beyond these sets are accepted. Enforcement happens at
|
|
-- publish/validation time (ValidateLiquid); anything outside the sets is
|
|
-- rejected even though Liquex would otherwise support it as a built-in.
|
|
|
|
config {
|
|
-- The 5 allowed tag families as their concrete tag names.
|
|
-- Whitespace-stripped variants ({%- -%}) of each are also accepted.
|
|
-- render takes named parameters: {% render 'partial', param: value %}.
|
|
-- NOT allowed: include, capture, case/when, unless, raw, comment,
|
|
-- cycle, tablerow, increment, decrement, liquid, echo
|
|
liquid_allowed_tags: Set<String> = {
|
|
"if", "elsif", "else", "endif",
|
|
"for", "endfor",
|
|
"assign",
|
|
"render"
|
|
}
|
|
|
|
-- Standard filters (4).
|
|
-- default takes a fallback value, append a suffix string.
|
|
-- NOT allowed: date, strip_html, truncate, split, join, size (as
|
|
-- filter), upcase, downcase, replace, remove, sort, map, where,
|
|
-- first, last, reverse, concat, uniq, compact, strip,
|
|
-- newline_to_br, json, prepend, and all math filters
|
|
liquid_allowed_standard_filters: Set<String> = {
|
|
"escape", "url_encode", "default", "append"
|
|
}
|
|
|
|
-- Custom filters (3):
|
|
-- i18n: language — translates a key string for given language
|
|
-- markdown: post_id, post_data_json_by_id, canonical_post_path_by_slug,
|
|
-- canonical_media_path_by_source_path, language, language_prefix
|
|
-- — renders Markdown to HTML with link rewriting (6 arguments)
|
|
-- slugify — slugifies a string (tag/category URLs)
|
|
liquid_allowed_custom_filters: Set<String> = {
|
|
"i18n", "markdown", "slugify"
|
|
}
|
|
|
|
-- NOT allowed: !=, <, >=, <=, contains.
|
|
-- Logical operators or/and, bare-variable truthiness in {% if %},
|
|
-- the special value blank, dot property access, .size on arrays,
|
|
-- and bracket map lookups are part of expression syntax, not
|
|
-- operator validation.
|
|
liquid_allowed_comparison_operators: Set<String> = { "==", ">" }
|
|
}
|
|
|
|
-- A published template has passed ValidateLiquid, so its content only
|
|
-- uses the allowed sets. liquid_tags / liquid_filters /
|
|
-- liquid_comparison_operators are black boxes extracting the construct
|
|
-- names a template uses.
|
|
|
|
invariant LiquidTagSubset {
|
|
for t in Templates:
|
|
t.status = published implies
|
|
for tag in liquid_tags(effective_template_content(t)):
|
|
tag in config.liquid_allowed_tags
|
|
}
|
|
|
|
invariant LiquidFilterSubset {
|
|
let allowed = config.liquid_allowed_standard_filters
|
|
+ config.liquid_allowed_custom_filters
|
|
for t in Templates:
|
|
t.status = published implies
|
|
for f in liquid_filters(effective_template_content(t)):
|
|
f in allowed
|
|
}
|
|
|
|
invariant LiquidOperatorSubset {
|
|
for t in Templates:
|
|
t.status = published implies
|
|
for op in liquid_comparison_operators(effective_template_content(t)):
|
|
op in config.liquid_allowed_comparison_operators
|
|
}
|
|
|
|
-- The top-level variables available to templates at render time are
|
|
-- formalized as the RenderContext value records in template_context.allium.
|