MolinoPro

20260422_concept-cards-MLV_

Master Codebase Guidebook
Markdown + HTML Dev-Docs Renderer - Frontend Client Module

Default Index
Open README.md
Root: README.mdarchive
Milestones
H113) Practical recommendation order

Phase 1

Create import slice and ImportedSource / ImportedExport

Phase 2

Create URL + markdown ingestion endpoint

Phase 3

Project imports into ConceptGroup + ConceptCard

Phase 4

Project cards into:

  • skill.md
  • human_card_90
  • human_card_40
  • human_card_11

Phase 5

Project session stacks into:

  • tutorial
  • guidebook
  • youtube script
  • live assist

Phase 6

Build markdown browser

H1External page

→ stored as ImportedSource → projected non-destructively into current entities → exported to md/script/session/assets → browsed in app Do not use this yet: External page → only markdown files → everything else derived ad hoc from local files

  1. Strongest fit to your codebase

Given the modules visible now:

  • concept-cards
  • concept-groups
  • session-stacks
  • session-paths
  • export roadmap already started

The right integration is:

imports = new intake boundary concept-cards/groups = domain persistence session-stacks = composition layer skill/session/script md = export layer markdown browser = read surface

H1Important implementation note

For your deployment reality, prefer:

  • DB for authoritative imported/exported content
  • optional filesystem mirror for browser/demo/static use

not runtime local-file writes as the only stored source.

If you want, next step can be a concrete Phase 1 code package:

  • Prisma models
  • createImportFromRequest.ts
  • app/(pages)/imports/api/route.ts
  • normalizeImportedContent.ts
  • initial projectImportToConcepts.ts skeleton
H1FOLDER STRUCTURE

app/ (pages)/ imports/ page.tsx [id]/ page.tsx actions/ createImportFromUrl.ts createImportFromMarkdown.ts processImport.ts projectImportToConcepts.ts projectImportToSkillMd.ts projectImportToAssets.ts projectImportToSessionStack.ts api/ route.ts components/ ImportForm.tsx ImportResultView.tsx types/ imports.types.ts lib/ normalizeImportedContent.ts extractImportBlocks.ts deriveCardsFromImport.ts deriveSkillDocuments.ts deriveSessionStackScript.ts

lib/ export/ renderSkillMd.ts renderSessionMd.ts renderScriptMd.ts renderCardAssetMd.ts

##Prisma addition



model ImportedSource {

id String @id @default(cuid()) sourceType String // "url" | "markdown" | "html" | "manual" sourceRef String? // url or external reference title String? slug String? rawMarkdown String? rawHtml String? rawText String? normalizedMd String? renderedHtml String? status String @default("pending") // pending | processed | failed notes String? meta Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt

conceptGroups ImportedConceptGroupLink[] conceptCards ImportedConceptCardLink[] exports ImportedExport[] }

model ImportedConceptGroupLink { id String @id @default(cuid()) importedSourceId String conceptGroupId String role String? // "primary" | "derived"

importedSource ImportedSource @relation(fields: [importedSourceId], references: [id], onDelete: Cascade) conceptGroup ConceptGroup @relation(fields: [conceptGroupId], references: [id], onDelete: Cascade)

@@unique([importedSourceId, conceptGroupId]) }

model ImportedConceptCardLink { id String @id @default(cuid()) importedSourceId String conceptCardId String role String? // "primary" | "derived"

importedSource ImportedSource @relation(fields: [importedSourceId], references: [id], onDelete: Cascade) conceptCard ConceptCard @relation(fields: [conceptCardId], references: [id], onDelete: Cascade)

@@unique([importedSourceId, conceptCardId]) }

model ImportedExport { id String @id @default(cuid()) importedSourceId String exportType String // "skill_md" | "session_md" | "script_md" | "html" | "card_asset" title String? slug String body String renderedHtml String? meta Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt

importedSource ImportedSource @relation(fields: [importedSourceId], references: [id], onDelete: Cascade)

@@index([importedSourceId, exportType]) }

H1API ROUTE app/(pages)/imports/api/route.ts

