chore: update

This commit is contained in:
Jesús Pérez 2026-07-18 20:14:32 +01:00
parent 4a90b72576
commit c0281e759c
Signed by: jesus
GPG key ID: 9F243E355E0BC939
47 changed files with 4300 additions and 88 deletions

3
.gitignore vendored
View file

@ -109,3 +109,6 @@ SETUP_COMPLETE.md
# Archive and working directory
.wrks/
# sync-assets manifest — local artefact written by just sync-assets
assets/.sync-manifest.json

View file

@ -140,8 +140,9 @@ repos:
exclude: ^\.woodpecker/
- id: end-of-file-fixer
exclude: ^features/.*/site/public/js/
- id: trailing-whitespace
exclude: \.md$
exclude: (\.md$|^features/.*/site/public/js/)
- id: mixed-line-ending

View file

@ -1,84 +1,51 @@
# Woodpecker CI Pipeline
# Equivalent to GitHub Actions CI workflow
# Generated by dev-system/ci
# Generated by ore workflow generate — layer: ci-standard
# Source: .ontology/workflow.ncl
# Do not edit manually — regenerate with: ore workflow generate --layer ci-standard
when:
event: [push, pull_request, manual]
branch:
- main
- develop
steps:
# === LINTING ===
lint-rust:
rust-clippy-all:
image: rust:latest
commands:
- curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
- rustup component add clippy
- cargo fmt --all -- --check
- cargo clippy --all-targets -- -D warnings
- cargo clippy --all-targets --all-features -- -D warnings
lint-bash:
image: koalaman/shellcheck-alpine:stable
commands:
- apk add --no-cache curl bash
- find . -name '*.sh' -type f ! -path './target/*' -exec shellcheck {} +
lint-nickel:
nickel-typecheck:
image: rust:latest
commands:
- cargo install nickel-lang-cli --locked
- find . -name '*.ncl' -type f ! -path './target/*' -exec nickel typecheck {} \;
- nickel typecheck
lint-nushell:
nushell-check:
image: rust:latest
commands:
- cargo install nu --locked
- find . -name '*.nu' -type f ! -path './target/*' -exec nu --ide-check 100 {} \;
- find . -name '*.nu' ! -path '*/target/*' -print0 | xargs -0 -I\{\} nu --ide-check 100 \{\}
lint-markdown:
image: node:alpine
commands:
- npm install -g markdownlint-cli2
- markdownlint-cli2 '**/*.md' '#node_modules' '#target'
# === TESTING ===
test:
nextest-ci:
image: rust:latest
commands:
- cargo test --workspace --all-features
depends_on:
- lint-rust
- lint-bash
- lint-nickel
- lint-nushell
- lint-markdown
- cargo install cargo-nextest --locked
- cargo nextest run --all-features --workspace --profile ci --cargo-profile ci
depends_on: ["rust-clippy-all", "nickel-typecheck", "nushell-check"]
environment:
RUST_BACKTRACE: 1
# === BUILD ===
build:
image: rust:latest
commands:
- cargo build --release
depends_on:
- test
# === SECURITY ===
security-audit:
image: rust:latest
commands:
- cargo install cargo-audit --locked
- cargo audit --deny warnings
depends_on:
- lint-rust
license-check:
deny-subset:
image: rust:latest
commands:
- cargo install cargo-deny --locked
- cargo deny check licenses advisories
depends_on:
- lint-rust
docs-check:
image: rust:latest
commands:
- RUSTDOCFLAGS="-D rustdoc::broken-intra-doc-links -D rustdoc::private-intra-doc-links" cargo doc --no-deps --workspace --profile ci -q
depends_on: ["rust-clippy-all", "nickel-typecheck", "nushell-check"]
build-release-native:
image: rust:latest
commands:
- cargo build --release --workspace
depends_on: ["rust-clippy-all", "nickel-typecheck", "nushell-check"]

View file

@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Website templates + generator (2026-05-28)**: Two render-mode project templates and an interactive generator, modelled on the living jpl-website site.
- `templates/website-htmx-ssr/`, `templates/website-leptos/` — sanitized snapshots produced by a reproducible extractor (`scripts/generator/extract-template.nu`): rsync + prune + binary-safe sanitize + seed overlay; neutral `website-*` crate names; deploy identity defaults to `example.com` with a `TEMPLATE-SETUP.md` checklist. Both carry the full workspace (render profile is a build concern: features + cfg + Dockerfile + `rendering.ncl`).
- `scripts/generator/new-site.nu` + `templates/options.ncl` + `just site::new` — interactive (or `--config`/`--yes`) wizard: picks the template via the `templates.json` registry (now carrying `render_mode`), creates the site as a framework sibling, renames crates to the project, sets domain, and stamps ontoref onboarding. Verified: a generated site's full `cargo metadata` resolves ~700 packages via `../rustelo`.
- `scripts/generator/onto-onboard.nu` + `templates/_ontoref-skeleton/` — ontoref onboarding levels (full/minimal/none) that delegate ontology scaffolding to `ontoref setup` and write a `.rustelo.ontoref` domain pointer (reference, not embed).
- `templates/shared/public/scripts/` — htmx-ssr integration glue promoted from jpl-website (`htmx-reinit.js`, `reinit-handlers.js`, `theme-init.js`, `highlight-utils.js`, `tag-filters.js`); the htmx library + extensions already lived under `templates/shared/htmx/`.
- `just site::extract` — re-snapshot both templates from the living model.
- **ADR-006** — Rendering Profile Axis: HTMX-SSR Coexisting with Leptos Hydration.
- **ADR-007** — Website Templates as Reproducible Snapshots + a Generator that Delegates (snapshot model, neutral crate names, delegated ontoref onboarding, self-contained trees superseding init.rs assembly).
### Removed
- Obsolete Leptos-only templates (basic/cms/content-website/enterprise/minimal/website-full) removed from `templates/` and the registry; `just site::new` is the single scaffolding path. A reference copy of the old trees is kept under `.coder/archive/templates/`.
- **Legacy `cargo rustelo init` scaffolding removed** from `rustelo_cli`: deleted `commands/init.rs`, `templates.rs`, `commands/remote.rs`, `embedded.rs`, the `Commands::Init` variant + match arm, and the now-orphaned `generate` feature (+ optional deps tera/quote/syn/proc-macro2). The remaining `cargo rustelo` commands (build/dev/update/assets/pipeline/cross) are unchanged. Verified: `cargo check -p rustelo_cli` and `cargo clippy -p rustelo_cli --no-deps -- -D warnings` both clean.
- Framework landing (`assets/web/src/index.html`) updated to present both render modes (added a "Two Render Modes" card).
- **Architecture Self-Description (on+re)**: Integrated Ontoref protocol across framework and implementation
- `.ontology/core.ncl` — knowledge graph with axioms (config-driven, language-agnostic, no-framework-forks), tensions, and practices (build-time codegen, custom routing, layered override, plugin architecture, auth framework, component library, page library, feature system, CSS theme system)
- `.ontology/state.ncl` — state dimensions tracking framework-maturity (feature-complete → api-stable), plugin-system (L5 → L8), template-system (designed → operational)
- `.ontology/gate.ncl` — four architecture membranes: routing-integrity (Closed), code-quality (Closed), configuration-driven (Low permeability), framework-boundary (Closed)
- `.ontology/manifest.ncl` — Library manifest with Developer, Downstream, and Agent consumption modes; framework-core, templates, features, and self-description layers
- `reflection/` — backlog, constraints, defaults, qa, schema, and operational modes (integrity-check, sync-ontology, validate-pap)
- **ADR System**: Four Architecture Decision Records in typed Nickel NCL with contract validation
- `adr-001` — Build-Time Code Generation Pattern (`include!(concat!(env!("OUT_DIR"), ...))`)
- `adr-002` — Custom Routing System over Leptos Router
- `adr-003` — Layered Override System (Local > Feature > Template > Framework precedence)
- `adr-004` — Compile-Time Plugin Architecture (Level 5) with documented Level 8 upgrade path
- `adr-005` — Auth Framework as Hexagonal Port over Foundation (`rustelo_auth` re-exports from `rustelo_core_lib::auth`; constraints: consumers-use-port, no-auth-impl-in-framework-crate)
- **on+re migration (2026-04)**: Ontology updated to reflect current codebase state
- `core.ncl` — artifact path `site/config/` corrected to `resources/nickel/routes/`; `auth-framework` description updated to hexagonal port; `unified-template-system` updated (CLI wired)
- `state.ncl``template-system` and `framework-maturity` blockers/catalysts updated
### Fixed
- **rustelo_server**: Resolved 302 compilation errors after dependency updates
- Removed duplicate module declarations (`auth`, `content`) in lib.rs

View file

@ -1,11 +1,11 @@
<div align="center">
<img src="logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="450" />
<img src="assets/logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="450" />
</div>
A unified, modern, and secure **Rust** platform to build, deploy, and deliver scalable, high-performance, and reactive web applications with integrated content management, user authentication, multilingual UI, email services, CI/CD pipelines, and comprehensive tooling from development to production.
<div align="left">
<img src="logos/rustelo-imag.svg" alt="RUSTELO" width="50" />
<img src="assets/logos/rustelo-imag.svg" alt="RUSTELO" width="50" />
</div>
>[!NOTE]
@ -281,6 +281,26 @@ A unified, modern, and secure **Rust** platform to build, deploy, and deliver sc
**New to Rustelo?** Check out our **[Quick Start Guide](QUICK_START.md)** for a complete walkthrough!
### Generate a new website (recommended for new sites)
Scaffold a fresh website from a render-mode template (snapshot of the reference site):
```bash
# Interactive — asks project name, render mode, languages, domain, ontoref level
just site::new
# Non-interactive
just site::new --project acme --render-mode htmx-ssr --domain acme.dev --ontoref minimal --yes
```
Render modes:
- `htmx-ssr` — server-rendered HTML + HTMX, no WASM.
- `leptos-hydration` — SSR + WASM hydration.
The site is created as a sibling of this framework (so its `../rustelo` path deps
resolve), with crates renamed to your project and a `SETUP.md` listing what to complete.
`just site::extract` re-snapshots the templates from the living model. See ADR-007.
### Option 1: One-Command Setup (Recommended)
```bash
# Clone and install everything automatically
@ -563,9 +583,46 @@ rustelo_core_lib::load_resources_from_config()?;
### Documentation
- [Plugin Architecture Guide](../docs/architecture/rustelo-plugin-architecture.md)
- [Plugin Development Guide](./.coder/info/PHASE3-PLUGIN-DEVELOPMENT-GUIDE.md)
- [Example Plugin](../website/website-impl/crates/plugin-example-theme/)
- [Plugin Architecture Guide](./docs/architecture/rustelo-plugin-architecture.md)
- [ADR-004: Plugin Architecture Level 5](./adrs/adr-004-plugin-architecture-level5.ncl)
- [Example Plugin](./examples/plugin-example-theme/)
## 🔍 Architecture Self-Description (on+re)
Rustelo is **self-describing** via the [Ontoref](https://github.com/ontoref) `on+re` protocol. The `.ontology/` and `reflection/` directories form a machine- and agent-readable knowledge graph of the framework's architecture, current state, and invariants.
### What's in `.ontology/`
| File | Purpose |
|------|---------|
| `core.ncl` | Knowledge graph: axioms, tensions, practices, edges |
| `state.ncl` | State dimensions with current/desired states and transitions |
| `gate.ncl` | Architecture membranes (routing integrity, config-driven gate, framework boundary) |
| `manifest.ncl` | Consumer modes, layer definitions, operational modes |
### ADR System (`adrs/`)
Architecture Decision Records in typed NCL with contract validation:
- `adr-001` — Build-Time Code Generation Pattern
- `adr-002` — Custom Routing over Leptos Router
- `adr-003` — Layered Override System
- `adr-004` — Compile-Time Plugin Architecture (Level 5) with Runtime Upgrade Path
### How Agents Use This
An agent reading `.ontology/core.ncl` knows before touching any code:
- Which axioms are invariant (routing integrity, config-driven, no framework forks)
- Which tensions exist and how they're resolved
- Which practices are operative and their artifact paths
- Which gates reject changes without justification
```bash
# Browse framework architecture
nickel export .ontology/core.ncl
nickel export .ontology/state.ncl # current state vs desired state
nickel export .ontology/gate.ncl # active membranes and protocols
```
## 🏗️ Project Structure
@ -603,13 +660,17 @@ template/
## 🚀 Development
### Prerequisites
- Rust 1.75+
- Node.js 18+ (for frontend tooling)
- PostgreSQL (if using database features)
- **mdBook** (for documentation) - `cargo install mdbook`
- **Just** (task runner) - `cargo install just`
- **Rust 1.75+** - Core language and toolchain
- **Node.js 18+** - Frontend tooling and asset processing
- **Nickel** (Configuration language) - **REQUIRED** for type-safe configuration
- macOS: `brew install nickel`
- Linux/Windows: `cargo install nickel-lang-cli`
- See [Nickel Installation Guide](../docs/guides/nickel-installation.md)
- **PostgreSQL** - Required if using database features (`auth` or `content-db`)
- **mdBook** - Documentation generation - `cargo install mdbook`
- **Just** - Task runner - `cargo install just`
> **Note**: All tools are automatically installed by `./scripts/install.sh`. For manual setup, see [INSTALL.md](INSTALL.md).
> **Note**: Nickel is mandatory for Rustelo projects. All configuration files (routes, themes, menus, content types) use NCL format for type-safety and DRY principles. See [Why Nickel?](../docs/adr/0002-nickel-configuration-language.md) and [Configuration Guide](../docs/guides/nickel-configuration-guide.md).
### Quick Setup
```bash

View file

@ -0,0 +1,14 @@
[package]
name = "content-graph"
version = "0.1.0"
edition = "2021"
[workspace]
[lib]
path = "src/lib.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
glob = "0.3"

View file

@ -0,0 +1,65 @@
[feature]
name = "content-graph"
version = "0.1.0"
source = "rustelo"
description = "Knowledge graph connecting content items with each other and with on+re ontology nodes. Generates content_graph.json at build time and provides Cytoscape-powered interactive graph views plus SSR-safe RelatedContent and OntologyContext components."
requires = []
[dependencies]
workspace = ["serde", "serde_json"]
external = ["glob = '0.3'"]
[[environment.variables]]
name = "CONTENT_GRAPH_CONTENT_DIR"
default = "site/content"
required = false
[[environment.variables]]
name = "CONTENT_GRAPH_NICKEL_SCHEMA_DIR"
default = "rustelo/nickel"
required = false
[[environment.variables]]
name = "CONTENT_GRAPH_FRAMEWORK_ONTOLOGY"
default = ""
required = true
[[environment.variables]]
name = "CONTENT_GRAPH_IMPL_ONTOLOGY"
default = ".ontology/core.ncl"
required = false
[[environment.variables]]
name = "CONTENT_GRAPH_FRAMEWORK_ADRS_GLOB"
default = ""
required = true
[[environment.variables]]
name = "CONTENT_GRAPH_IMPL_ADRS_GLOB"
default = "adrs/adr-*.ncl"
required = false
[[environment.variables]]
name = "ONTOREF_NICKEL_IMPORT_PATH"
default = ""
required = true
[configuration]
files = [
{ path = "site/config/assets_content_graph.ncl", template = "templates/assets_ncl_fragment.ncl" },
]
[resources]
public = [
{ from = "js/cytoscape-shim.js", to = "public/js/content-graph-shim.js" },
]
[node]
scripts = [
{ name = "cytoscape:download", command = "node scripts/download/download-cytoscape.js" },
]
build_all_hook = "cytoscape:download"
[[scripts]]
from = "scripts/download/download-cytoscape.js"
to = "scripts/download/download-cytoscape.js"

View file

