chore: update
This commit is contained in:
parent
4a90b72576
commit
c0281e759c
47 changed files with 4300 additions and 88 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -109,3 +109,6 @@ SETUP_COMPLETE.md
|
||||||
|
|
||||||
# Archive and working directory
|
# Archive and working directory
|
||||||
.wrks/
|
.wrks/
|
||||||
|
|
||||||
|
# sync-assets manifest — local artefact written by just sync-assets
|
||||||
|
assets/.sync-manifest.json
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,9 @@ repos:
|
||||||
exclude: ^\.woodpecker/
|
exclude: ^\.woodpecker/
|
||||||
|
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
exclude: ^features/.*/site/public/js/
|
||||||
|
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude: \.md$
|
exclude: (\.md$|^features/.*/site/public/js/)
|
||||||
|
|
||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
|
|
|
||||||
|
|
@ -1,84 +1,51 @@
|
||||||
# Woodpecker CI Pipeline
|
# Generated by ore workflow generate — layer: ci-standard
|
||||||
# Equivalent to GitHub Actions CI workflow
|
# Source: .ontology/workflow.ncl
|
||||||
# Generated by dev-system/ci
|
# Do not edit manually — regenerate with: ore workflow generate --layer ci-standard
|
||||||
|
|
||||||
when:
|
when:
|
||||||
event: [push, pull_request, manual]
|
event: [push, pull_request, manual]
|
||||||
branch:
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# === LINTING ===
|
rust-clippy-all:
|
||||||
|
|
||||||
lint-rust:
|
|
||||||
image: rust:latest
|
image: rust:latest
|
||||||
commands:
|
commands:
|
||||||
- curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
- cargo clippy --all-targets --all-features -- -D warnings
|
||||||
- rustup component add clippy
|
|
||||||
- cargo fmt --all -- --check
|
|
||||||
- cargo clippy --all-targets -- -D warnings
|
|
||||||
|
|
||||||
lint-bash:
|
nickel-typecheck:
|
||||||
image: koalaman/shellcheck-alpine:stable
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl bash
|
|
||||||
- find . -name '*.sh' -type f ! -path './target/*' -exec shellcheck {} +
|
|
||||||
|
|
||||||
lint-nickel:
|
|
||||||
image: rust:latest
|
image: rust:latest
|
||||||
commands:
|
commands:
|
||||||
- cargo install nickel-lang-cli --locked
|
- 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
|
image: rust:latest
|
||||||
commands:
|
commands:
|
||||||
- cargo install nu --locked
|
- 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:
|
nextest-ci:
|
||||||
image: node:alpine
|
|
||||||
commands:
|
|
||||||
- npm install -g markdownlint-cli2
|
|
||||||
- markdownlint-cli2 '**/*.md' '#node_modules' '#target'
|
|
||||||
|
|
||||||
# === TESTING ===
|
|
||||||
|
|
||||||
test:
|
|
||||||
image: rust:latest
|
image: rust:latest
|
||||||
commands:
|
commands:
|
||||||
- cargo test --workspace --all-features
|
- cargo install cargo-nextest --locked
|
||||||
depends_on:
|
- cargo nextest run --all-features --workspace --profile ci --cargo-profile ci
|
||||||
- lint-rust
|
depends_on: ["rust-clippy-all", "nickel-typecheck", "nushell-check"]
|
||||||
- lint-bash
|
environment:
|
||||||
- lint-nickel
|
RUST_BACKTRACE: 1
|
||||||
- lint-nushell
|
|
||||||
- lint-markdown
|
|
||||||
|
|
||||||
# === BUILD ===
|
deny-subset:
|
||||||
|
|
||||||
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:
|
|
||||||
image: rust:latest
|
image: rust:latest
|
||||||
commands:
|
commands:
|
||||||
- cargo install cargo-deny --locked
|
- cargo install cargo-deny --locked
|
||||||
- cargo deny check licenses advisories
|
- 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"]
|
||||||
|
|
|
||||||
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Fixed
|
||||||
- **rustelo_server**: Resolved 302 compilation errors after dependency updates
|
- **rustelo_server**: Resolved 302 compilation errors after dependency updates
|
||||||
- Removed duplicate module declarations (`auth`, `content`) in lib.rs
|
- Removed duplicate module declarations (`auth`, `content`) in lib.rs
|
||||||
|
|
|
||||||
83
README.md
83
README.md
|
|
@ -1,11 +1,11 @@
|
||||||
<div align="center">
|
<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>
|
</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.
|
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">
|
<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>
|
</div>
|
||||||
|
|
||||||
>[!NOTE]
|
>[!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!
|
**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)
|
### Option 1: One-Command Setup (Recommended)
|
||||||
```bash
|
```bash
|
||||||
# Clone and install everything automatically
|
# Clone and install everything automatically
|
||||||
|
|
@ -563,9 +583,46 @@ rustelo_core_lib::load_resources_from_config()?;
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
- [Plugin Architecture Guide](../docs/architecture/rustelo-plugin-architecture.md)
|
- [Plugin Architecture Guide](./docs/architecture/rustelo-plugin-architecture.md)
|
||||||
- [Plugin Development Guide](./.coder/info/PHASE3-PLUGIN-DEVELOPMENT-GUIDE.md)
|
- [ADR-004: Plugin Architecture Level 5](./adrs/adr-004-plugin-architecture-level5.ncl)
|
||||||
- [Example Plugin](../website/website-impl/crates/plugin-example-theme/)
|
- [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
|
## 🏗️ Project Structure
|
||||||
|
|
||||||
|
|
@ -603,13 +660,17 @@ template/
|
||||||
## 🚀 Development
|
## 🚀 Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Rust 1.75+
|
- **Rust 1.75+** - Core language and toolchain
|
||||||
- Node.js 18+ (for frontend tooling)
|
- **Node.js 18+** - Frontend tooling and asset processing
|
||||||
- PostgreSQL (if using database features)
|
- **Nickel** (Configuration language) - **REQUIRED** for type-safe configuration
|
||||||
- **mdBook** (for documentation) - `cargo install mdbook`
|
- macOS: `brew install nickel`
|
||||||
- **Just** (task runner) - `cargo install just`
|
- 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
|
### Quick Setup
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
14
features/content-graph/Cargo.toml
Normal file
14
features/content-graph/Cargo.toml
Normal 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"
|
||||||
65
features/content-graph/feature.toml
Normal file
65
features/content-graph/feature.toml
Normal 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"
|
||||||
262
features/content-graph/js/cytoscape-shim.js
Normal file
262
features/content-graph/js/cytoscape-shim.js
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -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();
|
||||||
458
features/content-graph/site/public/js/cytoscape-cose-bilkent.js
Normal file
458
features/content-graph/site/public/js/cytoscape-cose-bilkent.js
Normal 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;
|
||||||
|
|
||||||
|
/***/ })
|
||||||
|
/******/ ]);
|
||||||
|
});
|
||||||
32
features/content-graph/site/public/js/cytoscape.min.js
vendored
Normal file
32
features/content-graph/site/public/js/cytoscape.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
512
features/content-graph/src/build/codegen.rs
Normal file
512
features/content-graph/src/build/codegen.rs
Normal 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
191
features/content-graph/src/build/edge_resolver.rs
Normal file
191
features/content-graph/src/build/edge_resolver.rs
Normal 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
|
||||||
|
}
|
||||||
364
features/content-graph/src/build/graph_generator.rs
Normal file
364
features/content-graph/src/build/graph_generator.rs
Normal 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()));
|
||||||
|
}
|
||||||
47
features/content-graph/src/build/mod.rs
Normal file
47
features/content-graph/src/build/mod.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
335
features/content-graph/src/build/ncl_parser.rs
Normal file
335
features/content-graph/src/build/ncl_parser.rs
Normal 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()
|
||||||
|
}
|
||||||
286
features/content-graph/src/build/node_types.rs
Normal file
286
features/content-graph/src/build/node_types.rs
Normal 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,
|
||||||
|
}
|
||||||
1
features/content-graph/src/lib.rs
Normal file
1
features/content-graph/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod build;
|
||||||
22
features/content-graph/templates/assets_ncl_fragment.ncl
Normal file
22
features/content-graph/templates/assets_ncl_fragment.ncl
Normal 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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
35
features/content-graph/templates/build_rs_snippet.rs
Normal file
35
features/content-graph/templates/build_rs_snippet.rs
Normal 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());
|
||||||
|
}
|
||||||
43
features/content-graph/templates/components/graph_mini.rs
Normal file
43
features/content-graph/templates/components/graph_mini.rs
Normal 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()
|
||||||
|
}
|
||||||
137
features/content-graph/templates/components/graph_view.rs
Normal file
137
features/content-graph/templates/components/graph_view.rs
Normal 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) {}
|
||||||
28
features/content-graph/templates/components/mod.rs
Normal file
28
features/content-graph/templates/components/mod.rs
Normal 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"));
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
17
features/shared/migrations/004_gdpr_bookmarks_postgres.sql
Normal file
17
features/shared/migrations/004_gdpr_bookmarks_postgres.sql
Normal 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);
|
||||||
17
features/shared/migrations/004_gdpr_bookmarks_sqlite.sql
Normal file
17
features/shared/migrations/004_gdpr_bookmarks_sqlite.sql
Normal 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);
|
||||||
|
|
@ -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) {
|
fn generate_content_post_component(component_info: &ComponentInfo) -> (String, String, String) {
|
||||||
let imports = r#"
|
let imports = r#"
|
||||||
use rustelo_core_types::content::*;
|
use rustelo_core_types::content::*;
|
||||||
use rustelo_components::content::*;
|
use rustelo_components_leptos::content::*;
|
||||||
use rustelo_pages::post_viewer::unified::PostViewerPage;"#;
|
use rustelo_pages::post_viewer::unified::PostViewerPage;"#;
|
||||||
|
|
||||||
let content = format!(
|
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) {
|
fn generate_content_category_component(component_info: &ComponentInfo) -> (String, String, String) {
|
||||||
let imports = r#"
|
let imports = r#"
|
||||||
use rustelo_core_types::content::*;
|
use rustelo_core_types::content::*;
|
||||||
use rustelo_components::content::*;"#;
|
use rustelo_components_leptos::content::*;"#;
|
||||||
|
|
||||||
let style_mode = component_info
|
let style_mode = component_info
|
||||||
.params_component
|
.params_component
|
||||||
|
|
@ -302,7 +302,7 @@ use rustelo_components::content::*;"#;
|
||||||
fn generate_content_index_component(component_info: &ComponentInfo) -> (String, String, String) {
|
fn generate_content_index_component(component_info: &ComponentInfo) -> (String, String, String) {
|
||||||
let imports = r#"
|
let imports = r#"
|
||||||
use rustelo_core_types::content::*;
|
use rustelo_core_types::content::*;
|
||||||
use rustelo_components::content::*;"#;
|
use rustelo_components_leptos::content::*;"#;
|
||||||
|
|
||||||
let style_mode = component_info
|
let style_mode = component_info
|
||||||
.params_component
|
.params_component
|
||||||
|
|
@ -332,7 +332,7 @@ use rustelo_components::content::*;"#;
|
||||||
|
|
||||||
/// Generate standard component
|
/// Generate standard component
|
||||||
fn generate_standard_component(component_info: &ComponentInfo) -> (String, String, String) {
|
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!(
|
let content = format!(
|
||||||
r#"
|
r#"
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,34 @@ use std::path::Path;
|
||||||
|
|
||||||
/// Load routes configuration from SITE_CONFIG_PATH/routes/ directory or fallback to routes.toml
|
/// 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> {
|
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_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());
|
let site_root = std::env::var("SITE_ROOT_PATH").unwrap_or_else(|_| "site".to_string());
|
||||||
format!("{}/config", site_root)
|
format!("{}/config", site_root)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the project root to make path absolute
|
let config_dir = {
|
||||||
let routes_dir = if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_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)
|
let project_root = Path::new(&manifest_dir)
|
||||||
.parent()
|
.parent()
|
||||||
.and_then(|p| p.parent())
|
.and_then(|p| p.parent())
|
||||||
.unwrap_or_else(|| Path::new("."))
|
.unwrap_or_else(|| Path::new("."))
|
||||||
.to_path_buf(); // Convert to owned PathBuf
|
.to_path_buf();
|
||||||
project_root.join(&site_config_path).join("routes")
|
project_root.join(&config_dir).join("routes")
|
||||||
} else {
|
} else {
|
||||||
Path::new(&site_config_path).join("routes")
|
config_dir.join("routes")
|
||||||
};
|
};
|
||||||
let routes_file = Path::new(content_root).join("routes.toml");
|
let routes_file = Path::new(content_root).join("routes.toml");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
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
|
/// This must stay in sync with the shared version to ensure compatibility
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl mb-ds-4">
|
||||||
{{content.t("{}-title")}}
|
{{content.t("{}-title")}}
|
||||||
</h1>
|
</h1>
|
||||||
<rustelo_components::HtmlContent
|
<rustelo_components_leptos::HtmlContent
|
||||||
content={{content.t("{}-description")}}
|
content={{content.t("{}-description")}}
|
||||||
class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto".to_string()
|
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">
|
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl mb-ds-4">
|
||||||
{{content.t("{}-hero-title")}}
|
{{content.t("{}-hero-title")}}
|
||||||
</h1>
|
</h1>
|
||||||
<rustelo_components::HtmlContent
|
<rustelo_components_leptos::HtmlContent
|
||||||
content={{content.t("{}-hero-subtitle")}}
|
content={{content.t("{}-hero-subtitle")}}
|
||||||
class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto".to_string()
|
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">
|
<h1 class="text-3xl font-bold ds-text mb-4">
|
||||||
{{content.t("{}-title")}}
|
{{content.t("{}-title")}}
|
||||||
</h1>
|
</h1>
|
||||||
<rustelo_components::HtmlContent
|
<rustelo_components_leptos::HtmlContent
|
||||||
content={{content.t("{}-description")}}
|
content={{content.t("{}-description")}}
|
||||||
class="ds-text-secondary".to_string()
|
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">
|
<h1 class="text-3xl font-bold ds-text mb-4">
|
||||||
{{content.t("{}-title")}}
|
{{content.t("{}-title")}}
|
||||||
</h1>
|
</h1>
|
||||||
<rustelo_components::HtmlContent
|
<rustelo_components_leptos::HtmlContent
|
||||||
content={{content.t("{}-description")}}
|
content={{content.t("{}-description")}}
|
||||||
class="ds-text-secondary max-w-2xl mx-auto".to_string()
|
class="ds-text-secondary max-w-2xl mx-auto".to_string()
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
6
justfile
6
justfile
|
|
@ -19,6 +19,8 @@
|
||||||
# Set shell for commands
|
# Set shell for commands
|
||||||
set shell := ["bash", "-c"]
|
set shell := ["bash", "-c"]
|
||||||
|
|
||||||
|
project_root := justfile_directory()
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# FRAMEWORK MODULE IMPORTS
|
# FRAMEWORK MODULE IMPORTS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -32,7 +34,11 @@ import 'justfiles/docs.just'
|
||||||
import 'justfiles/content.just'
|
import 'justfiles/content.just'
|
||||||
import 'justfiles/testing.just'
|
import 'justfiles/testing.just'
|
||||||
import 'justfiles/build.just'
|
import 'justfiles/build.just'
|
||||||
|
import 'justfiles/assets.just'
|
||||||
import 'justfiles/aliases.just'
|
import 'justfiles/aliases.just'
|
||||||
|
import 'justfiles/adr.just'
|
||||||
|
|
||||||
|
mod site 'justfiles/site.just'
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# FRAMEWORK-SPECIFIC COMMANDS
|
# FRAMEWORK-SPECIFIC COMMANDS
|
||||||
|
|
|
||||||
20
justfiles/adr.just
Normal file
20
justfiles/adr.just
Normal 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
51
justfiles/assets.just
Normal 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)"
|
||||||
|
|
@ -57,6 +57,23 @@ build-prod:
|
||||||
@echo "🔨 Building for production..."
|
@echo "🔨 Building for production..."
|
||||||
cargo leptos build --release
|
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 build artifacts
|
||||||
clean:
|
clean:
|
||||||
@echo "🧹 Cleaning build artifacts..."
|
@echo "🧹 Cleaning build artifacts..."
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,42 @@ build-all-assets:
|
||||||
@just build-design-system
|
@just build-design-system
|
||||||
@just build-theme
|
@just build-theme
|
||||||
@just copy-css-assets
|
@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
12
justfiles/site.just
Normal 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
12
justfiles/workflow.just
Normal 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
148
scripts/adr-constraints.nu
Normal 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
113
scripts/check-wasm-needed.nu
Executable 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
scripts/generator/extract-template.nu
Normal file
152
scripts/generator/extract-template.nu
Normal 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)"
|
||||||
|
}
|
||||||
190
scripts/generator/new-site.nu
Normal file
190
scripts/generator/new-site.nu
Normal 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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
71
scripts/generator/onto-onboard.nu
Normal file
71
scripts/generator/onto-onboard.nu
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
scripts/generator/seed/common/TEMPLATE-SETUP.md
Normal file
41
scripts/generator/seed/common/TEMPLATE-SETUP.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
97
scripts/vendor-htmx.nu
Executable 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."
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue