diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a02bc..7a95c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to `learn-content-engine`. The format is inspired by [SemVer](https://semver.org/) (schema evolution is additive, see [docs/concepts.md](docs/concepts.md#schema-version-policy-additive)). +## [0.14.0] - 2026-07-23 + +Adds an optional `visibility` flag to the content-manifest set entry (#83). + +Some sets in a content repo are technical reference or conformance fixtures, +not learner content. The canonical case is the graded-quiz demo in +`adaptive-learner-content-test`, the deliberate `E-EXT-UNSUPPORTED` negative +case in `scripts/conformance-real.mjs`: it must stay on disk for conformance +but should not surface to learners. Until now the consumer app carried a +hardcoded app-side blocklist, the wrong layer for repo-owned metadata, because +the strict set-entry schema (`additionalProperties: false`) rejected any new +field. + +`schema/content-manifest.schema.json` now declares an optional +`visibility: "visible" | "hidden"` on `ContentSet` (default `"visible"`). It is +a consumer-display hint only: the engine and the real-content conformance +harness still validate hidden sets and never exclude them from validation; only +consumer apps filter on it. Absent means visible, so every existing manifest +keeps validating unchanged. The flag flows through the canonical projection, so +`asContentSetEntry` exposes it on `ContentSetEntry.visibility` (a new +`SetVisibility` type), normalizing any out-of-enum value back to `"visible"`. + +Additive and backward compatible, so `x-schema-version` stays `1.8` (matching +the field-level additive precedent in the lesson schema); the frozen schema +baseline is refreshed in the same commit. + ## [0.13.3] - 2026-07-22 Hardens `W-INVISIBLE-CHAR` (#77), the lint shipped one release earlier. diff --git a/docs/concepts.md b/docs/concepts.md index 745ef80..1e47882 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -20,9 +20,10 @@ manifest.yaml ──parseManifest──▶ ParsedManifest `ParsedManifest` (a thin YAML/JSON parse - no projection). 2. **`asContentSetEntry(source, parsedSet, cachedVersion, ...)`** projects one `sets[]` entry into a canonical `ContentSetEntry`: it resolves the language - pair, applies defaults, computes `update_available`, and projects the book - block. **`setBasePath(parsedSet)`** tells you the repo-relative directory - holding that set's `lessons/`. + pair, applies defaults, computes `update_available`, projects the book + block, and carries the optional `visibility` hint (`"visible"` unless the set + opts out with `"hidden"`). **`setBasePath(parsedSet)`** tells you the + repo-relative directory holding that set's `lessons/`. 3. From the set entry you build a **`LessonSetContext`** (`language`, `target_language`, `source_language`, `domain`) - the context a lesson inherits from its parent set. diff --git a/package.json b/package.json index 37c2e7c..cab0ace 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "learn-content-engine", - "version": "0.13.3", + "version": "0.14.0", "description": "Framework-agnostic TypeScript engine that parses lesson content from pluggable sources into a canonical lesson object.", "type": "module", "license": "MIT", diff --git a/schema/content-manifest.schema.json b/schema/content-manifest.schema.json index 64c09af..fad05bc 100644 --- a/schema/content-manifest.schema.json +++ b/schema/content-manifest.schema.json @@ -156,6 +156,16 @@ "description": "Semver-style version. Bumped whenever ANY lesson or asset inside the set changes; drives cache invalidation.", "title": "Version", "type": "string" + }, + "visibility": { + "default": "visible", + "description": "#83 - consumer-display hint. ``hidden`` asks a consumer app NOT to surface the set to learners (e.g. a conformance/reference fixture that must stay on disk for engine validation but is not learner content). Additive and optional; absent means ``visible``. DISPLAY hint only: the engine and ``scripts/conformance-real.mjs`` still validate hidden sets and never exclude them from engine validation; only consumer apps filter on it.", + "enum": [ + "visible", + "hidden" + ], + "title": "Visibility", + "type": "string" } }, "required": [ diff --git a/src/__fixtures__/schema-baseline/content-manifest.schema.json b/src/__fixtures__/schema-baseline/content-manifest.schema.json index 64c09af..fad05bc 100644 --- a/src/__fixtures__/schema-baseline/content-manifest.schema.json +++ b/src/__fixtures__/schema-baseline/content-manifest.schema.json @@ -156,6 +156,16 @@ "description": "Semver-style version. Bumped whenever ANY lesson or asset inside the set changes; drives cache invalidation.", "title": "Version", "type": "string" + }, + "visibility": { + "default": "visible", + "description": "#83 - consumer-display hint. ``hidden`` asks a consumer app NOT to surface the set to learners (e.g. a conformance/reference fixture that must stay on disk for engine validation but is not learner content). Additive and optional; absent means ``visible``. DISPLAY hint only: the engine and ``scripts/conformance-real.mjs`` still validate hidden sets and never exclude them from engine validation; only consumer apps filter on it.", + "enum": [ + "visible", + "hidden" + ], + "title": "Visibility", + "type": "string" } }, "required": [ diff --git a/src/content-engine.test.ts b/src/content-engine.test.ts index 3833190..5e77e39 100644 --- a/src/content-engine.test.ts +++ b/src/content-engine.test.ts @@ -213,6 +213,51 @@ describe("Content-Engine — canonical set-entry projection", () => { expect(entry.cached_version).toBe("1.0.0"); }); + it("projects visibility: hidden through to the canonical entry (#83)", () => { + const entry = asContentSetEntry( + SOURCE, + { + id: "graded-quiz-demo-from-de", + title: "Graded Quiz Demo", + target_language: "de", + level: "A1", + version: "1.0.0", + lesson_count: 1, + visibility: "hidden", + }, + null, + ); + expect(entry.visibility).toBe("hidden"); + }); + + it("defaults visibility to ``visible`` when the set omits it (boundary)", () => { + const entry = asContentSetEntry( + SOURCE, + { id: "fr-a1", title: "French A1", target_language: "fr", level: "A1", version: "1.0.0", lesson_count: 1 }, + null, + ); + expect(entry.visibility).toBe("visible"); + }); + + it("normalizes an unexpected visibility value to ``visible`` (defensive projection)", () => { + const entry = asContentSetEntry( + SOURCE, + { + id: "fr-a1", + title: "French A1", + target_language: "fr", + level: "A1", + version: "1.0.0", + lesson_count: 1, + // A raw parsed set may carry an out-of-enum value; the projection + // must not leak it as a truthy "hidden"-like state. + visibility: "bogus" as unknown as "visible", + }, + null, + ); + expect(entry.visibility).toBe("visible"); + }); + it("passes through downloaded_at, explicit status and optional set fields", () => { const entry = asContentSetEntry( SOURCE, diff --git a/src/content-engine.ts b/src/content-engine.ts index be112c7..477bdfd 100644 --- a/src/content-engine.ts +++ b/src/content-engine.ts @@ -34,6 +34,7 @@ import type { ContentSetEntry, ContentSetSource, SetStatus, + SetVisibility, } from "./types/index.js"; /** Manifest ``sets[].book`` block. */ @@ -78,6 +79,9 @@ export interface ParsedSet { path?: string; /** Optional set-level book block (title/author/url/asin). */ book?: ParsedSetBook; + /** Consumer-display hint (#83). ``"hidden"`` keeps the set out of a + * consumer app's learner-facing list; absent means ``"visible"``. */ + visibility?: SetVisibility; } /** A parsed ``manifest.yaml`` document (repo-level or set-level). */ @@ -181,6 +185,7 @@ export function asContentSetEntry( downloaded_at: downloadedAt, status, book: asContentSetBook(parsed.book), + visibility: parsed.visibility === "hidden" ? "hidden" : "visible", }; } diff --git a/src/index.ts b/src/index.ts index e9dc9ba..97190c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ export type { ContentSetEntry, ContentSetSource, SetStatus, + SetVisibility, } from "./types/index.js"; // Underlying generated schema element types. diff --git a/src/types/content.ts b/src/types/content.ts index 97a7980..1dd48e1 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -36,6 +36,15 @@ import type { */ export type SetStatus = "active" | "deferred" | "completed"; +/** + * Consumer-display hint for a set (#83). ``"visible"`` is the default; + * ``"hidden"`` asks a consumer app NOT to surface the set to learners (e.g. a + * conformance/reference fixture that must stay on disk for engine validation + * but is not learner content). It is a DISPLAY hint only: the engine never + * excludes a hidden set from validation or conformance. + */ +export type SetVisibility = "visible" | "hidden"; + /** The canonical projection of one content set (a manifest ``sets[]`` entry * resolved against its cached state). Produced by ``asContentSetEntry``. */ export interface ContentSetEntry { @@ -72,6 +81,10 @@ export interface ContentSetEntry { status?: SetStatus; /** Optional set-level book block. */ book?: ContentSetBook | null; + /** Consumer-display hint (#83); ``"visible"`` unless the set opts out + * with ``"hidden"``. A DISPLAY hint only - never affects engine + * validation or conformance. */ + visibility: SetVisibility; } /** A set's manifest-level book block. Mirrors the manifest \`sets[].book\` diff --git a/src/types/index.ts b/src/types/index.ts index bf75a3d..25fc234 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -21,6 +21,7 @@ export type { ContentSetEntry, ContentSetSource, SetStatus, + SetVisibility, } from "./content.js"; // The generated schema element types (raw, pre-alias) — useful for consumers diff --git a/src/validate.test.ts b/src/validate.test.ts index 3661062..6275cf2 100644 --- a/src/validate.test.ts +++ b/src/validate.test.ts @@ -400,4 +400,47 @@ describe("validateManifest — negative + legacy-alias parity", () => { const result = validateManifest({ name: "Broken", sets: [null] }); expect(result.valid).toBe(false); }); + + it("accepts a set with visibility: hidden (additive consumer-display hint, #83)", () => { + const manifest = { + schema_version: "1.2", + name: "Fixtures", + sets: [ + { + id: "graded-quiz-demo-from-de", + title: "Graded Quiz Demo", + target_language: "de", + level: "A1", + version: "1.0.0", + lesson_count: 1, + visibility: "hidden", + }, + ], + }; + expect(validateManifest(manifest).valid).toBe(true); + }); + + it("accepts an explicit visibility: visible", () => { + const manifest = { + schema_version: "1.2", + name: "Fixtures", + sets: [ + { id: "x", title: "X", target_language: "de", level: "A1", version: "1.0.0", lesson_count: 1, visibility: "visible" }, + ], + }; + expect(validateManifest(manifest).valid).toBe(true); + }); + + it("rejects an unknown visibility value (closed enum)", () => { + const manifest = { + schema_version: "1.2", + name: "Fixtures", + sets: [ + { id: "x", title: "X", target_language: "de", level: "A1", version: "1.0.0", lesson_count: 1, visibility: "secret" }, + ], + }; + const result = validateManifest(manifest); + expect(result.valid).toBe(false); + expect(mentions(result, "visibility")).toBe(true); + }); });