@ -0,0 +1,262 @@
/**
* content-graph-shim.js
*
* Thin bridge between Leptos/WASM and Cytoscape.js.
* Exposes window.ContentGraph with a single render() entry point.
*
* Called from GraphView component after mount:
* window.ContentGraph.render(containerId, focusNodeId, theme)
*
* Reads graph data from:
* <script type="application/json" id="content-graph-data">{ nodes, edges }</script>
*
* Requires (loaded before this script via assets pipeline):
* - cytoscape.min.js
* - cytoscape-cose-bilkent.min.js
*/
(function () {
'use strict';
// ── Theme tokens ────────────────────────────────────────────────────────────
function buildPalette(isDark) {
return {
// Content node variants
contentFill: isDark ? '#3b82f6' : '#2563eb',
// Ontology node variants by level
axiomFill: isDark ? '#8b5cf6' : '#7c3aed',
tensionFill: isDark ? '#f59e0b' : '#d97706',
practiceFill: isDark ? '#10b981' : '#059669',
projectFill: isDark ? '#06b6d4' : '#0891b2',
// ADR
adrFill: isDark ? '#f97316' : '#ea580c',
// Ego node ring
egoStroke: isDark ? '#facc15' : '#ca8a04',
// Edge colours by kind group
declaredEdge: isDark ? '#6b7280' : '#9ca3af',
autoEdge: isDark ? '#4b5563' : '#d1d5db',
implicitEdge: isDark ? '#374151' : '#e5e7eb',
// Labels
labelColor: isDark ? '#f9fafb' : '#111827',
labelBg: isDark ? 'rgba(17,24,39,0.75)' : 'rgba(255,255,255,0.75)',
// Background
bg: isDark ? '#111827' : '#ffffff',
};
}
// ── Cytoscape stylesheet ─────────────────────────────────────────────────────
function buildStylesheet(palette, egoId) {
const label = {
label: 'data(title)',
color: palette.labelColor,
'background-color':palette.labelBg,
'background-opacity': 0.75,
'background-padding': '3px',
'font-size': '11px',
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': '4px',
};
return [
// Default node
{
selector: 'node',
style: {
...label,
shape: 'ellipse',
width: '36px',
height: '36px',
'background-color': palette.contentFill,
'border-width': '0px',
'cursor': 'pointer',
},
},
// Ego node (focused)
{
selector: `node[id="${egoId}"]`,
style: {
width: '48px',
height: '48px',
'border-width': '3px',
'border-color': palette.egoStroke,
'font-size': '13px',
'font-weight': 'bold',
},
},
// Ontology variants
{ selector: 'node[level="Axiom"]', style: { 'background-color': palette.axiomFill, shape: 'diamond' } },
{ selector: 'node[level="Tension"]', style: { 'background-color': palette.tensionFill, shape: 'triangle' } },
{ selector: 'node[level="Practice"]', style: { 'background-color': palette.practiceFill, shape: 'rectangle' } },
{ selector: 'node[level="Project"]', style: { 'background-color': palette.projectFill, shape: 'rectangle' } },
// ADR
{ selector: 'node[type="adr"]', style: { 'background-color': palette.adrFill, shape: 'pentagon' } },
// Hover
{
selector: 'node:selected',
style: { 'border-width': '3px', 'border-color': palette.egoStroke },
},
// Default edge
{
selector: 'edge',
style: {
width: '1.5px',
'line-color': palette.declaredEdge,
'target-arrow-color':palette.declaredEdge,
'target-arrow-shape':'triangle',
'curve-style': 'bezier',
opacity: 0.7,
},
},
// Auto-backlink edges (lighter)
{
selector: 'edge[source="auto_backlink"]',
style: { 'line-color': palette.autoEdge, 'target-arrow-color': palette.autoEdge, opacity: 0.5 },
},
// Implicit / SharedTag edges (dashed, very light)
{
selector: 'edge[kind="shared_tag"]',
style: {
'line-style': 'dashed',
'line-color': palette.implicitEdge,
'target-arrow-color':palette.implicitEdge,
opacity: 0.35,
},
},
];
}
// ── Element conversion ───────────────────────────────────────────────────────
function toCytoscapeElements(graphData) {
const nodes = (graphData.nodes || []).map(n => ({
data: {
id: n.id,
title: n.title || n.name || n.id,
type: n.type,
level: n.level || null,
url: n.url || null,
instance: n.instance || null,
},
}));
const edges = (graphData.edges || []).map((e, i) => ({
data: {
id: `e-${i}`,
source: e.from,
target: e.to,
kind: e.kind,
source_type: e.source, // "declared" | "auto_backlink" | "implicit"
},
}));
return [...nodes, ...edges];
}
// ── Layout config ────────────────────────────────────────────────────────────
function buildLayout(nodeCount) {
// cose-bilkent for larger graphs; grid fallback for tiny graphs
if (nodeCount <= 4) {
return { name: 'circle', animate: false, padding: 40 };
}
return {
name: 'cose-bilkent',
animate: false,
nodeDimensionsIncludeLabels: true,
idealEdgeLength: 100,
nodeRepulsion: 8000,
padding: 30,
};
}
// ── Instances registry ───────────────────────────────────────────────────────
// Keeps track of active cy instances so they can be destroyed on re-render.
const _instances = new Map();
// ── Public API ───────────────────────────────────────────────────────────────
window.ContentGraph = {
/**
* Render the knowledge graph into `containerId`.
*
* @param {string} containerId - DOM id of the container element
* @param {string} focusNodeId - node id to highlight/center; may be empty
* @param {string} theme - "dark" | "light"
*/
render(containerId, focusNodeId, theme) {
const container = document.getElementById(containerId);
if (!container) {
console.warn('[ContentGraph] container not found:', containerId);
return;
}
// Destroy previous instance if re-rendering
if (_instances.has(containerId)) {
_instances.get(containerId).destroy();
_instances.delete(containerId);
}
// Read graph data embedded by SSR
const dataEl = document.getElementById('content-graph-data');
if (!dataEl) {
console.warn('[ContentGraph] #content-graph-data not found');
return;
}
let graphData;
try {
graphData = JSON.parse(dataEl.textContent);
} catch (e) {
console.error('[ContentGraph] failed to parse graph data:', e);
return;
}
const isDark = theme === 'dark';
const palette = buildPalette(isDark);
const egoId = focusNodeId || '';
const cy = window.cytoscape({
container,
elements: toCytoscapeElements(graphData),
style: buildStylesheet(palette, egoId),
layout: buildLayout((graphData.nodes || []).length),
minZoom: 0.3,
maxZoom: 4,
});
_instances.set(containerId, cy);
// Navigate to content URL on node tap
cy.on('tap', 'node', evt => {
const url = evt.target.data('url');
if (url) window.location.href = url;
});
// Pointer cursor on hover
cy.on('mouseover', 'node[?url]', () => { container.style.cursor = 'pointer'; });
cy.on('mouseout', 'node', () => { container.style.cursor = 'default'; });
// Focus and center on ego node
if (egoId) {
const egoNode = cy.$(`#${CSS.escape(egoId)}`);
if (egoNode.length) {
cy.animate({ fit: { eles: egoNode.neighborhood().add(egoNode), padding: 60 } }, { duration: 400 });
}
}
},
/**
* Destroy the Cytoscape instance for a given container (cleanup on unmount).
*/
destroy(containerId) {
if (_instances.has(containerId)) {
_instances.get(containerId).destroy();
_instances.delete(containerId);
}
},
};
})();

View file

