Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions schema/content-manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
10 changes: 10 additions & 0 deletions src/__fixtures__/schema-baseline/content-manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
45 changes: 45 additions & 0 deletions src/content-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/content-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
ContentSetEntry,
ContentSetSource,
SetStatus,
SetVisibility,
} from "./types/index.js";

/** Manifest ``sets[].book`` block. */
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -181,6 +185,7 @@ export function asContentSetEntry(
downloaded_at: downloadedAt,
status,
book: asContentSetBook(parsed.book),
visibility: parsed.visibility === "hidden" ? "hidden" : "visible",
};
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type {
ContentSetEntry,
ContentSetSource,
SetStatus,
SetVisibility,
} from "./types/index.js";

// Underlying generated schema element types.
Expand Down
13 changes: 13 additions & 0 deletions src/types/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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\`
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type {
ContentSetEntry,
ContentSetSource,
SetStatus,
SetVisibility,
} from "./content.js";

// The generated schema element types (raw, pre-alias) — useful for consumers
Expand Down
43 changes: 43 additions & 0 deletions src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading