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

Explore The Way

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

Explore The Way

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.

{% for section in generated.canon.sections %} -

{{ section.id }}

{{ section.title }}

+

{{ section.id }}

{{ section.title }}

{% endfor %} diff --git a/src/way/unit.njk b/src/way/unit.njk index 12fef2b..1f62733 100644 --- a/src/way/unit.njk +++ b/src/way/unit.njk @@ -7,8 +7,8 @@ pagination: permalink: "/way/{{ unit.routeSlug }}/" title: "{{ unit.title }}" --- - +

Canon-derived companion · {{ unit.section }}

{{ unit.title }}

canon-derivedorientation pending review

This page preserves the unit’s identity and location while offering paths into interpretation and exact source. It does not rewrite the canonical text.

-

Place in the document

Unit ID
{{ unit.id }}
Stable route
{{ unit.routeSlug }}
Source lines
{{ unit.startLine }}–{{ unit.endLine }}
Detected notes
{{ unit.notes.length }}
Unit digest
{{ unit.hash }}
+

Place in the document

Unit ID
{{ unit.id }}
Heading level
{{ unit.level }}
{% if unit.parentId %}
Parent heading
{% for parent in generated.canon.units %}{% if parent.id == unit.parentId %}{{ parent.title }}{% endif %}{% endfor %}
{% endif %}
Stable route
{{ unit.routeSlug }}
Source lines
{{ unit.startLine }}–{{ unit.endLine }}
Detected notes
{{ unit.notes.length }}
Unit digest
{{ unit.hash }}

Companion reading

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.

