diff --git a/packages/base/command-field.gts b/packages/base/command-field.gts new file mode 100644 index 00000000000..7a08c1ee7f7 --- /dev/null +++ b/packages/base/command-field.gts @@ -0,0 +1,125 @@ +import { + Component, + FieldDef, + field, + contains, + relativeTo, + virtualNetworkFor, +} from './card-api'; +import BooleanField from './boolean'; +import { AbsoluteCodeRefField } from './code-ref'; +import StringField from './string'; +import CommandIcon from '@cardstack/boxel-icons/square-chevron-right'; +import { Pill } from '@cardstack/boxel-ui/components'; +import { buildCommandFunctionName } from '@cardstack/runtime-common'; + +// A single command attached to a skill: an absolute code reference plus the +// approval policy the host applies before invoking it. Shared by the legacy +// `Skill` card (`commands`) and by `SkillFrontmatterField` (skill markdown +// frontmatter). +export class CommandField extends FieldDef { + static displayName = 'CommandField'; + static icon = CommandIcon; + + @field cardTitle = contains(StringField, { + computeVia: function (this: CommandField) { + let moduleRef = this.codeRef?.module; + if (!moduleRef) { + return 'Untitled Command'; + } + let nameSegment = moduleRef.split('/').pop(); + let formattedName = nameSegment + ?.split(/[-_]/g) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + return formattedName; + }, + }); + + @field codeRef = contains(AbsoluteCodeRefField, { + description: 'An absolute code reference to the command to be executed', + }); + @field requiresApproval = contains(BooleanField, { + description: + 'If true, this command will require human approval before it is executed in the host.', + }); + + @field functionName = contains(StringField, { + description: 'The name of the function to be executed', + computeVia: function (this: CommandField) { + let vn = virtualNetworkFor(this); + if (!vn) { + throw new Error( + `CommandField.functionName requires a VirtualNetwork (no store attached to ${this.constructor.name})`, + ); + } + return buildCommandFunctionName( + this.codeRef, + this[Symbol.for('cardstack-relative-to') as typeof relativeTo], + vn, + ); + }, + }); + + static embedded = class Embedded extends Component { + + }; +} diff --git a/packages/base/commands/search-card-result.gts b/packages/base/commands/search-card-result.gts index b1bcb2b6357..c1192732166 100644 --- a/packages/base/commands/search-card-result.gts +++ b/packages/base/commands/search-card-result.gts @@ -18,7 +18,6 @@ import { contains, containsMany, field, - queryableValue, type CardContext, type Format, FieldDef, @@ -36,12 +35,9 @@ interface CardListSignature { context?: CardContext; } -export class JsonField extends FieldDef { - static [primitive]: Record; - static [queryableValue](_value: any, _stack: BaseDef[]): null { - return null; - } -} +// `JsonField` lives in its own module so non-command code can reuse it. +// Re-exported here so it is importable from this module too. +export { JsonField } from '../json-field'; export class QueryField extends FieldDef { static [primitive]: Query; diff --git a/packages/base/frontmatter-field.gts b/packages/base/frontmatter-field.gts new file mode 100644 index 00000000000..a423cfabf6e --- /dev/null +++ b/packages/base/frontmatter-field.gts @@ -0,0 +1,46 @@ +import { + Component, + FieldDef, + field, + contains, + type BaseDefComponent, +} from './card-api'; +import { JsonField } from './json-field'; + +// The parsed YAML frontmatter of a markdown file, captured as JSON. The base +// type holds the entire frontmatter in `rawContent`; when the frontmatter +// declares a recognized `boxel.kind` (e.g. `skill`), the concrete instance is a +// subclass (e.g. `SkillFrontmatterField`) selected by the kind registry, which +// adds typed fields on top of the raw copy. +export class FrontmatterField extends FieldDef { + static displayName = 'Frontmatter'; + + // The entire frontmatter (all top-level keys), as JSON — a lossless raw copy. + // Not indexed for search; searchable bits are projected into typed fields + // (e.g. `MarkdownDef.kind`, `SkillFrontmatterField.name`). + @field rawContent = contains(JsonField); + + // Map a file's parsed frontmatter into this field's serialized attributes. + // The base keeps the whole frontmatter as the raw copy; subclasses add their + // own typed fields. A subclass is the only thing that knows its own + // frontmatter schema. + static fromFrontmatter( + frontmatter: Record, + ): Record { + return { rawContent: frontmatter }; + } + + static embedded: BaseDefComponent = class Embedded extends Component< + typeof this + > { + get kind() { + let raw = this.args.model?.rawContent as + | { boxel?: { kind?: string } } + | undefined; + return raw?.boxel?.kind ?? ''; + } + + }; +} diff --git a/packages/base/frontmatter-kinds.ts b/packages/base/frontmatter-kinds.ts new file mode 100644 index 00000000000..266bbc7bff2 --- /dev/null +++ b/packages/base/frontmatter-kinds.ts @@ -0,0 +1,29 @@ +import { FrontmatterField } from './frontmatter-field'; +import { SkillFrontmatterField } from './skill-frontmatter-field'; + +// Maps a `boxel.kind` value to the FrontmatterField subclass that models it. +// This registry lives above `MarkdownDef` so the base markdown type stays +// ignorant of its kinds: adding a new kind (e.g. `recipe`, `persona`) is a new +// field type + an entry here, not a new FileDef subclass or extension rule. +const FRONTMATTER_FIELD_BY_KIND: Record = { + skill: SkillFrontmatterField, +}; + +export function frontmatterFieldForKind( + kind: string | undefined, +): typeof FrontmatterField { + if ( + kind && + Object.prototype.hasOwnProperty.call(FRONTMATTER_FIELD_BY_KIND, kind) + ) { + return FRONTMATTER_FIELD_BY_KIND[kind]; + } + return FrontmatterField; +} + +export function isKnownFrontmatterKind(kind: string | undefined): boolean { + return ( + !!kind && + Object.prototype.hasOwnProperty.call(FRONTMATTER_FIELD_BY_KIND, kind) + ); +} diff --git a/packages/base/frontmatter-parse.ts b/packages/base/frontmatter-parse.ts new file mode 100644 index 00000000000..7f7c968e721 --- /dev/null +++ b/packages/base/frontmatter-parse.ts @@ -0,0 +1,32 @@ +import { parse as parseYaml } from 'yaml'; + +export interface ParsedFrontmatter { + // The parsed YAML frontmatter as a plain object (empty when the file has no + // frontmatter block). + data: Record; + // The markdown body with the leading frontmatter block removed. + body: string; +} + +// Matches a leading YAML frontmatter block delimited by `---` fences at the +// very start of the file: `---\n\n---\n`. +const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; + +// Parse a leading `--- … ---` YAML frontmatter block. Throws if the block is +// present but contains invalid YAML, so callers can surface the parse failure +// rather than silently dropping it. +export function parseFrontmatter(markdown: string): ParsedFrontmatter { + if (!markdown.startsWith('---')) { + return { data: {}, body: markdown }; + } + let match = FRONTMATTER_RE.exec(markdown); + if (!match) { + return { data: {}, body: markdown }; + } + let parsed = parseYaml(match[1]); + let data = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + return { data, body: markdown.slice(match[0].length) }; +} diff --git a/packages/base/json-field.gts b/packages/base/json-field.gts new file mode 100644 index 00000000000..f5b4de73e4b --- /dev/null +++ b/packages/base/json-field.gts @@ -0,0 +1,17 @@ +import { primitive } from '@cardstack/runtime-common'; +import type { BaseDef } from './card-api'; +import { FieldDef, queryableValue } from './card-api'; + +// A field whose value is an arbitrary JSON object, round-tripped as-is. It is +// intentionally NOT indexed for search (queryableValue → null): it backs +// loosely-typed blobs (command payloads, raw frontmatter) that shouldn't bloat +// the search index, and the typed query engine can't filter arbitrary nested +// JSON paths anyway. Callers that need to filter project the searchable parts +// into their own typed fields. +export class JsonField extends FieldDef { + static displayName = 'JSON'; + static [primitive]: Record; + static [queryableValue](_value: any, _stack: BaseDef[]): null { + return null; + } +} diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index 45c6b6504dd..9cd2fef48d9 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -1,6 +1,7 @@ import { byteStreamToUint8Array, extractCardReferenceUrls, + identifyCard, VirtualNetwork, } from '@cardstack/runtime-common'; import MarkdownIcon from '@cardstack/boxel-icons/align-box-left-middle'; @@ -22,6 +23,18 @@ import { type ByteStream, type SerializedFile, } from './file-api'; +import { FrontmatterField } from './frontmatter-field'; +import { + frontmatterFieldForKind, + isKnownFrontmatterKind, +} from './frontmatter-kinds'; +import { parseFrontmatter } from './frontmatter-parse'; + +// Channel for routing per-field meta (e.g. the concrete subclass of a +// polymorphic field) from `extractAttributes` to the index resource builder, +// without it leaking into the flat `search_doc`. The host file extractor reads +// the same global symbol. See `file-def-attributes-extractor.ts`. +const fileFieldMetaSymbol = Symbol.for('boxel:file-field-meta'); const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']); const EXCERPT_MAX_LENGTH = 500; @@ -447,6 +460,18 @@ export class MarkdownDef extends FileDef { @field excerpt = contains(StringField); @field content = contains(StringField); + // The frontmatter's `boxel.kind`, surfaced as a direct, indexed field so + // skills are findable via `searchFiles({ filter: { eq: { kind: 'skill' } } })`. + // Empty for plain markdown. + @field kind = contains(StringField); + + // The file's parsed YAML frontmatter. `rawContent` holds the whole thing as + // JSON; when `boxel.kind` names a recognized kind (e.g. `skill`) this + // rehydrates as the matching `FrontmatterField` subclass (e.g. + // `SkillFrontmatterField`) via `meta.fields.frontmatter.adoptsFrom`, set in + // `extractAttributes`. + @field frontmatter = contains(FrontmatterField); + @field cardReferenceUrls = containsMany(StringField, { computeVia: function (this: MarkdownDef) { if (!this.content) { @@ -496,6 +521,12 @@ export class MarkdownDef extends FileDef { excerpt: string; content: string; cardReferenceUrls: string[]; + // The frontmatter's `boxel.kind`, as a direct searchable field (e.g. + // `searchFiles({ filter: { eq: { kind: 'skill' } } })`). + kind?: string; + // The `frontmatter` field value: `{ rawContent, … }`, typed by + // `boxel.kind` via the registry. + frontmatter?: Record; }> > { let extension = getExtension(url); @@ -516,16 +547,76 @@ export class MarkdownDef extends FileDef { let markdown = new TextDecoder().decode(bytes); let fallbackTitle = fileNameWithoutExtension(base.name ?? ''); - return { + let frontmatterData: Record = {}; + let body = markdown; + try { + let parsed = parseFrontmatter(normalizeMarkdown(markdown)); + frontmatterData = parsed.data; + body = parsed.body; + } catch (err) { + // Invalid YAML: index the markdown without frontmatter rather than fail + // the whole file. TODO: surface this via indexing diagnostics rather than + // only a console warning, so frontmatter parse errors stay visible. + console.warn(`[markdown-file-def] frontmatter parse failed for ${url}:`, err); + } + + let attributes: SerializedFile<{ + title: string; + excerpt: string; + content: string; + cardReferenceUrls: string[]; + kind?: string; + frontmatter?: Record; + }> = { ...base, - title: extractTitle(markdown, fallbackTitle), - excerpt: extractExcerpt(markdown), - content: markdown, + title: extractTitle(body, fallbackTitle), + excerpt: extractExcerpt(body), + // The body with any frontmatter block stripped — what the markdown / + // isolated / embedded paths render. The parsed frontmatter lives in + // `frontmatter.rawContent`, and the verbatim file is always served from + // the realm, so nothing is lost. + content: body, cardReferenceUrls: extractCardReferenceUrls( - markdown, + body, url, new VirtualNetwork(), ), }; + + // Boxel-specific frontmatter is namespaced under `boxel:`; generic + // top-level keys (shared with Claude Code) never trigger Boxel behavior. + let boxelNamespace = + frontmatterData.boxel && + typeof frontmatterData.boxel === 'object' && + !Array.isArray(frontmatterData.boxel) + ? (frontmatterData.boxel as Record) + : undefined; + let kind = + typeof boxelNamespace?.kind === 'string' + ? boxelNamespace.kind + : undefined; + if (kind !== undefined) { + attributes.kind = kind; // direct, indexed, searchable + } + + // `boxel.kind` selects the FrontmatterField subclass; the subclass maps the + // parsed frontmatter into its own field value (the base keeps the raw copy). + // MarkdownDef stays ignorant of any kind's schema. A recognized kind is + // recorded so the field rehydrates as that subclass on read. + if (Object.keys(frontmatterData).length > 0) { + let frontmatterFieldClass = frontmatterFieldForKind(kind); + attributes.frontmatter = + frontmatterFieldClass.fromFrontmatter(frontmatterData); + if (isKnownFrontmatterKind(kind)) { + let adoptsFrom = identifyCard(frontmatterFieldClass); + if (adoptsFrom) { + (attributes as Record)[fileFieldMetaSymbol] = { + frontmatter: { adoptsFrom }, + }; + } + } + } + + return attributes; } } diff --git a/packages/base/package.json b/packages/base/package.json index cbf47832d38..875e69e23a6 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -24,7 +24,8 @@ "matrix-js-sdk": "catalog:", "super-fast-md5": "catalog:", "@floating-ui/dom": "catalog:", - "tracked-built-ins": "catalog:" + "tracked-built-ins": "catalog:", + "yaml": "catalog:" }, "peerDependencies": { "ember-provide-consume-context": "^0.7.0", diff --git a/packages/base/skill-frontmatter-field.gts b/packages/base/skill-frontmatter-field.gts new file mode 100644 index 00000000000..4ac7bb6c28d --- /dev/null +++ b/packages/base/skill-frontmatter-field.gts @@ -0,0 +1,52 @@ +import { + Component, + field, + contains, + containsMany, + type BaseDefComponent, +} from './card-api'; +import StringField from './string'; +import { FrontmatterField } from './frontmatter-field'; +import { CommandField } from './command-field'; + +// A skill markdown file's frontmatter (`boxel.kind: skill`). Adds typed fields +// on top of the base `FrontmatterField` (which holds the raw frontmatter in +// `rawContent`). Mirrors the field shape of the legacy `Skill` card so the +// host's command-definition upload flow reads `markdownDef.frontmatter.commands` +// exactly as it reads `Skill.commands`. `name`/`description` are sourced from the +// shared top-level frontmatter keys (see `MarkdownDef.extractAttributes`). +export class SkillFrontmatterField extends FrontmatterField { + static displayName = 'Skill'; + + @field name = contains(StringField); + @field description = contains(StringField); + @field commands = containsMany(CommandField); + + // `name`/`description` come from the shared top-level frontmatter keys; + // `commands` from the `boxel:` namespace. Only this subclass knows that + // mapping. + static fromFrontmatter( + frontmatter: Record, + ): Record { + let boxel = + frontmatter.boxel && + typeof frontmatter.boxel === 'object' && + !Array.isArray(frontmatter.boxel) + ? (frontmatter.boxel as Record) + : undefined; + return { + ...super.fromFrontmatter(frontmatter), + name: frontmatter.name, + description: frontmatter.description, + commands: boxel?.commands, + }; + } + + static embedded: BaseDefComponent = class Embedded extends Component< + typeof this + > { + + }; +} diff --git a/packages/base/skill.gts b/packages/base/skill.gts index 0d87ab62624..185699bb4c2 100644 --- a/packages/base/skill.gts +++ b/packages/base/skill.gts @@ -1,131 +1,22 @@ import { CardDef, Component, - FieldDef, field, contains, containsMany, - relativeTo, - virtualNetworkFor, type BaseDefComponent, } from './card-api'; -import BooleanField from './boolean'; -import { AbsoluteCodeRefField } from './code-ref'; import MarkdownField from './markdown'; import StringField from './string'; import RobotIcon from '@cardstack/boxel-icons/robot'; -import CommandIcon from '@cardstack/boxel-icons/square-chevron-right'; -import { Pill } from '@cardstack/boxel-ui/components'; -import { buildCommandFunctionName } from '@cardstack/runtime-common'; +import { CommandField } from './command-field'; export const isSkillCard = Symbol.for('is-skill-card'); -export class CommandField extends FieldDef { - static displayName = 'CommandField'; - static icon = CommandIcon; - - @field cardTitle = contains(StringField, { - computeVia: function (this: CommandField) { - let moduleRef = this.codeRef?.module; - if (!moduleRef) { - return 'Untitled Command'; - } - let nameSegment = moduleRef.split('/').pop(); - let formattedName = nameSegment - ?.split(/[-_]/g) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - return formattedName; - }, - }); - - @field codeRef = contains(AbsoluteCodeRefField, { - description: 'An absolute code reference to the command to be executed', - }); - @field requiresApproval = contains(BooleanField, { - description: - 'If true, this command will require human approval before it is executed in the host.', - }); - - @field functionName = contains(StringField, { - description: 'The name of the function to be executed', - computeVia: function (this: CommandField) { - let vn = virtualNetworkFor(this); - if (!vn) { - throw new Error( - `CommandField.functionName requires a VirtualNetwork (no store attached to ${this.constructor.name})`, - ); - } - return buildCommandFunctionName( - this.codeRef, - this[Symbol.for('cardstack-relative-to') as typeof relativeTo], - vn, - ); - }, - }); - - static embedded = class Embedded extends Component { - - }; -} +// `CommandField` lives in its own module so `SkillFrontmatterField` (skill +// markdown frontmatter) can reuse it without importing the `Skill` card. +// Re-exported here so it is importable from this module too. +export { CommandField }; export class Skill extends CardDef { static displayName = 'Skill'; diff --git a/packages/host/app/lib/externals.ts b/packages/host/app/lib/externals.ts index 91ee002f5a7..dbbd2e274c8 100644 --- a/packages/host/app/lib/externals.ts +++ b/packages/host/app/lib/externals.ts @@ -193,6 +193,10 @@ export function shimExternals(virtualNetwork: VirtualNetwork) { id: 'uuid', resolve: () => import('uuid'), }); + virtualNetwork.shimAsyncModule({ + id: 'yaml', + resolve: () => import('yaml'), + }); virtualNetwork.shimAsyncModule({ id: '@cardstack/runtime-common/marked-sync', resolve: () => import('@cardstack/runtime-common/marked-sync.ts'), diff --git a/packages/host/app/utils/file-def-attributes-extractor.ts b/packages/host/app/utils/file-def-attributes-extractor.ts index a3700717e27..ef2daf75712 100644 --- a/packages/host/app/utils/file-def-attributes-extractor.ts +++ b/packages/host/app/utils/file-def-attributes-extractor.ts @@ -200,14 +200,28 @@ export class FileDefAttributesExtractor { let displayNames = getDisplayNames(klass); let adoptsFrom = typeCodeRefs[0] ?? this.#fileDefCodeRef; let queryFieldDefs = await this.extractQueryFieldDefs(klass); + // `extractAttributes` may route per-field meta (the concrete subclass + // of a nested polymorphic field) via this global symbol. Lift it out so + // it lands in the resource's `meta.fields` and never in the flat + // `search_doc`. The base FileDef sets the same `Symbol.for` key. + let fieldMetaSymbol = Symbol.for('boxel:file-field-meta'); + let cleanedDoc: Record = { ...searchDoc }; + let cleanedBag = cleanedDoc as Record; + let fieldsMeta = cleanedBag[fieldMetaSymbol] as + | NonNullable + | undefined; + if (fieldsMeta) { + delete cleanedBag[fieldMetaSymbol]; + } return { status: 'ready', - searchDoc, + searchDoc: cleanedDoc, resource: buildFileResource( this.#fileURL, - searchDoc, + cleanedDoc, adoptsFrom, queryFieldDefs, + fieldsMeta, ), types, displayNames, @@ -401,6 +415,7 @@ export function buildFileResource( attributes: Record, adoptsFrom: CodeRef, queryFieldDefs?: Record, + fieldsMeta?: NonNullable, ): FileMetaResource { let name = new URL(fileURL).pathname.split('/').pop() ?? fileURL; let baseAttributes = { @@ -421,6 +436,10 @@ export function buildFileResource( attributes: mergedAttributes, meta: { adoptsFrom, + // Per-field subclass overrides for nested polymorphic fields (e.g. + // `frontmatter` → SkillFrontmatterField). Without this the field rehydrates + // as its declared base type. Supplied by `extractAttributes` (see below). + ...(fieldsMeta ? { fields: fieldsMeta } : {}), ...(queryFieldDefs ? { queryFieldDefs } : {}), }, links: { self: fileURL }, diff --git a/packages/host/package.json b/packages/host/package.json index c5cb10a80f7..847869afdc3 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -189,7 +189,8 @@ "uuid": "catalog:", "vite": "^8.0.8", "wait-for-localhost-cli": "catalog:", - "wtfnode": "^0.10.1" + "wtfnode": "^0.10.1", + "yaml": "catalog:" }, "dependencies": { "ember-modify-based-class-resource": "catalog:", diff --git a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts new file mode 100644 index 00000000000..072db3727cb --- /dev/null +++ b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts @@ -0,0 +1,236 @@ +// A skill is markdown with `boxel.kind: skill` frontmatter, modeled by the +// FrontmatterField / SkillFrontmatterField field types on MarkdownDef. These +// tests exercise the real MarkdownDef: +// +// 1. parseFrontmatter pulls the YAML block off the top of the body. +// 2. MarkdownDef.extractAttributes parses frontmatter, writes a direct +// searchable `kind`, captures the whole frontmatter in +// `frontmatter.rawContent`, and routes the per-field subclass marker +// (SkillFrontmatterField) via the file-field-meta symbol. +// 3. The write→read round-trip (extractor lift → buildFileResource → +// createFromSerialized) rehydrates `frontmatter` as a SkillFrontmatterField +// with its commands intact. +// 4. Plain markdown (no frontmatter) is unaffected. + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { baseRealm, identifyCard } from '@cardstack/runtime-common'; +import type { Loader } from '@cardstack/runtime-common/loader'; + +import { buildFileResource } from '@cardstack/host/utils/file-def-attributes-extractor'; + +import { setupLocalIndexing, testRealmURL } from '../../helpers'; +import { setupBaseRealm, createFromSerialized } from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +const FILE_FIELD_META = Symbol.for('boxel:file-field-meta'); + +let loader: Loader; + +// Minimal getStream for extractAttributes: hand back the markdown bytes. +function streamOf(markdown: string): () => Promise { + return async () => new TextEncoder().encode(markdown); +} + +const SKILL_MD = `--- +name: Realm Sync +description: Sync workspace files +boxel: + kind: skill + commands: + - codeRef: + module: '@cardstack/boxel-host/commands/realm-sync' + name: SyncCommand + requiresApproval: true +--- +# Realm Sync + +Body paragraph. +`; + +module('Integration | markdown skill frontmatter', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + setupMockMatrix(hooks); + + hooks.beforeEach(function () { + loader = getService('loader-service').loader; + }); + + async function loadBase() { + let { MarkdownDef } = await loader.import( + `${baseRealm.url}markdown-file-def`, + ); + let { SkillFrontmatterField } = await loader.import( + `${baseRealm.url}skill-frontmatter-field`, + ); + let { FrontmatterField } = await loader.import( + `${baseRealm.url}frontmatter-field`, + ); + let { parseFrontmatter } = await loader.import( + `${baseRealm.url}frontmatter-parse`, + ); + return { + MarkdownDef, + SkillFrontmatterField, + FrontmatterField, + parseFrontmatter, + }; + } + + test('parseFrontmatter splits the YAML block from the body', async function (assert) { + let { parseFrontmatter } = await loadBase(); + let { data, body } = parseFrontmatter(SKILL_MD); + assert.strictEqual(data.name, 'Realm Sync', 'top-level name parsed'); + assert.strictEqual((data.boxel as any).kind, 'skill', 'boxel.kind parsed'); + assert.strictEqual( + (data.boxel as any).commands[0].codeRef.name, + 'SyncCommand', + 'nested boxel.commands codeRef parsed', + ); + assert.true( + body.startsWith('# Realm Sync'), + 'body excludes the frontmatter block', + ); + + let plain = parseFrontmatter('# Just markdown\n\nNo frontmatter.'); + assert.deepEqual(plain.data, {}, 'no frontmatter -> empty data'); + assert.strictEqual( + plain.body, + '# Just markdown\n\nNo frontmatter.', + 'no frontmatter -> body verbatim', + ); + }); + + test('extractAttributes surfaces searchable kind, raw frontmatter, and routes the SkillFrontmatterField marker', async function (assert) { + let { MarkdownDef, SkillFrontmatterField } = await loadBase(); + let url = `${testRealmURL}skills/realm-sync/SKILL.md`; + let attrs = await MarkdownDef.extractAttributes( + url, + streamOf(SKILL_MD), + {}, + ); + + assert.strictEqual(attrs.kind, 'skill', 'direct searchable kind written'); + assert.true( + attrs.content.startsWith('# Realm Sync'), + 'content is the body — the frontmatter block is stripped', + ); + assert.notOk( + attrs.content.includes('boxel:'), + 'content excludes the raw frontmatter', + ); + assert.strictEqual( + attrs.frontmatter.rawContent.boxel.kind, + 'skill', + 'rawContent holds the whole frontmatter (incl. boxel namespace)', + ); + assert.strictEqual( + attrs.frontmatter.rawContent.name, + 'Realm Sync', + 'rawContent includes shared top-level keys', + ); + assert.strictEqual( + attrs.frontmatter.name, + 'Realm Sync', + 'typed name sourced from shared top-level name', + ); + assert.true( + attrs.frontmatter.commands[0].requiresApproval, + 'typed commands sourced from the boxel namespace', + ); + + let routed = (attrs as Record)[FILE_FIELD_META]; + assert.deepEqual( + routed?.frontmatter?.adoptsFrom, + identifyCard(SkillFrontmatterField), + 'routed per-field meta points frontmatter at SkillFrontmatterField', + ); + }); + + test('write -> read round-trip rehydrates frontmatter as SkillFrontmatterField with commands', async function (assert) { + let { MarkdownDef, SkillFrontmatterField } = await loadBase(); + let url = `${testRealmURL}skills/realm-sync/SKILL.md`; + let attrs = await MarkdownDef.extractAttributes( + url, + streamOf(SKILL_MD), + {}, + ); + + // Mirror what the file extractor does: lift the routed field meta out of + // the flat searchDoc and thread it into the resource's meta.fields. + let cleaned: Record = { ...attrs }; + let cleanedBag = cleaned as Record; + let fieldsMeta = cleanedBag[FILE_FIELD_META]; + delete cleanedBag[FILE_FIELD_META]; + + let resource = buildFileResource( + url, + cleaned, + identifyCard(MarkdownDef)!, + undefined, + fieldsMeta, + ); + assert.deepEqual( + (resource.meta as any).fields?.frontmatter?.adoptsFrom, + identifyCard(SkillFrontmatterField), + 'buildFileResource now threads meta.fields (gap closed)', + ); + + let instance: any = await createFromSerialized( + resource as any, + { data: resource } as any, + undefined, + ); + assert.true( + instance.frontmatter instanceof SkillFrontmatterField, + 'frontmatter rehydrated as SkillFrontmatterField subclass', + ); + assert.strictEqual( + instance.frontmatter.name, + 'Realm Sync', + 'typed subclass field survives round-trip', + ); + assert.strictEqual( + instance.frontmatter.rawContent.boxel.kind, + 'skill', + 'rawContent survives round-trip', + ); + assert.strictEqual( + instance.frontmatter.commands.length, + 1, + 'commands survive round-trip', + ); + assert.strictEqual( + instance.frontmatter.commands[0].codeRef.name, + 'SyncCommand', + 'command codeRef survives round-trip', + ); + }); + + test('plain markdown (no frontmatter) carries no kind and no frontmatter value', async function (assert) { + let { MarkdownDef } = await loadBase(); + let url = `${testRealmURL}notes/readme.md`; + let attrs = await MarkdownDef.extractAttributes( + url, + streamOf('# Readme\n\nHello.'), + {}, + ); + assert.strictEqual(attrs.kind, undefined, 'no kind for plain markdown'); + assert.strictEqual( + attrs.frontmatter, + undefined, + 'no frontmatter value for plain markdown', + ); + assert.strictEqual( + (attrs as Record)[FILE_FIELD_META], + undefined, + 'no routed field meta for plain markdown', + ); + assert.strictEqual(attrs.title, 'Readme', 'title still extracted'); + assert.true(attrs.content.includes('Hello.'), 'content still extracted'); + }); +}); diff --git a/packages/host/tests/integration/markdown-skill-search-test.gts b/packages/host/tests/integration/markdown-skill-search-test.gts new file mode 100644 index 00000000000..ddac38a9bce --- /dev/null +++ b/packages/host/tests/integration/markdown-skill-search-test.gts @@ -0,0 +1,149 @@ +// A search query finds MarkdownDef files whose frontmatter declares +// `boxel.kind: skill`. The discriminator is projected onto the indexed +// `MarkdownDef.kind` field at extraction, so it's filterable via the same +// file-meta query path used for any other FileDef field (cf. the `eq: { url }` +// file-meta filter in realm-querying-test). +// +// Runs under the host test-services stack / CI. + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { baseRRI, baseRealm } from '@cardstack/runtime-common'; +import type { RealmIndexQueryEngine } from '@cardstack/runtime-common/realm-index-query-engine'; + +import { + testRealmURL, + setupLocalIndexing, + setupIntegrationTestRealm, + setupRealmCacheTeardown, + withCachedRealmSetup, +} from '../helpers'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupRenderingTest } from '../helpers/setup'; + +const SKILL_MD = `--- +name: Realm Sync +description: Sync workspace files +boxel: + kind: skill + commands: + - codeRef: + module: '@cardstack/boxel-host/commands/realm-sync' + name: SyncCommand + requiresApproval: true +--- +# Realm Sync +`; + +// A markdown file with frontmatter but a non-skill kind — must NOT match. +const RECIPE_MD = `--- +name: Pasta +boxel: + kind: recipe +--- +# Pasta +`; + +// Plain markdown, no frontmatter — must NOT match. +const PLAIN_MD = `# Just a note + +Nothing special here. +`; + +const markdownRef = { + module: baseRRI('markdown-file-def'), + name: 'MarkdownDef', +}; + +module('Integration | markdown skill search', function (hooks) { + setupRenderingTest(hooks); + setupLocalIndexing(hooks); + let mockMatrixUtils = setupMockMatrix(hooks); + setupRealmCacheTeardown(hooks); + + let queryEngine: RealmIndexQueryEngine; + + hooks.beforeEach(async function () { + let { realm } = await withCachedRealmSetup(async () => + setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'skills/realm-sync/SKILL.md': SKILL_MD, + 'recipes/pasta/SKILL.md': RECIPE_MD, + 'notes/plain.md': PLAIN_MD, + }, + }), + ); + queryEngine = realm.realmIndexQueryEngine; + }); + + test('finds MarkdownDef files where boxel.kind is "skill"', async function (assert) { + let result = (await queryEngine.searchCards({ + filter: { + on: markdownRef, + eq: { kind: 'skill' }, + }, + })) as unknown as { data: { id?: string; type: string }[] }; + + assert.deepEqual( + result.data.map((entry) => entry.id), + [`${testRealmURL}skills/realm-sync/SKILL.md`], + 'only the skill markdown file matches kind: skill', + ); + assert.strictEqual( + result.data[0]?.type, + 'file-meta', + 'the match is a file-meta resource (an indexed MarkdownDef file)', + ); + }); + + test('all three markdown files index as MarkdownDef (kind only discriminates the filter)', async function (assert) { + let result = (await queryEngine.searchCards({ + filter: { type: markdownRef }, + })) as unknown as { data: { id?: string }[] }; + + let ids = result.data.map((entry) => entry.id); + assert.ok( + ids.includes(`${testRealmURL}skills/realm-sync/SKILL.md`), + 'skill file is a MarkdownDef', + ); + assert.ok( + ids.includes(`${testRealmURL}recipes/pasta/SKILL.md`), + 'recipe file is also a MarkdownDef (different kind, not a different type)', + ); + assert.ok( + ids.includes(`${testRealmURL}notes/plain.md`), + 'plain markdown is a MarkdownDef too', + ); + }); + + test('reading a skill markdown file rehydrates frontmatter as SkillFrontmatterField', async function (assert) { + // The realm serves a file's `meta.fields` (the per-field subclass override) + // alongside the indexed resource, so the polymorphic `frontmatter` field + // rehydrates as its concrete subclass on read rather than the declared base. + let loader = getService('loader-service').loader; + let { SkillFrontmatterField } = await loader.import( + `${baseRealm.url}skill-frontmatter-field`, + ); + let store = getService('store'); + let url = `${testRealmURL}skills/realm-sync/SKILL.md`; + let instance: any = await store.get(url, { type: 'file-meta' }); + + assert.strictEqual(instance?.kind, 'skill', 'kind read back as skill'); + assert.true( + instance?.frontmatter instanceof SkillFrontmatterField, + 'frontmatter rehydrated as SkillFrontmatterField, not the base FrontmatterField', + ); + assert.strictEqual( + instance.frontmatter.commands.length, + 1, + 'typed commands survive the realm file-meta read', + ); + assert.strictEqual( + instance.frontmatter.commands[0].codeRef.name, + 'SyncCommand', + 'command codeRef survives the realm file-meta read', + ); + }); +}); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 6f9f321ec5f..781114275a9 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -4276,6 +4276,12 @@ export class Realm { adoptsFrom, realmInfo, realmURL: this.url as RealmIdentifier, + // Per-field subclass overrides for nested polymorphic fields (e.g. + // `frontmatter` → SkillFrontmatterField). Without this the field + // rehydrates as its declared base type when the document is read. + ...(fileEntry.resource?.meta?.fields + ? { fields: fileEntry.resource.meta.fields } + : {}), ...(fileEntry.resource?.meta?.queryFieldDefs ? { queryFieldDefs: fileEntry.resource.meta.queryFieldDefs } : {}), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfe284bc085..aa2ffc4364c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -954,6 +954,9 @@ importers: tracked-built-ins: specifier: ^4.1.2 version: 4.1.2(@babel/core@7.29.0) + yaml: + specifier: 'catalog:' + version: 2.9.0 packages/billing: dependencies: @@ -2434,6 +2437,9 @@ importers: wtfnode: specifier: ^0.10.1 version: 0.10.1 + yaml: + specifier: 'catalog:' + version: 2.9.0 packages/local-types: devDependencies: @@ -10250,7 +10256,7 @@ packages: optional: true eslint-plugin-qunit-dom@https://codeload.github.com/mainmatter/eslint-plugin-qunit-dom/tar.gz/d66c84190b018deb96c715c7f2bde3536a9c704b: - resolution: {gitHosted: true, tarball: https://codeload.github.com/mainmatter/eslint-plugin-qunit-dom/tar.gz/d66c84190b018deb96c715c7f2bde3536a9c704b} + resolution: {gitHosted: true, integrity: sha512-jRZYHAvECM5EM8ypI2sy7TJzr5B9auRsrUTghrj26o+pXdd0ElHw1ORdKDpICuRFpuGvoT748FVmOpaqswLd1g==, tarball: https://codeload.github.com/mainmatter/eslint-plugin-qunit-dom/tar.gz/d66c84190b018deb96c715c7f2bde3536a9c704b} version: 0.2.0 engines: {node: 12.* || 14.* || >= 16.*} peerDependencies: @@ -19120,10 +19126,7 @@ snapshots: '@percy/cli-command': 1.31.13(typescript@5.9.3) '@percy/cli-exec': 1.31.13(typescript@5.9.3) transitivePeerDependencies: - - bufferutil - - supports-color - typescript - - utf-8-validate '@percy/cli-build@1.31.13(typescript@5.9.3)': dependencies: @@ -19172,10 +19175,7 @@ snapshots: cross-spawn: 7.0.6 which: 2.0.2 transitivePeerDependencies: - - bufferutil - - supports-color - typescript - - utf-8-validate '@percy/cli-snapshot@1.31.13(typescript@5.9.3)': dependencies: