Compare commits

...

4 Commits

12 changed files with 1461 additions and 20 deletions

View File

@@ -16,7 +16,7 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| A1-4 | ~~`doNotTranslate: false` written to frontmatter despite "only when true"~~ | frontmatter.allium:398 | `file_sync.ex:78` now converts false→nil so serializer omits the key | **Resolved:** doNotTranslate omitted from frontmatter when false, test added | | A1-4 | ~~`doNotTranslate: false` written to frontmatter despite "only when true"~~ | frontmatter.allium:398 | `file_sync.ex:78` now converts false→nil so serializer omits the key | **Resolved:** doNotTranslate omitted from frontmatter when false, test added |
| A1-5 | ~~Auto-save after 3000ms idle~~ | editor_post.allium:183-188 | PostEditor schedules auto-save via parent timer on dirty change | **Resolved:** 3000ms idle auto-save timer in Bridges, tab-switch save in ShellLive, cancel on manual save, 3 tests added | | A1-5 | ~~Auto-save after 3000ms idle~~ | editor_post.allium:183-188 | PostEditor schedules auto-save via parent timer on dirty change | **Resolved:** 3000ms idle auto-save timer in Bridges, tab-switch save in ShellLive, cancel on manual save, 3 tests added |
| A1-6 | ~~On-demand rendering in preview server~~ | preview.allium:53-93 | `Preview.Router` matches post/archive/home/language routes and renders on-demand via `Rendering` | **Resolved:** `Preview.Router` implements on-demand template rendering for post, archive, home, date, tag, category, page, and language-prefixed routes; static file fallback retained for non-HTML assets (pagefind, feeds); 6 tests added | | A1-6 | ~~On-demand rendering in preview server~~ | preview.allium:53-93 | `Preview.Router` matches post/archive/home/language routes and renders on-demand via `Rendering` | **Resolved:** `Preview.Router` implements on-demand template rendering for post, archive, home, date, tag, category, page, and language-prefixed routes; static file fallback retained for non-HTML assets (pagefind, feeds); 6 tests added |
| A1-7 | Template lookup must use all 4 levels (post→tag→category→default) | template_context.allium:267-277 | Only levels 1 and 4 implemented; tag/category fallback unused | Fix code: implement levels 2-3 in template_selection.ex | | A1-7 | ~~Template lookup must use all 4 levels (post→tag→category→default)~~ | template_context.allium:267-277 | `resolve_post_template_slug/3` implements tagcategory cascade; all callers (preview, generation) updated | **Resolved:** `resolve_post_template_slug/3` in template_selection.ex, callers in preview.ex, router.ex, outputs.ex updated, 8 tests added |
| A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish | | A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish |
| A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover | | A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover |
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create | | A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
@@ -24,6 +24,7 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI | | A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
| A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec | | A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec |
| A1-14 | Embedding uses TF-IDF hash projection instead of real neural model | embedding.allium:44-53, invariants ModelCaching/VectorCacheInDb | `backends/in_app.ex` hashes terms into sparse vectors via `:erlang.phash2`; no ONNX model, no `"query: "` prefix, no mean pooling, vectors stored as JSON text not Float32Array BLOB, snapshot-based neighbor lookup instead of USearch HNSW index | Fix code: (1) add Bumblebee + ONNX runtime deps to run `Xenova/multilingual-e5-small`, (2) implement lazy model download + cache in app data dir, (3) `"query: "` prefix + mean pooling + L2 norm in backend, (4) store vectors as binary BLOB (1536 bytes), (5) replace JSON snapshot with USearch HNSW index (cosine, M=16, ef=128/64, 5s debounce), (6) cross-language semantic similarity must work | | A1-14 | Embedding uses TF-IDF hash projection instead of real neural model | embedding.allium:44-53, invariants ModelCaching/VectorCacheInDb | `backends/in_app.ex` hashes terms into sparse vectors via `:erlang.phash2`; no ONNX model, no `"query: "` prefix, no mean pooling, vectors stored as JSON text not Float32Array BLOB, snapshot-based neighbor lookup instead of USearch HNSW index | Fix code: (1) add Bumblebee + ONNX runtime deps to run `Xenova/multilingual-e5-small`, (2) implement lazy model download + cache in app data dir, (3) `"query: "` prefix + mean pooling + L2 norm in backend, (4) store vectors as binary BLOB (1536 bytes), (5) replace JSON snapshot with USearch HNSW index (cosine, M=16, ef=128/64, 5s debounce), (6) cross-language semantic similarity must work |
| A1-15 | ~~Preview vs generation content source strategy undocumented~~ | preview.allium (no invariant), generation.allium (no invariant) | Generation uses only published .md file content (`Generation.Data` snapshots set `content: nil`); preview includes published+draft posts and prefers DB content over file (`Preview.Router` queries `:published`/`:draft`, uses `editor_body`) | **Resolved:** added `PreviewDraftOverlay` invariant to preview.allium and `GenerationPublishedOnly` invariant to generation.allium; both cross-reference each other; code already correct, 3 tests added for draft-in-preview behavior |
### A2. Spec Should Update (code is normative) ### A2. Spec Should Update (code is normative)