type ImportRequest = | { mode: "url"; url: string; output?: Array<"concepts" | "skill_md" | "session_md" | "script_md" | "html" | "assets">; title?: string; groupTitle?: string; } | { mode: "markdown"; markdown: string; title?: string; output?: Array<"concepts" | "skill_md" | "session_md" | "script_md" | "html" | "assets">; groupTitle?: string; } | { mode: "html"; html: string; title?: string; output?: Array<"concepts" | "skill_md" | "session_md" | "script_md" | "html" | "assets">; groupTitle?: string; };

// app/(pages)/imports/api/route.ts import { NextRequest, NextResponse } from "next/server"; import { createImportFromRequest } from "../actions/createImportFromRequest";

export async function POST(req: NextRequest) { try { const body = await req.json(); const result = await createImportFromRequest(body);

return NextResponse.json(result, { status: 200 });

} catch (error) { return NextResponse.json( { ok: false, error: error instanceof Error ? error.message : "Import failed", }, { status: 400 } ); } }

H1SERVER ACTION

"use server";

import { z } from "zod"; import { prisma } from "@/lib/prisma"; import { fetchUrlAsNormalizedContent } from "../lib/fetchUrlAsNormalizedContent"; import { normalizeImportedContent } from "../lib/normalizeImportedContent"; import { processImport } from "./processImport";

const ImportSchema = z.object({ mode: z.enum(["url", "markdown", "html"]), url: z.string().url().optional(), markdown: z.string().optional(), html: z.string().optional(), title: z.string().optional(), groupTitle: z.string().optional(), output: z .array(z.enum(["concepts", "skill_md", "session_md", "script_md", "html", "assets"])) .optional(), });

export async function createImportFromRequest(input: unknown) { const data = ImportSchema.parse(input);

let title = data.title ?? null; let rawMarkdown: string | null = null; let rawHtml: string | null = null; let rawText: string | null = null; let sourceRef: string | null = null;

if (data.mode === "url") { const fetched = await fetchUrlAsNormalizedContent(data.url!); title = title ?? fetched.title ?? null; rawMarkdown = fetched.markdown ?? null; rawHtml = fetched.html ?? null; rawText = fetched.text ?? null; sourceRef = data.url!; }

if (data.mode === "markdown") { rawMarkdown = data.markdown ?? null; rawText = data.markdown ?? null; }

if (data.mode === "html") { rawHtml = data.html ?? null; rawText = data.html ?? null; }

const normalized = await normalizeImportedContent({ title, rawMarkdown, rawHtml, rawText, });

const imported = await prisma.importedSource.create({ data: { sourceType: data.mode, sourceRef, title: normalized.title, slug: normalized.slug, rawMarkdown, rawHtml, rawText, normalizedMd: normalized.markdown, renderedHtml: normalized.renderedHtml, meta: { requestedOutputs: data.output ?? ["concepts", "skill_md"], groupTitle: data.groupTitle ?? null, }, status: "pending", }, });

const processed = await processImport({ importedSourceId: imported.id, requestedOutputs: data.output ?? ["concepts", "skill_md"], groupTitle: data.groupTitle ?? null, });

return { ok: true, importedSourceId: imported.id, ...processed, }; }

H15) Processing pipeline

"use server";

import { prisma } from "@/lib/prisma"; import { projectImportToConcepts } from "./projectImportToConcepts"; import { projectImportToSkillMd } from "./projectImportToSkillMd"; import { projectImportToSessionStack } from "./projectImportToSessionStack"; import { projectImportToAssets } from "./projectImportToAssets";

type RequestedOutput = | "concepts" | "skill_md" | "session_md" | "script_md" | "html" | "assets";

export async function processImport({ importedSourceId, requestedOutputs, groupTitle, }: { importedSourceId: string; requestedOutputs: RequestedOutput[]; groupTitle?: string | null; }) { const created: Record<string, unknown> = {};

if (requestedOutputs.includes("concepts")) { created.concepts = await projectImportToConcepts({ importedSourceId, groupTitle, }); }

if ( requestedOutputs.includes("skill_md") || requestedOutputs.includes("session_md") || requestedOutputs.includes("script_md") || requestedOutputs.includes("html") ) { created.documents = await projectImportToSkillMd({ importedSourceId, requestedOutputs, }); }

if (requestedOutputs.includes("assets")) { created.assets = await projectImportToAssets({ importedSourceId, }); }

await prisma.importedSource.update({ where: { id: importedSourceId }, data: { status: "processed" }, });

return created; }