@ -0,0 +1,76 @@
#!/usr/bin/env node
// Download Cytoscape.js + cose-bilkent layout plugin from CDN.
// Skips download if files exist and are < 30 days old.
//
// Usage:
// node scripts/download/download-cytoscape.js
import fs from 'fs';
import path from 'path';
import https from 'https';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC_JS = path.join(__dirname, '../../site/public/js');
const ASSETS = [
{
url: 'https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.29.2/cytoscape.min.js',
out: 'cytoscape.min.js',
name: 'Cytoscape.js 3.29.2',
},
{
url: 'https://cdn.jsdelivr.net/npm/cytoscape-cose-bilkent@4.1.0/cytoscape-cose-bilkent.js',
out: 'cytoscape-cose-bilkent.js',
name: 'cose-bilkent layout 4.1.0',
},
];
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
function download(url, dest) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
return download(res.headers.location, dest).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode}: ${url}`));
}
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.concat(chunks));
resolve();
});
}).on('error', reject);
});
}
async function main() {
for (const asset of ASSETS) {
const dest = path.join(PUBLIC_JS, asset.out);
if (fs.existsSync(dest)) {
const age = Date.now() - fs.statSync(dest).mtimeMs;
if (age < MAX_AGE_MS) {
const kb = (fs.statSync(dest).size / 1024).toFixed(1);
console.log(`skip ${asset.name} (${kb} kB, cached)`);
continue;
}
}
process.stdout.write(`get ${asset.name} ... `);
try {
await download(asset.url, dest);
const kb = (fs.statSync(dest).size / 1024).toFixed(1);
console.log(`ok (${kb} kB)`);
} catch (err) {
console.error(`\nfail ${asset.name}: ${err.message}`);
process.exit(1);
}
}
}
main();

View file

@ -0,0 +1,458 @@
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory(require("cose-base"));
else if(typeof define === 'function' && define.amd)
define(["cose-base"], factory);
else if(typeof exports === 'object')
exports["cytoscapeCoseBilkent"] = factory(require("cose-base"));
else
root["cytoscapeCoseBilkent"] = factory(root["coseBase"]);
})(this, function(__WEBPACK_EXTERNAL_MODULE_0__) {
return /******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // identity function for calling harmony imports with the correct context
/******/ __webpack_require__.i = function(value) { return value; };
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 1);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports) {
module.exports = __WEBPACK_EXTERNAL_MODULE_0__;
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
var LayoutConstants = __webpack_require__(0).layoutBase.LayoutConstants;
var FDLayoutConstants = __webpack_require__(0).layoutBase.FDLayoutConstants;
var CoSEConstants = __webpack_require__(0).CoSEConstants;
var CoSELayout = __webpack_require__(0).CoSELayout;
var CoSENode = __webpack_require__(0).CoSENode;
var PointD = __webpack_require__(0).layoutBase.PointD;
var DimensionD = __webpack_require__(0).layoutBase.DimensionD;
var defaults = {
// Called on `layoutready`
ready: function ready() {},
// Called on `layoutstop`
stop: function stop() {},
// 'draft', 'default' or 'proof"
// - 'draft' fast cooling rate
// - 'default' moderate cooling rate
// - "proof" slow cooling rate
quality: 'default',
// include labels in node dimensions
nodeDimensionsIncludeLabels: false,
// number of ticks per frame; higher is faster but more jerky
refresh: 30,
// Whether to fit the network view after when done
fit: true,
// Padding on fit
padding: 10,
// Whether to enable incremental mode
randomize: true,
// Node repulsion (non overlapping) multiplier
nodeRepulsion: 4500,
// Ideal edge (non nested) length
idealEdgeLength: 50,
// Divisor to compute edge forces
edgeElasticity: 0.45,
// Nesting factor (multiplier) to compute ideal edge length for nested edges
nestingFactor: 0.1,
// Gravity force (constant)
gravity: 0.25,
// Maximum number of iterations to perform
numIter: 2500,
// For enabling tiling
tile: true,
// Type of layout animation. The option set is {'during', 'end', false}
animate: 'end',
// Duration for animate:end
animationDuration: 500,
// Represents the amount of the vertical space to put between the zero degree members during the tiling operation(can also be a function)
tilingPaddingVertical: 10,
// Represents the amount of the horizontal space to put between the zero degree members during the tiling operation(can also be a function)
tilingPaddingHorizontal: 10,
// Gravity range (constant) for compounds
gravityRangeCompound: 1.5,
// Gravity force (constant) for compounds
gravityCompound: 1.0,
// Gravity range (constant)
gravityRange: 3.8,
// Initial cooling factor for incremental layout
initialEnergyOnIncremental: 0.5
};
function extend(defaults, options) {
var obj = {};
for (var i in defaults) {
obj[i] = defaults[i];
}
for (var i in options) {
obj[i] = options[i];
}
return obj;
};
function _CoSELayout(_options) {
this.options = extend(defaults, _options);
getUserOptions(this.options);
}
var getUserOptions = function getUserOptions(options) {
if (options.nodeRepulsion != null) CoSEConstants.DEFAULT_REPULSION_STRENGTH = FDLayoutConstants.DEFAULT_REPULSION_STRENGTH = options.nodeRepulsion;
if (options.idealEdgeLength != null) CoSEConstants.DEFAULT_EDGE_LENGTH = FDLayoutConstants.DEFAULT_EDGE_LENGTH = options.idealEdgeLength;
if (options.edgeElasticity != null) CoSEConstants.DEFAULT_SPRING_STRENGTH = FDLayoutConstants.DEFAULT_SPRING_STRENGTH = options.edgeElasticity;
if (options.nestingFactor != null) CoSEConstants.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR = FDLayoutConstants.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR = options.nestingFactor;
if (options.gravity != null) CoSEConstants.DEFAULT_GRAVITY_STRENGTH = FDLayoutConstants.DEFAULT_GRAVITY_STRENGTH = options.gravity;
if (options.numIter != null) CoSEConstants.MAX_ITERATIONS = FDLayoutConstants.MAX_ITERATIONS = options.numIter;
if (options.gravityRange != null) CoSEConstants.DEFAULT_GRAVITY_RANGE_FACTOR = FDLayoutConstants.DEFAULT_GRAVITY_RANGE_FACTOR = options.gravityRange;
if (options.gravityCompound != null) CoSEConstants.DEFAULT_COMPOUND_GRAVITY_STRENGTH = FDLayoutConstants.DEFAULT_COMPOUND_GRAVITY_STRENGTH = options.gravityCompound;
if (options.gravityRangeCompound != null) CoSEConstants.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR = FDLayoutConstants.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR = options.gravityRangeCompound;
if (options.initialEnergyOnIncremental != null) CoSEConstants.DEFAULT_COOLING_FACTOR_INCREMENTAL = FDLayoutConstants.DEFAULT_COOLING_FACTOR_INCREMENTAL = options.initialEnergyOnIncremental;
if (options.quality == 'draft') LayoutConstants.QUALITY = 0;else if (options.quality == 'proof') LayoutConstants.QUALITY = 2;else LayoutConstants.QUALITY = 1;
CoSEConstants.NODE_DIMENSIONS_INCLUDE_LABELS = FDLayoutConstants.NODE_DIMENSIONS_INCLUDE_LABELS = LayoutConstants.NODE_DIMENSIONS_INCLUDE_LABELS = options.nodeDimensionsIncludeLabels;
CoSEConstants.DEFAULT_INCREMENTAL = FDLayoutConstants.DEFAULT_INCREMENTAL = LayoutConstants.DEFAULT_INCREMENTAL = !options.randomize;
CoSEConstants.ANIMATE = FDLayoutConstants.ANIMATE = LayoutConstants.ANIMATE = options.animate;
CoSEConstants.TILE = options.tile;
CoSEConstants.TILING_PADDING_VERTICAL = typeof options.tilingPaddingVertical === 'function' ? options.tilingPaddingVertical.call() : options.tilingPaddingVertical;
CoSEConstants.TILING_PADDING_HORIZONTAL = typeof options.tilingPaddingHorizontal === 'function' ? options.tilingPaddingHorizontal.call() : options.tilingPaddingHorizontal;
};
_CoSELayout.prototype.run = function () {
var ready;
var frameId;
var options = this.options;
var idToLNode = this.idToLNode = {};
var layout = this.layout = new CoSELayout();
var self = this;
self.stopped = false;
this.cy = this.options.cy;
this.cy.trigger({ type: 'layoutstart', layout: this });
var gm = layout.newGraphManager();
this.gm = gm;
var nodes = this.options.eles.nodes();
var edges = this.options.eles.edges();
this.root = gm.addRoot();
this.processChildrenList(this.root, this.getTopMostNodes(nodes), layout);
for (var i = 0; i < edges.length; i++) {
var edge = edges[i];
var sourceNode = this.idToLNode[edge.data("source")];
var targetNode = this.idToLNode[edge.data("target")];
if (sourceNode !== targetNode && sourceNode.getEdgesBetween(targetNode).length == 0) {
var e1 = gm.add(layout.newEdge(), sourceNode, targetNode);
e1.id = edge.id();
}
}
var getPositions = function getPositions(ele, i) {
if (typeof ele === "number") {
ele = i;
}
var theId = ele.data('id');
var lNode = self.idToLNode[theId];
return {
x: lNode.getRect().getCenterX(),
y: lNode.getRect().getCenterY()
};
};
/*
* Reposition nodes in iterations animatedly
*/
var iterateAnimated = function iterateAnimated() {
// Thigs to perform after nodes are repositioned on screen
var afterReposition = function afterReposition() {
if (options.fit) {
options.cy.fit(options.eles, options.padding);
}
if (!ready) {
ready = true;
self.cy.one('layoutready', options.ready);
self.cy.trigger({ type: 'layoutready', layout: self });
}
};
var ticksPerFrame = self.options.refresh;
var isDone;
for (var i = 0; i < ticksPerFrame && !isDone; i++) {
isDone = self.stopped || self.layout.tick();
}
// If layout is done
if (isDone) {
// If the layout is not a sublayout and it is successful perform post layout.
if (layout.checkLayoutSuccess() && !layout.isSubLayout) {
layout.doPostLayout();
}
// If layout has a tilingPostLayout function property call it.
if (layout.tilingPostLayout) {
layout.tilingPostLayout();
}
layout.isLayoutFinished = true;
self.options.eles.nodes().positions(getPositions);
afterReposition();
// trigger layoutstop when the layout stops (e.g. finishes)
self.cy.one('layoutstop', self.options.stop);
self.cy.trigger({ type: 'layoutstop', layout: self });
if (frameId) {
cancelAnimationFrame(frameId);
}
ready = false;
return;
}
var animationData = self.layout.getPositionsData(); // Get positions of layout nodes note that all nodes may not be layout nodes because of tiling
// Position nodes, for the nodes whose id does not included in data (because they are removed from their parents and included in dummy compounds)
// use position of their ancestors or dummy ancestors
options.eles.nodes().positions(function (ele, i) {
if (typeof ele === "number") {
ele = i;
}
// If ele is a compound node, then its position will be defined by its children
if (!ele.isParent()) {
var theId = ele.id();
var pNode = animationData[theId];
var temp = ele;
// If pNode is undefined search until finding position data of its first ancestor (It may be dummy as well)
while (pNode == null) {
pNode = animationData[temp.data('parent')] || animationData['DummyCompound_' + temp.data('parent')];
animationData[theId] = pNode;
temp = temp.parent()[0];
if (temp == undefined) {
break;
}
}
if (pNode != null) {
return {
x: pNode.x,
y: pNode.y
};
} else {
return {
x: ele.position('x'),
y: ele.position('y')
};
}
}
});
afterReposition();
frameId = requestAnimationFrame(iterateAnimated);
};
/*
* Listen 'layoutstarted' event and start animated iteration if animate option is 'during'
*/
layout.addListener('layoutstarted', function () {
if (self.options.animate === 'during') {
frameId = requestAnimationFrame(iterateAnimated);
}
});
layout.runLayout(); // Run cose layout
/*
* If animate option is not 'during' ('end' or false) perform these here (If it is 'during' similar things are already performed)
*/
if (this.options.animate !== "during") {
self.options.eles.nodes().not(":parent").layoutPositions(self, self.options, getPositions); // Use layout positions to reposition the nodes it considers the options parameter
ready = false;
}
return this; // chaining
};
//Get the top most ones of a list of nodes
_CoSELayout.prototype.getTopMostNodes = function (nodes) {
var nodesMap = {};
for (var i = 0; i < nodes.length; i++) {
nodesMap[nodes[i].id()] = true;
}
var roots = nodes.filter(function (ele, i) {
if (typeof ele === "number") {
ele = i;
}
var parent = ele.parent()[0];
while (parent != null) {
if (nodesMap[parent.id()]) {
return false;
}
parent = parent.parent()[0];
}
return true;
});
return roots;
};
_CoSELayout.prototype.processChildrenList = function (parent, children, layout) {
var size = children.length;
for (var i = 0; i < size; i++) {
var theChild = children[i];
var children_of_children = theChild.children();
var theNode;
var dimensions = theChild.layoutDimensions({
nodeDimensionsIncludeLabels: this.options.nodeDimensionsIncludeLabels
});
if (theChild.outerWidth() != null && theChild.outerHeight() != null) {
theNode = parent.add(new CoSENode(layout.graphManager, new PointD(theChild.position('x') - dimensions.w / 2, theChild.position('y') - dimensions.h / 2), new DimensionD(parseFloat(dimensions.w), parseFloat(dimensions.h))));
} else {
theNode = parent.add(new CoSENode(this.graphManager));
}
// Attach id to the layout node
theNode.id = theChild.data("id");
// Attach the paddings of cy node to layout node
theNode.paddingLeft = parseInt(theChild.css('padding'));
theNode.paddingTop = parseInt(theChild.css('padding'));
theNode.paddingRight = parseInt(theChild.css('padding'));
theNode.paddingBottom = parseInt(theChild.css('padding'));
//Attach the label properties to compound if labels will be included in node dimensions
if (this.options.nodeDimensionsIncludeLabels) {
if (theChild.isParent()) {
var labelWidth = theChild.boundingBox({ includeLabels: true, includeNodes: false }).w;
var labelHeight = theChild.boundingBox({ includeLabels: true, includeNodes: false }).h;
var labelPos = theChild.css("text-halign");
theNode.labelWidth = labelWidth;
theNode.labelHeight = labelHeight;
theNode.labelPos = labelPos;
}
}
// Map the layout node
this.idToLNode[theChild.data("id")] = theNode;
if (isNaN(theNode.rect.x)) {
theNode.rect.x = 0;
}
if (isNaN(theNode.rect.y)) {
theNode.rect.y = 0;
}
if (children_of_children != null && children_of_children.length > 0) {
var theNewGraph;
theNewGraph = layout.getGraphManager().add(layout.newGraph(), theNode);
this.processChildrenList(theNewGraph, children_of_children, layout);
}
}
};
/**
* @brief : called on continuous layouts to stop them before they finish
*/
_CoSELayout.prototype.stop = function () {
this.stopped = true;
return this; // chaining
};
var register = function register(cytoscape) {
// var Layout = getLayout( cytoscape );
cytoscape('layout', 'cose-bilkent', _CoSELayout);
};
// auto reg for globals
if (typeof cytoscape !== 'undefined') {
register(cytoscape);
}
module.exports = register;
/***/ })
/******/ ]);
});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,512 @@
use std::collections::HashMap;
use std::path::Path;
use super::{
node_types::{ContentGraph, EdgeKind, GraphNode, OntologyLevel},
BuildError,
};
// ── Helpers
// ───────────────────────────────────────────────────────────────────
/// Derives a site URL from an ontology node `artifact_paths` entry.
///
/// Expects paths of the form `site/content/{type}/en/{category}/` and returns
/// `/{type}/{category}`. Returns `None` for project/non-content paths.
fn ontology_artifact_to_url(path: &str) -> Option<String> {
let rest = path.strip_prefix("site/content/")?;
let segs: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
// Require at least [content_type, lang, category]
if segs.len() < 3 {
return None;
}
if !["en", "es"].contains(&segs[1]) {
return None;
}
// Drop the language segment; join remaining with content_type prefix.
let mut parts = vec![segs[0]];
parts.extend_from_slice(&segs[2..]);
Some(format!("/{}", parts.join("/")))
}
// ── Public entry point
// ────────────────────────────────────────────────────────
/// Write all generated artefacts to `out_dir`.
///
/// Outputs:
/// - `content_graph.json` — full serialised graph for Cytoscape
/// - `related_map.rs` — static map node_id → related nodes
/// (RelatedContent, no JS)
/// - `ontology_context_map.rs` — static map node_id → connected ontology nodes
/// - `graph_mini_map.rs` — static map node_id → ego SVG string
/// (GraphMini, SSR-safe)
pub fn write_outputs(graph: &ContentGraph, out_dir: &Path) -> Result<(), BuildError> {
write_graph_json(graph, out_dir)?;
write_related_map(graph, out_dir)?;
write_ontology_context_map(graph, out_dir)?;
write_graph_mini_map(graph, out_dir)?;
Ok(())
}
// ── content_graph.json
// ────────────────────────────────────────────────────────
fn write_graph_json(graph: &ContentGraph, out_dir: &Path) -> Result<(), BuildError> {
let json = serde_json::to_string_pretty(graph)?;
std::fs::write(out_dir.join("content_graph.json"), json)?;
Ok(())
}
// ── related_map.rs
// ────────────────────────────────────────────────────────────
/// Generates a phf-style static map:
/// ```rust
/// pub static RELATED_NODES: &[(&str, &[RelatedNode])] = &[...];
/// ```
/// Used by `RelatedContent` component — no JS, fully SSR-safe.
fn write_related_map(graph: &ContentGraph, out_dir: &Path) -> Result<(), BuildError> {
// Build adjacency: node_id → [(related_id, kind_label)]
// Use a HashSet keyed by (related_id, kind_label) to prevent duplicate entries
// from forward + autobacklink edge pairs hitting the same (from, to) pair.
let mut adjacency: HashMap<String, std::collections::HashSet<(String, String)>> =
HashMap::new();
for edge in &graph.edges {
// Skip SharedTag (too noisy) and RelatedFrom (autobacklink of RelatedTo —
// the symmetric clause below already mirrors it into the target's sidebar).
if matches!(edge.kind, EdgeKind::SharedTag | EdgeKind::RelatedFrom) {
continue;
}
let kind_label = format!("{:?}", edge.kind);
adjacency
.entry(edge.from.clone())
.or_default()
.insert((edge.to.clone(), kind_label.clone()));
// RelatedTo is symmetric for display — show in both nodes' sidebars.
if matches!(edge.kind, EdgeKind::RelatedTo) {
adjacency
.entry(edge.to.clone())
.or_default()
.insert((edge.from.clone(), kind_label));
}
}
// Convert sets to sorted vecs for deterministic output.
let adjacency: HashMap<String, Vec<(String, String)>> = adjacency
.into_iter()
.map(|(k, set)| {
let mut v: Vec<_> = set.into_iter().collect();
v.sort();
(k, v)
})
.collect();
// Build node title lookup
let titles: HashMap<&str, &str> = graph.nodes.iter().map(|n| (n.id(), n.title())).collect();
// Build node URL lookup — content nodes use their stored URL; Practice-level
// ontology nodes derive their URL from the first artifact_path if it points
// to a content directory (site/content/{type}/en/{category}/).
let node_urls: HashMap<&str, String> = graph
.nodes
.iter()
.filter_map(|n| match n {
GraphNode::Content { id, url, .. } => Some((id.as_str(), url.clone())),
GraphNode::Ontology {
id,
level: OntologyLevel::Practice,
artifact_paths,
..
} => {
let url = artifact_paths
.first()
.and_then(|p| ontology_artifact_to_url(p))?;
Some((id.as_str(), url))
}
_ => None,
})
.collect();
// Slug → id aliases: content nodes whose URL slug differs from their id.
// PostViewer passes the URL slug as node_id, so the map must also answer to it.
let slug_to_id: HashMap<String, String> = graph
.nodes
.iter()
.filter_map(|n| {
let slug = n.url_slug()?;
if slug != n.id() {
Some((slug.to_owned(), n.id().to_owned()))
} else {
None
}
})
.collect();
let make_entry = |node_id: &str, related: &Vec<(String, String)>| -> String {
let related_entries: Vec<String> = related.iter().map(|(rid, kind)| {
let title = titles.get(rid.as_str()).copied().unwrap_or(rid.as_str());
let url = node_urls.get(rid.as_str()).map(|s| s.as_str()).unwrap_or("");
format!(
r#" RelatedNode {{ id: {id:?}, title: {title:?}, kind: {kind:?}, url: {url:?} }}"#,
id = rid,
title = title,
kind = kind,
url = url,
)
}).collect();
format!(
" ({node_id:?}, &[\n{entries}\n ]),",
node_id = node_id,
entries = related_entries.join(",\n"),
)
};
let mut entries: Vec<String> = adjacency
.iter()
.map(|(node_id, related)| make_entry(node_id, related))
.collect();
// Emit slug-aliased entries for content nodes where URL slug != id
for (slug, id) in &slug_to_id {
if let Some(related) = adjacency.get(id) {
entries.push(make_entry(slug, related));
}
}
entries.sort(); // deterministic output for incremental builds
let code = format!(
r#"// Generated by content-graph feature — do not edit.
pub struct RelatedNode {{
pub id: &'static str,
pub title: &'static str,
pub kind: &'static str,
pub url: &'static str,
}}
pub static RELATED_NODES: &[(&'static str, &'static [RelatedNode])] = &[
{entries}
];
"#,
entries = entries.join("\n"),
);
std::fs::write(out_dir.join("related_map.rs"), code)?;
Ok(())
}
// ── ontology_context_map.rs
// ───────────────────────────────────────────────────
/// Generates:
/// ```rust
/// pub static ONTOLOGY_CONTEXT: &[(&str, &[OntologyRef])] = &[...];
/// ```
/// Used by `OntologyContext` badge component.
fn write_ontology_context_map(graph: &ContentGraph, out_dir: &Path) -> Result<(), BuildError> {
// Find all ontology nodes connected to content nodes
let ontology_ids: std::collections::HashSet<String> = graph
.nodes
.iter()
.filter_map(|n| match n {
GraphNode::Ontology { id, .. } | GraphNode::Adr { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
let ontology_names: HashMap<&str, &str> = graph
.nodes
.iter()
.filter_map(|n| match n {
GraphNode::Ontology { id, name, .. } => Some((id.as_str(), name.as_str())),
GraphNode::Adr { id, title, .. } => Some((id.as_str(), title.as_str())),
_ => None,
})
.collect();
// content_id → [(ontology_id, ontology_name, edge_kind)]
// Only process content→ontology direction (edge.to ∈ ontology_ids).
// Processing edge.from as well would double-add entries because every
// Implements edge has an ImplementedBy autobacklink pointing the other way.
let mut ctx_map: HashMap<String, std::collections::HashSet<(String, String, String)>> =
HashMap::new();
for edge in &graph.edges {
if ontology_ids.contains(&edge.from) {
continue;
} // skip ontology-origin edges
if ontology_ids.contains(&edge.to) {
let name = ontology_names
.get(edge.to.as_str())
.copied()
.unwrap_or(&edge.to);
ctx_map.entry(edge.from.clone()).or_default().insert((
edge.to.clone(),
name.to_owned(),
format!("{:?}", edge.kind),
));
}
}
// Convert sets to sorted vecs.
let ctx_map: HashMap<String, Vec<(String, String, String)>> = ctx_map
.into_iter()
.map(|(k, set)| {
let mut v: Vec<_> = set.into_iter().collect();
v.sort();
(k, v)
})
.collect();
// Slug → id aliases (same rationale as write_related_map)
let slug_to_id: HashMap<String, String> = graph
.nodes
.iter()
.filter_map(|n| {
let slug = n.url_slug()?;
if slug != n.id() {
Some((slug.to_owned(), n.id().to_owned()))
} else {
None
}
})
.collect();
let make_entry = |content_id: &str, refs: &Vec<(String, String, String)>| -> String {
let ref_entries: Vec<String> = refs
.iter()
.map(|(oid, oname, kind)| {
format!(
r#" OntologyRef {{ id: {oid:?}, name: {oname:?}, kind: {kind:?} }}"#,
oid = oid,
oname = oname,
kind = kind,
)
})
.collect();
format!(
" ({content_id:?}, &[\n{entries}\n ]),",
content_id = content_id,
entries = ref_entries.join(",\n"),
)
};
let mut entries: Vec<String> = ctx_map
.iter()
.map(|(content_id, refs)| make_entry(content_id, refs))
.collect();
for (slug, id) in &slug_to_id {
if let Some(refs) = ctx_map.get(id) {
entries.push(make_entry(slug, refs));
}
}
entries.sort();
let code = format!(
r#"// Generated by content-graph feature — do not edit.
pub struct OntologyRef {{
pub id: &'static str,
pub name: &'static str,
pub kind: &'static str,
}}
pub static ONTOLOGY_CONTEXT: &[(&'static str, &'static [OntologyRef])] = &[
{entries}
];
"#,
entries = entries.join("\n"),
);
std::fs::write(out_dir.join("ontology_context_map.rs"), code)?;
Ok(())
}
// ── Ego-network SVGs
// ──────────────────────────────────────────────────────────
const MINI_SVG_STYLES: &str = r#"
.graph-node { fill: var(--cg-node, #6b7280); stroke: none; }
.graph-node.ego { fill: var(--cg-ego, #3b82f6); }
.graph-node.ontology { fill: var(--cg-onto, #8b5cf6); }
.graph-node.adr { fill: var(--cg-adr, #f59e0b); }
.graph-edge { stroke: var(--cg-edge, #d1d5db); stroke-width: 1.5; }
.graph-label { font: 10px sans-serif; fill: var(--cg-label, #374151); text-anchor: middle; }
.graph-label.ego { font-weight: 600; }
"#;
/// Generates `graph_mini_map.rs` with all ego-network SVGs inlined:
/// ```rust
/// pub static GRAPH_MINI_SVGS: &[(&str, &str)] = &[
/// ("rustelo", r#"<svg ...>...</svg>"#),
/// ...
/// ];
/// ```
fn write_graph_mini_map(graph: &ContentGraph, out_dir: &Path) -> Result<(), BuildError> {
// HashSet per node prevents duplicate neighbors from forward + backlink edge
// pairs (e.g. Implements + ImplementedBy both connect content ↔ ontology
// node).
let adjacency: HashMap<String, Vec<String>> = {
let mut m: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
for edge in &graph.edges {
if matches!(edge.kind, EdgeKind::SharedTag) {
continue;
}
m.entry(edge.from.clone())
.or_default()
.insert(edge.to.clone());
m.entry(edge.to.clone())
.or_default()
.insert(edge.from.clone());
}
m.into_iter()
.map(|(k, set)| (k, set.into_iter().collect()))
.collect()
};
let node_lookup: HashMap<&str, &GraphNode> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
let mut entries: Vec<String> = Vec::new();
for node in &graph.nodes {
if !matches!(node, GraphNode::Content { .. }) {
continue;
}
let neighbors: Vec<&GraphNode> = adjacency
.get(node.id())
.map(|ids| {
ids.iter()
.filter_map(|id| node_lookup.get(id.as_str()).copied())
.collect()
})
.unwrap_or_default();
// Isolated nodes carry no graph information — skip them so GraphMini
// renders nothing rather than showing a single orphan circle.
if neighbors.is_empty() {
continue;
}
let svg = render_ego_svg(node, &neighbors);
// Escape any `#` sequences that would break raw string literal
let delim = pick_raw_delim(&svg);
let hashes = "#".repeat(delim);
entries.push(format!(
" ({id:?}, r{hashes}\"{svg}\"{hashes})",
id = node.id(),
));
// Also emit a slug-keyed alias when the URL slug differs from the id.
// PostViewer identifies content by URL slug, not by the NCL id field.
if let Some(slug) = node.url_slug() {
if slug != node.id() {
entries.push(format!(
" ({slug:?}, r{hashes}\"{svg}\"{hashes})",
slug = slug,
));
}
}
}
entries.sort();
let code = format!(
"// Generated by content-graph feature — do not edit.\npub static GRAPH_MINI_SVGS: \
&[(&'static str, &'static str)] = &[\n{entries}\n];\n",
entries = entries.join(",\n"),
);
std::fs::write(out_dir.join("graph_mini_map.rs"), code)?;
Ok(())
}
/// Find the minimum number of `#` needed for a raw string literal containing
/// `svg`.
fn pick_raw_delim(s: &str) -> usize {
let mut n = 0usize;
loop {
let needle = format!("\"{}", "#".repeat(n));
if !s.contains(&needle) {
return n;
}
n += 1;
}
}
fn render_ego_svg(ego: &GraphNode, neighbors: &[&GraphNode]) -> String {
let (cx, cy) = (180.0_f32, 180.0_f32);
let radius = 120.0_f32;
let n = neighbors.len().max(1) as f32;
// Start angle offset so first node lands at top rather than right,
// giving a more balanced layout on landscape viewports.
let start_angle = -std::f32::consts::FRAC_PI_2;
let lines: String = neighbors.iter().enumerate().map(|(i, _)| {
let angle = start_angle + (i as f32 / n) * std::f32::consts::TAU;
let nx = cx + radius * angle.cos();
let ny = cy + radius * angle.sin();
format!(r#" <line x1="{cx:.1}" y1="{cy:.1}" x2="{nx:.1}" y2="{ny:.1}" class="graph-edge"/>"#)
}).collect::<Vec<_>>().join("\n");
let neighbor_circles: String = neighbors
.iter()
.enumerate()
.map(|(i, node)| {
let angle = start_angle + (i as f32 / n) * std::f32::consts::TAU;
let nx = cx + radius * angle.cos();
let ny = cy + radius * angle.sin();
let css = node_css_class(node);
let label = truncate(node.title(), 22);
// Place label below circle; shift up when in upper half to avoid clipping.
let label_y = if angle.sin() < -0.3 {
ny - 24.0
} else {
ny + 28.0
};
format!(
r#" <circle cx="{nx:.1}" cy="{ny:.1}" r="14" class="graph-node {css}"/>
<text x="{nx:.1}" y="{label_y:.1}" class="graph-label">{label}</text>"#,
)
})
.collect::<Vec<_>>()
.join("\n");
let ego_css = node_css_class(ego);
let ego_label = truncate(ego.title(), 30);
format!(
r#"<svg viewBox="0 0 360 360" xmlns="http://www.w3.org/2000/svg" class="content-graph-mini" role="img" aria-label="Knowledge graph: {ego_label}">
<style>{MINI_SVG_STYLES}</style>
{lines}
<circle cx="{cx:.1}" cy="{cy:.1}" r="22" class="graph-node ego {ego_css}"/>
<text x="{cx:.1}" y="{:.1}" class="graph-label ego">{ego_label}</text>
{neighbor_circles}
</svg>"#,
cy + 36.0,
)
}
fn node_css_class(node: &GraphNode) -> &'static str {
match node {
GraphNode::Content { .. } => "content",
GraphNode::Ontology { .. } => "ontology",
GraphNode::Adr { .. } => "adr",
}
}
fn truncate(s: &str, max: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
s.to_owned()
} else {
chars[..max - 1].iter().collect::<String>() + ""
}
}

View file

@ -0,0 +1,191 @@
use std::collections::{HashMap, HashSet};
use super::node_types::{
ContentGraph, EdgeKind, EdgeSource, EdgeWeight, GraphEdge, GraphMeta, GraphNode,
OntologyEdgeRaw, OntologyInstance,
};
// ── Edge key for deduplication
// ────────────────────────────────────────────────
/// Unique key for a directed edge.
#[derive(Debug, PartialEq, Eq, Hash)]
struct EdgeKey {
from: String,
to: String,
kind: String,
}
impl EdgeKey {
fn new(from: &str, to: &str, kind: &EdgeKind) -> Self {
Self {
from: from.to_owned(),
to: to.to_owned(),
kind: format!("{kind:?}"),
}
}
}
/// Unordered pair key — used to detect existing coverage for SharedTag
/// suppression.
fn pair_key(a: &str, b: &str) -> (String, String) {
if a <= b {
(a.to_owned(), b.to_owned())
} else {
(b.to_owned(), a.to_owned())
}
}
// ── Public API
// ────────────────────────────────────────────────────────────────
/// Resolve all edges for the graph applying the precedence:
///
/// ```text
/// Declared > AutoBacklink > Implicit(SharedTag)
/// ```
///
/// For any `(from, to, kind)` triple, the highest-precedence source wins.
/// SharedTag edges are additionally suppressed when any Declared or
/// AutoBacklink edge already exists between the same node pair (in either
/// direction).
pub fn resolve_edges(
graph: &mut ContentGraph,
content_graph_metas: &[(String, GraphMeta)], // (node_id, graph_meta)
ontology_edges_raw: &[OntologyEdgeRaw],
_ontology_instance: OntologyInstance,
) {
let mut edge_map: HashMap<EdgeKey, GraphEdge> = HashMap::new();
// 1. Ontology native edges (from core.ncl) — treated as Declared at ontology
// layer
for raw in ontology_edges_raw {
let Some(kind) = raw.to_edge_kind() else {
continue;
};
let weight = raw.to_weight();
let key = EdgeKey::new(&raw.from, &raw.to, &kind);
edge_map.entry(key).or_insert_with(|| GraphEdge {
from: raw.from.clone(),
to: raw.to.clone(),
kind,
weight,
source: EdgeSource::Declared,
note: raw.note.clone(),
});
}
// 2. Declared content edges + AutoBacklinks
let mut declared_pairs: HashSet<(String, String)> = HashSet::new();
for (node_id, meta) in content_graph_metas {
let declared = collect_declared_edges(node_id, meta);
for edge in declared {
declared_pairs.insert(pair_key(&edge.from, &edge.to));
// AutoBacklink first (lower precedence — Declared will override below)
if let Some(bk_kind) = edge.kind.backlink() {
let bk_weight = bk_kind.default_weight();
let bk_key = EdgeKey::new(&edge.to, &edge.from, &bk_kind);
edge_map.entry(bk_key).or_insert_with(|| GraphEdge {
from: edge.to.clone(),
to: edge.from.clone(),
kind: bk_kind,
weight: bk_weight,
source: EdgeSource::AutoBacklink,
note: None,
});
}
// Declared overwrites any existing entry for this key
let key = EdgeKey::new(&edge.from, &edge.to, &edge.kind);
edge_map.insert(key, edge);
}
}
// 3. Implicit SharedTag edges — only when no Declared/Auto pair exists
for edge in compute_implicit_tag_edges(&graph.nodes) {
let p = pair_key(&edge.from, &edge.to);
if declared_pairs.contains(&p) {
continue; // suppressed — explicit relationship already covers this
// pair
}
let key = EdgeKey::new(&edge.from, &edge.to, &edge.kind);
edge_map.entry(key).or_insert(edge);
}
graph.edges = edge_map.into_values().collect();
}
// ── Helpers
// ───────────────────────────────────────────────────────────────────
fn collect_declared_edges(node_id: &str, meta: &GraphMeta) -> Vec<GraphEdge> {
let mut edges = Vec::new();
let push = |edges: &mut Vec<GraphEdge>, targets: &[String], kind: EdgeKind| {
for target in targets {
let weight = kind.default_weight();
edges.push(GraphEdge {
from: node_id.to_owned(),
to: target.clone(),
kind: kind.clone(),
weight,
source: EdgeSource::Declared,
note: None,
});
}
};
push(&mut edges, &meta.implements, EdgeKind::Implements);
push(&mut edges, &meta.related_to, EdgeKind::RelatedTo);
push(&mut edges, &meta.part_of, EdgeKind::PartOf);
push(&mut edges, &meta.references, EdgeKind::References);
push(&mut edges, &meta.extends, EdgeKind::Extends);
edges
}
fn compute_implicit_tag_edges(nodes: &[GraphNode]) -> Vec<GraphEdge> {
// Build tag index: tag → [node_id]
let mut tag_index: HashMap<String, Vec<String>> = HashMap::new();
for node in nodes {
for tag in node.tags() {
tag_index
.entry(tag.clone())
.or_default()
.push(node.id().to_owned());
}
}
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut edges = Vec::new();
for node_ids in tag_index.values() {
if node_ids.len() < 2 {
continue;
}
for i in 0..node_ids.len() {
for j in (i + 1)..node_ids.len() {
let a = &node_ids[i];
let b = &node_ids[j];
let p = pair_key(a, b);
if seen.insert(p) {
edges.push(GraphEdge {
from: a.clone(),
to: b.clone(),
kind: EdgeKind::SharedTag,
weight: EdgeWeight::Low,
source: EdgeSource::Implicit,
note: None,
});
}
}
}
}
edges
}

View file

@ -0,0 +1,364 @@
use std::path::{Path, PathBuf};
use glob::glob;
use super::{
edge_resolver::resolve_edges,
ncl_parser::{
parse_adr, parse_content_ncl, parse_ontology_core, ContentParserConfig,
OntologyParserConfig,
},
node_types::{AdrRaw, ContentGraph, GraphMeta, GraphNode, OntologyInstance},
BuildError,
};
// ── Config ────────────────────────────────────────────────────────────────────
/// All paths needed to generate the content graph.
/// Populated from environment variables in `build.rs`.
pub struct GraphGeneratorConfig {
/// Root of the site's content directory (e.g., site/content/).
pub content_dir: PathBuf,
/// Root of the site's nickel schema directory for `--import-path`.
/// Must contain `content/metadata/` with the schema files.
pub nickel_schema_dir: PathBuf,
/// Path to `rustelo/.ontology/core.ncl`.
pub framework_ontology: PathBuf,
/// Path to `{impl}/.ontology/core.ncl`.
pub impl_ontology: PathBuf,
/// Glob pattern matching framework ADR files (e.g., `rustelo/adrs/*.ncl`).
/// Skips files starting with `_` automatically.
pub framework_adrs_glob: String,
/// Glob pattern matching implementation ADR files.
pub impl_adrs_glob: String,
/// Path to the ontoref ontology directory, for `--import-path` on ontology
/// files. Typically: `$ONTOREF_NICKEL_IMPORT_PATH` =
/// `/path/to/ontoref/ontology`.
pub ontoref_import_path: PathBuf,
}
/// `<root>/.ontoref/ontology/core.ncl` if it exists, else the pre-migration
/// `<root>/.ontology/core.ncl`.
///
/// Returns the LEGACY path when neither exists, so the caller's own existence
/// check reports the familiar location rather than a path the project has never
/// had.
fn first_existing_ontology(root: &Path) -> PathBuf {
// In a CONSTELLATION the spine lives at the parent of the code repo
// (`rustelo/.ontoref/`, with the crates under `rustelo/code/`). A
// framework_root that resolves to `code/` therefore has to look one level
// up — ADR-062's layout, and the reason `code/.ontoref/` does not exist.
//
// Four candidates, most-migrated first. The last is the pre-migration layout,
// so a project that never moved its spine still works untouched.
// Walk UP. A site nested inside a constellation (`ontoref/outreach/site`) has
// no spine of its own — it SHARES the constellation's, three levels above.
// So the search climbs rather than guessing a depth: a fixed number of `..`
// is a hardcoded layout wearing a disguise, and this generator has already
// been bitten once by exactly that.
let mut here = root.to_path_buf();
for _ in 0..6 {
for c in [
here.join(".ontoref").join("ontology").join("core.ncl"),
here.join(".ontology").join("core.ncl"),
] {
if c.exists() {
return c;
}
}
match here.parent() {
Some(p) => here = p.to_path_buf(),
None => break,
}
}
// None found: return the modern path so the caller's existence check names the
// location the project SHOULD have, not one it never had.
root.join(".ontoref").join("ontology").join("core.ncl")
}
impl GraphGeneratorConfig {
/// Build config from environment variables set in the implementation's
/// `build.rs`.
///
/// Required env vars (no default):
/// CONTENT_GRAPH_FRAMEWORK_ONTOLOGY
/// CONTENT_GRAPH_FRAMEWORK_ADRS_GLOB
/// ONTOREF_NICKEL_IMPORT_PATH
///
/// Optional env vars (override defaults derived from `workspace_root`):
/// CONTENT_GRAPH_CONTENT_DIR (default: {root}/site/content)
/// CONTENT_GRAPH_NICKEL_SCHEMA_DIR (default: {root}/rustelo/nickel)
/// CONTENT_GRAPH_IMPL_ONTOLOGY (default: {root}/.ontology/core.ncl)
/// CONTENT_GRAPH_IMPL_ADRS_GLOB (default: {root}/adrs/adr-*.ncl)
pub fn from_env() -> Result<Self, BuildError> {
let var = |name: &'static str| -> Result<String, BuildError> {
std::env::var(name).map_err(|_| BuildError::MissingEnv(name))
};
Ok(Self {
content_dir: PathBuf::from(var("CONTENT_GRAPH_CONTENT_DIR")?),
nickel_schema_dir: PathBuf::from(var("CONTENT_GRAPH_NICKEL_SCHEMA_DIR")?),
framework_ontology: PathBuf::from(var("CONTENT_GRAPH_FRAMEWORK_ONTOLOGY")?),
impl_ontology: PathBuf::from(var("CONTENT_GRAPH_IMPL_ONTOLOGY")?),
framework_adrs_glob: var("CONTENT_GRAPH_FRAMEWORK_ADRS_GLOB")?,
impl_adrs_glob: var("CONTENT_GRAPH_IMPL_ADRS_GLOB")?,
ontoref_import_path: PathBuf::from(var("ONTOREF_NICKEL_IMPORT_PATH")?),
})
}
/// Build config with paths derived from the implementation's workspace
/// root.
///
/// `workspace_root` is the absolute path to the directory containing
/// `rustelo.manifest.toml` (the implementation workspace root).
/// `framework_root` is the absolute path to the rustelo framework root
/// (sibling of the implementation, e.g. `../../rustelo` from the
/// workspace). `ontoref_import_path` is the external ontoref ontology
/// directory.
///
/// Env vars with the same names override any field if set.
pub fn with_root(
workspace_root: &Path,
framework_root: &Path,
ontoref_import_path: &Path,
) -> Self {
let env_path = |name: &str, default: PathBuf| -> PathBuf {
std::env::var(name).map(PathBuf::from).unwrap_or(default)
};
let env_str =
|name: &str, default: String| -> String { std::env::var(name).unwrap_or(default) };
Self {
content_dir: env_path(
"CONTENT_GRAPH_CONTENT_DIR",
workspace_root.join("site").join("content"),
),
nickel_schema_dir: env_path(
"CONTENT_GRAPH_NICKEL_SCHEMA_DIR",
framework_root.join("resources").join("nickel"),
),
// FOLLOW THE ONTOLOGY WHEN IT MOVES.
//
// These defaulted to `<root>/.ontology/core.ncl` — the layout ontoref used BEFORE it
// moved its spine to `.ontoref/ontology/`. The ontology moved and this generator did
// not follow, so on any migrated project it asked nickel for a file that is not there
// and got back "IO error" with no hint of what it had been looking for. The graph came
// out with zero nodes, the page rendered, and nothing complained.
//
// It is the same failure as everything else this generator feeds: a witness that stays
// where it was while the thing it watches moves, and reports GREEN because it looked
// where it always looked and found nothing wrong there.
//
// `.ontoref/ontology/` first, `.ontology/` second — a pre-migration project still
// works.
framework_ontology: env_path(
"CONTENT_GRAPH_FRAMEWORK_ONTOLOGY",
first_existing_ontology(framework_root),
),
impl_ontology: env_path(
"CONTENT_GRAPH_IMPL_ONTOLOGY",
first_existing_ontology(workspace_root),
),
framework_adrs_glob: env_str(
"CONTENT_GRAPH_FRAMEWORK_ADRS_GLOB",
framework_root
.join("adrs")
.join("adr-*.ncl")
.display()
.to_string(),
),
impl_adrs_glob: env_str(
"CONTENT_GRAPH_IMPL_ADRS_GLOB",
workspace_root
.join("adrs")
.join("adr-*.ncl")
.display()
.to_string(),
),
ontoref_import_path: env_path(
"ONTOREF_NICKEL_IMPORT_PATH",
ontoref_import_path.to_path_buf(),
),
}
}
}
// ── Generator
// ─────────────────────────────────────────────────────────────────
/// Build the full `ContentGraph` from all content and ontology sources.
pub fn generate(config: &GraphGeneratorConfig) -> Result<ContentGraph, BuildError> {
let content_parser_cfg = ContentParserConfig {
nickel_schema_dir: config.nickel_schema_dir.clone(),
};
let framework_ontology_cfg = OntologyParserConfig {
ontoref_import_path: config.ontoref_import_path.clone(),
instance: OntologyInstance::Framework,
};
let impl_ontology_cfg = OntologyParserConfig {
ontoref_import_path: config.ontoref_import_path.clone(),
instance: OntologyInstance::Implementation,
};
let mut graph = ContentGraph::default();
// Ontology nodes + edges (both instances)
let (fw_nodes, fw_edges) =
parse_ontology_core(&config.framework_ontology, &framework_ontology_cfg)?;
let (im_nodes, im_edges) = parse_ontology_core(&config.impl_ontology, &impl_ontology_cfg)?;
for raw in &fw_nodes {
graph
.nodes
.push(super::ncl_parser::ontology_node_to_graph_node(
raw,
OntologyInstance::Framework,
));
}
for raw in &im_nodes {
graph
.nodes
.push(super::ncl_parser::ontology_node_to_graph_node(
raw,
OntologyInstance::Implementation,
));
}
// ADR nodes (both instances)
let fw_adrs = collect_adrs(&config.framework_adrs_glob, &config.ontoref_import_path)?;
let im_adrs = collect_adrs(&config.impl_adrs_glob, &config.ontoref_import_path)?;
for adr in &fw_adrs {
graph.nodes.push(super::ncl_parser::adr_to_graph_node(
adr,
OntologyInstance::Framework,
));
}
for adr in &im_adrs {
graph.nodes.push(super::ncl_parser::adr_to_graph_node(
adr,
OntologyInstance::Implementation,
));
}
// Content nodes + their graph metadata
let mut content_metas: Vec<(String, GraphMeta)> = Vec::new();
for (node, meta) in collect_content_nodes(&config.content_dir, &content_parser_cfg)? {
let id = node.id().to_owned();
graph.nodes.push(node);
content_metas.push((id, meta));
}
// Deduplicate nodes by id (ontology nodes can appear in both instances if the
// same id is reused — last write wins by keeping first occurrence)
dedup_nodes(&mut graph.nodes);
// Resolve all edges with precedence: Declared > AutoBacklink > Implicit
let all_ontology_edges: Vec<_> = fw_edges.into_iter().chain(im_edges).collect();
resolve_edges(
&mut graph,
&content_metas,
&all_ontology_edges,
OntologyInstance::Framework,
);
// Remove edges whose from/to node does not exist in the graph
let node_ids: std::collections::HashSet<String> =
graph.nodes.iter().map(|n| n.id().to_owned()).collect();
graph
.edges
.retain(|e| node_ids.contains(&e.from) && node_ids.contains(&e.to));
Ok(graph)
}
// ── Internal helpers
// ──────────────────────────────────────────────────────────
fn collect_content_nodes(
content_dir: &Path,
cfg: &ContentParserConfig,
) -> Result<Vec<(GraphNode, GraphMeta)>, BuildError> {
let pattern = content_dir.join("**").join("*.ncl").display().to_string();
let mut results = Vec::new();
for entry in glob(&pattern).map_err(|e| BuildError::Glob(e.to_string()))? {
let path = entry.map_err(|e| BuildError::Glob(e.to_string()))?;
// Skip schema/config files and any file starting with underscore.
// Note: "index.ncl" is the canonical file for projects — do NOT skip it.
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if stem.starts_with('_') {
continue;
}
// Skip known schema/config NCL files (content-kinds, list_*)
if matches!(stem, "content-kinds" | "list_categories" | "list_tags") {
continue;
}
if let Some(pair) = parse_content_ncl(&path, cfg)? {
results.push(pair);
}
}
Ok(results)
}
fn collect_adrs(glob_pattern: &str, ontoref_import_path: &Path) -> Result<Vec<AdrRaw>, BuildError> {
let mut adrs = Vec::new();
for entry in glob(glob_pattern).map_err(|e| BuildError::Glob(e.to_string()))? {
let path = entry.map_err(|e| BuildError::Glob(e.to_string()))?;
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if stem.starts_with('_')
|| matches!(stem, "adr-defaults" | "adr-constraints" | "adr-schema")
{
continue;
}
if let Some(adr) = parse_adr(&path, ontoref_import_path)? {
adrs.push(adr);
}
}
Ok(adrs)
}
fn dedup_nodes(nodes: &mut Vec<GraphNode>) {
// For content nodes with the same logical id (bilingual content), keep the
// English ("en") variant as the canonical node so URLs use the primary
// language. Sort: content nodes with lang "en" come before other languages,
// so first-seen wins in the retain below.
nodes.sort_by(|a, b| {
let lang_a = if let GraphNode::Content { lang, .. } = a {
lang.as_str()
} else {
""
};
let lang_b = if let GraphNode::Content { lang, .. } = b {
lang.as_str()
} else {
""
};
// "en" < everything else → en nodes sort first
let en_a = if lang_a == "en" { 0u8 } else { 1 };
let en_b = if lang_b == "en" { 0u8 } else { 1 };
en_a.cmp(&en_b)
});
let mut seen = std::collections::HashSet::new();
nodes.retain(|n| seen.insert(n.id().to_owned()));
}

View file

@ -0,0 +1,47 @@
pub mod codegen;
pub mod edge_resolver;
pub mod graph_generator;
pub mod ncl_parser;
pub mod node_types;
use std::fmt;
#[derive(Debug)]
pub enum BuildError {
NiclCommand(String),
NclExport { path: String, stderr: String },
NclParse(String),
Io(std::io::Error),
Json(serde_json::Error),
MissingEnv(&'static str),
Glob(String),
}
impl fmt::Display for BuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NiclCommand(e) => write!(f, "nickel command failed: {e}"),
Self::NclExport { path, stderr } => {
write!(f, "nickel export failed for {path}: {stderr}")
}
Self::NclParse(e) => write!(f, "NCL JSON parse error: {e}"),
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Json(e) => write!(f, "JSON error: {e}"),
Self::MissingEnv(v) => write!(f, "missing env var: {v}"),
Self::Glob(e) => write!(f, "glob error: {e}"),
}
}
}
impl std::error::Error for BuildError {}
impl From<std::io::Error> for BuildError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<serde_json::Error> for BuildError {
fn from(e: serde_json::Error) -> Self {
Self::Json(e)
}
}

View file

@ -0,0 +1,335 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
use super::{
node_types::{
AdrRaw, ContentKind, GraphMeta, GraphNode, OntologyEdgeRaw, OntologyInstance,
OntologyNodeRaw,
},
BuildError,
};
// ── NCL export
// ────────────────────────────────────────────────────────────────
/// Run `nickel export --format json` with the given import paths.
/// Returns the parsed JSON value.
fn export_ncl(file: &Path, import_paths: &[&Path]) -> Result<Value, BuildError> {
let mut cmd = Command::new("nickel");
cmd.arg("export").arg("--format").arg("json");
for p in import_paths {
cmd.arg("--import-path").arg(p);
}
cmd.arg(file);
let out = cmd
.output()
.map_err(|e| BuildError::NiclCommand(e.to_string()))?;
if !out.status.success() {
return Err(BuildError::NclExport {
path: file.display().to_string(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
serde_json::from_slice(&out.stdout).map_err(|e| BuildError::NclParse(e.to_string()))
}
// ── Content NCL parsing
// ───────────────────────────────────────────────────────
/// Configuration for parsing content NCL files.
pub struct ContentParserConfig {
/// Root of the site's nickel schema directory (contains content/metadata/).
/// Used as --import-path for nickel export on content files.
pub nickel_schema_dir: PathBuf,
}
/// Parse a single content `.ncl` file and extract graph metadata + node info.
/// Returns `None` if the file is not published or is a draft.
pub fn parse_content_ncl(
file: &Path,
config: &ContentParserConfig,
) -> Result<Option<(GraphNode, GraphMeta)>, BuildError> {
// Emit rerun directive for incremental builds
println!("cargo:rerun-if-changed={}", file.display());
let import_paths = [
config.nickel_schema_dir.as_path(),
file.parent().unwrap_or(Path::new(".")),
];
let value = export_ncl(file, &import_paths)?;
// Skip unpublished and draft content
if value.get("published").and_then(Value::as_bool) == Some(false) {
return Ok(None);
}
if value.get("draft").and_then(Value::as_bool) == Some(true) {
return Ok(None);
}
let graph_meta = extract_graph_meta(&value);
let node = build_content_node(file, &value)?;
Ok(Some((node, graph_meta)))
}
fn extract_graph_meta(value: &Value) -> GraphMeta {
let empty = Value::Object(Default::default());
let graph = value.get("graph").unwrap_or(&empty);
GraphMeta {
implements: str_array(graph, "implements"),
related_to: str_array(graph, "related_to"),
part_of: str_array(graph, "part_of"),
references: str_array(graph, "references"),
extends: str_array(graph, "extends"),
}
}
fn build_content_node(file: &Path, value: &Value) -> Result<GraphNode, BuildError> {
let (content_kind, lang, category) = infer_content_kind_and_lang(file);
let id = value
.get("id")
.and_then(Value::as_str)
.or_else(|| value.get("slug").and_then(Value::as_str))
.unwrap_or_else(|| {
file.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
})
.to_owned();
let title = value
.get("title")
.and_then(Value::as_str)
.unwrap_or("Untitled")
.to_owned();
let slug = value
.get("slug")
.and_then(Value::as_str)
.unwrap_or(&id)
.to_owned();
let page_route = value.get("page_route").and_then(Value::as_str);
let url = if let Some(route) = page_route {
route.to_owned()
} else {
build_content_url(&content_kind, &lang, category.as_deref(), &slug)
};
let tags = str_array(value, "tags");
let excerpt = value
.get("excerpt")
.and_then(Value::as_str)
.map(str::to_owned);
Ok(GraphNode::Content {
id,
title,
content_kind,
lang,
url,
tags,
excerpt,
})
}
/// Infer content kind, language, and optional category from the file path.
///
/// Expected path segments (relative to site/content/):
/// blog/en/category/post.ncl → Blog, "en", Some("category")
/// recipes/es/category/recipe.ncl → Recipe, "es", Some("category")
/// projects/en/slug/index.ncl → Project, "en", None
/// activities/en/category/item.ncl → Activity, "en", Some("category")
fn infer_content_kind_and_lang(file: &Path) -> (ContentKind, String, Option<String>) {
let parts: Vec<&str> = file
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
// Find "blog" | "recipes" | "projects" | "activities" in path
let kind_idx = parts
.iter()
.position(|&s| matches!(s, "blog" | "recipes" | "projects" | "activities"));
let Some(ki) = kind_idx else {
return (ContentKind::Blog, "en".into(), None);
};
let kind = match parts[ki] {
"recipes" => ContentKind::Recipe,
"projects" => ContentKind::Project,
"activities" => ContentKind::Activity,
_ => ContentKind::Blog,
};
let lang = parts.get(ki + 1).copied().unwrap_or("en").to_owned();
let category = if matches!(
kind,
ContentKind::Blog | ContentKind::Recipe | ContentKind::Activity
) {
parts.get(ki + 2).map(|s| (*s).to_owned())
} else {
None
};
(kind, lang, category)
}
fn build_content_url(kind: &ContentKind, lang: &str, category: Option<&str>, slug: &str) -> String {
let segment = kind.url_segment_for_lang(lang);
match (kind, category) {
(ContentKind::Project, _) => format!("/{segment}/{slug}"),
(_, Some(cat)) => format!("/{segment}/{cat}/{slug}"),
(_, None) => format!("/{segment}/{slug}"),
}
}
// ── Ontology NCL parsing
// ──────────────────────────────────────────────────────
/// Configuration for parsing ontology NCL files.
pub struct OntologyParserConfig {
/// Path to the ontoref ontology directory (contains defaults/core.ncl).
/// Provided via `ONTOREF_NICKEL_IMPORT_PATH` environment variable.
pub ontoref_import_path: PathBuf,
pub instance: OntologyInstance,
}
/// Parse a `core.ncl` file and return (nodes, edges).
pub fn parse_ontology_core(
file: &Path,
config: &OntologyParserConfig,
) -> Result<(Vec<OntologyNodeRaw>, Vec<OntologyEdgeRaw>), BuildError> {
println!("cargo:rerun-if-changed={}", file.display());
// AN ONTOLOGY RESOLVES ITS IMPORTS AGAINST ITS OWN SPINE.
//
// `core.ncl` opens with `import "defaults/core.ncl"`, and that path is relative
// to the SPINE (`.ontoref/`), not to `ontology/`. The only import paths
// passed were a single global `ontoref_import_path` and the file's own
// directory — so when two ontologies from DIFFERENT projects are parsed in
// one run (the framework's and the implementation's, which is the whole
// point of this parser), each was handed the OTHER project's spine and neither
// could find its own defaults. One global import path cannot serve two
// projects.
//
// The file's grandparent IS its spine: `<project>/.ontoref/ontology/core.ncl` →
// `<project>/.ontoref`.
let own_spine = file.parent().and_then(Path::parent);
let mut import_paths: Vec<&Path> = vec![config.ontoref_import_path.as_path()];
if let Some(sp) = own_spine {
import_paths.push(sp);
}
if let Some(dir) = file.parent() {
import_paths.push(dir);
}
let value = export_ncl(file, &import_paths)?;
let nodes = value
.get("nodes")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| serde_json::from_value(v.clone()).ok())
.collect()
})
.unwrap_or_default();
let edges = value
.get("edges")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| serde_json::from_value(v.clone()).ok())
.collect()
})
.unwrap_or_default();
Ok((nodes, edges))
}
// ── ADR NCL parsing
// ───────────────────────────────────────────────────────────
/// Parse a single ADR `.ncl` file.
pub fn parse_adr(file: &Path, ontoref_import_path: &Path) -> Result<Option<AdrRaw>, BuildError> {
println!("cargo:rerun-if-changed={}", file.display());
let dir = file.parent().unwrap_or(Path::new("."));
let import_paths = [ontoref_import_path, dir];
let value = export_ncl(file, &import_paths)?;
// Skip template files (id starts with "_" or is missing)
let id = match value.get("id").and_then(Value::as_str) {
Some(id) if !id.starts_with('_') => id.to_owned(),
_ => return Ok(None),
};
let adr = AdrRaw {
id,
title: value
.get("title")
.and_then(Value::as_str)
.unwrap_or("Untitled ADR")
.to_owned(),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or("Unknown")
.to_owned(),
};
Ok(Some(adr))
}
// ── Conversion helpers (used by graph_generator)
// ──────────────────────────────
pub fn ontology_node_to_graph_node(raw: &OntologyNodeRaw, instance: OntologyInstance) -> GraphNode {
GraphNode::Ontology {
id: raw.id.clone(),
name: raw.name.clone(),
level: raw.level.clone(),
pole: raw.pole.clone(),
description: raw.description.clone(),
instance,
artifact_paths: raw.artifact_paths.clone(),
}
}
pub fn adr_to_graph_node(raw: &AdrRaw, instance: OntologyInstance) -> GraphNode {
GraphNode::Adr {
id: raw.id.clone(),
title: raw.title.clone(),
status: raw.status.clone(),
instance,
}
}
// ── Helpers
// ───────────────────────────────────────────────────────────────────
fn str_array(value: &Value, key: &str) -> Vec<String> {
value
.get(key)
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}

View file

@ -0,0 +1,286 @@
use serde::{Deserialize, Serialize};
// ── Enums ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ContentKind {
Blog,
Recipe,
Project,
Activity,
}
impl ContentKind {
/// URL path segment for this content type in the given language.
///
/// Matches the localized route patterns in `site/config/routes.ncl`.
pub fn url_segment_for_lang(&self, lang: &str) -> &'static str {
match (self, lang) {
(Self::Recipe, "es") => "recetas",
(Self::Project, "es") => "proyectos",
(Self::Activity, "es") => "actividades",
(Self::Blog, _) => "blog",
(Self::Recipe, _) => "recipes",
(Self::Project, _) => "projects",
(Self::Activity, _) => "activities",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "PascalCase")]
pub enum OntologyLevel {
Axiom,
Tension,
Practice,
Project,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum OntologyInstance {
Framework,
Implementation,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "PascalCase")]
pub enum Pole {
Yang,
Yin,
Spiral,
}
/// Edge kinds that can appear between any two graph nodes.
///
/// Declared* variants come from explicit `.ncl` `graph` fields.
/// Auto* variants are generated as backlinks.
/// Ontology* variants are read directly from `core.ncl` edges.
/// SharedTag is computed from shared tag arrays.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum EdgeKind {
// Content → Ontology/Content (declared)
Implements,
RelatedTo,
PartOf,
References,
Extends,
// Auto-generated backlinks
ImplementedBy,
RelatedFrom,
ParentOf,
ReferencedBy,
ExtendedBy,
// Ontology native (from core.ncl edges)
ManifestsIn,
Resolves,
Complements,
DependsOn,
DocumentedIn,
// Implicit — suppressed when a Declared/Auto edge exists for the same pair
SharedTag,
}
impl EdgeKind {
/// Returns the inverse kind used for auto-backlinks.
/// Ontology and SharedTag edges have no automatic backlink.
pub fn backlink(&self) -> Option<EdgeKind> {
match self {
Self::Implements => Some(Self::ImplementedBy),
Self::RelatedTo => Some(Self::RelatedFrom),
Self::PartOf => Some(Self::ParentOf),
Self::References => Some(Self::ReferencedBy),
Self::Extends => Some(Self::ExtendedBy),
_ => None,
}
}
pub fn default_weight(&self) -> EdgeWeight {
match self {
Self::Implements | Self::ManifestsIn | Self::Resolves => EdgeWeight::High,
Self::RelatedTo | Self::Complements | Self::DependsOn | Self::DocumentedIn => {
EdgeWeight::Medium
}
Self::PartOf | Self::References | Self::Extends => EdgeWeight::Medium,
_ => EdgeWeight::Low,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum EdgeWeight {
High,
Medium,
Low,
}
/// Determines merge precedence when two edges share the same (from, to, kind)
/// key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum EdgeSource {
Declared, // explicit in content .ncl graph field — highest
AutoBacklink, // generated from a Declared edge
Implicit, // SharedTag — lowest, suppressed by any Declared/Auto on same pair
}
// ── Nodes ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GraphNode {
Content {
id: String,
title: String,
content_kind: ContentKind,
lang: String,
url: String,
tags: Vec<String>,
excerpt: Option<String>,
},
Ontology {
id: String,
name: String,
level: OntologyLevel,
pole: Option<Pole>,
description: String,
instance: OntologyInstance,
artifact_paths: Vec<String>,
},
Adr {
id: String,
title: String,
status: String,
instance: OntologyInstance,
},
}
impl GraphNode {
pub fn id(&self) -> &str {
match self {
Self::Content { id, .. } => id,
Self::Ontology { id, .. } => id,
Self::Adr { id, .. } => id,
}
}
pub fn title(&self) -> &str {
match self {
Self::Content { title, .. } => title,
Self::Ontology { name, .. } => name,
Self::Adr { title, .. } => title,
}
}
pub fn tags(&self) -> &[String] {
match self {
Self::Content { tags, .. } => tags,
_ => &[],
}
}
/// The URL slug (last path segment) for content nodes, `None` for others.
/// When the slug differs from `id` (e.g. `id = "post-2024"`, url ends in
/// `"modern-post-title"`), this is what the PostViewer passes as `node_id`.
pub fn url_slug(&self) -> Option<&str> {
if let Self::Content { url, .. } = self {
url.rsplit('/').next()
} else {
None
}
}
}
// ── Edges ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEdge {
pub from: String,
pub to: String,
pub kind: EdgeKind,
pub weight: EdgeWeight,
pub source: EdgeSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
// ── Graph ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ContentGraph {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
// ── Raw NCL deserialization types
// ─────────────────────────────────────────────
/// Parsed from the `graph` field of a content .ncl file.
#[derive(Debug, Deserialize, Default)]
pub struct GraphMeta {
#[serde(default)]
pub implements: Vec<String>,
#[serde(default)]
pub related_to: Vec<String>,
#[serde(default)]
pub part_of: Vec<String>,
#[serde(default)]
pub references: Vec<String>,
#[serde(default)]
pub extends: Vec<String>,
}
/// Raw ontology node as exported from `core.ncl`.
#[derive(Debug, Deserialize)]
pub struct OntologyNodeRaw {
pub id: String,
pub name: String,
pub level: OntologyLevel,
pub pole: Option<Pole>,
pub description: String,
#[serde(default)]
pub artifact_paths: Vec<String>,
}
/// Raw ontology edge as exported from `core.ncl`.
#[derive(Debug, Deserialize)]
pub struct OntologyEdgeRaw {
pub from: String,
pub to: String,
pub kind: String,
pub weight: String,
#[serde(default)]
pub note: Option<String>,
}
impl OntologyEdgeRaw {
pub fn to_edge_kind(&self) -> Option<EdgeKind> {
match self.kind.as_str() {
"ManifestsIn" => Some(EdgeKind::ManifestsIn),
"Resolves" => Some(EdgeKind::Resolves),
"Complements" => Some(EdgeKind::Complements),
"DependsOn" => Some(EdgeKind::DependsOn),
"DocumentedIn" => Some(EdgeKind::DocumentedIn),
_ => None,
}
}
pub fn to_weight(&self) -> EdgeWeight {
match self.weight.as_str() {
"High" => EdgeWeight::High,
"Medium" => EdgeWeight::Medium,
_ => EdgeWeight::Low,
}
}
}
/// Raw ADR as exported from `adrs/*.ncl`.
#[derive(Debug, Deserialize)]
pub struct AdrRaw {
pub id: String,
pub title: String,
pub status: String,
}

View file

@ -0,0 +1 @@
pub mod build;

View file

@ -0,0 +1,22 @@
# content-graph asset additions — merge this into site/config/assets.ncl.
#
# The `rustelo add content-graph` command writes this file to
# site/config/assets_content_graph.ncl and server/build.rs must merge it
# with the base assets.ncl before generating shell_asset_paths.rs.
#
# Alternatively, copy the relevant paths directly into assets.ncl.
{
assets = {
source_js = [
"/js/cytoscape.min.js",
"/js/cytoscape-cose-bilkent.js",
"/js/content-graph-shim.js",
],
bundled_js = [
"/js/cytoscape.min.js",
"/js/cytoscape-cose-bilkent.js",
"/js/content-graph-shim.js",
],
},
}

View file

@ -0,0 +1,35 @@
// content-graph feature — build.rs integration snippet.
//
// Add this block to the implementation's server/build.rs (or as a separate
// build step) after sourcing the required environment variables.
//
// Required .env entries (add to foundation/.env):
//
// CONTENT_GRAPH_CONTENT_DIR=site/content
// CONTENT_GRAPH_NICKEL_SCHEMA_DIR=rustelo/nickel
// CONTENT_GRAPH_FRAMEWORK_ONTOLOGY=/abs/path/to/rustelo/.ontology/core.ncl
// CONTENT_GRAPH_IMPL_ONTOLOGY=.ontology/core.ncl
// CONTENT_GRAPH_FRAMEWORK_ADRS_GLOB=/abs/path/to/rustelo/adrs/adr-*.ncl
// CONTENT_GRAPH_IMPL_ADRS_GLOB=adrs/adr-*.ncl
// ONTOREF_NICKEL_IMPORT_PATH=/abs/path/to/ontoref/ontology
fn generate_content_graph() {
use content_graph::build::{codegen, graph_generator::GraphGeneratorConfig};
let config = GraphGeneratorConfig::from_env()
.expect("content-graph: missing required environment variables — see templates/build_rs_snippet.rs");
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
let graph = graph_generator::generate(&config)
.expect("content-graph: graph generation failed");
codegen::write_outputs(&graph, &out_dir)
.expect("content-graph: codegen write failed");
// Invalidate when any ontology or ADR file changes
println!("cargo:rerun-if-env-changed=CONTENT_GRAPH_CONTENT_DIR");
println!("cargo:rerun-if-env-changed=ONTOREF_NICKEL_IMPORT_PATH");
println!("cargo:rerun-if-changed={}", config.framework_ontology.display());
println!("cargo:rerun-if-changed={}", config.impl_ontology.display());
}

View file

@ -0,0 +1,43 @@
// content_graph/graph_mini.rs
//
// Inline ego-network SVG for embedding in content pages.
// Static SVG generated at build time — no JS, no Cytoscape, no hydration risk.
// Uses inner_html so SSR and WASM produce structurally identical DOM.
use leptos::prelude::*;
use super::generated::mini::GRAPH_MINI_SVGS;
/// Inline ego-network SVG for the given node.
///
/// Renders nothing if the node has no ego SVG (e.g. orphan nodes or
/// non-content nodes that don't get SVGs generated).
#[component]
pub fn GraphMini(
node_id: String,
#[prop(optional)] class: Option<String>,
) -> impl IntoView {
let svg: Option<&'static str> = GRAPH_MINI_SVGS
.iter()
.find(|(id, _)| *id == node_id.as_str())
.map(|(_, svg)| *svg);
let Some(svg) = svg else {
return view! { <></> }.into_any();
};
let wrapper_class = format!(
"content-graph-mini-wrapper {}",
class.unwrap_or_default()
);
// inner_html with a static &str guarantees SSR == WASM DOM structure.
// No reactive signals, no placeholders, no hydration mismatch possible.
view! {
<div
class=wrapper_class
aria-hidden="true"
inner_html=svg
/>
}.into_any()
}

View file

@ -0,0 +1,137 @@
// content_graph/graph_view.rs
//
// Full-page interactive knowledge graph via Cytoscape.js.
// Follows the SSR+WASM delegation pattern used across the codebase.
//
// SSR: renders container div + embedded JSON data script tag.
// WASM: Effect::new calls window.ContentGraph.render() after mount.
use leptos::prelude::*;
use super::GRAPH_JSON;
// ── Entry point (delegating component) ───────────────────────────────────────
/// Full-page knowledge graph view.
///
/// `node_id`: when provided, the graph centers on that node (ego view).
#[component]
pub fn GraphView(
#[prop(optional)] node_id: Option<String>,
#[prop(default = String::from("light"))] theme: String,
) -> impl IntoView {
#[cfg(not(target_arch = "wasm32"))]
return view! { <GraphViewInner node_id=node_id theme=theme /> };
#[cfg(target_arch = "wasm32")]
return view! { <GraphViewClient node_id=node_id theme=theme /> };
}
// ── Inner (SSR) ───────────────────────────────────────────────────────────────
#[component]
fn GraphViewInner(
node_id: Option<String>,
theme: String,
) -> impl IntoView {
let focus = node_id.unwrap_or_default();
let graph_json_safe = GRAPH_JSON.replace("</", "\\/");
view! {
<div class="content-graph-page w-full">
// Embedded graph data — read by the JS shim
<script
type="application/json"
id="content-graph-data"
inner_html=graph_json_safe
/>
// Cytoscape container
<div
id="content-graph-cyto"
data-focus=focus
data-theme=theme
class="w-full h-[600px] ds-bg-page ds-border ds-rounded-lg overflow-hidden"
aria-label="Knowledge graph visualisation"
role="img"
>
<noscript>
<p class="p-4 ds-caption text-center">
"Interactive graph requires JavaScript."
</p>
</noscript>
</div>
</div>
}
}
// ── Client (WASM) ─────────────────────────────────────────────────────────────
#[component]
fn GraphViewClient(
node_id: Option<String>,
theme: String,
) -> impl IntoView {
let focus = node_id.unwrap_or_default();
let focus_clone = focus.clone();
let theme_clone = theme.clone();
// Initialise Cytoscape after the component mounts in the browser
Effect::new(move |_| {
let focus = focus_clone.clone();
let theme = theme_clone.clone();
call_cytoscape_render("content-graph-cyto", &focus, &theme);
});
// On unmount, destroy the Cytoscape instance
on_cleanup(move || {
call_cytoscape_destroy("content-graph-cyto");
});
view! {
<div class="content-graph-page w-full">
<div
id="content-graph-cyto"
data-focus=focus
data-theme=theme
class="w-full h-[600px] ds-bg-page ds-border ds-rounded-lg overflow-hidden"
aria-label="Knowledge graph visualisation"
role="img"
/>
</div>
}
}
// ── JS interop ────────────────────────────────────────────────────────────────
#[cfg(target_arch = "wasm32")]
fn call_cytoscape_render(container_id: &str, focus: &str, theme: &str) {
use wasm_bindgen::JsValue;
let Some(window) = web_sys::window() else { return };
let Ok(content_graph) = js_sys::Reflect::get(&window, &JsValue::from_str("ContentGraph")) else { return };
let Ok(render_fn) = js_sys::Reflect::get(&content_graph, &JsValue::from_str("render")) else { return };
let render_fn: js_sys::Function = render_fn.dyn_into().unwrap_or_else(|_| js_sys::Function::new_no_args(""));
let _ = render_fn.call3(
&content_graph,
&JsValue::from_str(container_id),
&JsValue::from_str(focus),
&JsValue::from_str(theme),
);
}
#[cfg(not(target_arch = "wasm32"))]
fn call_cytoscape_render(_container_id: &str, _focus: &str, _theme: &str) {}
#[cfg(target_arch = "wasm32")]
fn call_cytoscape_destroy(container_id: &str) {
use wasm_bindgen::JsValue;
let Some(window) = web_sys::window() else { return };
let Ok(content_graph) = js_sys::Reflect::get(&window, &JsValue::from_str("ContentGraph")) else { return };
let Ok(destroy_fn) = js_sys::Reflect::get(&content_graph, &JsValue::from_str("destroy")) else { return };
let destroy_fn: js_sys::Function = destroy_fn.dyn_into().unwrap_or_else(|_| js_sys::Function::new_no_args(""));
let _ = destroy_fn.call1(&content_graph, &JsValue::from_str(container_id));
}
#[cfg(not(target_arch = "wasm32"))]
fn call_cytoscape_destroy(_container_id: &str) {}

View file

@ -0,0 +1,28 @@
// Generated by `rustelo add content-graph` — owned by the implementation.
// Copied to: crates/pages/src/content_graph/mod.rs
pub mod graph_mini;
pub mod graph_view;
pub mod ontology_context;
pub mod related_content;
pub use graph_mini::GraphMini;
pub use graph_view::GraphView;
pub use ontology_context::OntologyContext;
pub use related_content::RelatedContent;
// Generated static data — included from build OUT_DIR.
// The include! paths are resolved by the implementation's build.rs.
pub mod generated {
pub mod related {
include!(concat!(env!("OUT_DIR"), "/related_map.rs"));
}
pub mod ontology {
include!(concat!(env!("OUT_DIR"), "/ontology_context_map.rs"));
}
pub mod mini {
include!(concat!(env!("OUT_DIR"), "/graph_mini_map.rs"));
}
}
const GRAPH_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/content_graph.json"));

View file

@ -0,0 +1,70 @@
// content_graph/ontology_context.rs
//
// Badge component showing the on+re ontology nodes connected to a content item.
// Pure HTML — no JS, fully SSR-safe.
// Data comes from ONTOLOGY_CONTEXT static map generated at build time.
use leptos::prelude::*;
use super::generated::ontology::{OntologyRef, ONTOLOGY_CONTEXT};
// ── Level / kind display helpers ──────────────────────────────────────────────
fn ref_icon(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "",
"ManifestsIn" => "",
"Resolves" => "",
"Complements" | "DependsOn" => "~",
"DocumentedIn" | "References" => "",
_ => "·",
}
}
fn ref_css(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "text-violet-500",
"ManifestsIn" => "text-blue-500",
"Resolves" => "text-emerald-500",
_ => "text-amber-500",
}
}
// ── Component ─────────────────────────────────────────────────────────────────
/// Footer/sidebar badge showing on+re ontology nodes connected to this content.
///
/// Renders nothing when the node has no ontology connections.
#[component]
pub fn OntologyContext(node_id: String) -> impl IntoView {
let refs: &'static [OntologyRef] = ONTOLOGY_CONTEXT
.iter()
.find(|(id, _)| *id == node_id.as_str())
.map(|(_, refs)| *refs)
.unwrap_or(&[]);
if refs.is_empty() {
return view! { <></> }.into_any();
}
view! {
<div class="content-graph-ontology-context mt-6 pt-4 ds-border-t space-y-2">
<p class="text-xs font-semibold ds-text uppercase tracking-wide opacity-50">
"Ontology context"
</p>
<ul class="flex flex-wrap gap-2">
{refs.iter().map(|r| {
let icon = ref_icon(r.kind);
let css = ref_css(r.kind);
view! {
<li class="flex items-center gap-1 text-xs ds-text">
<span class=css>{icon}</span>
<span class="font-mono opacity-80">{r.id}</span>
<span class="opacity-50">{r.name}</span>
</li>
}
}).collect_view()}
</ul>
</div>
}.into_any()
}

View file

@ -0,0 +1,79 @@
// content_graph/related_content.rs
//
// Sidebar widget showing content related to the current node.
// Pure HTML — no JS, fully SSR-safe.
// Data comes from RELATED_NODES static map generated at build time.
use leptos::prelude::*;
use super::generated::related::{RelatedNode, RELATED_NODES};
// ── Kind display helpers ──────────────────────────────────────────────────────
fn kind_label(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "implements",
"RelatedTo" | "RelatedFrom" => "related",
"PartOf" | "ParentOf" => "part of",
"References" | "ReferencedBy" => "references",
"Extends" | "ExtendedBy" => "extends",
"ManifestsIn" => "manifests in",
"Resolves" => "resolves",
"Complements" => "complements",
"DependsOn" => "depends on",
"DocumentedIn" => "documented in",
_ => kind,
}
}
fn kind_css(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "ds-badge-primary",
"RelatedTo" | "RelatedFrom" => "ds-badge-secondary",
"PartOf" | "ParentOf" => "ds-badge-accent",
"References" | "ReferencedBy" => "ds-badge-neutral",
_ => "ds-badge-ghost",
}
}
// ── Component ─────────────────────────────────────────────────────────────────
/// Sidebar list of related content items for a given node.
///
/// Renders nothing if the node has no relationships.
#[component]
pub fn RelatedContent(node_id: String) -> impl IntoView {
let related: &'static [RelatedNode] = RELATED_NODES
.iter()
.find(|(id, _)| *id == node_id.as_str())
.map(|(_, nodes)| *nodes)
.unwrap_or(&[]);
if related.is_empty() {
return view! { <></> }.into_any();
}
view! {
<aside class="content-graph-related ds-card ds-bg-base-200 p-4 space-y-3">
<h3 class="text-sm font-semibold ds-text uppercase tracking-wide opacity-60">
"Related"
</h3>
<ul class="space-y-2">
{related.iter().map(|node| {
let badge = kind_css(node.kind);
let label = kind_label(node.kind);
view! {
<li class="flex items-start gap-2">
<span class=format!("ds-badge text-xs mt-0.5 shrink-0 {badge}")>
{label}
</span>
<span class="text-sm ds-text leading-snug">
{node.title}
</span>
</li>
}
}).collect_view()}
</ul>
</aside>
}.into_any()
}

View file

@ -0,0 +1,17 @@
-- Migration: 004 GDPR consent timestamp + user bookmarks
-- Database: PostgreSQL
ALTER TABLE users ADD COLUMN IF NOT EXISTS gdpr_accepted_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS user_bookmarks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content_type VARCHAR(50) NOT NULL,
content_id VARCHAR(255) NOT NULL,
content_title VARCHAR(500),
content_url VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, content_type, content_id)
);
CREATE INDEX IF NOT EXISTS idx_user_bookmarks_user_id ON user_bookmarks(user_id);

View file

@ -0,0 +1,17 @@
-- Migration: 004 GDPR consent timestamp + user bookmarks
-- Database: SQLite
ALTER TABLE users ADD COLUMN gdpr_accepted_at DATETIME;
CREATE TABLE IF NOT EXISTS user_bookmarks (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content_type TEXT NOT NULL,
content_id TEXT NOT NULL,
content_title TEXT,
content_url TEXT,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, content_type, content_id)
);
CREATE INDEX IF NOT EXISTS idx_user_bookmarks_user_id ON user_bookmarks(user_id);

View file

@ -253,7 +253,7 @@ fn generate_params_setup(params_component: &HashMap<String, String>) -> String {
fn generate_content_post_component(component_info: &ComponentInfo) -> (String, String, String) {
let imports = r#"
use rustelo_core_types::content::*;
use rustelo_components::content::*;
use rustelo_components_leptos::content::*;
use rustelo_pages::post_viewer::unified::PostViewerPage;"#;
let content = format!(
@ -274,7 +274,7 @@ use rustelo_pages::post_viewer::unified::PostViewerPage;"#;
fn generate_content_category_component(component_info: &ComponentInfo) -> (String, String, String) {
let imports = r#"
use rustelo_core_types::content::*;
use rustelo_components::content::*;"#;
use rustelo_components_leptos::content::*;"#;
let style_mode = component_info
.params_component
@ -302,7 +302,7 @@ use rustelo_components::content::*;"#;
fn generate_content_index_component(component_info: &ComponentInfo) -> (String, String, String) {
let imports = r#"
use rustelo_core_types::content::*;
use rustelo_components::content::*;"#;
use rustelo_components_leptos::content::*;"#;
let style_mode = component_info
.params_component
@ -332,7 +332,7 @@ use rustelo_components::content::*;"#;
/// Generate standard component
fn generate_standard_component(component_info: &ComponentInfo) -> (String, String, String) {
let imports = "use rustelo_components::rustelo_pages::*;";
let imports = "use rustelo_components_leptos::rustelo_pages::*;";
let content = format!(
r#"

View file

@ -7,22 +7,34 @@ use std::path::Path;
/// Load routes configuration from SITE_CONFIG_PATH/routes/ directory or fallback to routes.toml
pub fn load_routes_config(content_root: &str) -> Result<RoutesConfig, String> {
// Use SITE_CONFIG_PATH, resolving from SITE_ROOT if needed
// Use SITE_CONFIG_PATH, resolving from SITE_ROOT if needed.
// If SITE_CONFIG_PATH points to a file (e.g. site/config/index.ncl), use its
// parent directory as the config root, since this loader expects a directory.
let site_config_path = std::env::var("SITE_CONFIG_PATH").unwrap_or_else(|_| {
let site_root = std::env::var("SITE_ROOT_PATH").unwrap_or_else(|_| "site".to_string());
format!("{}/config", site_root)
});
// Get the project root to make path absolute
let routes_dir = if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
let config_dir = {
let p = Path::new(&site_config_path);
if p.is_file() || p.extension().is_some() {
p.parent().unwrap_or(p).to_path_buf()
} else {
p.to_path_buf()
}
};
let routes_dir = if config_dir.is_absolute() {
config_dir.join("routes")
} else if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
let project_root = Path::new(&manifest_dir)
.parent()
.and_then(|p| p.parent())
.unwrap_or_else(|| Path::new("."))
.to_path_buf(); // Convert to owned PathBuf
project_root.join(&site_config_path).join("routes")
.to_path_buf();
project_root.join(&config_dir).join("routes")
} else {
Path::new(&site_config_path).join("routes")
config_dir.join("routes")
};
let routes_file = Path::new(content_root).join("routes.toml");

View file

@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Route configuration structure - mirrors shared::routing::rustelo_components::RouteConfigToml
/// Route configuration structure - mirrors shared::routing::rustelo_components_leptos::RouteConfigToml
/// This must stay in sync with the shared version to ensure compatibility
#[derive(Deserialize, Serialize, Debug)]
#[allow(dead_code)]

View file

@ -355,7 +355,7 @@ fn generate_basic_page_content(prefix: &str) -> String {
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl mb-ds-4">
{{content.t("{}-title")}}
</h1>
<rustelo_components::HtmlContent
<rustelo_components_leptos::HtmlContent
content={{content.t("{}-description")}}
class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto".to_string()
/>
@ -383,7 +383,7 @@ fn generate_hero_page_content(prefix: &str) -> String {
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl mb-ds-4">
{{content.t("{}-hero-title")}}
</h1>
<rustelo_components::HtmlContent
<rustelo_components_leptos::HtmlContent
content={{content.t("{}-hero-subtitle")}}
class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto".to_string()
/>
@ -421,7 +421,7 @@ fn generate_form_page_content(prefix: &str) -> String {
<h1 class="text-3xl font-bold ds-text mb-4">
{{content.t("{}-title")}}
</h1>
<rustelo_components::HtmlContent
<rustelo_components_leptos::HtmlContent
content={{content.t("{}-description")}}
class="ds-text-secondary".to_string()
/>
@ -454,7 +454,7 @@ fn generate_content_list_content(prefix: &str) -> String {
<h1 class="text-3xl font-bold ds-text mb-4">
{{content.t("{}-title")}}
</h1>
<rustelo_components::HtmlContent
<rustelo_components_leptos::HtmlContent
content={{content.t("{}-description")}}
class="ds-text-secondary max-w-2xl mx-auto".to_string()
/>

View file

@ -19,6 +19,8 @@
# Set shell for commands
set shell := ["bash", "-c"]
project_root := justfile_directory()
# =============================================================================
# FRAMEWORK MODULE IMPORTS
# =============================================================================
@ -32,7 +34,11 @@ import 'justfiles/docs.just'
import 'justfiles/content.just'
import 'justfiles/testing.just'
import 'justfiles/build.just'
import 'justfiles/assets.just'
import 'justfiles/aliases.just'
import 'justfiles/adr.just'
mod site 'justfiles/site.just'
# =============================================================================
# FRAMEWORK-SPECIFIC COMMANDS

20
justfiles/adr.just Normal file
View file

@ -0,0 +1,20 @@
# =============================================================================
# ADR CONSTRAINTS
# =============================================================================
# Executes the constraint checks declared by accepted ADRs in .ontoref/adrs/.
#
# Deliberately NOT wired into check-strict: part of the corpus is red against an
# untriaged backlog, and a gate that fails on day one gets disabled by the first
# person it blocks. Report first, enforce when the red is triaged.
# Run all accepted-ADR constraint checks and report per constraint
adr-check:
@nu {{ justfile_directory() }}/scripts/adr-constraints.nu
# Same, with the detail line for every constraint
adr-check-verbose:
@nu {{ justfile_directory() }}/scripts/adr-constraints.nu --verbose
# Same, but exit non-zero if any constraint fails (for when the red is triaged)
adr-check-strict:
@nu {{ justfile_directory() }}/scripts/adr-constraints.nu --strict

51
justfiles/assets.just Normal file
View file

@ -0,0 +1,51 @@
# Sync assets from the constellation parent (../assets/) into code/assets/.
# Canonical source: ../assets/ → materialized copy: code/assets/ (tracked in git)
[doc("Sync assets from constellation parent ../assets/ into code/assets/")]
sync-assets:
#!/usr/bin/env nu
let src = (($env.PWD | path dirname) | path join "assets")
let dst = ($env.PWD | path join "assets")
if not ($src | path exists) {
error make { msg: $"sync-assets: source not found: ($src)" }
}
def needs-copy [s: string, d: string]: nothing -> bool {
if not ($d | path exists) { return true }
(open --raw $s | hash sha256) != (open --raw $d | hash sha256)
}
def sync-file [s: string, d: string, label: string]: nothing -> record {
if (needs-copy $s $d) {
mkdir ($d | path dirname)
cp $s $d
{ file: $label, action: "copied", sha256: (open --raw $d | hash sha256) }
} else {
{ file: $label, action: "unchanged", sha256: (open --raw $d | hash sha256) }
}
}
mut results = []
# ── Logos for README.md ───────────────────────────────────────────────────
for f in ["rustelo_dev-logo-h.svg", "rustelo_dev-logo-b-h.svg", "rustelo_dev-logo-b-v.svg", "rustelo_dev-logo-v.svg", "rustelo-imag.svg", "github-img.png"] {
let s = ($src | path join "logos" $f)
if ($s | path exists) {
$results = ($results | append (sync-file $s ($dst | path join "logos" $f) $"logos/($f)"))
}
}
let digest_input = ($results | each { |r| $"($r.file):($r.sha256)" } | sort | str join "\n")
let aggregate = $"sha256:($digest_input | hash sha256)"
let synced_at = (date now | format date "%Y-%m-%dT%H:%M:%SZ")
{
source: ($src | into string),
aggregate_digest: $aggregate,
synced_at: $synced_at,
files: $results,
} | to json | save --force ($dst | path join ".sync-manifest.json")
let copied = ($results | where action == "copied" | length)
let unchanged = ($results | where action == "unchanged" | length)
print $"✓ sync-assets copied=($copied) unchanged=($unchanged) ($dst)"

View file

@ -57,6 +57,23 @@ build-prod:
@echo "🔨 Building for production..."
cargo leptos build --release
# Build for production with automatic WASM dispatch driven by the rendering profile
build-auto routes_dir="site/config/routes" workspace_config="site/config/rendering.toml" bin="":
@echo "🔍 Inspecting rendering profile for {{routes_dir}} + {{workspace_config}}..."
@if nu {{project_root}}/scripts/check-wasm-needed.nu --verbose \
--routes-dir {{routes_dir}} \
--workspace-config {{workspace_config}}; then \
echo "🔨 WASM required — running cargo leptos build --release"; \
cargo leptos build --release; \
else \
echo "🪶 Pure htmx-ssr — skipping wasm32 target"; \
if [ -n "{{bin}}" ]; then \
cargo build --release -p {{bin}} --no-default-features --features ssr,htmx-ssr; \
else \
cargo build --release --no-default-features --features ssr,htmx-ssr; \
fi \
fi
# Clean build artifacts
clean:
@echo "🧹 Cleaning build artifacts..."

View file

@ -50,3 +50,42 @@ build-all-assets:
@just build-design-system
@just build-theme
@just copy-css-assets
# =============================================================================
# DEPENDENCY SYNC - registry/Cargo.toml → workspace.dependencies
# =============================================================================
# `registry/Cargo.toml` is the single source of truth (editable in Zed via
# rust-analyzer's inline upgrade hints). These recipes propagate it to
# `rustelo/Cargo.toml` and to any implementation workspace.
# Propagate the registry into rustelo's own Cargo.toml managed region.
deps-sync:
@echo "🔗 Syncing rustelo/Cargo.toml from registry/Cargo.toml..."
cargo xtask sync-deps
# Verify rustelo/Cargo.toml matches the registry (exit 1 if drifted).
deps-sync-check:
@echo "🔍 Checking rustelo/Cargo.toml for drift from registry..."
cargo xtask sync-deps --check
# Propagate the registry into an implementation workspace.
# Example: just deps-sync-impl ../jpl-website
deps-sync-impl target:
@echo "🔗 Syncing {{target}}/Cargo.toml from registry/Cargo.toml..."
cargo xtask sync-deps --target {{target}}
# Verify an implementation's Cargo.toml matches the registry (exit 1 if drifted).
deps-sync-check-impl target:
@echo "🔍 Checking {{target}}/Cargo.toml for drift from registry..."
cargo xtask sync-deps --check --target {{target}}
# Sync rustelo + every implementation declared in registry/sync.toml [impls.known].
# Adding a new implementation is a registry/sync.toml edit — no recipe change needed.
deps-sync-all:
@echo "🔗 Syncing rustelo + every known implementation..."
cargo xtask sync-deps --all
# Verify rustelo + every implementation matches the registry (exit 1 if any drifts).
deps-sync-check-all:
@echo "🔍 Checking rustelo + every known implementation for drift..."
cargo xtask sync-deps --check --all

12
justfiles/site.just Normal file
View file

@ -0,0 +1,12 @@
# Website generator — scaffold a new Rustelo website from a template snapshot.
# Scaffold a new website. Interactive by default; pass flags + --yes for non-interactive.
# just site::new
# just site::new --project acme --render-mode htmx-ssr --domain acme.dev --ontoref minimal --yes
new *args:
nu {{justfile_directory()}}/scripts/generator/new-site.nu {{args}}
# Re-snapshot the template trees from the living model (jpl-website).
extract:
nu {{justfile_directory()}}/scripts/generator/extract-template.nu --mode htmx-ssr --out {{justfile_directory()}}/templates/website-htmx-ssr
nu {{justfile_directory()}}/scripts/generator/extract-template.nu --mode leptos --out {{justfile_directory()}}/templates/website-leptos

12
justfiles/workflow.just Normal file
View file

@ -0,0 +1,12 @@
# Generated by ore workflow generate
# Source: .ontology/workflow.ncl
# layer: ci-standard
ci-standard:
cargo clippy --all-targets --all-features -- -D warnings
cargo nextest run --all-features --workspace --profile ci --cargo-profile ci
cargo deny check licenses advisories
cargo doc --no-deps --workspace --profile ci -q
nickel typecheck
nu --ide-check 100
cargo build --release --workspace

148
scripts/adr-constraints.nu Normal file
View file

@ -0,0 +1,148 @@
#!/usr/bin/env nu
# Execute the constraint checks declared by accepted ADRs and report per constraint.
#
# Constraints live in `.ontoref/adrs/adr-*.ncl` at the constellation root. Each carries a
# `check` in one of three shapes:
#
# NuCmd { cmd, expect_exit } run cmd, compare exit code
# Grep { pattern, paths, must_be_empty }
# FileExists { path, present }
#
# Reported states:
# ✓ the check ran and passed
# ✗ the check ran and failed
# ⊘ no check ran — the constraint declares `cmd = "exit 0"` (a no-op that cannot fail)
# or its shape is unknown. Reported apart from ✓ on purpose: a constraint that cannot
# fail is not a satisfied constraint, and counting it green is the lie this runner exists
# to prevent.
#
# Only `Accepted` ADRs gate. `Proposed` ones are listed as skipped.
#
# Exits 0 regardless of findings unless `--strict` is passed, so this can be read before it
# is enforced.
def adrs_dir [] { $env.FILE_PWD | path join ".." ".." ".ontoref" "adrs" | path expand }
# A cmd that cannot fail is not a check. Extend this list as no-op idioms appear.
def is_noop [cmd: string] { ($cmd | str trim) in ["exit 0", "true", ""] }
def run_nucmd [check: record] {
if (is_noop ($check.cmd? | default "")) {
return { state: "skip", detail: "check is `exit 0` — a no-op that cannot fail" }
}
let expect = ($check.expect_exit? | default 0)
let r = (do --ignore-errors { ^nu -c $check.cmd } | complete)
if $r.exit_code == $expect {
{ state: "pass", detail: $"exit ($r.exit_code) == expected ($expect)" }
} else {
{ state: "fail", detail: $"exit ($r.exit_code), expected ($expect)" }
}
}
def run_grep [check: record] {
let paths = ($check.paths? | default [])
if ($paths | is-empty) {
return { state: "skip", detail: "Grep check declares no paths" }
}
let existing = ($paths | where { |p| ($p | path exists) })
if ($existing | is-empty) {
return { state: "skip", detail: $"none of the declared paths exist: ($paths | str join ', ')" }
}
let r = (do --ignore-errors { ^rg -n --no-heading $check.pattern ...$existing } | complete)
let hits = if ($r.stdout | str trim | is-empty) { 0 } else { $r.stdout | lines | length }
let must_be_empty = ($check.must_be_empty? | default true)
if $must_be_empty and $hits > 0 {
{ state: "fail", detail: $"($hits) hits, must_be_empty=true" }
} else if (not $must_be_empty) and $hits == 0 {
{ state: "fail", detail: "0 hits, must_be_empty=false" }
} else {
{ state: "pass", detail: $"($hits) hits" }
}
}
def run_fileexists [check: record] {
let want = ($check.present? | default true)
let got = ($check.path | path exists)
if $got == $want {
{ state: "pass", detail: $"present=($got), expected ($want)" }
} else {
{ state: "fail", detail: $"present=($got), expected ($want) — ($check.path)" }
}
}
def run_check [check: record] {
match ($check.tag? | default "UNKNOWN") {
"NuCmd" => (run_nucmd $check),
"Grep" => (run_grep $check),
"FileExists" => (run_fileexists $check),
_ => { state: "skip", detail: $"unknown check shape: ($check.tag? | default '<none>')" },
}
}
def main [
--strict # exit 1 if any constraint fails
--verbose # print the detail line for passing constraints too
] {
let dir = (adrs_dir)
let files = (glob ($dir | path join "adr-*.ncl")
| where { |f| not (($f | path basename) starts-with "_") }
| where { |f| ($f | path basename) not-in ["adr-defaults.ncl" "adr-schema.ncl" "adr-constraints.ncl"] }
| sort)
mut rows = []
mut skipped_adrs = []
for f in $files {
let export = (do --ignore-errors { ^nickel export $f } | complete)
if $export.exit_code != 0 {
print $"(ansi red)✗(ansi reset) ($f | path basename) — nickel export failed"
print ($export.stderr | lines | first 3 | str join "\n")
continue
}
let adr = ($export.stdout | from json)
if $adr.status != "Accepted" {
$skipped_adrs = ($skipped_adrs | append $"($adr.id) [($adr.status)]")
continue
}
for c in $adr.constraints {
let res = (run_check $c.check)
$rows = ($rows | append {
adr: $adr.id, id: $c.id, severity: ($c.severity? | default "Hard"),
state: $res.state, detail: $res.detail,
})
}
}
print $"(ansi default_bold)ADR constraints(ansi reset) — ($rows | length) from accepted ADRs\n"
for r in $rows {
let mark = match $r.state {
"pass" => $"(ansi green)✓(ansi reset)",
"fail" => $"(ansi red)✗(ansi reset)",
_ => $"(ansi yellow)⊘(ansi reset)",
}
if $r.state == "pass" and (not $verbose) {
print $" ($mark) ($r.adr) ($r.id)"
} else {
print $" ($mark) ($r.adr) ($r.id)"
print $" ($r.detail)"
}
}
let passed = ($rows | where state == "pass" | length)
let failed = ($rows | where state == "fail" | length)
let skipped = ($rows | where state == "skip" | length)
print ""
print $" (ansi green)($passed) ✓(ansi reset) · (ansi red)($failed) ✗(ansi reset) · (ansi yellow)($skipped) ⊘(ansi reset)"
if not ($skipped_adrs | is-empty) {
print $" not gating \(status\): ($skipped_adrs | str join ', ')"
}
if $skipped > 0 {
print $" (ansi yellow)⊘(ansi reset) = declared but unexecutable — neither green nor red. A no-op check is not a check."
}
if $strict and $failed > 0 {
exit 1
}
}

113
scripts/check-wasm-needed.nu Executable file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env nu
# Decide whether the workspace needs a wasm32 build for the Leptos hydration profile.
#
# Used by the `build-auto` justfile recipe (and any CI driver) to choose between:
# cargo leptos build --release (any route declares leptos-hydration)
# cargo build --release --no-default-features --features ssr,htmx-ssr
# (all routes are htmx-ssr)
#
# Decision sources, in precedence order:
# 1. A `rendering_profile = "leptos-hydration"` override on any per-route TOML.
# 2. `[rendering] default_profile = "leptos-hydration"` in the workspace config.
# 3. Absence of a workspace `[rendering]` table (Rustelo default is hydration).
#
# Exit codes (suited for shell `if`):
# 0 — wasm build needed (run cargo leptos)
# 1 — wasm build NOT needed (run cargo build with htmx-ssr features)
# 2 — argument or filesystem error
#
# Usage:
# nu scripts/check-wasm-needed.nu --routes-dir site/config/routes \
# --workspace-config site/config/rendering.toml
#
# nu scripts/check-wasm-needed.nu --verbose ... # also print the reason
def main [
--routes-dir: string # directory containing per-language route TOMLs; scanned recursively
--workspace-config: string # path to the workspace config TOML carrying the [rendering] table
--verbose # print one-line reason to stderr
] {
let routes_dir = if $routes_dir == null { null } else { $routes_dir }
let workspace_config = if $workspace_config == null { null } else { $workspace_config }
# 1. Any route-level override forces the decision.
if $routes_dir != null and ($routes_dir | path exists) {
let hits = (try { route_overrides $routes_dir } catch { |e|
print --stderr $"check-wasm-needed: failed to scan routes dir: ($e)"
exit 2
})
if ($hits | length) > 0 {
if $verbose {
let first = ($hits | first)
print --stderr $"wasm needed: route override (($first.file)) declares leptos-hydration"
}
exit 0
}
}
# 2. Workspace default.
let workspace_says_hydrate = if $workspace_config != null and ($workspace_config | path exists) {
workspace_default_is_hydration $workspace_config
} else {
true # 3. Absent table → Rustelo default is hydration.
}
if $workspace_says_hydrate {
if $verbose {
print --stderr "wasm needed: workspace default is leptos-hydration (explicit or implicit)"
}
exit 0
} else {
if $verbose {
print --stderr "wasm skipped: workspace default is htmx-ssr and no route opts back into leptos-hydration"
}
exit 1
}
}
# Returns rows {file, line} for every route TOML under `dir` whose
# `rendering_profile` field is `"leptos-hydration"`.
def route_overrides [dir: string] {
glob $"($dir)/**/*.toml" | each {|file|
let content = (open --raw $file)
$content | lines | enumerate | each {|row|
let line = $row.item
if ($line | str replace --all ' ' '' | str contains 'rendering_profile="leptos-hydration"') {
{ file: $file, line: ($row.index + 1) }
} else {
null
}
} | where { |it| $it != null }
} | flatten
}
# Parses the workspace config (TOML or NCL) and returns true when the
# rendering.default_profile field is missing or set to "leptos-hydration".
#
# NCL workspaces are read as text and the field is grepped (avoids requiring
# the nickel CLI in PATH for this dispatch decision). The grep is intentionally
# strict — only single-line `default_profile = "<value>"` syntax is recognised.
def workspace_default_is_hydration [config_path: string] {
let extension = ($config_path | path parse | get extension)
if $extension == "ncl" {
let content = (try { open --raw $config_path } catch { return true })
# Look for a single-line `default_profile = "..."` assignment.
let htmx_line = (
$content
| lines
| each { |l| $l | str replace --all ' ' '' }
| where { |l| $l | str contains 'default_profile="htmx-ssr"' }
)
($htmx_line | length) == 0
} else {
let parsed = (try { open $config_path } catch { return true })
let table = ($parsed.rendering? | default null)
if $table == null {
true
} else {
let value = ($table.default_profile? | default "leptos-hydration")
$value == "leptos-hydration"
}
}
}

View file

@ -0,0 +1,152 @@
#!/usr/bin/env nu
# extract-template.nu — derive a website template from a living model project
# (jpl-website) as a sanitized snapshot with safe defaults + COMPLETE markers.
#
# Reproducible seed for templates/website-htmx-ssr and templates/website-leptos.
# Re-run to re-snapshot when the model evolves (snapshot model, not a live mirror).
#
# nu extract-template.nu --mode htmx-ssr --out ../templates/website-htmx-ssr
# nu extract-template.nu --mode leptos --out ../templates/website-leptos
#
# Design:
# - crate names stay neutral ("website-*"); the generator renames to the chosen
# project later. Only the jpl-named pages_htmx crate is normalised to website-*.
# - deploy identity (domain, registry, secrets base, author) is replaced with
# safe defaults so NCL still parses and the site runs, each marked COMPLETE.
# - jesusperez portfolio pages and real content are pruned to generic examples.
# Binary / huge extensions to skip during sanitization (everything else is text).
def binary-ext [] {
[png jpg jpeg gif ico svg webp avif woff woff2 ttf otf eot wasm db "db-shm" "db-wal" pdf zip tgz gz xz lock map]
}
# Ordered, specific-before-generic substitutions applied to text files.
def subs [] {
[
# crate normalisation (the one jpl-named crate)
["jpl-pages-htmx" "website-pages-htmx"]
["jpl_pages_htmx" "website_pages_htmx"]
["jpl-website" "website"]
["jpl_website" "website"]
# deploy identity → safe defaults (COMPLETE: set to your own)
["hello@jesusperez.pro" "hello@example.com"]
["dev@jesusperez.pro" "dev@example.com"]
["repo.jesusperez.pro/jesus" "repo.example.com/org"]
["daoreg.librecloud.online" "registry.example.com"]
["daoreg" "registry"]
["reg.librecloud.online" "registry.example.com"]
["librecloud.online" "example.com"]
["jesusperez.pro" "example.com"]
["reg-librecloud-pull" "registry-pull-secret"]
["librecloud-fip-wuji" "gateway-fip"]
["libre-wuji" "secrets-base"]
["librecloud" "example"]
["jesusperezlorenzo" "example-org"]
["@jesusperez_dev" "@example_dev"]
["JesusPerez" "ExampleOrg"]
["jesusperez" "example"]
]
}
def main [
--mode: string = "htmx-ssr" # htmx-ssr | leptos
--src: string = "/Users/Akasha/Development/jpl-website"
--out: string # target template dir (required)
] {
if ($out | is-empty) { error make { msg: "--out is required" } }
if ($mode not-in ["htmx-ssr" "leptos"]) { error make { msg: $"bad --mode ($mode)" } }
let src = ($src | path expand)
let out = ($out | path expand)
if not ($src | path exists) { error make { msg: $"src not found: ($src)" } }
print $"[extract] mode=($mode) src=($src) out=($out)"
if ($out | path exists) { rm -rf $out }
mkdir $out
# --- 1. rsync the tree with explicit excludes -------------------------------
let common_excludes = [
".git/" ".gitignore" "target/" "node_modules/" ".env" ".env.dev" ".k"
"cache/" "dist/" "works-pv/" "lian-build-out/" ".DS_Store"
".ontoref/" ".ontoref-backup-pre-0023.tgz" ".coder/" ".config/" ".cargo/"
".claude/" ".rustelo.ontoref/" ".woodpecker/"
"Cargo.lock" "pnpm-lock.yaml" "rel.sh" "RESUME.md" "test_img_gen.sh"
"card.ncl" "rustelo-deps-overlay.toml"
# jpl-only top-level cruft
"data/" "rustelo/" "crates/site/" "templates/" ".woodpecker/" ".npmrc"
"just/claude-admin.just" "just/claude-content.just" "just/claude-dev.just"
# cluster/workspace-specific deploy bundles — a new site generates its own
"provisioning/workspaces/" "provisioning/rustelo_website/"
# real content — replaced by seed example post + pruned extras
"site/content/blog/"
"site/content/projects/" "site/content/recipes/" "site/content/activities/"
"site/guide/" "site/info/" "site/reflection/" "site/public/kitdigital/"
"site/public/r/" "site/public/content/" "site/public/docs/"
# build artifacts in lian-build
"lian-build/save/" "lian-build/all_Dockerfile" "lian-build/Dockerfile.server"
"lian-build/build_directives_server.ncl"
# jesusperez portfolio pages (htmx j2)
"crates/pages_htmx/templates/pages/kogral.j2"
"crates/pages_htmx/templates/pages/ontoref.j2"
"crates/pages_htmx/templates/pages/provisioning.j2"
"crates/pages_htmx/templates/pages/rustelo.j2"
"crates/pages_htmx/templates/pages/secretumvault.j2"
"crates/pages_htmx/templates/pages/stratumiops.j2"
"crates/pages_htmx/templates/pages/syntaxis.j2"
"crates/pages_htmx/templates/pages/typedialog.j2"
"crates/pages_htmx/templates/pages/vapora.j2"
# jpl logos (binary, name-bearing)
"site/assets/logos/" "site/public/images/logos/"
]
# NOTE: crates are NOT split by mode. jpl's server hard-depends on website-pages
# and optionally website-client; mode is selected via cargo features + cfg, not
# crate membership. Both templates carry the full workspace; only rendering.ncl
# default_profile, the kept Dockerfile, and build features differ. (A pure-htmx
# site can prune the leptos crates later only after making those deps optional.)
let excludes = $common_excludes
let exfile = (mktemp -t rsync-ex.XXXXXX)
$excludes | str join "\n" | save -f $exfile
(^rsync -a --exclude-from $exfile $"($src)/" $"($out)/")
rm -f $exfile
# --- 2. sanitize text files -------------------------------------------------
let bin = (binary-ext)
let files = (glob ($out | path join "**" "*") --no-dir
| where { |f| (($f | path parse | get extension) not-in $bin) and (not ($f | str contains "node_modules")) })
let table = (subs)
mut done = 0
for f in $files {
try {
mut content = (open --raw $f | decode utf-8)
for pair in $table {
$content = ($content | str replace --all ($pair | get 0) ($pair | get 1))
}
$content | save -f $f
$done = $done + 1
} catch {
# binary or non-utf8 file — leave untouched
}
}
print $"[extract] sanitized ($done) / ($files | length) text files"
# --- 3. mode-specific tweaks ------------------------------------------------
# rendering profile default
let rendering = ($out | path join "site" "config" "rendering.ncl")
if ($rendering | path exists) {
let want = (if $mode == "htmx-ssr" { "htmx-ssr" } else { "leptos-hydration" })
open --raw $rendering | decode utf-8 | str replace --all --regex 'default_profile\s*=\s*"[^"]*"' $'default_profile = "($want)"' | save -f $rendering
}
# workspace members are left as-is (full workspace; see note above).
# drop the non-active Dockerfile (rsync excludes are unreliable for single files here)
let drop_dockerfile = (if $mode == "htmx-ssr" { "Dockerfile.leptos-hydration" } else { "Dockerfile.htmx-ssr" })
let df = ($out | path join "lian-build" $drop_dockerfile)
if ($df | path exists) { rm -f $df }
# --- 4. overlay seed (example content, setup doc) ---------------------------
let seed = ($env.FILE_PWD | path join "seed" "common")
if ($seed | path exists) {
^rsync -a $"($seed)/" $"($out)/"
print $"[extract] seed overlaid from ($seed)"
}
print $"[extract] done: ($out)"
}

View file

@ -0,0 +1,190 @@
#!/usr/bin/env nu
# new-site.nu — scaffold a new Rustelo website from a self-contained template.
#
# Interactive: nu new-site.nu
# Non-interactive (InitConfig-style, like `cargo rustelo init --config`):
# nu new-site.nu --project acme --render-mode htmx-ssr --domain acme.dev \
# --site-title "Acme" --languages "en,es" --default-lang en --ontoref minimal --yes
#
# The site is created as a sibling of the rustelo repo (constellation or flat).
# All {{RUSTELO_ROOT}} placeholders in template files are replaced with the
# correct relative path from each file's execution context to the framework root.
def fw-root [] { $env.FILE_PWD | path join ".." ".." | path expand }
def ask [prompt: string, default: string] {
let ans = (input $"($prompt) [($default)]: ")
if ($ans | str trim | is-empty) { $default } else { ($ans | str trim) }
}
def snake [s: string] { $s | str replace --all "-" "_" }
# Compute the relative path from directory `from` to directory `to`.
# Both must be absolute expanded paths.
def rel-path [from: string, to: string] {
let from_parts = ($from | path split | where { |p| ($p | str length) > 0 })
let to_parts = ($to | path split | where { |p| ($p | str length) > 0 })
let min_len = ([$from_parts $to_parts] | each { length } | math min)
mut common = 0
for i in 0..<$min_len {
if ($from_parts | get $i) == ($to_parts | get $i) {
$common = ($common + 1)
} else {
break
}
}
let ups = ($from_parts | length) - $common
let down = ($to_parts | skip $common)
let parts = ((0..<$ups | each { ".." }) ++ $down)
if ($parts | is-empty) { "." } else { $parts | str join "/" }
}
# Replace {{RUSTELO_ROOT}} in every text file under `root` with the correct
# relative path from that file's execution-context directory to `fw`.
#
# Files under `workspace_root_dirs` (like justfiles/) run in the workspace
# root cwd context even though they sit in a subdirectory, so they receive the
# workspace-root-relative path rather than the file-directory-relative path.
def apply-rustelo-root [root: string, fw: string] {
let bin = [png jpg jpeg gif ico svg webp avif woff woff2 ttf otf eot wasm
db "db-shm" "db-wal" pdf zip tgz gz xz lock map]
let workspace_root_dirs = ["justfiles"]
let files = (glob ($root | path join "**" "*") --no-dir
| where { |f| ($f | path parse | get extension) not-in $bin }
| where { |f| not ($f | str contains "node_modules") })
for f in $files {
try {
let c = (open --raw $f | decode utf-8)
if not ($c | str contains "{{RUSTELO_ROOT}}") { continue }
let file_dir = ($f | path dirname)
let rel_parts = ($file_dir | path relative-to $root | path split
| where { |p| ($p | str length) > 0 })
let effective_dir = if (($rel_parts | length) > 0
and ($rel_parts | first) in $workspace_root_dirs) {
$root
} else {
$file_dir
}
let rustelo_rel = (rel-path $effective_dir $fw)
($c | str replace --all "{{RUSTELO_ROOT}}" $rustelo_rel) | save -f $f
} catch { }
}
}
def main [
--project: string = ""
--render-mode: string = ""
--site-title: string = ""
--domain: string = ""
--languages: string = ""
--default-lang: string = ""
--ontoref: string = ""
--out: string = "" # parent dir for the new site (default: sibling of fw repo)
--yes # non-interactive: take provided flags / defaults
] {
let fw = (fw-root)
let opts = (^nickel export ($fw | path join "templates" "options.ncl") | from json)
let defs = ($opts.questions | reduce --fold {} { |q, acc| $acc | insert $q.id $q.default })
# resolve each answer: flag > prompt (interactive) > default
let interactive = (not $yes)
let project = (if ($project | is-not-empty) { $project } else if $interactive { ask "Project name (kebab-case)" $defs.project } else { $defs.project })
let render_mode = (if ($render_mode | is-not-empty) { $render_mode } else if $interactive { ask "Render mode (htmx-ssr|leptos-hydration)" $defs.render_mode } else { $defs.render_mode })
let site_title = (if ($site_title | is-not-empty) { $site_title } else if $interactive { ask "Site title" $defs.site_title } else { $defs.site_title })
let domain = (if ($domain | is-not-empty) { $domain } else if $interactive { ask "Domain (no scheme)" $defs.domain } else { $defs.domain })
let languages = (if ($languages | is-not-empty) { $languages } else if $interactive { ask "Languages (csv)" $defs.languages } else { $defs.languages })
let default_lang = (if ($default_lang | is-not-empty) { $default_lang } else if $interactive { ask "Default language" $defs.default_lang } else { $defs.default_lang })
let ontoref = (if ($ontoref | is-not-empty) { $ontoref } else if $interactive { ask "ontoref onboarding (full|minimal|none)" $defs.ontoref } else { $defs.ontoref })
if ($render_mode not-in ($opts.template_for | columns)) {
error make { msg: $"unknown render_mode ($render_mode); expected one of ($opts.template_for | columns)" }
}
let tmpl_name = ($opts.template_for | get $render_mode)
let tmpl = ($fw | path join "templates" $tmpl_name)
if not ($tmpl | path exists) { error make { msg: $"template not found: ($tmpl)" } }
# Determine parent directory for the new site.
# In constellation layout (fw basename == "code"), the fw repo root is one
# level up, and the site should be a sibling of that repo root.
let parent = if ($out | is-not-empty) {
($out | path expand)
} else if (($fw | path basename) == "code") {
$fw | path dirname | path dirname
} else {
$fw | path dirname
}
let dest = ($parent | path join $project)
if ($dest | path exists) { error make { msg: $"destination exists: ($dest)" } }
print $"[new-site] ($project) mode=($render_mode) → ($dest)"
cp -r $tmpl $dest
rm -f ($dest | path join "TEMPLATE-SETUP.md") # re-added at end with status
# Substitute {{RUSTELO_ROOT}} with the correct per-file relative path to fw
apply-rustelo-root $dest $fw
# --- rename crate identifiers website-* → <project>-* (skip if project == website)
if $project != "website" {
let p = $project
let ps = (snake $project)
let renames = [
["website-pages-htmx" $"($p)-pages-htmx"] ["website_pages_htmx" $"($ps)_pages_htmx"]
["website-pages" $"($p)-pages"] ["website_pages" $"($ps)_pages"]
["website-client" $"($p)-client"] ["website_client" $"($ps)_client"]
["rustelo-htmx-server" $"($p)-htmx-server"] ["rustelo_htmx_server" $"($ps)_htmx_server"]
["rustelo-leptos-server" $"($p)-leptos-server"] ["rustelo_leptos_server" $"($ps)_leptos_server"]
["website-shared" $"($p)-shared"] ["website_shared" $"($ps)_shared"]
["name = \"website\"" $"name = \"($p)\""] # leptos metadata output name
]
sanitize-tree $dest $renames
}
# --- set deploy/identity values
let domain_subs = [
["example.com" $domain]
]
sanitize-tree $dest $domain_subs
# --- ontoref onboarding
let onto = ($env.FILE_PWD | path join "onto-onboard.nu")
nu $onto --level $ontoref --root $dest --project $project --languages $languages --profile $render_mode
# --- write a fresh setup checklist with the resolved values
[
$"# ($site_title) — generated site"
""
$"- project: ($project) render mode: ($render_mode) languages: ($languages) (default ($default_lang))"
$"- domain: ($domain) ontoref: ($ontoref)"
""
"## Remaining COMPLETE items"
"- site/config/site.ncl — set site name (title) and languages/default_language."
"- site/config/routes.ncl — your routes; content under site/content/."
"- .env (copy from .env.example) — session secret, DB, SMTP/OAuth."
"- provisioning/project.ncl + lian-build/build_directives.ncl — registry, namespace, secrets (search: example.com, registry.example.com, secrets-base)."
"- Logos under site/public/images/logos/ + site/config/site.ncl logo.*"
"- Replace the example post in site/content/blog/{en,es}/getting-started/."
""
"Build: just build-auto (auto-detects profile)"
"Pack: just build::distro"
"Verify: cargo check"
] | str join "\n" | save -f ($dest | path join "SETUP.md")
print ""
print $"[new-site] done → ($dest)"
print $" next: cd ($dest); cat SETUP.md; cargo check"
}
# replace a list of [from to] pairs across all text files in a tree (binary-safe).
def sanitize-tree [root: string, pairs: list] {
let bin = [png jpg jpeg gif ico svg webp avif woff woff2 ttf otf eot wasm db "db-shm" "db-wal" pdf zip tgz gz xz lock map]
let files = (glob ($root | path join "**" "*") --no-dir
| where { |f| (($f | path parse | get extension) not-in $bin) and (not ($f | str contains "node_modules")) })
for f in $files {
try {
mut c = (open --raw $f | decode utf-8)
for pair in $pairs { $c = ($c | str replace --all ($pair | get 0) ($pair | get 1)) }
$c | save -f $f
} catch { }
}
}

View file

@ -0,0 +1,71 @@
#!/usr/bin/env nu
# onto-onboard.nu — stamp ontoref onboarding into a generated site.
#
# nu onto-onboard.nu --level full --root <site> --project <id> --languages "en,es"
# nu onto-onboard.nu --level minimal --root <site> --project <id>
# nu onto-onboard.nu --level none --root <site> --project <id>
#
# Levels:
# none — do nothing. The site is not governed by ontoref.
# minimal — write .ontoref/{config.ncl, card.ncl} + schemas + a `just onto::init`
# hint. The owner runs `ontoref setup` later to scaffold the rest.
# full — minimal + delegate to `ontoref setup` (the tool's own onboarding,
# which scaffolds ontology/reflection/adrs from its bundled templates).
#
# The site's own .ontoref/ governs the SITE's project. The rustelo DOMAIN ontology
# is referenced (not copied) via .rustelo.ontoref → framework. Domain authoring lives
# elsewhere; this helper only writes a pointer.
def stamp [content: string, dest: string, project: string, languages: string, profile: string] {
$content
| str replace --all "{{ project_name }}" $project
| str replace --all "{{project_name}}" $project
| str replace --all "{{ languages }}" $languages
| str replace --all "{{ render_profile }}" $profile
| save -f $dest
}
def main [
--level: string = "minimal" # none | minimal | full
--root: string # generated site root (required)
--project: string = "website" # project slug/id
--languages: string = "en,es"
--profile: string = "htmx-ssr"
] {
if ($root | is-empty) { error make { msg: "--root is required" } }
if ($level not-in ["none" "minimal" "full"]) { error make { msg: $"bad --level ($level)" } }
let root = ($root | path expand)
if $level == "none" {
print "[onto] level=none — skipping ontoref onboarding"
return
}
let skel = ($env.FILE_PWD | path join ".." ".." "templates" "_ontoref-skeleton" | path expand)
let onto = ($root | path join ".ontoref")
mkdir $onto
mkdir ($onto | path join "reflection" "schemas")
mkdir ($onto | path join "ontology" "schemas")
# config + card + their schemas (minimal floor)
stamp (open --raw ($skel | path join "minimal" "config.ncl") | decode utf-8) ($onto | path join "config.ncl") $project $languages $profile
stamp (open --raw ($skel | path join "minimal" "card.ncl") | decode utf-8) ($onto | path join "card.ncl") $project $languages $profile
cp ($skel | path join "minimal" "schemas" "project-card.ncl") ($onto | path join "ontology" "schemas" "project-card.ncl")
cp ($skel | path join "minimal" "schemas" "backlog.ncl") ($onto | path join "reflection" "schemas" "backlog.ncl")
# domain reference (pointer only — do not author the domain layer here)
let domain_ref = ($root | path join ".rustelo.ontoref")
mkdir $domain_ref
"# .rustelo.ontoref — reference to the Rustelo domain ontology.\n# The domain layer is authored in the framework, not here. This marker tells\n# ontoref where the framework's domain ontology lives for cross-project browsing.\n# Set DOMAIN_ROOT to your rustelo checkout (sibling by default).\n" | save -f ($domain_ref | path join "README.md")
print $"[onto] minimal skeleton stamped at ($onto)"
if $level == "full" {
# delegate ontology/reflection/adrs scaffolding to the tool's own onboarding.
let ok = (do { ^ontoref setup } | complete)
if $ok.exit_code == 0 {
print "[onto] full — ontoref setup completed"
} else {
print "[onto] full — `ontoref setup` unavailable here; run it in the site root to finish onboarding"
}
}
}

View file

@ -0,0 +1,41 @@
# Template setup — complete these before going live
This site was generated from a Rustelo website template (a sanitized snapshot of a
living site). It builds and runs with placeholder defaults. Replace each item below.
Search the tree for `example.com`, `Your Name`, and `# COMPLETE:` to find spots.
## 1. Identity (required)
- `site/config/site.ncl` — site name, languages, default language.
- `site/config/seo.ncl` / `server.ncl``base_url` (currently `https://example.com`).
- Author/byline strings in `crates/*/templates/pages/about.j2`, `contact.j2`.
## 2. Branding (required)
- Logos were not shipped. Add yours under `site/public/images/logos/` and point
`site/config/site.ncl` `logo.*` at them.
- Theme: `site/assets/styles/themes/*.toml` + `uno.config.ts`.
## 3. Content (required)
- Replace the example post: `site/content/blog/{en,es}/getting-started/`.
- Add pages under `site/content/pages/` and routes in `site/config/routes.ncl`.
## 4. Secrets & services (required for auth/email/deploy)
- Copy `.env.example``.env`; fill session secret, DB URL, SMTP/OAuth.
- `site/config/{email.ncl,external-services.ncl,auth.toml}` — providers.
## 5. Deploy (required only to ship images)
- `provisioning/project.ncl` — namespace, image ref, PVC, pull secret
(`registry-pull-secret`), TLS issuer/zone. All default to `example.com`.
- `lian-build/build_directives.ncl` — registry (`registry.example.com`), signing,
cache, secrets base (`secrets-base`).
- `provisioning/build.nu``SECRETS_BASE` env / workspace.
## 6. Render profile
- `site/config/rendering.ncl` `default_profile` is preset for this template's mode.
Switch profiles only with a matching build (`just build-auto` auto-detects).
## 7. ontoref (optional)
- If you chose ontoref onboarding, `.ontoref/` governs this project. Run
`ONTOREF_ACTOR=agent ontoref describe project` to verify. The rustelo domain
ontology is referenced (not copied) via `.rustelo.ontoref`.
Delete this file once setup is done.

View file

@ -0,0 +1,39 @@
---
# Post metadata
id: "hello-world"
title: "Hello, World"
slug: "hello-world"
subtitle: "Your first post in this Rustelo website"
excerpt: "A starter post showing the blog front-matter schema. Replace this with your own content. Every field below is consumed by the blog list and post pages."
# Publication info
author: "Your Name"
date: "2026-01-01"
published: true
featured: true
# Categorization
category: "getting-started"
tags: ["rustelo", "example"]
# Display
read_time: "2 min read"
sort_order: 1
css_class: "category-getting-started"
category_description: "Getting started"
category_published: true
---
# Hello, World
This is an example post shipped with the template. It exists so the blog list,
category filters, and post page render out of the box.
## What to do next
- Edit or delete this file and `../../es/getting-started/hola-mundo.md`.
- Add posts under `site/content/blog/<lang>/<category>/`.
- Keep the front-matter keys above — they drive the list and detail pages.
Both render profiles (`htmx-ssr` and `leptos-hydration`) read the same content;
nothing here is profile-specific.

View file

@ -0,0 +1,39 @@
---
# Post metadata
id: "hello-world"
title: "Hola, Mundo"
slug: "hola-mundo"
subtitle: "Tu primer post en este sitio Rustelo"
excerpt: "Un post inicial que muestra el esquema de front-matter del blog. Sustitúyelo por tu propio contenido. Todos los campos de abajo los consumen el listado y la página de post."
# Publication info
author: "Tu Nombre"
date: "2026-01-01"
published: true
featured: true
# Categorization
category: "getting-started"
tags: ["rustelo", "ejemplo"]
# Display
read_time: "2 min de lectura"
sort_order: 1
css_class: "category-getting-started"
category_description: "Primeros pasos"
category_published: true
---
# Hola, Mundo
Este es un post de ejemplo incluido en el template. Existe para que el listado del
blog, los filtros por categoría y la página de post funcionen desde el primer arranque.
## Qué hacer ahora
- Edita o borra este fichero y `../../en/getting-started/hello-world.md`.
- Añade posts en `site/content/blog/<lang>/<categoria>/`.
- Mantén las claves de front-matter de arriba — alimentan el listado y el detalle.
Ambos perfiles de render (`htmx-ssr` y `leptos-hydration`) leen el mismo contenido;
nada aquí depende del perfil.

97
scripts/vendor-htmx.nu Executable file
View file

@ -0,0 +1,97 @@
#!/usr/bin/env nu
# Vendor htmx + extensions declared in templates/shared/htmx/htmx.lock.toml.
#
# Usage:
# nu scripts/vendor-htmx.nu # re-download every file listed in the lockfile
# nu scripts/vendor-htmx.nu --verify # only verify SHA-384 of files already on disk
# nu scripts/vendor-htmx.nu --update X.Y # bump runtime + all extensions to version X.Y and refresh hashes
const LOCKFILE = "templates/shared/htmx/htmx.lock.toml"
const HTMX_DIR = "templates/shared/htmx"
def main [
--verify # do not download; only check existing files match recorded hashes
--update: string # bump runtime version, re-download all entries, rewrite the lockfile
] {
let lock = open $LOCKFILE
if $update != null {
update_runtime $lock $update
return
}
let runtime_entry = {
file: $lock.runtime.file,
source_url: $lock.runtime.source_url,
sha384: $lock.runtime.sha384,
size_bytes: $lock.runtime.size_bytes,
}
let all_entries = [$runtime_entry] | append (
$lock.extensions | each {|e|
{
file: $e.file,
source_url: $e.source_url,
sha384: $e.sha384,
size_bytes: $e.size_bytes,
}
}
)
if $verify {
verify_all $all_entries
} else {
download_all $all_entries
}
}
def download_all [entries: list] {
for entry in $entries {
let path = ($HTMX_DIR | path join $entry.file)
let parent = ($path | path dirname)
mkdir $parent
^curl -sSfL --max-time 30 -o $path $entry.source_url
let actual = (^shasum -a 384 $path | split row " " | first)
if $actual != $entry.sha384 {
print $"FAIL ($entry.file): expected ($entry.sha384), got ($actual)"
exit 1
}
let size = $entry.size_bytes
print $"OK ($entry.file) ($size) bytes"
}
}
def verify_all [entries: list] {
mut failures = 0
for entry in $entries {
let path = ($HTMX_DIR | path join $entry.file)
if not ($path | path exists) {
print $"MISSING ($entry.file)"
$failures = $failures + 1
continue
}
let actual = (^shasum -a 384 $path | split row " " | first)
if $actual != $entry.sha384 {
print $"MISMATCH ($entry.file): expected ($entry.sha384), got ($actual)"
$failures = $failures + 1
} else {
print $"OK ($entry.file)"
}
}
if $failures > 0 {
print $"($failures) failure(s)"
exit 1
}
}
def update_runtime [lock, new_version: string] {
let new_runtime_url = $"https://unpkg.com/htmx.org@($new_version)/dist/htmx.min.js"
print $"Updating htmx runtime to ($new_version)"
let runtime_path = ($HTMX_DIR | path join $lock.runtime.file)
^curl -sSfL --max-time 30 -o $runtime_path $new_runtime_url
let runtime_hash = (^shasum -a 384 $runtime_path | split row " " | first)
let runtime_size = (ls $runtime_path | first | get size | into int)
print $"Wrote runtime: ($runtime_hash) ($runtime_size) bytes"
print "Edit templates/shared/htmx/htmx.lock.toml manually with the new version/hash/size, then re-run --verify."
}