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
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ jobs:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@v4
with:
Expand Down
10 changes: 9 additions & 1 deletion apps/studio/src/components/studio/studio-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ function StudioEditorBlock({
<div
className="flex cursor-pointer items-center gap-2 px-3 py-2"
onClick={() => onFocus(focused ? "" : block.uiId)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onFocus(focused ? "" : block.uiId);
}}
>
<button
{...attributes}
Expand Down Expand Up @@ -1056,7 +1059,12 @@ export function StudioApp(): React.JSX.Element {
{/* Split content */}
<div className="flex min-h-0 min-w-0 flex-1">
{/* Block editor */}
<div className={cn("flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden", sourceOpen && "border-r")}>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
sourceOpen && "border-r",
)}
>
<div className="flex items-center gap-2 border-b px-4 py-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@
}
},
"files": {
"ignore": ["node_modules", "dist", ".turbo", "coverage"]
"ignore": ["node_modules", "dist", ".turbo", "coverage", "**/.next/**"]
}
}
248 changes: 180 additions & 68 deletions models/examples/checkout.product.mdx

Large diffs are not rendered by default.

12 changes: 4 additions & 8 deletions packages/cli/src/commands/studio.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { defineCommand } from "citty";
import { spawn, exec } from "node:child_process";
import { createServer } from "node:net";
import { exec, spawn } from "node:child_process";
import { createRequire } from "node:module";
import { createServer } from "node:net";
import { dirname, join, resolve } from "node:path";
import { defineCommand } from "citty";

