chore: tend to allium spec to align with code
This commit is contained in:
@@ -19,7 +19,7 @@ value PostEditorView {
|
||||
metadata: PostEditorMetadata
|
||||
metadata_expanded: Boolean -- starts expanded when title is empty
|
||||
excerpt_expanded: Boolean
|
||||
editor_mode: String -- visual | markdown | preview
|
||||
editor_mode: String -- markdown | preview
|
||||
footer: PostEditorFooter
|
||||
}
|
||||
|
||||
@@ -152,15 +152,15 @@ surface PostEditorSurface {
|
||||
-- Collapsible section with textarea (4 rows).
|
||||
|
||||
@guarantee EditorBodyToolbar
|
||||
-- Toolbar: "Content" label, mode toggle (Visual/Markdown/Preview),
|
||||
-- Toolbar: "Content" label, mode toggle (Markdown/Preview),
|
||||
-- action buttons (markdown mode only): Gallery (with media count),
|
||||
-- Insert Post Link, Insert Media.
|
||||
|
||||
@guarantee EditorModes
|
||||
-- Visual: rich-text WYSIWYG editor.
|
||||
-- Markdown: code editor with markdown-with-macros language,
|
||||
-- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font.
|
||||
-- Preview: iframe showing rendered preview.
|
||||
-- Note: visual/WYSIWYG mode not implemented; "visual" normalizes to markdown.
|
||||
|
||||
@guarantee DragDropImages
|
||||
-- Drop image file onto editor area triggers import chain.
|
||||
@@ -193,8 +193,8 @@ invariant PostDirtyTracking {
|
||||
}
|
||||
|
||||
invariant PostEditorModePersistence {
|
||||
-- Editor mode (visual/markdown/preview) persists per session.
|
||||
-- Default mode comes from editor settings.
|
||||
-- Editor mode (markdown/preview) persists per session.
|
||||
-- Default mode comes from editor settings (markdown).
|
||||
}
|
||||
|
||||
-- ─── Post editor actions ────────────────────────────────────
|
||||
|
||||
@@ -33,7 +33,7 @@ value SettingsProjectSection {
|
||||
}
|
||||
|
||||
value SettingsEditorSection {
|
||||
default_mode: String -- select: wysiwyg | markdown | preview
|
||||
default_mode: String -- select: markdown | preview
|
||||
diff_view_style: String -- select: inline | side-by-side
|
||||
wrap_long_lines: Boolean -- checkbox
|
||||
hide_unchanged_regions: Boolean -- checkbox
|
||||
@@ -138,7 +138,7 @@ surface SettingsViewSurface {
|
||||
-- Bookmarklet uses project's publicUrl to construct POST endpoint.
|
||||
|
||||
@guarantee EditorSection
|
||||
-- Section 2: Default Editor Mode (select: WYSIWYG/Markdown/Preview),
|
||||
-- Section 2: Default Editor Mode (select: Markdown/Preview),
|
||||
-- Diff View Style (select: Inline/Side-by-side),
|
||||
-- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox).
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ rule ImportMediaSideEffects {
|
||||
if media.is_image:
|
||||
ensures: ThumbnailsGenerated(media)
|
||||
-- small=150px, medium=400px, large=800px, ai=448x448
|
||||
-- Asynchronous, emits thumbnailsGenerated on completion
|
||||
-- Synchronous (awaited), logged on error
|
||||
ensures: FTSIndexUpdated(media)
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ rule DeleteMediaTranslationSideEffects {
|
||||
-- updatePost | rename only* | yes | if Δ | no | no | yes | no
|
||||
-- publishPost | .md + trans | yes | yes | no | no | yes | no
|
||||
-- deletePost | delete .md | del | del | no | Δ media | del | no
|
||||
-- importMedia | copy file | yes | no | async | write | no | no
|
||||
-- importMedia | copy file | yes | no | sync | write | no | no
|
||||
-- updateMedia | no | yes | no | no | rewrite | no | no
|
||||
-- replaceMediaFile | overwrite | no | no | regen | no | no | no
|
||||
-- deleteMedia | delete all | del | no | del | del all | no | no
|
||||
|
||||
@@ -25,7 +25,7 @@ surface PostFrontmatterSurface {
|
||||
frontmatter.title
|
||||
frontmatter.slug
|
||||
frontmatter.status
|
||||
frontmatter.published_at
|
||||
frontmatter.publishedAt
|
||||
frontmatter.tags
|
||||
frontmatter.categories
|
||||
}
|
||||
@@ -35,11 +35,11 @@ surface MediaSidecarSurface {
|
||||
|
||||
exposes:
|
||||
sidecar.id
|
||||
sidecar.original_name
|
||||
sidecar.mime_type
|
||||
sidecar.originalName
|
||||
sidecar.mimeType
|
||||
sidecar.width
|
||||
sidecar.height
|
||||
sidecar.updated_at
|
||||
sidecar.updatedAt
|
||||
}
|
||||
|
||||
surface TemplateFrontmatterSurface {
|
||||
@@ -70,8 +70,8 @@ surface MenuOpmlSurface {
|
||||
|
||||
exposes:
|
||||
document.header.title
|
||||
document.header.date_created
|
||||
document.header.date_modified
|
||||
document.header.dateCreated
|
||||
document.header.dateModified
|
||||
for item in document.body:
|
||||
item.kind
|
||||
item.label
|
||||
@@ -88,6 +88,7 @@ config {
|
||||
|
||||
value PostFrontmatter {
|
||||
-- File path: posts/{YYYY}/{MM}/{slug}.md
|
||||
-- All keys serialized as camelCase in YAML frontmatter
|
||||
id: String -- UUID v4
|
||||
title: String
|
||||
slug: String
|
||||
@@ -95,25 +96,29 @@ value PostFrontmatter {
|
||||
status: draft | published | archived
|
||||
author: String? -- Only written if present
|
||||
language: String? -- Only written if present (ISO 639-1)
|
||||
do_not_translate: Boolean -- Only written when true
|
||||
template_slug: String? -- Only written if present
|
||||
created_at: Timestamp -- Unix timestamp in milliseconds
|
||||
updated_at: Timestamp -- Unix timestamp in milliseconds
|
||||
published_at: Timestamp? -- Only written if published
|
||||
doNotTranslate: Boolean -- Only written when true
|
||||
templateSlug: String? -- Only written if present
|
||||
createdAt: Timestamp -- Unix timestamp in milliseconds
|
||||
updatedAt: Timestamp -- Unix timestamp in milliseconds
|
||||
publishedAt: Timestamp? -- Only written if published
|
||||
tags: List<String> -- Always written, even if empty
|
||||
categories: List<String> -- Always written, even if empty
|
||||
}
|
||||
|
||||
value TranslationFrontmatter {
|
||||
-- File path: posts/{YYYY}/{MM}/{slug}.{language}.md
|
||||
-- Translation files only store language-specific metadata.
|
||||
-- Shared publication state and timestamps are inherited from the
|
||||
-- canonical post file and are not duplicated here.
|
||||
-- Translation files carry their own publication state and timestamps
|
||||
-- so that each translation can be rebuilt independently.
|
||||
-- All keys serialized as camelCase in YAML frontmatter
|
||||
id: String -- UUID v4
|
||||
translation_for: String -- Canonical post UUID
|
||||
translationFor: String -- Canonical post UUID
|
||||
language: String -- ISO 639-1 language code
|
||||
title: String -- Translated title
|
||||
excerpt: String? -- Only written when the translated excerpt differs
|
||||
status: draft | published
|
||||
createdAt: Timestamp -- Unix timestamp in milliseconds
|
||||
updatedAt: Timestamp -- Unix timestamp in milliseconds
|
||||
publishedAt: Timestamp -- Canonical post's publishedAt at time of publish
|
||||
}
|
||||
|
||||
surface TranslationFrontmatterSurface {
|
||||
@@ -121,10 +126,14 @@ surface TranslationFrontmatterSurface {
|
||||
|
||||
exposes:
|
||||
frontmatter.id
|
||||
frontmatter.translation_for
|
||||
frontmatter.translationFor
|
||||
frontmatter.language
|
||||
frontmatter.title
|
||||
frontmatter.excerpt when frontmatter.excerpt != null
|
||||
frontmatter.status
|
||||
frontmatter.createdAt
|
||||
frontmatter.updatedAt
|
||||
frontmatter.publishedAt
|
||||
}
|
||||
|
||||
invariant PostFileLayout {
|
||||
@@ -147,9 +156,10 @@ invariant PostTranslationFileLayout {
|
||||
lang: t.language)
|
||||
}
|
||||
|
||||
invariant TranslationFilesInheritCanonicalMetadata {
|
||||
-- Missing status and timestamp fields in translation files are expected.
|
||||
-- Rebuild and metadata diff must resolve those values from the canonical post.
|
||||
invariant TranslationFrontmatterRoundtrip {
|
||||
-- Translation files carry status and timestamps explicitly.
|
||||
-- On rebuild, these fields are read back directly; fallback to canonical
|
||||
-- post values applies only when fields are absent (legacy files).
|
||||
for t in PostTranslations where file_path != "":
|
||||
parse_frontmatter(read_file(t.file_path)) = translation_frontmatter_fields(t)
|
||||
}
|
||||
@@ -171,11 +181,12 @@ rule WritePostFile {
|
||||
value MediaSidecar {
|
||||
-- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta)
|
||||
-- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext}
|
||||
-- Format: YAML-like key-value (hand-built, not gray-matter frontmatter)
|
||||
-- Format: YAML-like key-value wrapped in --- delimiters (gray-matter style, hand-built serializer)
|
||||
-- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path
|
||||
-- All keys serialized as camelCase
|
||||
id: String -- UUID v4
|
||||
original_name: String -- Original uploaded filename
|
||||
mime_type: String
|
||||
originalName: String -- Original uploaded filename
|
||||
mimeType: String
|
||||
size: Integer -- Bytes
|
||||
width: Integer?
|
||||
height: Integer?
|
||||
@@ -185,8 +196,9 @@ value MediaSidecar {
|
||||
author: String? -- Only written if present
|
||||
language: String? -- Only written if present
|
||||
tags: List<String> -- Always written, even if empty
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
linkedPostIds: List<String> -- UUIDs of posts that reference this media
|
||||
createdAt: Timestamp
|
||||
updatedAt: Timestamp
|
||||
}
|
||||
|
||||
invariant MediaSidecarLayout {
|
||||
@@ -200,14 +212,16 @@ invariant MediaSidecarLayout {
|
||||
|
||||
value TemplateFrontmatter {
|
||||
-- File path: templates/{slug}.liquid
|
||||
-- All keys serialized as camelCase in YAML frontmatter
|
||||
id: String -- UUID v4
|
||||
projectId: String -- Scoped to project
|
||||
slug: String
|
||||
title: String
|
||||
kind: post | list | not_found | partial
|
||||
enabled: Boolean
|
||||
version: Integer
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
createdAt: Timestamp
|
||||
updatedAt: Timestamp
|
||||
}
|
||||
|
||||
rule WriteTemplateFile {
|
||||
@@ -227,15 +241,17 @@ rule WriteTemplateFile {
|
||||
value ScriptFrontmatter {
|
||||
-- File path: scripts/{slug}.{extension}
|
||||
-- YAML frontmatter delimited by --- markers
|
||||
-- All keys serialized as camelCase in YAML frontmatter
|
||||
id: String -- UUID v4
|
||||
projectId: String -- Scoped to project
|
||||
slug: String
|
||||
title: String
|
||||
kind: macro | utility | transform
|
||||
entrypoint: String -- Default: "render" for macros, "main" otherwise
|
||||
enabled: Boolean
|
||||
version: Integer
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
createdAt: Timestamp
|
||||
updatedAt: Timestamp
|
||||
}
|
||||
|
||||
rule WriteScriptFile {
|
||||
@@ -252,24 +268,20 @@ rule WriteScriptFile {
|
||||
-- TAGS FILE FORMAT
|
||||
-- ============================================================================
|
||||
|
||||
value TagsFile {
|
||||
-- File path: meta/tags.json
|
||||
-- Portable JSON format (no internal IDs)
|
||||
tags: List<TagEntry>
|
||||
}
|
||||
|
||||
value TagEntry {
|
||||
-- File path: meta/tags.json
|
||||
-- Stored as a bare JSON array (no wrapper object)
|
||||
-- Portable JSON format (no internal IDs), camelCase keys
|
||||
name: String
|
||||
color: String?
|
||||
post_template_slug: String?
|
||||
postTemplateSlug: String?
|
||||
}
|
||||
|
||||
invariant TagsFileFormat {
|
||||
-- Tags are stored as a sorted JSON array
|
||||
-- Tags are stored as a bare sorted JSON array
|
||||
-- Sorted alphabetically by name (case-insensitive)
|
||||
parse_json(read_file("meta/tags.json")) = {
|
||||
tags: sort_by(Tags, t => lowercase(t.name))
|
||||
}
|
||||
parse_json(read_file("meta/tags.json")) =
|
||||
sort_by(tags, t => lowercase(t.name))
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
@@ -278,16 +290,17 @@ invariant TagsFileFormat {
|
||||
|
||||
value ProjectJson {
|
||||
-- File path: meta/project.json
|
||||
-- All keys serialized as camelCase
|
||||
name: String
|
||||
description: String?
|
||||
public_url: String?
|
||||
main_language: String?
|
||||
default_author: String?
|
||||
max_posts_per_page: Integer
|
||||
blogmark_category: String?
|
||||
pico_theme: String?
|
||||
semantic_similarity_enabled: Boolean
|
||||
blog_languages: List<String>
|
||||
publicUrl: String?
|
||||
mainLanguage: String?
|
||||
defaultAuthor: String?
|
||||
maxPostsPerPage: Integer
|
||||
blogmarkCategory: String?
|
||||
picoTheme: String?
|
||||
semanticSimilarityEnabled: Boolean
|
||||
blogLanguages: List<String>
|
||||
}
|
||||
|
||||
value CategoriesJson {
|
||||
@@ -303,18 +316,19 @@ value CategoryMetaJson {
|
||||
}
|
||||
|
||||
value CategorySettings {
|
||||
render_in_lists: Boolean
|
||||
show_title: Boolean
|
||||
post_template_slug: String?
|
||||
list_template_slug: String?
|
||||
renderInLists: Boolean
|
||||
showTitle: Boolean
|
||||
postTemplateSlug: String?
|
||||
listTemplateSlug: String?
|
||||
}
|
||||
|
||||
value PublishingJson {
|
||||
-- File path: meta/publishing.json
|
||||
ssh_host: String?
|
||||
ssh_user: String?
|
||||
ssh_remote_path: String?
|
||||
ssh_mode: scp | rsync
|
||||
-- All keys serialized as camelCase
|
||||
sshHost: String?
|
||||
sshUser: String?
|
||||
sshRemotePath: String?
|
||||
sshMode: scp | rsync
|
||||
}
|
||||
|
||||
invariant MetadataFileLayout {
|
||||
@@ -325,7 +339,7 @@ invariant MetadataFileLayout {
|
||||
meta/category-meta.json = serialize(CategoryMetaJson)
|
||||
meta/publishing.json = serialize(PublishingJson)
|
||||
meta/menu.opml = serialize(Menu)
|
||||
meta/tags.json = serialize(TagsFile)
|
||||
meta/tags.json = serialize(List<TagEntry>)
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
@@ -341,8 +355,8 @@ value MenuOpml {
|
||||
|
||||
value OpmlHeader {
|
||||
title: String
|
||||
date_created: Timestamp
|
||||
date_modified: Timestamp
|
||||
dateCreated: Timestamp
|
||||
dateModified: Timestamp
|
||||
}
|
||||
|
||||
value MenuItem {
|
||||
@@ -376,6 +390,11 @@ invariant YamlFormatting {
|
||||
-- Boolean values are lowercase: true/false
|
||||
}
|
||||
|
||||
invariant CamelCaseKeys {
|
||||
-- All serialized keys in YAML frontmatter and JSON metadata use camelCase.
|
||||
-- Entity/DB fields use snake_case internally; the mapping happens at serialization.
|
||||
}
|
||||
|
||||
invariant AtomicWrites {
|
||||
-- All file writes are atomic
|
||||
-- Write to temp file first, then rename
|
||||
@@ -390,7 +409,7 @@ invariant RequiredPostFields {
|
||||
-- These fields are ALWAYS written for posts
|
||||
for p in Posts:
|
||||
required_fields(p) = {
|
||||
id, title, slug, status, created_at, updated_at,
|
||||
id, title, slug, status, createdAt, updatedAt,
|
||||
tags, categories
|
||||
}
|
||||
}
|
||||
@@ -399,9 +418,9 @@ invariant ConditionalPostFields {
|
||||
-- These fields are ONLY written if truthy
|
||||
for p in Posts:
|
||||
conditional_fields(p) = {
|
||||
excerpt, author, language, template_slug, published_at
|
||||
excerpt, author, language, templateSlug, publishedAt
|
||||
}
|
||||
-- do_not_translate is only written when true
|
||||
-- doNotTranslate is only written when true
|
||||
}
|
||||
|
||||
invariant RequiredMediaFields {
|
||||
@@ -409,8 +428,8 @@ invariant RequiredMediaFields {
|
||||
-- Note: 'filename' is NOT a sidecar field — it is the binary path itself
|
||||
for m in Media:
|
||||
required_fields(m) = {
|
||||
id, original_name, mime_type, size,
|
||||
created_at, updated_at, tags
|
||||
id, originalName, mimeType, size,
|
||||
createdAt, updatedAt, tags
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,19 +143,39 @@ rule GenerateTagPages {
|
||||
when: GenerateSiteRequested(generation)
|
||||
requires: tag in generation.sections
|
||||
for t in Tags where post_count > 0:
|
||||
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slugify(t.name)))
|
||||
let slug = slugify(t.name)
|
||||
let page_count = ceil(posts_with_tag(t).count / generation.max_posts_per_page)
|
||||
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slug))
|
||||
for page in page_range(2, page_count):
|
||||
ensures: FileGenerated(format("tag/{slug}/page/{page}/index.html",
|
||||
slug: slug, page: page))
|
||||
}
|
||||
|
||||
-- Date section: year and month archives
|
||||
-- Date section: year, month, and day archives
|
||||
|
||||
rule GenerateDateArchivePages {
|
||||
when: GenerateSiteRequested(generation)
|
||||
requires: date in generation.sections
|
||||
for year in distinct_years(Posts):
|
||||
let yp = ceil(posts_in_year(year).count / generation.max_posts_per_page)
|
||||
ensures: FileGenerated(format("{year}/index.html", year: year))
|
||||
for page in page_range(2, yp):
|
||||
ensures: FileGenerated(format("{year}/page/{page}/index.html",
|
||||
year: year, page: page))
|
||||
for month in distinct_months(Posts, year):
|
||||
let mp = ceil(posts_in_month(year, month).count / generation.max_posts_per_page)
|
||||
ensures: FileGenerated(format("{year}/{month}/index.html",
|
||||
year: year, month: month))
|
||||
for page in page_range(2, mp):
|
||||
ensures: FileGenerated(format("{year}/{month}/page/{page}/index.html",
|
||||
year: year, month: month, page: page))
|
||||
for day in distinct_days(Posts, year, month):
|
||||
let dp = ceil(posts_in_day(year, month, day).count / generation.max_posts_per_page)
|
||||
ensures: FileGenerated(format("{year}/{month}/{day}/index.html",
|
||||
year: year, month: month, day: day))
|
||||
for page in page_range(2, dp):
|
||||
ensures: FileGenerated(format("{year}/{month}/{day}/page/{page}/index.html",
|
||||
year: year, month: month, day: day, page: page))
|
||||
}
|
||||
|
||||
-- Template rendering context
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
-- allium: 1
|
||||
-- bDS Navigation Menu
|
||||
-- Scope: core (read for rendering), extension Bucket F (menu editor UI)
|
||||
-- Distilled from: src/main/engine/MenuEngine.ts
|
||||
-- File-only model: no DB table. Loaded from meta/menu.opml into a
|
||||
-- transient value, mutated in memory, written back to OPML on save.
|
||||
|
||||
surface MenuManagementSurface {
|
||||
facing _: MenuOperator
|
||||
|
||||
provides:
|
||||
UpdateMenuRequested(menu, items)
|
||||
MenuLoadRequested(project_id)
|
||||
UpdateMenuRequested(items)
|
||||
SyncMenuFromFilesystemRequested(project_id)
|
||||
}
|
||||
|
||||
value MenuItem {
|
||||
kind: page | submenu | category_archive | home
|
||||
label: String
|
||||
slug: String?
|
||||
children: List<MenuItem>? -- only for submenu kind
|
||||
slug: String? -- pageSlug for page/home, categoryName for category_archive
|
||||
children: List<MenuItem>? -- present only for submenu kind
|
||||
}
|
||||
|
||||
entity Menu {
|
||||
value Menu {
|
||||
items: List<MenuItem>
|
||||
|
||||
-- Derived
|
||||
home_items: items where kind = home
|
||||
home_entry: home_items.first
|
||||
home_entry: items.first -- always home after normalization
|
||||
}
|
||||
|
||||
surface MenuSurface {
|
||||
@@ -30,27 +32,42 @@ surface MenuSurface {
|
||||
|
||||
exposes:
|
||||
menu.items.count
|
||||
menu.home_items.count
|
||||
menu.home_entry.label
|
||||
}
|
||||
|
||||
invariant HomeAlwaysPresent {
|
||||
-- The menu always has a Home entry, extracted and prepended
|
||||
invariant HomeAlwaysFirst {
|
||||
-- Normalization guarantees home is always the first item.
|
||||
-- UpdateMenu strips any home entries from input, then prepends one.
|
||||
for menu in Menus:
|
||||
menu.items.first.kind = home
|
||||
}
|
||||
|
||||
invariant MenuPersistedAsOpml {
|
||||
-- meta/menu.opml is the canonical storage format
|
||||
-- Uses OPML with outline elements for each item
|
||||
-- meta/menu.opml is the sole persistent store (no DB table).
|
||||
-- OPML outline attributes: text (label), type (kind),
|
||||
-- pageSlug (slug for page/home), categoryName (slug for category_archive).
|
||||
-- Nested <outline> elements represent submenu children.
|
||||
parse_opml(read_file("meta/menu.opml")) = menu.items
|
||||
}
|
||||
|
||||
rule UpdateMenu {
|
||||
when: UpdateMenuRequested(menu, items)
|
||||
-- Normalizes Home entry: extracts from items, prepends
|
||||
let without_home = items where kind != home
|
||||
let home = MenuItem{kind: home, label: "Home"}
|
||||
ensures: menu.items = build_menu_items(home, without_home)
|
||||
ensures: MenuFileWritten(menu)
|
||||
rule LoadMenu {
|
||||
when: MenuLoadRequested(project_id)
|
||||
-- Reads meta/menu.opml; if file missing, returns default (home-only) menu.
|
||||
-- Normalizes: strips home entries from body, prepends canonical home.
|
||||
ensures: MenuLoaded(project_id, normalize(parse_opml_or_empty(project_id)))
|
||||
}
|
||||
|
||||
rule UpdateMenu {
|
||||
when: UpdateMenuRequested(items)
|
||||
-- Normalizes Home entry: strips all home items, prepends canonical home.
|
||||
-- Writes normalized menu back to meta/menu.opml.
|
||||
let without_home = items where kind != home
|
||||
ensures: MenuFileWritten(normalize(without_home))
|
||||
}
|
||||
|
||||
rule SyncMenuFromFilesystem {
|
||||
when: SyncMenuFromFilesystemRequested(project_id)
|
||||
-- Reloads menu from OPML, normalizes, writes back (round-trip repair).
|
||||
ensures: MenuLoaded(project_id, _)
|
||||
ensures: MenuFileWritten(_)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ value Slug {
|
||||
-- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens
|
||||
-- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used.
|
||||
-- Verify transliteration matches the established bDS behaviour for this set.
|
||||
-- Uniqueness: tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}
|
||||
-- Uniqueness: tries base, then {slug}-2, {slug}-3, … (unbounded numeric suffix)
|
||||
}
|
||||
|
||||
value PostFilePath {
|
||||
|
||||
@@ -279,7 +279,6 @@ entity AiModel {
|
||||
max_output_tokens: Integer
|
||||
interleaved: String? -- interleaved capability descriptor
|
||||
status: String? -- active | deprecated | preview
|
||||
provider_package_ref: String? -- provider-specific legacy package reference
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
@@ -288,7 +287,7 @@ entity AiModelModality {
|
||||
provider: AiProvider
|
||||
model_id: String
|
||||
direction: String -- "input" | "output"
|
||||
modality: String -- "text" | "image" | "audio" | "video"
|
||||
modality: String -- "text" | "image" | "audio" | "file" | "tool"
|
||||
}
|
||||
|
||||
entity AiCatalogMeta {
|
||||
@@ -552,7 +551,6 @@ surface AiModelRecordSurface {
|
||||
model.max_output_tokens
|
||||
model.interleaved when model.interleaved != null
|
||||
model.status when model.status != null
|
||||
model.provider_package_ref when model.provider_package_ref != null
|
||||
model.updated_at
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ enum ScriptStatus {
|
||||
}
|
||||
|
||||
entity Script {
|
||||
project_id: String
|
||||
slug: String
|
||||
title: String
|
||||
kind: macro | utility | transform
|
||||
@@ -63,8 +64,8 @@ surface ScriptManagementSurface {
|
||||
facing _: ScriptOperator
|
||||
|
||||
provides:
|
||||
CreateScriptRequested(title, kind, content, entrypoint)
|
||||
CreateAndPublishScriptRequested(title, kind, content, entrypoint)
|
||||
CreateScriptRequested(project, title, kind, content, entrypoint)
|
||||
CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
|
||||
UpdateScriptRequested(script, changes)
|
||||
PublishScriptRequested(script)
|
||||
DeleteScriptRequested(script)
|
||||
@@ -113,9 +114,10 @@ surface ScriptRuntimeSurface {
|
||||
}
|
||||
|
||||
invariant UniqueScriptSlug {
|
||||
-- Slug uniqueness is scoped per project, not globally.
|
||||
for a in Scripts:
|
||||
for b in Scripts:
|
||||
a != b implies a.slug != b.slug
|
||||
a != b and a.project_id = b.project_id implies a.slug != b.slug
|
||||
}
|
||||
|
||||
invariant ScriptFileLayout {
|
||||
@@ -125,11 +127,12 @@ invariant ScriptFileLayout {
|
||||
-- Script files use standard --- YAML frontmatter
|
||||
|
||||
rule CreateScript {
|
||||
when: CreateScriptRequested(title, kind, content, entrypoint)
|
||||
when: CreateScriptRequested(project, title, kind, content, entrypoint)
|
||||
let slug = slugify(title)
|
||||
-- Creates a draft script: content stored in DB, no file written yet
|
||||
ensures:
|
||||
let new_script = Script.created(
|
||||
project_id: project.id,
|
||||
slug: slug,
|
||||
title: title,
|
||||
kind: kind,
|
||||
@@ -160,11 +163,12 @@ rule ReopenPublishedScript {
|
||||
rule CreateAndPublishScript {
|
||||
-- Alternative creation path: create + immediately publish (file written)
|
||||
-- Some implementations may expose this as a single user action
|
||||
when: CreateAndPublishScriptRequested(title, kind, content, entrypoint)
|
||||
when: CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
|
||||
let slug = slugify(title)
|
||||
requires: ValidateScript(content) = valid
|
||||
ensures:
|
||||
let new_script = Script.created(
|
||||
project_id: project.id,
|
||||
slug: slug,
|
||||
title: title,
|
||||
kind: kind,
|
||||
@@ -234,7 +238,7 @@ rule ExecuteTransform {
|
||||
-- Execution uses the same managed job host API contract as other batch
|
||||
-- scripts and may report progress while mass-processing remote or local
|
||||
-- content.
|
||||
let transforms = Scripts where kind = transform and enabled = true
|
||||
let transforms = Scripts where project_id = data.project_id and kind = transform and enabled = true
|
||||
for t in ordered_by(transforms, s => s.updated_at, s => s.slug, s => s.id):
|
||||
requires: t.entrypoint != ""
|
||||
ensures: TransformApplied(t, data)
|
||||
|
||||
@@ -12,7 +12,7 @@ surface SearchControlSurface {
|
||||
|
||||
provides:
|
||||
SearchPostsRequested(query, filters)
|
||||
SearchMediaRequested(query)
|
||||
SearchMediaRequested(query, filters)
|
||||
}
|
||||
|
||||
surface SearchIndexRuntimeSurface {
|
||||
@@ -24,8 +24,9 @@ surface SearchIndexRuntimeSurface {
|
||||
}
|
||||
|
||||
value StemmerLanguage {
|
||||
-- Snowball stemmers for 24 languages
|
||||
-- ISO 639-1 to Snowball mapping
|
||||
-- Snowball stemmers via library (Stemex)
|
||||
-- Languages with a Snowball algorithm get real stemming;
|
||||
-- others pass through unstemmed
|
||||
-- Applied to both indexing and query processing
|
||||
code: String
|
||||
}
|
||||
@@ -38,19 +39,29 @@ surface StemmerLanguageSurface {
|
||||
}
|
||||
|
||||
entity PostSearchIndex {
|
||||
-- Full-text index projection
|
||||
-- Indexed fields: title, excerpt, content, tags, categories
|
||||
-- Plus all translation titles, excerpts, and content
|
||||
-- FTS5 virtual table with per-field stemmed columns
|
||||
-- Each field is stemmed independently; translations are
|
||||
-- stemmed with their own language stemmer and appended
|
||||
-- to the corresponding field
|
||||
post: post/Post
|
||||
stemmed_content: String
|
||||
title: String
|
||||
excerpt: String
|
||||
content: String
|
||||
tags: String
|
||||
categories: String
|
||||
}
|
||||
|
||||
entity MediaSearchIndex {
|
||||
-- Full-text index projection
|
||||
-- Indexed fields: title, alt, caption, original_name, tags
|
||||
-- Plus all translation titles, alts, and captions
|
||||
-- FTS5 virtual table with per-field stemmed columns
|
||||
-- Each field is stemmed independently; translations are
|
||||
-- stemmed with their own language stemmer and appended
|
||||
-- to the corresponding field
|
||||
media: media/Media
|
||||
stemmed_content: String
|
||||
title: String
|
||||
alt: String
|
||||
caption: String
|
||||
original_name: String
|
||||
tags: String
|
||||
}
|
||||
|
||||
invariant CrossLanguageStemming {
|
||||
@@ -77,42 +88,80 @@ rule SearchPosts {
|
||||
}
|
||||
|
||||
rule SearchMedia {
|
||||
when: SearchMediaRequested(query)
|
||||
when: SearchMediaRequested(query, filters)
|
||||
-- Full-text search with optional filters:
|
||||
-- language, tags, year, month, date range (from/to)
|
||||
-- Returns paginated results with total count
|
||||
let stemmed_query = stem(query, detect_language(query))
|
||||
let matched = search_fts(MediaSearchIndex, stemmed_query)
|
||||
let matched = search_fts(MediaSearchIndex, stemmed_query, filters)
|
||||
ensures: SearchResults(
|
||||
media: matched
|
||||
media: matched,
|
||||
total: matched.count,
|
||||
offset: filters.offset,
|
||||
limit: filters.limit
|
||||
)
|
||||
}
|
||||
|
||||
rule IndexPost {
|
||||
when: SearchIndexUpdated(post)
|
||||
-- Stems: title + excerpt + content + tags + categories
|
||||
-- Plus all translations' title + excerpt + content
|
||||
let all_text = concat_post_text(post)
|
||||
-- Concatenates: post.title, post.excerpt, post.content,
|
||||
-- join(post.tags, " "), join(post.categories, " "),
|
||||
-- and all translations' title, excerpt, content
|
||||
let index_entry = PostSearchIndex{post: post}
|
||||
ensures:
|
||||
if exists index_entry:
|
||||
index_entry.stemmed_content = stem(all_text)
|
||||
else:
|
||||
PostSearchIndex.created(post: post, stemmed_content: stem(all_text))
|
||||
-- Delete-and-reinsert: no in-place update for FTS5 rows
|
||||
-- Each field is stemmed per-language; translations are stemmed
|
||||
-- with their own language stemmer and joined into the same field
|
||||
let lang = post.language
|
||||
let translations = post.translations
|
||||
let title = join_stemmed(
|
||||
stem(post.title, lang),
|
||||
for t in translations: stem(t.title, t.language)
|
||||
)
|
||||
let excerpt = join_stemmed(
|
||||
stem(post.excerpt, lang),
|
||||
for t in translations: stem(t.excerpt, t.language)
|
||||
)
|
||||
let content = join_stemmed(
|
||||
stem(post.content, lang),
|
||||
for t in translations: stem(t.content, t.language)
|
||||
)
|
||||
let tags = stem(join(post.tags, " "), lang)
|
||||
let categories = stem(join(post.categories, " "), lang)
|
||||
ensures: not exists PostSearchIndex{post: post}
|
||||
ensures: PostSearchIndex.created(
|
||||
post: post,
|
||||
title: title,
|
||||
excerpt: excerpt,
|
||||
content: content,
|
||||
tags: tags,
|
||||
categories: categories
|
||||
)
|
||||
}
|
||||
|
||||
rule IndexMedia {
|
||||
when: SearchIndexUpdated(media)
|
||||
-- Stems: title + alt + caption + original_name + tags
|
||||
-- Plus all translations' title, alt, caption
|
||||
let all_text = concat_media_text(media)
|
||||
-- Concatenates: media.title, media.alt, media.caption,
|
||||
-- media.original_name, join(media.tags, " "),
|
||||
-- and all translations' title, alt, caption
|
||||
let index_entry = MediaSearchIndex{media: media}
|
||||
ensures:
|
||||
if exists index_entry:
|
||||
index_entry.stemmed_content = stem(all_text)
|
||||
else:
|
||||
MediaSearchIndex.created(media: media, stemmed_content: stem(all_text))
|
||||
-- Delete-and-reinsert: no in-place update for FTS5 rows
|
||||
-- Each field is stemmed per-language; translations are stemmed
|
||||
-- with their own language stemmer and joined into the same field
|
||||
let lang = media.language
|
||||
let translations = media.translations
|
||||
let title = join_stemmed(
|
||||
stem(media.title, lang),
|
||||
for t in translations: stem(t.title, t.language)
|
||||
)
|
||||
let alt = join_stemmed(
|
||||
stem(media.alt, lang),
|
||||
for t in translations: stem(t.alt, t.language)
|
||||
)
|
||||
let caption = join_stemmed(
|
||||
stem(media.caption, lang),
|
||||
for t in translations: stem(t.caption, t.language)
|
||||
)
|
||||
let original_name = stem(media.original_name, lang)
|
||||
let tags = stem(join(media.tags, " "), lang)
|
||||
ensures: not exists MediaSearchIndex{media: media}
|
||||
ensures: MediaSearchIndex.created(
|
||||
media: media,
|
||||
title: title,
|
||||
alt: alt,
|
||||
caption: caption,
|
||||
original_name: original_name,
|
||||
tags: tags
|
||||
)
|
||||
}
|
||||
|
||||
@@ -652,6 +652,9 @@ rule ImportListClick {
|
||||
|
||||
-- Full git interface, not the SidebarEntityList pattern.
|
||||
-- Three possible states: loading, not_a_repo, active_repo.
|
||||
-- Backend: BDS.Git provides status, diff, commit_all, history,
|
||||
-- fetch, pull, push, prune_lfs_cache, remote_state, initialize_repo.
|
||||
-- The sidebar must surface these capabilities directly.
|
||||
|
||||
-- State: not_a_repo
|
||||
-- Remote URL text input + "Initialize Git" button.
|
||||
|
||||
@@ -10,6 +10,7 @@ enum TemplateStatus {
|
||||
}
|
||||
|
||||
entity Template {
|
||||
project_id: String
|
||||
slug: String
|
||||
title: String
|
||||
kind: post | list | not_found | partial
|
||||
@@ -23,8 +24,8 @@ entity Template {
|
||||
|
||||
-- Derived
|
||||
content_location: if status = published: file_path else: content
|
||||
referencing_posts: Posts where template_slug = this.slug
|
||||
referencing_tags: Tags where post_template_slug = this.slug
|
||||
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
|
||||
@@ -36,8 +37,8 @@ surface TemplateManagementSurface {
|
||||
facing _: TemplateOperator
|
||||
|
||||
provides:
|
||||
CreateTemplateRequested(title, kind, content)
|
||||
CreateAndPublishTemplateRequested(title, kind, content)
|
||||
CreateTemplateRequested(project, title, kind, content)
|
||||
CreateAndPublishTemplateRequested(project, title, kind, content)
|
||||
UpdateTemplateRequested(template, changes)
|
||||
PublishTemplateRequested(template)
|
||||
DeleteTemplateRequested(template)
|
||||
@@ -45,9 +46,10 @@ surface TemplateManagementSurface {
|
||||
}
|
||||
|
||||
invariant UniqueTemplateSlug {
|
||||
-- Slug uniqueness is scoped per project, not globally.
|
||||
for a in Templates:
|
||||
for b in Templates:
|
||||
a != b implies a.slug != b.slug
|
||||
a != b and a.project_id = b.project_id implies a.slug != b.slug
|
||||
}
|
||||
|
||||
invariant TemplateFrontmatter {
|
||||
@@ -85,11 +87,12 @@ invariant RebuildTemplatesIndexesOnlyProjectTemplates {
|
||||
}
|
||||
|
||||
rule CreateTemplate {
|
||||
when: CreateTemplateRequested(title, kind, content)
|
||||
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,
|
||||
@@ -105,11 +108,12 @@ rule CreateTemplate {
|
||||
rule CreateAndPublishTemplate {
|
||||
-- Alternative creation path: create + immediately publish (file written)
|
||||
-- Some implementations may expose this as a single user action
|
||||
when: CreateAndPublishTemplateRequested(title, kind, content)
|
||||
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,
|
||||
|
||||
@@ -64,13 +64,16 @@ entity PostTranslation {
|
||||
}
|
||||
}
|
||||
|
||||
invariant TranslationFilesStoreOnlyLanguageSpecificMetadata {
|
||||
-- Translation markdown files persist only fields that differ by language.
|
||||
-- Shared metadata such as publication status and timestamps belongs to the
|
||||
-- canonical post file and is inherited from the canonical post when
|
||||
-- rebuilding or diffing translation files.
|
||||
invariant TranslationFilesCarryFullMetadata {
|
||||
-- Translation markdown files include status and timestamps alongside
|
||||
-- language-specific fields. This allows each translation to be rebuilt
|
||||
-- independently. On rebuild, missing fields fall back to canonical post
|
||||
-- values for compatibility with legacy files.
|
||||
for t in PostTranslations where file_path != "":
|
||||
translation_file(t).omits_shared_metadata = true
|
||||
translation_file(t).has_fields(
|
||||
id, translation_for, language, title, excerpt?,
|
||||
status, created_at, updated_at, published_at
|
||||
)
|
||||
}
|
||||
|
||||
surface PostTranslationSurface {
|
||||
|
||||
Reference in New Issue
Block a user