fix: A1-16 keep public project content out of repo via per-user content location and machine-local project registry
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@
|
||||
/priv/data/*.db
|
||||
/priv/data/*.db-shm
|
||||
/priv/data/*.db-wal
|
||||
# Project public content (posts, media, templates, generated html) lives in the
|
||||
# per-user default content folder, never the repo. See PublicContentLivesInProjectFolder.
|
||||
/priv/data/projects/
|
||||
# Embeddings index artifacts are per-project runtime caches, never committed.
|
||||
*.usearch
|
||||
*.usearch.meta.json
|
||||
|
||||
@@ -27,7 +27,7 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
|
||||
| A1-14b | ~~USearch HNSW ANN index + debounced persistence not implemented~~ | embedding.allium config/FindSimilar/DebouncedPersistence | `Embeddings.Index` is now an HNSW (hnswlib) ANN index with debounced persistence | **Resolved:** rewrote `Embeddings.Index` as a DB-free GenServer wrapping an hnswlib HNSW graph (cosine, M=16, efConstruction=128, efSearch=64) — O(n·log n) build, O(log n) queries, replacing the O(n²) JSON cosine snapshot; per-project in-memory index + `label→post_id` map; 5s debounced `save_index` + `.meta.json` sidecar, force-save on project switch (`set_active_project`) and shutdown (`terminate`), `forget/1` on project delete; lazy reload from disk with rebuild-from-DB self-heal on miss; `find_similar`/`find_duplicates`/`compute_similarities` rewired (no brute-force fallback); USearch has no Elixir binding so hnswlib provides the identical HNSW algorithm/params (spec reconciled); supervision + dialyzer PLT updated; tests updated for debounced/binary persistence + self-heal. Follow-up hardening: explicit rebuild now forces re-embedding regardless of content_hash (ReindexAll), and model-unavailable errors propagate cleanly (post saves degrade to unindexed + log; rebuild/index return `{:error, reason}` surfaced as a failed task with a user-facing message instead of crashing). |
|
||||
| A1-14c | ~~Embedding model runs on CPU only; no Apple GPU acceleration~~ | embedding.allium invariant NativeAcceleratedExecution | `Backends.Neural` now selects the defn compiler at serving-build time: Apple GPU via EMLX (MLX/Metal) on arm64 macOS, EXLA-CPU elsewhere | **Resolved:** added `{:emlx, "~> 0.2.0"}` dep (ships precompiled MLX binaries; EMLX 0.2.0 implements both `EMLX.Backend` and the `Nx.Defn.Compiler` behaviour, GPU-default); `Backends.Neural` gained a pure `select_accelerator/3` policy (`:auto` prefers EMLX only when available **and** on Apple Silicon; explicit `:emlx`/`:exla` honoured; forced `:emlx` degrades to EXLA when unavailable so misconfigured hosts still run), `current_accelerator/0`, and `defn_options/1`; `build_serving` places params on `{EMLX.Backend, device: :gpu}` and compiles with `EMLX` for the EMLX path, keeps `EXLA` otherwise; new `accelerator: :auto` config key; spec `NativeAcceleratedExecution` + `EmbeddingModel` updated; PLT app added; 7 tests added (offline — test config still uses the InApp stub). |
|
||||
| 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 |
|
||||
| A1-16 | Public project content + data_path discovery not compliant with storage-location spec | project.allium `PublicContentLivesInProjectFolder` / `PrivateArtifactsLiveInOsAppDir` / `DataPathNotPersistedInProjectJson` / `DiscoverProjectDataPath` (newly added) | **Private side done:** `Projects.project_cache_root/0` now falls back to the OS private app dir (`:filename.basedir(:user_config, "bds")` → macOS `~/Library/Application Support/bds`) instead of `priv/data`, so the embeddings index no longer lands in the repo. **Still non-compliant (public side):** `project_data_dir/0` (projects.ex:97-99) falls back to `priv/data/projects/<id>` when `data_path` is nil, so the default project's *public* content (posts, media, templates, scripts, `meta/`, generated `html/`) is written into the application repo; there is no discovery of `data_path` from the `meta/project.json` location, and the `default` project is created with `data_path: nil` (projects.ex:80). | Implement project-folder discovery: `data_path` := the folder containing `meta/project.json` (never stored in project.json, keeping projects movable — `DiscoverProjectDataPath`); create the default project's folder at a per-user default content location on first launch (never in repo/private_dir); drop the `priv/data/projects/<id>` fallback in `project_data_dir/0`; persist the current project-folder location as a machine-local pointer (project registry) under `private_dir`. Migrate the committed `priv/data/projects/default/` content out of the repo. |
|
||||
| A1-16 | ~~Public project content + data_path discovery not compliant with storage-location spec~~ | project.allium `PublicContentLivesInProjectFolder` / `PrivateArtifactsLiveInOsAppDir` / `DataPathNotPersistedInProjectJson` / `DiscoverProjectDataPath` | Public content now lives under a per-user default content location, never the repo | **Resolved:** `project_data_dir/1` drops the `priv/data/projects/<id>` repo fallback — a project without an explicit `data_path` resolves to `default_content_root()/<id>` (configurable via `:default_content_root`, else `~/bds`), never the repo or `private_dir`; the `default` project is now created on first launch with an explicit `data_path` under that location and its folder is `mkdir`'d (`PublicContentLivesInProjectFolder`); added `Projects.private_dir/0`, `default_content_root/0`, and a machine-local project registry (`registry_path/0` → `project_registry.json` under `private_dir`, written on create/ensure-default, removed on delete) that remembers each project's folder without embedding it in `meta/project.json` (`DataPathNotPersistedInProjectJson`/`DiscoverProjectDataPath` — already satisfied since `project.json` never serializes `data_path`); `delete_project` removes app-managed folders (those under `default_content_root`) but preserves user-chosen external folders; committed `priv/data/projects/default/` content removed from the repo and `/priv/data/projects/` git-ignored; test config redirects `:default_content_root` to a temp dir; 4 tests added (default folder outside repo/private, no-repo fallback, registry round-trip, registry cleanup on delete). |
|
||||
|
||||
### A2. Spec Should Update (code is normative)
|
||||
|
||||
@@ -188,7 +188,7 @@ All reconciled to follow code. Specs must be self-consistent and match code.
|
||||
## Priority Order for Resolution
|
||||
|
||||
1. ~~**A1-1 through A1-15**~~ — all resolved: auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown, real embedding model, HNSW ANN index, Apple GPU/EMLX acceleration (A1-14c), and preview/generation content strategy (A1-15)
|
||||
1b. **A1-16** — storage-location compliance: private side done (embeddings index → OS app dir); public side open (data_path discovery from meta/project.json, drop the `priv/data/projects/<id>` fallback, migrate committed default project out of repo)
|
||||
1b. ~~**A1-16**~~ — storage-location compliance resolved: public content now lives under a per-user default content location (never the repo/private dir), `priv/data/projects/<id>` fallback dropped, machine-local project registry added, committed default project content removed from repo
|
||||
2. **D1-1 through D1-18** — untested invariants/guarantees
|
||||
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
|
||||
4. **B1-1 through B1-6** — major code behaviors missing from spec
|
||||
|
||||
@@ -69,6 +69,12 @@ defmodule BDS.Projects do
|
||||
now = Persistence.now_ms()
|
||||
is_active = not Repo.exists?(from project in Project, where: project.is_active == true)
|
||||
|
||||
# The default project's public content folder is created at the per-user
|
||||
# default content location on first launch — never in the repo or the
|
||||
# private app dir (PublicContentLivesInProjectFolder).
|
||||
data_path = default_project_dir(@default_project_id)
|
||||
File.mkdir_p!(data_path)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
project =
|
||||
%Project{}
|
||||
@@ -77,7 +83,7 @@ defmodule BDS.Projects do
|
||||
name: @default_project_name,
|
||||
slug: unique_slug(Slug.slugify(@default_project_name)),
|
||||
description: nil,
|
||||
data_path: nil,
|
||||
data_path: data_path,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_active: is_active
|
||||
@@ -87,17 +93,51 @@ defmodule BDS.Projects do
|
||||
project
|
||||
end)
|
||||
|> case do
|
||||
{:ok, project} -> rebuild_project_templates(project)
|
||||
{:error, reason} -> {:error, reason}
|
||||
{:ok, project} ->
|
||||
record_project_location(project.id, data_path)
|
||||
rebuild_project_templates(project)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec project_data_dir(Project.t()) :: String.t()
|
||||
def project_data_dir(%Project{} = project) do
|
||||
project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__)
|
||||
def project_data_dir(%Project{data_path: data_path}) when is_binary(data_path) and data_path != "",
|
||||
do: data_path
|
||||
|
||||
# A project without an explicit data_path resolves to its folder under the
|
||||
# per-user default content location — never priv/data inside the repo
|
||||
# (PublicContentLivesInProjectFolder).
|
||||
def project_data_dir(%Project{id: id}), do: default_project_dir(id)
|
||||
|
||||
@doc """
|
||||
Per-user base directory that holds the public, portable content of projects
|
||||
created without an explicit folder (the default project on first launch).
|
||||
|
||||
Configurable via `:default_content_root`; otherwise the user's home dir under
|
||||
`bds/`. Never the application repo nor `private_dir/0`
|
||||
(PublicContentLivesInProjectFolder).
|
||||
"""
|
||||
@spec default_content_root() :: String.t()
|
||||
def default_content_root do
|
||||
case Application.get_env(:bds, :default_content_root) do
|
||||
root when is_binary(root) -> Path.expand(root)
|
||||
_other -> Path.join(System.user_home!(), "bds")
|
||||
end
|
||||
end
|
||||
|
||||
defp default_project_dir(project_id), do: Path.join(default_content_root(), project_id)
|
||||
|
||||
@doc """
|
||||
The OS per-user app-data directory holding machine-specific, regenerable
|
||||
artifacts only (database, embeddings index, model cache, project registry,
|
||||
UI state) — never project content (PrivateArtifactsLiveInOsAppDir).
|
||||
"""
|
||||
@spec private_dir() :: String.t()
|
||||
def private_dir, do: private_app_dir()
|
||||
|
||||
@spec project_cache_dir(Project.t() | String.t()) :: String.t()
|
||||
def project_cache_dir(%Project{} = project), do: project_cache_dir(project.id)
|
||||
|
||||
@@ -130,6 +170,8 @@ defmodule BDS.Projects do
|
||||
end)
|
||||
|> case do
|
||||
{:ok, project} ->
|
||||
record_project_location(project.id, project_data_dir(project))
|
||||
|
||||
with {:ok, project} <- rebuild_project_templates(project) do
|
||||
sync_filesystem_metadata(project)
|
||||
end
|
||||
@@ -192,10 +234,15 @@ defmodule BDS.Projects do
|
||||
{:error, :cannot_delete_active_project}
|
||||
|
||||
%Project{} = project ->
|
||||
internal_dir = if is_nil(project.data_path), do: project_data_dir(project), else: nil
|
||||
data_dir = project_data_dir(project)
|
||||
|
||||
# App-managed folders (those under the per-user default content location)
|
||||
# are removed; user-chosen external folders are preserved.
|
||||
managed_dir =
|
||||
if String.starts_with?(data_dir, default_content_root()), do: data_dir, else: nil
|
||||
|
||||
cleanup_dirs =
|
||||
[internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
|
||||
[managed_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
|
||||
|
||||
Repo.transaction(fn ->
|
||||
case Repo.delete(project) do
|
||||
@@ -206,6 +253,7 @@ defmodule BDS.Projects do
|
||||
|> case do
|
||||
{:ok, deleted_project} ->
|
||||
BDS.Embeddings.Index.forget(deleted_project.id)
|
||||
forget_project_location(deleted_project.id)
|
||||
|
||||
Enum.each(cleanup_dirs, fn dir ->
|
||||
_ = File.rm_rf(dir)
|
||||
@@ -287,6 +335,41 @@ defmodule BDS.Projects do
|
||||
|> Path.expand()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Path to the machine-local project registry: a `id => data_path` pointer file
|
||||
under `private_dir/0` that remembers where each project's folder currently
|
||||
lives. The folder location is never embedded in `meta/project.json`, so a
|
||||
project folder can be moved or renamed and only the registry is updated
|
||||
(DataPathNotPersistedInProjectJson).
|
||||
"""
|
||||
@spec registry_path() :: String.t()
|
||||
def registry_path, do: Path.join(private_dir(), "project_registry.json")
|
||||
|
||||
@doc "Reads the machine-local project registry as an `id => data_path` map."
|
||||
@spec project_registry() :: %{optional(String.t()) => String.t()}
|
||||
def project_registry do
|
||||
with {:ok, contents} <- File.read(registry_path()),
|
||||
{:ok, map} when is_map(map) <- Jason.decode(contents) do
|
||||
map
|
||||
else
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
defp record_project_location(project_id, data_path) when is_binary(data_path) do
|
||||
project_registry() |> Map.put(project_id, data_path) |> write_registry()
|
||||
end
|
||||
|
||||
defp forget_project_location(project_id) do
|
||||
project_registry() |> Map.delete(project_id) |> write_registry()
|
||||
end
|
||||
|
||||
defp write_registry(registry) do
|
||||
path = registry_path()
|
||||
File.mkdir_p!(Path.dirname(path))
|
||||
File.write(path, Jason.encode!(registry))
|
||||
end
|
||||
|
||||
defp attr(attrs, key) do
|
||||
cond do
|
||||
Map.has_key?(attrs, key) -> Map.get(attrs, key)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<div
|
||||
class="macro-gallery gallery-cols-{{ columns }}"
|
||||
data-post-id="{{ post_id | escape }}"
|
||||
data-columns="{{ columns }}"
|
||||
data-lightbox="true"
|
||||
>
|
||||
<div class="gallery-container gallery-lightbox">
|
||||
{%- if items.size > 0 -%}
|
||||
{%- for item in items -%}
|
||||
<a
|
||||
class="gallery-item"
|
||||
href="{{ item.media_path | escape }}"
|
||||
data-lightbox="{{ item.group_name | escape }}"
|
||||
data-title="{{ item.title | escape }}"
|
||||
>
|
||||
<img
|
||||
src="{{ item.media_path | escape }}"
|
||||
alt="{{ item.alt | escape }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="gallery-empty">{{ empty_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
{%- if caption -%}
|
||||
<figcaption class="gallery-caption">{{ caption | escape }}</figcaption>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
@@ -1,33 +0,0 @@
|
||||
<div class="{{ root_classes }}"{% for attr in data_attrs %} {{ attr.name }}="{{ attr.value | escape }}"{% endfor %}>
|
||||
<div class="photo-archive-container">
|
||||
{%- if months.size > 0 -%}
|
||||
{%- for month in months -%}
|
||||
<div class="photo-archive-month-wrapper">
|
||||
<div class="photo-archive-month">
|
||||
<div class="photo-archive-month-label">
|
||||
<span>{{ month.label | escape }}</span>
|
||||
</div>
|
||||
<div class="photo-archive-gallery">
|
||||
{%- for item in month.items -%}
|
||||
<a
|
||||
class="photo-archive-item"
|
||||
href="{{ item.media_path | escape }}"
|
||||
data-lightbox="{{ item.group_name | escape }}"
|
||||
data-title="{{ item.title | escape }}"
|
||||
>
|
||||
<img
|
||||
src="{{ item.media_path | escape }}"
|
||||
alt="{{ item.alt | escape }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="photo-archive-empty">{{ empty_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,19 +0,0 @@
|
||||
<div
|
||||
class="macro-tag-cloud"
|
||||
data-tag-cloud="true"
|
||||
data-orientation="{{ orientation }}"
|
||||
data-color-distribution="quantile"
|
||||
data-color-easing="0.7"
|
||||
data-color-theme="pico"{%- if words_json -%} data-tag-cloud-words="{{ words_json }}" data-width="{{ width }}" data-height="{{ height }}"{%- endif -%}
|
||||
>
|
||||
{%- if words_json -%}
|
||||
<svg
|
||||
class="tag-cloud-canvas"
|
||||
viewBox="0 0 {{ width }} {{ height }}"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-label="{{ aria_label | escape }}"
|
||||
></svg>
|
||||
{%- else -%}
|
||||
<div class="tag-cloud-empty">{{ empty_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
<div class="macro-vimeo">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/{{ id | escape }}"
|
||||
title="{{ title | escape }}"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
<div class="macro-youtube">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/{{ id | escape }}?rel=0"
|
||||
title="{{ title | escape }}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
id: 77f27148-8a19-4da2-8532-faa79683ba40
|
||||
slug: not-found
|
||||
title: Not Found
|
||||
kind: not_found
|
||||
enabled: true
|
||||
version: 1
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
|
||||
<body>
|
||||
<main>
|
||||
<section class="not-found" data-template="not-found">
|
||||
<article>
|
||||
<h1>404</h1>
|
||||
<p>{{ not_found_message }}</p>
|
||||
<p><a href="/" role="button">{{ not_found_back_label }}</a></p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +0,0 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ page_title }}</title>
|
||||
{% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %}
|
||||
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
|
||||
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
||||
<link rel="stylesheet" href="/assets/highlight.min.css" />
|
||||
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
|
||||
<link rel="stylesheet" href="/assets/bds.css" />
|
||||
{% assign feed_prefix = language_prefix | default: '' %}
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="{{ feed_prefix }}/rss.xml" />
|
||||
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ feed_prefix }}/atom.xml" />
|
||||
{% for alternate_link in alternate_links %}
|
||||
<link rel="alternate" hreflang="{{ alternate_link.hreflang | escape }}" href="{{ alternate_link.href | escape }}" />
|
||||
{% endfor %}
|
||||
<script defer src="/assets/highlight.min.js"></script>
|
||||
<script defer src="/assets/code-enhancements.js"></script>
|
||||
<script defer src="/assets/d3.layout.cloud.js"></script>
|
||||
<script defer src="/assets/tag-cloud.js"></script>
|
||||
<script defer src="/assets/lightbox.min.js"></script>
|
||||
<script defer src="/assets/vanilla-calendar.min.js"></script>
|
||||
<script defer src="/assets/calendar-runtime.js"></script>
|
||||
<script defer src="/assets/search-runtime.js"></script>
|
||||
<link rel="stylesheet" href="{{ language_prefix }}/pagefind/pagefind-ui.css" />
|
||||
<script defer src="{{ language_prefix }}/pagefind/pagefind-ui.js"></script>
|
||||
</head>
|
||||
@@ -1,41 +0,0 @@
|
||||
{% if blog_languages.size > 1 %}
|
||||
<nav class="language-switcher" aria-label="{{ labels.language_switcher_label }}">
|
||||
{% for lang in blog_languages %}
|
||||
{% if lang.is_current %}
|
||||
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="{{ lang.code }}">{{ lang.flag }}</span>
|
||||
{% else %}
|
||||
<a class="language-switcher-badge" href="{{ lang.href_prefix | default: '/' }}" data-lang-prefix="{{ lang.href_prefix }}" title="{{ lang.code }}">{{ lang.flag }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="blog-search-widget" aria-label="{{ labels.site_search_label }}">
|
||||
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ labels.site_search_label }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}" data-search-no-results="{{ labels.search_no_results }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
var links=document.querySelectorAll('.language-switcher-badge[data-lang-prefix]');
|
||||
var path=location.pathname.replace(/^\/[a-z]{2}(?=\/|$)/,'') || '/';
|
||||
links.forEach(function(a){a.href=(a.dataset.langPrefix||'')+path;});
|
||||
}());
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="blog-search-standalone" aria-label="{{ labels.site_search_label }}">
|
||||
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ labels.site_search_label }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}" data-search-no-results="{{ labels.search_no_results }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,63 +0,0 @@
|
||||
<ul class="blog-menu-list">
|
||||
{% for item in items %}
|
||||
<li class="blog-menu-item{% if item.has_children %} blog-menu-item-with-children{% endif %}">
|
||||
{% if item.href == '#' %}
|
||||
<span class="blog-menu-link">{{ item.title }}</span>
|
||||
{% else %}
|
||||
<a class="blog-menu-link" href="{{ item.href }}">{{ item.title }}</a>
|
||||
{% endif %}
|
||||
{% if item.has_children %}
|
||||
<div class="blog-menu-submenu">
|
||||
{% render 'partials/menu-items', items: item.children %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if include_calendar %}
|
||||
<li class="blog-menu-item blog-menu-calendar">
|
||||
<button
|
||||
type="button"
|
||||
class="blog-menu-calendar-button"
|
||||
data-blog-calendar-toggle
|
||||
{% if calendar_initial_year %}data-blog-calendar-year="{{ calendar_initial_year }}"{% endif %}
|
||||
{% if calendar_initial_month %}data-blog-calendar-month="{{ calendar_initial_month }}"{% endif %}
|
||||
aria-label="{{ labels.calendar_open_label }}"
|
||||
title="{{ labels.calendar_open_label }}"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="8" y1="3" x2="8" y2="7"></line>
|
||||
<line x1="16" y1="3" x2="16" y2="7"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<section
|
||||
id="blog-calendar"
|
||||
class="blog-calendar-panel"
|
||||
data-blog-calendar-panel
|
||||
data-i18n-loading="{{ labels.calendar_loading_label }}"
|
||||
data-i18n-error="{{ labels.calendar_error_label }}"
|
||||
hidden
|
||||
>
|
||||
<header class="blog-calendar-header">
|
||||
<strong>{{ labels.calendar_title_label }}</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="blog-calendar-close"
|
||||
data-blog-calendar-close
|
||||
aria-label="{{ labels.calendar_close_label }}"
|
||||
title="{{ labels.calendar_close_label }}"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="blog-calendar-content">
|
||||
<div data-blog-calendar-root></div>
|
||||
<p class="blog-calendar-status" data-blog-calendar-status>{{ labels.calendar_loading_label }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@@ -1,7 +0,0 @@
|
||||
<nav class="blog-menu">
|
||||
{% if menu_items and menu_items.size > 0 %}
|
||||
{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month, labels: labels %}
|
||||
{% else %}
|
||||
{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month, labels: labels %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
id: b27d1547-548a-47ca-8bab-d81edef003ba
|
||||
slug: post-list
|
||||
title: Post List
|
||||
kind: list
|
||||
enabled: true
|
||||
version: 1
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}
|
||||
<body>
|
||||
<main>
|
||||
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language, labels: labels %}
|
||||
{% if archive_context %}
|
||||
{% if show_archive_range_heading and min_date and max_date %}
|
||||
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
||||
<h1 class="archive-heading">{{ archive_context.name }} - {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
||||
{% else %}
|
||||
<h1 class="archive-heading">{{ labels.archive_label }} {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
||||
<h1 class="archive-heading">{{ archive_context.name }}</h1>
|
||||
{% elsif archive_context.kind == 'month' and archive_context.month and archive_context.year %}
|
||||
<h1 class="archive-heading">{{ labels.archive_label }} {{ archive_month_name }} {{ archive_context.year }}</h1>
|
||||
{% elsif archive_context.kind == 'year' and archive_context.year %}
|
||||
<h1 class="archive-heading">{{ labels.archive_label }} {{ archive_context.year }}</h1>
|
||||
{% elsif archive_context.kind == 'day' and archive_context.day and archive_context.month and archive_context.year %}
|
||||
<h1 class="archive-heading">{{ labels.archive_label }} {{ archive_context.day }}. {{ archive_month_name }} {{ archive_context.year }}</h1>
|
||||
{% else %}
|
||||
<h1 class="archive-heading">{{ page_title }}</h1>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month, labels: labels %}
|
||||
|
||||
<section class="post-list" data-template="post-list" data-list-page="{{ is_list_page }}" data-first-page="{{ is_first_page }}" data-last-page="{{ is_last_page }}">
|
||||
{% for day_block in day_blocks %}
|
||||
{% if day_block.show_date_marker %}
|
||||
<section class="archive-day-group">
|
||||
<aside class="archive-day-marker"><span>{{ day_block.date_label }}</span></aside>
|
||||
<div class="archive-day-posts">
|
||||
{% for post in day_block.posts %}
|
||||
<div class="post">
|
||||
{% if post.show_title %}
|
||||
{% assign canonical_post_href = canonical_post_path_by_slug[post.slug] %}
|
||||
{% if canonical_post_href == blank %}
|
||||
{% assign canonical_post_href = '/posts/' | append: post.slug %}
|
||||
{% endif %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% endif %}
|
||||
{{ post.content }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
{% for post in day_block.posts %}
|
||||
<div class="post">
|
||||
{% if post.show_title %}
|
||||
{% assign canonical_post_href = canonical_post_path_by_slug[post.slug] %}
|
||||
{% if canonical_post_href == blank %}
|
||||
{% assign canonical_post_href = '/posts/' | append: post.slug %}
|
||||
{% endif %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% endif %}
|
||||
{{ post.content }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if day_block.show_separator %}
|
||||
<div class="archive-day-separator" aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{% if has_prev_page or has_next_page %}
|
||||
<nav class="preview-pagination" aria-label="{{ labels.pagination_label }}">
|
||||
{% if has_prev_page %}
|
||||
<a href="{{ prev_page_href }}" class="preview-pagination-link" aria-label="{{ labels.newer_label }}">{{ labels.newer_label }}</a>
|
||||
{% else %}
|
||||
<span class="spacer"></span>
|
||||
{% endif %}
|
||||
|
||||
{% if has_next_page %}
|
||||
<a href="{{ next_page_href }}" class="preview-pagination-link" aria-label="{{ labels.older_label }}">{{ labels.older_label }}</a>
|
||||
{% else %}
|
||||
<span class="spacer"></span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
id: d64dcf22-28dc-4406-bc2b-1ef72f92cc0a
|
||||
slug: single-post
|
||||
title: Single Post
|
||||
kind: post
|
||||
enabled: true
|
||||
version: 1
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, alternate_links: alternate_links, language_prefix: language_prefix %}
|
||||
<body>
|
||||
<main>
|
||||
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language, labels: labels %}
|
||||
<h1>{{ post.title }}</h1>
|
||||
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month, labels: labels %}
|
||||
{% if post_categories.size > 0 or post_tags.size > 0 %}
|
||||
<div class="single-post-taxonomy" aria-label="{{ labels.taxonomy_label }}">
|
||||
{% for category in post_categories %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | slugify | url_encode }}/">{{ category | escape }}</a>
|
||||
{% endfor %}
|
||||
{% for tag in post_tags %}
|
||||
{% assign tag_color = tag_color_by_name[tag] %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | slugify | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<article class="single-post blog-post" data-template="single-post" data-pagefind-body>
|
||||
<div class="post">{{ post.content }}</div>
|
||||
</article>
|
||||
{% if backlinks.size > 0 %}
|
||||
<div class="single-post-backlinks" aria-label="{{ labels.backlinks_label }}">
|
||||
<span class="single-post-backlinks-label">{{ labels.linked_from_label }}</span>
|
||||
{% for backlink in backlinks %}
|
||||
<a class="single-post-taxonomy-bubble single-post-backlink-bubble" href="{{ backlink.path }}">{{ backlink.display_slug }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -157,6 +157,48 @@ defmodule BDS.ProjectsTest do
|
||||
assert Repo.aggregate(Project, :count, :id) == 1
|
||||
end
|
||||
|
||||
test "the default project's public content folder lives outside the repo and private dir" do
|
||||
Repo.delete_all(Project)
|
||||
|
||||
assert {:ok, default_project} = BDS.Projects.ensure_default_project()
|
||||
|
||||
data_dir = BDS.Projects.project_data_dir(default_project)
|
||||
|
||||
# Public content must live under the per-user default content location,
|
||||
# never in the application repo (priv/data) nor the private app dir.
|
||||
refute String.starts_with?(data_dir, Path.expand("../../priv/data", __DIR__))
|
||||
refute String.starts_with?(data_dir, BDS.Projects.private_dir())
|
||||
assert String.starts_with?(data_dir, Application.fetch_env!(:bds, :default_content_root))
|
||||
end
|
||||
|
||||
test "project_data_dir never falls back into the application repo" do
|
||||
# A project without an explicit data_path resolves to the per-user default
|
||||
# content location, not priv/data/projects/<id> inside the repo.
|
||||
project = %Project{id: "no-path-#{System.unique_integer([:positive])}", data_path: nil}
|
||||
|
||||
data_dir = BDS.Projects.project_data_dir(project)
|
||||
|
||||
refute String.starts_with?(data_dir, Path.expand("../../priv/data", __DIR__))
|
||||
assert String.starts_with?(data_dir, Application.fetch_env!(:bds, :default_content_root))
|
||||
end
|
||||
|
||||
test "project locations are recorded in a machine-local registry under private_dir", %{
|
||||
temp_root: temp_root
|
||||
} do
|
||||
external_dir = Path.join(temp_root, "registry-blog")
|
||||
File.mkdir_p!(external_dir)
|
||||
|
||||
assert {:ok, project} =
|
||||
BDS.Projects.create_project(%{name: "Registry Blog", data_path: external_dir})
|
||||
|
||||
registry = BDS.Projects.project_registry()
|
||||
assert registry[project.id] == external_dir
|
||||
assert String.starts_with?(BDS.Projects.registry_path(), BDS.Projects.private_dir())
|
||||
|
||||
assert {:ok, _deleted} = BDS.Projects.delete_project(project.id)
|
||||
refute Map.has_key?(BDS.Projects.project_registry(), project.id)
|
||||
end
|
||||
|
||||
test "delete_project rejects the default and active projects", %{temp_root: temp_root} do
|
||||
Repo.delete_all(Project)
|
||||
|
||||
|
||||
@@ -2,6 +2,16 @@ cache_root = Path.join(System.tmp_dir!(), "bds-test-cache-#{System.unique_intege
|
||||
File.mkdir_p!(cache_root)
|
||||
Application.put_env(:bds, :project_cache_root, cache_root)
|
||||
|
||||
# Public, user-owned default project content lives under a per-user default
|
||||
# content location — never the repo or the private app dir. Tests redirect it
|
||||
# to a throwaway temp dir so first-launch defaults never touch the developer's
|
||||
# home directory.
|
||||
content_root =
|
||||
Path.join(System.tmp_dir!(), "bds-test-content-#{System.unique_integer([:positive])}")
|
||||
|
||||
File.mkdir_p!(content_root)
|
||||
Application.put_env(:bds, :default_content_root, content_root)
|
||||
|
||||
Enum.each(["LC_ALL", "LC_MESSAGES", "LANG"], fn variable ->
|
||||
System.put_env(variable, "en_US.UTF-8")
|
||||
end)
|
||||
@@ -10,6 +20,7 @@ ExUnit.start()
|
||||
|
||||
ExUnit.after_suite(fn _results ->
|
||||
File.rm_rf(cache_root)
|
||||
File.rm_rf(content_root)
|
||||
end)
|
||||
|
||||
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual)
|
||||
|
||||
Reference in New Issue
Block a user