Enter this unit’s LabRead exact source
diff --git a/tests/canon-integrity.test.mjs b/tests/canon-integrity.test.mjs index 3165fa3..4ceaa8d 100644 --- a/tests/canon-integrity.test.mjs +++ b/tests/canon-integrity.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; -test('canon data preserves Wayseer identity, provenance, and stable unit evidence', async () => { +test('canon data preserves Wayseer identity, provenance, stable unit evidence, and nested heading parents', async () => { const canon = JSON.parse(await readFile('src/_data/generated/canon.json', 'utf8')); assert.equal(canon.source.repository, 'wayseer00/main'); assert.equal(canon.source.path, 'canon/INTERDEPENDENT_WAY.txt'); @@ -23,4 +23,14 @@ test('canon data preserves Wayseer identity, provenance, and stable unit evidenc assert.equal(routes.has(unit.routeSlug), false); routes.add(unit.routeSlug); } + + const interdefinables = canon.units.find(unit => unit.title === 'The Interdefinables'); + const human = canon.units.find(unit => /^Human consciousness emerges from:?$/i.test(unit.title)); + assert.ok(interdefinables); + assert.ok(human); + assert.equal(human.level, 3); + assert.equal(human.section, interdefinables.section); + assert.equal(human.parentId, interdefinables.id); + assert.equal(canon.sections.some(section => /^Human consciousness emerges from:?$/i.test(section.title)), false); + assert.ok(canon.edges.some(edge => edge.type === 'heading-parent' && edge.from === human.id && edge.to === interdefinables.id)); }); diff --git a/tests/canon-parser.test.mjs b/tests/canon-parser.test.mjs index 46c9254..ad03589 100644 --- a/tests/canon-parser.test.mjs +++ b/tests/canon-parser.test.mjs @@ -14,6 +14,49 @@ test('Markdown recovery headings normalize to plain-canon parent levels', () => assert.ok(data.sections.some(section => section.id === 'rights-and-definitions-of-the-way' && section.level === 2)); }); +test('Interdefinables owns the Human consciousness hierarchy until Preamble', () => { + const data = parseCanon(`The Interdependent Way\n\nAwakening\nOpening.\n\nThe Interdefinables\nDefinitions.\n\nHuman consciousness emerges from\nBinary essences meaningfully, divided; then, rooted.\nBody.\nTrinary perceptual focal states of complex system spirals: mind (body) soul\nBody.\nPreamble\nCivic claim.`); + const interdefinables = data.units.find(unit => unit.title === 'The Interdefinables'); + const human = data.units.find(unit => unit.title === 'Human consciousness emerges from'); + const binary = data.units.find(unit => unit.title.startsWith('Binary essences')); + const trinary = data.units.find(unit => unit.title.startsWith('Trinary perceptual')); + const preamble = data.units.find(unit => unit.title === 'Preamble'); + + assert.equal(interdefinables.level, 2); + assert.equal(human.level, 3); + assert.equal(human.section, 'interdefinables'); + assert.equal(human.parentId, interdefinables.id); + assert.equal(binary.level, 4); + assert.equal(binary.section, 'interdefinables'); + assert.equal(binary.parentId, human.id); + assert.equal(trinary.level, 4); + assert.equal(trinary.parentId, human.id); + assert.equal(preamble.level, 2); + assert.equal(preamble.section, 'preamble'); + assert.equal(data.sections.some(section => /^Human consciousness/.test(section.title)), false); + assert.ok(data.sections.findIndex(section => section.title === 'The Interdefinables') < data.sections.findIndex(section => section.title === 'Preamble')); + assert.ok(data.edges.some(edge => edge.type === 'heading-parent' && edge.from === human.id && edge.to === interdefinables.id)); +}); + +test('legacy Markdown title levels cannot promote Human consciousness to a peer section', () => { + const data = parseCanon(`# The Interdependent Way\n### The Interdefinables\nDefinitions.\n### Human consciousness emerges from:\n#### Binary essences meaningfully divided, then rooted:\nBody.\n### Preamble\nCivic claim.`); + const interdefinables = data.units.find(unit => unit.title === 'The Interdefinables'); + const human = data.units.find(unit => /^Human consciousness/.test(unit.title)); + const binary = data.units.find(unit => unit.title.startsWith('Binary essences')); + const preamble = data.units.find(unit => unit.title === 'Preamble'); + + assert.equal(human.sourceLevel, 3); + assert.equal(human.level, 3); + assert.equal(human.section, 'interdefinables'); + assert.equal(human.parentId, interdefinables.id); + assert.equal(binary.sourceLevel, 4); + assert.equal(binary.level, 4); + assert.equal(binary.parentId, human.id); + assert.equal(preamble.sourceLevel, 3); + assert.equal(preamble.level, 2); + assert.equal(preamble.section, 'preamble'); +}); + test('multiple superscript note definitions on one physical line remain distinct', () => { assert.deepEqual(extractNotes('>¹ first tension ² second tension ³ third tension'), [ { marker: '¹', text: 'first tension' }, diff --git a/tests/generated-site.test.mjs b/tests/generated-site.test.mjs index 29a67f3..7aef9f0 100644 --- a/tests/generated-site.test.mjs +++ b/tests/generated-site.test.mjs @@ -46,6 +46,22 @@ test('generated deployment artifact contains the unified routes', async () => { ]) assert.match(articles, new RegExp(title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); }); +test('Way map renders Human consciousness beneath Interdefinables and before Preamble', async () => { + const way = await readFile('_site/way/index.html', 'utf8'); + const interdefinablesSection = way.indexOf('

The Interdefinables

'); + const humanHeading = way.indexOf('Human consciousness emerges from'); + const binaryHeading = way.indexOf('Binary essences meaningfully'); + const preambleSection = way.indexOf('

Preamble

'); + + assert.ok(interdefinablesSection >= 0, 'Interdefinables section missing'); + assert.ok(humanHeading > interdefinablesSection, 'Human consciousness must appear inside Interdefinables'); + assert.ok(binaryHeading > humanHeading, 'Human consciousness child headings must follow their parent'); + assert.ok(preambleSection > binaryHeading, 'Preamble must be the next major section after Interdefinables'); + assert.match(way, /class="unit-level-3"[^>]*>[\s\S]*?Human consciousness emerges from/); + assert.match(way, /class="unit-level-4"[^>]*>[\s\S]*?Binary essences meaningfully/); + assert.doesNotMatch(way, /

Human consciousness emerges from:?<\/h2>/); +}); + test('generated deployment artifact contains all rights article vertical slices', async () => { const pages = [ ['article-one', /Contribution without contempt/, /From each as they will/],