Files
RuDS/specs/post.allium

190 lines
5.5 KiB
Plaintext

-- allium: 1
-- bDS Post Lifecycle
-- Distilled from: src/main/engine/PostEngine.ts, postFileUtils.ts, schema.ts
use "./project.allium" as project
value Slug {
value: String
-- Generated by: transliterate unicode to ASCII, lowercase,
-- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens
-- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used.
-- Verify deunicode handles this set correctly against transliteration npm.
-- Uniqueness: tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}
}
value PostFilePath {
-- posts/YYYY/MM/{slug}.md
-- YYYY and MM derived from created_at
base_dir: String
year: String
month: String
slug: Slug
}
value PostCanonicalUrl {
-- /{YYYY}/{MM}/{DD}/{slug}
-- YYYY/MM/DD from created_at (zero-padded)
year: String
month: String
day: String
slug: Slug
}
value Frontmatter {
-- YAML between --- delimiters at start of .md file
-- Always present: id, title, slug, status, createdAt, updatedAt, tags, categories
-- Optional (written only when truthy): excerpt, author, language,
-- doNotTranslate (only when true), templateSlug, publishedAt
}
entity Post {
project: project/Project
title: String
slug: Slug
excerpt: String?
content: String?
status: draft | published | archived
author: String?
language: String?
do_not_translate: Boolean
template_slug: String?
file_path: String
checksum: String?
tags: Set<String>
categories: Set<String>
created_at: Timestamp
updated_at: Timestamp
published_at: Timestamp?
-- Relationships
translations: PostTranslation with canonical_post = this
linked_media: PostMediaLink with post = this
outgoing_links: PostLink with source = this
incoming_links: PostLink with target = this
-- Derived
available_languages: translations -> language
is_slug_frozen: published_at != null
-- Slug changes only allowed before first publish
content_location: if status = published: file_path else: content
-- Published: body in filesystem. Draft: body in DB field.
transitions status {
draft -> published
draft -> archived
published -> draft
published -> archived
archived -> draft
archived -> published
}
}
entity PostLink {
source: Post
target: Post
link_text: String?
}
entity PostMediaLink {
post: Post
media_id: String
sort_order: Integer
}
invariant UniqueSlugPerProject {
for a in Posts:
for b in Posts:
(a != b and a.project = b.project) implies a.slug != b.slug
}
rule CreatePost {
when: CreatePostRequested(project, title, content, tags, categories, author, language, template_slug)
let slug = Slug.generate(title ?? "untitled")
let unique_slug = Slug.ensure_unique(slug, project)
ensures: Post.created(
project: project,
title: title ?? "",
slug: unique_slug,
content: content,
status: draft,
author: author,
language: language,
tags: tags ?? {},
categories: categories ?? {},
template_slug: template_slug,
do_not_translate: false,
file_path: ""
)
ensures: SearchIndexUpdated(post)
}
rule UpdatePost {
when: UpdatePostRequested(post, changes)
requires: not post.is_slug_frozen or changes.slug = null
-- Cannot change slug after first publish
ensures: post.updated_at = now
ensures: PostFieldsUpdated(post, changes)
ensures: SearchIndexUpdated(post)
@guidance
-- If post is published and content/metadata changed,
-- status auto-transitions back to draft
}
rule PublishPost {
when: PublishPostRequested(post)
requires: post.status = draft or post.status = archived
ensures: post.status = published
ensures: post.published_at = post.published_at ?? now
-- Preserve original publish date on re-publish
ensures: PostFileWritten(post)
-- Writes frontmatter + markdown to posts/YYYY/MM/{slug}.md
ensures: post.content = null
-- Content cleared from DB; now lives in filesystem only
ensures: SearchIndexUpdated(post)
ensures: PostLinksUpdated(post)
-- Parse inter-post links, update link graph
ensures:
for t in post.translations:
TranslationFileWritten(t)
}
rule DeletePost {
when: DeletePostRequested(post)
ensures: not exists post
ensures: PostFileDeleted(post)
-- Remove .md file if it exists
ensures:
for t in post.translations:
not exists t
ensures: SearchIndexUpdated(post)
}
rule ArchivePost {
when: ArchivePostRequested(post)
ensures: post.status = archived
}
-- File format axioms
invariant FrontmatterRoundtrip {
-- Reading a post file written by the system produces identical
-- field values to the database record at time of writing
for post in Posts where status = published:
parse_frontmatter(read_file(post.file_path)) = frontmatter_fields(post)
}
invariant DateBasedFileLayout {
for post in Posts where file_path != "":
post.file_path = format("posts/{yyyy}/{mm}/{slug}.md",
yyyy: post.created_at.year,
mm: post.created_at.month_padded,
slug: post.slug)
}
-- Slug freezing behaviour matches TypeScript app:
-- slug changes allowed on draft posts even if previously published,
-- frozen only while status = published (is_slug_frozen = published_at != null
-- is the guard, but status must also be published for the file to exist)