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
|
||||
.wrks/
|
||||
|
||||
# sync-assets manifest — local artefact written by just sync-assets
|
||||
assets/.sync-manifest.json
|
||||
|
|
|
|||
|
|
@ -140,8 +140,9 @@ repos:
|
|||
exclude: ^\.woodpecker/
|
||||
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^features/.*/site/public/js/
|
||||
|
||||
- id: trailing-whitespace
|
||||
exclude: \.md$
|
||||
exclude: (\.md$|^features/.*/site/public/js/)
|
||||
|
||||
- id: mixed-line-ending
|
||||
|
|
|
|||
|
|
@ -1,84 +1,51 @@
|
|||
# Woodpecker CI Pipeline
|
||||
# Equivalent to GitHub Actions CI workflow
|
||||
# Generated by dev-system/ci
|
||||
# Generated by ore workflow generate — layer: ci-standard
|
||||
# Source: .ontology/workflow.ncl
|
||||
# Do not edit manually — regenerate with: ore workflow generate --layer ci-standard
|
||||
|
||||
when:
|
||||
event: [push, pull_request, manual]
|
||||
branch:
|
||||
- main
|
||||
- develop
|
||||
|
||||
steps:
|
||||
# === LINTING ===
|
||||
|
||||
lint-rust:
|
||||
rust-clippy-all:
|
||||
image: rust:latest
|
||||
commands:
|
||||
- curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
- rustup component add clippy
|
||||
- cargo fmt --all -- --check
|
||||
- cargo clippy --all-targets -- -D warnings
|
||||
- cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
lint-bash:
|
||||
image: koalaman/shellcheck-alpine:stable
|
||||
commands:
|
||||
- apk add --no-cache curl bash
|
||||
- find . -name '*.sh' -type f ! -path './target/*' -exec shellcheck {} +
|
||||
|
||||
lint-nickel:
|
||||
nickel-typecheck:
|
||||
image: rust:latest
|
||||
commands:
|
||||
- cargo install nickel-lang-cli --locked
|
||||
- find . -name '*.ncl' -type f ! -path './target/*' -exec nickel typecheck {} \;
|
||||
- nickel typecheck
|
||||
|
||||
lint-nushell:
|
||||
nushell-check:
|
||||
image: rust:latest
|
||||
commands:
|
||||
- cargo install nu --locked
|
||||
- find . -name '*.nu' -type f ! -path './target/*' -exec nu --ide-check 100 {} \;
|
||||
- find . -name '*.nu' ! -path '*/target/*' -print0 | xargs -0 -I\{\} nu --ide-check 100 \{\}
|
||||
|
||||
lint-markdown:
|
||||
image: node:alpine
|
||||
commands:
|
||||
- npm install -g markdownlint-cli2
|
||||
- markdownlint-cli2 '**/*.md' '#node_modules' '#target'
|
||||
|
||||
# === TESTING ===
|
||||
|
||||
test:
|
||||
nextest-ci:
|
||||
image: rust:latest
|
||||
commands:
|
||||
- cargo test --workspace --all-features
|
||||
depends_on:
|
||||
- lint-rust
|
||||
- lint-bash
|
||||
- lint-nickel
|
||||
- lint-nushell
|
||||
- lint-markdown
|
||||
- cargo install cargo-nextest --locked
|
||||
- cargo nextest run --all-features --workspace --profile ci --cargo-profile ci
|
||||
depends_on: ["rust-clippy-all", "nickel-typecheck", "nushell-check"]
|
||||
environment:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
# === BUILD ===
|
||||
|
||||
build:
|
||||
image: rust:latest
|
||||
commands:
|
||||
- cargo build --release
|
||||
depends_on:
|
||||
- test
|
||||
|
||||
# === SECURITY ===
|
||||
|
||||
security-audit:
|
||||
image: rust:latest
|
||||
commands:
|
||||
- cargo install cargo-audit --locked
|
||||
- cargo audit --deny warnings
|
||||
depends_on:
|
||||
- lint-rust
|
||||
|
||||
license-check:
|
||||
deny-subset:
|
||||
image: rust:latest
|
||||
commands:
|
||||
- cargo install cargo-deny --locked
|
||||
- cargo deny check licenses advisories
|
||||
depends_on:
|
||||
- lint-rust
|
||||
|
||||
docs-check:
|
||||
image: rust:latest
|
||||
commands:
|
||||
- RUSTDOCFLAGS="-D rustdoc::broken-intra-doc-links -D rustdoc::private-intra-doc-links" cargo doc --no-deps --workspace --profile ci -q
|
||||
depends_on: ["rust-clippy-all", "nickel-typecheck", "nushell-check"]
|
||||
|
||||
build-release-native:
|
||||
image: rust:latest
|
||||
commands:
|
||||
- cargo build --release --workspace
|
||||
depends_on: ["rust-clippy-all", "nickel-typecheck", "nushell-check"]
|
||||
|
|
|
|||
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]
|
||||
|
||||
### Added
|
||||
- **Website templates + generator (2026-05-28)**: Two render-mode project templates and an interactive generator, modelled on the living jpl-website site.
|
||||
- `templates/website-htmx-ssr/`, `templates/website-leptos/` — sanitized snapshots produced by a reproducible extractor (`scripts/generator/extract-template.nu`): rsync + prune + binary-safe sanitize + seed overlay; neutral `website-*` crate names; deploy identity defaults to `example.com` with a `TEMPLATE-SETUP.md` checklist. Both carry the full workspace (render profile is a build concern: features + cfg + Dockerfile + `rendering.ncl`).
|
||||
- `scripts/generator/new-site.nu` + `templates/options.ncl` + `just site::new` — interactive (or `--config`/`--yes`) wizard: picks the template via the `templates.json` registry (now carrying `render_mode`), creates the site as a framework sibling, renames crates to the project, sets domain, and stamps ontoref onboarding. Verified: a generated site's full `cargo metadata` resolves ~700 packages via `../rustelo`.
|
||||
- `scripts/generator/onto-onboard.nu` + `templates/_ontoref-skeleton/` — ontoref onboarding levels (full/minimal/none) that delegate ontology scaffolding to `ontoref setup` and write a `.rustelo.ontoref` domain pointer (reference, not embed).
|
||||
- `templates/shared/public/scripts/` — htmx-ssr integration glue promoted from jpl-website (`htmx-reinit.js`, `reinit-handlers.js`, `theme-init.js`, `highlight-utils.js`, `tag-filters.js`); the htmx library + extensions already lived under `templates/shared/htmx/`.
|
||||
- `just site::extract` — re-snapshot both templates from the living model.
|
||||
- **ADR-006** — Rendering Profile Axis: HTMX-SSR Coexisting with Leptos Hydration.
|
||||
- **ADR-007** — Website Templates as Reproducible Snapshots + a Generator that Delegates (snapshot model, neutral crate names, delegated ontoref onboarding, self-contained trees superseding init.rs assembly).
|
||||
### Removed
|
||||
- Obsolete Leptos-only templates (basic/cms/content-website/enterprise/minimal/website-full) removed from `templates/` and the registry; `just site::new` is the single scaffolding path. A reference copy of the old trees is kept under `.coder/archive/templates/`.
|
||||
- **Legacy `cargo rustelo init` scaffolding removed** from `rustelo_cli`: deleted `commands/init.rs`, `templates.rs`, `commands/remote.rs`, `embedded.rs`, the `Commands::Init` variant + match arm, and the now-orphaned `generate` feature (+ optional deps tera/quote/syn/proc-macro2). The remaining `cargo rustelo` commands (build/dev/update/assets/pipeline/cross) are unchanged. Verified: `cargo check -p rustelo_cli` and `cargo clippy -p rustelo_cli --no-deps -- -D warnings` both clean.
|
||||
- Framework landing (`assets/web/src/index.html`) updated to present both render modes (added a "Two Render Modes" card).
|
||||
|
||||
- **Architecture Self-Description (on+re)**: Integrated Ontoref protocol across framework and implementation
|
||||
- `.ontology/core.ncl` — knowledge graph with axioms (config-driven, language-agnostic, no-framework-forks), tensions, and practices (build-time codegen, custom routing, layered override, plugin architecture, auth framework, component library, page library, feature system, CSS theme system)
|
||||
- `.ontology/state.ncl` — state dimensions tracking framework-maturity (feature-complete → api-stable), plugin-system (L5 → L8), template-system (designed → operational)
|
||||
- `.ontology/gate.ncl` — four architecture membranes: routing-integrity (Closed), code-quality (Closed), configuration-driven (Low permeability), framework-boundary (Closed)
|
||||
- `.ontology/manifest.ncl` — Library manifest with Developer, Downstream, and Agent consumption modes; framework-core, templates, features, and self-description layers
|
||||
- `reflection/` — backlog, constraints, defaults, qa, schema, and operational modes (integrity-check, sync-ontology, validate-pap)
|
||||
- **ADR System**: Four Architecture Decision Records in typed Nickel NCL with contract validation
|
||||
- `adr-001` — Build-Time Code Generation Pattern (`include!(concat!(env!("OUT_DIR"), ...))`)
|
||||
- `adr-002` — Custom Routing System over Leptos Router
|
||||
- `adr-003` — Layered Override System (Local > Feature > Template > Framework precedence)
|
||||
- `adr-004` — Compile-Time Plugin Architecture (Level 5) with documented Level 8 upgrade path
|
||||
- `adr-005` — Auth Framework as Hexagonal Port over Foundation (`rustelo_auth` re-exports from `rustelo_core_lib::auth`; constraints: consumers-use-port, no-auth-impl-in-framework-crate)
|
||||
- **on+re migration (2026-04)**: Ontology updated to reflect current codebase state
|
||||
- `core.ncl` — artifact path `site/config/` corrected to `resources/nickel/routes/`; `auth-framework` description updated to hexagonal port; `unified-template-system` updated (CLI wired)
|
||||
- `state.ncl` — `template-system` and `framework-maturity` blockers/catalysts updated
|
||||
|
||||
### Fixed
|
||||
- **rustelo_server**: Resolved 302 compilation errors after dependency updates
|
||||
- Removed duplicate module declarations (`auth`, `content`) in lib.rs
|
||||
|
|
|
|||
83
README.md
83
README.md
|
|
@ -1,11 +1,11 @@
|
|||
<div align="center">
|
||||
<img src="logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="450" />
|
||||
<img src="assets/logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="450" />
|
||||
</div>
|
||||
|
||||
A unified, modern, and secure **Rust** platform to build, deploy, and deliver scalable, high-performance, and reactive web applications with integrated content management, user authentication, multilingual UI, email services, CI/CD pipelines, and comprehensive tooling from development to production.
|
||||
|
||||
<div align="left">
|
||||
<img src="logos/rustelo-imag.svg" alt="RUSTELO" width="50" />
|
||||
<img src="assets/logos/rustelo-imag.svg" alt="RUSTELO" width="50" />
|
||||
</div>
|
||||
|
||||
>[!NOTE]
|
||||
|
|
@ -281,6 +281,26 @@ A unified, modern, and secure **Rust** platform to build, deploy, and deliver sc
|
|||
|
||||
**New to Rustelo?** Check out our **[Quick Start Guide](QUICK_START.md)** for a complete walkthrough!
|
||||
|
||||
### Generate a new website (recommended for new sites)
|
||||
|
||||
Scaffold a fresh website from a render-mode template (snapshot of the reference site):
|
||||
|
||||
```bash
|
||||
# Interactive — asks project name, render mode, languages, domain, ontoref level
|
||||
just site::new
|
||||
|
||||
# Non-interactive
|
||||
just site::new --project acme --render-mode htmx-ssr --domain acme.dev --ontoref minimal --yes
|
||||
```
|
||||
|
||||
Render modes:
|
||||
- `htmx-ssr` — server-rendered HTML + HTMX, no WASM.
|
||||
- `leptos-hydration` — SSR + WASM hydration.
|
||||
|
||||
The site is created as a sibling of this framework (so its `../rustelo` path deps
|
||||
resolve), with crates renamed to your project and a `SETUP.md` listing what to complete.
|
||||
`just site::extract` re-snapshots the templates from the living model. See ADR-007.
|
||||
|
||||
### Option 1: One-Command Setup (Recommended)
|
||||
```bash
|
||||
# Clone and install everything automatically
|
||||
|
|
@ -563,9 +583,46 @@ rustelo_core_lib::load_resources_from_config()?;
|
|||
|
||||
### Documentation
|
||||
|
||||
- [Plugin Architecture Guide](../docs/architecture/rustelo-plugin-architecture.md)
|
||||
- [Plugin Development Guide](./.coder/info/PHASE3-PLUGIN-DEVELOPMENT-GUIDE.md)
|
||||
- [Example Plugin](../website/website-impl/crates/plugin-example-theme/)
|
||||
- [Plugin Architecture Guide](./docs/architecture/rustelo-plugin-architecture.md)
|
||||
- [ADR-004: Plugin Architecture Level 5](./adrs/adr-004-plugin-architecture-level5.ncl)
|
||||
- [Example Plugin](./examples/plugin-example-theme/)
|
||||
|
||||
## 🔍 Architecture Self-Description (on+re)
|
||||
|
||||
Rustelo is **self-describing** via the [Ontoref](https://github.com/ontoref) `on+re` protocol. The `.ontology/` and `reflection/` directories form a machine- and agent-readable knowledge graph of the framework's architecture, current state, and invariants.
|
||||
|
||||
### What's in `.ontology/`
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `core.ncl` | Knowledge graph: axioms, tensions, practices, edges |
|
||||
| `state.ncl` | State dimensions with current/desired states and transitions |
|
||||
| `gate.ncl` | Architecture membranes (routing integrity, config-driven gate, framework boundary) |
|
||||
| `manifest.ncl` | Consumer modes, layer definitions, operational modes |
|
||||
|
||||
### ADR System (`adrs/`)
|
||||
|
||||
Architecture Decision Records in typed NCL with contract validation:
|
||||
|
||||
- `adr-001` — Build-Time Code Generation Pattern
|
||||
- `adr-002` — Custom Routing over Leptos Router
|
||||
- `adr-003` — Layered Override System
|
||||
- `adr-004` — Compile-Time Plugin Architecture (Level 5) with Runtime Upgrade Path
|
||||
|
||||
### How Agents Use This
|
||||
|
||||
An agent reading `.ontology/core.ncl` knows before touching any code:
|
||||
- Which axioms are invariant (routing integrity, config-driven, no framework forks)
|
||||
- Which tensions exist and how they're resolved
|
||||
- Which practices are operative and their artifact paths
|
||||
- Which gates reject changes without justification
|
||||
|
||||
```bash
|
||||
# Browse framework architecture
|
||||
nickel export .ontology/core.ncl
|
||||
nickel export .ontology/state.ncl # current state vs desired state
|
||||
nickel export .ontology/gate.ncl # active membranes and protocols
|
||||
```
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
|
|
@ -603,13 +660,17 @@ template/
|
|||
## 🚀 Development
|
||||
|
||||
### Prerequisites
|
||||
- Rust 1.75+
|
||||
- Node.js 18+ (for frontend tooling)
|
||||
- PostgreSQL (if using database features)
|
||||
- **mdBook** (for documentation) - `cargo install mdbook`
|
||||
- **Just** (task runner) - `cargo install just`
|
||||
- **Rust 1.75+** - Core language and toolchain
|
||||
- **Node.js 18+** - Frontend tooling and asset processing
|
||||
- **Nickel** (Configuration language) - **REQUIRED** for type-safe configuration
|
||||
- macOS: `brew install nickel`
|
||||
- Linux/Windows: `cargo install nickel-lang-cli`
|
||||
- See [Nickel Installation Guide](../docs/guides/nickel-installation.md)
|
||||
- **PostgreSQL** - Required if using database features (`auth` or `content-db`)
|
||||
- **mdBook** - Documentation generation - `cargo install mdbook`
|
||||
- **Just** - Task runner - `cargo install just`
|
||||
|
||||
> **Note**: All tools are automatically installed by `./scripts/install.sh`. For manual setup, see [INSTALL.md](INSTALL.md).
|
||||
> **Note**: Nickel is mandatory for Rustelo projects. All configuration files (routes, themes, menus, content types) use NCL format for type-safety and DRY principles. See [Why Nickel?](../docs/adr/0002-nickel-configuration-language.md) and [Configuration Guide](../docs/guides/nickel-configuration-guide.md).
|
||||
|
||||
### Quick Setup
|
||||
```bash
|
||||
|
|
|
|||
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) {
|
||||
let imports = r#"
|
||||
use rustelo_core_types::content::*;
|
||||
use rustelo_components::content::*;
|
||||
use rustelo_components_leptos::content::*;
|
||||
use rustelo_pages::post_viewer::unified::PostViewerPage;"#;
|
||||
|
||||
let content = format!(
|
||||
|
|
@ -274,7 +274,7 @@ use rustelo_pages::post_viewer::unified::PostViewerPage;"#;
|
|||
fn generate_content_category_component(component_info: &ComponentInfo) -> (String, String, String) {
|
||||
let imports = r#"
|
||||
use rustelo_core_types::content::*;
|
||||
use rustelo_components::content::*;"#;
|
||||
use rustelo_components_leptos::content::*;"#;
|
||||
|
||||
let style_mode = component_info
|
||||
.params_component
|
||||
|
|
@ -302,7 +302,7 @@ use rustelo_components::content::*;"#;
|
|||
fn generate_content_index_component(component_info: &ComponentInfo) -> (String, String, String) {
|
||||
let imports = r#"
|
||||
use rustelo_core_types::content::*;
|
||||
use rustelo_components::content::*;"#;
|
||||
use rustelo_components_leptos::content::*;"#;
|
||||
|
||||
let style_mode = component_info
|
||||
.params_component
|
||||
|
|
@ -332,7 +332,7 @@ use rustelo_components::content::*;"#;
|
|||
|
||||
/// Generate standard component
|
||||
fn generate_standard_component(component_info: &ComponentInfo) -> (String, String, String) {
|
||||
let imports = "use rustelo_components::rustelo_pages::*;";
|
||||
let imports = "use rustelo_components_leptos::rustelo_pages::*;";
|
||||
|
||||
let content = format!(
|
||||
r#"
|
||||
|
|
|
|||
|
|
@ -7,22 +7,34 @@ use std::path::Path;
|
|||
|
||||
/// Load routes configuration from SITE_CONFIG_PATH/routes/ directory or fallback to routes.toml
|
||||
pub fn load_routes_config(content_root: &str) -> Result<RoutesConfig, String> {
|
||||
// Use SITE_CONFIG_PATH, resolving from SITE_ROOT if needed
|
||||
// Use SITE_CONFIG_PATH, resolving from SITE_ROOT if needed.
|
||||
// If SITE_CONFIG_PATH points to a file (e.g. site/config/index.ncl), use its
|
||||
// parent directory as the config root, since this loader expects a directory.
|
||||
let site_config_path = std::env::var("SITE_CONFIG_PATH").unwrap_or_else(|_| {
|
||||
let site_root = std::env::var("SITE_ROOT_PATH").unwrap_or_else(|_| "site".to_string());
|
||||
format!("{}/config", site_root)
|
||||
});
|
||||
|
||||
// Get the project root to make path absolute
|
||||
let routes_dir = if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
let config_dir = {
|
||||
let p = Path::new(&site_config_path);
|
||||
if p.is_file() || p.extension().is_some() {
|
||||
p.parent().unwrap_or(p).to_path_buf()
|
||||
} else {
|
||||
p.to_path_buf()
|
||||
}
|
||||
};
|
||||
|
||||
let routes_dir = if config_dir.is_absolute() {
|
||||
config_dir.join("routes")
|
||||
} else if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
let project_root = Path::new(&manifest_dir)
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_path_buf(); // Convert to owned PathBuf
|
||||
project_root.join(&site_config_path).join("routes")
|
||||
.to_path_buf();
|
||||
project_root.join(&config_dir).join("routes")
|
||||
} else {
|
||||
Path::new(&site_config_path).join("routes")
|
||||
config_dir.join("routes")
|
||||
};
|
||||
let routes_file = Path::new(content_root).join("routes.toml");
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Route configuration structure - mirrors shared::routing::rustelo_components::RouteConfigToml
|
||||
/// Route configuration structure - mirrors shared::routing::rustelo_components_leptos::RouteConfigToml
|
||||
/// This must stay in sync with the shared version to ensure compatibility
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[allow(dead_code)]
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ fn generate_basic_page_content(prefix: &str) -> String {
|
|||
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl mb-ds-4">
|
||||
{{content.t("{}-title")}}
|
||||
</h1>
|
||||
<rustelo_components::HtmlContent
|
||||
<rustelo_components_leptos::HtmlContent
|
||||
content={{content.t("{}-description")}}
|
||||
class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto".to_string()
|
||||
/>
|
||||
|
|
@ -383,7 +383,7 @@ fn generate_hero_page_content(prefix: &str) -> String {
|
|||
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl mb-ds-4">
|
||||
{{content.t("{}-hero-title")}}
|
||||
</h1>
|
||||
<rustelo_components::HtmlContent
|
||||
<rustelo_components_leptos::HtmlContent
|
||||
content={{content.t("{}-hero-subtitle")}}
|
||||
class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto".to_string()
|
||||
/>
|
||||
|
|
@ -421,7 +421,7 @@ fn generate_form_page_content(prefix: &str) -> String {
|
|||
<h1 class="text-3xl font-bold ds-text mb-4">
|
||||
{{content.t("{}-title")}}
|
||||
</h1>
|
||||
<rustelo_components::HtmlContent
|
||||
<rustelo_components_leptos::HtmlContent
|
||||
content={{content.t("{}-description")}}
|
||||
class="ds-text-secondary".to_string()
|
||||
/>
|
||||
|
|
@ -454,7 +454,7 @@ fn generate_content_list_content(prefix: &str) -> String {
|
|||
<h1 class="text-3xl font-bold ds-text mb-4">
|
||||
{{content.t("{}-title")}}
|
||||
</h1>
|
||||
<rustelo_components::HtmlContent
|
||||
<rustelo_components_leptos::HtmlContent
|
||||
content={{content.t("{}-description")}}
|
||||
class="ds-text-secondary max-w-2xl mx-auto".to_string()
|
||||
/>
|
||||
|
|
|
|||
6
justfile
6
justfile
|
|
@ -19,6 +19,8 @@
|
|||
# Set shell for commands
|
||||
set shell := ["bash", "-c"]
|
||||
|
||||
project_root := justfile_directory()
|
||||
|
||||
# =============================================================================
|
||||
# FRAMEWORK MODULE IMPORTS
|
||||
# =============================================================================
|
||||
|
|
@ -32,7 +34,11 @@ import 'justfiles/docs.just'
|
|||
import 'justfiles/content.just'
|
||||
import 'justfiles/testing.just'
|
||||
import 'justfiles/build.just'
|
||||
import 'justfiles/assets.just'
|
||||
import 'justfiles/aliases.just'
|
||||
import 'justfiles/adr.just'
|
||||
|
||||
mod site 'justfiles/site.just'
|
||||
|
||||
# =============================================================================
|
||||
# FRAMEWORK-SPECIFIC COMMANDS
|
||||
|
|
|
|||
20
justfiles/adr.just
Normal file
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..."
|
||||
cargo leptos build --release
|
||||
|
||||
# Build for production with automatic WASM dispatch driven by the rendering profile
|
||||
build-auto routes_dir="site/config/routes" workspace_config="site/config/rendering.toml" bin="":
|
||||
@echo "🔍 Inspecting rendering profile for {{routes_dir}} + {{workspace_config}}..."
|
||||
@if nu {{project_root}}/scripts/check-wasm-needed.nu --verbose \
|
||||
--routes-dir {{routes_dir}} \
|
||||
--workspace-config {{workspace_config}}; then \
|
||||
echo "🔨 WASM required — running cargo leptos build --release"; \
|
||||
cargo leptos build --release; \
|
||||
else \
|
||||
echo "🪶 Pure htmx-ssr — skipping wasm32 target"; \
|
||||
if [ -n "{{bin}}" ]; then \
|
||||
cargo build --release -p {{bin}} --no-default-features --features ssr,htmx-ssr; \
|
||||
else \
|
||||
cargo build --release --no-default-features --features ssr,htmx-ssr; \
|
||||
fi \
|
||||
fi
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "🧹 Cleaning build artifacts..."
|
||||
|
|
|
|||
|
|
@ -50,3 +50,42 @@ build-all-assets:
|
|||
@just build-design-system
|
||||
@just build-theme
|
||||
@just copy-css-assets
|
||||
|
||||
# =============================================================================
|
||||
# DEPENDENCY SYNC - registry/Cargo.toml → workspace.dependencies
|
||||
# =============================================================================
|
||||
# `registry/Cargo.toml` is the single source of truth (editable in Zed via
|
||||
# rust-analyzer's inline upgrade hints). These recipes propagate it to
|
||||
# `rustelo/Cargo.toml` and to any implementation workspace.
|
||||
|
||||
# Propagate the registry into rustelo's own Cargo.toml managed region.
|
||||
deps-sync:
|
||||
@echo "🔗 Syncing rustelo/Cargo.toml from registry/Cargo.toml..."
|
||||
cargo xtask sync-deps
|
||||
|
||||
# Verify rustelo/Cargo.toml matches the registry (exit 1 if drifted).
|
||||
deps-sync-check:
|
||||
@echo "🔍 Checking rustelo/Cargo.toml for drift from registry..."
|
||||
cargo xtask sync-deps --check
|
||||
|
||||
# Propagate the registry into an implementation workspace.
|
||||
# Example: just deps-sync-impl ../jpl-website
|
||||
deps-sync-impl target:
|
||||
@echo "🔗 Syncing {{target}}/Cargo.toml from registry/Cargo.toml..."
|
||||
cargo xtask sync-deps --target {{target}}
|
||||
|
||||
# Verify an implementation's Cargo.toml matches the registry (exit 1 if drifted).
|
||||
deps-sync-check-impl target:
|
||||
@echo "🔍 Checking {{target}}/Cargo.toml for drift from registry..."
|
||||
cargo xtask sync-deps --check --target {{target}}
|
||||
|
||||
# Sync rustelo + every implementation declared in registry/sync.toml [impls.known].
|
||||
# Adding a new implementation is a registry/sync.toml edit — no recipe change needed.
|
||||
deps-sync-all:
|
||||
@echo "🔗 Syncing rustelo + every known implementation..."
|
||||
cargo xtask sync-deps --all
|
||||
|
||||
# Verify rustelo + every implementation matches the registry (exit 1 if any drifts).
|
||||
deps-sync-check-all:
|
||||
@echo "🔍 Checking rustelo + every known implementation for drift..."
|
||||
cargo xtask sync-deps --check --all
|
||||
|
|
|
|||
12
justfiles/site.just
Normal file
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