H16) Concept projection

"use server";

import { prisma } from "@/lib/prisma"; import { deriveCardsFromImport } from "../lib/deriveCardsFromImport";

export async function projectImportToConcepts({ importedSourceId, groupTitle, }: { importedSourceId: string; groupTitle?: string | null; }) { const imported = await prisma.importedSource.findUniqueOrThrow({ where: { id: importedSourceId }, });

const derived = deriveCardsFromImport({ title: imported.title ?? "Imported Source", markdown: imported.normalizedMd ?? "", meta: imported.meta, });

const group = await prisma.conceptGroup.create({ data: { title: groupTitle ?? derived.groupTitle, summary: derived.groupSummary ?? null, }, });

await prisma.importedConceptGroupLink.create({ data: { importedSourceId, conceptGroupId: group.id, role: "primary", }, });

const cards = [];

for (const card of derived.cards) { const createdCard = await prisma.conceptCard.create({ data: { title: card.title, hook: card.hook, promise: card.promise, boundaries: card.boundaries, durationMin: card.durationMin, difficulty: card.difficulty, domains: card.domains, intro: card.intro, notes: card.notes, }, });

await prisma.cardContent.create({
  data: {
    conceptCardId: createdCard.id,
    steps: card.steps,
    materials: card.materials,
    structure: card.structure ?? {},
    version: 1,
    language: "en",
  },
});

await prisma.conceptGroupItem.create({
  data: {
    conceptGroupId: group.id,
    conceptCardId: createdCard.id,
  },
});

await prisma.importedConceptCardLink.create({
  data: {
    importedSourceId,
    conceptCardId: createdCard.id,
    role: "primary",
  },
});

cards.push(createdCard);

}

return { groupId: group.id, cardIds: cards.map((c) => c.id), cardCount: cards.length, }; }

H1SILL.md DOCS

This is where your three concerns converge.

Concern A

Generate skill md documents for system / AI assistant instructions

Concern B

Generate human-readable action cards at multiple time-depths: 90, 40, 11

Concern C

Use session stacks as stackable scripts/tutorials/guidebooks/video scripts

All three should be projections from either:

  • imported source
  • existing concept card group
  • existing session stack

Never manually fork content.

H17.1 Multi-depth deliverables

export type DeliveryDepth = "full_90" | "medium_40" | "micro_11";

Then each concept card projection can emit:

  • skill.md → assistant/system instruction version
  • card-90.md → complete tutorial / session card
  • card-40.md → compressed workshop / focused practical
  • card-11.md → very short action card / quick guide

EXAMPLE -> skill_md human_card_90 human_card_40 human_card_11 session_md script_md html

H1projectImportToSkillMd.ts

"use server";

import { prisma } from "@/lib/prisma"; import { renderSkillMd } from "@/lib/export/renderSkillMd"; import { renderSessionMd } from "@/lib/export/renderSessionMd"; import { renderScriptMd } from "@/lib/export/renderScriptMd"; import { renderHumanCardMd } from "@/lib/export/renderHumanCardMd"; import { toExportableCard } from "@/lib/export/toExportableCard"; import { slugify } from "@/lib/slugify";

export async function projectImportToSkillMd({ importedSourceId, requestedOutputs, }: { importedSourceId: string; requestedOutputs: Array<"skill_md" | "session_md" | "script_md" | "html" | "assets" | "concepts">; }) { const links = await prisma.importedConceptCardLink.findMany({ where: { importedSourceId }, include: { conceptCard: { include: { contents: true, conceptGroupItems: { include: { conceptGroup: { include: { conceptGroupItems: { include: { conceptCard: true }, }, }, }, }, }, }, }, }, });

const exportsCreated = [];

for (const link of links) { const card = toExportableCard(link.conceptCard);

if (requestedOutputs.includes("skill_md")) {
  exportsCreated.push(
    await prisma.importedExport.create({
      data: {
        importedSourceId,
        exportType: "skill_md",
        title: card.title,
        slug: `${slugify(card.title)}.skill`,
        body: renderSkillMd(card),
      },
    })
  );
}

if (requestedOutputs.includes("session_md")) {
  exportsCreated.push(
    await prisma.importedExport.create({
      data: {
        importedSourceId,
        exportType: "session_md",
        title: `${card.title} Session`,
        slug: `${slugify(card.title)}.session`,
        body: renderSessionMd(card),
      },
    })
  );
}

if (requestedOutputs.includes("script_md")) {
  exportsCreated.push(
    await prisma.importedExport.create({
      data: {
        importedSourceId,
        exportType: "script_md",
        title: `${card.title} Script`,
        slug: `${slugify(card.title)}.script`,
        body: renderScriptMd(card),
      },
    })
  );
}

exportsCreated.push(
  await prisma.importedExport.create({
    data: {
      importedSourceId,
      exportType: "human_card_90",
      title: `${card.title} 90`,
      slug: `${slugify(card.title)}.90`,
      body: renderHumanCardMd(card, "full_90"),
    },
  })
);

exportsCreated.push(
  await prisma.importedExport.create({
    data: {
      importedSourceId,
      exportType: "human_card_40",
      title: `${card.title} 40`,
      slug: `${slugify(card.title)}.40`,
      body: renderHumanCardMd(card, "medium_40"),
    },
  })
);

exportsCreated.push(
  await prisma.importedExport.create({
    data: {
      importedSourceId,
      exportType: "human_card_11",
      title: `${card.title} 11`,
      slug: `${slugify(card.title)}.11`,
      body: renderHumanCardMd(card, "micro_11"),
    },
  })
);

}

return { count: exportsCreated.length, exportIds: exportsCreated.map((item) => item.id), }; }

H18) Session stacks as real scripts

This should not be separate authoring. It should compose card exports in order. SessionStack → ordered concept groups → ordered concept cards → each card contributes:

  • opener
  • explanation
  • steps
  • prompts
  • materials → combined into:
  • tutorial.md
  • guidebook.md
  • live-assistance.md
  • youtube-script.md

Good export families for session stacks

  • tutorial.md
  • process.md
  • guidebook.md
  • developer-assist.md
  • user-assist.md
  • youtube-script.md

All of those are just formatting profiles over the same ordered stack content.

So for your concern, yes:

session stacks can become the canonical script-composition layer.

Not just display lists.

H19) Filesystem markdown browser

This should be read-only and secondary.

Purpose

  • inspect generated md
  • share results inside the app
  • create fast navigable documentation
  • avoid external platforms

Recommended source

Do not read raw local files first.

Read from ImportedExport first, then optionally mirror to filesystem later.

