fix(den): parse skill frontmatter when saving skills (#1308)

* fix(den): parse skill frontmatter when saving skills

Keep the full SKILL.md payload in the database, derive title and description from frontmatter, and require a frontmatter name in the API. Also include shared packages in the Den web Docker image so the upload flow can be verified end to end.

* fix(den-web): transpile shared utils package

Include @openwork-ee/utils in Next's transpilePackages list so the client skill editor resolves the shared frontmatter helpers during production builds.

* fix(den-web): keep skill parsing local to the app

Move the client-side skill markdown parser into the Den web app so production builds do not depend on resolving a workspace package boundary from a client component.

* refactor(den): share skill frontmatter parsing via utils

Make Den web and Den API both depend on @openwork-ee/utils for skill frontmatter parsing, refresh the workspace lockfile, and keep the Den web build wiring explicit for CI and Docker.

* fix(den-web): build Den from the monorepo root on Vercel

Keep the shared utils package setup, but make Vercel install and build from the repo root so workspace dependencies like @openwork-ee/utils are always available when Den web deploys from its app subdirectory.

* docs(den-web): keep Vercel workspace settings in the dashboard

Remove the app-level Vercel command override from the repo and document the monorepo install/build settings for the dashboard instead.

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-03 11:44:02 -07:00
committed by GitHub
parent e006acade6
commit 780bbaa9f9
10 changed files with 178 additions and 23 deletions

View File

@@ -8,6 +8,7 @@ import {
SkillTable,
TeamTable,
} from "@openwork-ee/den-db/schema"
import { hasSkillFrontmatterName, parseSkillMarkdown } from "@openwork-ee/utils"
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { z } from "zod"
@@ -23,13 +24,30 @@ import type { MemberTeamsContext } from "../../middleware/member-teams.js"
import type { OrgRouteVariables } from "./shared.js"
import { idParamSchema, memberHasRole, orgIdParamSchema } from "./shared.js"
const skillTextSchema = z.string().superRefine((value, ctx) => {
if (!value.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Skill content cannot be empty.",
})
return
}
if (!hasSkillFrontmatterName(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Skill content must start with frontmatter that includes a name.",
})
}
})
const createSkillSchema = z.object({
skillText: z.string().trim().min(1),
skillText: skillTextSchema,
shared: z.enum(["org", "public"]).nullable().optional(),
})
const updateSkillSchema = z.object({
skillText: z.string().trim().min(1).optional(),
skillText: skillTextSchema.optional(),
shared: z.enum(["org", "public"]).nullable().optional(),
}).superRefine((value, ctx) => {
if (value.skillText === undefined && value.shared === undefined) {
@@ -111,6 +129,17 @@ function parseTeamId(value: string) {
}
function parseSkillMetadata(skillText: string) {
const parsed = parseSkillMarkdown(skillText)
if (parsed.hasFrontmatter) {
const title = parsed.name.trim() || "Untitled skill"
const description = parsed.description.trim() || null
return {
title: title.slice(0, 255),
description: description ? description.slice(0, 65535) : null,
}
}
const lines = skillText
.split(/\r?\n/g)
.map((line) => line.trim())

View File

@@ -54,9 +54,11 @@ Recommended project settings:
- Root directory: `ee/apps/den-web`
- Framework preset: Next.js
- Build command: `next build`
- Build command: `cd ../../.. && pnpm --filter @openwork-ee/den-web build`
- Output directory: `.next`
- Install command: `pnpm install --frozen-lockfile`
- Install command: `cd ../../.. && pnpm install --frozen-lockfile`
These commands should be configured in the Vercel dashboard rather than committed in `vercel.json`, so the app still builds from the monorepo root and can resolve shared workspace packages like `@openwork-ee/utils`.
Then assign custom domain:

View File

@@ -16,6 +16,7 @@ import {
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import {
buildSkillText,
getSkillBodyText,
parseSkillDraft,
useOrgSkillLibrary,
} from "./skill-hub-data";
@@ -67,7 +68,10 @@ export function SkillEditorScreen({ skillId }: { skillId?: string }) {
});
setName(draft.name || skill.title);
setDescription(draft.description || skill.description || "");
setDetails(draft.details || skill.skillText);
setDetails(getSkillBodyText(skill.skillText, {
name: skill.title,
description: skill.description,
}));
setVisibility(
skill.shared === "org" ? "org" : skill.shared === "public" ? "public" : "private",
);
@@ -129,7 +133,7 @@ export function SkillEditorScreen({ skillId }: { skillId?: string }) {
setUploadedFileName(file.name);
setName(draft.name || file.name.replace(/\.md$/i, ""));
setDescription(draft.description);
setDetails(draft.details || text);
setDetails(getSkillBodyText(text));
setMode("upload");
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { composeSkillMarkdown, parseSkillMarkdown } from "@openwork-ee/utils";
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
export type DenSkillShared = "org" | "public" | null;
@@ -220,6 +221,16 @@ export function parseSkillCategory(skillText: string): string | null {
}
export function parseSkillDraft(skillText: string, fallback?: { name?: string | null; description?: string | null }): SkillComposerDraft {
const parsed = parseSkillMarkdown(skillText);
if (parsed.hasFrontmatter) {
return {
name: parsed.name || fallback?.name || "",
description: parsed.description || fallback?.description || "",
category: parseSkillCategory(skillText) ?? "General",
details: parsed.body.trim(),
};
}
const lines = skillText.split(/\r?\n/g);
const nonEmptyIndexes = lines.reduce<number[]>((indexes, line, index) => {
if (line.trim()) {
@@ -252,6 +263,11 @@ export function parseSkillDraft(skillText: string, fallback?: { name?: string |
}
export function getSkillBodyText(skillText: string, fallback?: { name?: string | null; description?: string | null }) {
const parsed = parseSkillMarkdown(skillText);
if (parsed.hasFrontmatter) {
return parsed.body.trim();
}
const draft = parseSkillDraft(skillText, fallback);
return draft.details || skillText;
}
@@ -284,21 +300,7 @@ function cleanupSkillMetadataLine(value: string): string {
}
export function buildSkillText(input: SkillComposerDraft): string {
const sections = [`# ${input.name.trim()}`];
if (input.description.trim()) {
sections.push(input.description.trim());
}
if (input.category.trim()) {
sections.push(`Category: ${input.category.trim()}`);
}
if (input.details.trim()) {
sections.push(input.details.trim());
}
return `${sections.join("\n\n")}\n`;
return composeSkillMarkdown(input.name, input.description, input.details);
}
export function getSkillVisibilityLabel(shared: DenSkillShared): string {

View File

@@ -4,7 +4,7 @@ const path = require("path");
const nextConfig = {
reactStrictMode: true,
skipTrailingSlashRedirect: true,
transpilePackages: ["@openwork/ui"],
transpilePackages: ["@openwork/ui", "@openwork-ee/utils"],
outputFileTracingRoot: path.join(__dirname, "../../.."),
};

View File

@@ -5,12 +5,13 @@
"scripts": {
"dev": "OPENWORK_DEV_MODE=1 NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_API_KEY= next dev --hostname 0.0.0.0 --port 3005",
"dev:local": "sh -lc 'OPENWORK_DEV_MODE=1 NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_API_KEY= next dev --hostname 0.0.0.0 --port ${DEN_WEB_PORT:-3005}'",
"prebuild": "pnpm --dir ../../../packages/ui build",
"prebuild": "pnpm --dir ../../../packages/ui build && pnpm --dir ../../packages/utils build",
"build": "next build",
"start": "next start --hostname 0.0.0.0 --port 3005",
"lint": "next lint"
},
"dependencies": {
"@openwork-ee/utils": "workspace:*",
"@openwork/ui": "workspace:*",
"@paper-design/shaders-react": "0.0.72",
"lucide-react": "^0.577.0",

View File

@@ -1 +1,2 @@
export * from "./typeid"
export * from "./skill-markdown"

View File

@@ -0,0 +1,109 @@
export type ParsedSkillMarkdown = {
name: string
description: string
body: string
hasFrontmatter: boolean
}
const SKILL_FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/
function normalizeSkillText(content: string): string {
return String(content ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n")
}
function normalizeFrontmatterValue(value: string | undefined): string {
const normalized = String(value ?? "").trim()
if (!normalized) {
return ""
}
if (normalized.startsWith('"') && normalized.endsWith('"')) {
try {
const parsed = JSON.parse(normalized)
return typeof parsed === "string" ? parsed.trim() : normalized
} catch {
return normalized.slice(1, -1).trim()
}
}
if (normalized.startsWith("'") && normalized.endsWith("'")) {
return normalized.slice(1, -1).trim()
}
return normalized
}
function parseFrontmatter(header: string): Record<string, string> {
const data: Record<string, string> = {}
for (const line of header.split("\n")) {
const trimmed = line.trim()
if (!trimmed) {
continue
}
const match = trimmed.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/)
if (!match) {
continue
}
const key = match[1]?.trim().toLowerCase()
if (!key) {
continue
}
data[key] = normalizeFrontmatterValue(match[2])
}
return data
}
export function yamlValue(value: string): string {
const normalized = String(value ?? "").trim()
if (/^[A-Za-z0-9._/\- ]+$/.test(normalized) && normalized && !normalized.includes(":")) {
return normalized
}
return JSON.stringify(normalized)
}
export function parseSkillMarkdown(content: string): ParsedSkillMarkdown {
const text = normalizeSkillText(content)
const match = text.match(SKILL_FRONTMATTER_PATTERN)
if (!match) {
return {
name: "",
description: "",
body: text,
hasFrontmatter: false,
}
}
const header = match[1] ?? ""
const data = parseFrontmatter(header)
return {
name: normalizeFrontmatterValue(data.name),
description: normalizeFrontmatterValue(data.description),
body: text.slice(match[0].length),
hasFrontmatter: true,
}
}
export function hasSkillFrontmatterName(content: string): boolean {
const parsed = parseSkillMarkdown(content)
return parsed.hasFrontmatter && Boolean(parsed.name.trim())
}
export function composeSkillMarkdown(name: string, description: string, body: string): string {
const normalizedName = String(name ?? "").trim()
const normalizedDescription = String(description ?? "").trim()
const normalizedBody = normalizeSkillText(body).trim()
const frontmatter = [
"---",
`name: ${yamlValue(normalizedName)}`,
...(normalizedDescription ? [`description: ${yamlValue(normalizedDescription)}`] : []),
"---",
].join("\n")
return normalizedBody ? `${frontmatter}\n\n${normalizedBody}\n` : `${frontmatter}\n`
}

View File

@@ -7,10 +7,14 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/
COPY .npmrc /app/.npmrc
COPY patches /app/patches
COPY packages/ui/package.json /app/packages/ui/package.json
COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json
COPY ee/apps/den-web/package.json /app/ee/apps/den-web/package.json
RUN pnpm install --frozen-lockfile --filter @openwork-ee/den-web...
COPY packages/ui /app/packages/ui
COPY ee/packages/utils /app/ee/packages/utils
COPY ee/apps/den-web /app/ee/apps/den-web
WORKDIR /app/ee/apps/den-web

3
pnpm-lock.yaml generated
View File

@@ -426,6 +426,9 @@ importers:
ee/apps/den-web:
dependencies:
'@openwork-ee/utils':
specifier: workspace:*
version: link:../../packages/utils
'@openwork/ui':
specifier: workspace:*
version: link:../../../packages/ui