From 282e55e6c2e34d419918a149a19d3fd11c1e8dea Mon Sep 17 00:00:00 2001 From: serafin-garcia Date: Fri, 24 Jul 2026 17:12:43 -0700 Subject: [PATCH] feat(open-knowledge): validate frontmatter against JSON Schema (#2919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(open-knowledge): frontmatter data contract spec, evidence, and story catalog Carried over from theming-as-plugin so the frontmatter validation work can be kicked off from main later. Brings the durable data contract spec with its worldmodel evidence, the doc-patterns / property-schemas / linting story catalog it serves, and the lint-plugins ecosystem-standard seam spec it is grounded in. Implementation, themes, markdownlint, and the plugin research reports stay behind on the old branch on purpose; four report links inside the seam spec dangle as a result. * docs(open-knowledge): carry the Vale/remark-lint engine survey report The frontmatter data contract's worldmodel evidence cites this report twice as the internal engine survey behind the ajv / JSON Schema decision (buy the engine, keep the thin data layer; parse is not validate). It was the only branch-only report the contract leans on; the other cited reports already live on main. Its link to zed-vscode-rule-management-architecture dangles on the source branch as well, so it is carried unchanged. * docs(open-knowledge): flatten frontmatter config key to contentRules.frontmatter The plugins nesting level between contentRules and frontmatter carried no data or behavior of its own and is being removed in the linter work; the contract example now shows the flattened path and notes that branch-era evidence still shows the old nesting. * docs(open-knowledge): worldmodel groundings — schema-rules file location + agent read-time schema discovery Two fresh /worldmodel runs on the frontmatter data contract, per review feedback. First: whether schema rules belong inside .ok/config.yml or in separate files, re-derived from prior art without anchoring on the spec's pinned shape (web survey of four persistence shapes, current-tree and branch code, internal reports, LANDSCAPE seed). Second: how agents interact with glob-scoped schemas and what conventions exist for making a doc's schema known at read time before a write (harness-resolved globs, fetch-schema-before-write, validate-repair loops, OK's current MCP read/write surfaces). Observational only; divergences flagged, including spec-glob vs branch-regex appliesTo and the not-yet-implemented write-advisory ride-along. * docs(open-knowledge): reframe branch-code divergences as provenance notes The theming-as-plugin implementation is reference-only and will not merge; the rebuild starts from origin/main. The glob appliesTo and flattened config nesting decisions in the contract are normative, so the evidence docs now record the old code's regex matching and plugins nesting as provenance, not open questions. * docs(open-knowledge): ground the two-file-split question (rules.yml vs contentRules block) The file-location worldmodel answered where schema content lives but not the sharper question the feedback asked: is there prior art for a dedicated rules file next to the main config within one tool. Adds a re-probe: both currents are mainstream (markdownlint-cli2, golangci, Claude/Cursor rules dirs split; ESLint flat, Biome, Deno, Cargo lints consolidate), with the documented predictor being portability to other consumers, cascade scope, and mechanism class rather than fashion. * docs(open-knowledge): ratify D7 (rules stay in config.yml) + D8 (templates-pattern agent surfaces) Folds the two 2026-07-14 grounding outcomes into the contract: a decision entry confirming the contentRules block belongs in config.yml rather than a dedicated rules.yml (the prior-art predictors for a separate file do not hold), and a new serving-surfaces section pinning how agents learn applicable schemas — OK resolves appliesTo as the harness, read enrichment advertises schemas the way templates_available does today, write advisories cover the new-file gap, lint stays the on-demand audit. * docs(open-knowledge): cold-reader audit findings for the frontmatter contract doc set 17 findings (4 high, 7 medium, 6 low) from a fresh-context audit running coherence lenses and factual verification over the SPEC, its evidence files, the story catalog, and the seam spec. Headliners: the example schemas' https draft-07 $schema URI is unknown to ajv and would schemaError under the contract's own D2 rule; the branch DOES already ship the frontmatter write-advisory (computeLintViolations, cap 10) contradicting the SPEC's greenfield claim; the schema file path base is stated as both project-root-relative and content-dir bounded; and the primary evidence snapshot still presents regex appliesTo as decided. Findings only, nothing fixed. * docs(open-knowledge): greenfield the contract; fix $schema URIs; stamp stale evidence Audit findings 1, 2, and 4. Examples now carry the canonical http://json-schema.org/draft-07/schema# URI (ajv rejects the https form) and D2 pins the accepted alias set. The spec no longer grounds itself in the discarded theming-as-plugin prototype: the branch-relative deltas section is gone (all five items were already ratified as D1, D2, D4, D6), the sign-off ratifies a greenfield implementation off main, and the prototype survives only as provenance in the evidence files. The agent-discovery evidence gets an erratum (the prototype DID ride frontmatter violations on write advisories via computeLintViolations, capped at 10), and the 2026-07-13 snapshot gets a provenance stamp marking regex-appliesTo and the plugins nesting as superseded. * docs(open-knowledge): fix path base to project root; real glob dialect name; seam-spec supersession notes Audit findings 3 (option i: file resolves from and is bounded by the project root, so .ok/schemas/ works under any content.dir), 5 (the dialect is globby/picomatch set semantics, not gitignore re-inclusion), and 11 (the seam spec now marks its regex appliesTo and forbid keys as superseded by data-contract D1/D6). * docs(open-knowledge): pin dedup key, config-channel errors (D9), defaults + implicit-** rule Audit findings 7, 8, 9, per review. Dedup identity is the resolved schema-file path (byte-identical distinct files deliberately validate twice). New D9: schemaError and invalid-glob problems surface on the config channel (settings panel, top-level warnings on lint responses, CLI stderr), never as per-doc diagnostics; per-doc anchoring gains the line-1 fallback for root-level errors and fence-less docs. The slice pins enabled: true as default with an absent block meaning inert enabled-with-zero-schemas, and degenerate appliesTo sets get one rule: no positive glob means an implicit **, so negation-only lists carve out rather than match nothing. * docs(open-knowledge): settle the NG1 [NEVER] reading (D10) + pin the write-advisory cap Audit finding 10, both halves. New D10 settles the nested-frontmatter spec's NG1 tag by intent: it forbids blocking enforcement, and this contract is advisory-only on every surface — violations warn, never gate a write; making them gate is a posture-breaking change. NG1 itself gains a cross-pointer recording the settled reading. The agent write-advisory pins the cap: at most 10 violations per write response, full set on lint. assessment.md's discharged claim is now true. * docs(open-knowledge): sweep the five low-severity audit findings Verdict ownership moved to the spec (the groundings stay observational), D8's harness-resolves-globs headline carries its qualification, the schemas comment no longer implies entry-order precedence, the no-doc-path jargon is glossed, the worked example shows the actual offending value, the 'is exactly JSON Schema' quote is reattributed to the seam spec, and the repair-loop percentages now cite their study. * docs(open-knowledge): present the spec as the proposed contract, not its changelog Strips the internal dialog: no feedback-round narration, no earlier-draft blockquote, no discarded-prototype asides, no revisited/reaffirmed parentheticals. Grounding collapses to one statement pointing at the evidence worldmodels, and the revision history lives in git and the evidence files. * docs(open-knowledge): cite agents manage-ui JsonSchemaEditor as GUI prior art * docs(open-knowledge): ground the frontmatter contract against the shipped plugin PR PR #2433 landed the substrate on main, so re-ground the doc set against shipped code rather than the worktree prototype: - SPEC header: the registry, LSP diagnostics, async lint(), advisory channel, MCP lint + ok lint, and Plugins settings shipped; only the frontmatter artifacts remain greenfield. - Advisory asymmetry claim corrected (P6, D5, the derived-behavior note): the shipped channel carries every enabled lint source, markdownlint included, capped at 10; the data-vs-style distinction now lives in per-plugin enablement. - D9 aligned to the shipped warnings[] surfaces (audit response, MCP structured payload, ok lint report lines). - Flagged the enabled-default tension for approval: shipped registry convention is default-OFF opt-in; the contract proposes default-on. - assessment.md + agent-schema-discovery evidence: post-ship corrections (advisory widened not frontmatter-scoped; rules.yml/errorRuleIds retired; cli2 cascade shipped). * docs(open-knowledge): frontmatter plugin defaults off, matching the registry convention Resolves the flagged enabled-default question: no default-on carve-out. The frontmatter plugin is enabled the same way every other lint plugin is (Settings -> Plugins, contentRules.frontmatter.enabled, default false); an absent block means off. * [US-001] core appliesTo glob matcher (picomatch, set semantics, invalid-pattern reporting) * [US-002] core frontmatter validation engine (ajv draft-07, key-line anchoring) * [US-003] register the frontmatter lint plugin (registry, slices, config leaf) * [US-004] server frontmatter schema-file resolution and injection * [US-005] frontmatter results + config warnings across the lint surfaces * [US-006] ok lint validates frontmatter headless * [US-007] MCP read-time schema advertisement (exec enrichment) * [US-008] settings panel: frontmatter schema mappings editor * [US-009] non-destructive schema-file write path (applyFieldConstraint + endpoint) * [US-010] settings GUI: per-field schema editor * [US-011] property panel renders schema-driven enum selects * [US-012] changeset, knip and lint sweep for the frontmatter plugin * feat(open-knowledge): frontmatter schema file picker + agent-surface tests Add a pick-or-create schema-file affordance to the frontmatter mapping editor: a folder button that browses existing .ok/schemas/*.json and a file-plus button that scaffolds a new draft-07 schema by name. Backed by a new GET /api/lint/frontmatter-schemas listing endpoint and a create-empty branch on the existing schema-write endpoint (a field-less request scaffolds the skeleton, idempotent, escape-guarded). core: FrontmatterSchemasListSuccessSchema; the write-request schema now accepts a field-less create-empty request (field/constraint refined as a both-or-neither pair). server: listProjectSchemaFiles + handleFrontmatterSchemasList (GET, read only); createEmptyFrontmatterSchemaFile reusing the existing escape-guard and atomic-write path; the write handler branches edit vs create-empty. app: FrontmatterSchemaFilePicker (Command-in-Popover, two peer buttons) wired into the mapping draft and existing rows; list/create client helpers and a live schema-files hook on the lint-config client. tests: HTTP integration for the write (per-field and create-empty) and list endpoints against a real server; an exec composition test proving schemas_applicable read-time enrichment flows config to the agent response; unit coverage for create-empty and listing; picker DOM tests. Registers the new GET handler as read-only in the attribution + conflict-gate meta-tests. * feat(open-knowledge): offer schema-file deletion when removing a tool-managed mapping Removing a frontmatter mapping whose schema file lives in .ok/schemas (created in the tool) now asks whether to also delete the file. Files mapped from elsewhere in the project only ever lose the mapping. Server: delete branch on the schema write endpoint, scoped to flat .ok/schemas paths, idempotent, unlinks the directory entry so a planted symlink cannot reach outside. New CC1 lint-config derived-view channel signals when the persisted project config or a schema file changes on disk, closing the stale-problems race with the config persistence debounce. Schema create/delete also signals the files channel so show-OK trees update without a reload. App: confirm dialog on the mapping row, delete client helper, and the problems banner now hides entries for files no live mapping references so the stale compose never flashes. * feat(open-knowledge): schema field delete, rename, description, enum type Close the parity gaps with the agents-manage-ui schema builder while keeping the non-destructive merge posture: every operation is a surgical edit and unmodeled keywords survive verbatim. Core: removeSchemaField and renameSchemaField (rename carries the full property object, required membership, and key position; refuses missing source or existing target), description joins the modeled constraint keywords, and the write request schema gains removeField and renameTo shapes with mutual-exclusion refinements. Server: remove and rename wrappers over a shared guarded transform spine (existing file required, escape-guarded, atomic write); handler branches on the new shapes. App: field rows get an editable name (commit-on-blur rename), a remove button, and a description input; the type select adds an enum pseudo-type for discoverability that maps to the standard enum constraint on disk. * feat(open-knowledge): Source/Fields toggle for opened schema files Opening a tool-managed frontmatter schema (.ok/schemas/*.json) in the editor now dispatches to a dedicated SchemaConfigEditor with the same segmented Source/Fields toggle the markdownlint config editor ships: Source is the raw read-only file, Fields mounts the per-field schema editor from Settings directly over the file. Fields is gated to files a live frontmatter mapping references (the field editor reads the resolved schema from the effective config); unmapped schemas keep Source with an explanatory disabled segment. Shares the lint-config view-mode preference so one choice governs both config surfaces. * feat(open-knowledge): nested schema field editing via parentPath Address fields inside object-typed properties end to end: the core edit operations (constraint merge, remove, rename) take a parentPath of object-property segments (an array on the wire, not a dotted string, since frontmatter keys may contain dots), navigating with create-on-write for adds and typed refusals for missing or non-object segments. Required membership toggles on the addressed parent, matching JSON Schema's per-object required semantics. The field editor recurses into object rows (bounded depth) with an add-field affordance per level, and every row operation carries its path. HTTP request schema caps parentPath at 8 segments and requires it to accompany a field operation. * feat(open-knowledge): typed array elements + type icons in the schema editor Array fields get an element-type select (string, number, boolean, enum, object) writing items.type through the same non-destructive merge, and array-of-object elements render as editable nested rows: parentPath gains an {items: true} segment that descends into an array field's items sub-schema, so element fields support the full operation set (add, constraint edits, rename, remove, per-element required). No array-of-array UI on purpose; unmodeled items keywords keep the preserved flag. Both type selects now carry per-type colored icons, matching the agents-manage-ui builder affordance. * feat(open-knowledge): denser schema field rows Flatten each field card's header to one scan line, matching the agents-manage-ui builder's density without adopting its persistence: leading colored type icon, mono name input, inline description, required switch, and remove button share a row; type and pattern keep the second line. Commit-on-blur write semantics unchanged. * feat(open-knowledge): schema editor surface for *.schema.json anywhere Widen the Source/Fields dispatch from tool-managed .ok/schemas paths to any file following the ecosystem *.schema.json naming convention, plus the existing .ok/schemas/*.json scope. Mappings already accept schema files anywhere in the project and the per-field write endpoint already edits any in-project path, so the editor surface was the one place still bound to the delete-flow's safety scope. Fields stays gated on a live mapping; a bare dotfile named .schema.json and unconventionally named json stay on the asset preview. * feat(open-knowledge): surface root-level advanced schema rules in Fields view Per-field preserved flags cannot cover keywords that belong to no field: a schema's if/then, dependencies, or additionalProperties were invisible in the Fields view even though they validate and gate edits. The editor now leads with a note naming the root-level keywords it does not model, pointing at the schema file for the full picture. * feat(open-knowledge): schema browser settings panel Rework the frontmatter plugin settings from mapping rows + file picker to a browser over every schema file in the project, mirroring the markdownlint rule browser UX: each discovered schema (project-wide *.schema.json plus .ok/schemas/*.json plus anything config.yml maps) is a toggleable row with a Modified badge, reset-to-default, search, and a New schema button top right. Toggling on writes an enabled: true mapping whose appliesTo pills edit inline below the row; off keeps the mapping with enabled: false; reset wipes it. Clicking a schema opens the file itself in the editor via hash nav — field editing lives on the file surface now, so the settings-embedded field editor, the mapping file picker, and the remove-mapping dialog are gone. Core: mappings gain an optional enabled flag (absent = on); the lint plugin and the agent-surface enrichment skip disabled entries while resolution still inlines their content for the editor, and disabled mappings stay silent on the problems channel. Server: the schemas list endpoint unions the .ok/schemas scan with a filtered content walk for the *.schema.json convention. * feat(open-knowledge): schema browser polish — delete, layout, only-modified Each schema row gains a trash affordance behind a confirmation dialog that deletes the file and wipes its mapping in one gesture; the server delete scope widens from .ok/schemas to the browser's own predicate (*.schema.json anywhere plus .ok/schemas/*.json) so the visible and deletable scopes agree, with the entry-unlink and realpath guards unchanged. The New schema button moves out of the header to sit right of the search bar, alongside a markdownlint-style Only modified filter. * feat(open-knowledge): plain-language appliesTo summaries Each enabled schema row now reads its globs back in words under the pills: a core classifier maps authored patterns onto nameable shapes (every doc, everything under a folder, docs directly in a folder, a single doc, any doc named X, with one plain brace set expanded) split into includes and excludes, and the panel renders the localized sentence. Anything the classifier can't confidently describe falls back to naming the raw glob, so the summary never claims more than the matcher does. Classification mirrors compileAppliesTo's real semantics: picomatch over content-relative, extension-less doc paths. * fix(open-knowledge): honest appliesTo summaries + suspicious-glob warnings Fix the two summary defects: trailing/leading-slash patterns and globs that fail to compile now classify as non-matching (the sentence says nothing can match instead of claiming a doc), and interior-** subtree patterns get nameable shapes (blogs/**/pizza/** reads as everything under pizza/ folders inside blogs/; **/pizza/** as everything under any pizza/ folder). Validation: compileAppliesTo now reports suspicious patterns alongside invalid ones — syntactically fine globs that can never match a doc path (trailing or leading slash) or that target a doc extension (paths are extension-less). The schema resolver surfaces them as advisory config problems, silenced for disabled mappings, and the settings panel admits them to the problems banner. * refactor(open-knowledge): dedupe frontmatter schema selection and write guards Extract selectApplicableFrontmatterSchemas into core so the lint plugin and the property panel's enum constraints share one selector. This fixes a real divergence: a toggled-off schema mapping still drove enum selects in the property panel while contributing no diagnostics. Pinned by a new test. Server-side, a shared src/lint/fs-safety.ts now owns the lexical containment check and the traced atomic tmp+rename write that were copy-pasted across the frontmatter loader, the frontmatter schema writer, and the markdownlint writer. The schema write surface's triplicated escape-guard flow collapses into one resolveSchemaWriteTarget resolver, and basename() replaces the platform-dependent split('/').pop(). App-side, the five near-identical frontmatter-schema POST wrappers collapse onto one helper; delete/create now validate the 200 response like their siblings. Net -121 lines, behavior-identical except the panel fix. * docs(open-knowledge): frontmatter schemas page + two-linter language pass Add advanced/content-rules/frontmatter.mdx mirroring the markdownlint page: schema mappings and appliesTo semantics, draft-07 schema files and the config problems channel, diagnostic anchoring, the settings editor, the Source/Fields schema editor, property-panel enum selects, and the schemas advertisement agents see on reads. Update the surrounding language now that content rules has two linters: the overview no longer says markdownlint is the only linter, both linter pages cross-link, the MCP reference's lint row covers frontmatter validation and the config-problems warnings channel, and the configuration reference gains the contentRules.frontmatter rows. Also fix the markdownlint row's key path, which documented a contentRules.plugins.* nesting that never existed. * docs(open-knowledge): show real lint and fix output on every API surface Captured from actual runs against a scratch project (built CLI, real server, real MCP stdio session), not hand-written: the ok lint text report, the --json shape (0-based LSP ranges, fixes on auto-fixable findings, config-problem warnings, counts), the --fix report, the MCP lint tool's single-doc text and fix: true summaries, the agent write response's lint-violation advisories, and the exec listing's schemas: advertisement. Overview carries the shared shapes (CLI + MCP + write advisories); each linter page shows its own finding: markdownlint with the fixes payload the Fix action applies, frontmatter with anchoring, the got-value enum message, and the config-channel line for a broken schema. The MCP reference lint row links to the examples. * feat(open-knowledge): schema settings and problems-panel discovery polish Review feedback on the frontmatter data-contract work: - The appliesTo pill input names its grammar ("Add file or folder pattern, e.g. guides/**/*") instead of the generic "Add tag"; the field editor's allowed-values inputs say "Add value". - Enabled mappings whose globs match zero docs now warn on the config channel (settings panel + project audit), next to the invalid/suspicious glob warnings. New core helper findZeroMatchAppliesToPatterns; the audit walk is extracted as collectDocFiles so the lint-config surface and the audit agree on what counts as a doc. - Each schema row carries an explicit Edit button beside the name click. - Opening a schema from Settings lands on the WYSIWYG Fields view via a one-shot intent, without flipping the persisted Source/Fields preference. - The Problems panel names the active lint plugins and, with none enabled, replaces the misleading empty state with a pointer to Settings -> Plugins. * feat(open-knowledge): schema row and problems-panel indicator follow-ups - The schema name is plain text now; the Edit button is the row's only way into the file. - The problems panel's plugin indicator collapses to a compact "Checked by N plugins" line whose tooltip names the plugins, instead of an always-visible badge row. * feat(open-knowledge): move Checked-by hint into the Problems header row * feat(open-knowledge): Checked-by hint as a PanelCount pill (matches Graph header) * feat(open-knowledge): Beta tag on the frontmatter schemas plugin settings * feat(open-knowledge): Beta tag on the schema file editor surface * fix(open-knowledge): schema editor Beta tag to the top left * docs(open-knowledge): align content rules docs with shipped contracts Audited the three content-rules pages against the lint and audit CLI commands, the MCP lint tool, and the core lint types. Fixes the places where the docs disagreed with the code, and fills in the contracts that were never written down. Corrections: - appliesTo: a list with no positive glob gains an implicit **, so an exclusion-only list matches everything except the exclusions - write advisories nest under `document`, not at the response root - MCP lint structured content is close to `ok lint --json` but not identical: contentDir vs cwd, fixedCount counts files vs problems, plus the cap fields - the Source/Rules toggle is JSON and JSONC only; YAML and .markdownlintrc open in the read-only preview - YAML configs are re-serialized on edit, which drops their comments - the Problems panel has a Fix all action Newly documented: - `ok audit --json` response shape, with linkTarget on link findings - validation.links, its three values, and where to set it - brokenLinks on write responses, with all three reason values - omittedFileCount and omittedDiagnosticCount - the per-file `fixed` field on `ok lint --json` - advisories for appliesTo globs that compile but cannot match Also corrects the settings path in the markdownlint blog post to Settings > This project > Plugins. * fix: restore 15 skill symlinks deleted by accident The symlinks under .agents/skills/ point into public/agents/plugins/agents/skills/ and are resolved by bare name across agent harnesses, so removing them silently breaks skill resolution repo-wide. They were deleted in afe849caa, a docs commit about presenting a spec as the proposed contract, which has no functional relationship to skill symlinks. That is the signature of an unintended `git add -A`, not an intended retirement. Restored from origin/main. * fix(open-knowledge): silent-correctness fixes from local review Three verified defects in the new frontmatter data-contract paths, plus the docs and changeset corrections that came with them. deleteFrontmatterSchemaFile reported success on ANY parent-dir stat failure. Only ENOENT and ENOTDIR prove the entry is gone; EACCES/EPERM/ELOOP mean we could not prove it, and returning `deleted` there made the GUI drop the mapping and silently stop validating those docs. Those now return a typed refusal. Reachable on macOS via TCC, and via a chmod that removes the search bit on .ok/schemas. resolveFrontmatterSchemas suppressed the broken-schema config warning when a DISABLED mapping loaded the file first: the outcome cached, and a later enabled mapping for the same file skipped the cache-miss branch and never reported. Docs governed by that schema went unvalidated with no operator signal, and the suppression was order-dependent. The report now sits outside the cache-miss guard with a per-file dedup set so two enabled mappings still report once. applyFieldConstraint replaced tuple and boolean `items` with a fresh object holding only the new constraint, destroying the user's positional definition on any array-field edit. Worse than data loss: draft-07 ignores additionalItems once items is a single schema, so a surviving `additionalItems: false` became dead config. The same shape is already refused by navigateToParent, so editing now refuses too, which removes an internal contradiction rather than adding a new posture. Also: correct the ok lint --json example (warningCount said 3 with only 2 diagnostics), note that --errors-only never gates on frontmatter findings, document the exec tool's schemas output in the MCP reference, bump the changeset to minor per the pre-1.0 convention for additive features, and fix an isToolManagedSchemaPath docstring that named the wrong predicate and understated the delete scope. * fix(open-knowledge): review follow-ups on lint diagnostics and a11y Invalid appliesTo globs now carry the engine's own reason. picomatch names the actual defect ("Missing closing: ]"), which was being discarded, leaving the user with a message that said a glob was invalid but not why. invalidPatterns becomes {pattern, detail} to carry it, mirroring the suspiciousPatterns shape already in that interface. listProjectSchemaFiles logged nothing when the directory could not be read, so an EACCES under macOS TCC produced an empty schema picker indistinguishable from a first run. ENOENT and ENOTDIR stay quiet; anything else warns. The schema-description input had no accessible name of its own. A placeholder is only a last-resort fallback in the name computation, and every row computed the same one, so the name neither identified the field nor stayed correct once a description existed. Now labelled per field through the t macro, matching the three sibling interpolated labels in the same component. Catalogs re-extracted. Collapse the third copy of isInside onto the fs-safety primitive this branch introduced, and correct two docstrings that understated the delete surface: it admits any *.schema.json in the project, not only tool-managed .ok/schemas/ paths. Documents why write scope is deliberately broader than delete scope rather than narrowing it, since a mapping may point at any project-relative JSON the linter can already read. * test(open-knowledge): pin the order-dependent suppression and enum multi-ness The frontmatter-schemas fix landed without a test for the scenario it fixes. The existing suite covered a single disabled mapping staying silent, but not the duplicate-file ordering that was the actual bug, so the fix was one refactor away from silently regressing. Adds the disabled-then-enabled case, the two-enabled no-double-report case, and the disabled-only silence case. Mutation-checked: reintroducing the report inside the cache-miss branch fails the first test and only that one. Also covers the existing multi-ness mismatch fallback in the property panel, where a scalar enum and an array items.enum govern one field. Vocabularies in that test are identical so only the multi-ness guard can cause the drop, and both orderings are asserted since neither schema may decide the shape. * fix(ok-marketing): declare core's new ajv, ajv-formats and picomatch deps The vendored @inkeep/open-knowledge-core dist is derived from packages/core at build time, but the vendored package.json is committed and is what tells pnpm which runtime deps to install. The frontmatter schema feature added ajv, ajv-formats and picomatch to core, so the derived dist imports them while the manifest never declared them, and the Vercel build failed on the first one it could not resolve. Only ajv-formats surfaced in the log because the build stops at the first unresolvable import; the other two were latent behind it. * chore: lock the vendored core's new ajv, ajv-formats and picomatch deps * fix(open-knowledge): drop four unnecessary exports flagged by knip All four symbols are used, but only inside their own module, so exporting them added public surface with no consumer. knip fails the lint job on unused exports; this branch never ran it until the PR existed, which is why four accumulated at once. listFrontmatterSchemas is called twice in its own file; the three applies-to types are referenced only by the exported CompiledAppliesTo interface, which does not require them to be exported to remain usable. * perf(open-knowledge): lazy-load the two config-file editors EditorArea imported LintConfigEditor and SchemaConfigEditor statically, so both shipped in the main chunk for every session even though they render only when a .markdownlint.* or *.schema.json file is opened. That put the main app bundle 5.49 kB over its size-limit budget. Both now load through React.lazy behind a Suspense fallback, matching the pattern already used in this file for LazyActivityModeContent and in DocPanel for LazyGraphPanel. Main bundle drops 490.49 kB to 480.13 kB, back under the 485 kB budget. The two dispatch tests that assert routing to these editors now await the dynamic import. Only the first assertion against each lazy component needs it — later tests in the file find the module already resolved — so the await is on those two and the comment says why. * chore(open-knowledge): raise the all-JS size budget for frontmatter schemas The feature ships a JSON Schema validator plus two editor surfaces, so total JS grows. Splitting cannot offset a total-size budget, and this one was already over before the config editors moved behind React.lazy. 3.55 -> 3.6 MB (actual 3.57). The main-chunk budget stays at 485 kB, which the lazy-load brought back under, so initial load is unchanged for sessions that never open a config file. Matches how every recent feature has tracked this budget: the markdownlint Source/Rules toggle, Mermaid WYSIWYG, the Cmd+K palette, ACP agent threads and the in-app feedback form each raised it alongside their change. * chore(open-knowledge): refresh the ng-anchors catalog after the main merge The catalog is a code-as-truth provenance record whose counts track the real source tree. Merging origin/main added files (production 101 to 104, test 215 to 216) without regenerating it, so the freshness test that deep-equals the committed catalog against a fresh build failed. Regenerated via enumerate-ng-anchors; only the counts and generatedAt move. * fix(open-knowledge): make the enum free-text drop terminal enumConstraintsForDoc dropped a field to free text when two schemas disagreed, but the drop was not sticky: a third schema hit the "no existing constraint" branch and re-added its own vocabulary. With A offering [x,y], B mismatching on multi-ness, and C offering [z], the property panel ended up offering [z] — a value A forbids — and which schema won depended on entry order. That contradicts the module's contract that an offered value satisfies every governing schema. Reachable because schema selection dedups by file key, so distinct files with overlapping appliesTo all govern one doc. Blast radius was the panel only; validateFrontmatterSource always warned independently against every schema. But the panel could steer a user toward a value it had implied was safe. Conflicted fields are now tracked and skipped on later schemas, so the result is order-independent across the whole schema set rather than just pairwise. Both terminal paths (multi-ness mismatch and empty intersection) are covered by tests that fail against the previous behaviour. * fix(open-knowledge): stop the schema listing from re-admitting .ok The frontmatter schema-file listing walked the content tree with the .ok re-admission flag set, which lifts ContentFilter's always-skip floor. That made the listing a second surface able to enumerate OK's internal state, and the showok-caller-coverage gate exists to catch exactly that: the flag is pinned to the tree-listing reveal, and a new consumer is meant to be a deliberate decision rather than a new call site. The re-admission was for consistency with the Show All Files tree, not necessity. The handler's first discovery source already scans .ok/schemas/ directly, so the walk only needs to find the ecosystem *.schema.json convention in ordinary project directories. Dropping it narrows the capability back to the one sanctioned surface; the only discovery lost is a *.schema.json living under .ok/ but outside .ok/schemas/, which the tool never creates. Introduced by f51ef5992 and unnoticed because CI had not run on this branch until the PR existed. 48 integration and meta tests pass, including the frontmatter listing over HTTP. * fix(open-knowledge): converge other windows on a markdownlint rule write handleWriteFrontmatterSchema signals the lint-config CC1 channel after a successful write; its markdownlint sibling never did, so toggling a rule in one window left every other window linting against a stale config until it happened to refetch. This PR is what establishes lint-config as the channel for config convergence, so the sibling write surface belongs with it. Also closes two coverage gaps in code this PR added: a disabled mapping must not reach agents through schemas_applicable, and three schemas must narrow to the running intersection rather than folding against the original values. * docs(open-knowledge): cross-reference the two appliesTo selection sites schemasApplicableToDoc applies the same skip-disabled / match / dedup triad as selectApplicableFrontmatterSchemas, but over persisted mappings rather than resolved entries, because enrichment advertises which files govern a doc without loading them. Nothing links the two, so a change to what governs could land in one and silently miss the other — and only the core one is covered by the validator's tests. GitOrigin-RevId: 86e58ac35b8767d82165ef210136d56d63e165a3 --- .changeset/frontmatter-schema-validation.md | 20 + .../advanced/content-rules/frontmatter.mdx | 126 ++++ .../advanced/content-rules/markdownlint.mdx | 39 +- docs/content/advanced/content-rules/meta.json | 2 +- .../advanced/content-rules/overview.mdx | 173 ++++- docs/content/reference/configuration.mdx | 4 +- docs/content/reference/mcp.mdx | 8 +- packages/app/package.json | 2 +- ...itorArea.lint-config-dispatch.dom.test.tsx | 47 +- packages/app/src/components/EditorArea.tsx | 40 +- .../FrontmatterRow.enum.dom.test.tsx | 107 ++++ .../app/src/components/FrontmatterRow.tsx | 147 ++++- .../src/components/ProblemsPanel.dom.test.tsx | 80 ++- packages/app/src/components/ProblemsPanel.tsx | 70 +- packages/app/src/components/PropertyPanel.tsx | 11 + .../SchemaConfigEditor.dom.test.tsx | 205 ++++++ .../app/src/components/SchemaConfigEditor.tsx | 114 ++++ .../src/components/SystemDocSubscriber.tsx | 8 + .../FrontmatterPluginSection.dom.test.tsx | 311 +++++++++ .../settings/LintingSection.dom.test.tsx | 7 +- .../components/settings/LintingSection.tsx | 492 +++++++++++++- .../components/settings/PluginBetaBadge.tsx | 18 + ...ontmatter-schema-field-editor.dom.test.tsx | 286 +++++++++ .../frontmatter-schema-field-editor.tsx | 606 ++++++++++++++++++ .../components/settings/lint-plugin-meta.ts | 5 +- .../src/components/settings/lint-plugins.tsx | 3 +- packages/app/src/editor/lint-config-client.ts | 179 +++++- .../src/editor/useDocDiagnostics.dom.test.tsx | 5 +- .../lib/frontmatter-enum-constraints.test.ts | 183 ++++++ .../src/lib/frontmatter-enum-constraints.ts | 84 +++ .../app/src/lib/schema-fields-view-intent.ts | 24 + packages/app/src/lib/use-settings-route.ts | 14 + packages/app/src/locales/en/messages.json | 87 +++ packages/app/src/locales/en/messages.po | 297 +++++++++ packages/app/src/locales/pseudo/messages.json | 87 +++ packages/app/src/locales/pseudo/messages.po | 297 +++++++++ .../attribution-sweep-coverage.test.ts | 6 + .../conflict-gate-coverage.test.ts | 6 + .../integration/frontmatter-lint-http.test.ts | 338 ++++++++++ .../app/tests/integration/test-harness.ts | 12 +- packages/cli/src/commands/lint.test.ts | 28 + packages/cli/src/content/lint-runner.test.ts | 69 +- packages/cli/src/content/lint-runner.ts | 23 +- packages/core/package.json | 6 +- .../core/src/config/field-registry.test.ts | 2 + packages/core/src/config/schema.ts | 43 +- packages/core/src/index.ts | 25 + packages/core/src/markdown/lint/applies-to.ts | 219 +++++++ .../core/src/markdown/lint/config-schemas.ts | 85 ++- .../markdown/lint/frontmatter-schema-edit.ts | 228 +++++++ .../src/markdown/lint/frontmatter-validate.ts | 206 ++++++ packages/core/src/markdown/lint/index.ts | 37 +- packages/core/src/markdown/lint/plugins.ts | 30 +- packages/core/src/markdown/lint/types.ts | 18 +- packages/core/src/schemas/cc1.ts | 9 + packages/server/src/api-extension.ts | 217 ++++++- .../server/src/content/enrichment.test.ts | 76 +++ packages/server/src/content/enrichment.ts | 94 ++- packages/server/src/index.ts | 1 + .../server/src/lint/audit-symlink.test.ts | 5 +- packages/server/src/lint/audit.test.ts | 5 +- packages/server/src/lint/audit.ts | 109 +++- .../src/lint/frontmatter-schema-write.test.ts | 289 +++++++++ .../src/lint/frontmatter-schema-write.ts | 220 +++++++ .../src/lint/frontmatter-schemas.test.ts | 331 ++++++++++ .../server/src/lint/frontmatter-schemas.ts | 221 +++++++ packages/server/src/lint/fs-safety.ts | 33 + .../server/src/lint/markdownlint-discovery.ts | 6 +- .../server/src/lint/markdownlint-write.ts | 22 +- packages/server/src/lint/resolve-config.ts | 38 +- .../server/src/lint/validation-audit.test.ts | 5 +- packages/server/src/mcp/tools/exec.test.ts | 61 ++ packages/server/src/mcp/tools/exec.ts | 22 +- packages/server/src/mcp/tools/lint.ts | 17 +- packages/server/src/server-factory.ts | 12 +- pnpm-lock.yaml | 23 +- 76 files changed, 7233 insertions(+), 152 deletions(-) create mode 100644 .changeset/frontmatter-schema-validation.md create mode 100644 docs/content/advanced/content-rules/frontmatter.mdx create mode 100644 packages/app/src/components/FrontmatterRow.enum.dom.test.tsx create mode 100644 packages/app/src/components/SchemaConfigEditor.dom.test.tsx create mode 100644 packages/app/src/components/SchemaConfigEditor.tsx create mode 100644 packages/app/src/components/settings/FrontmatterPluginSection.dom.test.tsx create mode 100644 packages/app/src/components/settings/PluginBetaBadge.tsx create mode 100644 packages/app/src/components/settings/frontmatter-schema-field-editor.dom.test.tsx create mode 100644 packages/app/src/components/settings/frontmatter-schema-field-editor.tsx create mode 100644 packages/app/src/lib/frontmatter-enum-constraints.test.ts create mode 100644 packages/app/src/lib/frontmatter-enum-constraints.ts create mode 100644 packages/app/src/lib/schema-fields-view-intent.ts create mode 100644 packages/app/tests/integration/frontmatter-lint-http.test.ts create mode 100644 packages/core/src/markdown/lint/applies-to.ts create mode 100644 packages/core/src/markdown/lint/frontmatter-schema-edit.ts create mode 100644 packages/core/src/markdown/lint/frontmatter-validate.ts create mode 100644 packages/server/src/lint/frontmatter-schema-write.test.ts create mode 100644 packages/server/src/lint/frontmatter-schema-write.ts create mode 100644 packages/server/src/lint/frontmatter-schemas.test.ts create mode 100644 packages/server/src/lint/frontmatter-schemas.ts create mode 100644 packages/server/src/lint/fs-safety.ts diff --git a/.changeset/frontmatter-schema-validation.md b/.changeset/frontmatter-schema-validation.md new file mode 100644 index 000000000..1268db1f7 --- /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 000000000..b0436e643 --- /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 24a2f88c4..6ef47bb0c 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 0890441d4..0f6bf0d61 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 dd203d1d0..4f857e645 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 ac31b5871..5828438d5 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 65788d8e1..b7b165ad6 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 ae8971bbd..926ed56ee 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 a6d657234..5e43a2b69 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 0bedb75ed..b9a700720 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 000000000..28fb23ffe --- /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 e6dbf6839..4a4da5401 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}
- + {enumConstraint && !isComplex && !enumConstraint.multi ? ( + + ) : enumConstraint && !isComplex && enumConstraint.multi ? ( + + ) : ( + + )}
{badge ?
{badge}
: 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 ( +
+ {[...values, ...extras].map((option) => { + const on = selected.includes(option); + return ( + + ); + })} +
+ ); +} diff --git a/packages/app/src/components/ProblemsPanel.dom.test.tsx b/packages/app/src/components/ProblemsPanel.dom.test.tsx index bcc780537..5f4a4774d 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 d67b324c5..f3e6c3696 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 e18e10ac7..8bee64c86 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 000000000..54796d4ec --- /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 000000000..77f71e298 --- /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 2b01d02e1..04ca6ccb4 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 000000000..80dc5b48d --- /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 709d1a0d4..cf47a91f9 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 040ddf396..5d44e8758 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 ( +

+ Applies to{' '} + {includes.length === 0 ? ( + + ) : ( + list(includes) + )} + {excludes.length > 0 ? ( + <> + {' '} + — except {list(excludes)} + + ) : null} + . +

+ ); +} + +/** Absent `enabled` = on — back-compat with configs written before the toggle. */ +function mappingEnabled(mapping: FrontmatterSchemaMapping): boolean { + return mapping.enabled !== false; +} + +function SchemaFileRow({ + file, + mapping, + disabled, + onToggle, + onAppliesToChange, + onReset, + onDelete, +}: { + file: string; + mapping: FrontmatterSchemaMapping | undefined; + disabled: boolean; + onToggle: (on: boolean) => void; + onAppliesToChange: (globs: string[]) => void; + onReset: () => void; + onDelete: (() => void) | null; +}) { + const { t } = useLingui(); + const on = mapping !== undefined && mappingEnabled(mapping); + const modified = mapping !== undefined; + + // The Edit button is the row's ONLY way into the file. Hash nav is OK's + // source of truth: it activates (or re-activates) the file's tab AND closes + // the hash-driven Settings dialog. The one-shot intent lands the schema + // editor on its Fields view — a Settings open is an editing gesture, + // whatever the persisted Source/Fields preference. + const openSchemaFile = () => { + requestSchemaFieldsView(file); + window.location.hash = hashFromAssetPath(file); + }; + + return ( +
+
+ + {file} + +
+ + {modified ? ( + + Modified + + ) : null} + {modified ? ( + + ) : null} + {onDelete !== null ? ( + + ) : null} + +
+
+ {on ? ( +
+ + + +
+ ) : null} +
+ ); +} + +/** + * Frontmatter plugin panel: a browser over every schema file in the project + * (discovered `*.schema.json` + `.ok/schemas/*.json` + anything config.yml + * maps), mirroring the markdownlint rule browser's UX. Toggling a schema on + * writes an `enabled: true` mapping to `contentRules.frontmatter.schemas`; + * off keeps the mapping (and its appliesTo) with `enabled: false`; reset + * wipes the mapping. The Edit button opens the file itself in the editor — + * field editing lives on the file surface, not here. + */ +export function FrontmatterPluginSection() { + const { t } = useLingui(); + const { contentRules, bindingReady, write } = useLinterConfig(); + const { data } = useProjectLintConfig(); + const { schemas: discovered } = useFrontmatterSchemaFiles(); + const mappings = contentRules?.frontmatter?.schemas ?? []; + const [search, setSearch] = useState(''); + const [onlyModified, setOnlyModified] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [newSchemaName, setNewSchemaName] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + // The config channel carries every plugin's problems; show the frontmatter + // ones where the mappings are managed. Per-mapping problems are gated on the + // file still being referenced by a LIVE mapping (the server composes from + // the on-disk config.yml, which lags a just-committed CRDT edit by the + // persistence debounce). The prefix strings are the compose contract in the + // server's frontmatter-schemas.ts — keep in sync on either-side change. + const mappedFiles = new Set(mappings.map((m) => m.file)); + const problems = (data?.configProblems ?? []).filter((p) => { + if (p.startsWith('frontmatter schema ')) { + return [...mappedFiles].some((file) => p.startsWith(`frontmatter schema ${file}:`)); + } + if ( + p.startsWith('invalid appliesTo glob') || + p.startsWith('suspicious appliesTo glob') || + p.startsWith('unmatched appliesTo glob') + ) { + return [...mappedFiles].some((file) => p.endsWith(`(frontmatter mapping for ${file})`)); + } + return false; + }); + + function writeMappings(next: FrontmatterSchemaMapping[]): void { + if (write({ frontmatter: { schemas: next } } as ConfigPatch['contentRules'])) { + emitLintConfigChanged(); + } + } + + // One row per file; the FIRST mapping for a file is the row's binding + // (hand-authored duplicates keep validating, untouched). + const files = [...new Set([...discovered, ...mappings.map((m) => m.file)])].sort((a, b) => + a.localeCompare(b), + ); + const query = search.trim().toLowerCase(); + const visible = files.filter( + (f) => + (query === '' || f.toLowerCase().includes(query)) && + (!onlyModified || mappings.some((m) => m.file === f)), + ); + + function toggleFile(file: string, on: boolean): void { + if (!mappings.some((m) => m.file === file)) { + if (on) writeMappings([...mappings, { file, enabled: true }]); + return; + } + writeMappings(mappings.map((m) => (m.file === file ? { ...m, enabled: on } : m))); + } + + function setAppliesTo(file: string, globs: string[]): void { + writeMappings( + mappings.map((m) => { + if (m.file !== file) return m; + if (globs.length === 0) { + const { appliesTo: _cleared, ...rest } = m; + return rest; + } + return { ...m, appliesTo: globs }; + }), + ); + } + + async function deleteSchemaFile(file: string): Promise { + setDeleting(true); + const result = await deleteFrontmatterSchema(file); + setDeleting(false); + if (!result.ok) { + toast.error(result.errorDetail ?? t`Failed to delete ${file}`); + return; + } + // The file is gone — its mapping (if any) would only produce a broken + // reference, so wipe it in the same gesture. + if (mappings.some((m) => m.file === file)) { + writeMappings(mappings.filter((m) => m.file !== file)); + } + setDeleteTarget(null); + toast.success(t`Deleted ${file}`); + } + + async function createSchema(): Promise { + const name = newSchemaName.trim(); + if (name === '' || name.includes('/') || name.includes('\\')) return; + const file = `.ok/schemas/${name.endsWith('.json') ? name : `${name}.schema.json`}`; + const result = await createEmptyFrontmatterSchema(file); + if (!result.ok) { + toast.error(result.errorDetail ?? t`Failed to create the schema file`); + return; + } + // A schema someone just created is a schema they mean to use — map it on. + writeMappings([...mappings, { file, enabled: true }]); + setCreateOpen(false); + setNewSchemaName(''); + } + + return ( +
+ + + Validate document frontmatter against standard JSON Schema files (draft-07). Toggle a + schema on to validate the docs its globs match; violations surface as warnings and never + block a write. Use Edit to open the schema file. + + + + {problems.length > 0 && ( +
+

+ Configuration problems +

+
    + {problems.map((problem) => ( +
  • {problem}
  • + ))} +
+
+ )} + +

+ + A Modified badge marks schemas your + config maps — on or off. Reset removes the mapping from config.yml. + +

+ +
+ setSearch(e.target.value)} + placeholder={t`Search schemas`} + aria-label={t`Search schema files by path`} + className="h-8" + data-testid="frontmatter-schema-search" + /> +
+ setOnlyModified(next === true)} + data-testid="frontmatter-only-modified" + /> + +
+ + + + + + + setNewSchemaName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void createSchema(); + }} + placeholder="doc" + data-testid="frontmatter-create-schema-name" + /> + + + +
+ + {visible.length === 0 ? ( +

+ {files.length === 0 ? ( + No schema files in this project yet — create one to start validating. + ) : ( + No schemas match your search. + )} +

+ ) : ( +
+ {visible.map((file) => ( + m.file === file)} + disabled={!bindingReady} + onToggle={(on) => toggleFile(file, on)} + onAppliesToChange={(globs) => setAppliesTo(file, globs)} + onReset={() => writeMappings(mappings.filter((m) => m.file !== file))} + onDelete={isFrontmatterSchemaAsset(file) ? () => setDeleteTarget(file) : null} + /> + ))} +
+ )} + { + if (!open && !deleting) setDeleteTarget(null); + }} + > + {deleteTarget !== null ? ( + void deleteSchemaFile(deleteTarget)} + customDescription={t`This permanently deletes ${deleteTarget} from the project and removes its mapping from config.yml. Docs it validated keep their frontmatter; they just stop being checked.`} + /> + ) : null} + +
+ ); +} diff --git a/packages/app/src/components/settings/PluginBetaBadge.tsx b/packages/app/src/components/settings/PluginBetaBadge.tsx new file mode 100644 index 000000000..ea228256f --- /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 000000000..b0a87975c --- /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 000000000..c3b9a37bd --- /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 ( +
+ {Object.entries(properties).map(([field, rawProperty]) => ( + + ))} + ops.save(name, { type: 'string' }, parentPath)} + /> +
+ ); +} + +function FieldRow({ + field, + property, + isRequired, + parentPath, + depth, + ops, +}: { + field: string; + property: Record; + isRequired: boolean; + parentPath: readonly SchemaParentPathSegment[]; + depth: number; + ops: FieldOps; +}) { + const { t } = useLingui(); + // The user picked the `enum` pseudo-type but hasn't entered values yet — + // keeps the select from snapping back to the concrete type until the first + // allowed value persists. + const [enumIntent, setEnumIntent] = useState(false); + // Same buffer for the items-type select's enum choice. + const [itemsEnumIntent, setItemsEnumIntent] = useState(false); + + const pathKey = pathKeyOf(parentPath, field); + const type = + typeof property.type === 'string' && (FIELD_TYPES as readonly string[]).includes(property.type) + ? (property.type as FieldType) + : undefined; + const enumValues = stringEnum(property.enum); + const items = isRecord(property.items) ? property.items : {}; + const itemsEnumValues = stringEnum(items.enum); + const pattern = typeof property.pattern === 'string' ? property.pattern : ''; + const description = typeof property.description === 'string' ? property.description : ''; + const preserved = hasUnmodeledKeywords(property); + const showAsEnum = + type !== 'array' && enumValues !== null && (enumValues.length > 0 || enumIntent); + const showChildren = type === 'object' && depth < MAX_NESTING_DEPTH; + const itemsType = + typeof items.type === 'string' && + (ITEMS_TYPE_SELECT_OPTIONS as readonly string[]).includes(items.type) + ? (items.type as ItemsType) + : undefined; + const showItemsAsEnum = + itemsType !== 'object' && + itemsEnumValues !== null && + (itemsEnumValues.length > 0 || itemsEnumIntent); + const showItemChildren = type === 'array' && itemsType === 'object' && depth < MAX_NESTING_DEPTH; + + return ( +
+
+ {(showAsEnum || type !== undefined) && ( + + )} + { + if (next !== '' && next !== field) ops.rename(field, next, parentPath); + }} + /> + + ops.save(field, { description: next === '' ? null : next }, parentPath) + } + /> + + ops.save(field, { required: next }, parentPath)} + data-testid={`frontmatter-field-required-${pathKey}`} + /> + +
+ {preserved && ( +

+ + This field carries additional schema keywords the editor does not show — they are + preserved on save. + +

+ )} +
+
+ + +
+
+ + ops.save(field, { pattern: next === '' ? null : next }, parentPath)} + /> +
+
+ {type !== 'array' && type !== 'object' && enumValues !== null && ( +
+ + + ops.save(field, { enum: next.length === 0 ? null : next }, parentPath) + } + placeholder={t`Add value`} + aria-label={t`Allowed values for ${field}`} + /> +
+ )} + {type === 'array' && ( +
+
+ + +
+
+ )} + {type === 'array' && itemsType !== 'object' && itemsEnumValues !== null && ( +
+ + + ops.save(field, { itemsEnum: next.length === 0 ? null : next }, parentPath) + } + placeholder={t`Add value`} + aria-label={t`Allowed element values for ${field}`} + /> +
+ )} + {showItemChildren && ( +
+

+ Element fields +

+ +
+ )} + {showChildren && ( +
+

+ Nested fields +

+ +
+ )} +
+ ); +} + +/** Name input + Add button for one object level; owns its draft state. */ +function AddFieldInput({ inputId, onAdd }: { inputId: string; onAdd: (name: string) => void }) { + const [name, setName] = useState(''); + return ( +
+
+ + setName(e.target.value)} + placeholder="owner" + data-testid={`${inputId}-input`} + /> +
+ +
+ ); +} + +/** Commit-on-blur/Enter buffer so a keystroke doesn't write the schema file. */ +function CommitInput({ + id, + initial, + onCommit, + placeholder, + ariaLabel, + className, +}: { + id: string; + initial: string; + onCommit: (value: string) => void; + placeholder?: string; + ariaLabel?: string; + className?: string; +}) { + const [draft, setDraft] = useState(initial); + const commit = () => { + if (draft.trim() !== initial) onCommit(draft.trim()); + }; + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + }} + placeholder={placeholder} + aria-label={ariaLabel} + className={className} + data-testid={id} + /> + ); +} diff --git a/packages/app/src/components/settings/lint-plugin-meta.ts b/packages/app/src/components/settings/lint-plugin-meta.ts index 709117892..9cc16ea84 100644 --- a/packages/app/src/components/settings/lint-plugin-meta.ts +++ b/packages/app/src/components/settings/lint-plugin-meta.ts @@ -13,4 +13,7 @@ export interface LintPluginMeta { label: string; } -export const LINT_PLUGIN_META: LintPluginMeta[] = [{ id: 'markdownlint', label: 'markdownlint' }]; +export const LINT_PLUGIN_META: LintPluginMeta[] = [ + { id: 'markdownlint', label: 'markdownlint' }, + { id: 'frontmatter', label: 'Frontmatter schemas' }, +]; diff --git a/packages/app/src/components/settings/lint-plugins.tsx b/packages/app/src/components/settings/lint-plugins.tsx index 8d8daa57d..9ffd00f61 100644 --- a/packages/app/src/components/settings/lint-plugins.tsx +++ b/packages/app/src/components/settings/lint-plugins.tsx @@ -6,13 +6,14 @@ * future plugin's GUI = one `LINT_PLUGIN_META` entry + its Section in the map. */ import type { ReactNode } from 'react'; -import { MarkdownlintPluginSection } from './LintingSection'; +import { FrontmatterPluginSection, MarkdownlintPluginSection } from './LintingSection'; import { LINT_PLUGIN_META, type LintPluginMeta } from './lint-plugin-meta'; type PluginSection = () => ReactNode; const SECTIONS: Record = { markdownlint: MarkdownlintPluginSection, + frontmatter: FrontmatterPluginSection, }; export interface LintPluginUi extends LintPluginMeta { diff --git a/packages/app/src/editor/lint-config-client.ts b/packages/app/src/editor/lint-config-client.ts index f296ea409..23a08e9e5 100644 --- a/packages/app/src/editor/lint-config-client.ts +++ b/packages/app/src/editor/lint-config-client.ts @@ -6,12 +6,15 @@ */ import { + type FrontmatterFieldConstraint, + FrontmatterSchemasListSuccessSchema, type LintConfigResponse, LintConfigResponseSchema, type LinterConfig, type LintFixResult, LintFixResultSchema, type MarkdownlintRuleWriteValue, + type SchemaParentPathSegment, } from '@inkeep/open-knowledge-core'; import { useEffect, useState } from 'react'; @@ -83,7 +86,7 @@ export async function fixLintDoc( const body = await res.json().catch(() => null); const parsed = LintFixResultSchema.safeParse(body); if (!parsed.success) { - // Mirror the sibling fetchLintConfig/runLintAudit logging so a + // Mirror the sibling fetchLintConfig/runValidationAudit logging so a // client/server schema drift leaves a diagnostic trail instead of a // silent failure. console.warn('[lint] fix response failed schema validation', parsed.error.issues); @@ -131,6 +134,180 @@ export async function writeMarkdownlintRule( } } +/** + * POST one request to the frontmatter schema write endpoint (the five shapes + * the request schema refines: per-field edit, removeField, renameTo, + * create-empty, delete). `undefined` members drop out of the JSON body. + * Returns the recomputed effective config on success. + */ +async function postFrontmatterSchema(body: { + file: string; + delete?: true; + field?: string; + constraint?: FrontmatterFieldConstraint; + removeField?: true; + renameTo?: string; + parentPath?: readonly SchemaParentPathSegment[]; +}): Promise< + { ok: true; response: LintConfigResponse } | { ok: false; errorDetail: string | null } +> { + try { + const res = await fetch('/api/lint/frontmatter-schema', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { title?: unknown } | null; + return { + ok: false, + errorDetail: typeof errBody?.title === 'string' ? errBody.title : null, + }; + } + const parsed = LintConfigResponseSchema.safeParse(await res.json().catch(() => null)); + return parsed.success ? { ok: true, response: parsed.data } : { ok: false, errorDetail: null }; + } catch { + return { ok: false, errorDetail: null }; + } +} + +/** Empty parentPath drops off the wire (the schema treats absent as the root). */ +function wireParentPath( + parentPath: readonly SchemaParentPathSegment[], +): readonly SchemaParentPathSegment[] | undefined { + return parentPath.length > 0 ? parentPath : undefined; +} + +/** + * POST one field-constraint change to a frontmatter schema file (create-on- + * first-edit; non-destructive merge — advanced keywords survive). Returns the + * recomputed effective config. Callers pair a success with + * `emitLintConfigChanged()` so open editors re-lint. + */ +export async function writeFrontmatterSchemaField( + file: string, + field: string, + constraint: FrontmatterFieldConstraint, + parentPath: readonly SchemaParentPathSegment[] = [], +): Promise<{ ok: true; response: LintConfigResponse } | { ok: false; errorDetail: string | null }> { + return postFrontmatterSchema({ file, field, constraint, parentPath: wireParentPath(parentPath) }); +} + +/** + * GET the project's existing frontmatter schema files (`.ok/schemas/*.json`, + * project-root-relative) for the mapping file-picker. Empty list on any + * failure (server down, no `.ok/schemas/` yet) — the picker degrades to plain + * free-text entry. + */ +async function listFrontmatterSchemas(): Promise { + try { + const res = await fetch('/api/lint/frontmatter-schemas'); + if (!res.ok) return []; + const body = await res.json().catch(() => null); + const parsed = FrontmatterSchemasListSuccessSchema.safeParse(body); + if (!parsed.success) { + console.warn( + '[lint] frontmatter-schemas response failed schema validation', + parsed.error.issues, + ); + return []; + } + return parsed.data.schemas; + } catch { + return []; + } +} + +/** + * POST a create-empty request to the schema write endpoint (no field) — + * scaffolds `` with the draft-07 skeleton if it doesn't exist yet, so + * the picker's "create new schema" lands a real, valid file. Idempotent + * server-side (an existing file is left untouched). Emits + * `emitLintConfigChanged()` on success so the picker list + open editors refresh. + */ +export async function createEmptyFrontmatterSchema( + file: string, +): Promise<{ ok: true } | { ok: false; errorDetail: string | null }> { + const result = await postFrontmatterSchema({ file }); + if (result.ok) emitLintConfigChanged(); + return result; +} + +/** + * POST a remove-field request — drops the field's properties entry and + * required membership from the schema file. Callers pair a success with + * `emitLintConfigChanged()`. + */ +export async function removeFrontmatterSchemaField( + file: string, + field: string, + parentPath: readonly SchemaParentPathSegment[] = [], +): Promise<{ ok: true } | { ok: false; errorDetail: string | null }> { + return postFrontmatterSchema({ + file, + field, + removeField: true, + parentPath: wireParentPath(parentPath), + }); +} + +/** + * POST a rename-field request — carries the field's full property object + * (including keywords the GUI does not model) and its required membership to + * the new name. The server refuses a rename onto an existing field. Callers + * pair a success with `emitLintConfigChanged()`. + */ +export async function renameFrontmatterSchemaField( + file: string, + field: string, + renameTo: string, + parentPath: readonly SchemaParentPathSegment[] = [], +): Promise<{ ok: true } | { ok: false; errorDetail: string | null }> { + return postFrontmatterSchema({ file, field, renameTo, parentPath: wireParentPath(parentPath) }); +} + +/** + * POST a delete request to the schema write endpoint — removes a schema file + * (`*.schema.json` anywhere, or `.ok/schemas/*.json`; the server refuses + * anything else). Idempotent server-side. Emits `emitLintConfigChanged()` on + * success so the browser list + open editors refresh. + */ +export async function deleteFrontmatterSchema( + file: string, +): Promise<{ ok: true } | { ok: false; errorDetail: string | null }> { + const result = await postFrontmatterSchema({ file, delete: true }); + if (result.ok) emitLintConfigChanged(); + return result; +} + +/** + * Live list of the project's `.ok/schemas/*.json` files for the mapping + * picker. Refetches on mount and on any `lint-config-changed` event (e.g. + * after `createEmptyFrontmatterSchema` writes a new file); `refresh` lets a + * caller re-pull on demand (e.g. when the picker popover opens). + */ +export function useFrontmatterSchemaFiles(): { schemas: string[]; refresh: () => void } { + const [schemas, setSchemas] = useState([]); + const refresh = () => { + void listFrontmatterSchemas().then(setSchemas); + }; + useEffect(() => { + let cancelled = false; + const load = () => { + void listFrontmatterSchemas().then((next) => { + if (!cancelled) setSchemas(next); + }); + }; + load(); + const unsub = subscribeToLintConfigChanged(load); + return () => { + cancelled = true; + unsub(); + }; + }, []); + return { schemas, refresh }; +} + /** * Live per-doc lint config. Refetches when `docName` changes and on any * `lint-config-changed` event (e.g. after a native-rule write in Settings). diff --git a/packages/app/src/editor/useDocDiagnostics.dom.test.tsx b/packages/app/src/editor/useDocDiagnostics.dom.test.tsx index 1b134fd31..02832eb4a 100644 --- a/packages/app/src/editor/useDocDiagnostics.dom.test.tsx +++ b/packages/app/src/editor/useDocDiagnostics.dom.test.tsx @@ -26,7 +26,10 @@ function fakeProvider(initial: string): { provider: HocuspocusProvider; doc: Y.D const enabled: LinterConfig = { ...DEFAULT_LINTER_CONFIG, enabled: true, - plugins: { markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true } }, + plugins: { + ...DEFAULT_LINTER_CONFIG.plugins, + markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true }, + }, }; afterEach(() => cleanup()); diff --git a/packages/app/src/lib/frontmatter-enum-constraints.test.ts b/packages/app/src/lib/frontmatter-enum-constraints.test.ts new file mode 100644 index 000000000..0b3f4699e --- /dev/null +++ b/packages/app/src/lib/frontmatter-enum-constraints.test.ts @@ -0,0 +1,183 @@ +import type { LinterConfig } from '@inkeep/open-knowledge-core'; +import { describe, expect, test } from 'vitest'; +import { enumConstraintsForDoc } from './frontmatter-enum-constraints.ts'; + +function configWith( + schemas: { + appliesTo?: string | string[]; + file: string; + key?: string; + enabled?: boolean; + schema?: Record; + }[], + enabled = true, +): LinterConfig { + return { + enabled: true, + plugins: { + markdownlint: { enabled: false, rules: {} }, + frontmatter: { enabled, schemas }, + }, + }; +} + +const DOC_SCHEMA = { + type: 'object', + properties: { + status: { enum: ['draft', 'review', 'published'] }, + tags: { type: 'array', items: { enum: ['a', 'b'] } }, + owner: { type: 'string' }, + }, +}; + +describe('enumConstraintsForDoc', () => { + test('enum fields map to single-select; array items.enum to multi', () => { + const constraints = enumConstraintsForDoc( + configWith([{ appliesTo: 'docs/**', file: 'd.json', schema: DOC_SCHEMA }]), + 'docs/guide', + ); + expect(constraints.get('status')).toEqual({ + values: ['draft', 'review', 'published'], + multi: false, + }); + expect(constraints.get('tags')).toEqual({ values: ['a', 'b'], multi: true }); + expect(constraints.has('owner')).toBe(false); + }); + + test('a non-matching doc, a disabled plugin, and no doc identity all yield nothing', () => { + const config = configWith([{ appliesTo: 'docs/**', file: 'd.json', schema: DOC_SCHEMA }]); + expect(enumConstraintsForDoc(config, 'specs/other').size).toBe(0); + expect(enumConstraintsForDoc(configWith([], false), 'docs/guide').size).toBe(0); + expect(enumConstraintsForDoc(config, undefined).size).toBe(0); + expect(enumConstraintsForDoc(null, 'docs/guide').size).toBe(0); + }); + + test('a toggled-off mapping constrains nothing, matching the lint plugin', () => { + const constraints = enumConstraintsForDoc( + configWith([{ appliesTo: 'docs/**', file: 'd.json', enabled: false, schema: DOC_SCHEMA }]), + 'docs/guide', + ); + expect(constraints.size).toBe(0); + }); + + test('conjunction intersects vocabularies across matching schemas', () => { + const constraints = enumConstraintsForDoc( + configWith([ + { + file: 'a.json', + key: 'A', + schema: { properties: { status: { enum: ['draft', 'review', 'published'] } } }, + }, + { + file: 'b.json', + key: 'B', + schema: { properties: { status: { enum: ['review', 'published', 'archived'] } } }, + }, + ]), + 'docs/guide', + ); + expect(constraints.get('status')).toEqual({ values: ['review', 'published'], multi: false }); + }); + + test('an empty intersection drops to free text', () => { + const constraints = enumConstraintsForDoc( + configWith([ + { file: 'a.json', key: 'A', schema: { properties: { status: { enum: ['x'] } } } }, + { file: 'b.json', key: 'B', schema: { properties: { status: { enum: ['y'] } } } }, + ]), + 'docs/guide', + ); + expect(constraints.has('status')).toBe(false); + }); + + test('mismatched multi-ness across schemas drops to free text', () => { + // Vocabularies are deliberately identical, so only the multi-ness guard can + // cause the drop — a scalar enum and an array items.enum cannot both be + // satisfied by one widget, and picking either would misrepresent a schema. + const single = { properties: { tags: { enum: ['x', 'y'] } } }; + const multi = { properties: { tags: { type: 'array', items: { enum: ['x', 'y'] } } } }; + expect( + enumConstraintsForDoc( + configWith([ + { file: 'a.json', key: 'A', schema: single }, + { file: 'b.json', key: 'B', schema: multi }, + ]), + 'docs/guide', + ).has('tags'), + ).toBe(false); + // Order-independent: whichever is seen first must not decide the shape. + expect( + enumConstraintsForDoc( + configWith([ + { file: 'b.json', key: 'B', schema: multi }, + { file: 'a.json', key: 'A', schema: single }, + ]), + 'docs/guide', + ).has('tags'), + ).toBe(false); + }); + + test('three schemas narrow progressively to the running intersection', () => { + // A∩B∩C is non-empty, so the field keeps a vocabulary. Folding against the + // ORIGINAL values instead of the running intersection would leave 'b' in. + const a = { properties: { status: { enum: ['a', 'b', 'c'] } } }; + const b = { properties: { status: { enum: ['a', 'b'] } } }; + const c = { properties: { status: { enum: ['a', 'c'] } } }; + const constraints = enumConstraintsForDoc( + configWith([ + { file: 'a.json', key: 'A', schema: a }, + { file: 'b.json', key: 'B', schema: b }, + { file: 'c.json', key: 'C', schema: c }, + ]), + 'docs/guide', + ); + expect(constraints.get('status')).toEqual({ values: ['a'], multi: false }); + }); + + test('a conflict is terminal — a later schema cannot resurrect a vocabulary', () => { + // A offers [x,y] as a single-select, B is multi (mismatch, drops to free + // text), then C offers [z]. If the drop is not terminal, C re-enters the + // "no existing constraint" branch and the panel offers [z] — a value A + // forbids, which is exactly the guarantee this module makes. + const single = { properties: { tags: { enum: ['x', 'y'] } } }; + const multi = { properties: { tags: { type: 'array', items: { enum: ['x', 'y'] } } } }; + const other = { properties: { tags: { enum: ['z'] } } }; + expect( + enumConstraintsForDoc( + configWith([ + { file: 'a.json', key: 'A', schema: single }, + { file: 'b.json', key: 'B', schema: multi }, + { file: 'c.json', key: 'C', schema: other }, + ]), + 'docs/guide', + ).has('tags'), + ).toBe(false); + }); + + test('an empty intersection is terminal too', () => { + const x = { properties: { status: { enum: ['x'] } } }; + const y = { properties: { status: { enum: ['y'] } } }; + const z = { properties: { status: { enum: ['z'] } } }; + expect( + enumConstraintsForDoc( + configWith([ + { file: 'a.json', key: 'A', schema: x }, + { file: 'b.json', key: 'B', schema: y }, + { file: 'c.json', key: 'C', schema: z }, + ]), + 'docs/guide', + ).has('status'), + ).toBe(false); + }); + + test('duplicate entries for one file (same key) count once', () => { + const constraints = enumConstraintsForDoc( + configWith([ + { appliesTo: '**', file: 'd.json', key: 'K', schema: DOC_SCHEMA }, + { appliesTo: 'docs/**', file: './d.json', key: 'K', schema: DOC_SCHEMA }, + ]), + 'docs/guide', + ); + expect(constraints.get('status')?.values).toEqual(['draft', 'review', 'published']); + }); +}); diff --git a/packages/app/src/lib/frontmatter-enum-constraints.ts b/packages/app/src/lib/frontmatter-enum-constraints.ts new file mode 100644 index 000000000..1b5180d5f --- /dev/null +++ b/packages/app/src/lib/frontmatter-enum-constraints.ts @@ -0,0 +1,84 @@ +/** + * Derive per-field enum constraints for the property panel from the RESOLVED + * lint config — the same schemas and the same `appliesTo` matching the linter + * uses, so the panel and the diagnostics can never disagree about which + * schemas govern a doc. Schemas are data; this consumer derives select + * vocabularies from them and nothing else. + * + * Merge across multiple matching schemas is the conjunction's intersection: a + * value the panel offers must satisfy every governing schema. An empty + * intersection falls back to no constraint (free text) — the linter still + * reports the conflict; the panel must not offer a vocabulary that cannot + * validate. + */ + +import { type LinterConfig, selectApplicableFrontmatterSchemas } from '@inkeep/open-knowledge-core'; + +export interface FieldEnumConstraint { + values: string[]; + /** True for `array` fields constrained via `items.enum` (multi-select). */ + multi: boolean; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function stringValues(value: unknown): string[] | null { + if (!Array.isArray(value) || value.length === 0) return null; + return value.every((entry) => typeof entry === 'string') ? (value as string[]) : null; +} + +function constraintFromProperty(property: Record): FieldEnumConstraint | null { + const direct = stringValues(property.enum); + if (direct) return { values: direct, multi: false }; + if (property.type === 'array' && isRecord(property.items)) { + const items = stringValues(property.items.enum); + if (items) return { values: items, multi: true }; + } + return null; +} + +/** Per-field enum constraints for `docName` under the resolved config. */ +export function enumConstraintsForDoc( + config: LinterConfig | null, + docName: string | undefined, +): Map { + const constraints = new Map(); + // Fields a conflict already dropped to free text. Terminal: without it, a + // later schema sees no existing constraint and re-adds its own vocabulary, + // so the offered values would depend on schema order and could violate a + // schema seen earlier. + const droppedToFreeText = new Set(); + const slice = config?.plugins.frontmatter; + if (!config?.enabled || !slice?.enabled || docName === undefined || docName === '') { + return constraints; + } + for (const { schema } of selectApplicableFrontmatterSchemas(slice.schemas, docName)) { + const properties = isRecord(schema.properties) ? schema.properties : {}; + for (const [field, rawProperty] of Object.entries(properties)) { + if (!isRecord(rawProperty)) continue; + const next = constraintFromProperty(rawProperty); + if (!next) continue; + if (droppedToFreeText.has(field)) continue; + const existing = constraints.get(field); + if (existing === undefined) { + constraints.set(field, next); + continue; + } + // Conjunction: intersect vocabularies. Mismatched multi-ness or an + // empty intersection means no offerable vocabulary — drop to free text. + if (existing.multi !== next.multi) { + constraints.delete(field); + droppedToFreeText.add(field); + continue; + } + const intersection = existing.values.filter((value) => next.values.includes(value)); + if (intersection.length === 0) { + constraints.delete(field); + droppedToFreeText.add(field); + } else constraints.set(field, { values: intersection, multi: existing.multi }); + } + } + return constraints; +} diff --git a/packages/app/src/lib/schema-fields-view-intent.ts b/packages/app/src/lib/schema-fields-view-intent.ts new file mode 100644 index 000000000..53a7af474 --- /dev/null +++ b/packages/app/src/lib/schema-fields-view-intent.ts @@ -0,0 +1,24 @@ +/** + * One-shot "open in Fields view" intent for schema files navigated to from the + * Settings frontmatter plugin panel. The schema editor's view toggle persists a + * user-global preference (`useLintConfigViewMode`, default `source`), but a + * schema opened from Settings is an editing gesture — it should land on the + * WYSIWYG Fields view regardless of that preference, without flipping the + * persisted choice for every other config file. + * + * Module-level and in-memory on purpose: the intent spans one hash navigation + * within one window, so persistence would only let stale intents leak across + * sessions. + */ + +const pendingFieldsView = new Set(); + +/** Record that the next open of `assetPath` should start on the Fields view. */ +export function requestSchemaFieldsView(assetPath: string): void { + pendingFieldsView.add(assetPath); +} + +/** Consume the intent for `assetPath`; true when one was pending. */ +export function consumeSchemaFieldsView(assetPath: string): boolean { + return pendingFieldsView.delete(assetPath); +} diff --git a/packages/app/src/lib/use-settings-route.ts b/packages/app/src/lib/use-settings-route.ts index 1537bd1d9..5e78b5c8a 100644 --- a/packages/app/src/lib/use-settings-route.ts +++ b/packages/app/src/lib/use-settings-route.ts @@ -58,6 +58,20 @@ export function openAgentSettings(): void { if (window.location.hash !== target) window.location.hash = target; } +/** Sidebar item id for the This project → Plugins manage section. */ +const PROJECT_PLUGINS_SECTION = 'plugins-manage'; + +/** + * Open Settings straight to This project → Plugins — the Problems panel's + * "enable plugins" pointer funnels through here so the deep-link literal is + * single-sourced (mirrors `openAgentSettings`). + */ +export function openProjectPluginsSettings(): void { + if (typeof window === 'undefined') return; + const target = settingsSectionHash(PROJECT_PLUGINS_SECTION); + if (window.location.hash !== target) window.location.hash = target; +} + interface SettingsRouteState { /** True when the dialog is open (hash is `#settings` or `#settings/`). */ open: boolean; diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index c32468c15..01dd1cf60 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -92,6 +92,7 @@ "0xnQ_X": ["Updates flow in"], "0xnmlQ": ["Repository name"], "0yL--g": ["Review conflicts"], + "0zAhu6": ["every doc"], "10g8I_": ["Turn off Only markdown files"], "12F9WL": ["(no id)"], "151wvk": ["Show sidebar"], @@ -175,7 +176,9 @@ "34qyH5": ["Could not reach server"], "36WXSC": ["Couldn't delete template: ", ["error"]], "36aWJD": ["Join us on Discord"], + "37P2BJ": ["Search schemas"], "38foVZ": ["Loading diff"], + "3BLniE": ["Configuration problems"], "3BnyYc": ["This doc isn't on GitHub yet. The link won't work until it's pushed."], "3DJEEt": [ ["sizeText"], @@ -185,6 +188,7 @@ "3DTatP": ["Copy a prompt for your agent, start a blank file, or scaffold from a template."], "3FMvls": ["A short line shown under the title in the template list."], "3Fcb0I": ["Open graph"], + "3HAPpl": ["No schemas match your search."], "3HNlwb": ["Hello, world!"], "3Ib6FN": ["Move down"], "3Ijjy1": ["Couldn't load this skill's files."], @@ -304,10 +308,12 @@ "5Tk8Fb": ["Loading files"], "5Umg9i": ["Disable external link previews"], "5WDhAk": ["Link to a page or external URL."], + "5Y17qa": ["Applies to (globs — leading ! excludes; empty means every doc)"], "5ZB7XC": ["File drop zone"], "5ZbJFS": ["Your current version is saved first, so this is reversible."], "5_8TCr": ["Fix all (", ["count"], ")"], "5_j-DA": ["Kill Terminal"], + "5aahsI": ["nothing (", ["0"], " is not a valid pattern)"], "5e1Dri": ["The menu label agents pick this template by (required)."], "5gr_ev": [ "Becomes the document's content when someone creates a doc from this template. Type <0>", @@ -336,7 +342,9 @@ } ] ], + "5qQr1u": ["Allowed values for ", ["field"]], "5rnB76": ["Your edits stay on this computer"], + "5tcmwY": ["Add value"], "5vYdh9": ["New thread with ", ["0"]], "5wV2qQ": ["and more"], "5y_9lO": ["Waiting for authorization"], @@ -380,6 +388,7 @@ "6ki7F2": [["0", "plural", { "one": ["#", " item"], "other": ["#", " items"] }]], "6lGV3K": ["Show less"], "6m_7We": ["Open this settings dialog."], + "6pe_G5": ["Element type"], "6q0rnH": ["writing"], "6qMe0o": [ "Draft a technical spec for my project. Start with an overview, then add cross-linked pages for the architecture, the key modules, and the open questions, all linked from a specs index." @@ -390,9 +399,13 @@ "6zzhQL": ["Send your report by email"], "72xhRD": ["Diagram view mode"], "74-bX-": ["A pull quote stands out from the surrounding text."], + "74C2zx": ["Edit schema ", ["file"]], "74eHt-": ["No file or folder selected"], "75Y1dk": ["Horizontal pill strip + active panel below; click a pill to switch panels."], "77SdEP": ["Delete ", ["itemName"]], + "78G7R3": [ + "Validate document frontmatter against JSON Schema files, scoped to doc sets by glob." + ], "7FamGZ": ["Go to line"], "7Fkcej": ["+ Add ", ["addChildName"]], "7H4UHD": ["Documents tagged <0>#", ["tagName"], ""], @@ -480,6 +493,11 @@ "92rbk2": ["Mirror loading <0>", ["mirrorRef"], ""], "96G6Re": ["New folder"], "96w9hU": ["Something went wrong opening this file (HTTP ", ["status"], ")."], + "98FP1E": [ + "This schema carries root-level advanced rules the editor does not show (", + ["0"], + ") — they still validate and survive every edit. Open the schema file to see them." + ], "99eR72": ["Visual editor table selection"], "9Bd18D": ["Failed to load hub pages"], "9KooKn": ["Run a project audit from the Problems panel."], @@ -513,6 +531,7 @@ "9i21pa": ["Commits happen automatically"], "9jnjV0": ["Embed an audio file with native player controls."], "9mEZeD": [["name"], " CLI"], + "9nIrRp": ["Nested fields"], "9npOH9": ["Open file"], "9o1bIV": [["behind"], " behind"], "9oE4C3": ["Browse your repos:"], @@ -613,6 +632,9 @@ "BymzO4": ["Document outline"], "C-xgad": ["or use a starter pack"], "C0hoX6": ["Always included"], + "C52RBg": [ + "Validate document frontmatter against standard JSON Schema files (draft-07). Toggle a schema on to validate the docs its globs match; violations surface as warnings and never block a write. Use Edit to open the schema file." + ], "C62P6b": ["Editor selection"], "C7XjNB": ["Global Skill"], "C9TBvF": ["No Outgoing"], @@ -630,6 +652,7 @@ "CVRM5N": [ "Follow fetches updates from your remote git repository and fast-forwards your copy automatically. Your local edits stay on this computer and are never pushed." ], + "CWjM1r": ["docs directly in ", ["0"], "/"], "CWzc2T": [ "<0>~/.ok/bin and <1>~/.ok/env.sh stay either way — the app always maintains them." ], @@ -648,6 +671,7 @@ "CoHvYd": ["Moved ", ["0"], " to position ", ["1"], " of ", ["2"]], "CooKEf": ["Dock sessions at the bottom"], "CvsD6Y": ["via ", ["viaTags"]], + "CwOXMT": ["Value for ", ["keyName"]], "D-Oh1s": ["How to set up ", ["0"], " (opens in browser)"], "D12vtS": ["(detached)"], "D2CwBE": [ @@ -657,6 +681,11 @@ ["1", "plural", { "one": ["#", " folder"], "other": ["#", " folders"] }], " and starter templates) in the folder of your choice." ], + "D2E7tr": [ + "This permanently deletes ", + ["deleteTarget"], + " from the project and removes its mapping from config.yml. Docs it validated keep their frontmatter; they just stop being checked." + ], "D2emTw": ["Something went wrong sending your feedback. Please try again."], "D4H2N6": ["Remove ", ["tag"]], "D8JJwQ": ["Feedback isn't available right now. Please try again later."], @@ -732,6 +761,7 @@ "ELYtNJ": [".ok folders"], "EL_YRd": ["Mirror of <0>api-spec"], "ENpRJV": ["External link previews"], + "EO4Dt1": ["Enable ", ["file"]], "EOjU5V": ["Inline Math Properties"], "EPwuTC": [["usedLabel"], " / ", ["sizeLabel"], " tokens used"], "ETyvvg": ["No language match."], @@ -760,6 +790,7 @@ "EiVgl5": ["You don't have permission to push to this repo."], "Eji13z": ["Toggle highlight formatting."], "EkH9pt": ["Update"], + "EkUnmS": ["any"], "El7NbA": ["Heading 2"], "ElatXA": [ "Context window: ", @@ -897,6 +928,7 @@ "H3oH0g": ["Redo"], "H6nKok": ["Show in File Manager"], "H86f9p": ["Collapse"], + "H9E1bE": ["Reset to default (removes it from config.yml)"], "HAvRAI": [["skillCount", "plural", { "one": ["skill"], "other": ["skills"] }]], "HCn0Ay": ["Toggle strikethrough formatting."], "HGNCsl": ["Connection lost — keep this tab open, your edits will sync when reconnected"], @@ -910,6 +942,7 @@ "HjNrT0": [["0", "plural", { "one": ["#", " backlink"], "other": ["#", " backlinks"] }]], "Hl89Qd": ["Loading templates"], "HlmZBk": ["Documents response did not match expected shape."], + "Hngy37": ["No problems found — but no lint plugins are enabled, so only links are checked."], "Hnl9nT": ["Set where pasted and dropped files are stored for this project."], "HoWElC": [ "This ", @@ -938,11 +971,13 @@ "IA1wNb": ["The Claude Desktop App didn't open the file. Is it installed? (", ["msg"], ")"], "IA6JW2": ["Detecting"], "ICoNYz": ["You can also manage this in Settings."], + "IFALcX": ["Description for ", ["field"]], "IFezUv": ["The audit could not be completed. Try again."], "IInnZo": ["Conflict"], "IKoTAC": ["Keep disabled"], "IN4UX8": ["Search (", ["searchShortcut"], ")"], "IQPyrD": ["Missing page — click to create"], + "IQgeSn": ["Failed to save the schema field"], "IRv9cH": ["Cloned ", ["repoName"], "."], "IWGfGF": ["Checking your GitHub connection"], "IX7-sq": ["Something went wrong opening \"", ["docName"], "\"."], @@ -966,6 +1001,7 @@ "IxyZXD": ["Screenshot"], "Izb10l": ["Fix all ", ["count"], " fixable problems"], "J2Mv98": ["Name (e.g. ", ["login"], ")"], + "J2_JuX": ["nothing (", ["0"], " cannot match a doc)"], "J2eKUI": ["File"], "J3HzPL": ["Collapse ", ["keyName"]], "J6N5MX": ["Couldn't construct the share URL. Try Done and re-share."], @@ -1075,6 +1111,7 @@ "LKOLNn": ["No AI agents installed"], "LNum6A": [["minutes"], " min ago"], "LPAv9E": [["days"], "d ago"], + "LQdLio": ["New schema"], "LQsCPn": ["Local commits are keeping this copy from updating"], "LRgZAt": [["owner"], "/", ["name"], " already exists"], "LTeJuX": ["Toggle Full Screen"], @@ -1201,6 +1238,7 @@ "NSnIUP": ["Two-page (even)"], "NUr4kz": ["A reusable starting point for new documents in this folder."], "NYUhy5": ["Subfolder name"], + "NYn1cR": ["Loading editor"], "NYu2Pt": ["New project…"], "N_7kkF": [ "Read through this codebase and draft a technical spec for the most complex module: an overview, the architecture, key files, and open questions, all linked from a specs index page." @@ -1255,6 +1293,7 @@ "OXdGnd": ["Problems"], "OYnx7O": ["Path cannot start with \"/\""], "OZJeTZ": ["Skill"], + "OZavHH": ["everything under ", ["0"], "/"], "Oawo4U": ["The report service couldn't be reached."], "OcBurH": [ "Choose which agents appear in the agent selector. Turn one off to hide it everywhere." @@ -1288,6 +1327,7 @@ "PBwmDJ": ["Searching by meaning"], "PBxg_E": ["Not now"], "PCSkw2": ["Skills"], + "PGPGsL": ["Add description"], "PIMM5l": ["You're subscribed!"], "PKLY-1": ["Opening..."], "PKjGhT": ["Headings"], @@ -1298,6 +1338,7 @@ "PRnH8G": ["of ", ["0"]], "PTb1Nx": ["Sync is paused. Turn it back on to resume."], "PVUn_c": ["Open folder"], + "PVggyD": ["Frontmatter schemas"], "PYmp-0": ["Import as template"], "PZtgO0": ["Opening worktree"], "P_bX4O": ["How to set up"], @@ -1309,6 +1350,7 @@ "Pgu10b": ["Saved as <0>", ["slug"], ".md"], "PgzIpb": ["Move to the previous visual-editor find result."], "PiH3UR": ["Copied!"], + "PijLsG": ["File name (created in .ok/schemas/)"], "PkeYXL": ["Expanded graph mode"], "Pkw7J9": ["This folder is empty."], "PlGC6r": ["the git exclude file isn't writable"], @@ -1330,6 +1372,7 @@ " already has a worktree — it'll open in its own window." ], "QDZbvN": [["diffDays"], "d ago"], + "QLgdT6": ["any doc named ", ["0"]], "QM3SBZ": ["Plan (", ["done"], "/", ["0"], ")"], "QOINDn": ["empty"], "QQbwzK": ["No agents available."], @@ -1385,6 +1428,7 @@ "RXEbZy": ["In app"], "RXNu_r": ["Shared via git with everyone working on this project."], "RY6Bq1": ["Open shared ", ["targetNoun"]], + "R_U_A4": ["Add file or folder pattern, e.g. guides/**/*"], "RaPMZv": ["Keyboard shortcuts available in the editor and workspace."], "Rah7yq": ["What should the AI do? (optional)"], "Rb1zMa": ["No excerpt"], @@ -1438,6 +1482,7 @@ "SgKLfA": ["Not connected to GitHub."], "Sh3Qkg": ["No matching branches."], "Si7mIR": ["Create report"], + "SiFgBx": ["docs matching ", ["0"]], "SiUUCS": ["Next match"], "SkMZlS": ["Couldn't remove <0>", ["targetGitPath"], ": ", ["removeGitError"]], "Skr_7L": ["Search worktrees"], @@ -1458,6 +1503,7 @@ "TJnkyf": ["ok ui responded but server.lock has no port yet"], "TKQ7K-": ["Install"], "TLIO3j": ["Toggle italic formatting."], + "TMLAx2": ["Required"], "TQJCR6": [["0", "plural", { "one": ["#", " result"], "other": ["#", " results"] }]], "TTDXD8": ["New worktree…"], "TUzThQ": ["Share skills"], @@ -1466,6 +1512,9 @@ "Ta25TG": ["No history yet"], "TbjyhA": ["Docs"], "TdfEV7": ["Archived"], + "TdlVEN": [ + "This field carries additional schema keywords the editor does not show — they are preserved on save." + ], "Te6sK2": [ "Heads up: editing any rule or option here creates a <0>.markdownlint.json file in your project to save your changes." ], @@ -1485,10 +1534,12 @@ "U5Spo9": ["Content directory must be inside the project"], "U6wmNc": ["Sync paused"], "U7HJ4y": ["Managed by OpenKnowledge (read-only)"], + "U88xbi": ["Failed to create the schema file"], "U8CD-l": ["Copy full path, No workspace"], "U9tm58": ["Hide problem indicators"], "UBv8UK": ["Delete Permanently"], "UDFrWm": ["Checking GitHub connection..."], + "UGeJuP": ["Allowed values (empty = any)"], "UL8A9w": ["Tag"], "ULRb5t": [ "Adds telemetry, server state, and runtime info when available. Document names are anonymized." @@ -1565,6 +1616,7 @@ "VZSBXL": ["Semantic ranking activates the first time an agent runs a search."], "V_6bJ4": ["Terminal settings not loaded yet — try again in a moment."], "V_wltx": ["Enter a title for this template."], + "Va3xJe": ["Add field"], "VbqhBI": ["Already at ", ["assetContentPath"], " — reusing."], "VcqWBy": ["Upload may have succeeded but the sidebar is out of date — refresh to resync"], "VdXv4D": ["Clone from GitHub"], @@ -1693,6 +1745,7 @@ "YEIS9T": ["Edits apply live. Light or dark is chosen from the background."], "YEP3X2": ["Delete a table when the active table cell selection covers the full table."], "YGIi_c": ["Reinstall"], + "YGNRB5": ["— except"], "YJ1BTp": ["A skill with that name already exists."], "YJPN3F": ["Too slow"], "YKLP_-": ["Template renamed"], @@ -1794,6 +1847,7 @@ "_IX_7x": ["Other"], "_JOGC0": ["Send feedback…"], "_LczaQ": ["Denied — ", ["optionName"]], + "_NWEid": ["Search schema files by path"], "_NX6gG": ["Managing project AI tools is unavailable in this build."], "_PACVa": ["Release notes"], "_PBJRl": ["Add at least one letter or number."], @@ -1824,8 +1878,10 @@ "_rP3HI": [["minutes"], "m ago"], "_tekeB": ["updated folder properties"], "_tjopj": ["Paste duplicate"], + "_yB-24": ["everything under any ", ["0"], "/ folder"], "a-cKXz": ["Select all source"], "a3H_MR": ["Open and focus the Ask AI composer for the current editor selection."], + "a45f2c": ["Glob patterns this schema applies to"], "a4FRQv": ["Undo to this edit?"], "a5Gha5": ["Local changes blocked the merge"], "a5W0q0": [ @@ -1880,6 +1936,7 @@ "bBE5tC": ["Detected on this machine"], "bBcFK_": ["Open emoji picker for ", ["keyName"]], "bD8I7O": ["Complete"], + "bF22t6": ["Remove field ", ["field"]], "bGa3Fk": ["Uploading file"], "bH382I": ["Edit this HTML — the preview updates live."], "bSD4im": ["Search worktrees and branches"], @@ -1901,6 +1958,7 @@ "bwil08": ["Building <0>openknowledge.skill and opening the Claude Desktop App"], "c1LZZH": ["Link copied."], "c3XJ18": ["Help"], + "c3ZGrq": ["the doc ", ["0"]], "c4fVUV": ["Fix"], "c5PTFE": ["Dev instance: ", ["label"], " (isolated from other worktrees)"], "c5f7sm": ["Code block language: ", ["currentLabel"], ". Click to change."], @@ -1923,6 +1981,7 @@ "cOSAfL": ["No repositories found on your GitHub account."], "cOrTMo": ["Your share link is ready below."], "cRHuuI": ["Couldn't copy path"], + "cRm498": ["No schema files in this project yet — create one to start validating."], "cSCbv3": ["BETA"], "cUK8lW": [ ["conflictCount", "plural", { "one": ["#", " conflict"], "other": ["#", " conflicts"] }] @@ -1959,6 +2018,7 @@ "crqzW5": ["AI tools"], "cs4mps": ["Config sharing"], "csDS2L": ["Available"], + "cstC0z": ["Pattern (regex)"], "ct2kv5": ["No headings in ", ["pageTarget"]], "cuA8QW": ["Organize specs"], "d1JjXo": [ @@ -2018,6 +2078,7 @@ "e4FxjG": ["See all ", ["0"], " starter packs"], "e4GHWP": ["Pull"], "e4PbgC": ["Duplicate the file-tree item copied from the Files sidebar."], + "e60wew": ["Allowed element values for ", ["field"]], "e8L17t": ["A non-empty folder already exists at this path. Pick a different folder."], "e8zRrb": ["Show Files"], "e8zYa8": ["Could not open ", ["shareBranch"], " in a worktree. Try again."], @@ -2048,6 +2109,7 @@ "Follow never pushes or commits your local changes. They stay only on this machine." ], "evs0iw": ["Tags matching \"", ["tagListQuery"], "\""], + "ezUmoU": ["Allowed element values (empty = any)"], "f1XQyZ": ["Move block"], "f3Nvg7": [ ["0"], @@ -2117,6 +2179,7 @@ "gYz2ep": ["Expires in ", ["timeLabel"]], "gZElfU": ["Fixing ", ["0"], " of ", ["1"], " files"], "gbHcbI": ["Failed to delete property"], + "geIbmI": ["Reset ", ["file"], " to default"], "gfdqcY": ["Couldn't apply the change: ", ["0"]], "gfohmY": ["Connect tools"], "ggm6JX": [ @@ -2172,6 +2235,7 @@ "haHgQj": ["Copied relative path"], "habOU_": ["Failed to update the auto-approve setting — ", ["detail"]], "he3ygx": ["Copy"], + "hgtsat": ["Failed to delete ", ["file"]], "hh4mVC": ["Ask AI to fix ", ["flatId"]], "hjO0Mq": ["Add tab"], "hmYd9H": [ @@ -2223,6 +2287,7 @@ "iTylMl": ["Templates"], "iXTp5j": ["Scope: "], "idD8Ev": ["Saved"], + "idni-S": ["Values for ", ["keyName"]], "ig5vUe": ["Failed to load file"], "ii5mVn": ["Report this error"], "iilQXA": ["Folder duplicated"], @@ -2253,6 +2318,7 @@ "jEZ6Lf": ["provisional"], "jEdF24": ["Missing page: ", ["linkTitle"], ". Click to create."], "jKd3AP": ["Could not enable the terminal: ", ["0"]], + "jL3gox": ["Select a value"], "jPUPvG": ["Couldn't resolve the conflict for ", ["filePath"], "."], "jRAGrQ": ["Configure agents"], "jRHdCz": ["Failed to load PDF"], @@ -2340,6 +2406,7 @@ "knjOzA": ["Tag value"], "ko386X": ["A document has a conflict — resolve it to keep it up to date."], "kolRU1": ["Do you want to permanently delete instead?"], + "kooyLJ": ["Failed to rename the field"], "kpqWwG": ["This ", ["targetNoun"], " was removed from branch <0>", ["branch"], "."], "ktspdq": ["Tint and badge files that have lint or link problems, without opening them."], "ku0yLQ": ["Pages are embedded the first time a search needs them."], @@ -2404,10 +2471,12 @@ "m16xKo": ["Add"], "m2T1Xj": ["Tag #", ["tagValue"], ", ", ["uses"]], "m6YGpY": ["Active panel content for the selected tab shows here."], + "mAzs2H": ["Disable ", ["file"]], "mBltnd": ["Path suggestions"], "mCB6Je": ["Select All"], "mGaZwZ": ["See the full install guide"], "mHVPm5": ["Reconnect required"], + "mHf8mp": ["Delete schema file ", ["file"]], "mLAjyL": [ "Give the AI tools you use access to read and update your projects. Checking a box sets it up right away; unchecking removes it." ], @@ -2487,6 +2556,9 @@ "nYKerc": ["(user-level only)"], "nYeBmY": [["hours"], "h ago"], "nbfdhU": ["Integrations"], + "nc-ipN": [ + "Field editing is available for schema files mapped in the Frontmatter schemas plugin" + ], "ndeI89": ["Use Cmd/Ctrl+C to copy the link below, or open OK in the desktop app."], "ndwmea": ["unavailable on this platform"], "neRy3o": ["Split list item"], @@ -2520,6 +2592,7 @@ "nrGWJ7": [ "Git identity isn't set — commits use a default author. Set yours so teammates see your name." ], + "nrXDdR": ["Field name"], "nskygk": ["Choose agent"], "nwtY4N": ["Something went wrong"], "nxlOpL": ["OpenKnowledge is using this GitHub account."], @@ -2588,6 +2661,7 @@ "p0Iq25": ["Insert link"], "p2LEhJ": ["Personal access token"], "p8TjFD": ["Collapse session dock"], + "p98xmP": ["Applies to"], "p9sdCy": ["New ignore pattern"], "pAgkuZ": ["Reopening \"", ["docName"], "\" with a fresh local collaboration cache."], "pBROkv": ["Thanks for subscribing. Watch your inbox for product updates."], @@ -2600,6 +2674,7 @@ "pWT04I": ["Checking..."], "pWbsW6": ["Loading docs"], "pZAs1T": ["Failed to load outgoing links"], + "paCw0z": ["everything under ", ["0"], "/ folders inside ", ["1"], "/"], "pajIrl": ["Could not turn off the terminal: ", ["0"]], "pbwTg6": [["seconds"], "s ago"], "pckX1A": [["0", "plural", { "one": ["#", " use"], "other": ["#", " uses"] }]], @@ -2629,6 +2704,7 @@ ["tabCount", "plural", { "one": ["#", " tab"], "other": ["#", " tabs"] }] ], "qAx7xG": ["Fixed folder in content root"], + "qC7PwZ": ["Failed to remove the field"], "qEEfPk": ["Bug report ", ["0"]], "qEIFZu": ["Key ending in ", ["keyHint"]], "qEue6Q": [" or "], @@ -2655,6 +2731,7 @@ "qmL1UG": ["Uploading securely"], "qo4zCg": ["Path cannot contain backslashes"], "qpQwGC": ["Describe what you're working on and the agent sets it up for you."], + "qribBG": ["schema ", ["deleteTarget"]], "qvWGzP": ["Collapse all"], "qyLmWw": ["Source editor search panel"], "r-OJYm": ["Enter a valid branch name (no spaces, no leading dot, no \"..\")."], @@ -2697,9 +2774,11 @@ "rfFpcF": ["Remove ", ["chip"]], "rg7hNd": ["Rate your experience"], "rgPqOw": ["Convert the current block to a paragraph."], + "ri6zS-": ["The schema file does not exist or failed to load — adding a field creates it."], "riqekz": ["Suggestion menu navigation"], "rlUzXK": ["Could not validate folder. Please try again."], "rn7n0G": ["Resume sync"], + "rnX-1S": ["Enable plugins"], "roAe2K": ["Link URL"], "roiUQQ": ["This page doesn't link to anything yet."], "rppgXF": ["Disconnecting"], @@ -2819,6 +2898,9 @@ "uKRYiW": ["Sync status unavailable — server error."], "uL_Wx8": ["The terminal stopped unexpectedly."], "uM6jnS": ["Restore failed"], + "uMHsvM": [ + "A <0>Modified badge marks schemas your config maps — on or off. Reset removes the mapping from config.yml." + ], "uO9WLl": ["Push"], "uQNW00": ["Detached HEAD — checkout a branch to resume"], "uQewpR": [ @@ -2867,6 +2949,7 @@ "vAYoCZ": ["Couldn't open the worktree. Try again."], "vDXqVv": ["Insert a markdown hard break."], "vEJDlt": ["The file is in your Trash; the file-watcher will reconcile."], + "vF68cg": ["Fields"], "vGeNNQ": ["Copy a prompt and paste it into your agent to set up your project."], "vIxrEk": ["Fit width"], "vLyv1R": ["Hide"], @@ -2875,6 +2958,7 @@ "vWc9a0": ["Type to edit; <0>⌘ Enter saves, <1>Esc cancels."], "vYPvPB": ["Switch project…"], "vZVeFG": ["Upload failed — please try again"], + "v_2_3v": ["Deleted ", ["file"]], "vbK_ZG": ["Files sidebar"], "ve8vEM": ["Code expired — please try again"], "vfRlCI": ["Could not copy full path"], @@ -2902,6 +2986,7 @@ "wQp1V3": ["Mirror of <0>", ["src"], ""], "wT4iQr": ["Open visual-editor find with replace controls expanded."], "wTcjov": ["No workspace"], + "wU8WyK": ["Element fields"], "wVa83H": ["Couldn't create the report"], "wXO4Tg": [["hrs"], "h ago"], "wXw19Y": [ @@ -2924,6 +3009,7 @@ "Skills teach agents repeatable tasks. Author them here; install a skill to project it into your editors' skill folders." ], "wndeIl": ["Project-level pages with neither incoming nor outgoing graph edges."], + "ws5o1F": ["Checked by: ", ["0"]], "wtcxiD": ["Share this setup with your team?"], "wvbc1r": ["Couldn't open document"], "wzq9Rv": [ @@ -3037,6 +3123,7 @@ "yutJKi": ["Checklist with checkboxes."], "yvG-l1": ["Open source lint panel"], "yvHDYG": ["Claude Desktop"], + "yxF19Y": [["0", "plural", { "one": ["#", " plugin"], "other": ["#", " plugins"] }]], "yz7wBu": ["Close"], "yzF66j": ["Link"], "yzGPkm": ["Expand graph"], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 07d8bb908..9a43f05e9 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -25,6 +25,10 @@ msgstr " Your selection could not be restored." msgid " Your selection has been restored." msgstr " Your selection has been restored." +#: src/components/settings/LintingSection.tsx +msgid "— except" +msgstr "— except" + #: src/components/acp/ThreadView.tsx msgid "… earlier output trimmed" msgstr "… earlier output trimmed" @@ -151,6 +155,11 @@ msgstr "{0, plural, one {# key} other {# keys}}" msgid "{0, plural, one {# option set} other {# options set}}" msgstr "{0, plural, one {# option set} other {# options set}}" +#. placeholder {0}: activePlugins.length +#: src/components/ProblemsPanel.tsx +msgid "{0, plural, one {# plugin} other {# plugins}}" +msgstr "{0, plural, one {# plugin} other {# plugins}}" + #. placeholder {0}: blocked.length #: src/editor/components/PreviewBlockedNotice.tsx msgid "{0, plural, one {# request blocked by the preview's security policy} other {# requests blocked by the preview's security policy}}" @@ -671,6 +680,10 @@ msgstr "📝 or assets/icon.png" msgid "A <0>Modified badge marks rules your config sets explicitly — directly, or inherited through <1>extends. Unmarked rules keep their default state." msgstr "A <0>Modified badge marks rules your config sets explicitly — directly, or inherited through <1>extends. Unmarked rules keep their default state." +#: src/components/settings/LintingSection.tsx +msgid "A <0>Modified badge marks schemas your config maps — on or off. Reset removes the mapping from config.yml." +msgstr "A <0>Modified badge marks schemas your config maps — on or off. Reset removes the mapping from config.yml." + #: src/components/ShareBranchSwitchDialog.tsx msgid "A branch named {shareBranch} already exists. Open its worktree from the switcher instead." msgstr "A branch named {shareBranch} already exists. Open its worktree from the switcher instead." @@ -854,6 +867,7 @@ msgstr "Actual Size" #: src/components/FrontmatterRow.tsx #: src/components/ObjectWidget.tsx #: src/components/PropertyPanel.tsx +#: src/components/settings/frontmatter-schema-field-editor.tsx msgid "Add" msgstr "Add" @@ -881,6 +895,18 @@ msgstr "Add at least one letter or number." msgid "Add cursors or select matching occurrences through CodeMirror." msgstr "Add cursors or select matching occurrences through CodeMirror." +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Add description" +msgstr "Add description" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Add field" +msgstr "Add field" + +#: src/components/settings/LintingSection.tsx +msgid "Add file or folder pattern, e.g. guides/**/*" +msgstr "Add file or folder pattern, e.g. guides/**/*" + #: src/components/SeedDialog.tsx msgid "Add it directly to this project." msgstr "Add it directly to this project." @@ -937,6 +963,10 @@ msgstr "Add the <0>ok command to your terminal" msgid "Add the next matching source selection." msgstr "Add the next matching source selection." +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Add value" +msgstr "Add value" + #. Subtext for the open-knowledge-write-skill skill row #. Subtext for the open-knowledge-write-skill skill row #: src/components/McpConsentDialogBody.tsx @@ -1030,6 +1060,22 @@ msgstr "All hunks resolved" msgid "All tags" msgstr "All tags" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Allowed element values (empty = any)" +msgstr "Allowed element values (empty = any)" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Allowed element values for {field}" +msgstr "Allowed element values for {field}" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Allowed values (empty = any)" +msgstr "Allowed values (empty = any)" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Allowed values for {field}" +msgstr "Allowed values for {field}" + #: src/editor/image-upload/index.ts msgid "Already at {assetContentPath} — reusing." msgstr "Already at {assetContentPath} — reusing." @@ -1067,6 +1113,15 @@ msgstr "and {remaining} more" msgid "and more" msgstr "and more" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "any" +msgstr "any" + +#. placeholder {0}: summary.name +#: src/components/settings/LintingSection.tsx +msgid "any doc named {0}" +msgstr "any doc named {0}" + #: src/components/PublishToGitHubDialog.tsx msgid "Anyone can see" msgstr "Anyone can see" @@ -1091,6 +1146,14 @@ msgstr "Append-only log of what changed." msgid "Application" msgstr "Application" +#: src/components/settings/LintingSection.tsx +msgid "Applies to" +msgstr "Applies to" + +#: src/components/settings/LintingSection.tsx +msgid "Applies to (globs — leading ! excludes; empty means every doc)" +msgstr "Applies to (globs — leading ! excludes; empty means every doc)" + #: src/components/settings/TerminalSection.tsx msgid "Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs \"ok open\"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent." msgstr "Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs \"ok open\"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent." @@ -1303,6 +1366,7 @@ msgid "Below" msgstr "Below" #: src/components/acp/AgentBetaBadge.tsx +#: src/components/settings/PluginBetaBadge.tsx msgid "Beta" msgstr "Beta" @@ -1535,6 +1599,11 @@ msgstr "Check spelling while typing" msgid "Checkbox" msgstr "Checkbox" +#. placeholder {0}: activePlugins.map((plugin) => plugin.label).join(', ') +#: src/components/ProblemsPanel.tsx +msgid "Checked by: {0}" +msgstr "Checked by: {0}" + #: src/components/BottomComposer.tsx msgid "Checking for agents" msgstr "Checking for agents" @@ -1914,6 +1983,10 @@ msgstr "Config sharing unavailable: {0}." msgid "Config sharing unchanged — see details below." msgstr "Config sharing unchanged — see details below." +#: src/components/settings/LintingSection.tsx +msgid "Configuration problems" +msgstr "Configuration problems" + #: src/components/ProblemsPanel.tsx msgid "Configuration warnings" msgstr "Configuration warnings" @@ -2595,6 +2668,7 @@ msgstr "Crash dump" #: src/components/CreateProjectDialog.tsx #: src/components/empty-state/CreatePromptComposer.tsx #: src/components/NewItemDialog.tsx +#: src/components/settings/LintingSection.tsx #: src/components/TemplateRow.tsx msgid "Create" msgstr "Create" @@ -2853,6 +2927,10 @@ msgstr "Delete code block" msgid "Delete Permanently" msgstr "Delete Permanently" +#: src/components/settings/LintingSection.tsx +msgid "Delete schema file {file}" +msgstr "Delete schema file {file}" + #: src/lib/keyboard-shortcuts.ts msgid "Delete selected files or folders" msgstr "Delete selected files or folders" @@ -2869,6 +2947,10 @@ msgstr "Delete selected table" msgid "Delete this conversation?" msgstr "Delete this conversation?" +#: src/components/settings/LintingSection.tsx +msgid "Deleted {file}" +msgstr "Deleted {file}" + #: src/components/FolderTimelineCard.tsx msgid "deleted template" msgstr "deleted template" @@ -2919,6 +3001,10 @@ msgstr "Description" msgid "Description (optional)" msgstr "Description (optional)" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Description for {field}" +msgstr "Description for {field}" + #: src/components/empty-state/CreatePromptComposer.tsx #: src/components/handoff/AgentSplitButton.tsx #: src/components/handoff/OpenInAgentContextSubmenu.tsx @@ -2994,6 +3080,10 @@ msgstr "Diff unavailable" msgid "Disable {0}" msgstr "Disable {0}" +#: src/components/settings/LintingSection.tsx +msgid "Disable {file}" +msgstr "Disable {file}" + #: src/components/settings/LinkPreviewsSection.tsx msgid "Disable external link previews" msgstr "Disable external link previews" @@ -3055,6 +3145,16 @@ msgstr "Dock sessions on the right" msgid "Docs" msgstr "Docs" +#. placeholder {0}: summary.folder +#: src/components/settings/LintingSection.tsx +msgid "docs directly in {0}/" +msgstr "docs directly in {0}/" + +#. placeholder {0}: summary.pattern +#: src/components/settings/LintingSection.tsx +msgid "docs matching {0}" +msgstr "docs matching {0}" + #: src/components/CommandPalette.tsx msgid "Docs tagged #{tagDocsName}" msgstr "Docs tagged #{tagDocsName}" @@ -3233,6 +3333,7 @@ msgid "Each selected tool gets an OpenKnowledge MCP entry." msgstr "Each selected tool gets an OpenKnowledge MCP entry." #: src/components/AppMenubar.tsx +#: src/components/settings/LintingSection.tsx #: src/components/skill-actions.tsx #: src/components/TemplateForm.tsx #: src/components/TemplateRow.tsx @@ -3258,6 +3359,10 @@ msgstr "Edit in config file" msgid "Edit markdown link" msgstr "Edit markdown link" +#: src/components/settings/LintingSection.tsx +msgid "Edit schema {file}" +msgstr "Edit schema {file}" + #: src/editor/extensions/CodeBlockView.tsx msgid "Edit source" msgstr "Edit source" @@ -3314,6 +3419,14 @@ msgstr "Edits apply live. Light or dark is chosen from the background." msgid "Edits at the source land here — no copy-paste drift." msgstr "Edits at the source land here — no copy-paste drift." +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Element fields" +msgstr "Element fields" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Element type" +msgstr "Element type" + #: src/components/AuthModal.tsx msgid "Email" msgstr "Email" @@ -3380,6 +3493,10 @@ msgstr "Enable {0}" msgid "Enable {displayName} CLI" msgstr "Enable {displayName} CLI" +#: src/components/settings/LintingSection.tsx +msgid "Enable {file}" +msgstr "Enable {file}" + #: src/components/AutoSyncOnboardingDialog.tsx #: src/components/EnableSyncConfirmDialog.tsx #: src/components/share-receive-miss-content.tsx @@ -3404,6 +3521,10 @@ msgstr "Enable Follow?" msgid "Enable git auto-sync?" msgstr "Enable git auto-sync?" +#: src/components/ProblemsPanel.tsx +msgid "Enable plugins" +msgstr "Enable plugins" + #: src/components/settings/SearchSection.tsx msgid "Enable semantic search" msgstr "Enable semantic search" @@ -3469,6 +3590,26 @@ msgstr "Error" msgid "Error:" msgstr "Error:" +#: src/components/settings/LintingSection.tsx +msgid "every doc" +msgstr "every doc" + +#. placeholder {0}: summary.folder +#: src/components/settings/LintingSection.tsx +msgid "everything under {0}/" +msgstr "everything under {0}/" + +#. placeholder {0}: summary.folder +#. placeholder {1}: summary.root +#: src/components/settings/LintingSection.tsx +msgid "everything under {0}/ folders inside {1}/" +msgstr "everything under {0}/ folders inside {1}/" + +#. placeholder {0}: summary.folder +#: src/components/settings/LintingSection.tsx +msgid "everything under any {0}/ folder" +msgstr "everything under any {0}/ folder" + #: src/components/settings/SharingSection.tsx msgid "Exception: skills are shared" msgstr "Exception: skills are shared" @@ -3576,6 +3717,14 @@ msgstr "Failed to create folder" msgid "Failed to create page" msgstr "Failed to create page" +#: src/components/settings/LintingSection.tsx +msgid "Failed to create the schema file" +msgstr "Failed to create the schema file" + +#: src/components/settings/LintingSection.tsx +msgid "Failed to delete {file}" +msgstr "Failed to delete {file}" + #: src/components/ArrayOfObjectsWidget.tsx msgid "Failed to delete item" msgstr "Failed to delete item" @@ -3751,6 +3900,10 @@ msgstr "Failed to parse forward-links error response (HTTP {status})" msgid "Failed to remove project." msgstr "Failed to remove project." +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Failed to remove the field" +msgstr "Failed to remove the field" + #: src/components/PropertyPanel.tsx msgid "Failed to rename" msgstr "Failed to rename" @@ -3764,6 +3917,10 @@ msgstr "Failed to rename path" msgid "Failed to rename property" msgstr "Failed to rename property" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Failed to rename the field" +msgstr "Failed to rename the field" + #: src/editor/components/Pdf.tsx msgid "Failed to render PDF" msgstr "Failed to render PDF" @@ -3779,6 +3936,10 @@ msgstr "Failed to reorder" msgid "Failed to save content rules — {0}" msgstr "Failed to save content rules — {0}" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Failed to save the schema field" +msgstr "Failed to save the schema field" + #: src/components/settings/LintingSection.tsx msgid "Failed to save theme setting" msgstr "Failed to save theme setting" @@ -3840,6 +4001,18 @@ msgstr "Fetch failed — check the server logs for details." msgid "Fetching" msgstr "Fetching" +#: src/components/SchemaConfigEditor.tsx +msgid "Field editing is available for schema files mapped in the Frontmatter schemas plugin" +msgstr "Field editing is available for schema files mapped in the Frontmatter schemas plugin" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Field name" +msgstr "Field name" + +#: src/components/SchemaConfigEditor.tsx +msgid "Fields" +msgstr "Fields" + #: src/components/AppMenubar.tsx #: src/components/CommandPalette.tsx #: src/components/settings/AiToolsSection.tsx @@ -3865,6 +4038,10 @@ msgstr "File duplicated" msgid "File name" msgstr "File name" +#: src/components/settings/LintingSection.tsx +msgid "File name (created in .ok/schemas/)" +msgstr "File name (created in .ok/schemas/)" + #: src/components/TrashFailureModal.tsx msgid "File not found" msgstr "File not found" @@ -4106,6 +4283,10 @@ msgstr "From template" msgid "Frontmatter every document created from this template starts with. Values are YAML — a list is <0>[a, b]; <1>{{date}} fills in on create." msgstr "Frontmatter every document created from this template starts with. Values are YAML — a list is <0>[a, b]; <1>{{date}} fills in on create." +#: src/components/settings/LintingSection.tsx +msgid "Frontmatter schemas" +msgstr "Frontmatter schemas" + #: src/components/settings/SyncSection.tsx #: src/components/SyncStatusBadge.tsx msgid "Full" @@ -4189,6 +4370,10 @@ msgstr "Give the AI tools you use access to read and update your projects. Check msgid "Give the AI tools you use access to read and update your projects. Pick what to set up below, and change it anytime." msgstr "Give the AI tools you use access to read and update your projects. Pick what to set up below, and change it anytime." +#: src/components/settings/LintingSection.tsx +msgid "Glob patterns this schema applies to" +msgstr "Glob patterns this schema applies to" + #: src/lib/keyboard-shortcuts.ts #: src/lib/skill-scope.ts msgid "Global" @@ -4989,6 +5174,10 @@ msgstr "Loading docs" msgid "Loading document" msgstr "Loading document" +#: src/components/EditorArea.tsx +msgid "Loading editor" +msgstr "Loading editor" + #: src/components/FileTree.tsx msgid "Loading files" msgstr "Loading files" @@ -5245,6 +5434,7 @@ msgid "Mode" msgstr "Mode" #: src/components/FolderOverview.tsx +#: src/components/settings/LintingSection.tsx #: src/components/settings/markdownlint-rule-browser.tsx msgid "Modified" msgstr "Modified" @@ -5411,6 +5601,10 @@ msgstr "Navigation and suggestions" msgid "Nested .okignore error in {path}" msgstr "Nested .okignore error in {path}" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Nested fields" +msgstr "Nested fields" + #: src/components/FileTree.tsx msgid "Network error - {kind} \"{createdPath}\" still exists on disk" msgstr "Network error - {kind} \"{createdPath}\" still exists on disk" @@ -5513,6 +5707,10 @@ msgstr "New project…" msgid "New property name" msgstr "New property name" +#: src/components/settings/LintingSection.tsx +msgid "New schema" +msgstr "New schema" + #: src/components/NewSkillDialog.tsx #: src/components/settings/SkillsManagerSection.tsx #: src/components/SkillsSidebarSection.tsx @@ -5766,6 +5964,10 @@ msgstr "No pages matched \"{semanticQueryText}\" by meaning." msgid "No patterns yet. Type a folder or file name below to start hiding files." msgstr "No patterns yet. Type a folder or file name below to start hiding files." +#: src/components/ProblemsPanel.tsx +msgid "No problems found — but no lint plugins are enabled, so only links are checked." +msgstr "No problems found — but no lint plugins are enabled, so only links are checked." + #: src/components/ProblemsPanel.tsx msgid "No problems found." msgstr "No problems found." @@ -5803,6 +6005,14 @@ msgstr "No results" msgid "No rules match your filters." msgstr "No rules match your filters." +#: src/components/settings/LintingSection.tsx +msgid "No schema files in this project yet — create one to start validating." +msgstr "No schema files in this project yet — create one to start validating." + +#: src/components/settings/LintingSection.tsx +msgid "No schemas match your search." +msgstr "No schemas match your search." + #: src/components/settings/SettingsDialogShell.tsx msgid "No settings found" msgstr "No settings found" @@ -5895,6 +6105,16 @@ msgstr "Not installed" msgid "Not now" msgstr "Not now" +#. placeholder {0}: summary.pattern +#: src/components/settings/LintingSection.tsx +msgid "nothing ({0} cannot match a doc)" +msgstr "nothing ({0} cannot match a doc)" + +#. placeholder {0}: summary.pattern +#: src/components/settings/LintingSection.tsx +msgid "nothing ({0} is not a valid pattern)" +msgstr "nothing ({0} is not a valid pattern)" + #: src/components/FolderPropertiesCard.tsx msgid "Nothing set yet." msgstr "Nothing set yet." @@ -5987,6 +6207,7 @@ msgstr "Only external links trigger a request. Links to other documents in this msgid "Only markdown files" msgstr "Only markdown files" +#: src/components/settings/LintingSection.tsx #: src/components/settings/markdownlint-rule-browser.tsx msgid "Only modified" msgstr "Only modified" @@ -6540,6 +6761,10 @@ msgstr "Path resolves outside project" msgid "Path suggestions" msgstr "Path suggestions" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Pattern (regex)" +msgstr "Pattern (regex)" + #: src/components/settings/OkignoreSection.tsx msgid "Pattern {patternNumber}" msgstr "Pattern {patternNumber}" @@ -7043,6 +7268,10 @@ msgstr "Remove {tag}" msgid "Remove failed: {error}" msgstr "Remove failed: {error}" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Remove field {field}" +msgstr "Remove field {field}" + #: src/components/NavigatorApp.tsx #: src/components/recent-remove-controls.tsx msgid "Remove from recent projects" @@ -7224,6 +7453,10 @@ msgstr "Repository name" msgid "Repository not found. It may have been renamed, deleted, or moved." msgstr "Repository not found. It may have been renamed, deleted, or moved." +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Required" +msgstr "Required" + #: src/components/handoff/OpenInAgentMenuItem.tsx msgid "Requires {brand}." msgstr "Requires {brand}." @@ -7245,6 +7478,10 @@ msgstr "Reset" msgid "Reset {0} to default" msgstr "Reset {0} to default" +#: src/components/settings/LintingSection.tsx +msgid "Reset {file} to default" +msgstr "Reset {file} to default" + #: src/components/settings/field-controls.tsx msgid "Reset {labelText} to default" msgstr "Reset {labelText} to default" @@ -7254,6 +7491,10 @@ msgstr "Reset {labelText} to default" msgid "Reset to default" msgstr "Reset to default" +#: src/components/settings/LintingSection.tsx +msgid "Reset to default (removes it from config.yml)" +msgstr "Reset to default (removes it from config.yml)" + #: src/editor/components/Mermaid.tsx msgid "Reset view" msgstr "Reset view" @@ -7512,6 +7753,10 @@ msgstr "Saves the file to <0>~/Downloads/openknowledge.skill and opens the C msgid "Saving" msgstr "Saving" +#: src/components/settings/LintingSection.tsx +msgid "schema {deleteTarget}" +msgstr "schema {deleteTarget}" + #: src/components/NewSkillDialog.tsx #: src/components/PanelScopeHeader.tsx msgid "Scope" @@ -7596,6 +7841,14 @@ msgstr "Search rules" msgid "Search rules by id, alias, or name" msgstr "Search rules by id, alias, or name" +#: src/components/settings/LintingSection.tsx +msgid "Search schema files by path" +msgstr "Search schema files by path" + +#: src/components/settings/LintingSection.tsx +msgid "Search schemas" +msgstr "Search schemas" + #: src/components/settings/SettingsDialogShell.tsx msgid "Search settings" msgstr "Search settings" @@ -7662,6 +7915,10 @@ msgstr "See all {0} starter packs" msgid "See the full install guide" msgstr "See the full install guide" +#: src/components/FrontmatterRow.tsx +msgid "Select a value" +msgstr "Select a value" + #: src/components/AppMenubar.tsx msgid "Select All" msgstr "Select All" @@ -8216,6 +8473,7 @@ msgstr "Something went wrong. Please try again." #: src/components/AgentDiffPane.tsx #: src/components/LintConfigEditor.tsx #: src/components/MermaidFileViewer.tsx +#: src/components/SchemaConfigEditor.tsx #: src/components/TimelineDiffPane.tsx msgid "Source" msgstr "Source" @@ -8777,6 +9035,11 @@ msgstr "The default branch is protected — pushes need a pull request." msgid "The desktop app isn't responding. Refresh and try again." msgstr "The desktop app isn't responding. Refresh and try again." +#. placeholder {0}: summary.target +#: src/components/settings/LintingSection.tsx +msgid "the doc {0}" +msgstr "the doc {0}" + #: src/components/FileTree.tsx msgid "The file is in your Trash; the file-watcher will reconcile." msgstr "The file is in your Trash; the file-watcher will reconcile." @@ -8827,6 +9090,10 @@ msgstr "The repository is private and your GitHub account doesn't have access to msgid "The repository was moved, renamed, or deleted." msgstr "The repository was moved, renamed, or deleted." +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "The schema file does not exist or failed to load — adding a field creates it." +msgstr "The schema file does not exist or failed to load — adding a field creates it." + #: src/components/TemplateProperties.tsx msgid "The template's filename (without `.md`)." msgstr "The template's filename (without `.md`)." @@ -8959,6 +9226,10 @@ msgstr "This doc isn't on GitHub yet. The link won't work until it's pushed." msgid "This document is already active in the editor. Use Open to collapse the graph." msgstr "This document is already active in the editor. Use Open to collapse the graph." +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "This field carries additional schema keywords the editor does not show — they are preserved on save." +msgstr "This field carries additional schema keywords the editor does not show — they are preserved on save." + #: src/components/use-viewer-text.ts msgid "This file can't be opened in the text editor." msgstr "This file can't be opened in the text editor." @@ -9015,6 +9286,10 @@ msgstr "This page doesn't link to anything yet." msgid "This path is on an external volume (/Volumes/...). OpenKnowledge will lose track of files when the drive ejects." msgstr "This path is on an external volume (/Volumes/...). OpenKnowledge will lose track of files when the drive ejects." +#: src/components/settings/LintingSection.tsx +msgid "This permanently deletes {deleteTarget} from the project and removes its mapping from config.yml. Docs it validated keep their frontmatter; they just stop being checked." +msgstr "This permanently deletes {deleteTarget} from the project and removes its mapping from config.yml. Docs it validated keep their frontmatter; they just stop being checked." + #. placeholder {0}: skill.path #: src/components/SkillDeleteDialog.tsx msgid "This permanently removes {0}. Agents that invoke this skill by name will fail until it's recreated." @@ -9081,6 +9356,11 @@ msgstr "This replaces the current skill content with the bundled version {0}. An msgid "This request is no longer active." msgstr "This request is no longer active." +#. placeholder {0}: rootAdvancedKeywords.join(', ') +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "This schema carries root-level advanced rules the editor does not show ({0}) — they still validate and survive every edit. Open the schema file to see them." +msgstr "This schema carries root-level advanced rules the editor does not show ({0}) — they still validate and survive every edit. Open the schema file to see them." + #: src/components/settings/SearchSection.tsx msgid "This sends content off your machine" msgstr "This sends content off your machine" @@ -9269,6 +9549,7 @@ msgstr "Two-page (even)" msgid "Two-page (odd)" msgstr "Two-page (odd)" +#: src/components/settings/frontmatter-schema-field-editor.tsx #: src/components/TemplateForm.tsx msgid "Type" msgstr "Type" @@ -9555,11 +9836,27 @@ msgstr "User plugins are personal to this device — turn them on or off here. T msgid "Using the <0>OK_EMBEDDINGS_API_KEY environment variable (managed outside OpenKnowledge)." msgstr "Using the <0>OK_EMBEDDINGS_API_KEY environment variable (managed outside OpenKnowledge)." +#: src/components/settings/LintingSection.tsx +msgid "Validate document frontmatter against JSON Schema files, scoped to doc sets by glob." +msgstr "Validate document frontmatter against JSON Schema files, scoped to doc sets by glob." + +#: src/components/settings/LintingSection.tsx +msgid "Validate document frontmatter against standard JSON Schema files (draft-07). Toggle a schema on to validate the docs its globs match; violations surface as warnings and never block a write. Use Edit to open the schema file." +msgstr "Validate document frontmatter against standard JSON Schema files (draft-07). Toggle a schema on to validate the docs its globs match; violations surface as warnings and never block a write. Use Edit to open the schema file." + +#: src/components/FrontmatterRow.tsx +msgid "Value for {keyName}" +msgstr "Value for {keyName}" + #: src/components/FolderPropertiesCard.tsx #: src/components/PropertyPanel.tsx msgid "Value is required" msgstr "Value is required" +#: src/components/FrontmatterRow.tsx +msgid "Values for {keyName}" +msgstr "Values for {keyName}" + #: src/components/CommandPalette.tsx msgid "via {viaTags}" msgstr "via {viaTags}" diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 67b9ecfae..2240d0129 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -92,6 +92,7 @@ "0xnQ_X": ["Ũƥďàţēś ƒĺōŵ ĩń"], "0xnmlQ": ["Ŕēƥōśĩţōŕŷ ńàḿē"], "0yL--g": ["Ŕēvĩēŵ ćōńƒĺĩćţś"], + "0zAhu6": ["ēvēŕŷ ďōć"], "10g8I_": ["Ţũŕń ōƒƒ Ōńĺŷ ḿàŕķďōŵń ƒĩĺēś"], "12F9WL": ["(ńō ĩď)"], "151wvk": ["Śĥōŵ śĩďēƀàŕ"], @@ -175,7 +176,9 @@ "34qyH5": ["Ćōũĺď ńōţ ŕēàćĥ śēŕvēŕ"], "36WXSC": ["Ćōũĺďń'ţ ďēĺēţē ţēḿƥĺàţē: ", ["error"]], "36aWJD": ["ĵōĩń ũś ōń Ďĩśćōŕď"], + "37P2BJ": ["Śēàŕćĥ śćĥēḿàś"], "38foVZ": ["Ĺōàďĩńĝ ďĩƒƒ"], + "3BLniE": ["Ćōńƒĩĝũŕàţĩōń ƥŕōƀĺēḿś"], "3BnyYc": ["Ţĥĩś ďōć ĩśń'ţ ōń ĜĩţĤũƀ ŷēţ. Ţĥē ĺĩńķ ŵōń'ţ ŵōŕķ ũńţĩĺ ĩţ'ś ƥũśĥēď."], "3DJEEt": [ ["sizeText"], @@ -185,6 +188,7 @@ "3DTatP": ["Ćōƥŷ à ƥŕōḿƥţ ƒōŕ ŷōũŕ àĝēńţ, śţàŕţ à ƀĺàńķ ƒĩĺē, ōŕ śćàƒƒōĺď ƒŕōḿ à ţēḿƥĺàţē."], "3FMvls": ["À śĥōŕţ ĺĩńē śĥōŵń ũńďēŕ ţĥē ţĩţĺē ĩń ţĥē ţēḿƥĺàţē ĺĩśţ."], "3Fcb0I": ["Ōƥēń ĝŕàƥĥ"], + "3HAPpl": ["Ńō śćĥēḿàś ḿàţćĥ ŷōũŕ śēàŕćĥ."], "3HNlwb": ["Ĥēĺĺō, ŵōŕĺď!"], "3Ib6FN": ["Ḿōvē ďōŵń"], "3Ijjy1": ["Ćōũĺďń'ţ ĺōàď ţĥĩś śķĩĺĺ'ś ƒĩĺēś."], @@ -304,10 +308,12 @@ "5Tk8Fb": ["Ĺōàďĩńĝ ƒĩĺēś"], "5Umg9i": ["Ďĩśàƀĺē ēxţēŕńàĺ ĺĩńķ ƥŕēvĩēŵś"], "5WDhAk": ["Ĺĩńķ ţō à ƥàĝē ōŕ ēxţēŕńàĺ ŨŔĹ."], + "5Y17qa": ["Àƥƥĺĩēś ţō (ĝĺōƀś — ĺēàďĩńĝ ! ēxćĺũďēś; ēḿƥţŷ ḿēàńś ēvēŕŷ ďōć)"], "5ZB7XC": ["Ƒĩĺē ďŕōƥ źōńē"], "5ZbJFS": ["Ŷōũŕ ćũŕŕēńţ vēŕśĩōń ĩś śàvēď ƒĩŕśţ, śō ţĥĩś ĩś ŕēvēŕśĩƀĺē."], "5_8TCr": ["Ƒĩx àĺĺ (", ["count"], ")"], "5_j-DA": ["Ķĩĺĺ Ţēŕḿĩńàĺ"], + "5aahsI": ["ńōţĥĩńĝ (", ["0"], " ĩś ńōţ à vàĺĩď ƥàţţēŕń)"], "5e1Dri": ["Ţĥē ḿēńũ ĺàƀēĺ àĝēńţś ƥĩćķ ţĥĩś ţēḿƥĺàţē ƀŷ (ŕēǫũĩŕēď)."], "5gr_ev": [ "ßēćōḿēś ţĥē ďōćũḿēńţ'ś ćōńţēńţ ŵĥēń śōḿēōńē ćŕēàţēś à ďōć ƒŕōḿ ţĥĩś ţēḿƥĺàţē. Ţŷƥē <0>", @@ -336,7 +342,9 @@ } ] ], + "5qQr1u": ["Àĺĺōŵēď vàĺũēś ƒōŕ ", ["field"]], "5rnB76": ["Ŷōũŕ ēďĩţś śţàŷ ōń ţĥĩś ćōḿƥũţēŕ"], + "5tcmwY": ["Àďď vàĺũē"], "5vYdh9": ["Ńēŵ ţĥŕēàď ŵĩţĥ ", ["0"]], "5wV2qQ": ["àńď ḿōŕē"], "5y_9lO": ["Ŵàĩţĩńĝ ƒōŕ àũţĥōŕĩźàţĩōń"], @@ -380,6 +388,7 @@ "6ki7F2": [["0", "plural", { "one": ["#", " ĩţēḿ"], "other": ["#", " ĩţēḿś"] }]], "6lGV3K": ["Śĥōŵ ĺēśś"], "6m_7We": ["Ōƥēń ţĥĩś śēţţĩńĝś ďĩàĺōĝ."], + "6pe_G5": ["Ēĺēḿēńţ ţŷƥē"], "6q0rnH": ["ŵŕĩţĩńĝ"], "6qMe0o": [ "Ďŕàƒţ à ţēćĥńĩćàĺ śƥēć ƒōŕ ḿŷ ƥŕōĴēćţ. Śţàŕţ ŵĩţĥ àń ōvēŕvĩēŵ, ţĥēń àďď ćŕōśś-ĺĩńķēď ƥàĝēś ƒōŕ ţĥē àŕćĥĩţēćţũŕē, ţĥē ķēŷ ḿōďũĺēś, àńď ţĥē ōƥēń ǫũēśţĩōńś, àĺĺ ĺĩńķēď ƒŕōḿ à śƥēćś ĩńďēx." @@ -390,9 +399,13 @@ "6zzhQL": ["Śēńď ŷōũŕ ŕēƥōŕţ ƀŷ ēḿàĩĺ"], "72xhRD": ["Ďĩàĝŕàḿ vĩēŵ ḿōďē"], "74-bX-": ["À ƥũĺĺ ǫũōţē śţàńďś ōũţ ƒŕōḿ ţĥē śũŕŕōũńďĩńĝ ţēxţ."], + "74C2zx": ["Ēďĩţ śćĥēḿà ", ["file"]], "74eHt-": ["Ńō ƒĩĺē ōŕ ƒōĺďēŕ śēĺēćţēď"], "75Y1dk": ["Ĥōŕĩźōńţàĺ ƥĩĺĺ śţŕĩƥ + àćţĩvē ƥàńēĺ ƀēĺōŵ; ćĺĩćķ à ƥĩĺĺ ţō śŵĩţćĥ ƥàńēĺś."], "77SdEP": ["Ďēĺēţē ", ["itemName"]], + "78G7R3": [ + "Vàĺĩďàţē ďōćũḿēńţ ƒŕōńţḿàţţēŕ àĝàĩńśţ ĵŚŌŃ Śćĥēḿà ƒĩĺēś, śćōƥēď ţō ďōć śēţś ƀŷ ĝĺōƀ." + ], "7FamGZ": ["Ĝō ţō ĺĩńē"], "7Fkcej": ["+ Àďď ", ["addChildName"]], "7H4UHD": ["Ďōćũḿēńţś ţàĝĝēď <0>#", ["tagName"], ""], @@ -480,6 +493,11 @@ "92rbk2": ["Ḿĩŕŕōŕ ĺōàďĩńĝ <0>", ["mirrorRef"], ""], "96G6Re": ["Ńēŵ ƒōĺďēŕ"], "96w9hU": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ ōƥēńĩńĝ ţĥĩś ƒĩĺē (ĤŢŢƤ ", ["status"], ")."], + "98FP1E": [ + "Ţĥĩś śćĥēḿà ćàŕŕĩēś ŕōōţ-ĺēvēĺ àďvàńćēď ŕũĺēś ţĥē ēďĩţōŕ ďōēś ńōţ śĥōŵ (", + ["0"], + ") — ţĥēŷ śţĩĺĺ vàĺĩďàţē àńď śũŕvĩvē ēvēŕŷ ēďĩţ. Ōƥēń ţĥē śćĥēḿà ƒĩĺē ţō śēē ţĥēḿ." + ], "99eR72": ["Vĩśũàĺ ēďĩţōŕ ţàƀĺē śēĺēćţĩōń"], "9Bd18D": ["Ƒàĩĺēď ţō ĺōàď ĥũƀ ƥàĝēś"], "9KooKn": ["Ŕũń à ƥŕōĴēćţ àũďĩţ ƒŕōḿ ţĥē Ƥŕōƀĺēḿś ƥàńēĺ."], @@ -513,6 +531,7 @@ "9i21pa": ["Ćōḿḿĩţś ĥàƥƥēń àũţōḿàţĩćàĺĺŷ"], "9jnjV0": ["Ēḿƀēď àń àũďĩō ƒĩĺē ŵĩţĥ ńàţĩvē ƥĺàŷēŕ ćōńţŕōĺś."], "9mEZeD": [["name"], " ĆĹĨ"], + "9nIrRp": ["Ńēśţēď ƒĩēĺďś"], "9npOH9": ["Ōƥēń ƒĩĺē"], "9o1bIV": [["behind"], " ƀēĥĩńď"], "9oE4C3": ["ßŕōŵśē ŷōũŕ ŕēƥōś:"], @@ -613,6 +632,9 @@ "BymzO4": ["Ďōćũḿēńţ ōũţĺĩńē"], "C-xgad": ["ōŕ ũśē à śţàŕţēŕ ƥàćķ"], "C0hoX6": ["Àĺŵàŷś ĩńćĺũďēď"], + "C52RBg": [ + "Vàĺĩďàţē ďōćũḿēńţ ƒŕōńţḿàţţēŕ àĝàĩńśţ śţàńďàŕď ĵŚŌŃ Śćĥēḿà ƒĩĺēś (ďŕàƒţ-07). Ţōĝĝĺē à śćĥēḿà ōń ţō vàĺĩďàţē ţĥē ďōćś ĩţś ĝĺōƀś ḿàţćĥ; vĩōĺàţĩōńś śũŕƒàćē àś ŵàŕńĩńĝś àńď ńēvēŕ ƀĺōćķ à ŵŕĩţē. Ũśē Ēďĩţ ţō ōƥēń ţĥē śćĥēḿà ƒĩĺē." + ], "C62P6b": ["Ēďĩţōŕ śēĺēćţĩōń"], "C7XjNB": ["Ĝĺōƀàĺ Śķĩĺĺ"], "C9TBvF": ["Ńō Ōũţĝōĩńĝ"], @@ -630,6 +652,7 @@ "CVRM5N": [ "Ƒōĺĺōŵ ƒēţćĥēś ũƥďàţēś ƒŕōḿ ŷōũŕ ŕēḿōţē ĝĩţ ŕēƥōśĩţōŕŷ àńď ƒàśţ-ƒōŕŵàŕďś ŷōũŕ ćōƥŷ àũţōḿàţĩćàĺĺŷ. Ŷōũŕ ĺōćàĺ ēďĩţś śţàŷ ōń ţĥĩś ćōḿƥũţēŕ àńď àŕē ńēvēŕ ƥũśĥēď." ], + "CWjM1r": ["ďōćś ďĩŕēćţĺŷ ĩń ", ["0"], "/"], "CWzc2T": [ "<0>~/.ōķ/ƀĩń àńď <1>~/.ōķ/ēńv.śĥ śţàŷ ēĩţĥēŕ ŵàŷ — ţĥē àƥƥ àĺŵàŷś ḿàĩńţàĩńś ţĥēḿ." ], @@ -648,6 +671,7 @@ "CoHvYd": ["Ḿōvēď ", ["0"], " ţō ƥōśĩţĩōń ", ["1"], " ōƒ ", ["2"]], "CooKEf": ["Ďōćķ śēśśĩōńś àţ ţĥē ƀōţţōḿ"], "CvsD6Y": ["vĩà ", ["viaTags"]], + "CwOXMT": ["Vàĺũē ƒōŕ ", ["keyName"]], "D-Oh1s": ["Ĥōŵ ţō śēţ ũƥ ", ["0"], " (ōƥēńś ĩń ƀŕōŵśēŕ)"], "D12vtS": ["(ďēţàćĥēď)"], "D2CwBE": [ @@ -657,6 +681,11 @@ ["1", "plural", { "one": ["#", " ƒōĺďēŕ"], "other": ["#", " ƒōĺďēŕś"] }], " àńď śţàŕţēŕ ţēḿƥĺàţēś) ĩń ţĥē ƒōĺďēŕ ōƒ ŷōũŕ ćĥōĩćē." ], + "D2E7tr": [ + "Ţĥĩś ƥēŕḿàńēńţĺŷ ďēĺēţēś ", + ["deleteTarget"], + " ƒŕōḿ ţĥē ƥŕōĴēćţ àńď ŕēḿōvēś ĩţś ḿàƥƥĩńĝ ƒŕōḿ ćōńƒĩĝ.ŷḿĺ. Ďōćś ĩţ vàĺĩďàţēď ķēēƥ ţĥēĩŕ ƒŕōńţḿàţţēŕ; ţĥēŷ Ĵũśţ śţōƥ ƀēĩńĝ ćĥēćķēď." + ], "D2emTw": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ śēńďĩńĝ ŷōũŕ ƒēēďƀàćķ. Ƥĺēàśē ţŕŷ àĝàĩń."], "D4H2N6": ["Ŕēḿōvē ", ["tag"]], "D8JJwQ": ["Ƒēēďƀàćķ ĩśń'ţ àvàĩĺàƀĺē ŕĩĝĥţ ńōŵ. Ƥĺēàśē ţŕŷ àĝàĩń ĺàţēŕ."], @@ -732,6 +761,7 @@ "ELYtNJ": [".ōķ ƒōĺďēŕś"], "EL_YRd": ["Ḿĩŕŕōŕ ōƒ <0>àƥĩ-śƥēć"], "ENpRJV": ["Ēxţēŕńàĺ ĺĩńķ ƥŕēvĩēŵś"], + "EO4Dt1": ["Ēńàƀĺē ", ["file"]], "EOjU5V": ["Ĩńĺĩńē Ḿàţĥ Ƥŕōƥēŕţĩēś"], "EPwuTC": [["usedLabel"], " / ", ["sizeLabel"], " ţōķēńś ũśēď"], "ETyvvg": ["Ńō ĺàńĝũàĝē ḿàţćĥ."], @@ -760,6 +790,7 @@ "EiVgl5": ["Ŷōũ ďōń'ţ ĥàvē ƥēŕḿĩśśĩōń ţō ƥũśĥ ţō ţĥĩś ŕēƥō."], "Eji13z": ["Ţōĝĝĺē ĥĩĝĥĺĩĝĥţ ƒōŕḿàţţĩńĝ."], "EkH9pt": ["Ũƥďàţē"], + "EkUnmS": ["àńŷ"], "El7NbA": ["Ĥēàďĩńĝ 2"], "ElatXA": [ "Ćōńţēxţ ŵĩńďōŵ: ", @@ -897,6 +928,7 @@ "H3oH0g": ["Ŕēďō"], "H6nKok": ["Śĥōŵ ĩń Ƒĩĺē Ḿàńàĝēŕ"], "H86f9p": ["Ćōĺĺàƥśē"], + "H9E1bE": ["Ŕēśēţ ţō ďēƒàũĺţ (ŕēḿōvēś ĩţ ƒŕōḿ ćōńƒĩĝ.ŷḿĺ)"], "HAvRAI": [["skillCount", "plural", { "one": ["śķĩĺĺ"], "other": ["śķĩĺĺś"] }]], "HCn0Ay": ["Ţōĝĝĺē śţŕĩķēţĥŕōũĝĥ ƒōŕḿàţţĩńĝ."], "HGNCsl": ["Ćōńńēćţĩōń ĺōśţ — ķēēƥ ţĥĩś ţàƀ ōƥēń, ŷōũŕ ēďĩţś ŵĩĺĺ śŷńć ŵĥēń ŕēćōńńēćţēď"], @@ -910,6 +942,7 @@ "HjNrT0": [["0", "plural", { "one": ["#", " ƀàćķĺĩńķ"], "other": ["#", " ƀàćķĺĩńķś"] }]], "Hl89Qd": ["Ĺōàďĩńĝ ţēḿƥĺàţēś"], "HlmZBk": ["Ďōćũḿēńţś ŕēśƥōńśē ďĩď ńōţ ḿàţćĥ ēxƥēćţēď śĥàƥē."], + "Hngy37": ["Ńō ƥŕōƀĺēḿś ƒōũńď — ƀũţ ńō ĺĩńţ ƥĺũĝĩńś àŕē ēńàƀĺēď, śō ōńĺŷ ĺĩńķś àŕē ćĥēćķēď."], "Hnl9nT": ["Śēţ ŵĥēŕē ƥàśţēď àńď ďŕōƥƥēď ƒĩĺēś àŕē śţōŕēď ƒōŕ ţĥĩś ƥŕōĴēćţ."], "HoWElC": [ "Ţĥĩś ", @@ -938,11 +971,13 @@ "IA1wNb": ["Ţĥē Ćĺàũďē Ďēśķţōƥ Àƥƥ ďĩďń'ţ ōƥēń ţĥē ƒĩĺē. Ĩś ĩţ ĩńśţàĺĺēď? (", ["msg"], ")"], "IA6JW2": ["Ďēţēćţĩńĝ"], "ICoNYz": ["Ŷōũ ćàń àĺśō ḿàńàĝē ţĥĩś ĩń Śēţţĩńĝś."], + "IFALcX": ["Ďēśćŕĩƥţĩōń ƒōŕ ", ["field"]], "IFezUv": ["Ţĥē àũďĩţ ćōũĺď ńōţ ƀē ćōḿƥĺēţēď. Ţŕŷ àĝàĩń."], "IInnZo": ["Ćōńƒĺĩćţ"], "IKoTAC": ["Ķēēƥ ďĩśàƀĺēď"], "IN4UX8": ["Śēàŕćĥ (", ["searchShortcut"], ")"], "IQPyrD": ["Ḿĩśśĩńĝ ƥàĝē — ćĺĩćķ ţō ćŕēàţē"], + "IQgeSn": ["Ƒàĩĺēď ţō śàvē ţĥē śćĥēḿà ƒĩēĺď"], "IRv9cH": ["Ćĺōńēď ", ["repoName"], "."], "IWGfGF": ["Ćĥēćķĩńĝ ŷōũŕ ĜĩţĤũƀ ćōńńēćţĩōń"], "IX7-sq": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ ōƥēńĩńĝ \"", ["docName"], "\"."], @@ -966,6 +1001,7 @@ "IxyZXD": ["Śćŕēēńśĥōţ"], "Izb10l": ["Ƒĩx àĺĺ ", ["count"], " ƒĩxàƀĺē ƥŕōƀĺēḿś"], "J2Mv98": ["Ńàḿē (ē.ĝ. ", ["login"], ")"], + "J2_JuX": ["ńōţĥĩńĝ (", ["0"], " ćàńńōţ ḿàţćĥ à ďōć)"], "J2eKUI": ["Ƒĩĺē"], "J3HzPL": ["Ćōĺĺàƥśē ", ["keyName"]], "J6N5MX": ["Ćōũĺďń'ţ ćōńśţŕũćţ ţĥē śĥàŕē ŨŔĹ. Ţŕŷ Ďōńē àńď ŕē-śĥàŕē."], @@ -1075,6 +1111,7 @@ "LKOLNn": ["Ńō ÀĨ àĝēńţś ĩńśţàĺĺēď"], "LNum6A": [["minutes"], " ḿĩń àĝō"], "LPAv9E": [["days"], "ď àĝō"], + "LQdLio": ["Ńēŵ śćĥēḿà"], "LQsCPn": ["Ĺōćàĺ ćōḿḿĩţś àŕē ķēēƥĩńĝ ţĥĩś ćōƥŷ ƒŕōḿ ũƥďàţĩńĝ"], "LRgZAt": [["owner"], "/", ["name"], " àĺŕēàďŷ ēxĩśţś"], "LTeJuX": ["Ţōĝĝĺē Ƒũĺĺ Śćŕēēń"], @@ -1201,6 +1238,7 @@ "NSnIUP": ["Ţŵō-ƥàĝē (ēvēń)"], "NUr4kz": ["À ŕēũśàƀĺē śţàŕţĩńĝ ƥōĩńţ ƒōŕ ńēŵ ďōćũḿēńţś ĩń ţĥĩś ƒōĺďēŕ."], "NYUhy5": ["Śũƀƒōĺďēŕ ńàḿē"], + "NYn1cR": ["Ĺōàďĩńĝ ēďĩţōŕ"], "NYu2Pt": ["Ńēŵ ƥŕōĴēćţ…"], "N_7kkF": [ "Ŕēàď ţĥŕōũĝĥ ţĥĩś ćōďēƀàśē àńď ďŕàƒţ à ţēćĥńĩćàĺ śƥēć ƒōŕ ţĥē ḿōśţ ćōḿƥĺēx ḿōďũĺē: àń ōvēŕvĩēŵ, ţĥē àŕćĥĩţēćţũŕē, ķēŷ ƒĩĺēś, àńď ōƥēń ǫũēśţĩōńś, àĺĺ ĺĩńķēď ƒŕōḿ à śƥēćś ĩńďēx ƥàĝē." @@ -1255,6 +1293,7 @@ "OXdGnd": ["Ƥŕōƀĺēḿś"], "OYnx7O": ["Ƥàţĥ ćàńńōţ śţàŕţ ŵĩţĥ \"/\""], "OZJeTZ": ["Śķĩĺĺ"], + "OZavHH": ["ēvēŕŷţĥĩńĝ ũńďēŕ ", ["0"], "/"], "Oawo4U": ["Ţĥē ŕēƥōŕţ śēŕvĩćē ćōũĺďń'ţ ƀē ŕēàćĥēď."], "OcBurH": [ "Ćĥōōśē ŵĥĩćĥ àĝēńţś àƥƥēàŕ ĩń ţĥē àĝēńţ śēĺēćţōŕ. Ţũŕń ōńē ōƒƒ ţō ĥĩďē ĩţ ēvēŕŷŵĥēŕē." @@ -1288,6 +1327,7 @@ "PBwmDJ": ["Śēàŕćĥĩńĝ ƀŷ ḿēàńĩńĝ"], "PBxg_E": ["Ńōţ ńōŵ"], "PCSkw2": ["Śķĩĺĺś"], + "PGPGsL": ["Àďď ďēśćŕĩƥţĩōń"], "PIMM5l": ["Ŷōũ'ŕē śũƀśćŕĩƀēď!"], "PKLY-1": ["Ōƥēńĩńĝ..."], "PKjGhT": ["Ĥēàďĩńĝś"], @@ -1298,6 +1338,7 @@ "PRnH8G": ["ōƒ ", ["0"]], "PTb1Nx": ["Śŷńć ĩś ƥàũśēď. Ţũŕń ĩţ ƀàćķ ōń ţō ŕēśũḿē."], "PVUn_c": ["Ōƥēń ƒōĺďēŕ"], + "PVggyD": ["Ƒŕōńţḿàţţēŕ śćĥēḿàś"], "PYmp-0": ["Ĩḿƥōŕţ àś ţēḿƥĺàţē"], "PZtgO0": ["Ōƥēńĩńĝ ŵōŕķţŕēē"], "P_bX4O": ["Ĥōŵ ţō śēţ ũƥ"], @@ -1309,6 +1350,7 @@ "Pgu10b": ["Śàvēď àś <0>", ["slug"], ".ḿď"], "PgzIpb": ["Ḿōvē ţō ţĥē ƥŕēvĩōũś vĩśũàĺ-ēďĩţōŕ ƒĩńď ŕēśũĺţ."], "PiH3UR": ["Ćōƥĩēď!"], + "PijLsG": ["Ƒĩĺē ńàḿē (ćŕēàţēď ĩń .ōķ/śćĥēḿàś/)"], "PkeYXL": ["Ēxƥàńďēď ĝŕàƥĥ ḿōďē"], "Pkw7J9": ["Ţĥĩś ƒōĺďēŕ ĩś ēḿƥţŷ."], "PlGC6r": ["ţĥē ĝĩţ ēxćĺũďē ƒĩĺē ĩśń'ţ ŵŕĩţàƀĺē"], @@ -1330,6 +1372,7 @@ " àĺŕēàďŷ ĥàś à ŵōŕķţŕēē — ĩţ'ĺĺ ōƥēń ĩń ĩţś ōŵń ŵĩńďōŵ." ], "QDZbvN": [["diffDays"], "ď àĝō"], + "QLgdT6": ["àńŷ ďōć ńàḿēď ", ["0"]], "QM3SBZ": ["Ƥĺàń (", ["done"], "/", ["0"], ")"], "QOINDn": ["ēḿƥţŷ"], "QQbwzK": ["Ńō àĝēńţś àvàĩĺàƀĺē."], @@ -1385,6 +1428,7 @@ "RXEbZy": ["Ĩń àƥƥ"], "RXNu_r": ["Śĥàŕēď vĩà ĝĩţ ŵĩţĥ ēvēŕŷōńē ŵōŕķĩńĝ ōń ţĥĩś ƥŕōĴēćţ."], "RY6Bq1": ["Ōƥēń śĥàŕēď ", ["targetNoun"]], + "R_U_A4": ["Àďď ƒĩĺē ōŕ ƒōĺďēŕ ƥàţţēŕń, ē.ĝ. ĝũĩďēś/**/*"], "RaPMZv": ["Ķēŷƀōàŕď śĥōŕţćũţś àvàĩĺàƀĺē ĩń ţĥē ēďĩţōŕ àńď ŵōŕķśƥàćē."], "Rah7yq": ["Ŵĥàţ śĥōũĺď ţĥē ÀĨ ďō? (ōƥţĩōńàĺ)"], "Rb1zMa": ["Ńō ēxćēŕƥţ"], @@ -1438,6 +1482,7 @@ "SgKLfA": ["Ńōţ ćōńńēćţēď ţō ĜĩţĤũƀ."], "Sh3Qkg": ["Ńō ḿàţćĥĩńĝ ƀŕàńćĥēś."], "Si7mIR": ["Ćŕēàţē ŕēƥōŕţ"], + "SiFgBx": ["ďōćś ḿàţćĥĩńĝ ", ["0"]], "SiUUCS": ["Ńēxţ ḿàţćĥ"], "SkMZlS": ["Ćōũĺďń'ţ ŕēḿōvē <0>", ["targetGitPath"], ": ", ["removeGitError"]], "Skr_7L": ["Śēàŕćĥ ŵōŕķţŕēēś"], @@ -1458,6 +1503,7 @@ "TJnkyf": ["ōķ ũĩ ŕēśƥōńďēď ƀũţ śēŕvēŕ.ĺōćķ ĥàś ńō ƥōŕţ ŷēţ"], "TKQ7K-": ["Ĩńśţàĺĺ"], "TLIO3j": ["Ţōĝĝĺē ĩţàĺĩć ƒōŕḿàţţĩńĝ."], + "TMLAx2": ["Ŕēǫũĩŕēď"], "TQJCR6": [["0", "plural", { "one": ["#", " ŕēśũĺţ"], "other": ["#", " ŕēśũĺţś"] }]], "TTDXD8": ["Ńēŵ ŵōŕķţŕēē…"], "TUzThQ": ["Śĥàŕē śķĩĺĺś"], @@ -1466,6 +1512,9 @@ "Ta25TG": ["Ńō ĥĩśţōŕŷ ŷēţ"], "TbjyhA": ["Ďōćś"], "TdfEV7": ["Àŕćĥĩvēď"], + "TdlVEN": [ + "Ţĥĩś ƒĩēĺď ćàŕŕĩēś àďďĩţĩōńàĺ śćĥēḿà ķēŷŵōŕďś ţĥē ēďĩţōŕ ďōēś ńōţ śĥōŵ — ţĥēŷ àŕē ƥŕēśēŕvēď ōń śàvē." + ], "Te6sK2": [ "Ĥēàďś ũƥ: ēďĩţĩńĝ àńŷ ŕũĺē ōŕ ōƥţĩōń ĥēŕē ćŕēàţēś à <0>.ḿàŕķďōŵńĺĩńţ.Ĵśōń ƒĩĺē ĩń ŷōũŕ ƥŕōĴēćţ ţō śàvē ŷōũŕ ćĥàńĝēś." ], @@ -1485,10 +1534,12 @@ "U5Spo9": ["Ćōńţēńţ ďĩŕēćţōŕŷ ḿũśţ ƀē ĩńśĩďē ţĥē ƥŕōĴēćţ"], "U6wmNc": ["Śŷńć ƥàũśēď"], "U7HJ4y": ["Ḿàńàĝēď ƀŷ ŌƥēńĶńōŵĺēďĝē (ŕēàď-ōńĺŷ)"], + "U88xbi": ["Ƒàĩĺēď ţō ćŕēàţē ţĥē śćĥēḿà ƒĩĺē"], "U8CD-l": ["Ćōƥŷ ƒũĺĺ ƥàţĥ, Ńō ŵōŕķśƥàćē"], "U9tm58": ["Ĥĩďē ƥŕōƀĺēḿ ĩńďĩćàţōŕś"], "UBv8UK": ["Ďēĺēţē Ƥēŕḿàńēńţĺŷ"], "UDFrWm": ["Ćĥēćķĩńĝ ĜĩţĤũƀ ćōńńēćţĩōń..."], + "UGeJuP": ["Àĺĺōŵēď vàĺũēś (ēḿƥţŷ = àńŷ)"], "UL8A9w": ["Ţàĝ"], "ULRb5t": [ "Àďďś ţēĺēḿēţŕŷ, śēŕvēŕ śţàţē, àńď ŕũńţĩḿē ĩńƒō ŵĥēń àvàĩĺàƀĺē. Ďōćũḿēńţ ńàḿēś àŕē àńōńŷḿĩźēď." @@ -1565,6 +1616,7 @@ "VZSBXL": ["Śēḿàńţĩć ŕàńķĩńĝ àćţĩvàţēś ţĥē ƒĩŕśţ ţĩḿē àń àĝēńţ ŕũńś à śēàŕćĥ."], "V_6bJ4": ["Ţēŕḿĩńàĺ śēţţĩńĝś ńōţ ĺōàďēď ŷēţ — ţŕŷ àĝàĩń ĩń à ḿōḿēńţ."], "V_wltx": ["Ēńţēŕ à ţĩţĺē ƒōŕ ţĥĩś ţēḿƥĺàţē."], + "Va3xJe": ["Àďď ƒĩēĺď"], "VbqhBI": ["Àĺŕēàďŷ àţ ", ["assetContentPath"], " — ŕēũśĩńĝ."], "VcqWBy": ["Ũƥĺōàď ḿàŷ ĥàvē śũććēēďēď ƀũţ ţĥē śĩďēƀàŕ ĩś ōũţ ōƒ ďàţē — ŕēƒŕēśĥ ţō ŕēśŷńć"], "VdXv4D": ["Ćĺōńē ƒŕōḿ ĜĩţĤũƀ"], @@ -1693,6 +1745,7 @@ "YEIS9T": ["Ēďĩţś àƥƥĺŷ ĺĩvē. Ĺĩĝĥţ ōŕ ďàŕķ ĩś ćĥōśēń ƒŕōḿ ţĥē ƀàćķĝŕōũńď."], "YEP3X2": ["Ďēĺēţē à ţàƀĺē ŵĥēń ţĥē àćţĩvē ţàƀĺē ćēĺĺ śēĺēćţĩōń ćōvēŕś ţĥē ƒũĺĺ ţàƀĺē."], "YGIi_c": ["Ŕēĩńśţàĺĺ"], + "YGNRB5": ["— ēxćēƥţ"], "YJ1BTp": ["À śķĩĺĺ ŵĩţĥ ţĥàţ ńàḿē àĺŕēàďŷ ēxĩśţś."], "YJPN3F": ["Ţōō śĺōŵ"], "YKLP_-": ["Ţēḿƥĺàţē ŕēńàḿēď"], @@ -1794,6 +1847,7 @@ "_IX_7x": ["Ōţĥēŕ"], "_JOGC0": ["Śēńď ƒēēďƀàćķ…"], "_LczaQ": ["Ďēńĩēď — ", ["optionName"]], + "_NWEid": ["Śēàŕćĥ śćĥēḿà ƒĩĺēś ƀŷ ƥàţĥ"], "_NX6gG": ["Ḿàńàĝĩńĝ ƥŕōĴēćţ ÀĨ ţōōĺś ĩś ũńàvàĩĺàƀĺē ĩń ţĥĩś ƀũĩĺď."], "_PACVa": ["Ŕēĺēàśē ńōţēś"], "_PBJRl": ["Àďď àţ ĺēàśţ ōńē ĺēţţēŕ ōŕ ńũḿƀēŕ."], @@ -1824,8 +1878,10 @@ "_rP3HI": [["minutes"], "ḿ àĝō"], "_tekeB": ["ũƥďàţēď ƒōĺďēŕ ƥŕōƥēŕţĩēś"], "_tjopj": ["Ƥàśţē ďũƥĺĩćàţē"], + "_yB-24": ["ēvēŕŷţĥĩńĝ ũńďēŕ àńŷ ", ["0"], "/ ƒōĺďēŕ"], "a-cKXz": ["Śēĺēćţ àĺĺ śōũŕćē"], "a3H_MR": ["Ōƥēń àńď ƒōćũś ţĥē Àśķ ÀĨ ćōḿƥōśēŕ ƒōŕ ţĥē ćũŕŕēńţ ēďĩţōŕ śēĺēćţĩōń."], + "a45f2c": ["Ĝĺōƀ ƥàţţēŕńś ţĥĩś śćĥēḿà àƥƥĺĩēś ţō"], "a4FRQv": ["Ũńďō ţō ţĥĩś ēďĩţ?"], "a5Gha5": ["Ĺōćàĺ ćĥàńĝēś ƀĺōćķēď ţĥē ḿēŕĝē"], "a5W0q0": [ @@ -1880,6 +1936,7 @@ "bBE5tC": ["Ďēţēćţēď ōń ţĥĩś ḿàćĥĩńē"], "bBcFK_": ["Ōƥēń ēḿōĴĩ ƥĩćķēŕ ƒōŕ ", ["keyName"]], "bD8I7O": ["Ćōḿƥĺēţē"], + "bF22t6": ["Ŕēḿōvē ƒĩēĺď ", ["field"]], "bGa3Fk": ["Ũƥĺōàďĩńĝ ƒĩĺē"], "bH382I": ["Ēďĩţ ţĥĩś ĤŢḾĹ — ţĥē ƥŕēvĩēŵ ũƥďàţēś ĺĩvē."], "bSD4im": ["Śēàŕćĥ ŵōŕķţŕēēś àńď ƀŕàńćĥēś"], @@ -1901,6 +1958,7 @@ "bwil08": ["ßũĩĺďĩńĝ <0>ōƥēńķńōŵĺēďĝē.śķĩĺĺ àńď ōƥēńĩńĝ ţĥē Ćĺàũďē Ďēśķţōƥ Àƥƥ"], "c1LZZH": ["Ĺĩńķ ćōƥĩēď."], "c3XJ18": ["Ĥēĺƥ"], + "c3ZGrq": ["ţĥē ďōć ", ["0"]], "c4fVUV": ["Ƒĩx"], "c5PTFE": ["Ďēv ĩńśţàńćē: ", ["label"], " (ĩśōĺàţēď ƒŕōḿ ōţĥēŕ ŵōŕķţŕēēś)"], "c5f7sm": ["Ćōďē ƀĺōćķ ĺàńĝũàĝē: ", ["currentLabel"], ". Ćĺĩćķ ţō ćĥàńĝē."], @@ -1923,6 +1981,7 @@ "cOSAfL": ["Ńō ŕēƥōśĩţōŕĩēś ƒōũńď ōń ŷōũŕ ĜĩţĤũƀ àććōũńţ."], "cOrTMo": ["Ŷōũŕ śĥàŕē ĺĩńķ ĩś ŕēàďŷ ƀēĺōŵ."], "cRHuuI": ["Ćōũĺďń'ţ ćōƥŷ ƥàţĥ"], + "cRm498": ["Ńō śćĥēḿà ƒĩĺēś ĩń ţĥĩś ƥŕōĴēćţ ŷēţ — ćŕēàţē ōńē ţō śţàŕţ vàĺĩďàţĩńĝ."], "cSCbv3": ["ßĒŢÀ"], "cUK8lW": [ ["conflictCount", "plural", { "one": ["#", " ćōńƒĺĩćţ"], "other": ["#", " ćōńƒĺĩćţś"] }] @@ -1959,6 +2018,7 @@ "crqzW5": ["ÀĨ ţōōĺś"], "cs4mps": ["Ćōńƒĩĝ śĥàŕĩńĝ"], "csDS2L": ["Àvàĩĺàƀĺē"], + "cstC0z": ["Ƥàţţēŕń (ŕēĝēx)"], "ct2kv5": ["Ńō ĥēàďĩńĝś ĩń ", ["pageTarget"]], "cuA8QW": ["Ōŕĝàńĩźē śƥēćś"], "d1JjXo": [ @@ -2018,6 +2078,7 @@ "e4FxjG": ["Śēē àĺĺ ", ["0"], " śţàŕţēŕ ƥàćķś"], "e4GHWP": ["Ƥũĺĺ"], "e4PbgC": ["Ďũƥĺĩćàţē ţĥē ƒĩĺē-ţŕēē ĩţēḿ ćōƥĩēď ƒŕōḿ ţĥē Ƒĩĺēś śĩďēƀàŕ."], + "e60wew": ["Àĺĺōŵēď ēĺēḿēńţ vàĺũēś ƒōŕ ", ["field"]], "e8L17t": ["À ńōń-ēḿƥţŷ ƒōĺďēŕ àĺŕēàďŷ ēxĩśţś àţ ţĥĩś ƥàţĥ. Ƥĩćķ à ďĩƒƒēŕēńţ ƒōĺďēŕ."], "e8zRrb": ["Śĥōŵ Ƒĩĺēś"], "e8zYa8": ["Ćōũĺď ńōţ ōƥēń ", ["shareBranch"], " ĩń à ŵōŕķţŕēē. Ţŕŷ àĝàĩń."], @@ -2048,6 +2109,7 @@ "Ƒōĺĺōŵ ńēvēŕ ƥũśĥēś ōŕ ćōḿḿĩţś ŷōũŕ ĺōćàĺ ćĥàńĝēś. Ţĥēŷ śţàŷ ōńĺŷ ōń ţĥĩś ḿàćĥĩńē." ], "evs0iw": ["Ţàĝś ḿàţćĥĩńĝ \"", ["tagListQuery"], "\""], + "ezUmoU": ["Àĺĺōŵēď ēĺēḿēńţ vàĺũēś (ēḿƥţŷ = àńŷ)"], "f1XQyZ": ["Ḿōvē ƀĺōćķ"], "f3Nvg7": [ ["0"], @@ -2117,6 +2179,7 @@ "gYz2ep": ["Ēxƥĩŕēś ĩń ", ["timeLabel"]], "gZElfU": ["Ƒĩxĩńĝ ", ["0"], " ōƒ ", ["1"], " ƒĩĺēś"], "gbHcbI": ["Ƒàĩĺēď ţō ďēĺēţē ƥŕōƥēŕţŷ"], + "geIbmI": ["Ŕēśēţ ", ["file"], " ţō ďēƒàũĺţ"], "gfdqcY": ["Ćōũĺďń'ţ àƥƥĺŷ ţĥē ćĥàńĝē: ", ["0"]], "gfohmY": ["Ćōńńēćţ ţōōĺś"], "ggm6JX": [ @@ -2172,6 +2235,7 @@ "haHgQj": ["Ćōƥĩēď ŕēĺàţĩvē ƥàţĥ"], "habOU_": ["Ƒàĩĺēď ţō ũƥďàţē ţĥē àũţō-àƥƥŕōvē śēţţĩńĝ — ", ["detail"]], "he3ygx": ["Ćōƥŷ"], + "hgtsat": ["Ƒàĩĺēď ţō ďēĺēţē ", ["file"]], "hh4mVC": ["Àśķ ÀĨ ţō ƒĩx ", ["flatId"]], "hjO0Mq": ["Àďď ţàƀ"], "hmYd9H": [ @@ -2223,6 +2287,7 @@ "iTylMl": ["Ţēḿƥĺàţēś"], "iXTp5j": ["Śćōƥē: "], "idD8Ev": ["Śàvēď"], + "idni-S": ["Vàĺũēś ƒōŕ ", ["keyName"]], "ig5vUe": ["Ƒàĩĺēď ţō ĺōàď ƒĩĺē"], "ii5mVn": ["Ŕēƥōŕţ ţĥĩś ēŕŕōŕ"], "iilQXA": ["Ƒōĺďēŕ ďũƥĺĩćàţēď"], @@ -2253,6 +2318,7 @@ "jEZ6Lf": ["ƥŕōvĩśĩōńàĺ"], "jEdF24": ["Ḿĩśśĩńĝ ƥàĝē: ", ["linkTitle"], ". Ćĺĩćķ ţō ćŕēàţē."], "jKd3AP": ["Ćōũĺď ńōţ ēńàƀĺē ţĥē ţēŕḿĩńàĺ: ", ["0"]], + "jL3gox": ["Śēĺēćţ à vàĺũē"], "jPUPvG": ["Ćōũĺďń'ţ ŕēśōĺvē ţĥē ćōńƒĺĩćţ ƒōŕ ", ["filePath"], "."], "jRAGrQ": ["Ćōńƒĩĝũŕē àĝēńţś"], "jRHdCz": ["Ƒàĩĺēď ţō ĺōàď ƤĎƑ"], @@ -2340,6 +2406,7 @@ "knjOzA": ["Ţàĝ vàĺũē"], "ko386X": ["À ďōćũḿēńţ ĥàś à ćōńƒĺĩćţ — ŕēśōĺvē ĩţ ţō ķēēƥ ĩţ ũƥ ţō ďàţē."], "kolRU1": ["Ďō ŷōũ ŵàńţ ţō ƥēŕḿàńēńţĺŷ ďēĺēţē ĩńśţēàď?"], + "kooyLJ": ["Ƒàĩĺēď ţō ŕēńàḿē ţĥē ƒĩēĺď"], "kpqWwG": ["Ţĥĩś ", ["targetNoun"], " ŵàś ŕēḿōvēď ƒŕōḿ ƀŕàńćĥ <0>", ["branch"], "."], "ktspdq": ["Ţĩńţ àńď ƀàďĝē ƒĩĺēś ţĥàţ ĥàvē ĺĩńţ ōŕ ĺĩńķ ƥŕōƀĺēḿś, ŵĩţĥōũţ ōƥēńĩńĝ ţĥēḿ."], "ku0yLQ": ["Ƥàĝēś àŕē ēḿƀēďďēď ţĥē ƒĩŕśţ ţĩḿē à śēàŕćĥ ńēēďś ţĥēḿ."], @@ -2404,10 +2471,12 @@ "m16xKo": ["Àďď"], "m2T1Xj": ["Ţàĝ #", ["tagValue"], ", ", ["uses"]], "m6YGpY": ["Àćţĩvē ƥàńēĺ ćōńţēńţ ƒōŕ ţĥē śēĺēćţēď ţàƀ śĥōŵś ĥēŕē."], + "mAzs2H": ["Ďĩśàƀĺē ", ["file"]], "mBltnd": ["Ƥàţĥ śũĝĝēśţĩōńś"], "mCB6Je": ["Śēĺēćţ Àĺĺ"], "mGaZwZ": ["Śēē ţĥē ƒũĺĺ ĩńśţàĺĺ ĝũĩďē"], "mHVPm5": ["Ŕēćōńńēćţ ŕēǫũĩŕēď"], + "mHf8mp": ["Ďēĺēţē śćĥēḿà ƒĩĺē ", ["file"]], "mLAjyL": [ "Ĝĩvē ţĥē ÀĨ ţōōĺś ŷōũ ũśē àććēśś ţō ŕēàď àńď ũƥďàţē ŷōũŕ ƥŕōĴēćţś. Ćĥēćķĩńĝ à ƀōx śēţś ĩţ ũƥ ŕĩĝĥţ àŵàŷ; ũńćĥēćķĩńĝ ŕēḿōvēś ĩţ." ], @@ -2487,6 +2556,9 @@ "nYKerc": ["(ũśēŕ-ĺēvēĺ ōńĺŷ)"], "nYeBmY": [["hours"], "ĥ àĝō"], "nbfdhU": ["Ĩńţēĝŕàţĩōńś"], + "nc-ipN": [ + "Ƒĩēĺď ēďĩţĩńĝ ĩś àvàĩĺàƀĺē ƒōŕ śćĥēḿà ƒĩĺēś ḿàƥƥēď ĩń ţĥē Ƒŕōńţḿàţţēŕ śćĥēḿàś ƥĺũĝĩń" + ], "ndeI89": ["Ũśē Ćḿď/Ćţŕĺ+Ć ţō ćōƥŷ ţĥē ĺĩńķ ƀēĺōŵ, ōŕ ōƥēń ŌĶ ĩń ţĥē ďēśķţōƥ àƥƥ."], "ndwmea": ["ũńàvàĩĺàƀĺē ōń ţĥĩś ƥĺàţƒōŕḿ"], "neRy3o": ["Śƥĺĩţ ĺĩśţ ĩţēḿ"], @@ -2520,6 +2592,7 @@ "nrGWJ7": [ "Ĝĩţ ĩďēńţĩţŷ ĩśń'ţ śēţ — ćōḿḿĩţś ũśē à ďēƒàũĺţ àũţĥōŕ. Śēţ ŷōũŕś śō ţēàḿḿàţēś śēē ŷōũŕ ńàḿē." ], + "nrXDdR": ["Ƒĩēĺď ńàḿē"], "nskygk": ["Ćĥōōśē àĝēńţ"], "nwtY4N": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ"], "nxlOpL": ["ŌƥēńĶńōŵĺēďĝē ĩś ũśĩńĝ ţĥĩś ĜĩţĤũƀ àććōũńţ."], @@ -2588,6 +2661,7 @@ "p0Iq25": ["Ĩńśēŕţ ĺĩńķ"], "p2LEhJ": ["Ƥēŕśōńàĺ àććēśś ţōķēń"], "p8TjFD": ["Ćōĺĺàƥśē śēśśĩōń ďōćķ"], + "p98xmP": ["Àƥƥĺĩēś ţō"], "p9sdCy": ["Ńēŵ ĩĝńōŕē ƥàţţēŕń"], "pAgkuZ": ["Ŕēōƥēńĩńĝ \"", ["docName"], "\" ŵĩţĥ à ƒŕēśĥ ĺōćàĺ ćōĺĺàƀōŕàţĩōń ćàćĥē."], "pBROkv": ["Ţĥàńķś ƒōŕ śũƀśćŕĩƀĩńĝ. Ŵàţćĥ ŷōũŕ ĩńƀōx ƒōŕ ƥŕōďũćţ ũƥďàţēś."], @@ -2600,6 +2674,7 @@ "pWT04I": ["Ćĥēćķĩńĝ..."], "pWbsW6": ["Ĺōàďĩńĝ ďōćś"], "pZAs1T": ["Ƒàĩĺēď ţō ĺōàď ōũţĝōĩńĝ ĺĩńķś"], + "paCw0z": ["ēvēŕŷţĥĩńĝ ũńďēŕ ", ["0"], "/ ƒōĺďēŕś ĩńśĩďē ", ["1"], "/"], "pajIrl": ["Ćōũĺď ńōţ ţũŕń ōƒƒ ţĥē ţēŕḿĩńàĺ: ", ["0"]], "pbwTg6": [["seconds"], "ś àĝō"], "pckX1A": [["0", "plural", { "one": ["#", " ũśē"], "other": ["#", " ũśēś"] }]], @@ -2629,6 +2704,7 @@ ["tabCount", "plural", { "one": ["#", " ţàƀ"], "other": ["#", " ţàƀś"] }] ], "qAx7xG": ["Ƒĩxēď ƒōĺďēŕ ĩń ćōńţēńţ ŕōōţ"], + "qC7PwZ": ["Ƒàĩĺēď ţō ŕēḿōvē ţĥē ƒĩēĺď"], "qEEfPk": ["ßũĝ ŕēƥōŕţ ", ["0"]], "qEIFZu": ["Ķēŷ ēńďĩńĝ ĩń ", ["keyHint"]], "qEue6Q": [" ōŕ "], @@ -2655,6 +2731,7 @@ "qmL1UG": ["Ũƥĺōàďĩńĝ śēćũŕēĺŷ"], "qo4zCg": ["Ƥàţĥ ćàńńōţ ćōńţàĩń ƀàćķśĺàśĥēś"], "qpQwGC": ["Ďēśćŕĩƀē ŵĥàţ ŷōũ'ŕē ŵōŕķĩńĝ ōń àńď ţĥē àĝēńţ śēţś ĩţ ũƥ ƒōŕ ŷōũ."], + "qribBG": ["śćĥēḿà ", ["deleteTarget"]], "qvWGzP": ["Ćōĺĺàƥśē àĺĺ"], "qyLmWw": ["Śōũŕćē ēďĩţōŕ śēàŕćĥ ƥàńēĺ"], "r-OJYm": ["Ēńţēŕ à vàĺĩď ƀŕàńćĥ ńàḿē (ńō śƥàćēś, ńō ĺēàďĩńĝ ďōţ, ńō \"..\")."], @@ -2697,9 +2774,11 @@ "rfFpcF": ["Ŕēḿōvē ", ["chip"]], "rg7hNd": ["Ŕàţē ŷōũŕ ēxƥēŕĩēńćē"], "rgPqOw": ["Ćōńvēŕţ ţĥē ćũŕŕēńţ ƀĺōćķ ţō à ƥàŕàĝŕàƥĥ."], + "ri6zS-": ["Ţĥē śćĥēḿà ƒĩĺē ďōēś ńōţ ēxĩśţ ōŕ ƒàĩĺēď ţō ĺōàď — àďďĩńĝ à ƒĩēĺď ćŕēàţēś ĩţ."], "riqekz": ["Śũĝĝēśţĩōń ḿēńũ ńàvĩĝàţĩōń"], "rlUzXK": ["Ćōũĺď ńōţ vàĺĩďàţē ƒōĺďēŕ. Ƥĺēàśē ţŕŷ àĝàĩń."], "rn7n0G": ["Ŕēśũḿē śŷńć"], + "rnX-1S": ["Ēńàƀĺē ƥĺũĝĩńś"], "roAe2K": ["Ĺĩńķ ŨŔĹ"], "roiUQQ": ["Ţĥĩś ƥàĝē ďōēśń'ţ ĺĩńķ ţō àńŷţĥĩńĝ ŷēţ."], "rppgXF": ["Ďĩśćōńńēćţĩńĝ"], @@ -2819,6 +2898,9 @@ "uKRYiW": ["Śŷńć śţàţũś ũńàvàĩĺàƀĺē — śēŕvēŕ ēŕŕōŕ."], "uL_Wx8": ["Ţĥē ţēŕḿĩńàĺ śţōƥƥēď ũńēxƥēćţēďĺŷ."], "uM6jnS": ["Ŕēśţōŕē ƒàĩĺēď"], + "uMHsvM": [ + "À <0>Ḿōďĩƒĩēď ƀàďĝē ḿàŕķś śćĥēḿàś ŷōũŕ ćōńƒĩĝ ḿàƥś — ōń ōŕ ōƒƒ. Ŕēśēţ ŕēḿōvēś ţĥē ḿàƥƥĩńĝ ƒŕōḿ ćōńƒĩĝ.ŷḿĺ." + ], "uO9WLl": ["Ƥũśĥ"], "uQNW00": ["Ďēţàćĥēď ĤĒÀĎ — ćĥēćķōũţ à ƀŕàńćĥ ţō ŕēśũḿē"], "uQewpR": [ @@ -2867,6 +2949,7 @@ "vAYoCZ": ["Ćōũĺďń'ţ ōƥēń ţĥē ŵōŕķţŕēē. Ţŕŷ àĝàĩń."], "vDXqVv": ["Ĩńśēŕţ à ḿàŕķďōŵń ĥàŕď ƀŕēàķ."], "vEJDlt": ["Ţĥē ƒĩĺē ĩś ĩń ŷōũŕ Ţŕàśĥ; ţĥē ƒĩĺē-ŵàţćĥēŕ ŵĩĺĺ ŕēćōńćĩĺē."], + "vF68cg": ["Ƒĩēĺďś"], "vGeNNQ": ["Ćōƥŷ à ƥŕōḿƥţ àńď ƥàśţē ĩţ ĩńţō ŷōũŕ àĝēńţ ţō śēţ ũƥ ŷōũŕ ƥŕōĴēćţ."], "vIxrEk": ["Ƒĩţ ŵĩďţĥ"], "vLyv1R": ["Ĥĩďē"], @@ -2875,6 +2958,7 @@ "vWc9a0": ["Ţŷƥē ţō ēďĩţ; <0>⌘ Ēńţēŕ śàvēś, <1>Ēść ćàńćēĺś."], "vYPvPB": ["Śŵĩţćĥ ƥŕōĴēćţ…"], "vZVeFG": ["Ũƥĺōàď ƒàĩĺēď — ƥĺēàśē ţŕŷ àĝàĩń"], + "v_2_3v": ["Ďēĺēţēď ", ["file"]], "vbK_ZG": ["Ƒĩĺēś śĩďēƀàŕ"], "ve8vEM": ["Ćōďē ēxƥĩŕēď — ƥĺēàśē ţŕŷ àĝàĩń"], "vfRlCI": ["Ćōũĺď ńōţ ćōƥŷ ƒũĺĺ ƥàţĥ"], @@ -2902,6 +2986,7 @@ "wQp1V3": ["Ḿĩŕŕōŕ ōƒ <0>", ["src"], ""], "wT4iQr": ["Ōƥēń vĩśũàĺ-ēďĩţōŕ ƒĩńď ŵĩţĥ ŕēƥĺàćē ćōńţŕōĺś ēxƥàńďēď."], "wTcjov": ["Ńō ŵōŕķśƥàćē"], + "wU8WyK": ["Ēĺēḿēńţ ƒĩēĺďś"], "wVa83H": ["Ćōũĺďń'ţ ćŕēàţē ţĥē ŕēƥōŕţ"], "wXO4Tg": [["hrs"], "ĥ àĝō"], "wXw19Y": [ @@ -2924,6 +3009,7 @@ "Śķĩĺĺś ţēàćĥ àĝēńţś ŕēƥēàţàƀĺē ţàśķś. Àũţĥōŕ ţĥēḿ ĥēŕē; ĩńśţàĺĺ à śķĩĺĺ ţō ƥŕōĴēćţ ĩţ ĩńţō ŷōũŕ ēďĩţōŕś' śķĩĺĺ ƒōĺďēŕś." ], "wndeIl": ["ƤŕōĴēćţ-ĺēvēĺ ƥàĝēś ŵĩţĥ ńēĩţĥēŕ ĩńćōḿĩńĝ ńōŕ ōũţĝōĩńĝ ĝŕàƥĥ ēďĝēś."], + "ws5o1F": ["Ćĥēćķēď ƀŷ: ", ["0"]], "wtcxiD": ["Śĥàŕē ţĥĩś śēţũƥ ŵĩţĥ ŷōũŕ ţēàḿ?"], "wvbc1r": ["Ćōũĺďń'ţ ōƥēń ďōćũḿēńţ"], "wzq9Rv": [ @@ -3037,6 +3123,7 @@ "yutJKi": ["Ćĥēćķĺĩśţ ŵĩţĥ ćĥēćķƀōxēś."], "yvG-l1": ["Ōƥēń śōũŕćē ĺĩńţ ƥàńēĺ"], "yvHDYG": ["Ćĺàũďē Ďēśķţōƥ"], + "yxF19Y": [["0", "plural", { "one": ["#", " ƥĺũĝĩń"], "other": ["#", " ƥĺũĝĩńś"] }]], "yz7wBu": ["Ćĺōśē"], "yzF66j": ["Ĺĩńķ"], "yzGPkm": ["Ēxƥàńď ĝŕàƥĥ"], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index f457500e5..df409c81e 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -25,6 +25,10 @@ msgstr "" msgid " Your selection has been restored." msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "— except" +msgstr "" + #: src/components/acp/ThreadView.tsx msgid "… earlier output trimmed" msgstr "" @@ -151,6 +155,11 @@ msgstr "" msgid "{0, plural, one {# option set} other {# options set}}" msgstr "" +#. placeholder {0}: activePlugins.length +#: src/components/ProblemsPanel.tsx +msgid "{0, plural, one {# plugin} other {# plugins}}" +msgstr "" + #. placeholder {0}: blocked.length #: src/editor/components/PreviewBlockedNotice.tsx msgid "{0, plural, one {# request blocked by the preview's security policy} other {# requests blocked by the preview's security policy}}" @@ -667,6 +676,10 @@ msgstr "" msgid "A <0>Modified badge marks rules your config sets explicitly — directly, or inherited through <1>extends. Unmarked rules keep their default state." msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "A <0>Modified badge marks schemas your config maps — on or off. Reset removes the mapping from config.yml." +msgstr "" + #: src/components/ShareBranchSwitchDialog.tsx msgid "A branch named {shareBranch} already exists. Open its worktree from the switcher instead." msgstr "" @@ -850,6 +863,7 @@ msgstr "" #: src/components/FrontmatterRow.tsx #: src/components/ObjectWidget.tsx #: src/components/PropertyPanel.tsx +#: src/components/settings/frontmatter-schema-field-editor.tsx msgid "Add" msgstr "" @@ -877,6 +891,18 @@ msgstr "" msgid "Add cursors or select matching occurrences through CodeMirror." msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Add description" +msgstr "" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Add field" +msgstr "" + +#: src/components/settings/LintingSection.tsx +msgid "Add file or folder pattern, e.g. guides/**/*" +msgstr "" + #: src/components/SeedDialog.tsx msgid "Add it directly to this project." msgstr "" @@ -933,6 +959,10 @@ msgstr "" msgid "Add the next matching source selection." msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Add value" +msgstr "" + #. Subtext for the open-knowledge-write-skill skill row #. Subtext for the open-knowledge-write-skill skill row #: src/components/McpConsentDialogBody.tsx @@ -1026,6 +1056,22 @@ msgstr "" msgid "All tags" msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Allowed element values (empty = any)" +msgstr "" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Allowed element values for {field}" +msgstr "" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Allowed values (empty = any)" +msgstr "" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Allowed values for {field}" +msgstr "" + #: src/editor/image-upload/index.ts msgid "Already at {assetContentPath} — reusing." msgstr "" @@ -1063,6 +1109,15 @@ msgstr "" msgid "and more" msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "any" +msgstr "" + +#. placeholder {0}: summary.name +#: src/components/settings/LintingSection.tsx +msgid "any doc named {0}" +msgstr "" + #: src/components/PublishToGitHubDialog.tsx msgid "Anyone can see" msgstr "" @@ -1087,6 +1142,14 @@ msgstr "" msgid "Application" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Applies to" +msgstr "" + +#: src/components/settings/LintingSection.tsx +msgid "Applies to (globs — leading ! excludes; empty means every doc)" +msgstr "" + #: src/components/settings/TerminalSection.tsx msgid "Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs \"ok open\"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent." msgstr "" @@ -1299,6 +1362,7 @@ msgid "Below" msgstr "" #: src/components/acp/AgentBetaBadge.tsx +#: src/components/settings/PluginBetaBadge.tsx msgid "Beta" msgstr "" @@ -1531,6 +1595,11 @@ msgstr "" msgid "Checkbox" msgstr "" +#. placeholder {0}: activePlugins.map((plugin) => plugin.label).join(', ') +#: src/components/ProblemsPanel.tsx +msgid "Checked by: {0}" +msgstr "" + #: src/components/BottomComposer.tsx msgid "Checking for agents" msgstr "" @@ -1910,6 +1979,10 @@ msgstr "" msgid "Config sharing unchanged — see details below." msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Configuration problems" +msgstr "" + #: src/components/ProblemsPanel.tsx msgid "Configuration warnings" msgstr "" @@ -2591,6 +2664,7 @@ msgstr "" #: src/components/CreateProjectDialog.tsx #: src/components/empty-state/CreatePromptComposer.tsx #: src/components/NewItemDialog.tsx +#: src/components/settings/LintingSection.tsx #: src/components/TemplateRow.tsx msgid "Create" msgstr "" @@ -2849,6 +2923,10 @@ msgstr "" msgid "Delete Permanently" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Delete schema file {file}" +msgstr "" + #: src/lib/keyboard-shortcuts.ts msgid "Delete selected files or folders" msgstr "" @@ -2865,6 +2943,10 @@ msgstr "" msgid "Delete this conversation?" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Deleted {file}" +msgstr "" + #: src/components/FolderTimelineCard.tsx msgid "deleted template" msgstr "" @@ -2915,6 +2997,10 @@ msgstr "" msgid "Description (optional)" msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Description for {field}" +msgstr "" + #: src/components/empty-state/CreatePromptComposer.tsx #: src/components/handoff/AgentSplitButton.tsx #: src/components/handoff/OpenInAgentContextSubmenu.tsx @@ -2990,6 +3076,10 @@ msgstr "" msgid "Disable {0}" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Disable {file}" +msgstr "" + #: src/components/settings/LinkPreviewsSection.tsx msgid "Disable external link previews" msgstr "" @@ -3051,6 +3141,16 @@ msgstr "" msgid "Docs" msgstr "" +#. placeholder {0}: summary.folder +#: src/components/settings/LintingSection.tsx +msgid "docs directly in {0}/" +msgstr "" + +#. placeholder {0}: summary.pattern +#: src/components/settings/LintingSection.tsx +msgid "docs matching {0}" +msgstr "" + #: src/components/CommandPalette.tsx msgid "Docs tagged #{tagDocsName}" msgstr "" @@ -3229,6 +3329,7 @@ msgid "Each selected tool gets an OpenKnowledge MCP entry." msgstr "" #: src/components/AppMenubar.tsx +#: src/components/settings/LintingSection.tsx #: src/components/skill-actions.tsx #: src/components/TemplateForm.tsx #: src/components/TemplateRow.tsx @@ -3254,6 +3355,10 @@ msgstr "" msgid "Edit markdown link" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Edit schema {file}" +msgstr "" + #: src/editor/extensions/CodeBlockView.tsx msgid "Edit source" msgstr "" @@ -3310,6 +3415,14 @@ msgstr "" msgid "Edits at the source land here — no copy-paste drift." msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Element fields" +msgstr "" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Element type" +msgstr "" + #: src/components/AuthModal.tsx msgid "Email" msgstr "" @@ -3376,6 +3489,10 @@ msgstr "" msgid "Enable {displayName} CLI" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Enable {file}" +msgstr "" + #: src/components/AutoSyncOnboardingDialog.tsx #: src/components/EnableSyncConfirmDialog.tsx #: src/components/share-receive-miss-content.tsx @@ -3400,6 +3517,10 @@ msgstr "" msgid "Enable git auto-sync?" msgstr "" +#: src/components/ProblemsPanel.tsx +msgid "Enable plugins" +msgstr "" + #: src/components/settings/SearchSection.tsx msgid "Enable semantic search" msgstr "" @@ -3465,6 +3586,26 @@ msgstr "" msgid "Error:" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "every doc" +msgstr "" + +#. placeholder {0}: summary.folder +#: src/components/settings/LintingSection.tsx +msgid "everything under {0}/" +msgstr "" + +#. placeholder {0}: summary.folder +#. placeholder {1}: summary.root +#: src/components/settings/LintingSection.tsx +msgid "everything under {0}/ folders inside {1}/" +msgstr "" + +#. placeholder {0}: summary.folder +#: src/components/settings/LintingSection.tsx +msgid "everything under any {0}/ folder" +msgstr "" + #: src/components/settings/SharingSection.tsx msgid "Exception: skills are shared" msgstr "" @@ -3572,6 +3713,14 @@ msgstr "" msgid "Failed to create page" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Failed to create the schema file" +msgstr "" + +#: src/components/settings/LintingSection.tsx +msgid "Failed to delete {file}" +msgstr "" + #: src/components/ArrayOfObjectsWidget.tsx msgid "Failed to delete item" msgstr "" @@ -3747,6 +3896,10 @@ msgstr "" msgid "Failed to remove project." msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Failed to remove the field" +msgstr "" + #: src/components/PropertyPanel.tsx msgid "Failed to rename" msgstr "" @@ -3760,6 +3913,10 @@ msgstr "" msgid "Failed to rename property" msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Failed to rename the field" +msgstr "" + #: src/editor/components/Pdf.tsx msgid "Failed to render PDF" msgstr "" @@ -3775,6 +3932,10 @@ msgstr "" msgid "Failed to save content rules — {0}" msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Failed to save the schema field" +msgstr "" + #: src/components/settings/LintingSection.tsx msgid "Failed to save theme setting" msgstr "" @@ -3836,6 +3997,18 @@ msgstr "" msgid "Fetching" msgstr "" +#: src/components/SchemaConfigEditor.tsx +msgid "Field editing is available for schema files mapped in the Frontmatter schemas plugin" +msgstr "" + +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Field name" +msgstr "" + +#: src/components/SchemaConfigEditor.tsx +msgid "Fields" +msgstr "" + #: src/components/AppMenubar.tsx #: src/components/CommandPalette.tsx #: src/components/settings/AiToolsSection.tsx @@ -3861,6 +4034,10 @@ msgstr "" msgid "File name" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "File name (created in .ok/schemas/)" +msgstr "" + #: src/components/TrashFailureModal.tsx msgid "File not found" msgstr "" @@ -4102,6 +4279,10 @@ msgstr "" msgid "Frontmatter every document created from this template starts with. Values are YAML — a list is <0>[a, b]; <1>{{date}} fills in on create." msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Frontmatter schemas" +msgstr "" + #: src/components/settings/SyncSection.tsx #: src/components/SyncStatusBadge.tsx msgid "Full" @@ -4185,6 +4366,10 @@ msgstr "" msgid "Give the AI tools you use access to read and update your projects. Pick what to set up below, and change it anytime." msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Glob patterns this schema applies to" +msgstr "" + #: src/lib/keyboard-shortcuts.ts #: src/lib/skill-scope.ts msgid "Global" @@ -4985,6 +5170,10 @@ msgstr "" msgid "Loading document" msgstr "" +#: src/components/EditorArea.tsx +msgid "Loading editor" +msgstr "" + #: src/components/FileTree.tsx msgid "Loading files" msgstr "" @@ -5241,6 +5430,7 @@ msgid "Mode" msgstr "" #: src/components/FolderOverview.tsx +#: src/components/settings/LintingSection.tsx #: src/components/settings/markdownlint-rule-browser.tsx msgid "Modified" msgstr "" @@ -5407,6 +5597,10 @@ msgstr "" msgid "Nested .okignore error in {path}" msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Nested fields" +msgstr "" + #: src/components/FileTree.tsx msgid "Network error - {kind} \"{createdPath}\" still exists on disk" msgstr "" @@ -5509,6 +5703,10 @@ msgstr "" msgid "New property name" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "New schema" +msgstr "" + #: src/components/NewSkillDialog.tsx #: src/components/settings/SkillsManagerSection.tsx #: src/components/SkillsSidebarSection.tsx @@ -5762,6 +5960,10 @@ msgstr "" msgid "No patterns yet. Type a folder or file name below to start hiding files." msgstr "" +#: src/components/ProblemsPanel.tsx +msgid "No problems found — but no lint plugins are enabled, so only links are checked." +msgstr "" + #: src/components/ProblemsPanel.tsx msgid "No problems found." msgstr "" @@ -5799,6 +6001,14 @@ msgstr "" msgid "No rules match your filters." msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "No schema files in this project yet — create one to start validating." +msgstr "" + +#: src/components/settings/LintingSection.tsx +msgid "No schemas match your search." +msgstr "" + #: src/components/settings/SettingsDialogShell.tsx msgid "No settings found" msgstr "" @@ -5891,6 +6101,16 @@ msgstr "" msgid "Not now" msgstr "" +#. placeholder {0}: summary.pattern +#: src/components/settings/LintingSection.tsx +msgid "nothing ({0} cannot match a doc)" +msgstr "" + +#. placeholder {0}: summary.pattern +#: src/components/settings/LintingSection.tsx +msgid "nothing ({0} is not a valid pattern)" +msgstr "" + #: src/components/FolderPropertiesCard.tsx msgid "Nothing set yet." msgstr "" @@ -5983,6 +6203,7 @@ msgstr "" msgid "Only markdown files" msgstr "" +#: src/components/settings/LintingSection.tsx #: src/components/settings/markdownlint-rule-browser.tsx msgid "Only modified" msgstr "" @@ -6536,6 +6757,10 @@ msgstr "" msgid "Path suggestions" msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Pattern (regex)" +msgstr "" + #: src/components/settings/OkignoreSection.tsx msgid "Pattern {patternNumber}" msgstr "" @@ -7039,6 +7264,10 @@ msgstr "" msgid "Remove failed: {error}" msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Remove field {field}" +msgstr "" + #: src/components/NavigatorApp.tsx #: src/components/recent-remove-controls.tsx msgid "Remove from recent projects" @@ -7220,6 +7449,10 @@ msgstr "" msgid "Repository not found. It may have been renamed, deleted, or moved." msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "Required" +msgstr "" + #: src/components/handoff/OpenInAgentMenuItem.tsx msgid "Requires {brand}." msgstr "" @@ -7241,6 +7474,10 @@ msgstr "" msgid "Reset {0} to default" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Reset {file} to default" +msgstr "" + #: src/components/settings/field-controls.tsx msgid "Reset {labelText} to default" msgstr "" @@ -7250,6 +7487,10 @@ msgstr "" msgid "Reset to default" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Reset to default (removes it from config.yml)" +msgstr "" + #: src/editor/components/Mermaid.tsx msgid "Reset view" msgstr "" @@ -7508,6 +7749,10 @@ msgstr "" msgid "Saving" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "schema {deleteTarget}" +msgstr "" + #: src/components/NewSkillDialog.tsx #: src/components/PanelScopeHeader.tsx msgid "Scope" @@ -7592,6 +7837,14 @@ msgstr "" msgid "Search rules by id, alias, or name" msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Search schema files by path" +msgstr "" + +#: src/components/settings/LintingSection.tsx +msgid "Search schemas" +msgstr "" + #: src/components/settings/SettingsDialogShell.tsx msgid "Search settings" msgstr "" @@ -7658,6 +7911,10 @@ msgstr "" msgid "See the full install guide" msgstr "" +#: src/components/FrontmatterRow.tsx +msgid "Select a value" +msgstr "" + #: src/components/AppMenubar.tsx msgid "Select All" msgstr "" @@ -8212,6 +8469,7 @@ msgstr "" #: src/components/AgentDiffPane.tsx #: src/components/LintConfigEditor.tsx #: src/components/MermaidFileViewer.tsx +#: src/components/SchemaConfigEditor.tsx #: src/components/TimelineDiffPane.tsx msgid "Source" msgstr "" @@ -8773,6 +9031,11 @@ msgstr "" msgid "The desktop app isn't responding. Refresh and try again." msgstr "" +#. placeholder {0}: summary.target +#: src/components/settings/LintingSection.tsx +msgid "the doc {0}" +msgstr "" + #: src/components/FileTree.tsx msgid "The file is in your Trash; the file-watcher will reconcile." msgstr "" @@ -8823,6 +9086,10 @@ msgstr "" msgid "The repository was moved, renamed, or deleted." msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "The schema file does not exist or failed to load — adding a field creates it." +msgstr "" + #: src/components/TemplateProperties.tsx msgid "The template's filename (without `.md`)." msgstr "" @@ -8955,6 +9222,10 @@ msgstr "" msgid "This document is already active in the editor. Use Open to collapse the graph." msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "This field carries additional schema keywords the editor does not show — they are preserved on save." +msgstr "" + #: src/components/use-viewer-text.ts msgid "This file can't be opened in the text editor." msgstr "" @@ -9011,6 +9282,10 @@ msgstr "" msgid "This path is on an external volume (/Volumes/...). OpenKnowledge will lose track of files when the drive ejects." msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "This permanently deletes {deleteTarget} from the project and removes its mapping from config.yml. Docs it validated keep their frontmatter; they just stop being checked." +msgstr "" + #. placeholder {0}: skill.path #: src/components/SkillDeleteDialog.tsx msgid "This permanently removes {0}. Agents that invoke this skill by name will fail until it's recreated." @@ -9077,6 +9352,11 @@ msgstr "" msgid "This request is no longer active." msgstr "" +#. placeholder {0}: rootAdvancedKeywords.join(', ') +#: src/components/settings/frontmatter-schema-field-editor.tsx +msgid "This schema carries root-level advanced rules the editor does not show ({0}) — they still validate and survive every edit. Open the schema file to see them." +msgstr "" + #: src/components/settings/SearchSection.tsx msgid "This sends content off your machine" msgstr "" @@ -9265,6 +9545,7 @@ msgstr "" msgid "Two-page (odd)" msgstr "" +#: src/components/settings/frontmatter-schema-field-editor.tsx #: src/components/TemplateForm.tsx msgid "Type" msgstr "" @@ -9551,11 +9832,27 @@ msgstr "" msgid "Using the <0>OK_EMBEDDINGS_API_KEY environment variable (managed outside OpenKnowledge)." msgstr "" +#: src/components/settings/LintingSection.tsx +msgid "Validate document frontmatter against JSON Schema files, scoped to doc sets by glob." +msgstr "" + +#: src/components/settings/LintingSection.tsx +msgid "Validate document frontmatter against standard JSON Schema files (draft-07). Toggle a schema on to validate the docs its globs match; violations surface as warnings and never block a write. Use Edit to open the schema file." +msgstr "" + +#: src/components/FrontmatterRow.tsx +msgid "Value for {keyName}" +msgstr "" + #: src/components/FolderPropertiesCard.tsx #: src/components/PropertyPanel.tsx msgid "Value is required" msgstr "" +#: src/components/FrontmatterRow.tsx +msgid "Values for {keyName}" +msgstr "" + #: src/components/CommandPalette.tsx msgid "via {viaTags}" msgstr "" diff --git a/packages/app/tests/integration/attribution-sweep-coverage.test.ts b/packages/app/tests/integration/attribution-sweep-coverage.test.ts index c8d6a2b72..22307fe8a 100644 --- a/packages/app/tests/integration/attribution-sweep-coverage.test.ts +++ b/packages/app/tests/integration/attribution-sweep-coverage.test.ts @@ -136,6 +136,12 @@ const EXEMPT_HANDLERS = new Set([ // content — same rationale as `handleFolderConfig`. No agent identity needed. 'handleGetLintConfig', 'handleWriteMarkdownlintRule', + // Project-config write (frontmatter schema file), not agent-authored content — + // same class as handleWriteMarkdownlintRule. + 'handleWriteFrontmatterSchema', + // `/api/lint/frontmatter-schemas` (GET) — read-only enumeration of the + // project's `.ok/schemas/*.json` files for the mapping picker. No writes. + 'handleFrontmatterSchemasList', // `/api/lint` (GET) + `/api/lint/audit` (GET) — read-only lint of a single doc // / the whole project. No writes, no agent identity — same rationale as the // other read handlers below. diff --git a/packages/app/tests/integration/conflict-gate-coverage.test.ts b/packages/app/tests/integration/conflict-gate-coverage.test.ts index ff65d05c4..52ee5921d 100644 --- a/packages/app/tests/integration/conflict-gate-coverage.test.ts +++ b/packages/app/tests/integration/conflict-gate-coverage.test.ts @@ -119,6 +119,12 @@ const EXEMPT_HANDLERS = new Set([ // apply — same posture as `handleFolderConfig`. 'handleGetLintConfig', 'handleWriteMarkdownlintRule', + // No Y.Doc target (schema-file write to disk) — the conflict gate is N/A, + // same class as handleWriteMarkdownlintRule. + 'handleWriteFrontmatterSchema', + // `/api/lint/frontmatter-schemas` (GET) — read-only enumeration of + // `.ok/schemas/*.json`. No Y.Doc target; conflict gate does not apply. + 'handleFrontmatterSchemasList', // `/api/lint` (GET) + `/api/lint/audit` (GET) — read-only lint of one doc / // the project. No Y.Doc target (reads from disk), so the per-doc conflict gate // does not apply. diff --git a/packages/app/tests/integration/frontmatter-lint-http.test.ts b/packages/app/tests/integration/frontmatter-lint-http.test.ts new file mode 100644 index 000000000..b65e26ea0 --- /dev/null +++ b/packages/app/tests/integration/frontmatter-lint-http.test.ts @@ -0,0 +1,338 @@ +/** + * L1 integration coverage for the frontmatter-schemas plugin against a real + * server + tmp contentDir: schema mappings in `.ok/config.yml`, schema files + * on disk, diagnostics through `GET /api/lint` + `GET /api/lint/audit`, config + * problems on the config channel, and the advisory-only agent write path. + */ + +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + FrontmatterSchemasListSuccessSchema, + LintAuditResponseSchema, + LintConfigResponseSchema, + LintDocResultSchema, +} from '@inkeep/open-knowledge-core'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { HARNESS_BOOT_TIMEOUT_MS } from './harness-boot-timeout'; +import { createTestServer, type TestServer } from './test-harness.ts'; + +let server: TestServer; + +const SEED_CONFIG = [ + 'contentRules:', + ' frontmatter:', + ' enabled: true', + ' schemas:', + ' - appliesTo:', + ' - "docs/**"', + ' - "!**/{index,log}"', + ' file: ".ok/schemas/doc.schema.json"', + ' - appliesTo: "broken/**"', + ' file: ".ok/schemas/missing.schema.json"', + '', +].join('\n'); + +const DOC_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['owner', 'status'], + properties: { + owner: { type: 'string' }, + status: { enum: ['draft', 'review', 'published'] }, + }, +}; + +const VIOLATING_DOC = ['---', 'status: shipped', '---', '', '# Guide'].join('\n'); + +beforeAll(async () => { + server = await createTestServer({ seedProjectConfigYml: SEED_CONFIG }); + mkdirSync(join(server.contentDir, '.ok', 'schemas'), { recursive: true }); + writeFileSync( + join(server.contentDir, '.ok', 'schemas', 'doc.schema.json'), + JSON.stringify(DOC_SCHEMA, null, 2), + 'utf-8', + ); + mkdirSync(join(server.contentDir, 'docs'), { recursive: true }); + writeFileSync(join(server.contentDir, 'docs', 'guide.md'), VIOLATING_DOC, 'utf-8'); + writeFileSync(join(server.contentDir, 'docs', 'index.md'), VIOLATING_DOC, 'utf-8'); +}, HARNESS_BOOT_TIMEOUT_MS); + +afterAll(async () => { + await server.cleanup(); +}); + +function api(pathAndQuery: string): string { + return `http://127.0.0.1:${server.port}${pathAndQuery}`; +} + +describe('GET /api/lint — frontmatter diagnostics', () => { + test('a matching doc reports the worked-example diagnostics', async () => { + const res = await fetch(api('/api/lint?doc=docs%2Fguide')); + expect(res.status).toBe(200); + const body = LintDocResultSchema.parse(await res.json()); + const byCode = new Map(body.diagnostics.map((d) => [d.code, d])); + const required = byCode.get('required'); + const enumViolation = byCode.get('enum'); + expect(required?.source).toBe('frontmatter'); + expect(required?.message).toContain('"owner"'); + expect(required?.range.start.line).toBe(0); + expect(enumViolation?.message).toContain('draft, review, published'); + expect(enumViolation?.range.start.line).toBe(1); + expect(enumViolation?.severity).toBe('warning'); + }); + + test('a doc excluded by the negated glob gets no frontmatter diagnostics', async () => { + const res = await fetch(api('/api/lint?doc=docs%2Findex')); + const body = LintDocResultSchema.parse(await res.json()); + expect(body.diagnostics.filter((d) => d.source === 'frontmatter')).toEqual([]); + }); + + test('single-doc responses carry the schemaError on the additive warnings channel, never per-doc', async () => { + const res = await fetch(api('/api/lint?doc=docs%2Fguide')); + const body = LintDocResultSchema.parse(await res.json()); + expect(body.warnings?.some((w) => w.includes('missing.schema.json'))).toBe(true); + expect(body.diagnostics.every((d) => !d.message.includes('missing.schema.json'))).toBe(true); + }); +}); + +describe('GET /api/lint/audit — aggregation + config channel', () => { + test('audit aggregates frontmatter diagnostics and dedupes schemaErrors into warnings', async () => { + const res = await fetch(api('/api/lint/audit?path=docs')); + expect(res.status).toBe(200); + const body = LintAuditResponseSchema.parse(await res.json()); + const guide = body.files.find((f) => f.file === 'docs/guide.md'); + expect(guide?.diagnostics.some((d) => d.source === 'frontmatter')).toBe(true); + expect(body.files.find((f) => f.file === 'docs/index.md')).toBeUndefined(); + const schemaErrors = body.warnings.filter((w) => w.includes('missing.schema.json')); + expect(schemaErrors).toHaveLength(1); + }); +}); + +describe('GET /api/lint/config — effective slice for the browser', () => { + test('the effective config inlines loaded schema content + configProblems', async () => { + const res = await fetch(api('/api/lint/config?doc=docs%2Fguide')); + const body = LintConfigResponseSchema.parse(await res.json()); + const slice = body.effective.plugins.frontmatter; + expect(slice.enabled).toBe(true); + const loaded = slice.schemas.find((s) => s.file === '.ok/schemas/doc.schema.json'); + expect(loaded?.schema).toMatchObject({ required: ['owner', 'status'] }); + expect(body.configProblems?.some((p) => p.includes('missing.schema.json'))).toBe(true); + }); +}); + +describe('agent write path — advisory only, never gating', () => { + test('a violating write lands AND returns frontmatter lint-violation advisories', async () => { + const md = ['---', 'status: bogus', '---', '', '# New doc'].join('\n'); + const res = await fetch(api('/api/agent-write-md'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ docName: 'docs/advisory-target', markdown: md, position: 'replace' }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + ok?: boolean; + warnings?: { kind: string; source?: string; code?: string }[]; + }; + const lintWarnings = (body.warnings ?? []).filter((w) => w.kind === 'lint-violation'); + expect(lintWarnings.length).toBeGreaterThan(0); + expect(lintWarnings.every((w) => w.source === 'frontmatter')).toBe(true); + expect(lintWarnings.length).toBeLessThanOrEqual(10); + // The write landed despite the violations: the doc reaches disk with the + // violating frontmatter intact (persistence is debounced — poll). + const target = join(server.contentDir, 'docs', 'advisory-target.md'); + const pollDelay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + let onDisk = ''; + for (let i = 0; i < 250; i++) { + try { + onDisk = readFileSync(target, 'utf-8'); + if (onDisk.includes('status: bogus')) break; + } catch { + // not persisted yet + } + await pollDelay(20); + } + expect(onDisk).toContain('status: bogus'); + rmSync(target, { force: true }); + }); +}); + +describe('POST /api/lint/frontmatter-schema — write surface over HTTP', () => { + function postSchema(payload: Record): Promise { + return fetch(api('/api/lint/frontmatter-schema'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + } + + test('a per-field edit routes to disk and returns the recomputed effective config', async () => { + const file = '.ok/schemas/http-edit.schema.json'; + const res = await postSchema({ + file, + field: 'status', + constraint: { enum: ['draft', 'shipped'], required: true }, + }); + expect(res.status).toBe(200); + // The response is the recomputed effective config (not a bare ok wrapper). + const body = LintConfigResponseSchema.parse(await res.json()); + expect(body.effective.plugins.frontmatter.enabled).toBe(true); + // The write actually reached disk with the field-scoped constraint merged + // into a freshly-scaffolded (create-on-first-edit) draft-07 skeleton. + const onDisk = JSON.parse(readFileSync(join(server.contentDir, file), 'utf-8')); + expect(onDisk.properties.status.enum).toEqual(['draft', 'shipped']); + expect(onDisk.required).toContain('status'); + expect(typeof onDisk.$schema).toBe('string'); + rmSync(join(server.contentDir, file), { force: true }); + }); + + test('a field-less request takes the create-empty branch and is idempotent', async () => { + const file = '.ok/schemas/http-create.schema.json'; + const created = await postSchema({ file }); + expect(created.status).toBe(200); + LintConfigResponseSchema.parse(await created.json()); + const first = readFileSync(join(server.contentDir, file), 'utf-8'); + expect(JSON.parse(first)).toMatchObject({ type: 'object' }); + + // Re-issuing create-empty must not clobber the now-existing file. + const again = await postSchema({ file }); + expect(again.status).toBe(200); + expect(readFileSync(join(server.contentDir, file), 'utf-8')).toBe(first); + rmSync(join(server.contentDir, file), { force: true }); + }); + + test('a project-root-escaping path is refused with 409, not written', async () => { + const res = await postSchema({ file: '../escape.schema.json' }); + expect(res.status).toBe(409); + }); + + test('a delete request removes a tool-managed schema file and is idempotent', async () => { + const file = '.ok/schemas/http-delete.schema.json'; + writeFileSync(join(server.contentDir, file), '{"type":"object"}', 'utf-8'); + const res = await postSchema({ file, delete: true }); + expect(res.status).toBe(200); + LintConfigResponseSchema.parse(await res.json()); + expect(existsSync(join(server.contentDir, file))).toBe(false); + + // Re-issuing the delete for the now-absent file still succeeds. + const again = await postSchema({ file, delete: true }); + expect(again.status).toBe(200); + }); + + test('a delete of a *.schema.json anywhere succeeds; unconventional json is refused', async () => { + const anywhere = 'notes/x.schema.json'; + mkdirSync(join(server.contentDir, 'notes'), { recursive: true }); + writeFileSync(join(server.contentDir, anywhere), '{"type":"object"}', 'utf-8'); + const okRes = await postSchema({ file: anywhere, delete: true }); + expect(okRes.status).toBe(200); + expect(existsSync(join(server.contentDir, anywhere))).toBe(false); + + const plain = 'user-owned.json'; + writeFileSync(join(server.contentDir, plain), '{"type":"object"}', 'utf-8'); + const refused = await postSchema({ file: plain, delete: true }); + expect(refused.status).toBe(409); + expect(existsSync(join(server.contentDir, plain))).toBe(true); + rmSync(join(server.contentDir, plain), { force: true }); + }); + + test('removeField and renameTo shapes edit the file over HTTP', async () => { + const file = '.ok/schemas/http-fieldops.schema.json'; + writeFileSync( + join(server.contentDir, file), + JSON.stringify({ type: 'object', required: ['a'], properties: { a: {}, b: {} } }), + 'utf-8', + ); + const renamed = await postSchema({ file, field: 'a', renameTo: 'z' }); + expect(renamed.status).toBe(200); + let onDisk = JSON.parse(readFileSync(join(server.contentDir, file), 'utf-8')); + expect(Object.keys(onDisk.properties)).toEqual(['z', 'b']); + expect(onDisk.required).toEqual(['z']); + + const removed = await postSchema({ file, field: 'b', removeField: true }); + expect(removed.status).toBe(200); + onDisk = JSON.parse(readFileSync(join(server.contentDir, file), 'utf-8')); + expect(Object.keys(onDisk.properties)).toEqual(['z']); + rmSync(join(server.contentDir, file), { force: true }); + }); + + test('parentPath addresses a nested object property over HTTP', async () => { + const file = '.ok/schemas/http-nested.schema.json'; + const res = await postSchema({ + file, + field: 'owner', + constraint: { type: 'string', required: true }, + parentPath: ['meta'], + }); + expect(res.status).toBe(200); + const onDisk = JSON.parse(readFileSync(join(server.contentDir, file), 'utf-8')); + expect(onDisk.properties.meta.properties.owner.type).toBe('string'); + expect(onDisk.properties.meta.required).toEqual(['owner']); + expect('required' in onDisk).toBe(false); + rmSync(join(server.contentDir, file), { force: true }); + }); + + test('an {items: true} parentPath segment edits an array element schema over HTTP', async () => { + const file = '.ok/schemas/http-items.schema.json'; + writeFileSync( + join(server.contentDir, file), + JSON.stringify({ + type: 'object', + properties: { ingredients: { type: 'array', items: { type: 'object' } } }, + }), + 'utf-8', + ); + const res = await postSchema({ + file, + field: 'name', + constraint: { type: 'string', required: true }, + parentPath: ['ingredients', { items: true }], + }); + expect(res.status).toBe(200); + const onDisk = JSON.parse(readFileSync(join(server.contentDir, file), 'utf-8')); + expect(onDisk.properties.ingredients.items.properties.name).toEqual({ type: 'string' }); + expect(onDisk.properties.ingredients.items.required).toEqual(['name']); + rmSync(join(server.contentDir, file), { force: true }); + }); + + test('a bare field, and removeField without field, are rejected as malformed', async () => { + expect((await postSchema({ file: '.ok/schemas/doc.schema.json', field: 'x' })).status).toBe( + 400, + ); + expect( + (await postSchema({ file: '.ok/schemas/doc.schema.json', removeField: true })).status, + ).toBe(400); + }); + + test('mixing delete with a field edit is rejected as malformed', async () => { + const res = await postSchema({ + file: '.ok/schemas/doc.schema.json', + delete: true, + field: 'status', + constraint: { type: 'string' }, + }); + expect(res.status).toBe(400); + }); +}); + +describe('GET /api/lint/frontmatter-schemas — file listing for the picker', () => { + test('discovers *.schema.json files anywhere in the project', async () => { + mkdirSync(join(server.contentDir, 'shapes'), { recursive: true }); + writeFileSync(join(server.contentDir, 'shapes', 'thing.schema.json'), '{}', 'utf-8'); + const res = await fetch(api('/api/lint/frontmatter-schemas')); + const body = FrontmatterSchemasListSuccessSchema.parse(await res.json()); + expect(body.schemas).toContain('shapes/thing.schema.json'); + expect(body.schemas).toContain('.ok/schemas/doc.schema.json'); + rmSync(join(server.contentDir, 'shapes'), { recursive: true, force: true }); + }); + + test('lists the project .ok/schemas/*.json files as project-relative paths', async () => { + const res = await fetch(api('/api/lint/frontmatter-schemas')); + expect(res.status).toBe(200); + const body = FrontmatterSchemasListSuccessSchema.parse(await res.json()); + // The seeded schema is enumerated; the missing mapping target is not on disk + // so it never appears (listing is a directory scan, not a config read). + expect(body.schemas).toContain('.ok/schemas/doc.schema.json'); + expect(body.schemas).not.toContain('.ok/schemas/missing.schema.json'); + expect(body.truncated).toBe(false); + }); +}); diff --git a/packages/app/tests/integration/test-harness.ts b/packages/app/tests/integration/test-harness.ts index d32a53e9a..7aece65be 100644 --- a/packages/app/tests/integration/test-harness.ts +++ b/packages/app/tests/integration/test-harness.ts @@ -186,6 +186,12 @@ export interface CreateTestServerOptions { * applies to fresh contentDirs (`options.contentDir === undefined`). */ markdownlintEnabled?: boolean; + /** + * Verbatim YAML seeded as the project `.ok/config.yml` — for tests needing + * config beyond the boolean toggles (e.g. frontmatter schema mappings). + * Takes precedence over `markdownlintEnabled`; fresh contentDirs only. + */ + seedProjectConfigYml?: string; /** * Seconds between the attached SyncEngine's background pull/push cycles. * Forwarded to `ServerOptions`. Sync-wired tests (`createSyncWiredTestServer`) @@ -243,9 +249,9 @@ export async function createTestServer(options: CreateTestServerOptions = {}): P mkdirSync(join(contentDir, '.ok'), { recursive: true }); // markdownlint is off by default; opt it in for lint tests that need // real diagnostics (readLinterBaseConfig reads this file fresh per request). - const seedConfig = options.markdownlintEnabled - ? 'contentRules:\n markdownlint:\n enabled: true\n' - : ''; + const seedConfig = + options.seedProjectConfigYml ?? + (options.markdownlintEnabled ? 'contentRules:\n markdownlint:\n enabled: true\n' : ''); writeFileSync(join(contentDir, '.ok', 'config.yml'), seedConfig, 'utf-8'); } diff --git a/packages/cli/src/commands/lint.test.ts b/packages/cli/src/commands/lint.test.ts index ee436466e..763b9d658 100644 --- a/packages/cli/src/commands/lint.test.ts +++ b/packages/cli/src/commands/lint.test.ts @@ -96,3 +96,31 @@ describe('resolveTarget', () => { expect(out.includes(`${cwd}/${cwd}`)).toBe(false); }); }); + +describe('formatLintReport — frontmatter diagnostics', () => { + test('renders the composed frontmatter/ id with 1-based display line', async () => { + const out = formatLintReport( + result({ + fileCount: 1, + warningCount: 1, + files: [ + { + file: 'docs/guide.md', + fixed: false, + diagnostics: [ + { + 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', + }, + ], + }, + ], + }), + ); + expect(out).toContain('frontmatter/enum'); + expect(out).toContain('2:1'); + }); +}); diff --git a/packages/cli/src/content/lint-runner.test.ts b/packages/cli/src/content/lint-runner.test.ts index 4ab061263..0f36c6f5a 100644 --- a/packages/cli/src/content/lint-runner.test.ts +++ b/packages/cli/src/content/lint-runner.test.ts @@ -38,7 +38,10 @@ function run(opts: Partial[0]> = {}) { // so enable the plugin explicitly on the base config. baseConfig: { ...DEFAULT_LINTER_CONFIG, - plugins: { markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true } }, + plugins: { + ...DEFAULT_LINTER_CONFIG.plugins, + markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true }, + }, }, ...opts, }); @@ -151,3 +154,67 @@ describe('runLint — per-dir cascade (cli2 semantics)', () => { expect(result.warnings).toEqual([expect.stringContaining('malformed markdownlint config')]); }); }); + +describe('runLint — frontmatter schemas', () => { + const DOC_SCHEMA = JSON.stringify({ + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['owner', 'status'], + properties: { status: { enum: ['draft', 'review', 'published'] } }, + }); + + function frontmatterBase(schemas: { appliesTo?: string | string[]; file: string }[]) { + return { + ...DEFAULT_LINTER_CONFIG, + plugins: { + ...DEFAULT_LINTER_CONFIG.plugins, + frontmatter: { enabled: true, schemas }, + }, + }; + } + + test('matching docs report frontmatter diagnostics with server-identical anchors', async () => { + write('.ok/schemas/doc.schema.json', DOC_SCHEMA); + write('docs/guide.md', '---\nstatus: shipped\n---\n\n# Guide\n'); + write('docs/index.md', '---\nstatus: shipped\n---\n\n# Index\n'); + const result = await run({ + baseConfig: frontmatterBase([ + { appliesTo: ['docs/**', '!**/{index,log}'], file: '.ok/schemas/doc.schema.json' }, + ]), + }); + expect(result.warnings).toEqual([]); + const guide = result.files.find((f) => f.file === join('docs', 'guide.md')); + const codes = guide?.diagnostics.map((d) => `${d.source}/${d.code}`).sort(); + expect(codes).toEqual(['frontmatter/enum', 'frontmatter/required']); + const enumDiag = guide?.diagnostics.find((d) => d.code === 'enum'); + expect(enumDiag?.range.start.line).toBe(1); + const index = result.files.find((f) => f.file === join('docs', 'index.md')); + expect(index?.diagnostics.filter((d) => d.source === 'frontmatter')).toEqual([]); + }); + + test('schemaError surfaces as a report warning and never flips diagnostics', async () => { + write('docs/guide.md', '---\nstatus: draft\n---\n'); + const result = await run({ + baseConfig: frontmatterBase([{ appliesTo: 'docs/**', file: '.ok/schemas/missing.json' }]), + }); + expect(result.warnings).toEqual([expect.stringContaining('missing.json')]); + expect(result.files.every((f) => f.diagnostics.length === 0)).toBe(true); + }); + + test('--fix leaves frontmatter diagnostics untouched while fixing markdownlint issues', async () => { + write('.ok/schemas/doc.schema.json', DOC_SCHEMA); + write('docs/guide.md', '---\nstatus: shipped\n---\n\n# G\n\na\tb\n'); + const base = frontmatterBase([{ appliesTo: 'docs/**', file: '.ok/schemas/doc.schema.json' }]); + base.plugins = { + ...base.plugins, + markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true }, + }; + const result = await run({ baseConfig: base, fix: true }); + const guide = result.files.find((f) => f.file === join('docs', 'guide.md')); + expect(guide?.fixed).toBe(true); + const onDisk = readFileSync(join(root, 'docs', 'guide.md'), 'utf-8'); + expect(onDisk).toContain('status: shipped'); + expect(onDisk).not.toContain('\t'); + expect(guide?.diagnostics.some((d) => d.source === 'frontmatter')).toBe(true); + }); +}); diff --git a/packages/cli/src/content/lint-runner.ts b/packages/cli/src/content/lint-runner.ts index fc9927f73..6e6c38fb2 100644 --- a/packages/cli/src/content/lint-runner.ts +++ b/packages/cli/src/content/lint-runner.ts @@ -24,6 +24,7 @@ import { } from '@inkeep/open-knowledge-core'; import { composeEffectiveLinterConfig, + composeFrontmatterSchemasConfig, createContentFilter, resolveNativeConfigForDoc, } from '@inkeep/open-knowledge-server'; @@ -63,23 +64,31 @@ export async function runLint(opts: RunLintOptions): Promise { const warnings: string[] = []; const filter = createContentFilter({ projectDir, contentDir }); + const seenConfigProblems = new Set(); + const pushConfigProblem = (problem: string): void => { + if (seenConfigProblems.has(problem)) return; + seenConfigProblems.add(problem); + warnings.push(problem); + }; + + // Frontmatter schema files load once per run (the mapping is project-wide, + // resolved from projectDir; per-doc appliesTo filtering lives in the plugin); + // load problems surface as report warnings — the same resolution the server + // uses. + const resolvedBase = composeFrontmatterSchemasConfig(projectDir, baseConfig, pushConfigProblem); + // markdownlint `rules` come from the project's native `.markdownlint.*` // files, resolved per doc with cli2 cascade semantics (nearest file on the // doc→root walk governs wholesale; OK's tuned defaults only when no file // governs) — the same resolution the server uses. Memoized per directory: // every doc in a folder shares one governing file. const cfgByDir = new Map(); - const seenConfigProblems = new Set(); const configForDoc = (rel: string): LinterConfig => { const dir = dirname(rel); const cached = cfgByDir.get(dir); if (cached) return cached; - const native = resolveNativeConfigForDoc(contentDir, rel, (problem) => { - if (seenConfigProblems.has(problem)) return; - seenConfigProblems.add(problem); - warnings.push(problem); - }); - const cfg = composeEffectiveLinterConfig(baseConfig, native); + const native = resolveNativeConfigForDoc(contentDir, rel, pushConfigProblem); + const cfg = composeEffectiveLinterConfig(resolvedBase, native); cfgByDir.set(dir, cfg); return cfg; }; diff --git a/packages/core/package.json b/packages/core/package.json index 18e363405..66019145f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -66,6 +66,8 @@ "@tiptap/extension-table": "^3.22.3", "@tiptap/pm": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "decode-named-character-reference": "^1.3.0", "diff-match-patch": "^1.0.5", "markdownlint": "^0.41.0", @@ -81,6 +83,7 @@ "micromark-util-sanitize-uri": "^2.0.1", "micromark-util-types": "^2.0.2", "node-diff3": "^3.2.0", + "picomatch": "^4.0.5", "rehype-parse": "^9.0.1", "rehype-remark": "^10.0.1", "rehype-stringify": "^10.0.1", @@ -122,9 +125,8 @@ "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "@types/node": "^25.6.0", + "@types/picomatch": "^4.0.3", "@types/unist": "^3.0.3", - "ajv": "^8.18.0", - "ajv-formats": "^3.0.1", "commonmark.json": "0.31.0", "culori": "^4.0.2", "fast-check": "^4.6.0", diff --git a/packages/core/src/config/field-registry.test.ts b/packages/core/src/config/field-registry.test.ts index b5e2280fa..5e0e7718e 100644 --- a/packages/core/src/config/field-registry.test.ts +++ b/packages/core/src/config/field-registry.test.ts @@ -202,6 +202,8 @@ describe('ConfigSchema coverage (NR3 — every leaf has fieldRegistry metadata)' 'autoSync.default', 'content.attachmentFolderPath', 'content.dir', + 'contentRules.frontmatter.enabled', + 'contentRules.frontmatter.schemas', 'contentRules.markdownlint.enabled', 'telemetry.localSink.attributeDenylist', 'telemetry.localSink.enabled', diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index d941450b0..a68e8eec0 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -707,8 +707,49 @@ export const ConfigSchema = z.looseObject({ // persists only this toggle. }) .default({ enabled: false }), + frontmatter: z + .object({ + enabled: z + .boolean() + .register(fieldRegistry, { + scope: 'project', + agentSettable: false, + defaultScope: 'project', + description: + 'Whether the frontmatter plugin (JSON-Schema validation of document frontmatter) contributes diagnostics.', + }) + .default(false), + // Schema CONTENT is NOT persisted here. Each entry scopes one + // standard draft-07 JSON Schema file (project-root-relative `file`, + // portable to any external tool) to a set of docs via `appliesTo` + // globs (single or list; leading `!` excludes; absent matches every + // doc). Loaded server/CLI-side and injected into the effective + // config; entry order carries no precedence — every match validates. + schemas: z + .array( + z.object({ + appliesTo: z.union([z.string(), z.array(z.string())]).optional(), + file: z.string(), + // Absent = enabled; the Settings toggle writes false to keep + // the mapping (and its appliesTo) without validating. + enabled: z.boolean().optional(), + }), + ) + .register(fieldRegistry, { + scope: 'project', + agentSettable: false, + defaultScope: 'project', + description: + 'Frontmatter schema mappings: which docs (appliesTo globs) validate against which JSON Schema file (project-root-relative path).', + }) + .default([]), + }) + .default({ enabled: false, schemas: [] }), }) - .default({ markdownlint: { enabled: false } }), + .default({ + markdownlint: { enabled: false }, + frontmatter: { enabled: false, schemas: [] }, + }), // Validation-surface behavior (the unified audit plane's non-plugin knobs). // PROJECT scope, like `contentRules`: how broken links are classified and // whether the file tree surfaces problem indicators are team-shared diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 84cd906dc..f085ccc6f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -474,16 +474,35 @@ export { MarkdownManager, type SerializeCallOptions } from './markdown/index.ts' // Markdown linter (the content-rules lint-plugin registry). Browser-safe; // consumed by the editor's CodeMirror lint facet + the no-code Settings GUI. export { + type AppliesToPatternSummary, + type AppliesToSummary, + applyFieldConstraint, applyTextEdits, + type CompiledAppliesTo, canonicalRuleId, + compileAppliesTo, DEFAULT_LINKS_VALIDATION, DEFAULT_LINTER_CONFIG, DEFAULT_MARKDOWNLINT_CONFIG, displayCategoryForRule, + emptyFrontmatterSchemaText, + type FrontmatterFieldConstraint, + FrontmatterSchemaEditError, + type FrontmatterSchemaMapping, + type FrontmatterSchemasListSuccess, + FrontmatterSchemasListSuccessSchema, + type FrontmatterSchemaWriteRequest, + FrontmatterSchemaWriteRequestSchema, + type FrontmatterSlice, findRuleConfigEntry, + findZeroMatchAppliesToPatterns, fixDocument, fixMarkdownText, + frontmatterSchemaCompileError, + isFrontmatterSchemaAsset, isMarkdownlintJsonConfig, + isSupportedSchemaDialect, + isToolManagedSchemaPath, LINKS_VALIDATION_SETTINGS, LINT_PLUGINS, type LinksValidationSetting, @@ -514,13 +533,19 @@ export { type MarkdownlintRuleWriteValue, type MarkdownlintSlice, type PersistedLinterConfig, + type ResolvedFrontmatterSchemaEntry, RULE_DISPLAY_CATEGORIES, type RuleCatalogEntry, type RuleDisplayCategory, type RuleOptionSpec, type RuleOptionType, + removeSchemaField, + renameSchemaField, resolveMarkdownlintConfig, runMarkdownlint, + type SchemaParentPathSegment, + selectApplicableFrontmatterSchemas, + summarizeAppliesTo, toEffectiveBase, type ValidationAuditResponse, ValidationAuditResponseSchema, diff --git a/packages/core/src/markdown/lint/applies-to.ts b/packages/core/src/markdown/lint/applies-to.ts new file mode 100644 index 000000000..fa0be9e86 --- /dev/null +++ b/packages/core/src/markdown/lint/applies-to.ts @@ -0,0 +1,219 @@ +import picomatch from 'picomatch'; +import { SUPPORTED_DOC_EXTENSIONS } from '../../constants/doc-extensions.ts'; + +type PathMatcher = (path: string) => boolean; + +type SuspiciousAppliesToReason = 'trailing-slash' | 'leading-slash' | 'doc-extension'; + +interface SuspiciousAppliesToPattern { + pattern: string; + reason: SuspiciousAppliesToReason; +} + +interface InvalidAppliesToPattern { + pattern: string; + detail: string; +} + +export interface CompiledAppliesTo { + invalidPatterns: InvalidAppliesToPattern[]; + suspiciousPatterns: SuspiciousAppliesToPattern[]; + matches(docName: string | undefined): boolean; +} + +function normalizeDocPath(docName: string): string { + let path = docName.replace(/\\/g, '/'); + while (path.startsWith('./')) path = path.slice(2); + const lower = path.toLowerCase(); + for (const ext of SUPPORTED_DOC_EXTENSIONS) { + if (lower.endsWith(ext)) return path.slice(0, -ext.length); + } + return path; +} + +export function compileAppliesTo(appliesTo: string | string[] | undefined): CompiledAppliesTo { + const raw = appliesTo === undefined ? [] : Array.isArray(appliesTo) ? appliesTo : [appliesTo]; + const authored = raw.map((pattern) => pattern.trim()).filter((pattern) => pattern.length > 0); + + const invalidPatterns: InvalidAppliesToPattern[] = []; + const suspiciousPatterns: SuspiciousAppliesToPattern[] = []; + const positiveMatchers: PathMatcher[] = []; + const negationMatchers: PathMatcher[] = []; + let authoredPositiveCount = 0; + + for (const pattern of authored) { + const negated = pattern.startsWith('!'); + const body = negated ? pattern.slice(1) : pattern; + if (!negated) authoredPositiveCount += 1; + if (body.length === 0) { + invalidPatterns.push({ pattern, detail: 'empty pattern' }); + continue; + } + try { + const matcher = picomatch(body, { dot: true, strictBrackets: true }); + (negated ? negationMatchers : positiveMatchers).push(matcher); + } catch (err) { + invalidPatterns.push({ + pattern, + detail: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (body.endsWith('/')) { + suspiciousPatterns.push({ pattern, reason: 'trailing-slash' }); + } else if (body.startsWith('/')) { + suspiciousPatterns.push({ pattern, reason: 'leading-slash' }); + } else { + const lower = body.toLowerCase(); + if (SUPPORTED_DOC_EXTENSIONS.some((ext) => lower.endsWith(ext))) { + suspiciousPatterns.push({ pattern, reason: 'doc-extension' }); + } + } + } + + const implicitAll = authoredPositiveCount === 0; + + return { + invalidPatterns, + suspiciousPatterns, + matches(docName) { + if (docName === undefined || docName === '') return false; + const path = normalizeDocPath(docName); + if (path === '') return false; + const positive = implicitAll || positiveMatchers.some((matcher) => matcher(path)); + if (!positive) return false; + return !negationMatchers.some((matcher) => matcher(path)); + }, + }; +} + +export function findZeroMatchAppliesToPatterns( + appliesTo: string | string[] | undefined, + docPaths: readonly string[], +): string[] { + if (docPaths.length === 0) return []; + const raw = appliesTo === undefined ? [] : Array.isArray(appliesTo) ? appliesTo : [appliesTo]; + const authored = raw.map((pattern) => pattern.trim()).filter((pattern) => pattern.length > 0); + const normalized = docPaths.map(normalizeDocPath).filter((path) => path !== ''); + const unmatched: string[] = []; + for (const pattern of authored) { + const body = pattern.startsWith('!') ? pattern.slice(1) : pattern; + if (body.length === 0) continue; + let matcher: PathMatcher; + try { + matcher = picomatch(body, { dot: true, strictBrackets: true }); + } catch { + continue; + } + if (!normalized.some((path) => matcher(path))) unmatched.push(pattern); + } + return unmatched; +} + +export type AppliesToPatternSummary = + | { kind: 'everything' } + | { kind: 'folder-recursive'; folder: string } + | { kind: 'folder-direct'; folder: string } + | { kind: 'folder-anywhere'; folder: string } + | { kind: 'folder-recursive-nested'; root: string; folder: string } + | { kind: 'exact'; target: string } + | { kind: 'name-anywhere'; name: string } + | { kind: 'matches-nothing'; pattern: string } + | { kind: 'invalid'; pattern: string } + | { kind: 'pattern'; pattern: string }; + +export interface AppliesToSummary { + includes: AppliesToPatternSummary[]; + excludes: AppliesToPatternSummary[]; +} + +const GLOB_CHARS_RE = /[*?[\]{}()+@!|]/; + +function expandSimpleBraces(body: string): string[] | null { + const open = body.indexOf('{'); + if (open === -1) return null; + const close = body.indexOf('}', open); + if (close === -1) return null; + if (body.indexOf('{', open + 1) !== -1 || body.indexOf('}', close + 1) !== -1) return null; + const alternatives = body.slice(open + 1, close).split(','); + if (alternatives.length < 2) return null; + if (alternatives.some((alt) => alt.trim() === '' || GLOB_CHARS_RE.test(alt))) return null; + return alternatives.map((alt) => body.slice(0, open) + alt + body.slice(close + 1)); +} + +function classifyBody(body: string): AppliesToPatternSummary { + if (body.endsWith('/') || body.startsWith('/')) { + return { kind: 'matches-nothing', pattern: body }; + } + try { + picomatch(body, { dot: true, strictBrackets: true }); + } catch { + return { kind: 'invalid', pattern: body }; + } + if (body === '**' || body === '**/*') return { kind: 'everything' }; + { + const anywhere = body.match(/^\*\*\/(.+)\/\*\*$/); + const anywhereFolder = anywhere?.[1]; + if (anywhereFolder !== undefined && !GLOB_CHARS_RE.test(anywhereFolder)) { + return { kind: 'folder-anywhere', folder: anywhereFolder }; + } + const nested = body.match(/^(.+)\/\*\*\/(.+)\/\*\*$/); + const nestedRoot = nested?.[1]; + const nestedFolder = nested?.[2]; + if ( + nestedRoot !== undefined && + nestedFolder !== undefined && + !GLOB_CHARS_RE.test(nestedRoot) && + !GLOB_CHARS_RE.test(nestedFolder) + ) { + return { kind: 'folder-recursive-nested', root: nestedRoot, folder: nestedFolder }; + } + } + for (const suffix of ['/**/*', '/**']) { + if (body.endsWith(suffix)) { + const folder = body.slice(0, -suffix.length); + if (folder.length > 0 && !GLOB_CHARS_RE.test(folder)) { + return { kind: 'folder-recursive', folder }; + } + } + } + if (body.endsWith('/*')) { + const folder = body.slice(0, -2); + if (folder.length > 0 && !GLOB_CHARS_RE.test(folder)) { + return { kind: 'folder-direct', folder }; + } + } + if (body.startsWith('**/')) { + const name = body.slice(3); + if (name.length > 0 && !name.includes('/') && !GLOB_CHARS_RE.test(name)) { + return { kind: 'name-anywhere', name }; + } + } + if (!GLOB_CHARS_RE.test(body)) return { kind: 'exact', target: body }; + return { kind: 'pattern', pattern: body }; +} + +function classifyPattern(body: string): AppliesToPatternSummary[] { + const direct = classifyBody(body); + if (direct.kind !== 'pattern') return [direct]; + const variants = expandSimpleBraces(body); + if (variants !== null) { + const classified = variants.map(classifyBody); + if (classified.every((c) => c.kind !== 'pattern')) return classified; + } + return [direct]; +} + +export function summarizeAppliesTo(appliesTo: string | string[] | undefined): AppliesToSummary { + const raw = appliesTo === undefined ? [] : Array.isArray(appliesTo) ? appliesTo : [appliesTo]; + const authored = raw.map((pattern) => pattern.trim()).filter((pattern) => pattern.length > 0); + const includes: AppliesToPatternSummary[] = []; + const excludes: AppliesToPatternSummary[] = []; + for (const pattern of authored) { + const negated = pattern.startsWith('!'); + const body = negated ? pattern.slice(1) : pattern; + if (body.length === 0) continue; + (negated ? excludes : includes).push(...classifyPattern(body)); + } + return { includes, excludes }; +} diff --git a/packages/core/src/markdown/lint/config-schemas.ts b/packages/core/src/markdown/lint/config-schemas.ts index 9291ee4db..3162881fc 100644 --- a/packages/core/src/markdown/lint/config-schemas.ts +++ b/packages/core/src/markdown/lint/config-schemas.ts @@ -2,15 +2,20 @@ import { z } from 'zod'; import { agentIdentityFields, safeDocNameField, summaryField } from '../../schemas/api/_shared.ts'; import { DEFAULT_MARKDOWNLINT_CONFIG } from './default-config.ts'; import { LINT_PLUGINS, type LinterConfig } from './plugins.ts'; -import type { MarkdownlintRuleSetting } from './types.ts'; +import type { FrontmatterSchemaMapping, MarkdownlintRuleSetting } from './types.ts'; interface PersistedMarkdownlintSlice { enabled: boolean; rules?: Record; } +interface PersistedFrontmatterSlice { + enabled: boolean; + schemas?: FrontmatterSchemaMapping[]; +} export interface PersistedLinterConfig { enabled?: boolean; markdownlint: PersistedMarkdownlintSlice; + frontmatter?: PersistedFrontmatterSlice; } export function toEffectiveBase(persisted: PersistedLinterConfig): LinterConfig { @@ -21,6 +26,10 @@ export function toEffectiveBase(persisted: PersistedLinterConfig): LinterConfig ...persisted.markdownlint, rules: persisted.markdownlint.rules ?? DEFAULT_MARKDOWNLINT_CONFIG, }, + frontmatter: { + enabled: persisted.frontmatter?.enabled ?? false, + schemas: persisted.frontmatter?.schemas ?? [], + }, }, }; } @@ -51,6 +60,79 @@ export const MarkdownlintRuleWriteRequestSchema = z.object({ value: z.union([MarkdownlintRuleWriteValueSchema, z.null()]), }); +export const FrontmatterSchemaWriteRequestSchema = z + .object({ + file: z.string().min(1).max(512), + delete: z.literal(true).optional(), + field: z.string().min(1).max(256).optional(), + parentPath: z + .array(z.union([z.string().min(1).max(256), z.object({ items: z.literal(true) }).strict()])) + .max(8) + .optional(), + removeField: z.literal(true).optional(), + renameTo: z.string().min(1).max(256).optional(), + constraint: z + .object({ + type: z.enum(['string', 'number', 'boolean', 'array', 'object']).nullable().optional(), + enum: z + .array(z.union([z.string(), z.number(), z.boolean()])) + .nullable() + .optional(), + itemsEnum: z + .array(z.union([z.string(), z.number(), z.boolean()])) + .nullable() + .optional(), + itemsType: z.enum(['string', 'number', 'boolean', 'object']).nullable().optional(), + pattern: z.string().nullable().optional(), + format: z.string().nullable().optional(), + description: z.string().max(2048).nullable().optional(), + required: z.boolean().optional(), + }) + .optional(), + }) + .refine( + (body) => + body.delete === undefined || + (body.field === undefined && + body.constraint === undefined && + body.removeField === undefined && + body.renameTo === undefined), + { message: 'delete cannot be combined with a field edit' }, + ) + .refine( + (body) => + [body.constraint, body.removeField, body.renameTo].filter((v) => v !== undefined).length <= 1, + { message: 'constraint, removeField, and renameTo are mutually exclusive' }, + ) + .refine( + (body) => + (body.removeField === undefined && body.renameTo === undefined) || body.field !== undefined, + { + message: 'removeField and renameTo require field', + }, + ) + .refine((body) => body.parentPath === undefined || body.field !== undefined, { + message: 'parentPath requires field', + }) + .refine( + (body) => + body.field === undefined + ? body.constraint === undefined + : body.constraint !== undefined || + body.removeField !== undefined || + body.renameTo !== undefined, + { message: 'field requires constraint, removeField, or renameTo' }, + ); +export type FrontmatterSchemaWriteRequest = z.infer; + +export const FrontmatterSchemasListSuccessSchema = z + .object({ + schemas: z.array(z.string()), + truncated: z.boolean(), + }) + .strict(); +export type FrontmatterSchemasListSuccess = z.infer; + export type LintConfigResponse = z.infer; const LintPositionSchema = z.object({ line: z.number(), character: z.number() }); @@ -68,6 +150,7 @@ const LintDiagnosticSchema = z.object({ export const LintDocResultSchema = z.object({ file: z.string(), diagnostics: z.array(LintDiagnosticSchema), + warnings: z.array(z.string()).optional(), }); export const LintAuditResponseSchema = z.object({ diff --git a/packages/core/src/markdown/lint/frontmatter-schema-edit.ts b/packages/core/src/markdown/lint/frontmatter-schema-edit.ts new file mode 100644 index 000000000..74226af8e --- /dev/null +++ b/packages/core/src/markdown/lint/frontmatter-schema-edit.ts @@ -0,0 +1,228 @@ +import { DRAFT07_SCHEMA_URIS } from './frontmatter-validate.ts'; + +export interface FrontmatterFieldConstraint { + type?: 'string' | 'number' | 'boolean' | 'array' | 'object' | null; + enum?: (string | number | boolean)[] | null; + itemsEnum?: (string | number | boolean)[] | null; + itemsType?: 'string' | 'number' | 'boolean' | 'object' | null; + pattern?: string | null; + format?: string | null; + description?: string | null; + required?: boolean; +} + +export type SchemaParentPathSegment = string | { items: true }; + +export function emptyFrontmatterSchemaText(): string { + return `${JSON.stringify({ $schema: DRAFT07_SCHEMA_URIS[0], type: 'object' }, null, 2)}\n`; +} + +export function isToolManagedSchemaPath(file: string): boolean { + const normalized = file.trim().replace(/^\.\//, ''); + if (!normalized.startsWith('.ok/schemas/')) return false; + const name = normalized.slice('.ok/schemas/'.length); + return ( + name.length > 0 && !name.includes('/') && !name.includes('\\') && name !== '.' && name !== '..' + ); +} + +export function isFrontmatterSchemaAsset(path: string): boolean { + const basename = path.trim().toLowerCase().split('/').pop() ?? ''; + if (basename.endsWith('.schema.json') && basename.length > '.schema.json'.length) return true; + return isToolManagedSchemaPath(path) && basename.endsWith('.json'); +} + +export class FrontmatterSchemaEditError extends Error {} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function navigateToParent( + schema: Record, + parentPath: readonly SchemaParentPathSegment[], + create: boolean, +): Record { + let node = schema; + for (const segment of parentPath) { + if (typeof segment !== 'string') { + const items = node.items; + if (items === undefined) { + if (!create) { + throw new FrontmatterSchemaEditError('field path items does not exist'); + } + const created: Record = { type: 'object' }; + node.items = created; + node = created; + continue; + } + if (!isRecord(items)) { + throw new FrontmatterSchemaEditError('field path items is not an object sub-schema'); + } + node = items; + continue; + } + const properties = schemaProperties(node); + const child = properties[segment]; + if (child === undefined) { + if (!create) { + throw new FrontmatterSchemaEditError( + `field path ${JSON.stringify(segment)} does not exist`, + ); + } + const created: Record = { type: 'object' }; + node.properties = { ...properties, [segment]: created }; + node = created; + continue; + } + if (!isRecord(child)) { + throw new FrontmatterSchemaEditError( + `field path ${JSON.stringify(segment)} is not an object property`, + ); + } + node = child; + } + return node; +} + +export function applyFieldConstraint( + schemaText: string, + field: string, + constraint: FrontmatterFieldConstraint, + parentPath: readonly SchemaParentPathSegment[] = [], +): string { + let schema: Record; + if (schemaText.trim() === '') { + schema = JSON.parse(emptyFrontmatterSchemaText()) as Record; + } else { + schema = parseSchemaObject(schemaText); + } + const parent = navigateToParent(schema, parentPath, true); + + const properties = schemaProperties(parent); + const existing = isRecord(properties[field]) ? properties[field] : {}; + const property: Record = { ...existing }; + + for (const keyword of ['type', 'enum', 'pattern', 'format', 'description'] as const) { + if (!(keyword in constraint)) continue; + const value = constraint[keyword]; + if (value === null || value === undefined) delete property[keyword]; + else property[keyword] = value; + } + + if ('itemsEnum' in constraint || 'itemsType' in constraint) { + if (property.items !== undefined && !isRecord(property.items)) { + throw new FrontmatterSchemaEditError('field path items is not an object sub-schema'); + } + const items = isRecord(property.items) ? { ...property.items } : {}; + if ('itemsEnum' in constraint) { + if (constraint.itemsEnum === null || constraint.itemsEnum === undefined) delete items.enum; + else items.enum = constraint.itemsEnum; + } + if ('itemsType' in constraint) { + if (constraint.itemsType === null || constraint.itemsType === undefined) delete items.type; + else items.type = constraint.itemsType; + } + if (Object.keys(items).length > 0) property.items = items; + else delete property.items; + } + + if (Object.keys(property).length > 0) { + parent.properties = { ...properties, [field]: property }; + } else if (field in properties) { + const { [field]: _removed, ...rest } = properties; + if (Object.keys(rest).length > 0) parent.properties = rest; + else delete parent.properties; + } + + if (constraint.required !== undefined) { + const required = schemaRequired(parent); + const has = required.includes(field); + if (constraint.required && !has) parent.required = [...required, field]; + else if (!constraint.required && has) { + setRequired( + parent, + required.filter((entry) => entry !== field), + ); + } + } + + return `${JSON.stringify(schema, null, 2)}\n`; +} + +function parseSchemaObject(schemaText: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(schemaText); + } catch (err) { + throw new FrontmatterSchemaEditError( + `schema is not valid JSON (${err instanceof Error ? err.message : String(err)})`, + ); + } + if (!isRecord(parsed)) { + throw new FrontmatterSchemaEditError('schema is not a JSON object'); + } + return parsed; +} + +function schemaProperties(schema: Record): Record { + return isRecord(schema.properties) ? schema.properties : {}; +} + +function schemaRequired(schema: Record): string[] { + return Array.isArray(schema.required) + ? schema.required.filter((entry): entry is string => typeof entry === 'string') + : []; +} + +function setRequired(schema: Record, required: string[]): void { + if (required.length > 0) schema.required = required; + else delete schema.required; +} + +export function removeSchemaField( + schemaText: string, + field: string, + parentPath: readonly SchemaParentPathSegment[] = [], +): string { + const schema = parseSchemaObject(schemaText); + const parent = navigateToParent(schema, parentPath, false); + const properties = schemaProperties(parent); + if (field in properties) { + const { [field]: _removed, ...rest } = properties; + if (Object.keys(rest).length > 0) parent.properties = rest; + else delete parent.properties; + } + setRequired( + parent, + schemaRequired(parent).filter((entry) => entry !== field), + ); + return `${JSON.stringify(schema, null, 2)}\n`; +} + +export function renameSchemaField( + schemaText: string, + field: string, + to: string, + parentPath: readonly SchemaParentPathSegment[] = [], +): string { + const schema = parseSchemaObject(schemaText); + const parent = navigateToParent(schema, parentPath, false); + const properties = schemaProperties(parent); + if (!(field in properties)) { + throw new FrontmatterSchemaEditError(`field ${JSON.stringify(field)} does not exist`); + } + if (to in properties) { + throw new FrontmatterSchemaEditError(`field ${JSON.stringify(to)} already exists`); + } + const renamed: Record = {}; + for (const [key, value] of Object.entries(properties)) { + renamed[key === field ? to : key] = value; + } + parent.properties = renamed; + setRequired( + parent, + schemaRequired(parent).map((entry) => (entry === field ? to : entry)), + ); + return `${JSON.stringify(schema, null, 2)}\n`; +} diff --git a/packages/core/src/markdown/lint/frontmatter-validate.ts b/packages/core/src/markdown/lint/frontmatter-validate.ts new file mode 100644 index 000000000..176822faf --- /dev/null +++ b/packages/core/src/markdown/lint/frontmatter-validate.ts @@ -0,0 +1,206 @@ +import Ajv, { type ValidateFunction } from 'ajv'; +import addFormats from 'ajv-formats'; +import { isMap, isScalar, parseDocument } from 'yaml'; +import { + FRONTMATTER_RE, + stripFrontmatter, + unwrapFrontmatterFences, +} from '../../extensions/frontmatter.ts'; +import { compileAppliesTo } from './applies-to.ts'; +import type { LintDiagnostic, ResolvedFrontmatterSchemaEntry } from './types.ts'; + +export interface LoadedFrontmatterSchema { + file: string; + schema: Record; +} + +export function selectApplicableFrontmatterSchemas( + entries: readonly ResolvedFrontmatterSchemaEntry[], + docName: string | undefined, +): LoadedFrontmatterSchema[] { + const seen = new Set(); + const selected: LoadedFrontmatterSchema[] = []; + for (const entry of entries) { + if (entry.enabled === false) continue; + if (!entry.schema) continue; + if (!compileAppliesTo(entry.appliesTo).matches(docName)) continue; + const key = entry.key ?? entry.file; + if (seen.has(key)) continue; + seen.add(key); + selected.push({ file: entry.file, schema: entry.schema }); + } + return selected; +} + +export const DRAFT07_SCHEMA_URIS = [ + 'http://json-schema.org/draft-07/schema#', + 'http://json-schema.org/draft-07/schema', + 'https://json-schema.org/draft-07/schema#', + 'https://json-schema.org/draft-07/schema', +] as const; + +export function isSupportedSchemaDialect(schema: Record): boolean { + const declared = schema.$schema; + if (declared === undefined) return true; + return ( + typeof declared === 'string' && (DRAFT07_SCHEMA_URIS as readonly string[]).includes(declared) + ); +} + +let ajvInstance: Ajv | null = null; + +function getAjv(): Ajv { + if (!ajvInstance) { + ajvInstance = new Ajv({ allErrors: true, strict: false }); + addFormats(ajvInstance); + } + return ajvInstance; +} + +const compiledByContent = new Map(); +const COMPILED_CACHE_CAP = 256; + +function compileSchema(schema: Record): ValidateFunction | null { + const key = JSON.stringify(schema); + const cached = compiledByContent.get(key); + if (cached !== undefined) return cached; + let compiled: ValidateFunction | null = null; + if (isSupportedSchemaDialect(schema)) { + const { $schema: _dialect, ...body } = schema; + try { + compiled = getAjv().compile(body); + } catch { + compiled = null; + } + } + if (compiledByContent.size >= COMPILED_CACHE_CAP) compiledByContent.clear(); + compiledByContent.set(key, compiled); + return compiled; +} + +export function frontmatterSchemaCompileError(schema: Record): string | null { + if (compileSchema(schema)) return null; + const { $schema: _dialect, ...body } = schema; + try { + getAjv().compile(body); + return 'schema was refused'; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +function parseFrontmatterData(text: string): { + data: Record; + keyLines: Map; +} { + const keyLines = new Map(); + if (!FRONTMATTER_RE.test(text)) return { data: {}, keyLines }; + const { frontmatter } = stripFrontmatter(text); + const yamlBody = unwrapFrontmatterFences(frontmatter); + if (yamlBody.trim() === '') return { data: {}, keyLines }; + + const doc = parseDocument(yamlBody, { uniqueKeys: false }); + if (doc.errors.length > 0) return { data: {}, keyLines }; + const data: unknown = doc.toJS(); + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + return { data: {}, keyLines }; + } + + if (isMap(doc.contents)) { + for (const pair of doc.contents.items) { + const key = pair.key; + if (!isScalar(key) || typeof key.value !== 'string' || !key.range) continue; + let line = 0; + const cap = Math.min(key.range[0], yamlBody.length); + for (let i = 0; i < cap; i++) { + if (yamlBody.charCodeAt(i) === 10) line++; + } + if (!keyLines.has(key.value)) keyLines.set(key.value, line + 1); + } + } + return { data: data as Record, keyLines }; +} + +function describeActual(value: unknown): string { + if (value === undefined) return ''; + let rendered: string; + if (typeof value === 'string') rendered = `"${value}"`; + else { + try { + rendered = JSON.stringify(value) ?? String(value); + } catch { + rendered = String(value); + } + } + if (rendered.length > 60) rendered = `${rendered.slice(0, 57)}…`; + return ` (got ${rendered})`; +} + +function topLevelKey(instancePath: string): string | null { + if (!instancePath.startsWith('/')) return null; + const segment = instancePath.slice(1).split('/')[0] ?? ''; + if (segment === '') return null; + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +function pointerToDotPath(instancePath: string): string { + return instancePath + .split('/') + .slice(1) + .map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~')) + .join('.'); +} + +export function validateFrontmatterSource( + text: string, + schemas: readonly LoadedFrontmatterSchema[], +): LintDiagnostic[] { + if (schemas.length === 0) return []; + const { data, keyLines } = parseFrontmatterData(text); + const lines = text.split('\n'); + const lineSpan = (line: number): LintDiagnostic['range'] => ({ + start: { line, character: 0 }, + end: { line, character: lines[line]?.length ?? 0 }, + }); + + const diagnostics: LintDiagnostic[] = []; + for (const { schema } of schemas) { + const validate = compileSchema(schema); + if (!validate) continue; + if (validate(data)) continue; + for (const error of validate.errors ?? []) { + const keyword = error.keyword; + let line = 0; + let message: string; + if (keyword === 'required' && error.instancePath === '') { + const missing = String( + (error.params as { missingProperty?: unknown }).missingProperty ?? '', + ); + message = `Frontmatter property "${missing}" is required`; + } else if (error.instancePath === '') { + message = `Frontmatter ${error.message ?? `violates "${keyword}"`}`; + } else { + const anchorKey = topLevelKey(error.instancePath); + if (anchorKey !== null) { + line = keyLines.get(anchorKey) ?? 0; + } + const path = pointerToDotPath(error.instancePath); + if (keyword === 'enum') { + const allowed = (error.params as { allowedValues?: unknown[] }).allowedValues ?? []; + const actual = anchorKey !== null && path === anchorKey ? data[anchorKey] : undefined; + message = `Frontmatter property "${path}" must be one of: ${allowed.map(String).join(', ')}${describeActual(actual)}`; + } else { + message = `Frontmatter property "${path}" ${error.message ?? `violates "${keyword}"`}`; + } + } + diagnostics.push({ + range: lineSpan(line), + severity: 'warning', + source: 'frontmatter', + code: keyword, + message, + }); + } + } + return diagnostics; +} diff --git a/packages/core/src/markdown/lint/index.ts b/packages/core/src/markdown/lint/index.ts index 754f5e68e..d458fea1f 100644 --- a/packages/core/src/markdown/lint/index.ts +++ b/packages/core/src/markdown/lint/index.ts @@ -11,9 +11,7 @@ export async function lintDocument( for (const plugin of LINT_PLUGINS) { const slice = config.plugins[plugin.id]; if (!slice.enabled) continue; - diagnostics.push( - ...(await plugin.lint(text, slice as Parameters[1], { docName })), - ); + diagnostics.push(...(await plugin.lint(text, slice as never, { docName }))); } return diagnostics; } @@ -24,13 +22,25 @@ export function fixDocument(text: string, config: LinterConfig): string { for (const plugin of LINT_PLUGINS) { const slice = config.plugins[plugin.id]; if (!slice.enabled || !plugin.fix) continue; - out = plugin.fix(out, slice as Parameters>[1]); + out = plugin.fix(out, slice as never); } return out; } +export { + type AppliesToPatternSummary, + type AppliesToSummary, + type CompiledAppliesTo, + compileAppliesTo, + findZeroMatchAppliesToPatterns, + summarizeAppliesTo, +} from './applies-to.ts'; export { isMarkdownlintJsonConfig } from './config-files.ts'; export { + type FrontmatterSchemasListSuccess, + FrontmatterSchemasListSuccessSchema, + type FrontmatterSchemaWriteRequest, + FrontmatterSchemaWriteRequestSchema, type LintAuditResponse, LintAuditResponseSchema, type LintConfigResponse, @@ -51,6 +61,22 @@ export { ValidationDocResultSchema, } from './config-schemas.ts'; export { DEFAULT_MARKDOWNLINT_CONFIG, resolveMarkdownlintConfig } from './default-config.ts'; +export { + applyFieldConstraint, + emptyFrontmatterSchemaText, + type FrontmatterFieldConstraint, + FrontmatterSchemaEditError, + isFrontmatterSchemaAsset, + isToolManagedSchemaPath, + removeSchemaField, + renameSchemaField, + type SchemaParentPathSegment, +} from './frontmatter-schema-edit.ts'; +export { + frontmatterSchemaCompileError, + isSupportedSchemaDialect, + selectApplicableFrontmatterSchemas, +} from './frontmatter-validate.ts'; export { fixMarkdownText, runMarkdownlint } from './markdownlint-runner.ts'; export { DEFAULT_LINTER_CONFIG, @@ -68,6 +94,8 @@ export { } from './rule-catalog-categories.ts'; export { applyTextEdits } from './text-edits.ts'; export type { + FrontmatterSchemaMapping, + FrontmatterSlice, LinksValidationSetting, LintDiagnostic, LintPosition, @@ -78,6 +106,7 @@ export type { MarkdownlintRuleSeverity, MarkdownlintRuleWriteValue, MarkdownlintSlice, + ResolvedFrontmatterSchemaEntry, RuleCatalogEntry, RuleOptionSpec, RuleOptionType, diff --git a/packages/core/src/markdown/lint/plugins.ts b/packages/core/src/markdown/lint/plugins.ts index 1cd40ef3a..5e832aa19 100644 --- a/packages/core/src/markdown/lint/plugins.ts +++ b/packages/core/src/markdown/lint/plugins.ts @@ -1,7 +1,12 @@ import { z } from 'zod'; import { DEFAULT_MARKDOWNLINT_CONFIG, resolveMarkdownlintConfig } from './default-config.ts'; +import { + selectApplicableFrontmatterSchemas, + validateFrontmatterSource, +} from './frontmatter-validate.ts'; import { fixMarkdownText, runMarkdownlint } from './markdownlint-runner.ts'; import { + type FrontmatterSlice, type LintDiagnostic, type LintPluginId, MARKDOWNLINT_RULE_SEVERITIES, @@ -37,7 +42,30 @@ const markdownlintPlugin: LintPlugin<'markdownlint', MarkdownlintSlice> = { }, }; -export const LINT_PLUGINS = [markdownlintPlugin] as const; +const FrontmatterSchemaEntrySchema = z.object({ + appliesTo: z.union([z.string(), z.array(z.string())]).optional(), + file: z.string(), + enabled: z.boolean().optional(), + key: z.string().optional(), + schema: z.record(z.string(), z.unknown()).optional(), +}); + +const frontmatterPlugin: LintPlugin<'frontmatter', FrontmatterSlice> = { + id: 'frontmatter', + sliceSchema: z.object({ + enabled: z.boolean(), + schemas: z.array(FrontmatterSchemaEntrySchema), + }), + defaultSlice: { enabled: false, schemas: [] }, + async lint(text, slice, ctx) { + return validateFrontmatterSource( + text, + selectApplicableFrontmatterSchemas(slice.schemas, ctx.docName), + ); + }, +}; + +export const LINT_PLUGINS = [markdownlintPlugin, frontmatterPlugin] as const; type LintPluginEntry = (typeof LINT_PLUGINS)[number]; diff --git a/packages/core/src/markdown/lint/types.ts b/packages/core/src/markdown/lint/types.ts index 0f756702b..da6c477e9 100644 --- a/packages/core/src/markdown/lint/types.ts +++ b/packages/core/src/markdown/lint/types.ts @@ -1,4 +1,4 @@ -export const LINT_PLUGIN_IDS = ['markdownlint'] as const; +export const LINT_PLUGIN_IDS = ['markdownlint', 'frontmatter'] as const; export type LintPluginId = (typeof LINT_PLUGIN_IDS)[number]; export type LintSeverity = 'error' | 'warning' | 'info' | 'hint'; @@ -53,6 +53,22 @@ export interface MarkdownlintSlice { rules: Record; } +export interface FrontmatterSchemaMapping { + appliesTo?: string | string[]; + file: string; + enabled?: boolean; +} + +export interface ResolvedFrontmatterSchemaEntry extends FrontmatterSchemaMapping { + key?: string; + schema?: Record; +} + +export interface FrontmatterSlice { + enabled: boolean; + schemas: ResolvedFrontmatterSchemaEntry[]; +} + interface RuleOptionSpecBase { key: string; description: string; diff --git a/packages/core/src/schemas/cc1.ts b/packages/core/src/schemas/cc1.ts index 1dc9cb4ab..475f6bde4 100644 --- a/packages/core/src/schemas/cc1.ts +++ b/packages/core/src/schemas/cc1.ts @@ -88,6 +88,14 @@ export const CC1_CHANNEL_CONFIG_IGNORE_NESTED_ERROR = 'config-ignore-nested-erro * Channels that carry derived-view invalidation hints (file list, * backlink graph, hub graph, sync-status). Debounced + seq-incrementing * on the server; invalidates TanStack Query caches on the client. + * + * `lint-config` is NOT the retired pre-pivot `'config'` channel (config + * CONTENT refresh is Y.Text-observer-driven — see the note on + * `CC1_CHANNEL_CONFIG_VALIDATION_REJECTED`). It invalidates the + * DISK-derived effective lint config: `GET /api/lint/config` re-reads + * `.ok/config.yml` + native/schema files fresh from disk, so clients must + * refetch when the persisted bytes change — a moment the config CRDT + * observers fire BEFORE (persistence debounce), not at. */ export const DerivedViewChannelSchema = z.enum([ 'files', @@ -96,6 +104,7 @@ export const DerivedViewChannelSchema = z.enum([ 'sync-status', 'session-activity', 'tags', + 'lint-config', ]); export type DerivedViewChannel = z.infer; diff --git a/packages/server/src/api-extension.ts b/packages/server/src/api-extension.ts index 49cd96ed4..47665a0b4 100644 --- a/packages/server/src/api-extension.ts +++ b/packages/server/src/api-extension.ts @@ -93,6 +93,8 @@ import { ForwardLinksSuccessSchema, FrontmatterPatchRequestSchema, FrontmatterPatchSuccessSchema, + FrontmatterSchemasListSuccessSchema, + FrontmatterSchemaWriteRequestSchema, getHeadingSlug, getParseHealth, type HeadingEntry, @@ -104,6 +106,7 @@ import { InstallSkillRequestSchema, InstallSkillSuccessSchema, instantiateDoc, + isFrontmatterSchemaAsset, isHiddenDocName, isManagedArtifactDocName, isValidAttachmentFolderPath, @@ -362,10 +365,24 @@ import { recordSkillInstall, removeSkillInstall, } from './installed-skills-marker.ts'; -import { auditProject, lintAndFixSource, lintDoc } from './lint/audit.ts'; +import { auditProject, collectDocFiles, lintAndFixSource, lintDoc } from './lint/audit.ts'; +import { + createEmptyFrontmatterSchemaFile, + deleteFrontmatterSchemaFile, + removeFrontmatterSchemaField, + renameFrontmatterSchemaField, + type WriteFrontmatterSchemaResult, + writeFrontmatterSchemaField, +} from './lint/frontmatter-schema-write.ts'; +import { + listProjectSchemaFiles, + SCHEMA_LIST_CAP, + unmatchedAppliesToProblems, +} from './lint/frontmatter-schemas.ts'; import { type WriteMarkdownlintResult, writeMarkdownlintRule } from './lint/markdownlint-write.ts'; import { composeEffectiveLinterConfig, + composeFrontmatterSchemasConfig, resolveEffectiveLinterConfig, resolveNativeConfigForDoc, } from './lint/resolve-config.ts'; @@ -2722,7 +2739,7 @@ export interface ApiExtensionOptions { contentRoot?: string; backlinkIndex?: BacklinkIndex; tagIndex?: TagIndex; - signalChannel?: (channel: 'files' | 'backlinks' | 'graph') => void; + signalChannel?: (channel: 'files' | 'backlinks' | 'graph' | 'lint-config') => void; /** * Optional. When present, agent write handlers publish per-write attribution * entries on `__system__` awareness (`agentFocus` map) with writeKind + @@ -19064,6 +19081,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { try { const effective = resolveEffectiveLinterConfig(contentDir, base, { docName, + projectDir: projectDir ?? contentDir, onProblem: (problem) => log.warn({ problem, docName }, '[lint] native config problem'), }); const lintFindings = await lintDocument(source, effective, docName); @@ -19122,6 +19140,17 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { } } + // Zero-match appliesTo detection needs the project's doc list — a content + // walk — so only the doc-independent lint-config responses (the surfaces the + // Settings frontmatter panel reads) pay for it; per-doc `?doc=` fetches and + // the per-write lint path never do. + function unmatchedGlobProblems(effective: LinterConfig): string[] { + const slice = effective.plugins.frontmatter; + if (!slice.enabled || slice.schemas.length === 0) return []; + const docFiles = collectDocFiles({ projectDir: projectDir ?? contentDir, contentDir }); + return unmatchedAppliesToProblems(slice.schemas, docFiles); + } + const handleGetLintConfig = withValidation( EmptyRequestSchema, async (req, res) => { @@ -19142,7 +19171,12 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { const native = resolveNativeConfigForDoc(contentDir, docName ?? undefined, (problem) => configProblems.push(problem), ); - const effective = composeEffectiveLinterConfig(base, native); + const effective = composeFrontmatterSchemasConfig( + projectDir ?? contentDir, + composeEffectiveLinterConfig(base, native), + (problem) => configProblems.push(problem), + ); + if (docName === null) configProblems.push(...unmatchedGlobProblems(effective)); successResponse( res, 200, @@ -19199,13 +19233,23 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { } // Past this point the rule IS on disk — a re-read failure must not // report the write itself as failed. + // Converge every other window on the new rules. Without this a rule + // toggle in one window leaves the rest linting against a stale config + // until they happen to refetch, which is what the frontmatter sibling + // below already avoids. + signalChannel?.('lint-config'); try { const base = getLinterBaseConfig?.() ?? DEFAULT_LINTER_CONFIG; const configProblems: string[] = []; const native = resolveNativeConfigForDoc(contentDir, undefined, (problem) => configProblems.push(problem), ); - const effective = composeEffectiveLinterConfig(base, native); + const effective = composeFrontmatterSchemasConfig( + projectDir ?? contentDir, + composeEffectiveLinterConfig(base, native), + (problem) => configProblems.push(problem), + ); + configProblems.push(...unmatchedGlobProblems(effective)); successResponse( res, 200, @@ -19226,6 +19270,158 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { { handler: 'markdownlint-config', method: 'POST' }, ); + // Edit one field of a frontmatter schema file (non-destructive merge via + // applyFieldConstraint; create-on-first-edit). Responds with the recomputed + // effective config, mirroring the markdownlint write. + const handleWriteFrontmatterSchema = withValidation( + FrontmatterSchemaWriteRequestSchema, + async (_req, res, body) => { + let writeResult: WriteFrontmatterSchemaResult; + try { + // Five shapes over one route (schema-refined): delete → remove the + // tool-managed file; field + removeField → drop the field; field + + // renameTo → rename it; field + constraint → per-field edit; + // otherwise create-empty (scaffold the skeleton so a freshly-picked + // new schema file exists). + const root = resolve(projectDir ?? contentDir); + const parentPath = body.parentPath ?? []; + writeResult = body.delete + ? deleteFrontmatterSchemaFile(root, body.file) + : body.field !== undefined && body.removeField + ? removeFrontmatterSchemaField(root, body.file, body.field, parentPath) + : body.field !== undefined && body.renameTo !== undefined + ? renameFrontmatterSchemaField(root, body.file, body.field, body.renameTo, parentPath) + : body.field !== undefined && body.constraint !== undefined + ? writeFrontmatterSchemaField( + root, + body.file, + body.field, + body.constraint, + parentPath, + ) + : createEmptyFrontmatterSchemaFile(root, body.file); + } catch (e) { + errorResponse( + res, + 500, + 'urn:ok:error:internal-server-error', + 'Failed to write the frontmatter schema.', + { handler: 'frontmatter-schema', cause: e }, + ); + return; + } + if (writeResult.action === 'refused') { + errorResponse( + res, + 409, + 'urn:ok:error:config-not-writable', + `The frontmatter schema (${writeResult.file}) was not written: ${writeResult.reason}.`, + { handler: 'frontmatter-schema' }, + ); + return; + } + // `.ok/` is outside the content file-watcher, so schema-file mutations + // never reach the tree or other clients through watcher events. `files` + // keeps show-OK trees live on create/delete; `lint-config` converges + // every other window's effective config (this response only reaches the + // requesting client). + if (writeResult.action === 'created' || writeResult.action === 'deleted') { + signalChannel?.('files'); + } + signalChannel?.('lint-config'); + try { + const base = getLinterBaseConfig?.() ?? DEFAULT_LINTER_CONFIG; + const configProblems: string[] = []; + const native = resolveNativeConfigForDoc(contentDir, undefined, (problem) => + configProblems.push(problem), + ); + const effective = composeFrontmatterSchemasConfig( + projectDir ?? contentDir, + composeEffectiveLinterConfig(base, native), + (problem) => configProblems.push(problem), + ); + configProblems.push(...unmatchedGlobProblems(effective)); + successResponse( + res, + 200, + LintConfigResponseSchema, + { effective, configFile: native?.file ?? null, configProblems }, + { handler: 'frontmatter-schema' }, + ); + } catch (e) { + errorResponse( + res, + 500, + 'urn:ok:error:internal-server-error', + 'The schema was saved, but the effective config could not be re-read.', + { handler: 'frontmatter-schema', cause: e }, + ); + } + }, + { handler: 'frontmatter-schema', method: 'POST' }, + ); + + // Enumerate the project's `.ok/schemas/*.json` files (flat, top-level only) + // as project-root-relative paths for the mapping picker. A missing dir is + // an empty list, not an error; bounded so a pathological schemas dir can't + // produce an unbounded response. + const handleFrontmatterSchemasList = withValidation( + EmptyRequestSchema, + async (_req, res) => { + try { + const root = resolve(projectDir ?? contentDir); + // Two discovery sources: the flat tool-created `.ok/schemas/` scan, + // plus a filtered content walk for the ecosystem `*.schema.json` + // convention anywhere in the project. The walk deliberately does NOT + // re-admit `.ok`: the scan above already covers `.ok/schemas/`, and + // lifting ContentFilter's always-skip floor here would let this + // surface enumerate the rest of OK's internal state to find schemas. + const { schemas, truncated } = listProjectSchemaFiles(root); + const found = new Set(schemas); + let walkTruncated = false; + if (contentFilter !== undefined) { + const walk = streamShowAllEntries({ + contentDir, + contentFilter, + dirFilter: null, + maxEntries: 20_000, + }); + let walkResult = await walk.next(); + while (!walkResult.done) { + const entry = walkResult.value; + const entryPath = entry.kind === 'asset' ? entry.path : undefined; + if (entryPath !== undefined && isFrontmatterSchemaAsset(entryPath)) { + const projectRel = relative(root, resolve(contentDir, entryPath)); + if (!projectRel.startsWith('..') && !isAbsolute(projectRel)) found.add(projectRel); + } + walkResult = await walk.next(); + } + walkTruncated = walkResult.value.truncated; + } + const merged = [...found].sort((a, b) => a.localeCompare(b)); + successResponse( + res, + 200, + FrontmatterSchemasListSuccessSchema, + { + schemas: merged.slice(0, SCHEMA_LIST_CAP), + truncated: truncated || walkTruncated || merged.length > SCHEMA_LIST_CAP, + }, + { handler: 'frontmatter-schemas-list' }, + ); + } catch (e) { + errorResponse( + res, + 500, + 'urn:ok:error:internal-server-error', + 'Failed to list frontmatter schemas.', + { handler: 'frontmatter-schemas-list', cause: e }, + ); + } + }, + { handler: 'frontmatter-schemas-list', method: 'GET', skipBodyParse: true }, + ); + /** * Live CRDT source for a currently-loaded doc, else null. Lint reads must * see the same bytes the editor and `/api/lint/fix` operate on: the disk @@ -19260,14 +19456,22 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { return; } const baseConfig = getLinterBaseConfig?.() ?? DEFAULT_LINTER_CONFIG; + const configWarnings: string[] = []; const result = await lintDoc({ projectDir: projectDir ?? contentDir, contentDir, baseConfig, docRelPath, + onConfigProblem: (problem) => configWarnings.push(problem), liveSourceFor: liveLintSourceFor, }); - successResponse(res, 200, LintDocResultSchema, result, { handler: 'lint' }); + successResponse( + res, + 200, + LintDocResultSchema, + configWarnings.length > 0 ? { ...result, warnings: configWarnings } : result, + { handler: 'lint' }, + ); } catch (e) { if (e instanceof SymlinkEscapeError) { errorResponse(res, 400, 'urn:ok:error:path-escape', 'Path escape detected.', { @@ -19771,6 +19975,8 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { '/api/semantic-status': handleSemanticStatus, '/api/lint/config': handleGetLintConfig, '/api/lint/markdownlint-config': handleWriteMarkdownlintRule, + '/api/lint/frontmatter-schema': handleWriteFrontmatterSchema, + '/api/lint/frontmatter-schemas': handleFrontmatterSchemasList, '/api/lint': handleLintDoc, '/api/lint/audit': handleLintAudit, '/api/lint/fix': handleLintFix, @@ -19856,6 +20062,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { const MUTATING_ROUTES: ReadonlySet = new Set([ '/api/upload', '/api/lint/markdownlint-config', + '/api/lint/frontmatter-schema', '/api/lint/fix', '/api/create-page', '/api/create-folder', diff --git a/packages/server/src/content/enrichment.test.ts b/packages/server/src/content/enrichment.test.ts index 01b962345..9aabd11ba 100644 --- a/packages/server/src/content/enrichment.test.ts +++ b/packages/server/src/content/enrichment.test.ts @@ -291,3 +291,79 @@ describe('computeGraphRole', () => { expect(computeGraphRole(2, 0)).toBe('leaf'); }); }); + +describe('schemas_applicable — read-time schema advertisement', () => { + const MAPPINGS = [ + { appliesTo: ['docs/**', '!**/{index,log}'], file: '.ok/schemas/doc.schema.json' }, + { appliesTo: 'specs/**', file: '.ok/schemas/spec.schema.json' }, + ]; + + test('a doc matching one mapping advertises that schema file', async () => { + const project = await bootstrapProject(); + mkdirSync(resolve(project, 'docs'), { recursive: true }); + writeFileSync(resolve(project, 'docs', 'guide.md'), '---\ntitle: G\n---\n'); + const meta = await enrichPath('docs/guide.md', { + projectDir: project, + frontmatterSchemas: MAPPINGS, + }); + expect(meta.schemas_applicable).toEqual(['.ok/schemas/doc.schema.json']); + }); + + test('a disabled mapping is not advertised', async () => { + // A parked mapping still resolves (the schema editor reads it) but must + // not reach agents, or a toggled-off schema would read as governing. + const project = await bootstrapProject(); + mkdirSync(resolve(project, 'docs'), { recursive: true }); + writeFileSync(resolve(project, 'docs', 'guide.md'), '---\ntitle: G\n---\n'); + const meta = await enrichPath('docs/guide.md', { + projectDir: project, + frontmatterSchemas: [ + { appliesTo: 'docs/**', file: '.ok/schemas/doc.schema.json', enabled: false }, + ], + }); + expect(meta.schemas_applicable).toBeUndefined(); + }); + + test('a doc matching no mapping (negated name) has no advertisement', async () => { + const project = await bootstrapProject(); + mkdirSync(resolve(project, 'docs'), { recursive: true }); + writeFileSync(resolve(project, 'docs', 'index.md'), '---\ntitle: I\n---\n'); + const meta = await enrichPath('docs/index.md', { + projectDir: project, + frontmatterSchemas: MAPPINGS, + }); + expect(meta.schemas_applicable).toBeUndefined(); + }); + + test('no mappings passed (plugin disabled) means no advertisement', async () => { + const project = await bootstrapProject(); + writeFileSync(resolve(project, 'a.md'), '# A\n'); + const meta = await enrichPath('a.md', { projectDir: project }); + expect(meta.schemas_applicable).toBeUndefined(); + }); + + test('folder-level advertisement covers the new-file gap', async () => { + const project = await bootstrapProject(); + mkdirSync(resolve(project, 'docs'), { recursive: true }); + const dir = await enrichDirectory('docs', { + projectDir: project, + frontmatterSchemas: MAPPINGS, + }); + expect(dir.schemas_applicable).toEqual(['.ok/schemas/doc.schema.json']); + const other = await enrichDirectory('', { projectDir: project, frontmatterSchemas: MAPPINGS }); + expect(other.schemas_applicable).toBeUndefined(); + }); + + test('content.dir rebasing: appliesTo matches content-relative paths', async () => { + const project = await bootstrapProject(); + const contentDir = resolve(project, 'kb'); + mkdirSync(resolve(contentDir, 'docs'), { recursive: true }); + writeFileSync(resolve(contentDir, 'docs', 'guide.md'), '---\ntitle: G\n---\n'); + const meta = await enrichPath('kb/docs/guide.md', { + projectDir: project, + contentDir, + frontmatterSchemas: MAPPINGS, + }); + expect(meta.schemas_applicable).toEqual(['.ok/schemas/doc.schema.json']); + }); +}); diff --git a/packages/server/src/content/enrichment.ts b/packages/server/src/content/enrichment.ts index eca3a7ff5..083ffb4b7 100644 --- a/packages/server/src/content/enrichment.ts +++ b/packages/server/src/content/enrichment.ts @@ -15,7 +15,12 @@ import type { Dirent } from 'node:fs'; import { readdir, readFile, stat } from 'node:fs/promises'; import { basename, relative, resolve } from 'node:path'; -import { OK_DIR, parseFrontmatterRecord } from '@inkeep/open-knowledge-core'; +import { + compileAppliesTo, + type FrontmatterSchemaMapping, + OK_DIR, + parseFrontmatterRecord, +} from '@inkeep/open-knowledge-core'; import { getLogger } from '../logger.ts'; import { resolveWithinRoot } from '../mcp/tools/path-safety.ts'; import { httpGet } from '../mcp/tools/shared.ts'; @@ -129,6 +134,13 @@ export interface DirectoryMeta { * start with" — folder frontmatter no longer cascades values into children. */ templates_available?: TemplateEntry[]; + /** + * Frontmatter schema files that could govern docs in this folder (resolved + * server-side from the enabled frontmatter plugin's `appliesTo` mappings — + * the agent never evaluates a glob). Covers the new-file gap: a read of the + * folder advertises the contract before the first write. + */ + schemas_applicable?: string[]; /** * Recursive subfolder enrichment. Populated when a caller (e.g. * an `exec` recursive listing) asks for subtree visibility — each entry @@ -214,6 +226,12 @@ export interface EnrichedMeta { * multi-path output, where the counts are not fully resolved. */ graphRole: GraphRole | null; + /** + * Frontmatter schema files governing this doc, resolved server-side via the + * enabled frontmatter plugin's `appliesTo` mappings. Absent when the plugin + * is off, no mapping matches, or the doc sits outside the content root. + */ + schemas_applicable?: string[]; } /** Coarse classification of a document by its link counts. */ @@ -248,6 +266,68 @@ interface EnrichPathDeps { serverUrl?: string | undefined; /** History depth for rich mode; defaults to 5. */ historyDepth?: number; + /** + * Content root (`resolve(projectDir, content.dir)`) — `appliesTo` globs + * match content-relative doc paths, while enrichment paths are + * project-relative. Defaults to `projectDir` when omitted. + */ + contentDir?: string; + /** + * The enabled frontmatter plugin's schema mappings. When present, doc and + * folder enrichment advertise which schema files govern each path + * (`schemas_applicable`) — the read-time serving surface that lets an agent + * learn a doc's contract without parsing config or evaluating globs itself. + */ + frontmatterSchemas?: FrontmatterSchemaMapping[]; +} + +/** + * Schema files whose `appliesTo` matches this doc (project-relative path is + * rebased to content-relative before matching). Unique, mapping order. + * + * Applies the same selection triad as `selectApplicableFrontmatterSchemas` in + * core — skip disabled, match `appliesTo`, dedup — but over the PERSISTED + * mappings rather than resolved entries, because enrichment advertises which + * files govern a doc without loading their content. Keep the two in step: a + * change to what "governs" means belongs in both, and only the core one is + * covered by the validator's own tests. + */ +function schemasApplicableToDoc( + deps: Pick, + relPath: string, +): string[] { + const mappings = deps.frontmatterSchemas; + if (!mappings || mappings.length === 0) return []; + const contentDir = deps.contentDir ?? deps.projectDir; + const contentRel = relative(contentDir, resolve(deps.projectDir, relPath)); + if (contentRel.startsWith('..')) return []; + const files: string[] = []; + for (const mapping of mappings) { + if (mapping.enabled === false) continue; + if (!compileAppliesTo(mapping.appliesTo).matches(contentRel)) continue; + if (!files.includes(mapping.file)) files.push(mapping.file); + } + return files; +} + +/** + * Schema files that could govern docs created in this folder: an entry is + * advertised when its globs match a placeholder direct child of the folder. + * A prefix-feasibility check — entries whose positive globs only match + * specific literal names (an index/log alternation) or deeper descendants are + * not advertised at the folder level; they still advertise on the docs + * themselves. (The obvious glob example can't appear here: a globstar + * followed by a slash inside a block comment would close the comment.) + */ +function schemasApplicableToFolder( + deps: Pick, + relPath: string, +): string[] { + // U+2042 asterism as the placeholder child name — matched by wildcard + // segments, never by a literal doc name. + const placeholder = '⁂'; + const probe = relPath === '' ? placeholder : `${relPath}/${placeholder}`; + return schemasApplicableToDoc(deps, probe); } interface EnrichPathOptions { @@ -467,6 +547,9 @@ export async function enrichPath( const rich = options.includeRichFields === true; const fmPromise = readFrontmatter(absPath); + const applicableSchemas = schemasApplicableToDoc(deps, relPath); + const schemasField = + applicableSchemas.length > 0 ? { schemas_applicable: applicableSchemas } : {}; if (!rich) { const fm = await fmPromise; @@ -486,6 +569,7 @@ export async function enrichPath( projectHistory: null, projectHistorySource: null, graphRole: null, + ...schemasField, }; } @@ -520,6 +604,7 @@ export async function enrichPath( projectHistory: project.commits, projectHistorySource: project.source, graphRole: computeGraphRole(backlinks?.length ?? null, forwardLinks?.length ?? null), + ...schemasField, }; } @@ -601,7 +686,7 @@ async function scanDirectory(absDir: string, projectDir: string): Promise, + deps: Pick, ): Promise { // See `enrichPath` for the rationale — same `..`/absolute-path escape // class via `node:fs`-direct readdir / stat. @@ -651,6 +736,9 @@ export async function enrichDirectory( const templates = resolveTemplatesAvailable(deps.projectDir, relPath); if (templates.length > 0) result.templates_available = templates; + const folderSchemas = schemasApplicableToFolder(deps, relPath); + if (folderSchemas.length > 0) result.schemas_applicable = folderSchemas; + return result; } @@ -667,7 +755,7 @@ export async function enrichDirectory( export async function enrichDirectoryRecursive( relPathInput: string, depth: number, - deps: Pick, + deps: Pick, ): Promise { const top = await enrichDirectory(relPathInput, deps); if (depth <= 1) return top; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 66faa5347..b9a2565d9 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -247,6 +247,7 @@ export { } from './lint/markdownlint-write.ts'; export { composeEffectiveLinterConfig, + composeFrontmatterSchemasConfig, type ResolveLinterConfigOptions, resolveEffectiveLinterConfig, resolveNativeConfigForDoc, diff --git a/packages/server/src/lint/audit-symlink.test.ts b/packages/server/src/lint/audit-symlink.test.ts index 0648a3f64..132cc73b8 100644 --- a/packages/server/src/lint/audit-symlink.test.ts +++ b/packages/server/src/lint/audit-symlink.test.ts @@ -20,7 +20,10 @@ const DOC_WITH_TAB = '# Title\n\n\tindented with a tab\n'; const base: LinterConfig = { ...DEFAULT_LINTER_CONFIG, - plugins: { markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true } }, + plugins: { + ...DEFAULT_LINTER_CONFIG.plugins, + markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true }, + }, }; beforeEach(() => { diff --git a/packages/server/src/lint/audit.test.ts b/packages/server/src/lint/audit.test.ts index d8493b06a..bd84d54f9 100644 --- a/packages/server/src/lint/audit.test.ts +++ b/packages/server/src/lint/audit.test.ts @@ -25,7 +25,10 @@ const CLEAN_DOC = '# Title\n\nClean paragraph.\n'; const base: LinterConfig = { ...DEFAULT_LINTER_CONFIG, - plugins: { markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true } }, + plugins: { + ...DEFAULT_LINTER_CONFIG.plugins, + markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true }, + }, }; beforeEach(() => { diff --git a/packages/server/src/lint/audit.ts b/packages/server/src/lint/audit.ts index 17430e11c..b00840af0 100644 --- a/packages/server/src/lint/audit.ts +++ b/packages/server/src/lint/audit.ts @@ -18,7 +18,8 @@ import { import { SymlinkEscapeError } from '../apply-managed-rename.ts'; import { createContentFilter } from '../content-filter.ts'; import { isWithinContentDir } from '../content-path.ts'; -import { resolveEffectiveLinterConfig } from './resolve-config.ts'; +import { unmatchedAppliesToProblems } from './frontmatter-schemas.ts'; +import { composeFrontmatterSchemasConfig, resolveEffectiveLinterConfig } from './resolve-config.ts'; export interface FileLintResult { /** Path relative to `contentDir`. */ @@ -54,7 +55,7 @@ export interface AuditOptions { export async function lintDoc( opts: AuditOptions & { docRelPath: string; onConfigProblem?: (problem: string) => void }, ): Promise { - const { contentDir, baseConfig, docRelPath, onConfigProblem, liveSourceFor } = opts; + const { projectDir, contentDir, baseConfig, docRelPath, onConfigProblem, liveSourceFor } = opts; const live = liveSourceFor?.(docRelPath) ?? null; let text: string; if (live !== null) { @@ -68,6 +69,7 @@ export async function lintDoc( } const cfg = resolveEffectiveLinterConfig(contentDir, baseConfig, { docName: docRelPath, + projectDir, onProblem: onConfigProblem, }); return { file: docRelPath, diagnostics: await lintDocument(text, cfg, docRelPath) }; @@ -89,9 +91,10 @@ export async function lintAndFixSource( onConfigProblem?: (problem: string) => void; }, ): Promise<{ cfg: LinterConfig; before: LintDiagnostic[]; fixed: string }> { - const { contentDir, baseConfig, docRelPath, source, onConfigProblem } = opts; + const { projectDir, contentDir, baseConfig, docRelPath, source, onConfigProblem } = opts; const cfg = resolveEffectiveLinterConfig(contentDir, baseConfig, { docName: docRelPath, + projectDir, onProblem: onConfigProblem, }); const before = await lintDocument(source, cfg, docRelPath); @@ -99,13 +102,61 @@ export async function lintAndFixSource( return { cfg, before, fixed }; } +/** + * Enumerate every in-scope `.md`/`.mdx` document under `scopeDir` (default: + * all of `contentDir`) as content-relative paths — the same walk the audit + * lints over (ignore rules, hidden-segment skips, extension gate), exposed so + * doc-independent checks (unmatched appliesTo globs on the lint-config + * surface) agree with the audit about what counts as a doc. + */ +export function collectDocFiles(opts: { + projectDir: string; + contentDir: string; + scopeDir?: string; + onWarning?: (warning: string) => void; +}): string[] { + const { projectDir, contentDir, scopeDir, onWarning } = opts; + const filter = createContentFilter({ projectDir, contentDir }); + const docFiles: string[] = []; + + function walk(absDir: string): void { + let entries: import('node:fs').Dirent[]; + try { + entries = readdirSync(absDir, { withFileTypes: true }); + } catch (e) { + onWarning?.(`could not read ${relative(contentDir, absDir) || '.'}: ${errMsg(e)}`); + return; + } + for (const entry of entries) { + // Hidden segments (.ok/, .git/, .obsidian/, dotfiles) are not + // addressable as docNames (see `validateDocName`), so a diagnostic here + // could be neither navigated to nor auto-fixed — the fix endpoint + // rejects the docName outright. Skip them so the audit's scope stays + // symmetric with the write path's addressability (precedent #55). + if (entry.name.startsWith('.')) continue; + const full = join(absDir, entry.name); + const rel = relative(contentDir, full); + if (entry.isDirectory()) { + if (filter.isDirExcluded(rel)) continue; + walk(full); + } else if (entry.isFile()) { + if (!isDocFile(entry.name)) continue; + if (filter.isExcluded(rel)) continue; + docFiles.push(rel); + } + } + } + + walk(scopeDir ?? contentDir); + return docFiles; +} + /** Lint every in-scope `.md`/`.mdx` document under `contentDir` (or a sub-path). */ export async function auditProject( opts: AuditOptions & { targetPath?: string }, ): Promise { const { projectDir, contentDir, baseConfig, targetPath } = opts; const warnings: string[] = []; - const filter = createContentFilter({ projectDir, contentDir }); const docFiles: string[] = []; const scope = resolveScope(targetPath, contentDir); @@ -139,35 +190,14 @@ export async function auditProject( if (scope.kind === 'file') { docFiles.push(relative(contentDir, scope.path)); } else { - walk(scope.path); - } - - function walk(absDir: string): void { - let entries: import('node:fs').Dirent[]; - try { - entries = readdirSync(absDir, { withFileTypes: true }); - } catch (e) { - warnings.push(`could not read ${relative(contentDir, absDir) || '.'}: ${errMsg(e)}`); - return; - } - for (const entry of entries) { - // Hidden segments (.ok/, .git/, .obsidian/, dotfiles) are not - // addressable as docNames (see `validateDocName`), so a diagnostic here - // could be neither navigated to nor auto-fixed — the fix endpoint - // rejects the docName outright. Skip them so the audit's scope stays - // symmetric with the write path's addressability (precedent #55). - if (entry.name.startsWith('.')) continue; - const full = join(absDir, entry.name); - const rel = relative(contentDir, full); - if (entry.isDirectory()) { - if (filter.isDirExcluded(rel)) continue; - walk(full); - } else if (entry.isFile()) { - if (!isDocFile(entry.name)) continue; - if (filter.isExcluded(rel)) continue; - docFiles.push(rel); - } - } + docFiles.push( + ...collectDocFiles({ + projectDir, + contentDir, + scopeDir: scope.path, + onWarning: (warning) => warnings.push(warning), + }), + ); } docFiles.sort(); @@ -182,13 +212,24 @@ export async function auditProject( seenConfigProblems.add(problem); warnings.push(problem); }; + // Frontmatter schema loading is doc-independent — resolve once for the whole + // audit; the per-doc resolution sees resolved entries and skips the reads. + const auditBase = composeFrontmatterSchemasConfig(projectDir, baseConfig, onConfigProblem); + // Zero-match globs only make sense against the FULL doc set — a sub-path + // audit would flag every pattern scoped to a folder outside the target. + const fmSlice = auditBase.plugins.frontmatter; + if ((targetPath === undefined || targetPath === '') && fmSlice.enabled) { + for (const problem of unmatchedAppliesToProblems(fmSlice.schemas, docFiles)) { + onConfigProblem(problem); + } + } for (const rel of docFiles) { let result: FileLintResult; try { result = await lintDoc({ projectDir, contentDir, - baseConfig, + baseConfig: auditBase, docRelPath: rel, onConfigProblem, liveSourceFor: opts.liveSourceFor, diff --git a/packages/server/src/lint/frontmatter-schema-write.test.ts b/packages/server/src/lint/frontmatter-schema-write.test.ts new file mode 100644 index 000000000..794d9b073 --- /dev/null +++ b/packages/server/src/lint/frontmatter-schema-write.test.ts @@ -0,0 +1,289 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { + createEmptyFrontmatterSchemaFile, + deleteFrontmatterSchemaFile, + removeFrontmatterSchemaField, + renameFrontmatterSchemaField, + writeFrontmatterSchemaField, +} from './frontmatter-schema-write.ts'; + +let projectDir: string; +let outsideDir: string; + +beforeEach(() => { + projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'ok-fm-write-'))); + outsideDir = realpathSync(mkdtempSync(join(tmpdir(), 'ok-fm-write-outside-'))); +}); + +afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); +}); + +function write(rel: string, content: string): string { + const abs = join(projectDir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + return abs; +} + +describe('writeFrontmatterSchemaField', () => { + test('create-on-first-edit materializes the skeleton + edit at the mapped path', () => { + const result = writeFrontmatterSchemaField( + projectDir, + '.ok/schemas/new.schema.json', + 'status', + { + enum: ['draft', 'review'], + required: true, + }, + ); + expect(result.action).toBe('created'); + const onDisk = JSON.parse( + readFileSync(join(projectDir, '.ok/schemas/new.schema.json'), 'utf-8'), + ); + expect(onDisk.$schema).toBe('http://json-schema.org/draft-07/schema#'); + expect(onDisk.properties.status.enum).toEqual(['draft', 'review']); + expect(onDisk.required).toEqual(['status']); + }); + + test('merge preserves advanced keywords on disk', () => { + write( + 'schema.json', + JSON.stringify({ type: 'object', allOf: [{ required: ['t'] }], 'x-k': 1 }, null, 2), + ); + const result = writeFrontmatterSchemaField(projectDir, 'schema.json', 'owner', { + type: 'string', + }); + expect(result.action).toBe('written'); + const onDisk = JSON.parse(readFileSync(join(projectDir, 'schema.json'), 'utf-8')); + expect(onDisk.allOf).toEqual([{ required: ['t'] }]); + expect(onDisk['x-k']).toBe(1); + expect(onDisk.properties.owner.type).toBe('string'); + }); + + test('an escaping path is refused without writing', () => { + const rel = `../${outsideDir.split('/').pop()}/evil.json`; + const result = writeFrontmatterSchemaField(projectDir, rel, 'x', { type: 'string' }); + expect(result.action).toBe('refused'); + }); + + test('a malformed existing schema is refused, file left untouched', () => { + const abs = write('broken.json', '{ nope'); + const result = writeFrontmatterSchemaField(projectDir, 'broken.json', 'x', { type: 'string' }); + expect(result.action).toBe('refused'); + expect(readFileSync(abs, 'utf-8')).toBe('{ nope'); + }); + + test('a no-op edit reports written without rewriting bytes', () => { + write('same.json', `${JSON.stringify({ type: 'object' }, null, 2)}\n`); + const before = readFileSync(join(projectDir, 'same.json'), 'utf-8'); + const result = writeFrontmatterSchemaField(projectDir, 'same.json', 'x', {}); + expect(result.action).toBe('written'); + expect(readFileSync(join(projectDir, 'same.json'), 'utf-8')).toBe(before); + }); +}); + +describe('createEmptyFrontmatterSchemaFile', () => { + test('creates the draft-07 skeleton at a fresh path, making the dir', () => { + const result = createEmptyFrontmatterSchemaFile(projectDir, '.ok/schemas/new.schema.json'); + expect(result.action).toBe('created'); + const onDisk = JSON.parse( + readFileSync(join(projectDir, '.ok/schemas/new.schema.json'), 'utf-8'), + ); + expect(onDisk).toMatchObject({ type: 'object' }); + expect(typeof onDisk.$schema).toBe('string'); + }); + + test('is idempotent — an existing schema is left untouched (never clobbered)', () => { + const existing = `${JSON.stringify({ type: 'object', properties: { a: { type: 'string' } } }, null, 2)}\n`; + write('.ok/schemas/keep.json', existing); + const result = createEmptyFrontmatterSchemaFile(projectDir, '.ok/schemas/keep.json'); + expect(result.action).toBe('written'); + expect(readFileSync(join(projectDir, '.ok/schemas/keep.json'), 'utf-8')).toBe(existing); + }); + + test('refuses a path that escapes the project root', () => { + const rel = `../${outsideDir.split('/').pop()}/evil.json`; + const result = createEmptyFrontmatterSchemaFile(projectDir, rel); + expect(result.action).toBe('refused'); + }); +}); + +describe('removeFrontmatterSchemaField / renameFrontmatterSchemaField', () => { + const SEED = JSON.stringify( + { type: 'object', required: ['a'], properties: { a: { type: 'string' }, b: {} } }, + null, + 2, + ); + + test('remove drops the field on disk', () => { + write('.ok/schemas/s.json', SEED); + const result = removeFrontmatterSchemaField(projectDir, '.ok/schemas/s.json', 'a'); + expect(result.action).toBe('written'); + const onDisk = JSON.parse(readFileSync(join(projectDir, '.ok/schemas/s.json'), 'utf-8')); + expect(onDisk.properties).toEqual({ b: {} }); + expect('required' in onDisk).toBe(false); + }); + + test('rename carries the property and required membership', () => { + write('.ok/schemas/s.json', SEED); + const result = renameFrontmatterSchemaField(projectDir, '.ok/schemas/s.json', 'a', 'z'); + expect(result.action).toBe('written'); + const onDisk = JSON.parse(readFileSync(join(projectDir, '.ok/schemas/s.json'), 'utf-8')); + expect(Object.keys(onDisk.properties)).toEqual(['z', 'b']); + expect(onDisk.required).toEqual(['z']); + }); + + test('a missing file is refused (no create-on-first-edit for these ops)', () => { + expect(removeFrontmatterSchemaField(projectDir, 'gone.json', 'a').action).toBe('refused'); + expect(renameFrontmatterSchemaField(projectDir, 'gone.json', 'a', 'b').action).toBe('refused'); + }); + + test('rename onto an existing field is refused, file untouched', () => { + const abs = write('.ok/schemas/s.json', SEED); + const result = renameFrontmatterSchemaField(projectDir, '.ok/schemas/s.json', 'a', 'b'); + expect(result.action).toBe('refused'); + expect(readFileSync(abs, 'utf-8')).toBe(SEED); + }); + + test('an escaping path is refused', () => { + const rel = `../${outsideDir.split('/').pop()}/evil.json`; + expect(removeFrontmatterSchemaField(projectDir, rel, 'a').action).toBe('refused'); + }); +}); + +describe('deleteFrontmatterSchemaFile', () => { + test('deletes an existing tool-managed schema file', () => { + const abs = write('.ok/schemas/doc.schema.json', '{"type":"object"}'); + const result = deleteFrontmatterSchemaFile(projectDir, '.ok/schemas/doc.schema.json'); + expect(result.action).toBe('deleted'); + expect(existsSync(abs)).toBe(false); + }); + + test('is idempotent — an already-absent file still reports deleted', () => { + const result = deleteFrontmatterSchemaFile(projectDir, '.ok/schemas/gone.schema.json'); + expect(result.action).toBe('deleted'); + }); + + test('deletes a *.schema.json anywhere in the project', () => { + const abs = write('docs/thing.schema.json', '{"type":"object"}'); + const result = deleteFrontmatterSchemaFile(projectDir, 'docs/thing.schema.json'); + expect(result.action).toBe('deleted'); + expect(existsSync(abs)).toBe(false); + }); + + test('refuses unconventionally named json outside .ok/schemas/, file left on disk', () => { + const abs = write('schemas/user-owned.json', '{"type":"object"}'); + const result = deleteFrontmatterSchemaFile(projectDir, 'schemas/user-owned.json'); + expect(result.action).toBe('refused'); + expect(existsSync(abs)).toBe(true); + }); + + test('refuses a nested non-convention path under .ok/schemas/', () => { + const abs = write('.ok/schemas/sub/nested.json', '{"type":"object"}'); + const result = deleteFrontmatterSchemaFile(projectDir, '.ok/schemas/sub/nested.json'); + expect(result.action).toBe('refused'); + expect(existsSync(abs)).toBe(true); + }); + + test('refuses traversal dressed as a schemas path', () => { + const result = deleteFrontmatterSchemaFile(projectDir, '.ok/schemas/../../evil.json'); + expect(result.action).toBe('refused'); + }); + + test('unlinks a symlink ENTRY without touching its target', () => { + const target = write('kept.json', '{"type":"object"}'); + mkdirSync(join(projectDir, '.ok/schemas'), { recursive: true }); + const link = join(projectDir, '.ok/schemas/linked.json'); + symlinkSync(target, link); + const result = deleteFrontmatterSchemaFile(projectDir, '.ok/schemas/linked.json'); + expect(result.action).toBe('deleted'); + expect(existsSync(link)).toBe(false); + expect(existsSync(target)).toBe(true); + }); + + test('refuses when .ok/schemas itself is a symlink escaping the project', () => { + mkdirSync(join(projectDir, '.ok'), { recursive: true }); + writeFileSync(join(outsideDir, 'victim.json'), '{"type":"object"}'); + symlinkSync(outsideDir, join(projectDir, '.ok/schemas')); + const result = deleteFrontmatterSchemaFile(projectDir, '.ok/schemas/victim.json'); + expect(result.action).toBe('refused'); + expect(existsSync(join(outsideDir, 'victim.json'))).toBe(true); + }); + + test('a non-directory path component still reports deleted (provably absent)', () => { + // `.ok` is a regular file, so resolving `.ok/schemas` fails with ENOTDIR. + // The entry cannot exist, so `deleted` is the honest answer — this pins the + // narrow branch against a naive "only ENOENT counts" rewrite. + writeFileSync(join(projectDir, '.ok'), 'not a directory'); + const result = deleteFrontmatterSchemaFile(projectDir, '.ok/schemas/x.schema.json'); + expect(result.action).toBe('deleted'); + }); +}); + +describe('write-path symlink containment', () => { + test('refuses editing a schema symlinked outside the project, target untouched', () => { + const victim = join(outsideDir, 'victim.json'); + const bytes = `${JSON.stringify({ type: 'object' }, null, 2)}\n`; + writeFileSync(victim, bytes); + symlinkSync(victim, join(projectDir, 'linked.schema.json')); + const result = writeFrontmatterSchemaField(projectDir, 'linked.schema.json', 'status', { + type: 'string', + }); + expect(result.action).toBe('refused'); + expect(readFileSync(victim, 'utf-8')).toBe(bytes); + }); + + test('refuses remove/rename on a schema symlinked outside the project', () => { + const victim = join(outsideDir, 'victim.json'); + const bytes = `${JSON.stringify({ type: 'object', required: ['a'], properties: { a: {} } }, null, 2)}\n`; + writeFileSync(victim, bytes); + symlinkSync(victim, join(projectDir, 'linked.schema.json')); + expect(removeFrontmatterSchemaField(projectDir, 'linked.schema.json', 'a').action).toBe( + 'refused', + ); + expect(renameFrontmatterSchemaField(projectDir, 'linked.schema.json', 'a', 'z').action).toBe( + 'refused', + ); + expect(readFileSync(victim, 'utf-8')).toBe(bytes); + }); + + test('refuses creating under a .ok/schemas that symlinks outside the project', () => { + mkdirSync(join(projectDir, '.ok'), { recursive: true }); + symlinkSync(outsideDir, join(projectDir, '.ok/schemas')); + const created = createEmptyFrontmatterSchemaFile(projectDir, '.ok/schemas/new.schema.json'); + expect(created.action).toBe('refused'); + const edited = writeFrontmatterSchemaField(projectDir, '.ok/schemas/new.schema.json', 'x', { + type: 'string', + }); + expect(edited.action).toBe('refused'); + expect(existsSync(join(outsideDir, 'new.schema.json'))).toBe(false); + }); + + test('a symlink resolving INSIDE the project is edited in place', () => { + const target = write('real/s.schema.json', `${JSON.stringify({ type: 'object' }, null, 2)}\n`); + mkdirSync(join(projectDir, '.ok/schemas'), { recursive: true }); + symlinkSync(target, join(projectDir, '.ok/schemas/link.json')); + const result = writeFrontmatterSchemaField(projectDir, '.ok/schemas/link.json', 'status', { + type: 'string', + }); + expect(result.action).toBe('written'); + const parsed = JSON.parse(readFileSync(target, 'utf-8')) as { + properties: { status: { type: string } }; + }; + expect(parsed.properties.status.type).toBe('string'); + }); +}); diff --git a/packages/server/src/lint/frontmatter-schema-write.ts b/packages/server/src/lint/frontmatter-schema-write.ts new file mode 100644 index 000000000..5b9611b73 --- /dev/null +++ b/packages/server/src/lint/frontmatter-schema-write.ts @@ -0,0 +1,220 @@ +/** + * Frontmatter schema-file WRITE surface: merge one field's constraints into a + * schema file via the core `applyFieldConstraint` (non-destructive — advanced + * keywords survive verbatim), creating the file with the draft-07 skeleton on + * first edit. Same safety posture as the markdownlint writer: project-root + * escape guarding (lexical + realpath), atomic tmp+rename through the traced + * fs wrappers, and a typed refusal instead of a throw for policy declines. + * + * Write/create scope is deliberately broader than delete's. A mapping may + * point at any project-relative JSON file (`loadSchemaFile` accepts any path), + * so the editor must be able to write wherever the linter can already read. + * Delete narrows to `isFrontmatterSchemaAsset` because it is destructive and + * irreversible, so it fails closed to the naming convention instead. + */ + +import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; +import { + applyFieldConstraint, + emptyFrontmatterSchemaText, + type FrontmatterFieldConstraint, + FrontmatterSchemaEditError, + isFrontmatterSchemaAsset, + removeSchemaField, + renameSchemaField, + type SchemaParentPathSegment, +} from '@inkeep/open-knowledge-core'; +import { tracedUnlinkSync } from '../fs-traced.ts'; +import { isInside, writeFileAtomic } from './fs-safety.ts'; + +export type WriteFrontmatterSchemaResult = + | { action: 'written' | 'created' | 'deleted'; file: string } + | { action: 'refused'; file: string; reason: string }; + +function refused(file: string, reason: string): { refusal: WriteFrontmatterSchemaResult } { + return { refusal: { action: 'refused', file, reason } }; +} + +/** + * Resolve `file` to its guarded on-disk write target: lexical containment + * first (an escaping path is refused even when it doesn't exist), then + * realpath containment — on the existing file (a symlinked schema is edited + * in place; an escaping link is refused), or, in `create-missing` mode, on + * the freshly-made parent directory (often `.ok/schemas/`). In + * `require-existing` mode a missing file is a refusal and nothing is created + * — the remove/rename/transform operations have nothing to edit. + */ +function resolveSchemaWriteTarget( + projectDir: string, + file: string, + mode: 'create-missing' | 'require-existing', +): + | { target: string; exists: boolean; currentText: string } + | { refusal: WriteFrontmatterSchemaResult } { + const abs = resolve(projectDir, file); + if (!isInside(abs, resolve(projectDir))) { + return refused(file, 'path resolves outside the project'); + } + if (existsSync(abs)) { + let real: string; + try { + real = realpathSync(abs); + } catch (err) { + return refused(file, `cannot read (${err instanceof Error ? err.message : String(err)})`); + } + if (!isInside(real, realpathSync(projectDir))) { + return refused(file, 'path resolves outside the project'); + } + return { target: real, exists: true, currentText: readFileSync(real, 'utf-8') }; + } + if (mode === 'require-existing') { + return refused(file, 'file does not exist'); + } + mkdirSync(dirname(abs), { recursive: true }); + const parentReal = realpathSync(dirname(abs)); + if (!isInside(parentReal, realpathSync(projectDir))) { + return refused(file, 'path resolves outside the project'); + } + return { target: resolve(parentReal, basename(abs)), exists: false, currentText: '' }; +} + +export function writeFrontmatterSchemaField( + projectDir: string, + file: string, + field: string, + constraint: FrontmatterFieldConstraint, + parentPath: readonly SchemaParentPathSegment[] = [], +): WriteFrontmatterSchemaResult { + const resolved = resolveSchemaWriteTarget(projectDir, file, 'create-missing'); + if ('refusal' in resolved) return resolved.refusal; + let nextText: string; + try { + nextText = applyFieldConstraint(resolved.currentText, field, constraint, parentPath); + } catch (err) { + if (err instanceof FrontmatterSchemaEditError) { + return { action: 'refused', file, reason: err.message }; + } + throw err; + } + if (nextText === resolved.currentText) return { action: 'written', file }; + writeFileAtomic(resolved.target, nextText); + return { action: resolved.exists ? 'written' : 'created', file }; +} + +/** + * Scaffold an empty schema file (the draft-07 skeleton) at `file` if it does + * not already exist — the create-empty half of the write surface, so the + * picker's "create new schema" lands a real, valid file before any field is + * added. Idempotent: an existing file is left untouched (never clobbered). + */ +export function createEmptyFrontmatterSchemaFile( + projectDir: string, + file: string, +): WriteFrontmatterSchemaResult { + const resolved = resolveSchemaWriteTarget(projectDir, file, 'create-missing'); + if ('refusal' in resolved) return resolved.refusal; + if (resolved.exists) return { action: 'written', file }; + writeFileAtomic(resolved.target, emptyFrontmatterSchemaText()); + return { action: 'created', file }; +} + +/** Apply a whole-schema transform to an existing schema file, atomically. */ +function transformSchemaFile( + projectDir: string, + file: string, + transform: (currentText: string) => string, +): WriteFrontmatterSchemaResult { + const resolved = resolveSchemaWriteTarget(projectDir, file, 'require-existing'); + if ('refusal' in resolved) return resolved.refusal; + let nextText: string; + try { + nextText = transform(resolved.currentText); + } catch (err) { + if (err instanceof FrontmatterSchemaEditError) { + return { action: 'refused', file, reason: err.message }; + } + throw err; + } + if (nextText !== resolved.currentText) writeFileAtomic(resolved.target, nextText); + return { action: 'written', file }; +} + +/** Remove one field (properties entry + required membership) from a schema file. */ +export function removeFrontmatterSchemaField( + projectDir: string, + file: string, + field: string, + parentPath: readonly SchemaParentPathSegment[] = [], +): WriteFrontmatterSchemaResult { + return transformSchemaFile(projectDir, file, (text) => + removeSchemaField(text, field, parentPath), + ); +} + +/** Rename one field in a schema file, carrying unmodeled keywords + required. */ +export function renameFrontmatterSchemaField( + projectDir: string, + file: string, + field: string, + to: string, + parentPath: readonly SchemaParentPathSegment[] = [], +): WriteFrontmatterSchemaResult { + return transformSchemaFile(projectDir, file, (text) => + renameSchemaField(text, field, to, parentPath), + ); +} + +/** + * Delete a schema file. Deletable scope = the schema-browser's scope + * (`isFrontmatterSchemaAsset`): the ecosystem `*.schema.json` convention + * anywhere in the project plus `.ok/schemas/*.json`; anything else is a + * typed refusal. Unlinks the directory ENTRY (never a symlink's target, so a + * planted link can't reach outside the project), and is idempotent: an + * already-absent file reports `deleted` so a retried delete flow can't fail. + */ +export function deleteFrontmatterSchemaFile( + projectDir: string, + file: string, +): WriteFrontmatterSchemaResult { + if (!isFrontmatterSchemaAsset(file)) { + return { + action: 'refused', + file, + reason: 'only *.schema.json files (or .ok/schemas/*.json) can be deleted', + }; + } + const abs = resolve(projectDir, file); + if (!isInside(abs, resolve(projectDir))) { + return { action: 'refused', file, reason: 'path resolves outside the project' }; + } + // The parent (`.ok/schemas/`) could itself be a symlink pointing outside the + // project — resolve it first so the unlinked entry provably lives inside. + let parentReal: string; + try { + parentReal = realpathSync(dirname(abs)); + } catch (err) { + // ENOENT/ENOTDIR prove the entry cannot exist (missing parent, or a + // non-directory component), so reporting it gone is honest. Anything else + // — EACCES, EPERM, ELOOP, EIO — means we could NOT prove it: the file may + // well still be there, and returning `deleted` would make the GUI drop the + // mapping and silently stop validating those docs. + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return { action: 'deleted', file }; + return { + action: 'refused', + file, + reason: `cannot read (${err instanceof Error ? err.message : String(err)})`, + }; + } + if (!isInside(parentReal, realpathSync(projectDir))) { + return { action: 'refused', file, reason: 'path resolves outside the project' }; + } + try { + tracedUnlinkSync(resolve(parentReal, basename(abs))); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { action: 'deleted', file }; + throw err; + } + return { action: 'deleted', file }; +} diff --git a/packages/server/src/lint/frontmatter-schemas.test.ts b/packages/server/src/lint/frontmatter-schemas.test.ts new file mode 100644 index 000000000..d9ef4d056 --- /dev/null +++ b/packages/server/src/lint/frontmatter-schemas.test.ts @@ -0,0 +1,331 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { DEFAULT_LINTER_CONFIG, type LinterConfig } from '@inkeep/open-knowledge-core'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { + listProjectSchemaFiles, + resolveFrontmatterSchemas, + unmatchedAppliesToProblems, +} from './frontmatter-schemas.ts'; +import { composeFrontmatterSchemasConfig, resolveEffectiveLinterConfig } from './resolve-config.ts'; + +let projectDir: string; +let outsideDir: string; + +beforeEach(() => { + projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'ok-fm-schemas-'))); + outsideDir = realpathSync(mkdtempSync(join(tmpdir(), 'ok-fm-outside-'))); +}); + +afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); +}); + +function write(rel: string, content: string): string { + const abs = join(projectDir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + return abs; +} + +const GOOD_SCHEMA = JSON.stringify({ + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['title'], +}); + +describe('resolveFrontmatterSchemas — loading', () => { + test('loads a good schema with content + canonical key', () => { + const abs = write('.ok/schemas/doc.schema.json', GOOD_SCHEMA); + const { entries, problems } = resolveFrontmatterSchemas(projectDir, [ + { appliesTo: 'docs/**', file: '.ok/schemas/doc.schema.json' }, + ]); + expect(problems).toEqual([]); + expect(entries).toHaveLength(1); + expect(entries[0]?.schema).toMatchObject({ required: ['title'] }); + expect(entries[0]?.key).toBe(realpathSync(abs)); + expect(entries[0]?.appliesTo).toBe('docs/**'); + }); + + test('schemas can live anywhere in the project, not only .ok/schemas/', () => { + write('schemas/custom.json', GOOD_SCHEMA); + const { entries, problems } = resolveFrontmatterSchemas(projectDir, [ + { file: 'schemas/custom.json' }, + ]); + expect(problems).toEqual([]); + expect(entries[0]?.schema).toBeDefined(); + }); + + test('a broken file mapped disabled-then-enabled still reports once', () => { + // The disabled mapping loads (and caches) the outcome first while staying + // silent. Reporting from inside the cache-miss branch would let the later + // ENABLED mapping hit the cache and never report, silently unvalidating + // docs it actively governs — and the suppression would depend on entry + // order, which the config contract says carries no precedence. + write('.ok/schemas/broken.json', '{ not json'); + const { problems } = resolveFrontmatterSchemas(projectDir, [ + { file: '.ok/schemas/broken.json', appliesTo: 'archive/**', enabled: false }, + { file: '.ok/schemas/broken.json', appliesTo: 'docs/**', enabled: true }, + ]); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('.ok/schemas/broken.json'); + }); + + test('a broken file mapped by two enabled mappings is not double-reported', () => { + write('.ok/schemas/broken.json', '{ not json'); + const { problems } = resolveFrontmatterSchemas(projectDir, [ + { file: '.ok/schemas/broken.json', appliesTo: 'docs/**' }, + { file: '.ok/schemas/broken.json', appliesTo: 'specs/**' }, + ]); + expect(problems).toHaveLength(1); + }); + + test('a broken file mapped only by a disabled mapping stays silent', () => { + write('.ok/schemas/broken.json', '{ not json'); + const { problems } = resolveFrontmatterSchemas(projectDir, [ + { file: '.ok/schemas/broken.json', appliesTo: 'archive/**', enabled: false }, + ]); + expect(problems).toEqual([]); + }); + + test('a missing file is a schemaError problem; the entry stays content-less with a key', () => { + const { entries, problems } = resolveFrontmatterSchemas(projectDir, [ + { file: '.ok/schemas/nope.json' }, + ]); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('.ok/schemas/nope.json'); + expect(problems[0]).toContain('cannot read'); + expect(entries[0]?.schema).toBeUndefined(); + expect(entries[0]?.key).toBe('.ok/schemas/nope.json'); + }); + + test('malformed JSON is a problem, never a throw', () => { + write('bad.json', '{ not json'); + const { entries, problems } = resolveFrontmatterSchemas(projectDir, [{ file: 'bad.json' }]); + expect(problems[0]).toContain('malformed JSON'); + expect(entries[0]?.schema).toBeUndefined(); + }); + + test('non-object JSON is a problem', () => { + write('array.json', '["not", "an", "object"]'); + const { problems } = resolveFrontmatterSchemas(projectDir, [{ file: 'array.json' }]); + expect(problems[0]).toContain('not a JSON object'); + }); + + test('another dialect is skipped with a problem', () => { + write( + '2020.json', + JSON.stringify({ $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object' }), + ); + const { entries, problems } = resolveFrontmatterSchemas(projectDir, [{ file: '2020.json' }]); + expect(problems[0]).toContain('unsupported dialect'); + expect(entries[0]?.schema).toBeUndefined(); + }); + + test('a schema ajv refuses is a problem', () => { + write('refused.json', JSON.stringify({ type: 'object', properties: { x: { pattern: '[' } } })); + const { problems } = resolveFrontmatterSchemas(projectDir, [{ file: 'refused.json' }]); + expect(problems[0]).toContain('does not compile'); + }); + + test('an invalid appliesTo glob surfaces as a problem while the schema still loads', () => { + write('ok.json', GOOD_SCHEMA); + const { entries, problems } = resolveFrontmatterSchemas(projectDir, [ + { appliesTo: ['docs/**', 'docs/['], file: 'ok.json' }, + ]); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('invalid appliesTo glob'); + expect(problems[0]).toContain('docs/['); + expect(entries[0]?.schema).toBeDefined(); + }); + + test('a suspicious glob surfaces an advisory problem; disabled mappings stay silent', () => { + write('ok.json', GOOD_SCHEMA); + const { problems } = resolveFrontmatterSchemas(projectDir, [ + { appliesTo: ['docs/', 'notes/**/*.md'], file: 'ok.json' }, + ]); + expect(problems).toHaveLength(2); + expect(problems[0]).toContain('suspicious appliesTo glob'); + expect(problems[0]).toContain('trailing slash'); + expect(problems[1]).toContain('extension'); + + const silent = resolveFrontmatterSchemas(projectDir, [ + { appliesTo: ['docs/'], file: 'ok.json', enabled: false }, + ]); + expect(silent.problems).toEqual([]); + }); +}); + +describe('resolveFrontmatterSchemas — escape guards', () => { + test('a .. path is refused lexically, even when the target exists', () => { + writeFileSync(join(outsideDir, 'evil.json'), GOOD_SCHEMA); + const rel = `../${outsideDir.split('/').pop()}/evil.json`; + const { entries, problems } = resolveFrontmatterSchemas(projectDir, [{ file: rel }]); + expect(problems[0]).toContain('outside the project'); + expect(entries[0]?.schema).toBeUndefined(); + }); + + test('a symlink pointing outside the project is refused', () => { + writeFileSync(join(outsideDir, 'evil.json'), GOOD_SCHEMA); + symlinkSync(join(outsideDir, 'evil.json'), join(projectDir, 'link.json')); + const { entries, problems } = resolveFrontmatterSchemas(projectDir, [{ file: 'link.json' }]); + expect(problems[0]).toContain('outside the project'); + expect(entries[0]?.schema).toBeUndefined(); + }); + + test('a symlink resolving inside the project is fine (realpath identity)', () => { + write('real/doc.schema.json', GOOD_SCHEMA); + symlinkSync(join(projectDir, 'real/doc.schema.json'), join(projectDir, 'alias.json')); + const { entries, problems } = resolveFrontmatterSchemas(projectDir, [{ file: 'alias.json' }]); + expect(problems).toEqual([]); + expect(entries[0]?.key).toBe(realpathSync(join(projectDir, 'real/doc.schema.json'))); + }); + + test('an unreadable file is a cannot-read problem', () => { + const abs = write('locked.json', GOOD_SCHEMA); + chmodSync(abs, 0o000); + const { problems } = resolveFrontmatterSchemas(projectDir, [{ file: 'locked.json' }]); + expect(problems[0]).toContain('cannot read'); + chmodSync(abs, 0o644); + }); +}); + +describe('resolveFrontmatterSchemas — dedup identity', () => { + test('./-spelled duplicates of one file share a canonical key and read once', () => { + const abs = write('.ok/schemas/doc.schema.json', GOOD_SCHEMA); + const { entries, problems } = resolveFrontmatterSchemas(projectDir, [ + { appliesTo: 'docs/**', file: '.ok/schemas/doc.schema.json' }, + { appliesTo: 'specs/**', file: './.ok/schemas/doc.schema.json' }, + ]); + expect(problems).toEqual([]); + expect(entries[0]?.key).toBe(realpathSync(abs)); + expect(entries[1]?.key).toBe(realpathSync(abs)); + }); + + test('distinct files with identical content keep distinct keys', () => { + write('a.json', GOOD_SCHEMA); + write('b.json', GOOD_SCHEMA); + const { entries } = resolveFrontmatterSchemas(projectDir, [ + { file: 'a.json' }, + { file: 'b.json' }, + ]); + expect(entries[0]?.key).not.toBe(entries[1]?.key); + }); +}); + +describe('composeFrontmatterSchemasConfig + resolveEffectiveLinterConfig', () => { + function baseWith(schemas: { appliesTo?: string | string[]; file: string }[]): LinterConfig { + return { + ...DEFAULT_LINTER_CONFIG, + plugins: { + ...DEFAULT_LINTER_CONFIG.plugins, + frontmatter: { enabled: true, schemas }, + }, + }; + } + + test('injects loaded content into the effective slice', () => { + write('.ok/schemas/doc.schema.json', GOOD_SCHEMA); + const problems: string[] = []; + const effective = composeFrontmatterSchemasConfig( + projectDir, + baseWith([{ appliesTo: 'docs/**', file: '.ok/schemas/doc.schema.json' }]), + (p) => problems.push(p), + ); + expect(problems).toEqual([]); + expect(effective.plugins.frontmatter.schemas[0]?.schema).toBeDefined(); + }); + + test('disabled slice or zero mappings skip the disk entirely', () => { + const disabled = composeFrontmatterSchemasConfig(projectDir, DEFAULT_LINTER_CONFIG); + expect(disabled).toBe(DEFAULT_LINTER_CONFIG); + const empty = composeFrontmatterSchemasConfig(projectDir, baseWith([])); + expect(empty.plugins.frontmatter.schemas).toEqual([]); + }); + + test('already-resolved entries pass through without re-reading (audit precompose)', () => { + write('.ok/schemas/doc.schema.json', GOOD_SCHEMA); + const once = composeFrontmatterSchemasConfig( + projectDir, + baseWith([{ file: '.ok/schemas/doc.schema.json' }]), + ); + rmSync(join(projectDir, '.ok/schemas/doc.schema.json')); + const twice = composeFrontmatterSchemasConfig(projectDir, once); + expect(twice).toBe(once); + }); + + test('projectDir resolution holds when contentDir is a subfolder (content.dir)', () => { + write('.ok/schemas/doc.schema.json', GOOD_SCHEMA); + const contentDir = join(projectDir, 'kb'); + mkdirSync(contentDir, { recursive: true }); + const problems: string[] = []; + const effective = resolveEffectiveLinterConfig( + contentDir, + baseWith([{ file: '.ok/schemas/doc.schema.json' }]), + { projectDir, onProblem: (p) => problems.push(p) }, + ); + expect(problems).toEqual([]); + expect(effective.plugins.frontmatter.schemas[0]?.schema).toBeDefined(); + }); +}); + +describe('listProjectSchemaFiles', () => { + test('enumerates .ok/schemas/*.json as project-relative paths, sorted', () => { + write('.ok/schemas/zebra.json', '{}'); + write('.ok/schemas/alpha.schema.json', '{}'); + write('.ok/schemas/notes.JSON', '{}'); // case-insensitive extension match + write('.ok/schemas/readme.md', '# not a schema'); // non-json ignored + mkdirSync(join(projectDir, '.ok/schemas/nested'), { recursive: true }); // dirs ignored + const { schemas, truncated } = listProjectSchemaFiles(projectDir); + expect(schemas).toEqual([ + '.ok/schemas/alpha.schema.json', + '.ok/schemas/notes.JSON', + '.ok/schemas/zebra.json', + ]); + expect(truncated).toBe(false); + }); + + test('missing .ok/schemas/ is an empty list, not an error', () => { + expect(listProjectSchemaFiles(projectDir)).toEqual({ schemas: [], truncated: false }); + }); +}); + +describe('unmatchedAppliesToProblems', () => { + const docs = ['docs/guide.md', 'notes/todo.md']; + + test('reports enabled mappings whose authored globs match zero docs', () => { + const problems = unmatchedAppliesToProblems( + [{ file: '.ok/schemas/doc.schema.json', enabled: true, appliesTo: ['docs/**', 'specs/**'] }], + docs, + ); + expect(problems).toEqual([ + 'unmatched appliesTo glob "specs/**" — matches no docs in this project (frontmatter mapping for .ok/schemas/doc.schema.json)', + ]); + }); + + test('disabled mappings stay silent', () => { + const problems = unmatchedAppliesToProblems( + [{ file: '.ok/schemas/doc.schema.json', enabled: false, appliesTo: ['specs/**'] }], + docs, + ); + expect(problems).toEqual([]); + }); + + test('mappings without appliesTo (implicit every doc) report nothing', () => { + const problems = unmatchedAppliesToProblems( + [{ file: '.ok/schemas/doc.schema.json', enabled: true }], + docs, + ); + expect(problems).toEqual([]); + }); +}); diff --git a/packages/server/src/lint/frontmatter-schemas.ts b/packages/server/src/lint/frontmatter-schemas.ts new file mode 100644 index 000000000..889e1174b --- /dev/null +++ b/packages/server/src/lint/frontmatter-schemas.ts @@ -0,0 +1,221 @@ +/** + * Frontmatter schema-file loading. Each `contentRules.frontmatter.schemas` + * mapping names a JSON Schema file by a path relative to the PROJECT ROOT + * (the folder containing `.ok/` — not `contentDir`, so the `.ok/schemas/` + * default keeps working when `content.dir` scopes docs to a subfolder). This + * loads each file once, validates the draft-07 dialect pin, and injects the + * parsed content + a canonical dedup key into the resolved entries the core + * plugin (and the browser, via the effective config) validates with. + * + * Every load failure — escape, missing/unreadable file, malformed JSON, + * unsupported dialect, ajv-refused schema — records a `problems[]` entry for + * the config channel and leaves that entry content-less; it never becomes a + * per-doc diagnostic. Escape guarding mirrors the markdownlint-discovery + * policy: lexical containment first (an escaping target is refused even when + * it doesn't exist), realpath containment second (symlinks pointing outside), + * and the bytes are read via the validated realpath. + */ + +import { readdirSync, readFileSync, realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + compileAppliesTo, + type FrontmatterSchemaMapping, + findZeroMatchAppliesToPatterns, + frontmatterSchemaCompileError, + isSupportedSchemaDialect, + type ResolvedFrontmatterSchemaEntry, +} from '@inkeep/open-knowledge-core'; +import { getLogger } from '../logger.ts'; +import { isInside } from './fs-safety.ts'; + +export interface ResolvedFrontmatterSchemas { + entries: ResolvedFrontmatterSchemaEntry[]; + problems: string[]; +} + +/** Upper bound on schema files enumerated for the mapping picker. */ +export const SCHEMA_LIST_CAP = 500; + +/** + * Enumerate the project's `.ok/schemas/*.json` files (flat, top-level only) as + * project-root-relative paths, for the mapping file-picker. A missing or + * unreadable `.ok/schemas/` is an empty list (not an error). Bounded by + * `SCHEMA_LIST_CAP` so a pathological directory can't produce an unbounded + * response; `truncated` signals the list was cut. This is a directory scan for + * discovery only — unlike `resolveFrontmatterSchemas`, it neither reads nor + * validates schema content (the picker just needs paths). + */ +export function listProjectSchemaFiles(projectRoot: string): { + schemas: string[]; + truncated: boolean; +} { + const dir = join(projectRoot, '.ok', 'schemas'); + let names: string[]; + try { + names = readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json')) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); + } catch (err) { + // A missing dir is the ordinary first-run case and stays quiet. Anything + // else (EACCES under macOS TCC, a permissions change) renders an empty + // picker that looks identical to first-run, so log it rather than let the + // operator stare at a list that silently cannot populate. + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + getLogger('frontmatter-schemas').warn( + { err, code, dir }, + 'could not enumerate .ok/schemas; the schema picker will show an empty list', + ); + } + return { schemas: [], truncated: false }; + } + const truncated = names.length > SCHEMA_LIST_CAP; + const schemas = names.slice(0, SCHEMA_LIST_CAP).map((name) => `.ok/schemas/${name}`); + return { schemas, truncated }; +} + +function errorDetail(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Config-channel problems for enabled mappings whose authored globs match zero + * of the project's docs — a typo'd or stale pattern silently scopes the schema + * to nothing, which no per-doc diagnostic can ever surface. Doc-walk cost is + * the caller's call: only doc-independent surfaces (the root lint-config + * response, the audit) pay it, never the per-doc lint path. The problem-string + * prefixes are part of the compose contract with the Settings frontmatter + * panel — keep in sync on either-side change. + */ +export function unmatchedAppliesToProblems( + mappings: readonly FrontmatterSchemaMapping[], + docPaths: readonly string[], +): string[] { + const problems: string[] = []; + for (const mapping of mappings) { + if (mapping.enabled === false) continue; + for (const pattern of findZeroMatchAppliesToPatterns(mapping.appliesTo, docPaths)) { + problems.push( + `unmatched appliesTo glob ${JSON.stringify(pattern)} — matches no docs in this project (frontmatter mapping for ${mapping.file})`, + ); + } + } + return problems; +} + +type LoadOutcome = { schema: Record; key: string } | { problem: string }; + +function loadSchemaFile(projectDir: string, file: string): LoadOutcome { + const abs = resolve(projectDir, file); + if (!isInside(abs, resolve(projectDir))) { + return { problem: `frontmatter schema ${file}: resolves outside the project` }; + } + let real: string; + try { + real = realpathSync(abs); + } catch (err) { + return { problem: `frontmatter schema ${file}: cannot read (${errorDetail(err)})` }; + } + let projectReal: string; + try { + projectReal = realpathSync(projectDir); + } catch (err) { + return { problem: `frontmatter schema ${file}: cannot read (${errorDetail(err)})` }; + } + if (!isInside(real, projectReal)) { + return { problem: `frontmatter schema ${file}: resolves outside the project` }; + } + let raw: string; + try { + // Read via the validated realpath so the parsed bytes come from the same + // inode the containment check admitted. + raw = readFileSync(real, 'utf-8'); + } catch (err) { + return { problem: `frontmatter schema ${file}: cannot read (${errorDetail(err)})` }; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + return { problem: `frontmatter schema ${file}: malformed JSON (${errorDetail(err)})` }; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { problem: `frontmatter schema ${file}: not a JSON object` }; + } + const schema = parsed as Record; + if (!isSupportedSchemaDialect(schema)) { + return { + problem: `frontmatter schema ${file}: unsupported dialect ${JSON.stringify(schema.$schema)} (draft-07 required)`, + }; + } + const compileError = frontmatterSchemaCompileError(schema); + if (compileError !== null) { + return { problem: `frontmatter schema ${file}: does not compile (${compileError})` }; + } + return { schema, key: real }; +} + +/** + * Load every mapping's schema file (each distinct file read once), returning + * resolved entries + config-channel problems. Every returned entry carries a + * `key` — the canonical realpath for loaded files, the authored path for + * failed ones — which downstream code also uses as the "already resolved" + * marker (see `resolve-config.ts`). + */ +export function resolveFrontmatterSchemas( + projectDir: string, + mappings: readonly FrontmatterSchemaMapping[], +): ResolvedFrontmatterSchemas { + const problems: string[] = []; + const outcomeByFile = new Map(); + const reportedFiles = new Set(); + const entries: ResolvedFrontmatterSchemaEntry[] = []; + + for (const mapping of mappings) { + // Disabled mappings still resolve (the schema editor reads their content) + // but stay silent on the problems channel — a toggled-off broken schema + // shouldn't nag. + const silent = mapping.enabled === false; + const compiled = compileAppliesTo(mapping.appliesTo); + for (const { pattern, detail } of compiled.invalidPatterns) { + if (silent) continue; + problems.push( + `invalid appliesTo glob ${JSON.stringify(pattern)} — ${detail} (frontmatter mapping for ${mapping.file})`, + ); + } + for (const { pattern, reason } of compiled.suspiciousPatterns) { + if (silent) continue; + const detail = + reason === 'trailing-slash' + ? 'a trailing slash can never match (doc paths have no trailing slash)' + : reason === 'leading-slash' + ? 'a leading slash can never match (doc paths are relative)' + : 'doc paths are extension-less; drop the file extension'; + problems.push( + `suspicious appliesTo glob ${JSON.stringify(pattern)} — ${detail} (frontmatter mapping for ${mapping.file})`, + ); + } + let outcome = outcomeByFile.get(mapping.file); + if (outcome === undefined) { + outcome = loadSchemaFile(projectDir, mapping.file); + outcomeByFile.set(mapping.file, outcome); + } + // Reported outside the cache-miss branch: a disabled mapping loads the file + // first and stays silent, so gating the report on the miss would let a + // LATER enabled mapping for the same file hit the cache and never report — + // silently unvalidating docs that are actively governed. `reportedFiles` + // keeps that from double-reporting when two enabled mappings share a file. + if ('problem' in outcome && !silent && !reportedFiles.has(mapping.file)) { + reportedFiles.add(mapping.file); + problems.push(outcome.problem); + } + entries.push( + 'problem' in outcome + ? { ...mapping, key: mapping.file } + : { ...mapping, key: outcome.key, schema: outcome.schema }, + ); + } + return { entries, problems }; +} diff --git a/packages/server/src/lint/fs-safety.ts b/packages/server/src/lint/fs-safety.ts new file mode 100644 index 000000000..6e57c41fb --- /dev/null +++ b/packages/server/src/lint/fs-safety.ts @@ -0,0 +1,33 @@ +/** + * Shared filesystem safety primitives for the lint read/write surfaces: + * lexical path containment (callers pair it with realpath re-checks for + * symlink escapes) and the traced atomic tmp+rename write. + */ + +import { isAbsolute, relative } from 'node:path'; +import { tracedRenameSync, tracedUnlinkSync, tracedWriteFileSync } from '../fs-traced.ts'; + +/** Whether `path` sits at or under `root`, lexically. */ +export function isInside(path: string, root: string): boolean { + const rel = relative(root, path); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} + +/** Atomic write (tmp + rename), traced; never leaves the tmp file behind. */ +export function writeFileAtomic(file: string, content: string): void { + const tmp = `${file}.tmp.${process.pid}.${Date.now()}`; + try { + tracedWriteFileSync(tmp, content, 'utf-8'); + tracedRenameSync(tmp, file); + } catch (err) { + // The tmp file sits in user-visible content space — never leave an + // orphan behind on a failed write/rename (same pattern as + // config-persistence.ts). + try { + tracedUnlinkSync(tmp); + } catch { + // tmp may not exist if the write itself failed. + } + throw err; + } +} diff --git a/packages/server/src/lint/markdownlint-discovery.ts b/packages/server/src/lint/markdownlint-discovery.ts index a4ccc16eb..f9054b611 100644 --- a/packages/server/src/lint/markdownlint-discovery.ts +++ b/packages/server/src/lint/markdownlint-discovery.ts @@ -15,6 +15,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import type { MarkdownlintRuleSetting } from '@inkeep/open-knowledge-core'; import { type ParseError, parse as parseJsonc, printParseErrorCode } from 'jsonc-parser'; import { parse as parseYaml } from 'yaml'; +import { isInside } from './fs-safety.ts'; /** markdownlint's native config filenames, in precedence order (first found wins). */ const MARKDOWNLINT_CANDIDATE_FILES = [ @@ -130,11 +131,6 @@ export function readOwnNativeRules( /** Max `extends` chain depth — cycles are caught separately; this bounds pathology. */ const MAX_EXTENDS_DEPTH = 10; -function isInside(path: string, root: string): boolean { - const rel = relative(root, path); - return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); -} - /** * Load one native config file and flatten its `extends` chain (child keys win * over the extended base, shallow — markdownlint's own merge). Guards: diff --git a/packages/server/src/lint/markdownlint-write.ts b/packages/server/src/lint/markdownlint-write.ts index cd121c839..869b41e28 100644 --- a/packages/server/src/lint/markdownlint-write.ts +++ b/packages/server/src/lint/markdownlint-write.ts @@ -27,7 +27,8 @@ import { } from '@inkeep/open-knowledge-core'; import { applyEdits, modify, parse as parseJsonc, stripComments } from 'jsonc-parser'; import { stringify as stringifyYaml } from 'yaml'; -import { tracedRenameSync, tracedUnlinkSync, tracedWriteFileSync } from '../fs-traced.ts'; +import { tracedUnlinkSync } from '../fs-traced.ts'; +import { writeFileAtomic } from './fs-safety.ts'; import { DEFAULT_MARKDOWNLINT_FILENAME, findNativeMarkdownlintFile, @@ -88,25 +89,6 @@ function applyJsoncRuleChange( return applyEdits(text, modify(text, [key], value, { formattingOptions: JSONC_FORMATTING })); } -/** Atomic write (tmp + rename), traced; never leaves the tmp file behind. */ -function writeFileAtomic(file: string, content: string): void { - const tmp = `${file}.tmp.${process.pid}.${Date.now()}`; - try { - tracedWriteFileSync(tmp, content, 'utf-8'); - tracedRenameSync(tmp, file); - } catch (err) { - // The tmp file sits in user-visible content space — never leave an - // orphan behind on a failed write/rename (same pattern as - // config-persistence.ts). - try { - tracedUnlinkSync(tmp); - } catch { - // tmp may not exist if the write itself failed. - } - throw err; - } -} - /** * Merge a single rule change into the project's native markdownlint file. * `value === null` deletes the rule (reverting it to OK's tuned default); a diff --git a/packages/server/src/lint/resolve-config.ts b/packages/server/src/lint/resolve-config.ts index f203a6943..44c967d15 100644 --- a/packages/server/src/lint/resolve-config.ts +++ b/packages/server/src/lint/resolve-config.ts @@ -11,6 +11,7 @@ import { dirname, join } from 'node:path'; import type { LinterConfig, MarkdownlintRuleSetting } from '@inkeep/open-knowledge-core'; +import { resolveFrontmatterSchemas } from './frontmatter-schemas.ts'; import { type DiscoveredMarkdownlintConfig, resolveNativeMarkdownlintConfig, @@ -23,6 +24,13 @@ export interface ResolveLinterConfigOptions { * docName works too). Omitted → root-level resolution. */ docName?: string; + /** + * The project root (the folder containing `.ok/`) — frontmatter schema + * `file` paths resolve against it, NOT `contentDir`, so mappings keep + * working when `content.dir` scopes docs to a subfolder. Omitted → + * `contentDir` (the two coincide unless `content.dir` is set). + */ + projectDir?: string; /** Receives each resolution problem, tagged with the governing file. */ onProblem?: (problem: string) => void; } @@ -62,13 +70,41 @@ export function composeEffectiveLinterConfig( }; } +/** + * Inject loaded frontmatter schemas into the effective config. Doc-independent + * (the mapping is project-wide; per-doc `appliesTo` filtering happens in the + * plugin), so callers that lint many docs — the audit — pre-compose ONCE and + * the per-doc pass skips the disk reads: entries that already carry a `key` + * are treated as resolved. + */ +export function composeFrontmatterSchemasConfig( + projectDir: string, + config: LinterConfig, + onProblem?: (problem: string) => void, +): LinterConfig { + const slice = config.plugins.frontmatter; + if (!slice.enabled || slice.schemas.length === 0) return config; + const alreadyResolved = slice.schemas.every((entry) => entry.key !== undefined); + if (alreadyResolved) return config; + const { entries, problems } = resolveFrontmatterSchemas(projectDir, slice.schemas); + if (onProblem) for (const problem of problems) onProblem(problem); + return { + ...config, + plugins: { + ...config.plugins, + frontmatter: { ...slice, schemas: entries }, + }, + }; +} + export function resolveEffectiveLinterConfig( contentDir: string, base: LinterConfig, opts: ResolveLinterConfigOptions = {}, ): LinterConfig { - return composeEffectiveLinterConfig( + const withNative = composeEffectiveLinterConfig( base, resolveNativeConfigForDoc(contentDir, opts.docName, opts.onProblem), ); + return composeFrontmatterSchemasConfig(opts.projectDir ?? contentDir, withNative, opts.onProblem); } diff --git a/packages/server/src/lint/validation-audit.test.ts b/packages/server/src/lint/validation-audit.test.ts index 19729da52..2e0d44f78 100644 --- a/packages/server/src/lint/validation-audit.test.ts +++ b/packages/server/src/lint/validation-audit.test.ts @@ -28,7 +28,10 @@ let admitted: Set; const lintOn: LinterConfig = { ...DEFAULT_LINTER_CONFIG, - plugins: { markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true } }, + plugins: { + ...DEFAULT_LINTER_CONFIG.plugins, + markdownlint: { ...DEFAULT_LINTER_CONFIG.plugins.markdownlint, enabled: true }, + }, }; // MD010 (hard tabs) is on by default; a doc with a tab produces a diagnostic. diff --git a/packages/server/src/mcp/tools/exec.test.ts b/packages/server/src/mcp/tools/exec.test.ts index f380c8edd..5a8f1aa4a 100644 --- a/packages/server/src/mcp/tools/exec.test.ts +++ b/packages/server/src/mcp/tools/exec.test.ts @@ -1024,6 +1024,67 @@ describe('formatFileEntry rendering', () => { }); }); +describe('exec — frontmatter schemas_applicable read-time advertisement', () => { + // The composition this pins: config.contentRules.frontmatter → exec.ts's + // enrichSchemaDeps wiring → enrichment's appliesTo resolver → the schema + // path surfaced on the `cat`/`ls` enriched entry an agent actually reads. + // (The resolver itself is unit-tested in content/enrichment.test.ts; this is + // the wiring root, which that test cannot reach.) + const SCHEMA_CONFIG: Config = ConfigSchema.parse({ + contentRules: { + frontmatter: { + enabled: true, + schemas: [{ appliesTo: 'content/**', file: '.ok/schemas/doc.schema.json' }], + }, + }, + }); + + test('cat advertises the governing schema for a matching doc', async () => { + const project = await bootstrap(); + const contentDir = resolve(project, 'content'); + mkdirSync(contentDir, { recursive: true }); + writeFileSync(resolve(contentDir, 'guide.md'), '---\ntitle: Guide\n---\nBody\n'); + + const result = (await buildExecResult( + { command: 'cat content/guide.md' }, + { resolveCwd: async () => project, serverUrl: undefined, config: SCHEMA_CONFIG }, + )) as ExecResult; + + const files = fileEntries(structured(result)); + expect(files[0]?.schemas_applicable).toEqual(['.ok/schemas/doc.schema.json']); + }); + + test('a doc outside the appliesTo scope carries no schemas_applicable', async () => { + const project = await bootstrap(); + const outside = resolve(project, 'other'); + mkdirSync(outside, { recursive: true }); + writeFileSync(resolve(outside, 'note.md'), '---\ntitle: Note\n---\nBody\n'); + + const result = (await buildExecResult( + { command: 'cat other/note.md' }, + { resolveCwd: async () => project, serverUrl: undefined, config: SCHEMA_CONFIG }, + )) as ExecResult; + + const files = fileEntries(structured(result)); + expect(files[0]?.schemas_applicable).toBeUndefined(); + }); + + test('plugin disabled → no advertisement even for a matching path', async () => { + const project = await bootstrap(); + const contentDir = resolve(project, 'content'); + mkdirSync(contentDir, { recursive: true }); + writeFileSync(resolve(contentDir, 'guide.md'), '---\ntitle: Guide\n---\nBody\n'); + + const result = (await buildExecResult( + { command: 'cat content/guide.md' }, + { resolveCwd: async () => project, serverUrl: undefined, config: DEFAULT_CONFIG }, + )) as ExecResult; + + const files = fileEntries(structured(result)); + expect(files[0]?.schemas_applicable).toBeUndefined(); + }); +}); + describe('exec — mutation-sweep scoping and honesty', () => { const OVER_CAP = 1005; diff --git a/packages/server/src/mcp/tools/exec.ts b/packages/server/src/mcp/tools/exec.ts index 861f5417c..8150f7932 100644 --- a/packages/server/src/mcp/tools/exec.ts +++ b/packages/server/src/mcp/tools/exec.ts @@ -295,6 +295,9 @@ function formatDirectoryEntry(d: DirectoryMeta): string { .join(', '); parts.push(`templates: ${tpl}`); } + if (d.schemas_applicable && d.schemas_applicable.length > 0) { + parts.push(`schemas: ${d.schemas_applicable.join(', ')}`); + } const counts: string[] = []; counts.push( d.recursiveMdCount > d.directMdCount @@ -325,6 +328,9 @@ export function formatFileEntry(m: EnrichedMeta): string { const fmType = typeof m.frontmatter.type === 'string' ? m.frontmatter.type : null; if (fmStatus) parts.push(`status: ${fmStatus}`); if (fmType) parts.push(`type: ${fmType}`); + if (m.schemas_applicable && m.schemas_applicable.length > 0) { + parts.push(`schemas: ${m.schemas_applicable.join(', ')}`); + } if (m.backlinkCount !== null) { // Source paths are populated on single-cat rich only (null on multi-path); render // them so the agent can follow a backlink without a second links() call. @@ -515,6 +521,18 @@ export async function buildExecResult( // differ only when the caller targets a subdirectory of the project. const { cwd, executionCwd, config, url: resolvedServerUrl } = context; + // Read-time schema advertisement: when the frontmatter plugin is enabled, + // enrichment resolves which schema files govern each listed doc/folder + // server-side (the agent never evaluates an appliesTo glob). + const frontmatterSlice = config.contentRules.frontmatter; + const enrichSchemaDeps = + frontmatterSlice.enabled && frontmatterSlice.schemas.length > 0 + ? { + contentDir: resolve(cwd, config.content.dir), + frontmatterSchemas: frontmatterSlice.schemas, + } + : {}; + // Referenced paths emerge from stdout relative to where bash ran // (`executionCwd`); enrichment addresses them project-relative, so rebase each // onto the project root. Identity no-op when the two coincide (the common @@ -618,7 +636,7 @@ export async function buildExecResult( files.map((p) => enrichPath( p, - { projectDir: cwd, serverUrl: resolvedServerUrl }, + { projectDir: cwd, serverUrl: resolvedServerUrl, ...enrichSchemaDeps }, { includeRichFields: isSinglePathCat, }, @@ -648,7 +666,7 @@ export async function buildExecResult( // change away. const dirEnriched: DirectoryMeta[] = await Promise.all( dirs.map((p) => - enrichDirectoryRecursive(p, 1, { projectDir: cwd }).catch( + enrichDirectoryRecursive(p, 1, { projectDir: cwd, ...enrichSchemaDeps }).catch( (): DirectoryMeta => ({ path: p, type: 'directory', diff --git a/packages/server/src/mcp/tools/lint.ts b/packages/server/src/mcp/tools/lint.ts index edf6df4f2..c4835fe6a 100644 --- a/packages/server/src/mcp/tools/lint.ts +++ b/packages/server/src/mcp/tools/lint.ts @@ -92,6 +92,7 @@ interface LintDiagnosticPayload { interface LintDocPayload { file?: string; diagnostics?: LintDiagnosticPayload[]; + warnings?: string[]; } interface LintFixPayload { @@ -158,7 +159,9 @@ export function register(server: ServerInstance, deps: LintDeps): void { warnings: z .array(z.string()) .optional() - .describe('Audit only: non-fatal issues (unreadable files/dirs).'), + .describe( + 'Non-fatal issues: unreadable files/dirs (audit) and lint-config problems such as broken frontmatter schema files (audit + single doc).', + ), omittedFileCount: z .number() .optional() @@ -255,19 +258,27 @@ async function lintSingleDoc(document: string, url: string, cwd: string) { const { ok: _ok, ...rest } = result; const data = rest as LintDocPayload; const diagnostics = data.diagnostics ?? []; + const configWarnings = data.warnings ?? []; const errorCount = diagnostics.filter((d) => d.severity === 'error').length; const warningCount = diagnostics.length - errorCount; const file = { file: data.file ?? normalized.docName, diagnostics }; - const structured = { files: [file], errorCount, warningCount, cwd }; + const structured = { + files: [file], + errorCount, + warningCount, + ...(configWarnings.length > 0 ? { warnings: configWarnings } : {}), + cwd, + }; const header = diagnostics.length === 0 ? `No problems in ${file.file}.` : `${file.file}: ${countSummary(errorCount, warningCount)}`; const lines = diagnostics.map(formatDiagnosticLine); + const warningLines = configWarnings.map((w) => ` ⚠ ${w}`); const fixableCount = diagnostics.filter((d) => (d.fixes?.length ?? 0) > 0).length; const footer = diagnostics.length > 0 ? [singleDocFixHint(fixableCount, diagnostics.length)] : []; - return textPlusStructured([header, ...lines, ...footer].join('\n'), structured); + return textPlusStructured([header, ...lines, ...warningLines, ...footer].join('\n'), structured); } async function lintAudit(path: string | undefined, url: string, cwd: string) { diff --git a/packages/server/src/server-factory.ts b/packages/server/src/server-factory.ts index a2e604ba7..53d1f5a15 100644 --- a/packages/server/src/server-factory.ts +++ b/packages/server/src/server-factory.ts @@ -802,6 +802,16 @@ export function createServer(options: ServerOptions): ServerInstance { enabled: semCfg.enabled, providerFingerprint: semanticProviderFingerprint(semCfg), }); + // The effective lint config is derived from the on-disk `.ok/config.yml` + // (readLinterBaseConfig reads fresh per request) — clients that refetched + // on the CRDT patch raced the persistence debounce and may hold a stale + // compose (e.g. a problems banner for a just-removed schema mapping). + // This persist is the moment the disk read converges; the signal is a + // debounced pure hint, so the producer-notify + watcher-echo double-fire + // this function documents is safe. + if (configDocName === CONFIG_DOC_NAME_PROJECT) { + cc1Broadcaster?.signal('lint-config'); + } log.info( { docName: configDocName, @@ -1002,7 +1012,7 @@ export function createServer(options: ServerOptions): ServerInstance { rejectReady = rej; }); - function signalChannel(channel: 'files' | 'backlinks' | 'graph' | 'tags'): void { + function signalChannel(channel: 'files' | 'backlinks' | 'graph' | 'tags' | 'lint-config'): void { cc1Broadcaster?.signal(channel); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3263bf785..8b88437ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -893,6 +893,12 @@ importers: '@tiptap/starter-kit': specifier: 3.22.3 version: 3.22.3 + ajv: + specifier: ^8.18.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) decode-named-character-reference: specifier: ^1.3.0 version: 1.3.0 @@ -938,6 +944,9 @@ importers: node-diff3: specifier: ^3.2.0 version: 3.2.1 + picomatch: + specifier: ^4.0.5 + version: 4.0.5 rehype-parse: specifier: ^9.0.1 version: 9.0.1 @@ -1020,15 +1029,12 @@ importers: '@types/node': specifier: ^24.7.0 version: 24.13.3 + '@types/picomatch': + specifier: ^4.0.3 + version: 4.0.3 '@types/unist': specifier: ^3.0.3 version: 3.0.3 - ajv: - specifier: ^8.18.0 - version: 8.20.0 - ajv-formats: - specifier: ^3.0.1 - version: 3.0.1(ajv@8.20.0) commonmark.json: specifier: 0.31.0 version: 0.31.0 @@ -6100,6 +6106,9 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/prismjs@1.26.6': resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} @@ -16339,6 +16348,8 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/picomatch@4.0.3': {} + '@types/prismjs@1.26.6': {} '@types/prop-types@15.7.15': {}