Safer order DB export record → optional write to /content/generated/skills/*.md → browser reads DB or mirrored files

H110) If you still want md-first very early

markdown file / pasted markdown / fetched page → ImportedSource.normalizedMd → projections

That gives you md-first without forcing all other models to depend on file parsing.

So the early-introduced md structure should be:

  • intake format
  • projection format
  • inspection format

But not yet your only domain truth.

That is the safe compromise.

H111) Suggested routes

External ingest POST /imports/api

VIEWS /imports /imports/[id] /skills /skills/[slug] /scripts /scripts/[slug] /sessions/[slug]

H112) Recommended processing profiles

type ProjectionProfile = | "assistant_skill" | "human_card_90" | "human_card_40" | "human_card_11" | "tutorial" | "process_doc" | "live_assist" | "guidebook" | "youtube_script";

H1ROADMAP — skill.md export layer

Version: V0.2 (codebase-grounded) Replaces: ROADMAP.skill-export.md V0.1 (abstract) Status: Planning — ready to implement


H2What this document is

A grounded implementation plan derived from reading the actual codebase. Every file path, type, and action name in this document maps to something that already exists. The goal is to add skill.md export capability — and optionally script.md / session.md — without touching a single existing file.


H2What already exists (the real picture)
H3Data hierarchy (confirmed from Prisma queries in codebase)
SessionPath
  └─ SessionPathItem[]          (session-paths/actions/sessionPathActions.ts)
       └─ SessionStack          (session-stacks/actions/getSessionStack.ts)
            └─ SessionConceptGroup[]
                 └─ ConceptGroup
                      └─ ConceptGroupItem[]
                           └─ ConceptCard
                                └─ CardContent[]  (steps[], materials[])
H3What the DB gives you per card (confirmed from getSessionCard.ts)
{
  id, title, hook, promise, boundaries,
  durationMin, difficulty, domains,
  intro, notes,
  contents: [{
    steps: JsonValue,     // JsonArray — use jsonArrayToStrings() to parse
    materials: JsonValue,
    version, language, structure
  }],
  conceptGroupItems: [{
    conceptGroup: {
      title, summary,
      sessionConceptGroups: [{ sessionStack }]
    }
  }]
}
H3What already has a renderer (card-engine.js)

card-engine.js already has renderPoster(), renderStudyCard() etc. as HTML renderers. The new export layer is the markdown equivalent of what card-engine.js does for HTML. They are parallel, not competing.

H3What already exists in session-stacks/skills.json

A flat JSON array of concept definitions with: id, title, category, specialty, summary, durationMinutes, priceEUR, location. These are the source concepts — the human-authored canonical list. Cards in the DB are generated from / linked to these.

H3The `jsonArrayToStrings()` utility

Lives at @/lib/json. Already used throughout. Must be used in the export layer whenever reading steps or materials from a CardContent record — these are stored as Prisma Json fields, not plain string[].


H2What the skill.md template requires (from TEMPLATE.skill.md)

Each output file needs these sections populated from DB fields:

frontmatter:
  id          ← slugified card.title
  title       ← card.title
  category    ← card.domains[0] or conceptGroup.title
  realm       ← "both" (default)
  source      ← conceptGroup.title or skills.json specialty
  tags        ← card.domains (parsed via jsonArrayToStrings)
  difficulty  ← card.difficulty
  frequency   ← "daily" | "situational" (inferred from durationMin)
  version     ← "1.0"

body sections:
  tagline       ← card.hook
  What it is    ← card.promise
  Why it works  ← card.boundaries (repurposed — this field holds constraint/rationale text)
  Steps         ← content.steps (via jsonArrayToStrings)
  Examples      ← generated from content.structure.collapsedFrom (if present)
  When to use   ← content.materials (these often describe context, not just tools)
  Prompt starter  ← generated template using card.title + card.domains
  Related       ← other cards in same ConceptGroup

Key mapping note: card.boundaries is the right source for "Why it works" — it holds the constraint/rationale text authored at creation time. card.intro and card.notes are freeform editorial fields — do not map these to fixed template sections; include them only if present as supplementary content.


H2What does NOT need to change
  • Zero changes to Prisma schema
  • Zero changes to generateSessionCardsFromText.ts
  • Zero changes to createConceptCardManual.ts
  • Zero changes to getSessionCard.ts, getSessionStack.ts, or any existing action
  • Zero changes to any existing page or component
  • card-engine.js stays as-is (HTML renderer, parallel not replaced)

H2New directory structure
app/
  (pages)/
    concept-cards/
      actions/
        export/
          exportConceptCard.ts        ← Phase 2
          exportConceptGroup.ts       ← Phase 3
          exportSessionStack.ts       ← Phase 3
      components/
        ExportButton.tsx              ← Phase 4
lib/
  export/
    types.ts                          ← Phase 1
    renderers/
      renderSkillMd.ts                ← Phase 1 (primary)
      renderScriptMd.ts               ← Phase 3 (optional)
      renderSessionMd.ts              ← Phase 3 (optional)
    adapters/
      toExportableCard.ts             ← Phase 1
      toExportableGroup.ts            ← Phase 2
  context/
    routeSkillMap.ts                  ← Phase 5
    loadSkillContext.ts               ← Phase 5
skills/
  _index.json                         ← Phase 5 (generated)
  skill-[slug].md                     ← Phase 4 (exported, committed)

H2Phase 1 — Core types + single card renderer

Goal: Given one card's data, produce a valid skill.md string. Risk: Zero. Pure functions. No DB. No routes.

H31.1 — Define `ExportableCard` type
// lib/export/types.ts

export type ExportableCard = {
  id: string
  title: string
  hook: string | null
  promise: string | null
  boundaries: string | null      // maps to "Why it works"
  difficulty: string | null
  domains: string[]              // already parsed strings
  durationMin: number
  steps: string[]                // already parsed via jsonArrayToStrings
  materials: string[]            // already parsed via jsonArrayToStrings
  intro: string | null           // freeform — included if present
  notes: string | null           // freeform — included if present
  groupTitle: string | null      // from conceptGroupItems[0].conceptGroup.title
  groupSummary: string | null
  siblingCardTitles: string[]    // other cards in same group → related skills
}

export type ExportableGroup = {
  id: string
  title: string
  summary: string | null
  cards: ExportableCard[]
}

export type ExportFormat = "skill" | "script" | "session"
H31.2 — Build `toExportableCard` adapter

This takes the raw Prisma result from getSessionCard() and shapes it into ExportableCard. This is the only place jsonArrayToStrings is called in the export layer.

// lib/export/adapters/toExportableCard.ts
import { jsonArrayToStrings } from "@/lib/json"

export function toExportableCard(raw: any): ExportableCard {
  const content = raw.contents?.[0] ?? null
  const groupItem = raw.conceptGroupItems?.[0] ?? null
  const group = groupItem?.conceptGroup ?? null

  const siblingCardTitles = group?.conceptGroupItems
    ?.map((item: any) => item.conceptCard?.title)
    ?.filter((t: string) => t && t !== raw.title)
    ?? []

  return {
    id: raw.id,
    title: raw.title,
    hook: raw.hook ?? null,
    promise: raw.promise ?? null,
    boundaries: raw.boundaries ?? null,
    difficulty: raw.difficulty ?? null,
    domains: jsonArrayToStrings(raw.domains ?? []),
    durationMin: raw.durationMin ?? 90,
    steps: content ? jsonArrayToStrings(content.steps) : [],
    materials: content ? jsonArrayToStrings(content.materials) : [],
    intro: raw.intro ?? null,
    notes: raw.notes ?? null,
    groupTitle: group?.title ?? null,
    groupSummary: group?.summary ?? null,
    siblingCardTitles,
  }
}
H31.3 — Build `renderSkillMd` renderer

Pure function. Input: ExportableCard. Output: markdown string matching TEMPLATE.skill.md.

// lib/export/renderers/renderSkillMd.ts

export function renderSkillMd(card: ExportableCard): string {
  const slug = slugify(card.title)
  const category = card.domains[0] ?? card.groupTitle ?? "General"
  const tags = card.domains.length > 0
    ? card.domains.join(", ")
    : "habits, practice"
  const frequency = card.durationMin <= 15 ? "daily" : "situational"

  const relatedSkills = card.siblingCardTitles
    .map(t => `- [\`skill-${slugify(t)}\`] — part of the same concept group`)
    .join("\n")

  return `---
id: skill-${slug}
title: "${card.title}"
category: "${category}"
realm: both
source: "${card.groupTitle ?? "original concept"}"
tags: [${tags}]
difficulty: ${card.difficulty ?? "beginner"}
frequency: ${frequency}
version: "1.0"
---

# ${card.title}

> ${card.hook ?? card.promise ?? "A practical skill for immediate use."}

## What it is

${card.promise ?? ""}${card.intro ? "\n\n" + card.intro : ""}

## Why it works

${card.boundaries ?? "This skill works because it is grounded in direct practice and repeatable execution."}

## Steps

${card.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}

## When to use

${card.materials.length > 0
  ? card.materials.map(m => `- ${m}`).join("\n")
  : "- When you want to apply this concept in a real context\n- As part of a structured session or self-directed practice"}

## Prompt starter (copy-ready)

> Apply the **${card.title}** skill to the following situation:
>
> [CONTEXT]
>
> Using the steps above, help me [GOAL]. Be specific, practical, and brief.

${relatedSkills ? `## Related skills\n\n${relatedSkills}\n` : ""}
${card.notes ? `## Notes\n\n${card.notes}\n` : ""}
---

_Source: ${card.groupTitle ?? card.title} — exported from ConceptCard \`${card.id}\`_
`
}

function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)/g, "")
}

Test this phase with a hardcoded ExportableCard fixture before moving to Phase 2. The output should open cleanly in any markdown viewer and match the TEMPLATE.skill.md structure.


H2Phase 2 — Export server action (single card)

Goal: Wire the adapter + renderer to a server action callable from the UI. Risk: Zero. Read-only Prisma. New file only.

// app/(pages)/concept-cards/actions/export/exportConceptCard.ts
"use server"

import { getSessionCard } from "../getSessionCard"
import { toExportableCard } from "@/lib/export/adapters/toExportableCard"
import { renderSkillMd } from "@/lib/export/renderers/renderSkillMd"
import type { ExportFormat } from "@/lib/export/types"

export async function exportConceptCard(
  cardId: string,
  format: ExportFormat = "skill"
): Promise<{ markdown: string; filename: string }> {
  const raw = await getSessionCard(cardId)
  if (!raw) throw new Error(`ConceptCard not found: ${cardId}`)

  const card = toExportableCard(raw)
  const slug = card.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")

  let markdown: string
  let filename: string

  switch (format) {
    case "skill":
    default:
      markdown = renderSkillMd(card)
      filename = `skill-${slug}.md`
  }

  return { markdown, filename }
}

H2Phase 3 — Group-level export (script.md / session.md)

Goal: Export an entire ConceptGroup or SessionStack as a single document. Risk: Low. Still read-only.

H33.1 — `toExportableGroup` adapter

Fetches a ConceptGroup with all its cards and shapes into ExportableGroup. Uses getConceptGroups or a dedicated getConceptGroupWithCards query (new, read-only).

H33.2 — `renderScriptMd` renderer

Produces a YouTube / study script from a group:

# [Group title]

## Hook
[first card's hook]

## Introduction
[group summary]

---

## [Card 1 title]
[steps as numbered prose]

## [Card 2 title]
...

---

## Summary + CTA
H33.3 — `renderSessionMd` renderer

Produces a 90-minute coach script from a group, following SessionCard MLV format:

  • Hook (≤70 chars)
  • Promise (≤120 chars)
  • Step-by-step flow (3–7 items, merged across cards)
  • Materials list
  • Memory aid placeholder
H33.4 — `exportConceptGroup` server action

Same pattern as exportConceptCard but accepts groupId and returns a multi-card document in the requested format.


H2Phase 4 — Export UI (additive only)

Goal: Surface the export action in the existing card detail page. Where: concept-cards/[id]/page.tsx — add below the existing QR section. Risk: Low. Additive UI. No layout changes.

H3Client-side download helper (no new route needed)
// inline in ExportButton.tsx
async function handleExport(cardId: string, format: ExportFormat) {
  const { markdown, filename } = await exportConceptCard(cardId, format)
  const blob = new Blob([markdown], { type: "text/markdown" })
  const url = URL.createObjectURL(blob)
  const a = document.createElement("a")
  a.href = url
  a.download = filename
  a.click()
  URL.revokeObjectURL(url)
}
H3What the button looks like in the page

Add inside the existing SessionCardEditorGate (editor-only, not public):

<ExportButton cardId={card.id} format="skill" label="Export as skill.md" />
<ExportButton cardId={card.id} format="script" label="Export as script.md" />

For group-level export, add to concept-groups/[groupId]/page.tsx the same way.


H2Phase 5 — Context injection for LLM actions

Goal: Make generateSessionCardsFromText (and future AI actions) route-aware by loading relevant skill.md files as system context. Risk: Low. One extra await per action. Fully reversible.

H35.1 — Commit exported skills to `/skills/` directory

After Phase 4, export your key concept cards and commit the .md files:

/skills/
  skill-ipad-creative-workflow.md
  skill-gtd-kanban-mastery.md
  skill-pkm-journaling-archives.md
  skill-apps-script-processes.md
  ... (one per entry in skills.json)
  _index.json

_index.json is a flat map: { [slug]: { title, category, tags[], path } } Generated by a one-time script (or manually at first — it's small).

H35.2 — `loadSkillContext` function
// lib/context/loadSkillContext.ts
import fs from "fs/promises"
import path from "path"

export async function loadSkillContext(
  slugs: string[],
  maxChars = 6000
): Promise<string> {
  const skillsDir = path.join(process.cwd(), "skills")
  const chunks: string[] = []
  let total = 0

  for (const slug of slugs) {
    if (total >= maxChars) break
    try {
      const content = await fs.readFile(
        path.join(skillsDir, `${slug}.md`), "utf8"
      )
      chunks.push(content)
      total += content.length
    } catch {
      // file not found — skip silently
    }
  }

  return chunks.join("\n\n---\n\n")
}
H35.3 — Route skill map
// lib/context/routeSkillMap.ts

export const routeSkillMap: Record<string, string[]> = {
  "/concept-cards":       ["skill-gtd-kanban-mastery", "skill-pkm-journaling-archives"],
  "/concept-cards/new":   ["skill-ipad-creative-workflow"],
  "/concept-groups":      ["skill-gtd-kanban-mastery"],
  "/session-stacks":      ["skill-apps-script-processes"],
  "/session-paths":       [],
}

Add slugs as you export and commit more cards. This is the only ongoing maintenance.

H35.4 — Wire into `generateSessionCardsFromText`

Minimal change to the existing action — add 3 lines before the OpenAI call:

// in generateSessionCardsFromText.ts — add before openai.chat.completions.create()

import { loadSkillContext } from "@/lib/context/loadSkillContext"
import { routeSkillMap } from "@/lib/context/routeSkillMap"

// inside the function, before the API call:
const skillSlugs = routeSkillMap[input.contextKey ?? "/concept-cards"] ?? []
const skillContext = skillSlugs.length > 0
  ? await loadSkillContext(skillSlugs)
  : ""

// then update the system message:
{
  role: "system",
  content: skillContext
    ? `You are a precise cognitive knowledge architect.\n\nActive skill context:\n---\n${skillContext}\n---`
    : "You are a precise cognitive knowledge architect.",
}

The contextKey field already exists in GenerateSessionCardsInput as an optional field. It is not yet used — this wires it up for the first time.


H2Phase 6 — Claude API (optional swap, when ready)

The context injection pattern is model-agnostic. When switching from OpenAI to Claude:

// replace the fetch call in generateSessionCardsFromText.ts

const response = await fetch("https://api.anthropic.com/v1/messages", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "claude-sonnet-4-20250514",
    max_tokens: 1000,
    system: skillContext
      ? `You are a precise cognitive knowledge architect.\n\nActive skill context:\n---\n${skillContext}\n---`
      : "You are a precise cognitive knowledge architect.",
    messages: [{ role: "user", content: buildSessionCardPrompt(text) }],
  }),
})
const data = await response.json()
const output = data.content?.find((b: any) => b.type === "text")?.text ?? ""

Everything else stays identical. The buildSessionCardPrompt function is unchanged. The safeParseJson / normalizeSessionCardPayload helpers are unchanged.


H2Delivery order
PhaseWhatNew filesTouches existing?
1Types + toExportableCard + renderSkillMd3No
2exportConceptCard server action1No
3Group-level adapters + renderers3–4No
4ExportButton UI component1Add 2 lines to existing pages
5/skills/ directory + context loader + route map3 + .md filesAdd 3 lines to 1 existing action
6Claude API swap0Replace 1 function body

H2The first thing to build (right now)

lib/export/renderers/renderSkillMd.ts

Write it. Test it with this hardcoded fixture:

const fixture: ExportableCard = {
  id: "abc123",
  title: "GTD & Kanban Mastery",
  hook: "Stop managing tasks. Start managing flow.",
  promise: "Design a personal productivity system that actually sticks.",
  boundaries: "Works because it externalises cognitive load and makes priorities visible at a glance.",
  difficulty: "intermediate",
  domains: ["productivity", "systems", "habits"],
  durationMin: 90,
  steps: [
    "Capture everything into a single trusted inbox",
    "Process each item: do, defer, delegate, or delete",
    "Assign to a Kanban column: backlog, this week, today, done",
    "Review the board every morning for 5 minutes",
    "Do a weekly review to clear and reset",
  ],
  materials: ["Notion or physical board", "Weekly review template", "Timer"],
  intro: null,
  notes: null,
  groupTitle: "Productivity Systems",
  groupSummary: null,
  siblingCardTitles: ["Notes, Archives & Journaling", "PDF & Paper Hybrid Systems"],
}

console.log(renderSkillMd(fixture))

The output should open cleanly as markdown and match TEMPLATE.skill.md section for section. When it does, Phase 1 is done.


ROADMAP V0.2 — grounded in codebase read on 2026-04-20 Replaces the abstract V0.1 written before code inspection Next step: implement lib/export/renderers/renderSkillMd.ts with the fixture above