Files
RuDS/specs/template.allium

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.