diff --git a/.changeset/frontmatter-schema-validation.md b/.changeset/frontmatter-schema-validation.md
new file mode 100644
index 00000000..1268db1f
--- /dev/null
+++ b/.changeset/frontmatter-schema-validation.md
@@ -0,0 +1,20 @@
+---
+"@inkeep/open-knowledge-core": minor
+"@inkeep/open-knowledge-server": minor
+"@inkeep/open-knowledge-app": minor
+"@inkeep/open-knowledge": minor
+---
+
+Validate document frontmatter against JSON Schema files with the new `frontmatter` content-rules plugin.
+
+**Schema mappings in `config.yml`.** Enable the plugin in Settings → Plugins and map schema files to doc sets with globs — `contentRules.frontmatter.schemas` entries pair an `appliesTo` glob (or list; a leading `!` excludes) with a `file` path. Schema files are ordinary draft-07 JSON Schema, readable by any external tool; `.ok/schemas/` is only the default home.
+
+**Warnings everywhere, blocking nowhere.** Violations (`frontmatter/required`, `frontmatter/enum`, `frontmatter/type`, …) surface in the editor gutter, the Problems panel, `ok lint`, the MCP `lint` tool, and as advisories on agent writes — a violation never blocks or changes a write. Broken schema files and invalid globs report on the config channel (Settings panel, audit `warnings`), never as document problems.
+
+**No-code editing.** The plugin's settings panel manages the glob → file mappings, and a per-field editor edits each schema (type, required, allowed values, pattern) without touching raw JSON — keywords the editor doesn't model are preserved verbatim, and editing a mapping whose file doesn't exist yet creates it.
+
+**Schema-aware property panel.** Fields constrained by an `enum` render as a select, and array fields with `items.enum` render as toggleable value chips, so valid values are picked instead of typed.
+
+**Agents learn the contract at read time.** `exec` listings and reads advertise which schema files govern a doc or folder (`schemas: …`), resolved server-side.
+
+**Discovery UX.** The glob editor names its grammar with an example pattern, an explicit Edit button opens each schema on the WYSIWYG Fields view, and a glob that matches zero docs warns on the config channel alongside the invalid/suspicious-glob warnings. The Problems panel names the plugins doing the checking and, when none are enabled, says so and links to Settings → Plugins instead of a misleading "no problems".
diff --git a/docs/content/advanced/content-rules/frontmatter.mdx b/docs/content/advanced/content-rules/frontmatter.mdx
new file mode 100644
index 00000000..b0436e64
--- /dev/null
+++ b/docs/content/advanced/content-rules/frontmatter.mdx
@@ -0,0 +1,126 @@
+---
+title: Frontmatter schemas
+icon: LuBraces
+description: Validate document frontmatter against standard JSON Schema files — scoped to folders with globs, with schema-driven selects in the property panel and a no-code schema editor.
+---
+
+Frontmatter schemas is a content-rules linter that validates each document's YAML frontmatter against [JSON Schema](https://json-schema.org/) (draft-07) files. The schema files are plain, portable JSON Schema — nothing OK-specific in them — so the same files work with any external tool that understands the standard.
+
+This page covers the schema mappings, the schema files, the no-code editors, and what agents see. For the linter-agnostic parts — where problems appear, the Problems panel, `ok lint`, and agent advisories — see the [content rules overview](/docs/advanced/content-rules/overview).
+
+## Schema mappings
+
+Which docs validate against which schema lives in the project's `config.yml` (shared via git):
+
+```yaml
+contentRules:
+ frontmatter:
+ enabled: true
+ schemas:
+ - appliesTo: "docs/**"
+ file: ".ok/schemas/doc.schema.json"
+ - appliesTo:
+ - "specs/**"
+ - "!**/index"
+ file: ".ok/schemas/spec.schema.json"
+```
+
+- **`file`** is a project-root-relative path to a JSON Schema file. Schemas can live anywhere in the project; `.ok/schemas/` is the default home the settings editor creates them in.
+- **`appliesTo`** is a glob or list of globs matched against content-relative, extension-less doc paths (`docs/guide`, not `docs/guide.md`). A leading `!` excludes, and a doc is in scope when it matches a positive glob and no negated one. A list with no positive glob gains an implicit `**`, so `["!drafts/**"]` means every doc except drafts; an absent or empty `appliesTo` means **every doc**.
+- **Bad globs surface on the config channel, never per-doc.** One that can't compile matches nothing and is reported as a configuration problem — it never silently widens a mapping. One that compiles but can't match a normalized doc path gets an advisory instead: a trailing slash (`docs/`), a leading slash (`/docs/**`), or a file extension (`docs/**/*.md`), none of which appear in the paths being matched.
+- **Every matching mapping validates.** Two schemas both in scope for a doc act as a conjunction — the frontmatter must satisfy both. One file mapped from several entries still validates once.
+- Each mapping has its own **`enabled`** toggle, so you can park a mapping without deleting it. Absent means enabled — set `enabled: false` to park one.
+
+## Schema files
+
+Standard draft-07 only: an absent `$schema` is treated as draft-07, and a file declaring another dialect is skipped with a configuration problem. A schema file that is missing, malformed, or refused by the validator is also a **configuration problem** — it shows at the top of the Problems panel's project scope, in `ok lint` warnings, and on the MCP `lint` tool, never as a per-doc diagnostic.
+
+A doc with no frontmatter block (or unparsable YAML) validates as an empty object — so `required` fields still report on brand-new docs. The reverse also works: a schema with `"maxProperties": 0` enforces that docs carry no frontmatter at all.
+
+## What a finding looks like
+
+Take the schema above governing `docs/**`, and a doc that misses one required field and misspells an enum value:
+
+```markdown
+---
+status: shipped
+---
+
+# Guide
+```
+
+`ok lint` (and the Problems panel) reports both violations, each carrying source `frontmatter` and the violated JSON Schema keyword as its code:
+
+```text
+docs/guide.md
+ 1:1 warning Frontmatter property "owner" is required frontmatter/required
+ 2:1 warning Frontmatter property "status" must be one of: draft, review, published (got "shipped") frontmatter/enum
+
+2 problems (0 errors, 2 warnings) across 1 file.
+```
+
+As a diagnostic in `ok lint --json` or the MCP `lint` tool's structured content (0-based, end-exclusive ranges — the text report is 1-based):
+
+```json
+{
+ "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 15 } },
+ "severity": "warning",
+ "source": "frontmatter",
+ "code": "enum",
+ "message": "Frontmatter property \"status\" must be one of: draft, review, published (got \"shipped\")"
+}
+```
+
+**Anchoring.** A violation on a key underlines that key's line (the `enum` finding above sits on line 2, where `status:` is written); a missing `required` field anchors to the opening `---` fence; a doc with no fence anchors to the top. And a broken schema file is never a per-doc finding — it rides the configuration channel instead, as a `!` line in `ok lint`:
+
+```text
+! frontmatter schema .ok/schemas/missing.schema.json: cannot read (ENOENT: no such file or directory, …)
+```
+
+Frontmatter findings are always **warnings** — there's no severity promotion yet, so `ok lint --errors-only` won't gate on them — and none carry `fixes` (choosing the right value is a human decision), so `ok lint --fix` and the MCP `fix: true` leave them in place, still reported.
+
+## The settings editor
+
+**Settings ▸ Plugins ▸ Frontmatter schemas** (once the plugin is enabled) lists the project's schema files: search, an **Only modified** filter, and **New schema** to scaffold a fresh file in `.ok/schemas/`. Each entry shows a plain-language summary of its scope ("everything under docs/ — except any doc named index"), an editor for the `appliesTo` globs, an enable toggle, and delete for schema files OK manages.
+
+## The schema editor
+
+Opening a schema file in the editor (any `*.schema.json`, or a `.json` under `.ok/schemas/`) gets a **Source / Fields** toggle, like the markdownlint config's rule browser. **Source** shows the raw JSON; **Fields** is a no-code editor: each frontmatter field as a row with its type (string, number, boolean, enum, array, object), required toggle, description, allowed values, and pattern — recursing into object fields and array-of-object elements for nested frontmatter.
+
+Edits are **non-destructive**: the editor merges only the field you changed, and keywords it doesn't model (`allOf`, `if`/`then`, `x-` extensions, …) survive verbatim — with a note when the schema carries root-level advanced rules the Fields view doesn't show. Your hand-written schema stays yours.
+
+## The property panel
+
+The document panel's property editor reads the same schemas through the same `appliesTo` matching as the linter, so the two can never disagree. A field constrained by `enum` renders as a select instead of free text; an array field with `items.enum` renders as a multi-select. When several schemas govern a doc, the offered values are the intersection — and if no value could satisfy them all, the field falls back to free text while the linter reports the conflict.
+
+## AI agents
+
+Beyond the shared `lint` tool and write advisories (see the [overview](/docs/advanced/content-rules/overview#ai-agents)), reads advertise the contract up front: `exec` listings and reads carry a `schemas:` entry naming the schema files that govern each doc — and each folder, so an agent learns the expected shape **before** writing the first doc there. An `exec("ls docs/")` comes back with:
+
+```text
+- **docs/** (directory) — schemas: .ok/schemas/doc.schema.json — 2 md files — most recent: guide.md (docs/guide.md, 2026-07-21)
+- **docs/guide.md** (docs/guide.md) — status: shipped — schemas: .ok/schemas/doc.schema.json — backlinks: 0
+```
+
+The server resolves the globs; agents never evaluate `appliesTo` themselves. And when a write violates a governing schema, the write still lands and the response carries the violations as `lint-violation` advisories (1-based `line`/`column`), so the agent can correct its own frontmatter in a follow-up edit:
+
+```json
+"warnings": [
+ {
+ "kind": "lint-violation",
+ "source": "frontmatter",
+ "code": "required",
+ "message": "Frontmatter property \"owner\" is required",
+ "severity": "warning",
+ "line": 1,
+ "column": 1
+ }
+]
+```
+
+## See also
+
+- [Content rules overview](/docs/advanced/content-rules/overview): where problems show up, the Problems panel, `ok lint`, and agents
+- [markdownlint](/docs/advanced/content-rules/markdownlint): the sibling linter for markdown style
+- [Configuration reference](/docs/reference/configuration): `config.yml` and what's shared via git
+- [JSON Schema draft-07](https://json-schema.org/specification-links#draft-7): the schema dialect
diff --git a/docs/content/advanced/content-rules/markdownlint.mdx b/docs/content/advanced/content-rules/markdownlint.mdx
index 24a2f88c..6ef47bb0 100644
--- a/docs/content/advanced/content-rules/markdownlint.mdx
+++ b/docs/content/advanced/content-rules/markdownlint.mdx
@@ -4,7 +4,9 @@ icon: LuListChecks
description: The markdownlint linter for content rules — VS Code-parity defaults, your project's native `.markdownlint.*` config, and a no-code browser for all 53 rules.
---
-markdownlint is the first content-rules linter — the standard [markdownlint](https://github.com/DavidAnson/markdownlint) engine, the same one behind `markdownlint-cli2` and the popular editor extensions. This page covers its defaults, how it reads your project's config, and the built-in rule browser. For the linter-agnostic parts — where problems appear, the Problems panel, `ok lint`, and agent advisories — see the [content rules overview](/docs/advanced/content-rules/overview).
+markdownlint is the content-rules linter for markdown style — the standard [markdownlint](https://github.com/DavidAnson/markdownlint) engine, the same one behind `markdownlint-cli2` and the popular editor extensions.
+
+This page covers its defaults, how it reads your project's config, and the built-in rule browser. For the linter-agnostic parts — where problems appear, the Problems panel, `ok lint`, and agent advisories — see the [content rules overview](/docs/advanced/content-rules/overview). For validating frontmatter rather than markdown style, see [Frontmatter schemas](/docs/advanced/content-rules/frontmatter).
## Default rules
@@ -29,17 +31,46 @@ The rules live in your project's own native markdownlint file — `.markdownlint
**Settings ▸ Plugins ▸ markdownlint** (markdownlint appears there once the plugin is enabled) lists the full catalog — all 53 rules, generated from the installed engine's own config schema, so it always matches what actually runs. Search by id, alias, or name; browse by category (Headings, Lists, Whitespace, Code, Links & images, Style); or check **Only modified** to see just the rules your config changes. Expanding a rule reveals a link to its upstream documentation and typed editors for each of its options.
-The same browser also opens **directly on the config file**. Open your project's root `.markdownlint.json` or `.markdownlint.jsonc` in the editor (reveal hidden files to find it) and use the **Source / Rules** toggle: **Source** — the default — shows the raw file read-only, and **Rules** is the same no-code browser. Because rule edits target the project's governing root config, **Rules** is enabled only for that root file; open a nested or otherwise non-governing `.markdownlint.*` and **Rules** is disabled with a tooltip while **Source** still shows the file. Your Source/Rules choice is remembered separately from the document editor's own Visual/Markdown mode.
+The same browser also opens **directly on the config file**. Open your project's root `.markdownlint.json` or `.markdownlint.jsonc` in the editor (reveal hidden files to find it) and use the **Source / Rules** toggle: **Source**, the default, shows the raw file read-only; **Rules** is the same no-code browser. Your choice is remembered separately from the document editor's own Visual/Markdown mode.
+
+That toggle appears for JSON and JSONC configs only — a `.markdownlint.yaml`, `.markdownlint.yml`, or `.markdownlintrc` opens in the read-only file preview instead. And because rule edits target the project's governing root config, **Rules** is enabled only for that file; open a nested JSON config and **Rules** is disabled with a tooltip while **Source** still shows it.
+
+Every edit writes back to your native `.markdownlint.*` file, and a rule you configured under an alias stays under that alias. JSON and JSONC files get minimal text edits, so comments and formatting survive; a YAML config is re-serialized, which drops its comments. Severity strings are shown as a read-only badge. If the project has no file yet, the first change creates a `.markdownlint.json` seeded with the defaults — from then on that file is the whole story for OK and every other markdownlint tool.
+
+## What a finding looks like
+
+A markdownlint finding carries the rule id as its `code` under source `markdownlint` — rendered as `markdownlint/MD010` in reports — with the upstream engine's message. In `ok lint` text output:
+
+```text
+docs/guide.md
+ 7:5 warning Hard tabs: Column: 5 markdownlint/MD010
+```
+
+And as a diagnostic in `ok lint --json` (or the MCP `lint` tool's structured content), where an auto-fixable rule also carries its `fixes` — the exact text edits `--fix` or the editor's **Fix** action would apply:
+
+```json
+{
+ "range": { "start": { "line": 6, "character": 4 }, "end": { "line": 6, "character": 5 } },
+ "severity": "warning",
+ "source": "markdownlint",
+ "code": "MD010",
+ "message": "Hard tabs: Column: 5",
+ "fixes": [
+ { "range": { "start": { "line": 6, "character": 4 }, "end": { "line": 6, "character": 5 } }, "newText": " " }
+ ]
+}
+```
-Every edit writes back to your native `.markdownlint.*` file, preserving what you wrote: JSON and JSONC files get minimal text edits (comments and formatting survive), and a rule you configured under an alias stays under that alias. Severity strings are shown as a read-only badge. If the project has no file yet, the first change creates a `.markdownlint.json` seeded with the defaults — from then on that file is the whole story for OK and every other markdownlint tool.
+JSON ranges are 0-based and end-exclusive; the text report is 1-based. A rule that can't be auto-fixed simply has no `fixes` key. The shared report shapes — file grouping, counts, configuration warnings — are on the [overview](/docs/advanced/content-rules/overview#what-it-returns).
## Auto-fix and severities
-- **Auto-fix covers only some rules.** markdownlint can mechanically fix problems like hard tabs, trailing whitespace, list-marker style, and blank-line spacing — but not ones that need a human decision, such as inline HTML (`MD033`) or a missing top-level heading (`MD041`). In source mode the **Fix** action appears only on a problem markdownlint can fix; `ok lint --fix` applies every fixable problem across the project and leaves the rest untouched, still reported. There's no "fix everything" — the rest always need a manual edit.
+- **Auto-fix covers only some rules.** markdownlint can mechanically fix problems like hard tabs, trailing whitespace, list-marker style, and blank-line spacing — but not ones that need a human decision, such as inline HTML (`MD033`) or a missing top-level heading (`MD041`). In source mode the **Fix** action appears only on a problem markdownlint can fix; the Problems panel offers **Fix all** to apply every fixable problem in the current scope at once; and `ok lint --fix` does the same across the whole project. Whatever's left is still reported, untouched — no affordance resolves the rules that need a human decision.
- **Severities.** A rule reports as a warning unless your config promotes it to `"error"`. Set `"error"` as the rule's value or via a `severity` key, then gate CI on just those rules with `ok lint --errors-only`.
## See also
- [Content rules overview](/docs/advanced/content-rules/overview): where problems show up, the Problems panel, `ok lint`, and agents
+- [Frontmatter schemas](/docs/advanced/content-rules/frontmatter): the sibling linter for frontmatter validation
- [Configuration reference](/docs/reference/configuration): `config.yml` and what's shared via git
- [markdownlint rules](https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md): the upstream rule reference
diff --git a/docs/content/advanced/content-rules/meta.json b/docs/content/advanced/content-rules/meta.json
index 0890441d..0f6bf0d6 100644
--- a/docs/content/advanced/content-rules/meta.json
+++ b/docs/content/advanced/content-rules/meta.json
@@ -1,5 +1,5 @@
{
"title": "Content rules",
"icon": "LuListChecks",
- "pages": ["overview", "markdownlint"]
+ "pages": ["overview", "markdownlint", "frontmatter"]
}
diff --git a/docs/content/advanced/content-rules/overview.mdx b/docs/content/advanced/content-rules/overview.mdx
index dd203d1d..4f857e64 100644
--- a/docs/content/advanced/content-rules/overview.mdx
+++ b/docs/content/advanced/content-rules/overview.mdx
@@ -6,7 +6,9 @@ description: A non-blocking linting layer over your project's markdown — probl
Content rules are a lightweight linting layer over your project's markdown. **Enable a linter for a project** and it checks your markdown as you write. Findings are **non-blocking**: they surface as warnings, never stop a save, and never gate an agent's edit.
-Today the only linter is [markdownlint](/docs/advanced/content-rules/markdownlint) — the standard engine for markdown style (hard tabs, heading increments, list markers, and the rest). Content rules is built as a pluggable system, and more linters are planned. This page covers the parts that are the same whatever the linter runs; the [markdownlint page](/docs/advanced/content-rules/markdownlint) covers that engine's rules and configuration.
+Two linters ship today. [markdownlint](/docs/advanced/content-rules/markdownlint) is the standard engine for markdown style — hard tabs, heading increments, list markers, and the rest. [Frontmatter schemas](/docs/advanced/content-rules/frontmatter) validates each doc's frontmatter against standard JSON Schema files. The system is pluggable and more linters are planned.
+
+This page covers what's the same whatever linter runs; each linter's page covers its own rules and configuration.
## Where problems show up
@@ -35,9 +37,85 @@ ok lint --json # structured JSON output
ok lint --errors-only # exit non-zero only on error-severity problems
```
-The exit code is CI-friendly: non-zero when any problems are found. Findings are warnings unless your config promotes a rule to `"error"` severity, so `--errors-only` lets you gate CI on just the rules you've chosen to enforce.
+The exit code is non-zero when any problem is found. Findings are warnings unless your `.markdownlint.*` promotes a rule to `"error"`, so `--errors-only` gates CI on just the rules you chose to enforce. Only markdownlint rules can be promoted — [frontmatter](/docs/advanced/content-rules/frontmatter) findings are always warnings, so `--errors-only` never gates on them.
+
+### What it returns
+
+The text report lists each finding as `line:column` (1-based), severity, message, and a composed `source/code` id naming the linter and the violated rule:
+
+```text
+docs/guide.md
+ 7:5 warning Hard tabs: Column: 5 markdownlint/MD010
+ 1:1 warning Frontmatter property "owner" is required frontmatter/required
+ 2:1 warning Frontmatter property "status" must be one of: draft, review, published (got "shipped") frontmatter/enum
+
+3 problems (0 errors, 3 warnings) across 1 file.
+```
+
+Configuration problems (a malformed config file, a broken schema) print as `!` lines after the findings — they describe your setup, not a document. `--json` emits the same data as a machine-readable object:
+
+```json
+{
+ "contentDir": "/path/to/project",
+ "files": [
+ {
+ "file": "docs/guide.md",
+ "fixed": false,
+ "diagnostics": [
+ {
+ "range": { "start": { "line": 6, "character": 4 }, "end": { "line": 6, "character": 5 } },
+ "severity": "warning",
+ "source": "markdownlint",
+ "code": "MD010",
+ "message": "Hard tabs: Column: 5",
+ "fixes": [
+ { "range": { "start": { "line": 6, "character": 4 }, "end": { "line": 6, "character": 5 } }, "newText": " " }
+ ]
+ },
+ {
+ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } },
+ "severity": "warning",
+ "source": "frontmatter",
+ "code": "required",
+ "message": "Frontmatter property \"owner\" is required"
+ },
+ {
+ "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 15 } },
+ "severity": "warning",
+ "source": "frontmatter",
+ "code": "enum",
+ "message": "Frontmatter property \"status\" must be one of: draft, review, published (got \"shipped\")"
+ }
+ ]
+ }
+ ],
+ "warnings": [],
+ "fileCount": 1,
+ "errorCount": 0,
+ "warningCount": 3,
+ "fixedCount": 0
+}
+```
+
+Conventions to know:
+
+- JSON ranges are **0-based and end-exclusive** (LSP-aligned); the text report displays 1-based positions.
+- `fixes` appears only on auto-fixable findings — its presence is how tooling knows `--fix` would resolve the problem.
+- `fixed` is `true` on each file `--fix` rewrote, and the top-level `fixedCount` counts **files**, not problems.
+- The top-level `warnings` array carries the configuration problems.
+
+With `--fix`, fixable findings are applied in place, fixed files are marked `(fixed)`, and the report lists what remains:
+
+```text
+docs/guide.md (fixed)
+ 1:1 warning Frontmatter property "owner" is required frontmatter/required
+ 2:1 warning Frontmatter property "status" must be one of: draft, review, published (got "shipped") frontmatter/enum
-`ok audit` widens the same report to the full validation plane — markdownlint problems **and** broken internal links, each finding tagged with its source:
+2 problems (0 errors, 2 warnings) across 1 file.
+Fixed 1 file.
+```
+
+`ok audit` widens the same report to the full validation plane — content-rule problems **and** broken internal links, each finding tagged with its source:
```bash
ok audit # audit the whole project (lint + links)
@@ -46,18 +124,99 @@ ok audit --json # the full structured diagnostic plane
ok audit --errors-only # exit non-zero only on error-severity problems
```
-Unlike `ok lint`, `ok audit` needs the project's server running (`ok start` or OK Desktop) — the links validator reads the live backlink index. Same CI-friendly exit code; there's no `--fix` because the audit is read-only (lint fixes go through `ok lint --fix`, link repairs are content edits).
+Unlike `ok lint`, `ok audit` needs the project's server running (`ok start` or OK Desktop) — the links validator reads the live backlink index. There's no `--fix` because the audit is read-only (lint fixes go through `ok lint --fix`, link repairs are content edits).
+
+`--json` returns the same per-file grouping as `ok lint --json`, with two differences: the audit never writes, so there's no `contentDir`, `fixed`, or `fixedCount`; and a `links` finding carries `linkTarget` — the unresolved target verbatim, so tooling never parses it back out of the message:
+
+```json
+{
+ "files": [
+ {
+ "file": "docs/guide.md",
+ "diagnostics": [
+ {
+ "range": { "start": { "line": 11, "character": 0 }, "end": { "line": 11, "character": 0 } },
+ "severity": "warning",
+ "source": "links",
+ "code": "dead-link",
+ "message": "Link target \"guides/setup\" does not resolve to an existing document.",
+ "linkTarget": "guides/setup"
+ }
+ ]
+ }
+ ],
+ "warnings": [],
+ "fileCount": 1,
+ "errorCount": 0,
+ "warningCount": 1
+}
+```
+
+Broken links are warnings by default. The project's `validation.links` setting — **Settings ▸ This project ▸ Content rules** — decides both whether they appear and at what severity: `warning` (the default), `error` to gate CI on them with `--errors-only`, or `off` to drop them from the plane entirely. Content-rule findings keep their own severities, so `--errors-only` covers both planes at once.
## AI agents
-Agents get the same signal you see:
+Agents get the same signal you see, across three surfaces. See the [MCP reference](/docs/reference/mcp) for the full tool list.
+
+### The `lint` tool
+
+Lints a single document, or audits the project when `document` is omitted (`path` scopes it to a folder or file). `fix: true` — which requires `document` — auto-fixes fixable rules in place, attributed and live in the preview, the same result as the editor's **Fix** action.
+
+A single-document call returns a readable summary. Configuration problems ride along as `⚠` lines, and the closing hint tells the agent whether `fix: true` would help:
+
+```text
+docs/guide.md: 2 warnings
+ ⚠ line 1 frontmatter/required: Frontmatter property "owner" is required
+ ⚠ line 2 frontmatter/enum: Frontmatter property "status" must be one of: draft, review, published (got "shipped")
+ ⚠ frontmatter schema .ok/schemas/missing.schema.json: cannot read (ENOENT: no such file or directory, …)
+None are auto-fixable — these need content edits via `edit`/`write`.
+```
+
+The structured content is close to `ok lint --json`, with three differences:
+
+| | `ok lint --json` | MCP `lint` |
+| --- | --- | --- |
+| Project path | `contentDir` | `cwd` |
+| `fixedCount` counts | files rewritten | **problems** resolved |
+| Cap fields | — | `omittedFileCount`, per-file `omittedDiagnosticCount` |
+
+Everything else matches: `files[].diagnostics` with 0-based `range`, `severity`, `source`, `code`, `message`, plus `errorCount`, `warningCount`, `fileCount` on an audit, and the configuration-problem `warnings`. Audit output is capped at 10 files × 10 diagnostics per file — the text channel marks the remainder with "… and N more", the structured channel with the two cap fields above. Counts always reflect the full scan, and re-running with a narrower `path` recovers the detail. With `fix: true` the summary reports what was applied and what remains: `Fixed 1 problem in docs/guide.md.` followed by the unfixable findings.
+
+### The `audit` tool
+
+The agent-side `ok audit` — content-rule problems and broken links in one read-only call, grouped by file, same 10 × 10 cap, no fix shape.
+
+### Write responses
+
+Every write response carries validation findings for the document it touched, on two channels. Both nest under `document` in the structured content, and both are advisory: a finding never blocks the write.
+
+```json
+"document": {
+ "brokenLinks": [
+ { "href": "./guides/setup", "resolvedTo": "guides/setup", "reason": "no-such-doc" }
+ ],
+ "warnings": [
+ {
+ "kind": "lint-violation",
+ "source": "frontmatter",
+ "code": "enum",
+ "message": "Frontmatter property \"status\" must be one of: draft, review, published (got \"shipped\")",
+ "severity": "warning",
+ "line": 2,
+ "column": 1
+ }
+ ]
+}
+```
+
+**`warnings`** carries up to 10 findings across the whole validation plane — lint violations and broken links alike, honoring the project's `validation.links` setting. Positions are 1-based (`line`/`column`), ready to echo back to a human, and a `links` finding adds `linkTarget`. The field is present only when the write produced findings.
-- The MCP **`lint`** tool lints a single document or audits the project (optionally scoped to a folder or file), and with `fix: true` on a document auto-fixes fixable rules in place — attributed and live in the preview, the same result as the editor's **Fix** action. Audit output is capped at 10 files × 10 diagnostics per file with explicit "… and N more" indicators — the totals always reflect the full scan, and re-running with a narrower scope recovers the detail. See the [MCP reference](/docs/reference/mcp).
-- Every agent write response carries up to 10 validation findings for the document it touched — lint violations **and** broken internal links, following the same [Content rules](/docs/reference/configuration) posture as the audit — so an agent that writes a dead link hears about it immediately and can clean up after itself without being asked. Dead-link findings name the unresolved target, ready for a create-page fix. Advisory only — a finding never blocks the write.
+**`brokenLinks`** is the dedicated link channel, and unlike `warnings` it is **always present** — an empty array is the positive "every outbound link resolves" confirmation, which saves a separate `links({ kind: "dead" })` round-trip. Each entry names the href exactly as authored, so an agent can grep for it. `reason` is `no-such-doc` (resolved to a docName that doesn't exist), `no-such-file` (a linked asset or source file missing from disk), or `unresolvable` (an empty href, or a relative path escaping the content root); `resolvedTo` is `null` for `unresolvable`.
## See also
- [markdownlint](/docs/advanced/content-rules/markdownlint): the rules, your `.markdownlint.*` config, and the rule browser
+- [Frontmatter schemas](/docs/advanced/content-rules/frontmatter): JSON Schema validation of document frontmatter, and the schema editor
- [Editor](/docs/features/editor): source mode, WYSIWYG, and the document panel
- [Configuration reference](/docs/reference/configuration): `config.yml` and what's shared via git
- [MCP reference](/docs/reference/mcp): the tools agents use to read and write your project
diff --git a/docs/content/reference/configuration.mdx b/docs/content/reference/configuration.mdx
index ac31b587..5828438d 100644
--- a/docs/content/reference/configuration.mdx
+++ b/docs/content/reference/configuration.mdx
@@ -33,7 +33,9 @@ A key set in a file more specific than its scope (a user-scope key in `.ok/confi
| --- | --- | --- | --- | --- |
| `content.dir` | string | `"."` | project | Content directory, relative to project root. Defaults to `.` (the project root) even when `.ok/` is scaffolded at the git working-tree root from a sub-folder, so opened folder and content scope align by default. To narrow scope to a sub-folder, pass `--content-dir
` to `ok init`, or uncomment `content.dir` in `.ok/config.yml` post-init. Excluded paths live in `.okignore`; see [Ignore patterns](/docs/features/ignore-patterns). |
| `content.attachmentFolderPath` | string | `"./"` | project | Where pasted and dropped assets are stored. `"./"` colocates beside the current document; `"/"` targets the content root; `"./subdir"` targets a subfolder under the current folder; `"folder"` targets a fixed folder under the content root. |
-| `contentRules.plugins.markdownlint.enabled` | boolean | `false` | project | Enable the [markdownlint](/docs/advanced/content-rules/markdownlint) content-rules plugin for this project (shared via git). Off by default — turn it on in **Settings → This project → Plugins**. The rules themselves live in your native `.markdownlint.*` file, not here. |
+| `contentRules.markdownlint.enabled` | boolean | `false` | project | Enable the [markdownlint](/docs/advanced/content-rules/markdownlint) content-rules plugin for this project (shared via git). Off by default — turn it on in **Settings → This project → Plugins**. The rules themselves live in your native `.markdownlint.*` file, not here. |
+| `contentRules.frontmatter.enabled` | boolean | `false` | project | Enable the [Frontmatter schemas](/docs/advanced/content-rules/frontmatter) content-rules plugin for this project (shared via git). Off by default — turn it on in **Settings → This project → Plugins**. |
+| `contentRules.frontmatter.schemas` | array | `[]` | project | Frontmatter schema mappings: each entry scopes one JSON Schema file to a set of docs — `file` (project-root-relative path), optional `appliesTo` (glob or list; leading `!` excludes; absent means every doc), optional `enabled: false` to park a mapping. Schema content lives in the mapped files, not here. See [Frontmatter schemas](/docs/advanced/content-rules/frontmatter). |
| `appearance.theme` | `"light" \| "dark" \| "system"` | (unset) | user | Editor theme. |
| `appearance.sidebar.showHiddenFiles` | boolean | `false` | project-local | Show files whose path segments start with `.`. The sidebar otherwise lists every file on disk under the content directory; tooling internals (`.git/`, `.ok/`, `node_modules/`) stay hidden regardless. Toggled from the sidebar's right-click menu or the macOS **View → Show Hidden Files** item. |
| `autoSync.mode` | `"off" \| "follow" \| "full" \| null` | `null` | project-local | Per-machine sync mode for this project: `off` (no sync), `follow` (one-directional — pull remote changes, never push your own; the earlier value `pull` is accepted as an alias), `full` (bidirectional pull and push). `null` means "unanswered"; the editor's onboarding modal triggers on first remote-detected open. Supersedes `autoSync.enabled`. |
diff --git a/docs/content/reference/mcp.mdx b/docs/content/reference/mcp.mdx
index 65788d8e..b7b165ad 100644
--- a/docs/content/reference/mcp.mdx
+++ b/docs/content/reference/mcp.mdx
@@ -8,17 +8,17 @@ The MCP server gives AI agents structured access to your knowledge base. A few t
Every other read tool (`search`, `links`, `history`, etc.) and all write tools route through the Hocuspocus server, which OpenKnowledge auto-starts on the first call that needs it, under the same `OK_MCP_AUTOSTART` gate. All tools take a `cwd` (an absolute path inside the target project) that selects which project the call lands on; a globally registered server requires it unless the client advertises exactly one root.
-The surface is **19 tools**. The four write verbs — `write`, `edit`, `delete`, `move` — are native CRUD operations polymorphic over a target. `write`, `edit`, and `delete` nest per-target fields inside the address key (`write({ document: { path, content } })`) and take exactly one target per call: `document`, `folder`, `template`, or `skill` — plus `asset` for `write` and `delete`, and a `documents` batch on `write` that writes several docs in order, each entry carrying its own `summary`. `move` takes flat `from`/`to` paths and auto-detects a document, folder, or asset, with nested `template` and `skill` targets for those two.
+The surface is **21 tools**. The four write verbs — `write`, `edit`, `delete`, `move` — are native CRUD operations polymorphic over a target. `write`, `edit`, and `delete` nest per-target fields inside the address key (`write({ document: { path, content } })`) and take exactly one target per call: `document`, `folder`, `template`, or `skill` — plus `asset` for `write` and `delete`, and a `documents` batch on `write` that writes several docs in order, each entry carrying its own `summary`. `move` takes flat `from`/`to` paths and auto-detects a document, folder, or asset, with nested `template` and `skill` targets for those two.
## Tools
| Tool | Purpose |
| --- | --- |
-| `exec` | Read-only shell (`cat`, `ls`, `grep`, `find`, `head`, `tail`, `wc`, `sort`, `uniq`, `cut`); reads return frontmatter, backlinks, and recent history. Fs-direct; works without the server. |
+| `exec` | Read-only shell (`cat`, `ls`, `grep`, `find`, `head`, `tail`, `wc`, `sort`, `uniq`, `cut`); reads return frontmatter, backlinks, and recent history. When the [frontmatter schemas](/docs/advanced/content-rules/frontmatter) plugin is on, doc and folder entries also carry `schemas: …` naming the JSON Schema files that govern them — the contract to read before writing. Fs-direct; works without the server. |
| `search` | Ranked workspace search (title boost + body BM25 + recency; same engine the cmd-K palette uses). `intent: "omnibar"` narrows to fast title/path matching (`full_text`, the default, includes the body); `scopes` filters hit kinds; `limit` caps rows (default 20, max 100). When [semantic search](/docs/reference/configuration#semantic-search) is enabled, an embeddings signal is additionally fused into `full_text` ranking (opt-in, content egress) — pass `semantic: false` to force pure-lexical. Right after boot, an empty result set with `ready: false` means the index is still building — retry after a couple of seconds rather than treating it as no matches. |
| `links` | Wiki-link graph. `kind` selects: `backlinks`, `forward`, `dead`, `orphans`, `hubs`, or `suggest`. Accepts an array (e.g. `["dead", "orphans", "hubs"]`) to fetch several views in one call; each view nests under its own key in the response |
-| `lint` | Report markdownlint problems for one `document` or a whole-project audit (`path` scopes it); each finding carries `source`/`code`/`message`/`range`/`severity`, audit capped at 10 files × 10 diagnostics with accurate totals. Pass `fix: true` with `document` to auto-fix fixable rules in place — the fix lands through the collaborative document (attributed, live preview), not a shell edit; the rest need `edit`/`write` |
-| `audit` | Unified read-only validation audit: every content problem — markdownlint violations **and** broken internal links — in one call, grouped by the file to fix (`path` scopes to a folder or doc). Each finding is tagged with its validator (`markdownlint` or `links`); broken links report under the source doc that contains them, at the offending line, as warnings by default (the project's `validation.links` setting can raise them to errors or hide them). Same 10 × 10 output cap as `lint`'s audit, with accurate totals. No fix shape — lint fixes go through `lint`, link repairs through `edit`/`write` |
+| `lint` | Report content-rule problems (markdownlint style rules + frontmatter schema validation) for one `document` or a whole-project audit (`path` scopes it); each finding carries `source`/`code`/`message`/`range`/`severity`, audit capped at 10 files × 10 diagnostics with accurate totals; configuration problems (e.g. a broken schema file) ride a separate `warnings` channel. Pass `fix: true` with `document` to auto-fix fixable rules in place — the fix lands through the collaborative document (attributed, live preview), not a shell edit; the rest need `edit`/`write`. Example output: [content rules overview](/docs/advanced/content-rules/overview#ai-agents) |
+| `audit` | Unified read-only validation audit: every content problem — content-rule violations (markdownlint + frontmatter schemas) **and** broken internal links — in one call, grouped by the file to fix (`path` scopes to a folder or doc). Each finding is tagged with its validator; broken links report under the source doc that contains them, at the offending line, as warnings by default (the project's `validation.links` setting can raise them to errors or hide them). Same 10 × 10 output cap as `lint`'s audit, with accurate totals. No fix shape — lint fixes go through `lint`, link repairs through `edit`/`write` |
| `history` | Version timeline for a `document`, `folder`, or `skill`. Each entry carries a `version` (commit SHA) you pass to `restore_version`. Filter with `kind` (`checkpoint` / `wip` / `upstream`), `author` / `excludeAuthor`, and `branch`; paginate with `limit` / `offset` (default 50, max 200). `folder: ""` selects the project-root timeline |
| `skills` | Find and read agent skills. Omit `name` to list every skill across Project + Global (rows carry `installed` + `hosts`); pass `name` to read one (addressed by `name`+`scope`, never by path); pass `name` + `file` to read one bundle file's text — the read path for `references/` and `scripts/` content |
| `config` | Read the effective merged config (`config({ key: "appearance.theme" })` for a sub-tree; omit `key` for the whole config) |
diff --git a/packages/app/package.json b/packages/app/package.json
index ae8971bb..926ed56e 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -52,7 +52,7 @@
{
"name": "all JS chunks combined (gzipped)",
"path": "dist/assets/*.js",
- "limit": "3.55 MB",
+ "limit": "3.6 MB",
"gzip": true,
"running": false
},
diff --git a/packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx b/packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx
index a6d65723..5e43a2b6 100644
--- a/packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx
+++ b/packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx
@@ -104,6 +104,11 @@ vi.doMock('@/components/LintConfigEditor', () => ({
),
}));
+vi.doMock('@/components/SchemaConfigEditor', () => ({
+ SchemaConfigEditor: ({ assetPath }: { assetPath: string }) => (
+
+ ),
+}));
const { EditorArea } = await import('./EditorArea');
@@ -124,13 +129,15 @@ describe('EditorArea — markdownlint config dispatch', () => {
beforeEach(() => cleanup());
afterEach(() => cleanup());
- test('routes a root .markdownlint.json to the config editor, not the asset preview', () => {
+ // The config editors are behind `React.lazy`, so the FIRST assertion against
+ // each one has to await the dynamic import; later tests in this file find the
+ // module already resolved and can query synchronously.
+ test('routes a root .markdownlint.json to the config editor, not the asset preview', async () => {
docCtx = assetCtx('.markdownlint.json');
renderEditorArea();
- expect(screen.getByTestId('lint-config-editor').getAttribute('data-asset-path')).toBe(
- '.markdownlint.json',
- );
+ const editor = await screen.findByTestId('lint-config-editor');
+ expect(editor.getAttribute('data-asset-path')).toBe('.markdownlint.json');
expect(screen.queryByTestId('asset-preview')).toBeNull();
});
@@ -169,4 +176,36 @@ describe('EditorArea — markdownlint config dispatch', () => {
expect(screen.getByTestId('asset-preview')).toBeDefined();
expect(screen.queryByTestId('lint-config-editor')).toBeNull();
});
+
+ test('routes a tool-managed frontmatter schema to the schema editor', async () => {
+ docCtx = assetCtx('.ok/schemas/doc.schema.json');
+ renderEditorArea();
+
+ const editor = await screen.findByTestId('schema-config-editor');
+ expect(editor.getAttribute('data-asset-path')).toBe('.ok/schemas/doc.schema.json');
+ expect(screen.queryByTestId('asset-preview')).toBeNull();
+ expect(screen.queryByTestId('lint-config-editor')).toBeNull();
+ });
+
+ test('routes a *.schema.json anywhere in the project to the schema editor', () => {
+ docCtx = assetCtx('schemas/doc.schema.json');
+ renderEditorArea();
+ expect(screen.getByTestId('schema-config-editor').getAttribute('data-asset-path')).toBe(
+ 'schemas/doc.schema.json',
+ );
+ expect(screen.queryByTestId('asset-preview')).toBeNull();
+ });
+
+ test('leaves a non-JSON file under .ok/schemas and unconventional json on the preview', () => {
+ docCtx = assetCtx('.ok/schemas/notes.md');
+ renderEditorArea();
+ expect(screen.getByTestId('asset-preview')).toBeDefined();
+ expect(screen.queryByTestId('schema-config-editor')).toBeNull();
+ cleanup();
+
+ docCtx = assetCtx('schemas/user-owned.json');
+ renderEditorArea();
+ expect(screen.getByTestId('asset-preview')).toBeDefined();
+ expect(screen.queryByTestId('schema-config-editor')).toBeNull();
+ });
});
diff --git a/packages/app/src/components/EditorArea.tsx b/packages/app/src/components/EditorArea.tsx
index 0bedb75e..b9a70072 100644
--- a/packages/app/src/components/EditorArea.tsx
+++ b/packages/app/src/components/EditorArea.tsx
@@ -1,5 +1,6 @@
import {
detectEmbeddedHostFromBrowser,
+ isFrontmatterSchemaAsset,
isMarkdownlintJsonConfig,
} from '@inkeep/open-knowledge-core';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -26,7 +27,6 @@ import { EditorSkeleton } from '@/components/EditorSkeleton';
import { EmptyEditorState } from '@/components/EmptyEditorState';
import { FolderOverview } from '@/components/FolderOverview';
import { LargeFileEditorState } from '@/components/LargeFileEditorState';
-import { LintConfigEditor } from '@/components/LintConfigEditor';
import { MountStalledAffordance } from '@/components/MountStalledAffordance';
import { PropertyProvider, useProperties } from '@/components/PropertyContext';
import { ShareReceiveMissPanel } from '@/components/ShareReceiveMissPanel';
@@ -81,6 +81,31 @@ const LazyActivityModeContent = lazy(async () => {
return { default: mod.ActivityModeContent };
});
+// The two config-file editors render only when a `.markdownlint.*` or
+// `*.schema.json` file is opened — rare next to ordinary document editing — so
+// they stay out of the main chunk rather than loading for every session.
+const LazyLintConfigEditor = lazy(async () => {
+ const mod = await import('@/components/LintConfigEditor');
+ return { default: mod.LintConfigEditor };
+});
+
+const LazySchemaConfigEditor = lazy(async () => {
+ const mod = await import('@/components/SchemaConfigEditor');
+ return { default: mod.SchemaConfigEditor };
+});
+
+function ConfigEditorFallback() {
+ return (
+
+ Loading editor
+
+ );
+}
+
// The full-pane version diffs are overlays that only paint on user action (open
// a Timeline/agent diff), so they're code-split off the eager editor bundle —
// this keeps the rendered-diff engine + `prosemirror-recreate-transform` out of
@@ -909,7 +934,18 @@ function EditorAreaInner({
// DocumentBoundary-wrapped: the config is served over HTTP, not a pooled
// CRDT doc. Keyed by path so navigating between configs remounts + resets.
viewContent = (
-
+ }>
+
+
+ );
+ } else if (activeTarget?.kind === 'asset' && isFrontmatterSchemaAsset(activeTarget.assetPath)) {
+ // A tool-managed frontmatter schema opens in the dedicated schema editor
+ // (a Source/Fields toggle) — same REST-backed sibling shape as the
+ // markdownlint branch above, keyed by path for the same remount reset.
+ viewContent = (
+ }>
+
+
);
} else if (activeTarget?.kind === 'asset') {
// `key={assetPath}` forces a fresh `AssetPreview` instance on every asset
diff --git a/packages/app/src/components/FrontmatterRow.enum.dom.test.tsx b/packages/app/src/components/FrontmatterRow.enum.dom.test.tsx
new file mode 100644
index 00000000..28fb23ff
--- /dev/null
+++ b/packages/app/src/components/FrontmatterRow.enum.dom.test.tsx
@@ -0,0 +1,107 @@
+/**
+ * DOM tests for the schema-driven enum widgets in FrontmatterRow: an enum
+ * constraint renders a single-select, an array items.enum constraint renders
+ * toggleable value chips, and rows without a constraint keep the free-text
+ * widgets. Commits flow through the row's standard onCommit write path.
+ */
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, test } from 'vitest';
+
+const globalWithDomShims = globalThis as { ResizeObserver?: unknown };
+if (globalWithDomShims.ResizeObserver === undefined) {
+ class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ globalWithDomShims.ResizeObserver = NoopResizeObserver;
+}
+
+const { FrontmatterRow } = await import('./FrontmatterRow.tsx');
+
+beforeEach(() => {
+ cleanup();
+});
+
+describe('FrontmatterRow enum widgets', () => {
+ test('an enum constraint renders a single-select with the current value', () => {
+ render(
+ {}}
+ onChangeType={() => {}}
+ />,
+ );
+ const trigger = screen.getByTestId('property-enum-select');
+ expect(trigger).toBeTruthy();
+ expect(trigger.textContent).toContain('review');
+ });
+
+ test('an items.enum constraint renders toggle chips committing the full array', () => {
+ const commits: unknown[] = [];
+ render(
+ commits.push(next)}
+ onChangeType={() => {}}
+ />,
+ );
+ const optionB = screen.getByTestId('property-enum-option-b');
+ expect(optionB.getAttribute('aria-pressed')).toBe('false');
+ fireEvent.click(optionB);
+ expect(commits).toEqual([['a', 'b']]);
+ const optionA = screen.getByTestId('property-enum-option-a');
+ expect(optionA.getAttribute('aria-pressed')).toBe('true');
+ fireEvent.click(optionA);
+ expect(commits[1]).toEqual([]);
+ });
+
+ test('an out-of-vocabulary current value stays visible in both widgets', () => {
+ render(
+ {}}
+ onChangeType={() => {}}
+ />,
+ );
+ expect(screen.getByTestId('property-enum-select').textContent).toContain('shipped');
+ cleanup();
+ render(
+ {}}
+ onChangeType={() => {}}
+ />,
+ );
+ expect(screen.getByTestId('property-enum-option-zzz').getAttribute('aria-pressed')).toBe(
+ 'true',
+ );
+ });
+
+ test('no constraint keeps the free-text widget path unchanged', () => {
+ render(
+ {}}
+ onChangeType={() => {}}
+ />,
+ );
+ expect(screen.queryByTestId('property-enum-select')).toBeNull();
+ expect(screen.queryByTestId('property-enum-multi')).toBeNull();
+ });
+});
diff --git a/packages/app/src/components/FrontmatterRow.tsx b/packages/app/src/components/FrontmatterRow.tsx
index e6dbf683..4a4da540 100644
--- a/packages/app/src/components/FrontmatterRow.tsx
+++ b/packages/app/src/components/FrontmatterRow.tsx
@@ -47,6 +47,13 @@ import {
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
export interface AddDraft {
name: string;
@@ -78,6 +85,14 @@ interface FrontmatterRowProps {
value: FrontmatterValue;
/** Declared type — selects which widget renders. */
declared: FrontmatterType;
+ /**
+ * Schema-derived vocabulary for this field (resolved from the doc's
+ * governing frontmatter schemas). When present and the value is simple,
+ * the free-text widget is replaced by a single-select (`multi: false`) or
+ * a toggleable multi-select (`multi: true`). Commits flow through the same
+ * `onCommit` write path as every other widget.
+ */
+ enumConstraint?: { values: string[]; multi: boolean };
/** Inline validation error to render below the row. */
error?: string | null;
/**
@@ -132,6 +147,7 @@ export function FrontmatterRow({
keyName,
value,
declared,
+ enumConstraint,
error,
resetCounter = 0,
sortableId,
@@ -214,14 +230,32 @@ export function FrontmatterRow({
) : null}
: null}
{onRemove ? (
@@ -762,3 +796,100 @@ export function InheritedBadge({
);
}
+
+/**
+ * Schema-vocabulary single-select: replaces the free-text widget when the
+ * doc's governing schemas give this field an `enum`. A current value outside
+ * the vocabulary stays selectable (the linter flags it; the panel must not
+ * eat it), and commits ride the standard row write path.
+ */
+function EnumSelectWidget({
+ keyName,
+ value,
+ values,
+ onCommit,
+}: {
+ keyName: string;
+ value: FrontmatterValue;
+ values: string[];
+ onCommit: (next: FrontmatterValue) => void;
+}) {
+ const { t } = useLingui();
+ const current = typeof value === 'string' ? value : '';
+ const options = current !== '' && !values.includes(current) ? [current, ...values] : values;
+ return (
+
+ );
+}
+
+/**
+ * Schema-vocabulary multi-select for `array` fields with `items.enum`:
+ * toggleable value chips committing the full array. Values already in the doc
+ * but outside the vocabulary render as chips too, so toggling never silently
+ * drops them.
+ */
+function EnumMultiSelectWidget({
+ keyName,
+ value,
+ values,
+ onCommit,
+}: {
+ keyName: string;
+ value: FrontmatterValue;
+ values: string[];
+ onCommit: (next: FrontmatterValue) => void;
+}) {
+ const { t } = useLingui();
+ const selected = Array.isArray(value)
+ ? value.filter((entry): entry is string => typeof entry === 'string')
+ : [];
+ const extras = selected.filter((entry) => !values.includes(entry));
+ const toggle = (option: string) => {
+ const next = selected.includes(option)
+ ? selected.filter((entry) => entry !== option)
+ : [...selected, option];
+ onCommit(next);
+ };
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/ProblemsPanel.dom.test.tsx b/packages/app/src/components/ProblemsPanel.dom.test.tsx
index bcc78053..5f4a4774 100644
--- a/packages/app/src/components/ProblemsPanel.dom.test.tsx
+++ b/packages/app/src/components/ProblemsPanel.dom.test.tsx
@@ -11,6 +11,7 @@ import type { LintDiagnostic, ValidationAuditResponse } from '@inkeep/open-knowl
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { TooltipProvider } from '@/components/ui/tooltip';
import { renderLinguiTemplate } from '@/test-utils/lingui-mock';
// Both lingui macro specifiers alias to ONE shim module under the vitest dom
@@ -36,6 +37,7 @@ let runLintAuditImpl: () => Promise = async () =
let fixLintDocCalls: string[] = [];
let fixLintDocImpl: (docName: string) => Promise<{ ok: boolean; errorDetail?: string | null }> =
async () => ({ ok: true });
+let projectLintConfigData: unknown = null;
const toastError = vi.fn((_message: string) => {});
const toastSuccess = vi.fn((_message: string) => {});
@@ -48,7 +50,7 @@ vi.doMock('@/editor/lint-config-client', () => ({
return fixLintDocImpl(docName);
},
useDocLintConfig: () => ({ data: null }),
- useProjectLintConfig: () => ({ data: null }),
+ useProjectLintConfig: () => ({ data: projectLintConfigData }),
fetchEffectiveLintConfig: async () => null,
writeMarkdownlintRule: async () => ({ ok: false, errorDetail: null }),
}));
@@ -82,6 +84,21 @@ vi.doMock('@/lib/create-page', () => ({
},
}));
+/** Minimal lint-config payload with the plugin toggles the panel reads. */
+function lintConfigWith(plugins: { markdownlint: boolean; frontmatter: boolean }): unknown {
+ return {
+ effective: {
+ enabled: true,
+ plugins: {
+ markdownlint: { enabled: plugins.markdownlint, rules: {} },
+ frontmatter: { enabled: plugins.frontmatter, schemas: [] },
+ },
+ },
+ configFile: null,
+ configProblems: [],
+ };
+}
+
const { ProblemsPanel, LINT_NAV_EVENT } = await import('./ProblemsPanel');
// The real registry, deliberately unmocked: the tests assert the banked intent
// through its public consume API — the same call the source editor replays.
@@ -132,6 +149,7 @@ beforeEach(() => {
runLintAuditImpl = async () => null;
fixLintDocCalls = [];
fixLintDocImpl = async () => ({ ok: true });
+ projectLintConfigData = null;
createPageCalls = [];
createPageImpl = async (seed) => ({ docName: seed.suggestedName });
addPageCalls.length = 0;
@@ -151,6 +169,66 @@ describe('ProblemsPanel', () => {
expect(screen.getByText('No problems found.')).toBeTruthy();
});
+ test('a compact Checked-by line reveals the active plugins in a tooltip', async () => {
+ projectLintConfigData = lintConfigWith({ markdownlint: true, frontmatter: true });
+ render(
+
+
+ ,
+ );
+ const trigger = screen.getByTestId('problems-active-plugins');
+ // The pill itself carries only the count — names live in the tooltip.
+ expect(trigger.textContent).toContain('2 plugins');
+ expect(trigger.textContent).not.toContain('markdownlint');
+ fireEvent.focus(trigger);
+ const tooltip = await screen.findByTestId('problems-active-plugins-tooltip');
+ expect(tooltip.textContent).toContain('Checked by: markdownlint, Frontmatter schemas');
+ // Plugins are on, so the ordinary empty state (not the no-plugins one) shows.
+ expect(screen.getByText('No problems found.')).toBeTruthy();
+ expect(screen.queryByTestId('problems-no-plugins')).toBeNull();
+ });
+
+ test('only enabled plugins appear in the tooltip', async () => {
+ projectLintConfigData = lintConfigWith({ markdownlint: true, frontmatter: false });
+ render(
+
+
+ ,
+ );
+ const trigger = screen.getByTestId('problems-active-plugins');
+ expect(trigger.textContent).toContain('1 plugin');
+ fireEvent.focus(trigger);
+ const tooltip = await screen.findByTestId('problems-active-plugins-tooltip');
+ expect(tooltip.textContent).toContain('markdownlint');
+ expect(tooltip.textContent).not.toContain('Frontmatter schemas');
+ });
+
+ test('with zero plugins enabled, the empty state names the gap and links to Settings', () => {
+ projectLintConfigData = lintConfigWith({ markdownlint: false, frontmatter: false });
+ render();
+ expect(screen.queryByTestId('problems-active-plugins')).toBeNull();
+ expect(screen.getByTestId('problems-no-plugins')).toBeTruthy();
+ fireEvent.click(screen.getByTestId('problems-enable-plugins'));
+ expect(window.location.hash).toBe('#settings/plugins-manage');
+ });
+
+ test('link findings still render with zero plugins enabled (links validate regardless)', () => {
+ projectLintConfigData = lintConfigWith({ markdownlint: false, frontmatter: false });
+ render();
+ // The no-plugins hint only replaces the EMPTY list — a populated plane
+ // (broken links) must never be hidden behind it.
+ expect(screen.queryByTestId('problems-no-plugins')).toBeNull();
+ expect(screen.getByText(/does not resolve/)).toBeTruthy();
+ });
+
+ test('while the lint config has not loaded, the panel makes no plugin claim', () => {
+ projectLintConfigData = null;
+ render();
+ expect(screen.queryByTestId('problems-active-plugins')).toBeNull();
+ expect(screen.queryByTestId('problems-no-plugins')).toBeNull();
+ expect(screen.getByText('No problems found.')).toBeTruthy();
+ });
+
test('a fixable row renders a Fix button that calls onFix; unfixable does not', () => {
const fixable = diag({
line: 3,
diff --git a/packages/app/src/components/ProblemsPanel.tsx b/packages/app/src/components/ProblemsPanel.tsx
index d67b324c..f3e6c369 100644
--- a/packages/app/src/components/ProblemsPanel.tsx
+++ b/packages/app/src/components/ProblemsPanel.tsx
@@ -15,6 +15,7 @@ import { type ReactNode, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { useOptionalPageList } from '@/components/PageListContext';
import { type PanelScope, PanelScopeHeader } from '@/components/PanelScopeHeader';
+import { LINT_PLUGIN_META, type LintPluginMeta } from '@/components/settings/lint-plugin-meta';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
@@ -28,11 +29,13 @@ import {
PanelTitle,
} from '@/components/ui/panel';
import { Skeleton } from '@/components/ui/skeleton';
-import { fixLintDoc } from '@/editor/lint-config-client';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { fixLintDoc, useProjectLintConfig } from '@/editor/lint-config-client';
import { rememberPendingSourceNavigation } from '@/editor/source-editor-navigation';
import { runValidationAudit } from '@/editor/validation-audit-client';
import { createPageFromSeedAndUpdate } from '@/lib/create-page';
import { filePathToDocName, hashFromDocName } from '@/lib/doc-hash';
+import { openProjectPluginsSettings } from '@/lib/use-settings-route';
import { cn } from '@/lib/utils';
import { replaceValidationFromAudit } from '@/lib/validation-store';
@@ -237,6 +240,17 @@ export function ProblemsPanel({
}) {
const { t } = useLingui();
const [scope, setScope] = useState('doc');
+ // Which lint plugins actually check content, from the server-resolved
+ // effective config (the same truth the diagnostics come from). Null while
+ // the config hasn't loaded — the panel then makes no claim either way.
+ const { data: lintConfig } = useProjectLintConfig();
+ const activePlugins: LintPluginMeta[] | null =
+ lintConfig === null
+ ? null
+ : lintConfig.effective.enabled
+ ? LINT_PLUGIN_META.filter((plugin) => lintConfig.effective.plugins[plugin.id].enabled)
+ : [];
+ const noPluginsEnabled = activePlugins !== null && activePlugins.length === 0;
const [audit, setAudit] = useState({ status: 'idle' });
const [projectFixing, setProjectFixing] = useState<{ done: number; total: number } | null>(null);
// The dead-link "Create page" one-shot (target being created, else null).
@@ -384,18 +398,60 @@ export function ProblemsPanel({
return (
-
- Problems
-
+
+
+ Problems
+
+ {activePlugins !== null && activePlugins.length > 0 && (
+
+ {/* Bare trigger = a real (focusable) button, so keyboard focus
+ opens the tooltip too; dressed as a PanelCount pill to match
+ the Graph header's node/link counts. */}
+
+
+
+
+ Checked by: {activePlugins.map((plugin) => plugin.label).join(', ')}
+
+
+ )}
+
{scope === 'doc' && sorted.length > 0 && {sorted.length}}
{scope === 'doc' ? (
{sorted.length === 0 ? (
-
- No problems found.
-
+ noPluginsEnabled ? (
+ // Zero lint plugins narrows the plane to link validation alone —
+ // say so instead of an unqualified "no problems", and point at
+ // the switch. Only the empty list carries the hint: link
+ // findings still render, so the body is never blanked.
+ <>
+
+
+ No problems found — but no lint plugins are enabled, so only links are checked.
+
+
+
+
+
+ >
+ ) : (
+
+ No problems found.
+
+ )
) : (
<>
{onFixAll !== undefined && (
diff --git a/packages/app/src/components/PropertyPanel.tsx b/packages/app/src/components/PropertyPanel.tsx
index e18e10ac..8bee64c8 100644
--- a/packages/app/src/components/PropertyPanel.tsx
+++ b/packages/app/src/components/PropertyPanel.tsx
@@ -42,7 +42,9 @@ import { PropertyDisclosure } from '@/components/PropertyDisclosure';
import { coerceValue, DEFAULT_VALUE_FOR_TYPE } from '@/components/PropertyWidgets';
import { usePropertiesCollapsed } from '@/components/properties-collapsed-store';
import { Button } from '@/components/ui/button';
+import { useDocLintConfig } from '@/editor/lint-config-client';
import { usePublishFrontmatterSelection } from '@/hooks/use-selection-context';
+import { enumConstraintsForDoc } from '@/lib/frontmatter-enum-constraints';
interface PropertyPanelProps {
provider: HocuspocusProvider;
@@ -106,6 +108,13 @@ export function PropertyPanel({ provider, reservedKeys }: PropertyPanelProps) {
const [resetCounters, setResetCounters] = useState>({});
const docName = provider.configuration.name ?? '';
+ // Schema-driven select vocabularies: enum-constrained fields render as
+ // selects instead of free text (same schemas + same appliesTo matching as
+ // the linter, via the server-resolved effective config). Resolution failure
+ // or a disabled plugin degrades to today's free-text panel.
+ const { data: lintConfigData } = useDocLintConfig(docName === '' ? null : docName);
+ const enumConstraints = enumConstraintsForDoc(lintConfigData?.effective ?? null, docName);
+
// Publish a highlight inside the property panel into the selection-context
// store (keyed `(docName, 'frontmatter')`) so a property-value selection feeds
// the Ask AI composer exactly like a body-text selection — no per-row "use as
@@ -465,6 +474,7 @@ export function PropertyPanel({ provider, reservedKeys }: PropertyPanelProps) {
keyName={key}
value={value}
declared={declared}
+ enumConstraint={enumConstraints.get(key)}
error={errors[key] ?? null}
resetCounter={resetCounters[key] ?? 0}
isDuplicate={isDuplicate}
@@ -499,6 +509,7 @@ export function PropertyPanel({ provider, reservedKeys }: PropertyPanelProps) {
keyName="tags"
value={[]}
declared="list"
+ enumConstraint={enumConstraints.get('tags')}
error={errors.tags ?? null}
resetCounter={resetCounters.tags ?? 0}
isPlaceholder
diff --git a/packages/app/src/components/SchemaConfigEditor.dom.test.tsx b/packages/app/src/components/SchemaConfigEditor.dom.test.tsx
new file mode 100644
index 00000000..54796d4e
--- /dev/null
+++ b/packages/app/src/components/SchemaConfigEditor.dom.test.tsx
@@ -0,0 +1,205 @@
+/**
+ * DOM tests for the schema-config editor pane: the Source/Fields toggle, its
+ * mapped-file gating (Fields is offered only for schemas a live frontmatter
+ * mapping references), and the mount discipline that lets the Source view
+ * reflect a Fields edit.
+ *
+ * The system boundaries are mocked: the effective-config lookup
+ * (`useProjectLintConfig`), the read-only source viewer (`TextViewer`), and
+ * the per-field editor (`FrontmatterSchemaFieldEditor`). The toggle, its
+ * gating logic, and the active-segment mount decision are the real code
+ * under test.
+ */
+
+import { cleanup, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { TooltipProvider } from '@/components/ui/tooltip';
+
+type WindowGlobals = { NodeFilter?: typeof NodeFilter };
+type GlobalWithDomShims = typeof globalThis &
+ WindowGlobals & { window?: WindowGlobals; ResizeObserver?: unknown };
+const globalWithDomShims = globalThis as GlobalWithDomShims;
+if (
+ globalWithDomShims.NodeFilter === undefined &&
+ globalWithDomShims.window?.NodeFilter !== undefined
+) {
+ globalWithDomShims.NodeFilter = globalWithDomShims.window.NodeFilter;
+}
+if (globalWithDomShims.ResizeObserver === undefined) {
+ class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ globalWithDomShims.ResizeObserver = NoopResizeObserver;
+}
+
+const VIEW_MODE_KEY = 'ok-lint-config-view-mode-v1';
+const MAPPED_SCHEMA = '.ok/schemas/doc.schema.json';
+const UNMAPPED_SCHEMA = '.ok/schemas/orphan.schema.json';
+
+let mockMappedFiles: string[] = [];
+
+vi.doMock('@/editor/lint-config-client', () => ({
+ useProjectLintConfig: () => ({
+ data: {
+ effective: {
+ enabled: true,
+ plugins: {
+ markdownlint: { enabled: false, rules: {} },
+ frontmatter: {
+ enabled: true,
+ schemas: mockMappedFiles.map((file) => ({ file, key: file })),
+ },
+ },
+ },
+ configFile: null,
+ configProblems: [],
+ },
+ }),
+}));
+
+vi.doMock('@/components/TextViewer', () => ({
+ TextViewer: (props: { src?: string; fileName: string; extension: string }) => (
+
+ ),
+}));
+
+vi.doMock('@/components/settings/frontmatter-schema-field-editor', () => ({
+ FrontmatterSchemaFieldEditor: (props: { file: string }) => (
+
+ ),
+}));
+
+vi.doMock('@/components/NotInSidebarIndicator', () => ({
+ NotInSidebarIndicator: (props: { entry: unknown }) => (
+
+ ),
+}));
+
+const { SchemaConfigEditor } = await import('./SchemaConfigEditor');
+
+function renderEditor(assetPath: string) {
+ return render(
+
+
+ ,
+ );
+}
+
+function fieldsSegment(): HTMLButtonElement {
+ return screen.getByLabelText('Fields') as HTMLButtonElement;
+}
+function sourceSegment(): HTMLButtonElement {
+ return screen.getByLabelText('Source') as HTMLButtonElement;
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ mockMappedFiles = [MAPPED_SCHEMA];
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('SchemaConfigEditor — toggle and default view', () => {
+ test('renders a Source/Fields toggle and defaults to the Source view', () => {
+ renderEditor(MAPPED_SCHEMA);
+
+ expect(fieldsSegment()).toBeDefined();
+ expect(sourceSegment()).toBeDefined();
+ // The feature-beta tag marks the schema editor surface.
+ expect(screen.getByText('Beta')).toBeDefined();
+
+ const viewer = screen.getByTestId('mock-text-viewer');
+ expect(viewer.getAttribute('data-src')).toBe(
+ `/api/asset-text?path=${encodeURIComponent(MAPPED_SCHEMA)}`,
+ );
+ expect(viewer.getAttribute('data-extension')).toBe('json');
+ expect(screen.queryByTestId('mock-field-editor')).toBeNull();
+
+ expect(screen.getByTestId('mock-not-in-sidebar').getAttribute('data-entry')).toBe(
+ JSON.stringify({ kind: 'asset', path: MAPPED_SCHEMA }),
+ );
+ });
+});
+
+describe('SchemaConfigEditor — Settings-open Fields intent', () => {
+ test('a banked Fields intent opens the mapped schema on Fields, once, without persisting', async () => {
+ const { requestSchemaFieldsView } = await import('@/lib/schema-fields-view-intent');
+ requestSchemaFieldsView(MAPPED_SCHEMA);
+ const first = renderEditor(MAPPED_SCHEMA);
+ expect(screen.getByTestId('mock-field-editor')).toBeDefined();
+ // The intent overrides the initial view only — the persisted preference
+ // (default source) is untouched, so the next plain open is Source again.
+ expect(localStorage.getItem(VIEW_MODE_KEY)).toBeNull();
+ first.unmount();
+ renderEditor(MAPPED_SCHEMA);
+ expect(screen.queryByTestId('mock-field-editor')).toBeNull();
+ expect(screen.getByTestId('mock-text-viewer')).toBeDefined();
+ });
+
+ test('the intent is inert on an unmapped schema (Fields unavailable → Source)', async () => {
+ const { requestSchemaFieldsView } = await import('@/lib/schema-fields-view-intent');
+ requestSchemaFieldsView(UNMAPPED_SCHEMA);
+ renderEditor(UNMAPPED_SCHEMA);
+ expect(screen.queryByTestId('mock-field-editor')).toBeNull();
+ expect(screen.getByTestId('mock-text-viewer')).toBeDefined();
+ });
+});
+
+describe('SchemaConfigEditor — mapped-file gating', () => {
+ test('enables Fields for a mapped schema file', () => {
+ renderEditor(MAPPED_SCHEMA);
+ expect(fieldsSegment().disabled).toBe(false);
+ });
+
+ test('disables Fields for an unmapped schema, Source still works', () => {
+ renderEditor(UNMAPPED_SCHEMA);
+ expect(fieldsSegment().disabled).toBe(true);
+ expect(screen.getByTestId('mock-text-viewer')).toBeDefined();
+ });
+
+ test('the disabled Fields segment explains why via its description', () => {
+ renderEditor(UNMAPPED_SCHEMA);
+ const fields = fieldsSegment();
+ const describedById = fields.getAttribute('aria-describedby');
+ expect(describedById).not.toBeNull();
+ expect(document.getElementById(describedById as string)?.textContent).toBe(
+ 'Field editing is available for schema files mapped in the Frontmatter schemas plugin',
+ );
+ });
+});
+
+describe('SchemaConfigEditor — switching and persistence', () => {
+ test('switching to Fields mounts the field editor over the file and persists', async () => {
+ const user = userEvent.setup();
+ renderEditor(MAPPED_SCHEMA);
+
+ await user.click(fieldsSegment());
+ expect(screen.getByTestId('mock-field-editor').getAttribute('data-file')).toBe(MAPPED_SCHEMA);
+ expect(screen.queryByTestId('mock-text-viewer')).toBeNull();
+ expect(localStorage.getItem(VIEW_MODE_KEY)).toBe('rules');
+
+ await user.click(sourceSegment());
+ expect(screen.getByTestId('mock-text-viewer')).toBeDefined();
+ expect(screen.queryByTestId('mock-field-editor')).toBeNull();
+ expect(localStorage.getItem(VIEW_MODE_KEY)).toBe('source');
+ });
+
+ test('a persisted Fields preference falls back to Source on an unmapped schema', () => {
+ localStorage.setItem(VIEW_MODE_KEY, 'rules');
+ renderEditor(UNMAPPED_SCHEMA);
+
+ expect(fieldsSegment().disabled).toBe(true);
+ expect(screen.getByTestId('mock-text-viewer')).toBeDefined();
+ expect(screen.queryByTestId('mock-field-editor')).toBeNull();
+ });
+});
diff --git a/packages/app/src/components/SchemaConfigEditor.tsx b/packages/app/src/components/SchemaConfigEditor.tsx
new file mode 100644
index 00000000..77f71e29
--- /dev/null
+++ b/packages/app/src/components/SchemaConfigEditor.tsx
@@ -0,0 +1,114 @@
+/**
+ * Editor pane for an opened frontmatter schema file (`.ok/schemas/*.json`).
+ * Offers a segmented toggle between the raw read-only Source view
+ * (`TextViewer`) and the WYSIWYG Fields view (`FrontmatterSchemaFieldEditor`,
+ * the same per-field editor the Settings frontmatter plugin panel uses), so a
+ * schema owner can edit fields without hand-writing JSON while still able to
+ * inspect keywords the friendly rows don't model.
+ *
+ * The field editor reads the RESOLVED schema from the effective lint config,
+ * which only inlines files referenced by a `contentRules.frontmatter.schemas`
+ * mapping — so Fields is offered only for mapped files. An unmapped schema
+ * would render an empty editor over a file that has content; the disabled
+ * segment explains instead, and Source stays usable.
+ *
+ * Only the active segment is mounted: returning to Source remounts
+ * `TextViewer`, which refetches the file so a field written through the
+ * Fields view is reflected.
+ */
+
+import { useLingui } from '@lingui/react/macro';
+import { useState } from 'react';
+import { EditorModeToggle } from '@/components/EditorModeToggle';
+import { NotInSidebarIndicator } from '@/components/NotInSidebarIndicator';
+import { FrontmatterSchemaFieldEditor } from '@/components/settings/frontmatter-schema-field-editor';
+import { PluginBetaBadge } from '@/components/settings/PluginBetaBadge';
+import { TextViewer } from '@/components/TextViewer';
+import { useProjectLintConfig } from '@/editor/lint-config-client';
+import { type LintConfigViewMode, useLintConfigViewMode } from '@/editor/useLintConfigViewMode';
+import { consumeSchemaFieldsView } from '@/lib/schema-fields-view-intent';
+
+interface SchemaConfigEditorProps {
+ /** Root-relative path of the opened schema asset (no leading slash). */
+ assetPath: string;
+}
+
+// Same ungated text sibling of `/api/asset` the markdownlint config editor
+// uses — serves any path-safe file as UTF-8 text so the dot-directory schema
+// renders. Path-safety is enforced server-side.
+function assetTextUrl(assetPath: string): string {
+ return `/api/asset-text?path=${encodeURIComponent(assetPath)}`;
+}
+
+export function SchemaConfigEditor({ assetPath }: SchemaConfigEditorProps) {
+ const { t } = useLingui();
+ const [persistedMode, setPersistedMode] = useLintConfigViewMode();
+ // A Settings-panel open carries a one-shot Fields intent that outranks the
+ // persisted preference for THIS mount only; the user's own toggle (which
+ // both persists and overrides) wins from then on.
+ const [overrideMode, setOverrideMode] = useState(() =>
+ consumeSchemaFieldsView(assetPath) ? 'rules' : null,
+ );
+ const viewMode = overrideMode ?? persistedMode;
+ const setViewMode = (next: LintConfigViewMode) => {
+ setOverrideMode(next);
+ setPersistedMode(next);
+ };
+ const { data } = useProjectLintConfig();
+
+ // Server and client paths are both root-relative with no leading slash, so
+ // string equality against the resolved mapping entries identifies a mapped
+ // schema file.
+ const fieldsEnabled = (data?.effective.plugins.frontmatter.schemas ?? []).some(
+ (s) => s.file === assetPath,
+ );
+
+ // Fields lives in the wysiwyg slot; force Source when it's unavailable so a
+ // persisted 'rules' preference on an unmapped file never renders blank.
+ const isSourceMode = !fieldsEnabled || viewMode === 'source';
+
+ const fileName = assetPath.split('/').pop() ?? assetPath;
+ const extension = fileName.includes('.') ? (fileName.split('.').pop() ?? '').toLowerCase() : '';
+
+ return (
+
+
+
+ setViewMode(next === 'source' ? 'source' : 'rules')}
+ wysiwygDisabled={!fieldsEnabled}
+ wysiwygLabel={t`Fields`}
+ sourceLabel={t`Source`}
+ wysiwygDisabledReason={t`Field editing is available for schema files mapped in the Frontmatter schemas plugin`}
+ />
+ {/* Absolute so the mode toggle stays visually centered. */}
+
+
+ {isSourceMode ? (
+ // Source is a CodeMirror viewer that owns its own scroll — full-bleed,
+ // no wrapper padding (a second scroll container would double-scroll).
+
+
+
+ ) : (
+ // The field editor has no scroll or padding of its own — in Settings it
+ // inherits both from the dialog body. Standalone it needs its own
+ // scroll container and the same content gutter as other panes.
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/app/src/components/SystemDocSubscriber.tsx b/packages/app/src/components/SystemDocSubscriber.tsx
index 2b01d02e..04ca6ccb 100644
--- a/packages/app/src/components/SystemDocSubscriber.tsx
+++ b/packages/app/src/components/SystemDocSubscriber.tsx
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import * as Y from 'yjs';
import { useDocumentContext } from '@/editor/DocumentContext';
+import { emitLintConfigChanged } from '@/editor/lint-config-client';
import { dispatchCC1Stateless, SYSTEM_DOC_NAME } from '@/lib/cc1';
import { emitConfigIgnoreNestedError } from '@/lib/config-ignore-nested-error-events';
import { emitConfigValidationRejected } from '@/lib/config-validation-events';
@@ -106,6 +107,13 @@ export function SystemDocSubscriber() {
void queryClient.invalidateQueries({ queryKey: ['orphans'] });
void queryClient.invalidateQueries({ queryKey: ['hubs'] });
}
+ // Server-side signal that the DISK-derived effective lint config
+ // changed (project config.yml persisted, or a schema file was
+ // written/deleted by any client). Re-fetch + re-lint through the same
+ // window event local writes use.
+ if (channels.includes('lint-config')) {
+ emitLintConfigChanged();
+ }
});
// Track first-sync vs subsequent-sync via the shared
diff --git a/packages/app/src/components/settings/FrontmatterPluginSection.dom.test.tsx b/packages/app/src/components/settings/FrontmatterPluginSection.dom.test.tsx
new file mode 100644
index 00000000..80dc5b48
--- /dev/null
+++ b/packages/app/src/components/settings/FrontmatterPluginSection.dom.test.tsx
@@ -0,0 +1,311 @@
+/**
+ * DOM tests for the frontmatter plugin settings panel — the schema BROWSER:
+ * every discovered schema file renders as a toggleable row, toggling writes
+ * `enabled` mappings to `contentRules`, reset wipes a mapping, search filters
+ * the list, and the Edit button navigates to the file via hash nav.
+ */
+
+import type { Config, ConfigBinding, ConfigPatch } from '@inkeep/open-knowledge-core';
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+const globalWithDomShims = globalThis as { ResizeObserver?: unknown };
+if (globalWithDomShims.ResizeObserver === undefined) {
+ class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ globalWithDomShims.ResizeObserver = NoopResizeObserver;
+}
+
+let mockProjectConfig: Partial | null = null;
+const patches: ConfigPatch[] = [];
+const mockBinding = {
+ patch: (patch: ConfigPatch) => {
+ patches.push(patch);
+ return { ok: true, value: { config: mockProjectConfig, appliedPaths: [] } };
+ },
+} as unknown as ConfigBinding;
+
+vi.doMock('@/lib/config-provider', () => ({
+ useConfigContext: () => ({
+ userBinding: null,
+ userSynced: false,
+ projectBinding: mockBinding,
+ projectLocalBinding: null,
+ okignoreBinding: null,
+ okignoreSynced: false,
+ userConfig: null,
+ projectConfig: mockProjectConfig,
+ projectSynced: true,
+ projectLocalConfig: null,
+ projectLocalSynced: false,
+ merged: null,
+ }),
+}));
+
+let mockLintData: unknown = null;
+let mockDiscovered: string[] = [];
+let lintConfigChangedCount = 0;
+const created: string[] = [];
+const deleted: string[] = [];
+vi.doMock('@/editor/lint-config-client', () => ({
+ emitLintConfigChanged: () => {
+ lintConfigChangedCount += 1;
+ },
+ subscribeToLintConfigChanged: () => () => {},
+ runLintAudit: async () => null,
+ useDocLintConfig: () => ({ data: null }),
+ useProjectLintConfig: () => ({ data: mockLintData }),
+ fetchEffectiveLintConfig: async () => null,
+ writeMarkdownlintRule: async () => ({ ok: false, errorDetail: null }),
+ writeFrontmatterSchemaField: async () => ({ ok: false, errorDetail: null }),
+ useFrontmatterSchemaFiles: () => ({ schemas: mockDiscovered, refresh: () => {} }),
+ createEmptyFrontmatterSchema: async (file: string) => {
+ created.push(file);
+ return { ok: true };
+ },
+ removeFrontmatterSchemaField: async () => ({ ok: true }),
+ renameFrontmatterSchemaField: async () => ({ ok: true }),
+ deleteFrontmatterSchema: async (file: string) => {
+ deleted.push(file);
+ return { ok: true };
+ },
+}));
+
+const { FrontmatterPluginSection } = await import('./LintingSection.tsx');
+// Real module on purpose: the tests assert the banked Fields-view intent the
+// schema editor consumes on mount.
+const { consumeSchemaFieldsView } = await import('@/lib/schema-fields-view-intent');
+
+function configWithMappings(
+ schemas: { appliesTo?: string | string[]; file: string; enabled?: boolean }[],
+): Partial {
+ return {
+ contentRules: {
+ markdownlint: { enabled: false },
+ frontmatter: { enabled: true, schemas },
+ },
+ } as Partial;
+}
+
+function lastSchemas(): { file: string; enabled?: boolean; appliesTo?: unknown }[] {
+ const contentRules = patches[patches.length - 1]?.contentRules as {
+ frontmatter?: { schemas?: { file: string; enabled?: boolean; appliesTo?: unknown }[] };
+ };
+ return contentRules.frontmatter?.schemas ?? [];
+}
+
+beforeEach(() => {
+ cleanup();
+ patches.length = 0;
+ created.length = 0;
+ deleted.length = 0;
+ lintConfigChangedCount = 0;
+ mockLintData = null;
+ mockDiscovered = ['.ok/schemas/doc.schema.json', 'schemas/local.schema.json'];
+ mockProjectConfig = configWithMappings([
+ { appliesTo: ['docs/**'], file: '.ok/schemas/doc.schema.json', enabled: true },
+ ]);
+ window.location.hash = '';
+});
+
+describe('FrontmatterPluginSection — schema browser', () => {
+ test('renders one toggleable row per discovered file; mapped rows are Modified', () => {
+ render();
+ const mapped = screen.getByTestId('frontmatter-schema-row-.ok/schemas/doc.schema.json');
+ expect(mapped).toBeTruthy();
+ expect(screen.getByTestId('frontmatter-schema-row-schemas/local.schema.json')).toBeTruthy();
+ expect(
+ screen.getByTestId('frontmatter-schema-modified-.ok/schemas/doc.schema.json'),
+ ).toBeTruthy();
+ expect(
+ screen.queryByTestId('frontmatter-schema-modified-schemas/local.schema.json'),
+ ).toBeNull();
+
+ const onToggle = screen.getByTestId(
+ 'frontmatter-schema-toggle-.ok/schemas/doc.schema.json',
+ ) as HTMLButtonElement;
+ expect(onToggle.getAttribute('aria-checked')).toBe('true');
+ // The enabled row shows its appliesTo pills.
+ expect(within(mapped).getByText('docs/**')).toBeTruthy();
+ });
+
+ test('toggling an unmapped file on appends an enabled mapping', () => {
+ render();
+ fireEvent.click(screen.getByTestId('frontmatter-schema-toggle-schemas/local.schema.json'));
+ expect(lastSchemas()).toEqual([
+ { appliesTo: ['docs/**'], file: '.ok/schemas/doc.schema.json', enabled: true },
+ { file: 'schemas/local.schema.json', enabled: true },
+ ]);
+ expect(lintConfigChangedCount).toBe(1);
+ });
+
+ test('toggling a mapped file off keeps the mapping with enabled: false', () => {
+ render();
+ fireEvent.click(screen.getByTestId('frontmatter-schema-toggle-.ok/schemas/doc.schema.json'));
+ expect(lastSchemas()).toEqual([
+ { appliesTo: ['docs/**'], file: '.ok/schemas/doc.schema.json', enabled: false },
+ ]);
+ });
+
+ test('a config-mapped file missing from discovery still renders (reset stays reachable)', () => {
+ mockProjectConfig = configWithMappings([{ file: 'gone/away.schema.json', enabled: true }]);
+ render();
+ expect(screen.getByTestId('frontmatter-schema-row-gone/away.schema.json')).toBeTruthy();
+ });
+
+ test('reset wipes the mapping from config entirely', () => {
+ render();
+ fireEvent.click(screen.getByTestId('frontmatter-schema-reset-.ok/schemas/doc.schema.json'));
+ expect(lastSchemas()).toEqual([]);
+ });
+
+ test('editing appliesTo writes the globs on the file mapping', () => {
+ render();
+ const input = document.getElementById(
+ 'frontmatter-schema-applies-.ok/schemas/doc.schema.json',
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'notes/**' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(lastSchemas()[0]?.appliesTo).toEqual(['docs/**', 'notes/**']);
+ });
+
+ test('search filters the rows', () => {
+ render();
+ fireEvent.change(screen.getByTestId('frontmatter-schema-search'), {
+ target: { value: 'local' },
+ });
+ expect(screen.queryByTestId('frontmatter-schema-row-.ok/schemas/doc.schema.json')).toBeNull();
+ expect(screen.getByTestId('frontmatter-schema-row-schemas/local.schema.json')).toBeTruthy();
+
+ fireEvent.change(screen.getByTestId('frontmatter-schema-search'), {
+ target: { value: 'zzz' },
+ });
+ expect(screen.getByTestId('frontmatter-schemas-empty').textContent).toContain(
+ 'No schemas match',
+ );
+ });
+
+ test('the Edit button opens the file via hash and banks the Fields-view intent', () => {
+ render();
+ fireEvent.click(screen.getByTestId('frontmatter-schema-edit-.ok/schemas/doc.schema.json'));
+ expect(window.location.hash).toBe('#/__asset__/.ok/schemas/doc.schema.json');
+ expect(consumeSchemaFieldsView('.ok/schemas/doc.schema.json')).toBe(true);
+ });
+
+ test('the schema name is plain text — Edit is the only way into the file', () => {
+ render();
+ expect(screen.queryByTestId('frontmatter-schema-open-schemas/local.schema.json')).toBeNull();
+ fireEvent.click(screen.getByTestId('frontmatter-schema-edit-schemas/local.schema.json'));
+ expect(window.location.hash).toBe('#/__asset__/schemas/local.schema.json');
+ expect(consumeSchemaFieldsView('schemas/local.schema.json')).toBe(true);
+ });
+
+ test('the section header carries the feature-beta tag', () => {
+ render();
+ const header = document.getElementById('settings-plugin-frontmatter-title')?.parentElement;
+ expect(header?.textContent).toContain('Beta');
+ });
+
+ test('the appliesTo input names the pattern grammar with an example glob', () => {
+ mockProjectConfig = configWithMappings([
+ { file: '.ok/schemas/doc.schema.json', enabled: true },
+ ]);
+ render();
+ const input = document.getElementById('frontmatter-schema-applies-.ok/schemas/doc.schema.json');
+ expect(input?.getAttribute('placeholder')).toContain('guides/**/*');
+ expect(input?.getAttribute('placeholder')).toContain('pattern');
+ });
+
+ test('create-schema creates the file in .ok/schemas and maps it enabled', async () => {
+ render();
+ fireEvent.click(screen.getByTestId('frontmatter-create-schema'));
+ fireEvent.change(screen.getByTestId('frontmatter-create-schema-name'), {
+ target: { value: 'release' },
+ });
+ fireEvent.click(screen.getByTestId('frontmatter-create-schema-save'));
+ await Promise.resolve();
+ expect(created).toEqual(['.ok/schemas/release.schema.json']);
+ expect(lastSchemas()).toEqual([
+ { appliesTo: ['docs/**'], file: '.ok/schemas/doc.schema.json', enabled: true },
+ { file: '.ok/schemas/release.schema.json', enabled: true },
+ ]);
+ });
+
+ test('frontmatter config problems render for mapped files; others filtered', () => {
+ mockLintData = {
+ effective: null,
+ configProblems: [
+ 'frontmatter schema .ok/schemas/doc.schema.json: cannot read (ENOENT)',
+ 'frontmatter schema .ok/schemas/unmapped.json: cannot read (ENOENT)',
+ 'unmatched appliesTo glob "specs/**" — matches no docs in this project (frontmatter mapping for .ok/schemas/doc.schema.json)',
+ '[.markdownlint.json] malformed markdownlint config',
+ ],
+ };
+ render();
+ const box = screen.getByTestId('frontmatter-config-problems');
+ expect(box.textContent).toContain('doc.schema.json');
+ expect(box.textContent).toContain('matches no docs');
+ expect(box.textContent).not.toContain('unmapped.json');
+ expect(box.textContent).not.toContain('markdownlint config');
+ });
+
+ test('the trash affordance confirms, deletes the file, and wipes its mapping', async () => {
+ render();
+ fireEvent.click(screen.getByTestId('frontmatter-schema-delete-.ok/schemas/doc.schema.json'));
+ // Nothing happens until the dialog confirms.
+ expect(deleted).toEqual([]);
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(deleted).toEqual(['.ok/schemas/doc.schema.json']);
+ expect(lastSchemas()).toEqual([]);
+ });
+
+ test('the pills carry a plain-language reading of the globs', () => {
+ mockProjectConfig = configWithMappings([
+ {
+ appliesTo: ['blogs/**/*', '!**/index'],
+ file: '.ok/schemas/doc.schema.json',
+ enabled: true,
+ },
+ ]);
+ render();
+ const summary = screen.getByTestId(
+ 'frontmatter-schema-applies-summary-.ok/schemas/doc.schema.json',
+ );
+ expect(summary.textContent).toContain('Applies to');
+ expect(summary.textContent).toContain('everything under blogs/');
+ expect(summary.textContent).toContain('except');
+ expect(summary.textContent).toContain('any doc named index');
+ });
+
+ test('empty globs read as every doc', () => {
+ mockProjectConfig = configWithMappings([
+ { file: '.ok/schemas/doc.schema.json', enabled: true },
+ ]);
+ render();
+ expect(
+ screen.getByTestId('frontmatter-schema-applies-summary-.ok/schemas/doc.schema.json')
+ .textContent,
+ ).toContain('every doc');
+ });
+
+ test('the only-modified filter narrows to mapped schemas', () => {
+ render();
+ fireEvent.click(screen.getByTestId('frontmatter-only-modified'));
+ expect(screen.getByTestId('frontmatter-schema-row-.ok/schemas/doc.schema.json')).toBeTruthy();
+ expect(screen.queryByTestId('frontmatter-schema-row-schemas/local.schema.json')).toBeNull();
+ });
+
+ test('empty state renders when no schemas exist anywhere', () => {
+ mockDiscovered = [];
+ mockProjectConfig = configWithMappings([]);
+ render();
+ expect(screen.getByTestId('frontmatter-schemas-empty').textContent).toContain(
+ 'No schema files in this project yet',
+ );
+ });
+});
diff --git a/packages/app/src/components/settings/LintingSection.dom.test.tsx b/packages/app/src/components/settings/LintingSection.dom.test.tsx
index 709d1a0d..cf47a91f 100644
--- a/packages/app/src/components/settings/LintingSection.dom.test.tsx
+++ b/packages/app/src/components/settings/LintingSection.dom.test.tsx
@@ -6,7 +6,7 @@
*/
import type { Config, ConfigBinding } from '@inkeep/open-knowledge-core';
-import { cleanup, render, screen } from '@testing-library/react';
+import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
@@ -141,6 +141,11 @@ describe('ProjectPluginsManageSection', () => {
expect(screen.getByTestId('settings-plugins-audit-pointer').textContent).toContain(
'Run a project audit from the Problems panel',
);
+ // The frontmatter plugin is feature-beta; markdownlint is not.
+ const list = screen.getByTestId('settings-plugins-list');
+ const rows = within(list).getAllByText('Beta');
+ expect(rows).toHaveLength(1);
+ expect(rows[0]?.closest('label')?.textContent).toContain('Frontmatter schemas');
});
test('toggling a project plugin writes the per-plugin enabled patch', async () => {
diff --git a/packages/app/src/components/settings/LintingSection.tsx b/packages/app/src/components/settings/LintingSection.tsx
index 040ddf39..5d44e875 100644
--- a/packages/app/src/components/settings/LintingSection.tsx
+++ b/packages/app/src/components/settings/LintingSection.tsx
@@ -9,21 +9,43 @@
* (the full-catalog rule browser — see `markdownlint-rule-browser.tsx`).
*/
import {
+ type AppliesToPatternSummary,
type ConfigBinding,
type ConfigPatch,
+ type FrontmatterSchemaMapping,
humanFormat,
+ isFrontmatterSchemaAsset,
type LintPluginId,
+ summarizeAppliesTo,
} from '@inkeep/open-knowledge-core';
import { Trans, useLingui } from '@lingui/react/macro';
-import { ArrowUpRight } from 'lucide-react';
-import type { ReactNode } from 'react';
+import { ArrowUpRight, Plus, RotateCcw, SquarePen, Trash2 } from 'lucide-react';
+import { type ReactNode, useState } from 'react';
import { toast } from 'sonner';
+import { DeleteConfirmationDialog } from '@/components/DeleteConfirmationDialog';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Dialog } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
+import { TagPillInput } from '@/components/ui/tag-pill-input';
+import {
+ createEmptyFrontmatterSchema,
+ deleteFrontmatterSchema,
+ emitLintConfigChanged,
+ useFrontmatterSchemaFiles,
+ useProjectLintConfig,
+} from '@/editor/lint-config-client';
import { useConfigContext } from '@/lib/config-provider';
+import { hashFromAssetPath } from '@/lib/doc-hash';
import { dispatchExternalLinkClick } from '@/lib/external-link';
+import { requestSchemaFieldsView } from '@/lib/schema-fields-view-intent';
import { LINT_PLUGIN_META } from './lint-plugin-meta';
import { MarkdownlintRuleBrowser } from './markdownlint-rule-browser';
+import { PluginBetaBadge } from './PluginBetaBadge';
import { ScopeBadge } from './ScopeBadge';
/** Project-scope content-rules config + a `contentRules`-patch writer. Shared by the sections. */
@@ -62,6 +84,12 @@ function PluginManageDescription({ id }: { id: LintPluginId }) {
Common markdown issues — hard tabs, heading increments, list markers, and more.
);
+ case 'frontmatter':
+ return (
+
+ Validate document frontmatter against JSON Schema files, scoped to doc sets by glob.
+
+ );
}
}
@@ -102,9 +130,10 @@ export function ProjectPluginsManageSection() {
@@ -196,12 +225,15 @@ function PluginSectionHeader({
titleId,
title,
scope,
+ beta,
children,
}: {
titleId: string;
title: string;
/** When set, renders a User/Project scope badge beside the title. */
scope?: 'user' | 'project';
+ /** When set, renders the feature-maturity Beta tag beside the title. */
+ beta?: boolean;
children: ReactNode;
}) {
return (
@@ -210,6 +242,7 @@ function PluginSectionHeader({
{title}
+ {beta ? : null}
{scope ? : null}
{children}
@@ -259,3 +292,456 @@ export function MarkdownlintPluginSection({
);
}
+
+/** Normalize a mapping's authored appliesTo (string | string[] | absent) for the pill editor. */
+function appliesToList(appliesTo: FrontmatterSchemaMapping['appliesTo']): string[] {
+ if (appliesTo === undefined) return [];
+ return Array.isArray(appliesTo) ? appliesTo : [appliesTo];
+}
+
+/**
+ * One classified pattern as a human phrase. The fallback names the raw glob
+ * so the summary never claims more than the matcher does.
+ */
+function AppliesToPhrase({ summary }: { summary: AppliesToPatternSummary }) {
+ switch (summary.kind) {
+ case 'everything':
+ return every doc;
+ case 'folder-recursive':
+ return everything under {summary.folder}/;
+ case 'folder-direct':
+ return docs directly in {summary.folder}/;
+ case 'exact':
+ return the doc {summary.target};
+ case 'name-anywhere':
+ return any doc named {summary.name};
+ case 'folder-anywhere':
+ return everything under any {summary.folder}/ folder;
+ case 'folder-recursive-nested':
+ return (
+
+ everything under {summary.folder}/ folders inside {summary.root}/
+
+ );
+ case 'matches-nothing':
+ return nothing ({summary.pattern} cannot match a doc);
+ case 'invalid':
+ return nothing ({summary.pattern} is not a valid pattern);
+ case 'pattern':
+ return docs matching {summary.pattern};
+ }
+}
+
+/** The live plain-language reading of a mapping's globs, under the pills. */
+function AppliesToSummaryLine({
+ file,
+ appliesTo,
+}: {
+ file: string;
+ appliesTo: FrontmatterSchemaMapping['appliesTo'];
+}) {
+ const { includes, excludes } = summarizeAppliesTo(appliesTo);
+ const list = (entries: AppliesToPatternSummary[]) =>
+ entries.map((entry, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: display-only phrase list, order-stable.
+
+ {index > 0 ? ', ' : null}
+
+
+ ));
+ return (
+
+ )}
+
+
+ );
+}
diff --git a/packages/app/src/components/settings/PluginBetaBadge.tsx b/packages/app/src/components/settings/PluginBetaBadge.tsx
new file mode 100644
index 00000000..ea228256
--- /dev/null
+++ b/packages/app/src/components/settings/PluginBetaBadge.tsx
@@ -0,0 +1,18 @@
+/**
+ * Feature-maturity "Beta" tag for the frontmatter schemas plugin — always
+ * visible, about the feature, not the build (mirrors the ACP AgentBetaBadge).
+ * Rendered on every surface the feature owns: the Plugins manage row, the
+ * plugin's settings panel header, and the schema file editor.
+ */
+
+import { Trans } from '@lingui/react/macro';
+import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+
+export function PluginBetaBadge({ className }: { readonly className?: string }) {
+ return (
+
+ Beta
+
+ );
+}
diff --git a/packages/app/src/components/settings/frontmatter-schema-field-editor.dom.test.tsx b/packages/app/src/components/settings/frontmatter-schema-field-editor.dom.test.tsx
new file mode 100644
index 00000000..b0a87975
--- /dev/null
+++ b/packages/app/src/components/settings/frontmatter-schema-field-editor.dom.test.tsx
@@ -0,0 +1,286 @@
+/**
+ * DOM tests for the per-field schema editor: rows render from the resolved
+ * schema in the effective lint config, edits persist one field-constraint at
+ * a time through the write client, and fields carrying unmodeled keywords are
+ * flagged as preserved.
+ */
+
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+const globalWithDomShims = globalThis as { ResizeObserver?: unknown };
+if (globalWithDomShims.ResizeObserver === undefined) {
+ class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ globalWithDomShims.ResizeObserver = NoopResizeObserver;
+}
+
+let mockLintData: unknown = null;
+const writes: [string, string, unknown, unknown[]][] = [];
+const removes: [string, string, unknown[]][] = [];
+const renames: [string, string, string, unknown[]][] = [];
+vi.doMock('@/editor/lint-config-client', () => ({
+ emitLintConfigChanged: () => {},
+ subscribeToLintConfigChanged: () => () => {},
+ runLintAudit: async () => null,
+ useDocLintConfig: () => ({ data: null }),
+ useProjectLintConfig: () => ({ data: mockLintData }),
+ fetchEffectiveLintConfig: async () => null,
+ writeMarkdownlintRule: async () => ({ ok: false, errorDetail: null }),
+ writeFrontmatterSchemaField: async (
+ file: string,
+ field: string,
+ constraint: unknown,
+ parentPath: unknown[] = [],
+ ) => {
+ writes.push([file, field, constraint, parentPath]);
+ return { ok: true, response: null };
+ },
+ removeFrontmatterSchemaField: async (file: string, field: string, parentPath: unknown[] = []) => {
+ removes.push([file, field, parentPath]);
+ return { ok: true };
+ },
+ renameFrontmatterSchemaField: async (
+ file: string,
+ field: string,
+ to: string,
+ parentPath: unknown[] = [],
+ ) => {
+ renames.push([file, field, to, parentPath]);
+ return { ok: true };
+ },
+}));
+
+const { FrontmatterSchemaFieldEditor } = await import('./frontmatter-schema-field-editor.tsx');
+
+const FILE = '.ok/schemas/doc.schema.json';
+
+function lintDataWithSchema(schema: Record | undefined) {
+ return {
+ effective: {
+ enabled: true,
+ plugins: {
+ markdownlint: { enabled: false, rules: {} },
+ frontmatter: {
+ enabled: true,
+ schemas: [{ appliesTo: 'docs/**', file: FILE, key: 'K', schema }],
+ },
+ },
+ },
+ configProblems: [],
+ };
+}
+
+beforeEach(() => {
+ cleanup();
+ writes.length = 0;
+ removes.length = 0;
+ renames.length = 0;
+ mockLintData = lintDataWithSchema({
+ type: 'object',
+ required: ['status'],
+ properties: {
+ status: { enum: ['draft', 'review'] },
+ tags: { type: 'array', items: { enum: ['a', 'b'] } },
+ custom: { type: 'string', minLength: 3 },
+ },
+ });
+});
+
+describe('FrontmatterSchemaFieldEditor', () => {
+ test('renders a row per property with required state and enum pills', () => {
+ render();
+ expect(screen.getByTestId('frontmatter-field-row-status')).toBeTruthy();
+ const requiredSwitch = screen.getByTestId(
+ 'frontmatter-field-required-status',
+ ) as HTMLButtonElement;
+ expect(requiredSwitch.getAttribute('aria-checked')).toBe('true');
+ expect(screen.getByText('draft')).toBeTruthy();
+ expect(screen.getByText('review')).toBeTruthy();
+ });
+
+ test('toggling required writes exactly that constraint', () => {
+ render();
+ fireEvent.click(screen.getByTestId('frontmatter-field-required-status'));
+ expect(writes).toEqual([[FILE, 'status', { required: false }, []]]);
+ });
+
+ test('array fields edit items.enum, not enum', () => {
+ render();
+ const row = within(screen.getByTestId('frontmatter-field-row-tags'));
+ expect(row.getByText('Allowed element values (empty = any)')).toBeTruthy();
+ expect(row.queryByText('Allowed values (empty = any)')).toBeNull();
+ expect(row.getByText('a')).toBeTruthy();
+ });
+
+ test('a field with unmodeled keywords is flagged as preserved', () => {
+ render();
+ expect(screen.getByTestId('frontmatter-field-preserved-custom')).toBeTruthy();
+ expect(screen.queryByTestId('frontmatter-field-preserved-status')).toBeNull();
+ });
+
+ test('root-level advanced keywords surface a note naming them; absent otherwise', () => {
+ // Raw JSON text, not an object literal: `then` is a JSON Schema
+ // conditional here and a literal carrying it trips the thenable lint.
+ mockLintData = lintDataWithSchema(
+ JSON.parse(
+ '{"type":"object","additionalProperties":false,"if":{"properties":{"a":{"const":"x"}}},"then":{"required":["b"]},"properties":{"a":{"type":"string"}}}',
+ ),
+ );
+ render();
+ const note = screen.getByTestId(`frontmatter-schema-root-preserved-${FILE}`);
+ expect(note.textContent).toContain('additionalProperties');
+ expect(note.textContent).toContain('if');
+ cleanup();
+
+ // The default beforeEach schema has only modeled root keys — no note.
+ mockLintData = lintDataWithSchema({
+ type: 'object',
+ required: ['a'],
+ properties: { a: { type: 'string' } },
+ });
+ render();
+ expect(screen.queryByTestId(`frontmatter-schema-root-preserved-${FILE}`)).toBeNull();
+ });
+
+ test('add field writes a string-typed constraint and works without a loaded schema', () => {
+ mockLintData = lintDataWithSchema(undefined);
+ render();
+ fireEvent.change(screen.getByTestId(`frontmatter-add-field-${FILE}-input`), {
+ target: { value: 'owner' },
+ });
+ fireEvent.click(screen.getByTestId(`frontmatter-add-field-${FILE}-save`));
+ expect(writes).toEqual([[FILE, 'owner', { type: 'string' }, []]]);
+ });
+
+ test('the remove button drops exactly that field', () => {
+ render();
+ fireEvent.click(screen.getByTestId('frontmatter-field-remove-status'));
+ expect(removes).toEqual([[FILE, 'status', []]]);
+ expect(writes).toEqual([]);
+ });
+
+ test('committing an edited field name issues a rename', () => {
+ render();
+ const name = screen.getByTestId('frontmatter-field-name-status') as HTMLInputElement;
+ expect(name.value).toBe('status');
+ fireEvent.change(name, { target: { value: 'state' } });
+ fireEvent.keyDown(name, { key: 'Enter' });
+ expect(renames).toEqual([[FILE, 'status', 'state', []]]);
+ });
+
+ test('an unchanged or empty name commit does not rename', () => {
+ render();
+ const name = screen.getByTestId('frontmatter-field-name-status') as HTMLInputElement;
+ fireEvent.keyDown(name, { key: 'Enter' });
+ fireEvent.change(name, { target: { value: ' ' } });
+ fireEvent.blur(name);
+ expect(renames).toEqual([]);
+ });
+
+ test('committing a description writes that constraint; clearing removes it', () => {
+ render();
+ const description = screen.getByTestId(
+ 'frontmatter-field-description-status',
+ ) as HTMLInputElement;
+ fireEvent.change(description, { target: { value: 'Lifecycle state' } });
+ fireEvent.keyDown(description, { key: 'Enter' });
+ expect(writes).toEqual([[FILE, 'status', { description: 'Lifecycle state' }, []]]);
+ });
+
+ test('a field with enum values presents as the enum pseudo-type', () => {
+ render();
+ const row = within(screen.getByTestId('frontmatter-field-row-status'));
+ expect(row.getByTestId('frontmatter-field-type-status').textContent).toContain('enum');
+ });
+
+ test('object fields render nested child rows; nested edits carry the parent path', () => {
+ mockLintData = lintDataWithSchema({
+ type: 'object',
+ properties: {
+ meta: {
+ type: 'object',
+ required: ['owner'],
+ properties: { owner: { type: 'string' } },
+ },
+ },
+ });
+ render();
+
+ const nestedRow = screen.getByTestId('frontmatter-field-row-meta.owner');
+ expect(nestedRow).toBeTruthy();
+ const requiredSwitch = screen.getByTestId(
+ 'frontmatter-field-required-meta.owner',
+ ) as HTMLButtonElement;
+ expect(requiredSwitch.getAttribute('aria-checked')).toBe('true');
+
+ fireEvent.click(requiredSwitch);
+ expect(writes).toEqual([[FILE, 'owner', { required: false }, ['meta']]]);
+
+ fireEvent.click(screen.getByTestId('frontmatter-field-remove-meta.owner'));
+ expect(removes).toEqual([[FILE, 'owner', ['meta']]]);
+ });
+
+ test('adding a field inside an object row targets that parent', () => {
+ mockLintData = lintDataWithSchema({
+ type: 'object',
+ properties: { meta: { type: 'object' } },
+ });
+ render();
+
+ const children = within(screen.getByTestId('frontmatter-field-children-meta'));
+ fireEvent.change(children.getByTestId('frontmatter-add-field-meta-input'), {
+ target: { value: 'owner' },
+ });
+ fireEvent.click(children.getByTestId('frontmatter-add-field-meta-save'));
+ expect(writes).toEqual([[FILE, 'owner', { type: 'string' }, ['meta']]]);
+ });
+
+ test('array rows show an element-type select; enum elements present as enum', () => {
+ render();
+ // `tags` has items.enum values → the element-type select presents `enum`.
+ const itemsSelect = screen.getByTestId('frontmatter-field-items-type-tags');
+ expect(itemsSelect.textContent).toContain('enum');
+ });
+
+ test('array-of-object rows render element fields; nested ops carry the items segment', () => {
+ mockLintData = lintDataWithSchema({
+ type: 'object',
+ properties: {
+ ingredients: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['name'],
+ properties: { name: { type: 'string' } },
+ },
+ },
+ },
+ });
+ render();
+
+ // The element sub-schema renders as rows under the `[]` path marker.
+ const row = screen.getByTestId('frontmatter-field-row-ingredients.[].name');
+ expect(row).toBeTruthy();
+ // No element-values pills for object elements.
+ expect(screen.queryByTestId('frontmatter-field-items-enum-ingredients')).toBeNull();
+
+ fireEvent.click(screen.getByTestId('frontmatter-field-required-ingredients.[].name'));
+ expect(writes).toEqual([[FILE, 'name', { required: false }, ['ingredients', { items: true }]]]);
+
+ const children = within(screen.getByTestId('frontmatter-field-item-children-ingredients'));
+ fireEvent.change(children.getByTestId('frontmatter-add-field-ingredients.[]-input'), {
+ target: { value: 'quantity' },
+ });
+ fireEvent.click(children.getByTestId('frontmatter-add-field-ingredients.[]-save'));
+ expect(writes[1]).toEqual([
+ FILE,
+ 'quantity',
+ { type: 'string' },
+ ['ingredients', { items: true }],
+ ]);
+ });
+});
diff --git a/packages/app/src/components/settings/frontmatter-schema-field-editor.tsx b/packages/app/src/components/settings/frontmatter-schema-field-editor.tsx
new file mode 100644
index 00000000..c3b9a37b
--- /dev/null
+++ b/packages/app/src/components/settings/frontmatter-schema-field-editor.tsx
@@ -0,0 +1,606 @@
+/**
+ * Per-field editor over one frontmatter schema file. Renders the schema's
+ * properties as friendly rows (name / type / required / description / allowed
+ * values / pattern), recursing into object-typed fields so nested frontmatter
+ * shapes are editable in place. Every edit persists as ONE operation through
+ * `POST /api/lint/frontmatter-schema` — a non-destructive merge addressed by
+ * `parentPath`, so keywords the editor does not model survive on disk and are
+ * flagged per row. Reads the RESOLVED schema content from the effective lint
+ * config (the server inlines loaded files; the browser never touches disk).
+ */
+
+import type {
+ FrontmatterFieldConstraint,
+ SchemaParentPathSegment,
+} from '@inkeep/open-knowledge-core';
+import { Trans, useLingui } from '@lingui/react/macro';
+import {
+ Braces,
+ Brackets,
+ CaseSensitive,
+ Hash,
+ ListChecks,
+ type LucideIcon,
+ ToggleLeft,
+ Trash2,
+} from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+import { TagPillInput } from '@/components/ui/tag-pill-input';
+import {
+ emitLintConfigChanged,
+ removeFrontmatterSchemaField,
+ renameFrontmatterSchemaField,
+ useProjectLintConfig,
+ writeFrontmatterSchemaField,
+} from '@/editor/lint-config-client';
+import { cn } from '@/lib/utils';
+
+const FIELD_TYPES = ['string', 'number', 'boolean', 'array', 'object'] as const;
+type FieldType = (typeof FIELD_TYPES)[number];
+
+/**
+ * The type select adds `enum` as pure UI sugar for discoverability (matching
+ * the agents-manage-ui builder): picking it defaults the field to `string` and
+ * points the user at the allowed-values input. On disk `enum` stays what JSON
+ * Schema says it is — a constraint orthogonal to `type`.
+ */
+const TYPE_SELECT_OPTIONS = ['string', 'number', 'boolean', 'enum', 'array', 'object'] as const;
+
+/** Element-type choices for an array field's `items` (no arrays-of-arrays UI). */
+const ITEMS_TYPE_SELECT_OPTIONS = ['string', 'number', 'boolean', 'enum', 'object'] as const;
+type ItemsType = 'string' | 'number' | 'boolean' | 'object';
+
+/** Same per-type color coding the agents-manage-ui builder uses. */
+const TYPE_ICONS: Record<
+ (typeof TYPE_SELECT_OPTIONS)[number],
+ { Icon: LucideIcon; className: string }
+> = {
+ string: { Icon: CaseSensitive, className: 'text-green-500' },
+ number: { Icon: Hash, className: 'text-blue-500' },
+ boolean: { Icon: ToggleLeft, className: 'text-orange-500' },
+ enum: { Icon: ListChecks, className: 'text-yellow-500' },
+ array: { Icon: Brackets, className: 'text-pink-500' },
+ object: { Icon: Braces, className: 'text-purple-500' },
+};
+
+function TypeSelectItemLabel({ option }: { option: (typeof TYPE_SELECT_OPTIONS)[number] }) {
+ const { Icon, className } = TYPE_ICONS[option];
+ return (
+
+
+ {option}
+
+ );
+}
+
+/** Leading row icon for a field's presented type — the scan anchor. */
+function RowTypeIcon({ option }: { option: (typeof TYPE_SELECT_OPTIONS)[number] }) {
+ const { Icon, className } = TYPE_ICONS[option];
+ return ;
+}
+
+/**
+ * Deepest object level whose CHILDREN the editor renders (root = 0). The wire
+ * caps `parentPath` at 8 segments; the UI stops well inside that.
+ */
+const MAX_NESTING_DEPTH = 4;
+
+/**
+ * Schema-ROOT keys the editor models (structurally or implicitly). Anything
+ * else at the root — if/then, dependencies, additionalProperties, x-
+ * extensions — is invisible to the per-field rows, so the editor surfaces a
+ * top-of-list note naming them; the per-field preserved flag can't cover
+ * keywords that don't belong to any field.
+ */
+const ROOT_MODELED_KEYWORDS = new Set(['$schema', 'type', 'properties', 'required']);
+
+/**
+ * The keywords the friendly rows model; anything else is preserved-but-flagged.
+ * `properties`/`required` are modeled structurally — as the nested child rows.
+ */
+const MODELED_KEYWORDS = new Set([
+ 'type',
+ 'enum',
+ 'pattern',
+ 'format',
+ 'items',
+ 'description',
+ 'properties',
+ 'required',
+]);
+
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function stringEnum(value: unknown): string[] | null {
+ if (value === undefined) return [];
+ if (!Array.isArray(value)) return null;
+ return value.every((entry) => typeof entry === 'string') ? (value as string[]) : null;
+}
+
+function hasUnmodeledKeywords(property: Record): boolean {
+ if (Object.keys(property).some((key) => !MODELED_KEYWORDS.has(key))) return true;
+ const items = property.items;
+ if (items !== undefined) {
+ if (!isRecord(items)) return true;
+ const modeledItemsKeys = new Set(['enum', 'type', 'properties', 'required', 'description']);
+ if (Object.keys(items).some((key) => !modeledItemsKeys.has(key))) return true;
+ }
+ return false;
+}
+
+/** Per-field write handlers threaded through the recursion. */
+interface FieldOps {
+ save: (
+ field: string,
+ constraint: FrontmatterFieldConstraint,
+ parentPath: readonly SchemaParentPathSegment[],
+ ) => void;
+ rename: (field: string, to: string, parentPath: readonly SchemaParentPathSegment[]) => void;
+ remove: (field: string, parentPath: readonly SchemaParentPathSegment[]) => void;
+}
+
+/** Stable testid/key text for a path — `{items: true}` renders as `[]`. */
+function pathKeyOf(parentPath: readonly SchemaParentPathSegment[], field: string): string {
+ return [...parentPath.map((seg) => (typeof seg === 'string' ? seg : '[]')), field].join('.');
+}
+
+export function FrontmatterSchemaFieldEditor({ file }: { file: string }) {
+ const { t } = useLingui();
+ const { data } = useProjectLintConfig();
+
+ const entry = data?.effective.plugins.frontmatter.schemas.find((s) => s.file === file);
+ const schema = entry?.schema;
+ const rootAdvancedKeywords = isRecord(schema)
+ ? Object.keys(schema).filter((key) => !ROOT_MODELED_KEYWORDS.has(key))
+ : [];
+
+ const ops: FieldOps = {
+ save: (field, constraint, parentPath) => {
+ void (async () => {
+ const result = await writeFrontmatterSchemaField(file, field, constraint, parentPath);
+ if (result.ok) emitLintConfigChanged();
+ else toast.error(result.errorDetail ?? t`Failed to save the schema field`);
+ })();
+ },
+ rename: (field, to, parentPath) => {
+ void (async () => {
+ const result = await renameFrontmatterSchemaField(file, field, to, parentPath);
+ if (result.ok) emitLintConfigChanged();
+ else toast.error(result.errorDetail ?? t`Failed to rename the field`);
+ })();
+ },
+ remove: (field, parentPath) => {
+ void (async () => {
+ const result = await removeFrontmatterSchemaField(file, field, parentPath);
+ if (result.ok) emitLintConfigChanged();
+ else toast.error(result.errorDetail ?? t`Failed to remove the field`);
+ })();
+ },
+ };
+
+ return (
+
+ {schema === undefined && (
+
+
+ The schema file does not exist or failed to load — adding a field creates it.
+
+
+ )}
+ {rootAdvancedKeywords.length > 0 && (
+
+
+ This schema carries root-level advanced rules the editor does not show (
+ {rootAdvancedKeywords.join(', ')}) — they still validate and survive every edit. Open
+ the schema file to see them.
+
+
+ )}
+
+
+ );
+}
+
+/** The rows for one object level (the schema root or a nested object field). */
+function FieldList({
+ node,
+ parentPath,
+ depth,
+ addInputId,
+ ops,
+}: {
+ node: Record;
+ parentPath: readonly SchemaParentPathSegment[];
+ depth: number;
+ addInputId: string;
+ ops: FieldOps;
+}) {
+ const properties = isRecord(node.properties) ? node.properties : {};
+ const required = Array.isArray(node.required)
+ ? node.required.filter((r): r is string => typeof r === 'string')
+ : [];
+
+ return (
+