-- 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 = { "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 = { "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 = { "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 = { "==", ">" } } -- 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.