diff --git a/docs/content-model.md b/docs/content-model.md
index 3b2e588..8b22bc2 100644
--- a/docs/content-model.md
+++ b/docs/content-model.md
@@ -1,5 +1,30 @@
# Content model
-- Canon units: parsed headings with stable IDs, line ranges, and SHA-256 hashes.
+## Canon hierarchy
+
+Canon units are parsed headings with stable IDs, line ranges, SHA-256 hashes, major-section membership, and immediate heading-parent relationships.
+
+The opening hierarchy is load-bearing:
+
+```text
+Awakening
+The Interdefinables
+ Human consciousness emerges from
+ Binary essences...
+ Trinary perceptual focal states...
+ Trinary states of social perception...
+ Archetype passions of possession...
+Preamble
+Rights and Definitions of The Way
+```
+
+`Human consciousness emerges from` is not a peer section. It is a subheading beneath `The Interdefinables`; its binary, trinary, and archetype headings are nested beneath it. `Preamble` is the next major section after `The Interdefinables`.
+
+## Other records
+
- Project records: public GitHub facts plus visible `hmmm` when editorial metadata is missing.
- Lab records: generated per canon unit, with reviewed research gaps explicitly shown.
+
+## Usage guidance
+
+Run `npm run validate` after a canon refresh. The validation gate rejects a build that promotes `Human consciousness emerges from` to a section, assigns it outside `The Interdefinables`, loses its immediate parent, or places another major section between `The Interdefinables` and `Preamble` in the controlling remote canon.
diff --git a/scripts/canon-parser.mjs b/scripts/canon-parser.mjs
index eb01343..c48e92d 100644
--- a/scripts/canon-parser.mjs
+++ b/scripts/canon-parser.mjs
@@ -5,10 +5,10 @@ import slugify from 'slugify';
// id: canon_parser_core
// module_name: canon-parser
// module_kind: engine
-// summary: Parses canonical or recovery text into stable sections, units, notes, routes, and provenance-bearing hashes.
+// summary: Parses canonical or recovery text into stable sections, nested heading units, notes, routes, and provenance-bearing hashes.
// owner: Erin Spencer
// public_surface: parseCanon, detectHeading, extractNotes
-// internal_surface: slug, boundedRouteSlug, parseDefinitionLine, extractNoteMarkers
+// internal_surface: slug, boundedRouteSlug, canonicalHeadingLevel, parseDefinitionLine, extractNoteMarkers
// auth_boundary: none
// storage_boundary: none
// network_boundary: none
@@ -16,12 +16,12 @@ import slugify from 'slugify';
// admin_only: false
// tests: tests/canon-parser.test.mjs, tests/canon-integrity.test.mjs
// rollout: imported by scripts/parse-canon.mjs during every canon refresh
-// rollback: restore the prior inline parser and remove this import
+// rollback: restore the prior parser version and remove nested heading-parent edges
// === END MODULE_BUILD ===
-// Usage: import parseCanon(text, provenance); run `node --test tests/canon-parser.test.mjs` for recovery and note fixtures.
-// Limits: heading recognition is canon-specific; unknown headings remain body text and must surface as hmmm during editorial review.
+// Usage: import parseCanon(text, provenance); run `node --test tests/canon-parser.test.mjs` for hierarchy, recovery, and note fixtures.
+// Limits: heading recognition is canon-specific; unknown headings retain normalized source levels and must surface as hmmm during editorial review.
-export const parserVersion = '0.5.0';
+export const parserVersion = '0.6.0';
function slug(value) {
return slugify(value, { lower: true, strict: true }) || 'unit';
@@ -34,39 +34,40 @@ function boundedRouteSlug(id) {
return `${candidate.slice(0, 84).replace(/-+$/, '')}-${suffix}`;
}
+function canonicalHeadingLevel(value) {
+ const title = value.trim().replace(/:\s*$/, '');
+ if (/^The Interdependent Way$/i.test(title)) return 1;
+ if (/^(Awakening|The Interdefinables|Preamble|Etiquette of the Body Politic)$/i.test(title)) return 2;
+ if (/^Rights.+of The Way[⁰¹²³⁴⁵⁶⁷⁸⁹]*$/i.test(title)) return 2;
+ if (/^Addendum:\s+.+$/i.test(value.trim())) return 2;
+ if (/^Human consciousness emerges from$/i.test(title)) return 3;
+ if (/^Article\s+(One|Two|Three|Four|Five|Six|Seven|Eight)(?:\s+\([^)]+\))?$/i.test(title)) return 3;
+ if (/^Binary essences meaningfully.*rooted[.]?$/i.test(title)) return 4;
+ if (/^Trinary perceptual focal (?:states|constructs) of complex system spirals/i.test(title)) return 4;
+ if (/^Trinary (?:states of social perception|social perception focal states)/i.test(title)) return 4;
+ if (/^(?:Five dominant )?Archetype passions of possession/i.test(title)) return 4;
+ if (/^(Summary|One-sentence takeaway \(exactly as previously given\))$/i.test(title)) return 3;
+ return null;
+}
+
export function detectHeading(line) {
const markdown = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
if (markdown) {
const sourceLevel = markdown[1].length;
- // The recovery mirror uses H3 for the same major divisions that the plain-text
- // canon expresses as level 2, and H4 for article units. Normalize those levels
- // before assigning parents so offline and remote builds have the same structure.
- const level = sourceLevel >= 3 ? sourceLevel - 1 : sourceLevel;
- return {
- level,
- sourceLevel,
- title: markdown[2].replace(/#+$/, '').trim(),
- syntax: 'markdown'
- };
+ const title = markdown[2].replace(/#+$/, '').trim();
+ const declaredLevel = canonicalHeadingLevel(title);
+ // Recovery Markdown historically used H3 for both major sections and the
+ // Human-consciousness subheading. Canonical title semantics take priority;
+ // unknown headings retain the established H3→2 / H4→3 normalization.
+ const level = declaredLevel ?? (sourceLevel >= 3 ? sourceLevel - 1 : sourceLevel);
+ return { level, sourceLevel, title, syntax: 'markdown' };
}
const title = line.trim();
if (!title) return null;
- if (title === 'The Interdependent Way') return { level: 1, sourceLevel: 1, title, syntax: 'plain' };
- if (/^(Awakening|The Interdefinables|Human consciousness emerges from|Preamble|Etiquette of the Body Politic)$/i.test(title)) {
- return { level: 2, sourceLevel: 2, title, syntax: 'plain' };
- }
- if (/^Rights[\w\s’'&⁰¹²³⁴⁵⁶⁷⁸⁹-]+of The Way[⁰¹²³⁴⁵⁶⁷⁸⁹]*$/i.test(title)) {
- return { level: 2, sourceLevel: 2, title, syntax: 'plain' };
- }
- if (/^Addendum:\s+.+$/i.test(title)) return { level: 2, sourceLevel: 2, title, syntax: 'plain' };
- if (/^Article\s+(One|Two|Three|Four|Five|Six|Seven|Eight)(?:\s+\([^)]+\))?$/i.test(title)) {
- return { level: 3, sourceLevel: 3, title, syntax: 'plain' };
- }
- if (/^(Binary essences meaningfully, divided; then, rooted\.|Trinary perceptual focal states of complex system spirals:.+|Trinary states of social perception:|Archetype passions of possession\..+|Summary|One-sentence takeaway \(exactly as previously given\))$/i.test(title)) {
- return { level: 3, sourceLevel: 3, title, syntax: 'plain' };
- }
- return null;
+ const level = canonicalHeadingLevel(title);
+ if (!level) return null;
+ return { level, sourceLevel: level, title, syntax: 'plain' };
}
function parseDefinitionLine(line) {
@@ -111,6 +112,7 @@ export function parseCanon(text, provenance = {}) {
const documentHash = createHash('sha256').update(text).digest('hex');
const units = [];
const sections = [];
+ const headingStack = [];
let current = null;
let sectionId = 'source';
@@ -140,16 +142,22 @@ export function parseCanon(text, provenance = {}) {
}
}
+ const parent = [...headingStack].reverse().find(entry => entry.level < level) ?? null;
+ const id = `${sectionId}.${slug(title)}`;
current = {
- id: `${sectionId}.${slug(title)}`,
+ id,
title,
section: sectionId,
+ parentId: parent?.id ?? null,
level,
sourceLevel,
syntax,
startLine: index + 1,
lines: [lines[index]]
};
+
+ while (headingStack.length && headingStack.at(-1).level >= level) headingStack.pop();
+ headingStack.push({ id, level });
}
finish(lines.length);
@@ -178,6 +186,9 @@ export function parseCanon(text, provenance = {}) {
sections,
units: units.map(({ lines: ignored, ...unit }) => unit),
notes,
- edges: units.map(unit => ({ from: unit.id, to: unit.section, type: 'unit-parent' }))
+ edges: [
+ ...units.map(unit => ({ from: unit.id, to: unit.section, type: 'unit-parent' })),
+ ...units.filter(unit => unit.parentId).map(unit => ({ from: unit.id, to: unit.parentId, type: 'heading-parent' }))
+ ]
};
}
diff --git a/scripts/validate-content.mjs b/scripts/validate-content.mjs
index 3b75702..fc796bd 100644
--- a/scripts/validate-content.mjs
+++ b/scripts/validate-content.mjs
@@ -5,18 +5,18 @@ import { access, readFile } from 'node:fs/promises';
// id: generated_content_gate
// module_name: validate-content
// module_kind: instrument
-// summary: Refuses deployment when canon identity, snapshot integrity, generated route coverage, or recovery artifacts drift.
+// summary: Refuses deployment when canon identity, heading hierarchy, snapshot integrity, generated route coverage, or recovery artifacts drift.
// owner: Erin Spencer
// public_surface: npm run validate
-// internal_surface: canon snapshot digest and repository-route assertions
+// internal_surface: canon snapshot digest, heading hierarchy, and repository-route assertions
// auth_boundary: none
// storage_boundary: read
// network_boundary: none
// user_data_boundary: none
// admin_only: false
-// tests: tests/canon-integrity.test.mjs, tests/repo-coverage.test.mjs, tests/site-contract.test.mjs
+// tests: tests/canon-parser.test.mjs, tests/canon-integrity.test.mjs, tests/repo-coverage.test.mjs, tests/site-contract.test.mjs
// rollout: required by npm run build and npm run check
-// rollback: remove the gate only with an explicit replacement preserving provenance and route checks
+// rollback: remove the gate only with an explicit replacement preserving provenance, hierarchy, and route checks
// === END MODULE_BUILD ===
// Usage: run `npm run validate`; it refreshes data first and exits nonzero on any integrity mismatch.
// Limits: validates repository artifacts, not GitHub Pages settings or public DNS.
@@ -47,8 +47,26 @@ if (!canon.source.contentSha256 || canon.source.contentSha256.length !== 64) thr
if (canon.source.contentSha256 !== snapshotHash) throw new Error('generated canon digest does not match selected snapshot');
if (!canon.source.fallback && (!canon.source.commit || !canon.source.blob)) throw new Error('remote canon provenance missing commit or blob SHA');
if (!canon.units.length || canon.units.some(unit => !unit.hash || !unit.id)) throw new Error('canon units missing identity or hash');
+
+const interdefinables = canon.units.find(unit => unit.title === 'The Interdefinables');
+const humanConsciousness = canon.units.find(unit => /^Human consciousness emerges from:?$/i.test(unit.title));
+const preamble = canon.units.find(unit => unit.title === 'Preamble');
+if (!interdefinables || !humanConsciousness || !preamble) throw new Error('canon missing Interdefinables, Human consciousness, or Preamble hierarchy unit');
+if (humanConsciousness.level !== 3) throw new Error('Human consciousness emerges from must be a level-3 subheading');
+if (humanConsciousness.section !== interdefinables.section) throw new Error('Human consciousness emerges from escaped The Interdefinables section');
+if (humanConsciousness.parentId !== interdefinables.id) throw new Error('Human consciousness emerges from must be parented by The Interdefinables');
+if (canon.sections.some(section => /^Human consciousness emerges from:?$/i.test(section.title))) throw new Error('Human consciousness emerges from must not be promoted to a peer section');
+if (preamble.level !== 2 || preamble.section !== 'preamble') throw new Error('Preamble must remain the next major section boundary');
+if (!canon.source.fallback) {
+ const interdefinablesIndex = canon.sections.findIndex(section => section.title === 'The Interdefinables');
+ const preambleIndex = canon.sections.findIndex(section => section.title === 'Preamble');
+ if (interdefinablesIndex < 0 || preambleIndex !== interdefinablesIndex + 1) {
+ throw new Error('Preamble must be the next major section after The Interdefinables');
+ }
+}
+
if (repos.publicRepoCount !== repos.generatedRouteCount) throw new Error('repo route mismatch');
if (new Set(repos.repositories.map(repo => repo.slug)).size !== repos.repositories.length) throw new Error('duplicate project slug');
await access('fallback/index.html');
await access('artifacts/four-cuts-1.html');
-console.log(`validated ${canon.units.length} canon units, ${canon.notes.length} notes, and ${repos.publicRepoCount} repositories`);
+console.log(`validated ${canon.units.length} canon units, ${canon.notes.length} notes, canonical heading hierarchy, and ${repos.publicRepoCount} repositories`);
diff --git a/src/assets/css/site.css b/src/assets/css/site.css
index 9adc07a..58b52ac 100644
--- a/src/assets/css/site.css
+++ b/src/assets/css/site.css
@@ -80,6 +80,10 @@ h3 { font-size: clamp(1.2rem, 2vw, 1.55rem); }
.index-list a { display: grid; grid-template-columns: 7rem 1fr auto; gap: 1rem; align-items: baseline; padding: .9rem 1rem; border: 1px solid var(--line); border-radius: .75rem; background: rgba(16,24,42,.75); color: inherit; text-decoration: none; }
.index-list a:hover { border-color: var(--violet); }
.index-list small { color: var(--silver); }
+.canon-outline .unit-level-3 { margin-left: clamp(1rem, 3vw, 2.5rem); }
+.canon-outline .unit-level-4 { margin-left: clamp(2rem, 6vw, 5rem); }
+.canon-outline .unit-level-3 a { border-left: .25rem solid var(--violet); }
+.canon-outline .unit-level-4 a { border-left: .25rem solid var(--cyan); background: rgba(9, 13, 24, .76); }
.source-block { white-space: pre-wrap; overflow-wrap: anywhere; padding: 1rem; border: 1px solid var(--line); border-radius: .8rem; background: #070a12; color: #dce6fa; font: .92rem/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; }
dl.meta { display: grid; grid-template-columns: minmax(9rem, .35fr) 1fr; gap: .45rem 1rem; }
dl.meta dt { color: var(--silver); }
@@ -104,6 +108,8 @@ summary { cursor: pointer; font-weight: 800; }
.hero { grid-template-columns: 1fr; min-height: auto; }
.hero-field { max-width: 22rem; width: 78%; margin: 1rem auto; }
.index-list a { grid-template-columns: 1fr; gap: .15rem; }
+ .canon-outline .unit-level-3 { margin-left: .65rem; }
+ .canon-outline .unit-level-4 { margin-left: 1.3rem; }
.site-footer { grid-template-columns: 1fr; }
.hmmm-boundary { grid-column: auto; grid-template-columns: 1fr; }
}
diff --git a/src/way/index.njk b/src/way/index.njk
index 720c52d..69198e1 100644
--- a/src/way/index.njk
+++ b/src/way/index.njk
@@ -1,9 +1,9 @@
---
layout: layouts/base.njk
title: Explore The Way
-description: A sectioned companion map of The Interdependent Way, with exact source available one deliberate layer deeper.
+description: A sectioned and nested companion map of The Interdependent Way, with exact source available one deliberate layer deeper.
---
- Canon-derived map This layer identifies the shape and relations of the source. It does not replace the canon. Open a unit for orientation, then choose its Lab conversation or exact source. Canon-derived map This layer identifies the shape and relations of the source. Major sections, subheadings, and nested headings remain visibly distinct. It does not replace the canon. Open a unit for orientation, then choose its Lab conversation or exact source. {{ section.id }} {{ section.id }} Canon-derived companion · {{ unit.section }} This page preserves the unit’s identity and location while offering paths into interpretation and exact source. It does not rewrite the canonical text. A reviewed plain-language reading has not yet been admitted for this unit. The source operators, conditions, exceptions, and obligations must remain intact before this field is populated.Explore The Way
Explore The Way
{{ section.title }}
{% for unit in generated.canon.units %}{% if unit.section == section.id %}
{{ section.title }}
{% for unit in generated.canon.units %}{% if unit.section == section.id %}
{{ unit.title }}
Place in the document
Place in the document
Companion reading