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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions packages/base/command-field.gts
Original file line number Diff line number Diff line change
@@ -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<typeof this> {
<template>
<div class='command-compact'>
<CommandIcon class='command-icon' />
<div class='command-info'>
<div class='command-title'>{{@model.cardTitle}}</div>
<div class='command-meta'>
<code
class='command-path'
>{{@model.codeRef.module}}/{{@model.codeRef.name}}</code>
</div>
{{#if @model.requiresApproval}}
<div>
<Pill class='command-label'>Requires Approval</Pill>
</div>
{{/if}}
</div>
</div>
<style scoped>
.command-compact {
--muted-color: color-mix(in lab, var(--muted) 60%, var(--foreground));
display: flex;
gap: var(--boxel-sp-3xs);
padding: var(--boxel-sp-xs);
background-color: var(--card, var(--boxel-light));
color: var(--card-foreground, var(--boxel-dark));
border: 1px solid var(--border, var(--boxel-border-color));
border-radius: var(--radius, var(--boxel-border-radius));
}
.command-icon {
color: var(--muted-color, var(--boxel-400));
flex-shrink: 0;
}
.command-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--boxel-sp-3xs);
padding-left: var(--boxel-sp-2xs);
border-left: 3px solid var(--muted-color, var(--boxel-400));
}
.command-title {
font-size: var(--boxel-font-size-sm);
font-weight: 500;
}
.command-meta {
font-size: var(--boxel-font-size-xs);
font-weight: 500;
color: var(--muted-foreground, var(--boxel-700));
}
.command-path {
word-break: break-all;
}
.command-label {
font-size: var(--boxel-font-size-2xs);
letter-spacing: var(--boxel-lsp-lg);
}
</style>
</template>
};
}
10 changes: 3 additions & 7 deletions packages/base/commands/search-card-result.gts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
contains,
containsMany,
field,
queryableValue,
type CardContext,
type Format,
FieldDef,
Expand All @@ -36,12 +35,9 @@ interface CardListSignature {
context?: CardContext;
}

export class JsonField extends FieldDef {
static [primitive]: Record<string, any>;
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;
Expand Down
46 changes: 46 additions & 0 deletions packages/base/frontmatter-field.gts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
): Record<string, unknown> {
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 ?? '';
}
<template>
{{this.kind}}
</template>
};
}
29 changes: 29 additions & 0 deletions packages/base/frontmatter-kinds.ts
Original file line number Diff line number Diff line change
@@ -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<string, typeof FrontmatterField> = {
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)
);
}
32 changes: 32 additions & 0 deletions packages/base/frontmatter-parse.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
// 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<yaml>\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<string, unknown>)
: {};
return { data, body: markdown.slice(match[0].length) };
}
17 changes: 17 additions & 0 deletions packages/base/json-field.gts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
static [queryableValue](_value: any, _stack: BaseDef[]): null {
return null;
}
}
Loading
Loading