function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
Expand All @@ -17,11 +17,7 @@ function isPortFree(port: number): Promise<boolean> {

function openBrowser(url: string): void {
const cmd =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
exec(`${cmd} ${url}`);
}

Expand Down
24 changes: 19 additions & 5 deletions packages/core/src/grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,33 @@ import type { GrammarTable } from "./types/grammar.js";
/**
* Canonical grammar table defining allowed parent → children relationships.
*
* - Feature: top-level container. Can hold Section, Definition, Policy, Constraint, Link, Logic.
* - Section: grouping container (recursive). Same children as Feature minus Logic.
* - Feature: top-level container. Can hold all block types except nested Feature.
* Outcome is exclusive to Feature (not allowed in Section).
* - Section: grouping container (recursive). Same children as Feature minus Logic and Outcome.
* - Policy: can contain nested Logic blocks for detailed execution rules.
* - Definition, Constraint, Link, Logic: leaf blocks with no children.
* - Actor, Definition, Constraint, Link, Logic, Outcome, Scenario: leaf blocks with no children.
*/
export const GRAMMAR_TABLE: GrammarTable = {
Feature: ["Section", "Definition", "Policy", "Constraint", "Link", "Logic"],
Section: ["Section", "Definition", "Policy", "Constraint", "Link"],
Feature: [
"Section",
"Definition",
"Policy",
"Constraint",
"Link",
"Logic",
"Actor",
"Outcome",
"Scenario",
],
Section: ["Section", "Definition", "Policy", "Constraint", "Link", "Actor", "Scenario"],
Definition: [],
Policy: ["Logic"],
Constraint: [],
Link: [],
Logic: [],
Actor: [],
Outcome: [],
Scenario: [],
} as const;

/**
Expand Down
115 changes: 99 additions & 16 deletions packages/core/src/parser/mdx-to-pmast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,63 @@ import { PMDocumentSchema } from "../schema/blocks.js";
import type { Block, PMDocument } from "../types/ast.js";
import type { ExtractedBlock } from "./remark-product-model.js";

/**
* Coerce a value that may be a stringified number (from MDX expression attributes)
* into a JS number. Returns undefined if the value is not numeric.
*/
function coerceNumber(value: unknown): number | undefined {
if (typeof value === "number") return value;
if (typeof value === "string") {
const n = Number(value);
return Number.isNaN(n) ? undefined : n;
}
return undefined;
}

/**
* Transform native <Field /> attributes into a FieldSpec-compatible object.
*
* Handles:
* - `values="USD,EUR"` → `enumValues: ["USD","EUR"]` (comma-separated enum values)
* - `required` (boolean attribute, present = true)
* - `min={1}` / `max={99}` (JSX expression string → number coercion)
*/
function transformFieldAttrs(raw: Record<string, unknown>): Record<string, unknown> {
const field: Record<string, unknown> = { ...raw };

// Convert comma-separated `values` attribute → `enumValues` array
if (typeof field.values === "string") {
field.enumValues = field.values
.split(",")
.map((v) => v.trim())
.filter(Boolean);
field.values = undefined;
}

// Coerce numeric constraints
const min = coerceNumber(field.min);
if (min !== undefined) field.min = min;

const max = coerceNumber(field.max);
if (max !== undefined) field.max = max;

// `required` is a boolean attribute — already `true` when present without value.
// Handle the edge-case where it arrives as the string "false".
if (field.required === "false") field.required = false;

return field;
}

/**
* Parse a space-separated actor string (e.g. "guest member") into an array of IDs.
* Returns undefined if the value is absent or empty.
*/
function parseActorList(value: unknown): string[] | undefined {
if (typeof value !== "string") return undefined;
const ids = value.trim().split(/\s+/).filter(Boolean);
return ids.length > 0 ? ids : undefined;
}

/**
* Transform an extracted block into a typed Block.
* Handles nested children for Feature and Section blocks.
Expand All @@ -16,27 +73,53 @@ function transformBlock(extracted: ExtractedBlock): Record<string, unknown> {
block.content = extracted.content;
}

// Parse JSON-encoded fields attribute for Definition blocks
if (extracted.type === "Definition" && typeof block.fields === "string") {
try {
block.fields = JSON.parse(block.fields as string);
} catch {
// Leave as-is; Zod validation will catch the error
}
}
// ── Definition: resolve fields from native <Field /> children or legacy JSON ──
if (extracted.type === "Definition") {
const fieldChildren = block.fieldChildren as Record<string, unknown>[] | undefined;

// Parse JSON-encoded enumValues within fields
if (extracted.type === "Definition" && Array.isArray(block.fields)) {
block.fields = (block.fields as Record<string, unknown>[]).map((field) => {
if (typeof field.enumValues === "string") {
if (Array.isArray(fieldChildren) && fieldChildren.length > 0) {
// Native <Field /> children take priority
block.fields = fieldChildren.map(transformFieldAttrs);
block.fieldChildren = undefined;
} else {
// Legacy JSON-encoded fields attribute (deprecated but still supported)
if (typeof block.fields === "string") {
try {
return { ...field, enumValues: JSON.parse(field.enumValues as string) };
block.fields = JSON.parse(block.fields as string);
} catch {
return field;
// Leave as-is; Zod validation will catch the error
}
}
return field;
});

// Parse JSON-encoded enumValues within legacy fields
if (Array.isArray(block.fields)) {
block.fields = (block.fields as Record<string, unknown>[]).map((field) => {
if (typeof field.enumValues === "string") {
try {
return { ...field, enumValues: JSON.parse(field.enumValues as string) };
} catch {
return field;
}
}
return field;
});
}
}

// Always clean up leftover fieldChildren key
block.fieldChildren = undefined;
}

// ── Policy / Constraint: parse space-separated actor list → string[] ──
// Only transform when actor is a string; leave non-string values intact so
// Zod can surface a meaningful validation error rather than silently discarding them.
if (extracted.type === "Policy" || extracted.type === "Constraint") {
if (typeof block.actor === "string") {
const actorList = parseActorList(block.actor);
if (actorList !== undefined) {
block.actor = actorList;
}
}
}

if (extracted.children.length > 0) {
Expand Down
37 changes: 31 additions & 6 deletions packages/core/src/parser/remark-product-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,25 @@ function normalizeContent(raw: string): string | undefined {
return normalized.length > 0 ? normalized : undefined;
}

/**
* Extract attributes from a raw MdxJsxFlowElement into a plain record.
*/
function extractRawAttributes(node: MdxJsxFlowElement): Record<string, unknown> {
const attrs: Record<string, unknown> = {};
for (const attr of node.attributes) {
if ("name" in attr && typeof attr.name === "string") {
attrs[attr.name] = extractAttributeValue(attr);
}
}
return attrs;
}

/**
* Extract block data from an MDX JSX element and its children.
*
* Special handling for `<Field />` child elements inside `<Definition>`:
* they are collected into `attributes.fieldChildren` as raw attribute records,
* rather than being treated as child blocks.
*/
function extractBlock(node: MdxJsxFlowElement): ExtractedBlock | null {
const tagName = node.name;
Expand All @@ -77,17 +94,25 @@ function extractBlock(node: MdxJsxFlowElement): ExtractedBlock | null {
const result = BlockTypeSchema.safeParse(tagName);
if (!result.success) return null;

const attributes: Record<string, unknown> = {};
for (const attr of node.attributes) {
if ("name" in attr && typeof attr.name === "string") {
attributes[attr.name] = extractAttributeValue(attr);
}
}
const attributes: Record<string, unknown> = extractRawAttributes(node);

const children: ExtractedBlock[] = [];
const contentFragments: string[] = [];

for (const child of node.children) {
if (isMdxJsxFlowElement(child)) {
// Collect native <Field /> children into fieldChildren, but only for Definition blocks.
// Allowing <Field /> under other block types would silently inject `fieldChildren` into
// strict schemas (e.g. Feature) causing Zod parse failures.
if (tagName === "Definition" && child.name === "Field") {
const fieldAttrs = extractRawAttributes(child);
if (!Array.isArray(attributes.fieldChildren)) {
attributes.fieldChildren = [];
}
(attributes.fieldChildren as Record<string, unknown>[]).push(fieldAttrs);
continue;
}
Comment thread
pmTouchedTheCode marked this conversation as resolved.

const extracted = extractBlock(child);
if (extracted) {
children.push(extracted);
Expand Down
Loading
Loading