View File

@@ -16,4 +16,5 @@
@import "./menu_editor.css"; @import "./menu_editor.css";
@import "./media_editor.css"; @import "./media_editor.css";
@import "./import_editor.css"; @import "./import_editor.css";
@import "./misc_editor.css";
@import "./utilities.css"; @import "./utilities.css";

539
assets/css/misc_editor.css Normal file
View File

@@ -0,0 +1,539 @@
/* ── Misc-editor shell (shared by all misc tabs) ──────────────────────── */
.misc-editor-shell {
background: var(--vscode-editor-background);
}
.misc-editor-header {
padding: 12px 16px 8px;
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-tab-activeBackground);
}
.misc-editor-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.misc-editor-header p {
margin: 2px 0 0;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.misc-editor-actions {
flex-shrink: 0;
}
.misc-editor-summary {
padding: 8px 16px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.misc-editor-content {
padding: 16px;
}
/* ── Summary pills ───────────────────────────────────────────────────── */
.misc-summary-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
color: var(--vscode-foreground);
}
.misc-summary-pill span {
color: var(--vscode-descriptionForeground);
}
.misc-summary-pill strong {
font-weight: 600;
}
/* ── Misc card (used by site-validation, empty states) ───────────────── */
.misc-card {
padding: 14px 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.misc-card h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.misc-card p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.misc-card ul {
margin: 6px 0 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.6;
}
/* ── Misc columns (site-validation 3-column layout) ──────────────────── */
.misc-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
/* ── Misc list (find-duplicates) ─────────────────────────────────────── */
.misc-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.misc-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 4px;
}
.misc-list-item:hover {
background: var(--vscode-list-hoverBackground);
}
.duplicate-pair-row label {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
cursor: pointer;
}
.duplicate-pair-row .linkish {
background: none;
border: none;
color: var(--vscode-textLink-foreground, #3794ff);
cursor: pointer;
padding: 0;
font: inherit;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.14em;
}
.duplicate-pair-row .linkish:hover {
color: var(--vscode-foreground);
}
/* ── Metadata-diff: tab bar ──────────────────────────────────────────── */
.metadata-diff-tool {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-diff-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.metadata-diff-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--vscode-tab-inactiveForeground, var(--vscode-descriptionForeground));
font: inherit;
font-size: 12px;
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.metadata-diff-tab:hover {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.metadata-diff-tab.active {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
border-bottom-color: var(--vscode-focusBorder, #007fd4);
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
background: var(--vscode-activityBarBadge-background, #007acc);
color: var(--vscode-activityBarBadge-foreground, #ffffff);
}
/* ── Metadata-diff: field pills ──────────────────────────────────────── */
.metadata-diff-field-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.metadata-diff-field-pill {
display: flex;
align-items: stretch;
border-radius: 6px;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-input-background);
overflow: hidden;
transition: border-color 0.12s;
}
.metadata-diff-field-pill.active {
border-color: var(--vscode-focusBorder, #007fd4);
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 12%, transparent);
}
.metadata-diff-field-pill-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: none;
background: transparent;
color: var(--vscode-foreground);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.metadata-diff-field-pill-toggle:hover {
background: var(--vscode-list-hoverBackground);
}
.field-pill-label {
font-weight: 500;
}
.field-pill-count {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
}
.metadata-diff-field-pill-actions {
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-left: 1px solid var(--vscode-panel-border);
}
.metadata-diff-action-button {
font-size: 11px !important;
padding: 2px 8px !important;
min-height: 22px !important;
}
/* ── Metadata-diff: results area ─────────────────────────────────────── */
.metadata-diff-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-diff-empty p {
text-align: center;
padding: 20px 0;
}
/* ── Diff item cards (shared by metadata-diff and orphan sections) ──── */
.diff-item-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-item-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
overflow: hidden;
}
.diff-item-card.orphan-file {
border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
}
.diff-item-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, transparent);
border-bottom: 1px solid var(--vscode-panel-border);
}
.diff-item-header strong {
font-size: 13px;
font-weight: 600;
color: var(--vscode-foreground);
}
.diff-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-top: 2px;
}
.diff-item-fields {
padding: 8px 14px;
}
.diff-field-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 50%, transparent);
}
.diff-field-row:last-child {
border-bottom: none;
}
.diff-field-name {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 3px;
}
.diff-field-values {
display: flex;
flex-direction: column;
gap: 3px;
}
.diff-field-value {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 1.4;
word-break: break-word;
}
.diff-field-value.db-value {
color: var(--vscode-foreground);
}
.diff-field-value.file-value {
color: var(--vscode-foreground);
opacity: 0.82;
}
.diff-source-label {
flex-shrink: 0;
min-width: 28px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 3px;
}
.db-value .diff-source-label {
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
color: var(--vscode-focusBorder, #007fd4);
}
.file-value .diff-source-label {
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
color: var(--vscode-testing-iconPassed, #73c991);
}
/* ── Orphan files section ────────────────────────────────────────────── */
.orphan-files-section {
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
border-radius: 8px;
padding: 14px 16px;
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 5%, var(--vscode-editor-background));
}
.orphan-files-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.orphan-files-header h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.orphan-files-actions {
display: flex;
align-items: center;
gap: 8px;
}
.orphan-path span {
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
/* ── Translation validation ──────────────────────────────────────────── */
.translation-validation-view {
display: flex;
flex-direction: column;
gap: 16px;
}
.translation-validation-summary {
padding: 10px 14px;
border-radius: 6px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.translation-validation-summary p {
margin: 0;
}
.translation-validation-section h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.translation-validation-empty {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.translation-validation-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.translation-validation-card {
padding: 10px 14px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.translation-validation-card-db {
border-left: 3px solid var(--vscode-focusBorder, #007fd4);
}
.translation-validation-card-file {
border-left: 3px solid var(--vscode-testing-iconPassed, #73c991);
}
.translation-validation-card-title {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
}
.translation-validation-card-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
margin: 0;
font-size: 12px;
}
.translation-validation-card-meta dt {
color: var(--vscode-descriptionForeground);
font-weight: 500;
}
.translation-validation-card-meta dd {
margin: 0;
}
.translation-validation-actions {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--vscode-panel-border);
}
/* ── Git diff ────────────────────────────────────────────────────────── */
.git-diff-view {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
height: 100%;
}
.git-diff-empty {
color: var(--vscode-descriptionForeground);
text-align: center;
padding: 24px 0;
}
.git-diff-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.git-diff-toolbar label {
font-size: 12px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
flex-shrink: 0;
}
.git-diff-toolbar select {
flex: 1;
min-width: 0;
}
.git-diff-editor {
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
}

View File

@@ -5,6 +5,8 @@ defmodule BDS.Generation.Outputs do
import BDS.Generation.Renderers import BDS.Generation.Renderers
import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1] import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1]
alias BDS.Rendering.TemplateSelection
@spec additional_languages(map()) :: [String.t()] @spec additional_languages(map()) :: [String.t()]
def additional_languages(plan) do def additional_languages(plan) do
Enum.reject(plan.blog_languages, &(&1 == plan.language)) Enum.reject(plan.blog_languages, &(&1 == plan.language))
@@ -391,10 +393,12 @@ defmodule BDS.Generation.Outputs do
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post) canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content) body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
effective_slug = effective_template_slug(project_id, post)
{page_output_path(post.slug, nil), {page_output_path(post.slug, nil),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: canonical_variant.id, id: canonical_variant.id,
title: canonical_variant.title, title: canonical_variant.title,
@@ -423,10 +427,12 @@ defmodule BDS.Generation.Outputs do
|> Enum.map(fn post -> |> Enum.map(fn post ->
body = load_body(project_id, post.file_path, post.content) body = load_body(project_id, post.file_path, post.content)
effective_slug = effective_template_slug(project_id, post)
{page_output_path(post.slug, language), {page_output_path(post.slug, language),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: post.id, id: post.id,
title: post.title, title: post.title,
@@ -521,10 +527,12 @@ defmodule BDS.Generation.Outputs do
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post) canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content) body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
effective_slug = effective_template_slug(project_id, post)
{post_output_path(post), {post_output_path(post),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: canonical_variant.id, id: canonical_variant.id,
title: canonical_variant.title, title: canonical_variant.title,
@@ -551,10 +559,12 @@ defmodule BDS.Generation.Outputs do
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
body = load_body(project_id, post.file_path, post.content) body = load_body(project_id, post.file_path, post.content)
effective_slug = effective_template_slug(project_id, post)
{post_output_path(post, language), {post_output_path(post, language),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: post.id, id: post.id,
title: post.title, title: post.title,
@@ -571,4 +581,18 @@ defmodule BDS.Generation.Outputs do
post_outputs ++ translation_outputs post_outputs ++ translation_outputs
end end
defp effective_template_slug(project_id, post) do
slug = Map.get(post, :template_slug)
if is_binary(slug) and slug != "" do
slug
else
TemplateSelection.resolve_post_template_slug(
project_id,
Map.get(post, :tags) || [],
Map.get(post, :categories) || []
)
end
end
end end

View File

@@ -9,6 +9,7 @@ defmodule BDS.Preview do
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
alias BDS.Rendering alias BDS.Rendering
alias BDS.Rendering.TemplateSelection
@host "127.0.0.1" @host "127.0.0.1"
@port 4123 @port 4123
@@ -219,6 +220,7 @@ defmodule BDS.Preview do
defp draft_preview_payload(post, query_params) do defp draft_preview_payload(post, query_params) do
requested_language = query_params |> Map.get("lang") |> normalize_requested_language() requested_language = query_params |> Map.get("lang") |> normalize_requested_language()
effective_slug = post.template_slug || TemplateSelection.resolve_post_template_slug(post.project_id, post.tags, post.categories)
case draft_preview_translation(post.id, requested_language, post.language) do case draft_preview_translation(post.id, requested_language, post.language) do
%Translation{} = translation -> %Translation{} = translation ->
@@ -230,7 +232,7 @@ defmodule BDS.Preview do
slug: post.slug, slug: post.slug,
language: translation.language, language: translation.language,
excerpt: translation.excerpt, excerpt: translation.excerpt,
template_slug: post.template_slug template_slug: effective_slug
} }
nil -> nil ->
@@ -242,7 +244,7 @@ defmodule BDS.Preview do
slug: post.slug, slug: post.slug,
language: post.language, language: post.language,
excerpt: post.excerpt, excerpt: post.excerpt,
template_slug: post.template_slug template_slug: effective_slug
} }
end end
end end

View File

@@ -10,6 +10,7 @@ defmodule BDS.Preview.Router do
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.Translation alias BDS.Posts.Translation
alias BDS.Rendering alias BDS.Rendering
alias BDS.Rendering.TemplateSelection
alias BDS.Repo alias BDS.Repo
@type route :: @type route ::
@@ -219,7 +220,9 @@ defmodule BDS.Preview.Router do
_post_record: effective_record _post_record: effective_record
} }
case Rendering.render_post_page(project_id, post.template_slug, assigns) do effective_slug = post.template_slug || TemplateSelection.resolve_post_template_slug(project_id, post.tags, post.categories)
case Rendering.render_post_page(project_id, effective_slug, assigns) do
{:ok, rendered} -> {:ok, rendered} {:ok, rendered} -> {:ok, rendered}
{:error, _reason} -> {:error, :not_found} {:error, _reason} -> {:error, :not_found}
end end
@@ -406,35 +409,35 @@ defmodule BDS.Preview.Router do
|> MapSet.new() |> MapSet.new()
project_id project_id
|> load_all_published_posts() |> load_previewable_posts()
|> Enum.reject(fn post -> |> Enum.reject(fn post ->
Enum.any?(post.categories || [], &MapSet.member?(excluded, &1)) Enum.any?(post.categories || [], &MapSet.member?(excluded, &1))
end) end)
end end
defp load_all_published_posts(project_id) do defp load_previewable_posts(project_id) do
Repo.all( Repo.all(
from p in Post, from p in Post,
where: p.project_id == ^project_id and p.status == :published, where: p.project_id == ^project_id and p.status in [:published, :draft],
order_by: [desc: p.created_at, desc: p.published_at, asc: p.slug] order_by: [desc: p.created_at, desc: p.published_at, asc: p.slug]
) )
end end
defp load_published_posts_by_category(project_id, category) do defp load_published_posts_by_category(project_id, category) do
project_id project_id
|> load_all_published_posts() |> load_previewable_posts()
|> Enum.filter(fn post -> category in (post.categories || []) end) |> Enum.filter(fn post -> category in (post.categories || []) end)
end end
defp load_published_posts_by_tag(project_id, tag) do defp load_published_posts_by_tag(project_id, tag) do
project_id project_id
|> load_all_published_posts() |> load_previewable_posts()
|> Enum.filter(fn post -> tag in (post.tags || []) end) |> Enum.filter(fn post -> tag in (post.tags || []) end)
end end
defp load_published_posts_by_year(project_id, year) do defp load_published_posts_by_year(project_id, year) do
project_id project_id
|> load_all_published_posts() |> load_previewable_posts()
|> Enum.filter(fn post -> |> Enum.filter(fn post ->
{post_year, _, _} = Paths.local_date_parts!(post.created_at) {post_year, _, _} = Paths.local_date_parts!(post.created_at)
post_year == year post_year == year
@@ -443,7 +446,7 @@ defmodule BDS.Preview.Router do
defp load_published_posts_by_month(project_id, year, month) do defp load_published_posts_by_month(project_id, year, month) do
project_id project_id
|> load_all_published_posts() |> load_previewable_posts()
|> Enum.filter(fn post -> |> Enum.filter(fn post ->
{post_year, post_month, _} = Paths.local_date_parts!(post.created_at) {post_year, post_month, _} = Paths.local_date_parts!(post.created_at)
post_year == year and post_month == month post_year == year and post_month == month
@@ -452,7 +455,7 @@ defmodule BDS.Preview.Router do
defp load_published_posts_by_day(project_id, year, month, day) do defp load_published_posts_by_day(project_id, year, month, day) do
project_id project_id
|> load_all_published_posts() |> load_previewable_posts()
|> Enum.filter(fn post -> |> Enum.filter(fn post ->
{post_year, post_month, post_day} = Paths.local_date_parts!(post.created_at) {post_year, post_month, post_day} = Paths.local_date_parts!(post.created_at)
post_year == year and post_month == month and post_day == day post_year == year and post_month == month and post_day == day
@@ -462,7 +465,9 @@ defmodule BDS.Preview.Router do
defp find_post_by_slug_and_date(project_id, slug, year, month, day) do defp find_post_by_slug_and_date(project_id, slug, year, month, day) do
case Repo.one( case Repo.one(
from p in Post, from p in Post,
where: p.project_id == ^project_id and p.slug == ^slug and p.status == :published where:
p.project_id == ^project_id and p.slug == ^slug and
p.status in [:published, :draft]
) do ) do
nil -> nil ->
nil nil
@@ -481,7 +486,9 @@ defmodule BDS.Preview.Router do
defp find_page_by_slug(project_id, slug) do defp find_page_by_slug(project_id, slug) do
case Repo.one( case Repo.one(
from p in Post, from p in Post,
where: p.project_id == ^project_id and p.slug == ^slug and p.status == :published where:
p.project_id == ^project_id and p.slug == ^slug and
p.status in [:published, :draft]
) do ) do
%Post{categories: categories} = post -> %Post{categories: categories} = post ->
if "page" in (categories || []), do: post, else: nil if "page" in (categories || []), do: post, else: nil

View File

@@ -4,13 +4,52 @@ defmodule BDS.Rendering.TemplateSelection do
import Ecto.Query import Ecto.Query
alias BDS.Frontmatter alias BDS.Frontmatter
alias BDS.Metadata
alias BDS.Projects alias BDS.Projects
alias BDS.Rendering.FileSystem alias BDS.Rendering.FileSystem
alias BDS.Rendering.Filters alias BDS.Rendering.Filters
alias BDS.Repo alias BDS.Repo
alias BDS.StarterTemplates alias BDS.StarterTemplates
alias BDS.Tags.Tag
alias BDS.Templates.Template alias BDS.Templates.Template
@spec resolve_post_template_slug(String.t(), [String.t()], [String.t()]) ::
String.t() | nil
def resolve_post_template_slug(project_id, tag_names, category_names) do
resolve_from_tags(project_id, tag_names) ||
resolve_from_categories(project_id, category_names)
end
defp resolve_from_tags(_project_id, []), do: nil
defp resolve_from_tags(project_id, tag_names) do
Repo.all(
from tag in Tag,
where:
tag.project_id == ^project_id and
tag.name in ^tag_names and
not is_nil(tag.post_template_slug) and
tag.post_template_slug != "",
select: tag.post_template_slug,
limit: 1
)
|> List.first()
end
defp resolve_from_categories(_project_id, []), do: nil
defp resolve_from_categories(project_id, category_names) do
{:ok, state} = Metadata.get_project_metadata(project_id)
settings = state.category_settings || %{}
Enum.find_value(category_names, fn cat_name ->
case Map.get(settings, cat_name) do
%{"post_template_slug" => slug} when is_binary(slug) and slug != "" -> slug
_ -> nil
end
end)
end
@spec load_template_source(String.t(), atom(), String.t() | nil) :: @spec load_template_source(String.t(), atom(), String.t() | nil) ::
{:ok, String.t()} | {:error, term()} {:ok, String.t()} | {:error, term()}
def load_template_source(project_id, kind, slug) do def load_template_source(project_id, kind, slug) do

View File

@@ -5337,6 +5337,472 @@ button.import-taxonomy-pill {
align-items: stretch; align-items: stretch;
} }
} }
.misc-editor-shell {
background: var(--vscode-editor-background);
}
.misc-editor-header {
padding: 12px 16px 8px;
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-tab-activeBackground);
}
.misc-editor-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.misc-editor-header p {
margin: 2px 0 0;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.misc-editor-actions {
flex-shrink: 0;
}
.misc-editor-summary {
padding: 8px 16px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.misc-editor-content {
padding: 16px;
}
.misc-summary-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
color: var(--vscode-foreground);
}
.misc-summary-pill span {
color: var(--vscode-descriptionForeground);
}
.misc-summary-pill strong {
font-weight: 600;
}
.misc-card {
padding: 14px 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: var(--vscode-editor-background);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
}
.misc-card h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.misc-card p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.misc-card ul {
margin: 6px 0 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.6;
}
.misc-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.misc-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.misc-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 4px;
}
.misc-list-item:hover {
background: var(--vscode-list-hoverBackground);
}
.duplicate-pair-row label {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
cursor: pointer;
}
.duplicate-pair-row .linkish {
background: none;
border: none;
color: var(--vscode-textLink-foreground, #3794ff);
cursor: pointer;
padding: 0;
font: inherit;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.14em;
}
.duplicate-pair-row .linkish:hover {
color: var(--vscode-foreground);
}
.metadata-diff-tool {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-diff-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.metadata-diff-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--vscode-tab-inactiveForeground, var(--vscode-descriptionForeground));
font: inherit;
font-size: 12px;
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.metadata-diff-tab:hover {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.metadata-diff-tab.active {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
border-bottom-color: var(--vscode-focusBorder, #007fd4);
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
background: var(--vscode-activityBarBadge-background, #007acc);
color: var(--vscode-activityBarBadge-foreground, #ffffff);
}
.metadata-diff-field-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.metadata-diff-field-pill {
display: flex;
align-items: stretch;
border-radius: 6px;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-input-background);
overflow: hidden;
transition: border-color 0.12s;
}
.metadata-diff-field-pill.active {
border-color: var(--vscode-focusBorder, #007fd4);
background: var(--vscode-focusBorder, #007fd4);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 12%, transparent);
}
}
.metadata-diff-field-pill-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: none;
background: transparent;
color: var(--vscode-foreground);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.metadata-diff-field-pill-toggle:hover {
background: var(--vscode-list-hoverBackground);
}
.field-pill-label {
font-weight: 500;
}
.field-pill-count {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
}
.metadata-diff-field-pill-actions {
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-left: 1px solid var(--vscode-panel-border);
}
.metadata-diff-action-button {
font-size: 11px !important;
padding: 2px 8px !important;
min-height: 22px !important;
}
.metadata-diff-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-diff-empty p {
text-align: center;
padding: 20px 0;
}
.diff-item-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-item-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: var(--vscode-editor-background);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
overflow: hidden;
}
.diff-item-card.orphan-file {
border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
}
.diff-item-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: var(--vscode-sideBar-background);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, transparent);
}
border-bottom: 1px solid var(--vscode-panel-border);
}
.diff-item-header strong {
font-size: 13px;
font-weight: 600;
color: var(--vscode-foreground);
}
.diff-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-top: 2px;
}
.diff-item-fields {
padding: 8px 14px;
}
.diff-field-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid var(--vscode-panel-border);
@supports (color: color-mix(in lab, red, red)) {
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 50%, transparent);
}
}
.diff-field-row:last-child {
border-bottom: none;
}
.diff-field-name {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 3px;
}
.diff-field-values {
display: flex;
flex-direction: column;
gap: 3px;
}
.diff-field-value {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 1.4;
word-break: break-word;
}
.diff-field-value.db-value {
color: var(--vscode-foreground);
}
.diff-field-value.file-value {
color: var(--vscode-foreground);
opacity: 0.82;
}
.diff-source-label {
flex-shrink: 0;
min-width: 28px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 3px;
}
.db-value .diff-source-label {
background: var(--vscode-focusBorder, #007fd4);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
}
color: var(--vscode-focusBorder, #007fd4);
}
.file-value .diff-source-label {
background: var(--vscode-testing-iconPassed, #73c991);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
}
color: var(--vscode-testing-iconPassed, #73c991);
}
.orphan-files-section {
border: 1px solid var(--vscode-editorWarning-foreground, #cca700);
@supports (color: color-mix(in lab, red, red)) {
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
}
border-radius: 8px;
padding: 14px 16px;
background: var(--vscode-editorWarning-foreground, #cca700);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 5%, var(--vscode-editor-background));
}
}
.orphan-files-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.orphan-files-header h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.orphan-files-actions {
display: flex;
align-items: center;
gap: 8px;
}
.orphan-path span {
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.translation-validation-view {
display: flex;
flex-direction: column;
gap: 16px;
}
.translation-validation-summary {
padding: 10px 14px;
border-radius: 6px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.translation-validation-summary p {
margin: 0;
}
.translation-validation-section h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.translation-validation-empty {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.translation-validation-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.translation-validation-card {
padding: 10px 14px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editor-background);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
}
.translation-validation-card-db {
border-left: 3px solid var(--vscode-focusBorder, #007fd4);
}
.translation-validation-card-file {
border-left: 3px solid var(--vscode-testing-iconPassed, #73c991);
}
.translation-validation-card-title {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
}
.translation-validation-card-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
margin: 0;
font-size: 12px;
}
.translation-validation-card-meta dt {
color: var(--vscode-descriptionForeground);
font-weight: 500;
}
.translation-validation-card-meta dd {
margin: 0;
}
.translation-validation-actions {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--vscode-panel-border);
}
.git-diff-view {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
height: 100%;
}
.git-diff-empty {
color: var(--vscode-descriptionForeground);
text-align: center;
padding: 24px 0;
}
.git-diff-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.git-diff-toolbar label {
font-size: 12px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
flex-shrink: 0;
}
.git-diff-toolbar select {
flex: 1;
min-width: 0;
}
.git-diff-editor {
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
}
@layer components { @layer components {
.ui-button { .ui-button {
display: inline-flex; display: inline-flex;

View File

@@ -63,6 +63,23 @@ surface GenerationStatusSurface {
generation.generated_files.count generation.generated_files.count
} }
invariant GenerationPublishedOnly {
-- Generation renders the *published* state of the blog, never draft content.
--
-- Post universe: posts that have a published .md file on disk.
-- This includes status=published posts and status=draft posts that were
-- previously published (they still have a file_path with last-published content).
-- Posts that have never been published (no file_path) are excluded entirely.
--
-- Content source: always the .md file on disk (the last-published snapshot).
-- The DB content field (which holds draft edits) is never read during generation.
-- Snapshots set content=nil to ensure file-based resolution.
--
-- Contrast with preview (see preview.allium PreviewDraftOverlay):
-- Preview includes all drafts and prefers DB content over file content,
-- giving the author a live view of unpublished edits.
}
invariant IncrementalByContentHash { invariant IncrementalByContentHash {
-- Files are only written when content_hash changes -- Files are only written when content_hash changes
-- generatedFileHashes table tracks (projectId, relativePath, contentHash) -- generatedFileHashes table tracks (projectId, relativePath, contentHash)

View File

@@ -49,18 +49,24 @@ rule StopPreview {
} }
-- Route resolution -- Route resolution
-- Preview renders all posts (published + draft) on-demand via Liquid templates.
-- Content priority: DB content (draft edits) over published .md file content.
-- See invariant PreviewDraftOverlay below.
rule ServePostPreview { rule ServePostPreview {
when: PreviewRequest(path) when: PreviewRequest(path)
requires: is_post_path(path) requires: is_post_path(path)
-- path matches "/{yyyy}/{mm}/{dd}/{slug}" -- path matches "/{yyyy}/{mm}/{dd}/{slug}"
-- Renders post via Liquid template with full PageRenderer context -- Finds post by slug+date regardless of status (published or draft).
-- Content resolved via editor_body: DB content if present, else .md file.
-- Renders via Liquid template with full PageRenderer context.
ensures: PreviewResponse(rendered_html) ensures: PreviewResponse(rendered_html)
} }
rule ServeDraftPreview { rule ServeDraftPreview {
when: PreviewDraftRequest(path, post_id) when: PreviewDraftRequest(path, post_id)
-- Renders draft content (from DB, not filesystem) -- Explicit draft preview by post_id (used by editor preview pane).
-- Renders draft content (from DB, not filesystem).
ensures: PreviewResponse(rendered_html) ensures: PreviewResponse(rendered_html)
} }
@@ -68,6 +74,7 @@ rule ServeArchivePreview {
when: PreviewRequest(path) when: PreviewRequest(path)
requires: is_archive_path(path) requires: is_archive_path(path)
-- Category, tag, date archives with pagination -- Category, tag, date archives with pagination
-- Includes both published and draft posts in listings.
ensures: PreviewResponse(rendered_html) ensures: PreviewResponse(rendered_html)
} }
@@ -92,6 +99,28 @@ rule ServeLanguagePrefixedRoute {
ensures: PreviewResponse(translated_html) ensures: PreviewResponse(translated_html)
} }
invariant PreviewDraftOverlay {
-- Preview is the draft workspace: it shows what the blog *will* look like,
-- not what it currently looks like on the published site.
--
-- Post universe: all posts with status in {published, draft}.
-- Archived posts are excluded.
--
-- Content priority (per post):
-- 1. DB content field (draft edits not yet published) → used when non-nil
-- 2. Published .md file (last-published snapshot) → used when DB content is nil
-- 3. Empty string → fallback if neither exists
--
-- This means:
-- - A purely draft post (never published) renders from DB content.
-- - A published-then-edited post renders from DB content (the draft edits).
-- - A published post with no pending edits renders from its .md file.
--
-- Contrast with generation (see generation.allium GenerationPublishedOnly):
-- Generation uses *only* published .md file content, never DB draft content,
-- and excludes posts that have never been published.
}
invariant ThemeSwitching { invariant ThemeSwitching {
-- Preview supports live theme/mode switching via query params -- Preview supports live theme/mode switching via query params
-- ?theme=amber&mode=dark etc. -- ?theme=amber&mode=dark etc.

View File

@@ -605,6 +605,107 @@ defmodule BDS.PreviewTest do
assert :ok = BDS.Preview.stop_preview(project.id) assert :ok = BDS.Preview.stop_preview(project.id)
end end
test "on-demand rendering: draft post (never published) is visible and uses DB content", %{
project: project
} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en"]
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Pure Draft",
content: "Draft-only content",
language: "en"
})
assert post.status == :draft
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
datetime = DateTime.from_unix!(post.created_at, :millisecond)
y = Integer.to_string(datetime.year)
m = String.pad_leading(Integer.to_string(datetime.month), 2, "0")
d = String.pad_leading(Integer.to_string(datetime.day), 2, "0")
assert {:ok, %{body: html, content_type: "text/html"}} =
BDS.Preview.request(project.id, "/#{y}/#{m}/#{d}/#{post.slug}")
assert html =~ "Pure Draft"
assert html =~ "Draft-only content"
assert :ok = BDS.Preview.stop_preview(project.id)
end
test "on-demand rendering: published-then-edited post shows draft DB content over file", %{
project: project
} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en"]
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Published Then Edited",
content: "Original content",
language: "en"
})
assert {:ok, published} = Posts.publish_post(post.id)
{:ok, edited} =
Posts.update_post(published.id, %{
title: "Edited Title",
content: "Edited draft content"
})
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
datetime = DateTime.from_unix!(edited.created_at, :millisecond)
y = Integer.to_string(datetime.year)
m = String.pad_leading(Integer.to_string(datetime.month), 2, "0")
d = String.pad_leading(Integer.to_string(datetime.day), 2, "0")
assert {:ok, %{body: html, content_type: "text/html"}} =
BDS.Preview.request(project.id, "/#{y}/#{m}/#{d}/#{edited.slug}")
assert html =~ "Edited Title"
assert html =~ "Edited draft content"
refute html =~ "Original content"
assert :ok = BDS.Preview.stop_preview(project.id)
end
test "on-demand rendering: draft posts appear in home listing", %{project: project} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en"]
})
assert {:ok, _draft} =
Posts.create_post(%{
project_id: project.id,
title: "Draft In List",
content: "Draft list body",
language: "en"
})
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
assert {:ok, %{body: html, content_type: "text/html"}} =
BDS.Preview.request(project.id, "/")
assert html =~ "Draft In List"
assert :ok = BDS.Preview.stop_preview(project.id)
end
test "preview query params can override the rendered theme for generated and draft pages", %{ test "preview query params can override the rendered theme for generated and draft pages", %{
project: project project: project
} do } do

View File

@@ -0,0 +1,215 @@
defmodule BDS.TemplateLookupPriorityTest do
use ExUnit.Case, async: false
alias BDS.Rendering.TemplateSelection
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-tlp-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "TLP Test", data_path: temp_dir})
{:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en"]
})
%{project: project}
end
defp create_published_template(project_id, slug, content) do
{:ok, template} =
BDS.Templates.create_template(%{
project_id: project_id,
title: "Template #{slug}",
kind: :post,
content: content
})
if template.slug != slug do
BDS.Repo.update!(Ecto.Changeset.change(template, slug: slug))
end
{:ok, published} = BDS.Templates.publish_template(template.id)
published
end
defp create_tag(project_id, name, post_template_slug) do
{:ok, tag} =
BDS.Tags.create_tag(%{
project_id: project_id,
name: name,
post_template_slug: post_template_slug
})
tag
end
describe "resolve_post_template_slug/3" do
test "level 1: returns post's own template_slug when set", %{project: project} do
result =
TemplateSelection.resolve_post_template_slug(
project.id,
["some-tag"],
["article"]
)
assert result == nil
end
test "level 2: falls back to tag's post_template_slug", %{project: project} do
_tag = create_tag(project.id, "photo", "photo-layout")
result =
TemplateSelection.resolve_post_template_slug(
project.id,
["photo"],
[]
)
assert result == "photo-layout"
end
test "level 2: uses first matching tag with a template slug", %{project: project} do
_tag1 = create_tag(project.id, "news", nil)
_tag2 = create_tag(project.id, "gallery", "gallery-layout")
result =
TemplateSelection.resolve_post_template_slug(
project.id,
["news", "gallery"],
[]
)
assert result == "gallery-layout"
end
test "level 3: falls back to category's post_template_slug", %{project: project} do
{:ok, _} = BDS.Metadata.add_category(project.id, "review")
{:ok, _} =
BDS.Metadata.update_category_settings(project.id, "review", %{
"post_template_slug" => "review-layout"
})
result =
TemplateSelection.resolve_post_template_slug(
project.id,
[],
["review"]
)
assert result == "review-layout"
end
test "level 2 takes priority over level 3", %{project: project} do
_tag = create_tag(project.id, "featured", "featured-layout")
{:ok, _} = BDS.Metadata.add_category(project.id, "review")
{:ok, _} =
BDS.Metadata.update_category_settings(project.id, "review", %{
"post_template_slug" => "review-layout"
})
result =
TemplateSelection.resolve_post_template_slug(
project.id,
["featured"],
["review"]
)
assert result == "featured-layout"
end
test "level 4: returns nil when no tag or category has a template", %{project: project} do
_tag = create_tag(project.id, "plain", nil)
result =
TemplateSelection.resolve_post_template_slug(
project.id,
["plain"],
["article"]
)
assert result == nil
end
end
describe "end-to-end template lookup with rendering" do
test "post renders with tag-specific template when no post template set", %{
project: project
} do
template =
create_published_template(project.id, "photo-layout", "<h1>PHOTO: {{ page.title }}</h1>")
_tag = create_tag(project.id, "photo", template.slug)
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "My Photo Post",
content: "photo content",
language: "en",
tags: ["photo"]
})
resolved_slug =
TemplateSelection.resolve_post_template_slug(
project.id,
post.tags,
post.categories
)
assert resolved_slug == "photo-layout"
{:ok, rendered} =
TemplateSelection.load_template_source(project.id, :post, resolved_slug)
assert rendered =~ "PHOTO:"
end
test "post renders with category-specific template when no post or tag template", %{
project: project
} do
template =
create_published_template(
project.id,
"review-layout",
"<h1>REVIEW: {{ page.title }}</h1>"
)
{:ok, _} = BDS.Metadata.add_category(project.id, "review")
{:ok, _} =
BDS.Metadata.update_category_settings(project.id, "review", %{
"post_template_slug" => template.slug
})
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "My Review",
content: "review content",
language: "en",
categories: ["review"]
})
resolved_slug =
TemplateSelection.resolve_post_template_slug(
project.id,
post.tags,
post.categories
)
assert resolved_slug == "review-layout"
{:ok, rendered} =
TemplateSelection.load_template_source(project.id, :post, resolved_slug)
assert rendered =~ "REVIEW:"
end
end
end