add contract md folder support

This commit is contained in:
timurgordon 2025-05-01 03:56:55 +03:00
parent 457f3c8268
commit c05803ff58
15 changed files with 365 additions and 76 deletions

View File

@ -259,6 +259,7 @@ dependencies = [
"lazy_static", "lazy_static",
"log", "log",
"num_cpus", "num_cpus",
"pulldown-cmark",
"redis", "redis",
"serde", "serde",
"serde_json", "serde_json",
@ -1075,6 +1076,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.15" version = "0.2.15"
@ -1931,6 +1941,25 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
dependencies = [
"bitflags",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.40"
@ -2622,6 +2651,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"

View File

@ -23,3 +23,4 @@ uuid = { version = "1.6.1", features = ["v4", "serde"] }
lazy_static = "1.4.0" lazy_static = "1.4.0"
redis = { version = "0.23.0", features = ["tokio-comp"] } redis = { version = "0.23.0", features = ["tokio-comp"] }
jsonwebtoken = "8.3.0" jsonwebtoken = "8.3.0"
pulldown-cmark = "0.13.0"

View File

@ -0,0 +1,3 @@
## 1. Purpose
The purpose of this Agreement is to establish the terms and conditions for tokenizing real estate assets on the Zanzibar blockchain network.

View File

@ -0,0 +1,3 @@
## 2. Tokenization Process
Tokenizer shall create digital tokens representing ownership interests in the properties listed in Appendix A according to the specifications in Appendix B.

View File

@ -0,0 +1,3 @@
## 3. Revenue Sharing
Revenue generated from the tokenized properties shall be distributed according to the formula set forth in Appendix C.

View File

@ -0,0 +1,3 @@
## 4. Governance
Decisions regarding the management of tokenized properties shall be made according to the governance framework outlined in Appendix D.

View File

@ -0,0 +1,3 @@
### Appendix A: Properties
List of properties to be tokenized.

View File

@ -0,0 +1,3 @@
### Appendix B: Specifications
Technical specifications for tokenization.

View File

@ -0,0 +1,3 @@
### Appendix C: Revenue Formula
Formula for revenue distribution.

View File

@ -0,0 +1,3 @@
### Appendix D: Governance Framework
Governance framework for tokenized properties.

View File

@ -0,0 +1,3 @@
# Digital Asset Tokenization Agreement
This Digital Asset Tokenization Agreement (the "Agreement") is entered into between Zanzibar Property Consortium ("Tokenizer") and the property owners listed in Appendix A ("Owners").

View File

@ -3,8 +3,10 @@ use tera::{Context, Tera};
use chrono::{Utc, Duration}; use chrono::{Utc, Duration};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use actix_web::web::Query;
use std::collections::HashMap;
use crate::models::contract::{Contract, ContractStatus, ContractType, ContractStatistics, ContractSigner, ContractRevision, SignerStatus}; use crate::models::contract::{Contract, ContractStatus, ContractType, ContractStatistics, ContractSigner, ContractRevision, SignerStatus, TocItem};
use crate::utils::render_template; use crate::utils::render_template;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -105,7 +107,11 @@ impl ContractController {
} }
// Display a specific contract // Display a specific contract
pub async fn detail(tmpl: web::Data<Tera>, path: web::Path<String>) -> Result<HttpResponse, Error> { pub async fn detail(
tmpl: web::Data<Tera>,
path: web::Path<String>,
query: Query<HashMap<String, String>>
) -> Result<HttpResponse, Error> {
let contract_id = path.into_inner(); let contract_id = path.into_inner();
let mut context = Context::new(); let mut context = Context::new();
@ -130,6 +136,50 @@ impl ContractController {
// Add contract to context // Add contract to context
context.insert("contract", &contract_json); context.insert("contract", &contract_json);
// If this contract uses multi-page markdown, load the selected section
println!("DEBUG: content_dir = {:?}, toc = {:?}", contract.content_dir, contract.toc);
if let (Some(content_dir), Some(toc)) = (&contract.content_dir, &contract.toc) {
use std::fs;
use pulldown_cmark::{Parser, Options, html};
// Helper to flatten toc recursively
fn flatten_toc<'a>(items: &'a Vec<TocItem>, out: &mut Vec<&'a TocItem>) {
for item in items {
out.push(item);
if !item.children.is_empty() {
flatten_toc(&item.children, out);
}
}
}
let mut flat_toc = Vec::new();
flatten_toc(&toc, &mut flat_toc);
let section_param = query.get("section");
let selected_file = section_param
.and_then(|f| flat_toc.iter().find(|item| item.file == *f).map(|item| item.file.clone()))
.unwrap_or_else(|| flat_toc.get(0).map(|item| item.file.clone()).unwrap_or_default());
context.insert("section", &selected_file);
let rel_path = format!("{}/{}", content_dir, selected_file);
let abs_path = match std::env::current_dir() {
Ok(dir) => dir.join(&rel_path),
Err(_) => std::path::PathBuf::from(&rel_path),
};
println!("DEBUG: Attempting to read markdown file at absolute path: {:?}", abs_path);
match fs::read_to_string(&abs_path) {
Ok(md) => {
println!("DEBUG: Successfully read markdown file");
let parser = Parser::new_ext(&md, Options::all());
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
context.insert("contract_section_content", &html_output);
},
Err(e) => {
let error_msg = format!("Error: Could not read contract section markdown at '{:?}': {}", abs_path, e);
println!("{}", error_msg);
context.insert("contract_section_content_error", &error_msg);
}
}
context.insert("toc", &toc);
}
// Count signed signers for the template // Count signed signers for the template
let signed_signers = contract.signers.iter().filter(|s| s.status == SignerStatus::Signed).count(); let signed_signers = contract.signers.iter().filter(|s| s.status == SignerStatus::Signed).count();
context.insert("signed_signers", &signed_signers); context.insert("signed_signers", &signed_signers);
@ -327,6 +377,8 @@ impl ContractController {
// Mock contract 1 - Signed Service Agreement // Mock contract 1 - Signed Service Agreement
let mut contract1 = Contract { let mut contract1 = Contract {
content_dir: None,
toc: None,
id: "contract-001".to_string(), id: "contract-001".to_string(),
title: "Digital Hub Service Agreement".to_string(), title: "Digital Hub Service Agreement".to_string(),
description: "Service agreement for cloud hosting and digital infrastructure services provided by the Zanzibar Digital Hub.".to_string(), description: "Service agreement for cloud hosting and digital infrastructure services provided by the Zanzibar Digital Hub.".to_string(),
@ -381,6 +433,8 @@ impl ContractController {
// Mock contract 2 - Pending Signatures // Mock contract 2 - Pending Signatures
let mut contract2 = Contract { let mut contract2 = Contract {
content_dir: None,
toc: None,
id: "contract-002".to_string(), id: "contract-002".to_string(),
title: "Software Development Agreement".to_string(), title: "Software Development Agreement".to_string(),
description: "Agreement for custom software development services for the Zanzibar Digital Marketplace platform.".to_string(), description: "Agreement for custom software development services for the Zanzibar Digital Marketplace platform.".to_string(),
@ -450,8 +504,57 @@ impl ContractController {
signers: Vec::new(), signers: Vec::new(),
revisions: Vec::new(), revisions: Vec::new(),
current_version: 1, current_version: 1,
content_dir: Some("src/content/contract-003".to_string()),
toc: Some(vec![
TocItem {
title: "Cover".to_string(),
file: "cover.md".to_string(),
children: vec![],
},
TocItem {
title: "1. Purpose".to_string(),
file: "1-purpose.md".to_string(),
children: vec![],
},
TocItem {
title: "2. Tokenization Process".to_string(),
file: "2-tokenization-process.md".to_string(),
children: vec![],
},
TocItem {
title: "3. Revenue Sharing".to_string(),
file: "3-revenue-sharing.md".to_string(),
children: vec![],
},
TocItem {
title: "4. Governance".to_string(),
file: "4-governance.md".to_string(),
children: vec![],
},
TocItem {
title: "Appendix A: Properties".to_string(),
file: "appendix-a.md".to_string(),
children: vec![],
},
TocItem {
title: "Appendix B: Technical Specs".to_string(),
file: "appendix-b.md".to_string(),
children: vec![],
},
TocItem {
title: "Appendix C: Revenue Formula".to_string(),
file: "appendix-c.md".to_string(),
children: vec![],
},
TocItem {
title: "Appendix D: Governance Framework".to_string(),
file: "appendix-d.md".to_string(),
children: vec![],
},
]),
}; };
// Add potential signers to contract 3 (still in draft) // Add potential signers to contract 3 (still in draft)
contract3.signers.push(ContractSigner { contract3.signers.push(ContractSigner {
id: "signer-006".to_string(), id: "signer-006".to_string(),
@ -471,17 +574,62 @@ impl ContractController {
comments: None, comments: None,
}); });
// Add revisions to contract 3 // Add ToC and content directory to contract 3
contract3.revisions.push(ContractRevision { contract3.content_dir = Some("src/content/contract-003".to_string());
version: 1, contract3.toc = Some(vec![
content: "<h1>Digital Asset Tokenization Agreement</h1><p>This Digital Asset Tokenization Agreement (the \"Agreement\") is entered into between Zanzibar Property Consortium (\"Tokenizer\") and the property owners listed in Appendix A (\"Owners\").</p><h2>1. Purpose</h2><p>The purpose of this Agreement is to establish the terms and conditions for tokenizing real estate assets on the Zanzibar blockchain network.</p><h2>2. Tokenization Process</h2><p>Tokenizer shall create digital tokens representing ownership interests in the properties listed in Appendix A according to the specifications in Appendix B.</p><h2>3. Revenue Sharing</h2><p>Revenue generated from the tokenized properties shall be distributed according to the formula set forth in Appendix C.</p><h2>4. Governance</h2><p>Decisions regarding the management of tokenized properties shall be made according to the governance framework outlined in Appendix D.</p>".to_string(), TocItem {
created_at: Utc::now() - Duration::days(3), title: "Digital Asset Tokenization Agreement".to_string(),
created_by: "Nala Okafor".to_string(), file: "cover.md".to_string(),
comments: Some("Initial draft of the tokenization agreement.".to_string()), children: vec![
}); TocItem {
title: "1. Purpose".to_string(),
file: "1-purpose.md".to_string(),
children: vec![],
},
TocItem {
title: "2. Tokenization Process".to_string(),
file: "2-tokenization-process.md".to_string(),
children: vec![],
},
TocItem {
title: "3. Revenue Sharing".to_string(),
file: "3-revenue-sharing.md".to_string(),
children: vec![],
},
TocItem {
title: "4. Governance".to_string(),
file: "4-governance.md".to_string(),
children: vec![],
},
TocItem {
title: "Appendix A: Properties".to_string(),
file: "appendix-a.md".to_string(),
children: vec![],
},
TocItem {
title: "Appendix B: Specifications".to_string(),
file: "appendix-b.md".to_string(),
children: vec![],
},
TocItem {
title: "Appendix C: Revenue Formula".to_string(),
file: "appendix-c.md".to_string(),
children: vec![],
},
TocItem {
title: "Appendix D: Governance Framework".to_string(),
file: "appendix-d.md".to_string(),
children: vec![],
},
],
}
]);
// No revision content for contract 3, content is in markdown files.
// Mock contract 4 - Rejected // Mock contract 4 - Rejected
let mut contract4 = Contract { let mut contract4 = Contract {
content_dir: None,
toc: None,
id: "contract-004".to_string(), id: "contract-004".to_string(),
title: "Data Sharing Agreement".to_string(), title: "Data Sharing Agreement".to_string(),
description: "Agreement governing the sharing of anonymized data between Zanzibar Digital Hub and research institutions.".to_string(), description: "Agreement governing the sharing of anonymized data between Zanzibar Digital Hub and research institutions.".to_string(),
@ -528,6 +676,8 @@ impl ContractController {
// Mock contract 5 - Active // Mock contract 5 - Active
let mut contract5 = Contract { let mut contract5 = Contract {
content_dir: None,
toc: None,
id: "contract-005".to_string(), id: "contract-005".to_string(),
title: "Digital Identity Verification Service Agreement".to_string(), title: "Digital Identity Verification Service Agreement".to_string(),
description: "Agreement for providing digital identity verification services to businesses operating in the Zanzibar Autonomous Zone.".to_string(), description: "Agreement for providing digital identity verification services to businesses operating in the Zanzibar Autonomous Zone.".to_string(),

View File

@ -136,6 +136,14 @@ impl ContractRevision {
} }
} }
/// Table of Contents item for multi-page contracts
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TocItem {
pub title: String,
pub file: String,
pub children: Vec<TocItem>,
}
/// Contract model /// Contract model
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contract { pub struct Contract {
@ -153,6 +161,9 @@ pub struct Contract {
pub revisions: Vec<ContractRevision>, pub revisions: Vec<ContractRevision>,
pub current_version: u32, pub current_version: u32,
pub organization_id: Option<String>, pub organization_id: Option<String>,
// Multi-page markdown support
pub content_dir: Option<String>,
pub toc: Option<Vec<TocItem>>,
} }
impl Contract { impl Contract {
@ -171,8 +182,10 @@ impl Contract {
expiration_date: None, expiration_date: None,
signers: Vec::new(), signers: Vec::new(),
revisions: Vec::new(), revisions: Vec::new(),
current_version: 0, current_version: 1,
organization_id, organization_id,
content_dir: None,
toc: None,
} }
} }

View File

@ -1,3 +1,5 @@
{% import "contracts/macros/contract_macros.html" as contract_macros %}
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Contract Details{% endblock %} {% block title %}Contract Details{% endblock %}
@ -41,6 +43,11 @@
<i class="bi bi-clock-history me-1"></i> Activity <i class="bi bi-clock-history me-1"></i> Activity
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="signatures-tab" data-bs-toggle="tab" data-bs-target="#signatures" type="button" role="tab" aria-controls="signatures" aria-selected="false">
<i class="bi bi-pencil-square me-1"></i> Signatures
</button>
</li>
</ul> </ul>
<div class="tab-content" id="contractTabsContent"> <div class="tab-content" id="contractTabsContent">
@ -53,83 +60,52 @@
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Contract Document</h5> <h5 class="mb-0">Contract Document</h5>
{% if contract.status == 'Signed' %} {% if contract.status == 'Signed' %}
<span class="badge bg-success">SIGNED</span> <span class="badge bg-success">SIGNED</span>
{% elif contract.status == 'Active' %} {% elif contract.status == 'Active' %}
<span class="badge bg-success">ACTIVE</span> <span class="badge bg-success">ACTIVE</span>
{% elif contract.status == 'PendingSignatures' %} {% elif contract.status == 'PendingSignatures' %}
<span class="badge bg-warning text-dark">PENDING</span> <span class="badge bg-warning text-dark">PENDING</span>
{% elif contract.status == 'Draft' %} {% elif contract.status == 'Draft' %}
<span class="badge bg-secondary">DRAFT</span> <span class="badge bg-secondary">DRAFT</span>
{% endif %} {% endif %}
</div> </div>
<div class="card-body bg-light"> <div class="card-body bg-light">
{% if contract.revisions|length > 0 %} {% if contract_section_content_error is defined %}
<div class="alert alert-danger">{{ contract_section_content_error }}</div>
{% endif %}
{% if contract_section_content is defined %}
<div class="row">
<div class="col-md-3">
<div class="list-group mb-3">
{% set section_param = section | default(value=toc[0].file) %}
{{ contract_macros::render_toc(items=toc, section_param=section_param) }}
</div>
</div>
<div class="col-md-9">
<div class="bg-white p-4 border rounded">
{{ contract_section_content | safe }}
</div>
</div>
</div>
{% elif contract.revisions|length > 0 %}
{% set latest_revision = contract.latest_revision %} {% set latest_revision = contract.latest_revision %}
<div class="bg-white p-4 border rounded"> <div class="bg-white p-4 border rounded">
{{ latest_revision.content|safe }} {{ latest_revision.content|safe }}
</div> </div>
{% else %} {% else %}
<div class="text-center py-5 text-muted"> <div class="alert alert-warning text-center py-5">
<p>No content has been added to this contract yet.</p> <p>
{% if contract_section_content_error is defined %}
{{ contract_section_content_error }}
{% else %}
No content or markdown sections could be loaded for this contract. Please check the contract's content directory and Table of Contents configuration.
{% endif %}
</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Signature Areas -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Signatures</h5>
</div>
<div class="card-body">
<div class="row">
{% for signer in contract.signers %}
<div class="col-md-6 mb-4">
<div class="card h-100 {% if signer.status == 'Signed' %}border-success{% elif signer.status == 'Rejected' %}border-danger{% else %}border-warning{% endif %}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">{{ signer.name }}</h6>
<span class="badge {% if signer.status == 'Signed' %}bg-success{% elif signer.status == 'Rejected' %}bg-danger{% else %}bg-warning text-dark{% endif %}">
{{ signer.status }}
</span>
</div>
<div class="card-body">
<p class="text-muted mb-2">{{ signer.email }}</p>
{% if signer.status == 'Signed' %}
<div class="text-center border-top pt-3">
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Signature_of_John_Hancock.svg/1280px-Signature_of_John_Hancock.svg.png" alt="Signature" class="img-fluid" style="max-height: 60px;">
<div class="small text-muted mt-2">Signed on {{ signer.signed_at }}</div>
</div>
{% elif signer.status == 'Rejected' %}
<div class="alert alert-danger mt-3">
<i class="bi bi-x-circle me-2"></i> Rejected on {{ signer.signed_at }}
</div>
{% else %}
<div class="text-center mt-3">
<p class="text-muted mb-2">Waiting for signature...</p>
{% if not user_has_signed %}
<button class="btn btn-primary btn-sm btn-sign" data-signer-id="{{ signer.id }}">
<i class="bi bi-pen me-1"></i> Sign Here
</button>
{% endif %}
</div>
{% endif %}
{% if signer.comments %}
<div class="mt-3">
<p class="small text-muted mb-1">Comments:</p>
<p class="small">{{ signer.comments }}</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
@ -168,7 +144,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Signers Status</h5> <h5 class="mb-0">Signers Status</h5>
@ -195,7 +170,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Contract Info</h5> <h5 class="mb-0">Contract Info</h5>
@ -224,6 +198,85 @@
</div> </div>
</div> </div>
<!-- Signatures Tab -->
<div class="tab-pane fade" id="signatures" role="tabpanel" aria-labelledby="signatures-tab">
<div class="row">
<div class="col-md-12">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Signatures</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Status</th>
<th scope="col">Signed At</th>
<th scope="col">Comments</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{% for signer in contract.signers %}
<tr class="{% if signer.status == 'Signed' %}table-success{% elif signer.status == 'Rejected' %}table-danger{% elif signer.status == 'Pending' %}table-warning{% endif %}">
<td>{{ signer.name }}</td>
<td>{{ signer.email }}</td>
<td>
<span class="badge {% if signer.status == 'Signed' %}bg-success{% elif signer.status == 'Rejected' %}bg-danger{% else %}bg-warning text-dark{% endif %}">
{{ signer.status }}
</span>
</td>
<td>
{% if signer.status == 'Signed' or signer.status == 'Rejected' %}
{{ signer.signed_at }}
{% else %}
<span class="text-muted">--</span>
{% endif %}
</td>
<td>
{% if signer.comments %}
<span class="small">{{ signer.comments }}</span>
{% else %}
<span class="text-muted">--</span>
{% endif %}
</td>
<td>
{% if signer.status == 'Signed' %}
<a href="/contracts/{{ contract.id }}/signed/{{ signer.id }}" class="btn btn-outline-primary btn-sm" target="_blank">
<i class="bi bi-eye"></i> View Signed Document
</a>
{% elif signer.status == 'Rejected' %}
<button class="btn btn-outline-secondary btn-sm" disabled title="Rejected">
<i class="bi bi-x-circle"></i> Rejected
</button>
<button class="btn btn-outline-warning btn-sm">
<i class="bi bi-bell"></i> Remind to Sign
</button>
{% else %}
{% if current_user is defined and not user_has_signed and signer.email == current_user.email %}
<button class="btn btn-primary btn-sm btn-sign" data-signer-id="{{ signer.id }}">
<i class="bi bi-pen"></i> Sign Here
</button>
{% endif %}
<button class="btn btn-outline-warning btn-sm">
<i class="bi bi-bell"></i> Remind to Sign
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Details Tab --> <!-- Details Tab -->
<div class="tab-pane fade" id="details" role="tabpanel" aria-labelledby="details-tab"> <div class="tab-pane fade" id="details" role="tabpanel" aria-labelledby="details-tab">
<div class="row"> <div class="row">

View File

@ -0,0 +1,10 @@
{% macro render_toc(items, section_param) %}
{% for item in items %}
<a href="?section={{ item.file }}" class="list-group-item list-group-item-action{% if section_param == item.file %} active{% endif %}">{{ item.title }}</a>
{% if item.children and item.children | length > 0 %}
<div class="ms-3">
{{ self::render_toc(items=item.children, section_param=section_param) }}
</div>
{% endif %}
{% endfor %}
{% endmacro %}