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
1 change: 1 addition & 0 deletions .github/workflows/ci-packages.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- "packages/**"
- "examples/**"
- "docs/**"
- "catalogue/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
Expand Down
17 changes: 17 additions & 0 deletions catalogue/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# app-shell-catalogue

UI pattern catalogue for `@tailor-platform/app-shell`. Contains reference implementations and generates skill documentation for AI coding agents.

## Structure

- `src/fundamental/` — Foundational references (components, design system, GraphQL)
- `src/pattern/` — UI pattern implementations (list, detail, form, interaction)
- `scripts/` — Generation tooling

## Generate Skills

```bash
pnpm build
```

Outputs skill files to `packages/core/skills/app-shell-patterns/` for distribution via npm.
20 changes: 20 additions & 0 deletions catalogue/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "app-shell-catalogue",
"private": true,
"type": "module",
"scripts": {
"build": "node scripts/generate-skill.mjs",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@tailor-platform/app-shell": "workspace:*",
"gray-matter": "4.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
}
}
36 changes: 36 additions & 0 deletions catalogue/scripts/SKILL.template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
name: app-shell-patterns
description: UI pattern catalog for building pages with @tailor-platform/app-shell components
---

# App-Shell Patterns

## Purpose

Select and implement the correct UI pattern using @tailor-platform/app-shell components.

## Fundamental References

These are the foundational rules that underpin all patterns. All patterns build on top of these references.

{{FUNDAMENTAL_TABLE}}

## Available Patterns

{{PATTERNS_TABLE}}

## How to Use

1. Identify the user's intent (list, detail, form, interaction, screen composition, recipe)
2. Match constraints to an entry slug from the tables above
3. Read the entry's detailed spec: `references/<category>/<slug>.md` (relative to this file)
4. Read fundamental references for component APIs, design tokens, and GraphQL conventions: `references/fundamental/`
5. Implement using ONLY the imports listed in the entry's `requiredImports`

## Rules

- ALWAYS cite the entry slug in a comment at the top of the file:
`/* pattern: list/dense-scan */`
- NEVER mix patterns in a single page component
- ALWAYS use AppShell components — do NOT use raw HTML or third-party UI libraries
- If no entry matches, compose directly from fundamental references
276 changes: 276 additions & 0 deletions catalogue/scripts/generate-skill.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
/**
* generate-skill.mjs
*
* Reads catalogue entry files, resolves <!-- source: file.tsx --> markers
* by embedding the referenced file content as fenced code blocks,
* and outputs:
* - skills/app-shell-patterns/SKILL.md (index table)
* - skills/app-shell-patterns/references/<category>/<slug>.md (per-entry docs)
*/

import { readdir, readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import matter from "gray-matter";

const __dirname = fileURLToPath(new URL(".", import.meta.url));
const catalogueRoot = join(__dirname, "..");
const repoRoot = join(catalogueRoot, "..");
const skillsDir = join(repoRoot, "packages", "core", "skills", "app-shell-patterns");
const referencesDir = join(skillsDir, "references");

/**
* Category definitions. To add a new category, append an entry here
* and add a corresponding {{<templateKey>}} placeholder to SKILL.template.md.
*
* - entryFile: marker filename to search recursively (e.g. "PATTERN.md").
* If null, all .md files in the category directory are copied as-is.
*/
const CATEGORIES = [
{
name: "fundamental",
entryFile: null,
outputDir: "fundamental",
templateKey: "FUNDAMENTAL_TABLE",
},
{
name: "pattern",
entryFile: "PATTERN.md",
outputDir: "patterns",
templateKey: "PATTERNS_TABLE",
},
];

/**
* Resolve <!-- source: file.tsx --> markers in markdown body.
* Reads the referenced file relative to the PATTERN.md directory
* and replaces the marker with a fenced code block.
*/
async function resolveSourceMarkers(body, patternDir) {
const sourceRegex = /<!--\s*source:\s*(.+?)\s*-->/g;
let result = body;
let match;

// Collect all matches first to avoid issues with async replacement
const replacements = [];
while ((match = sourceRegex.exec(body)) !== null) {
const filename = match[1];
const filePath = join(patternDir, filename);
try {
const content = await readFile(filePath, "utf-8");
const ext = filename.split(".").pop();
replacements.push({
original: match[0],
replacement: `\`\`\`${ext}\n${content.trim()}\n\`\`\``,
});
} catch (err) {
console.warn(`Warning: Could not read ${filePath}: ${err.message}`);
}
}

for (const { original, replacement } of replacements) {
result = result.replace(original, replacement);
}

return result;
}

/**
* Recursively find files matching the given filename in a directory tree.
*/
async function findEntryFiles(dir, filename) {
const results = [];
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return results;
}

for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...(await findEntryFiles(fullPath, filename)));
} else if (entry.name === filename) {
results.push(fullPath);
}
}
return results;
}

/**
* Generate a slug suitable for filenames.
* e.g., "pattern/list/dense-scan" → "list-dense-scan"
*/
function slugToFilename(slug) {
const parts = slug.split("/");
const categoryNames = CATEGORIES.map((c) => c.name);
if (parts.length > 1 && categoryNames.includes(parts[0])) {
parts.shift();
}
return parts.join("-");
}

/**
* Process a single category: find entry files, parse/copy them,
* and write output files.
*
* When sourceFile is set, entries are discovered by recursive search
* for that filename, parsed for frontmatter, and source markers are resolved.
* When sourceFile is null, all .md files in the category dir are copied as-is.
*/
async function processCategory(category) {
const categoryDir = join(catalogueRoot, "src", category.name);
const outputDir = join(referencesDir, category.outputDir);
await mkdir(outputDir, { recursive: true });

if (category.entryFile === null) {
return processCopiedCategory(category, categoryDir, outputDir);
}
return processEntryCategory(category, categoryDir, outputDir);
}

async function processCopiedCategory(category, categoryDir, outputDir) {
const files = await findMarkdownFiles(categoryDir);
for (const filePath of files) {
const filename = filePath.split("/").pop();
const outputPath = join(outputDir, filename);
await copyFile(filePath, outputPath);
console.log(` Generated references/${category.outputDir}/${filename}`);
}
return { category, files };
}

async function processEntryCategory(category, categoryDir, outputDir) {
const entryFiles = await findEntryFiles(categoryDir, category.entryFile);
const entries = [];

for (const filePath of entryFiles) {
const content = await readFile(filePath, "utf-8");
const { data: meta, content: body, matter: rawFrontmatter } = matter(content);
if (!meta.slug) {
console.warn(`Warning: No slug in frontmatter of ${filePath}`);
continue;
}

const entryDir = dirname(filePath);
const resolvedBody = await resolveSourceMarkers(body.trim(), entryDir);

entries.push({ meta, body: resolvedBody, rawFrontmatter });
}

for (const { meta, body, rawFrontmatter } of entries) {
const filename = slugToFilename(meta.slug) + ".md";
const outputPath = join(outputDir, filename);
const output = `---\n${rawFrontmatter.trim()}\n---\n\n${body}\n`;
await writeFile(outputPath, output);
console.log(` Generated references/${category.outputDir}/${filename}`);
}

return { category, entries };
}

async function main() {
// Process all categories
const results = [];
for (const category of CATEGORIES) {
const result = await processCategory(category);
results.push(result);
}

// Generate SKILL.md index
const skillMd = await generateSkillIndex(results);
await writeFile(join(skillsDir, "SKILL.md"), skillMd);
console.log(` Generated SKILL.md`);

const total = results.reduce((sum, r) => sum + (r.entries?.length ?? r.files?.length ?? 0), 0);
console.log(
`\nDone: ${total} file(s) across ${results.length} categories → skills/app-shell-patterns/`,
);
}

/**
* Generate an index table for entry-based categories (with frontmatter).
*/
function generateEntryTable(entries, outputDir) {
if (entries.length === 0) return "";

// Group entries by subcategory
const grouped = {};
for (const { meta } of entries) {
const key = meta.subcategory || meta.category;
if (!grouped[key]) grouped[key] = [];
grouped[key].push(meta);
}

let table = "";
for (const [group, items] of Object.entries(grouped)) {
table += `### ${group}\n\n`;
table += `| Slug | Name | Description |\n`;
table += `| ---- | ---- | ----------- |\n`;
for (const item of items) {
const filename = slugToFilename(item.slug) + ".md";
const displaySlug = item.slug.replace(/^[^/]+\//, "");
table += `| [\`${displaySlug}\`](references/${outputDir}/${filename}) | ${item.name} | ${item.description} |\n`;
}
table += `\n`;
}

return table.trim();
}

/**
* Generate an index table for copied categories (plain .md files).
*/
function generateCopiedTable(files, outputDir) {
if (files.length === 0) return "";

let table = "| File | Description |\n";
table += "| ---- | ----------- |\n";
for (const filePath of files) {
const filename = filePath.split("/").pop();
const name = filename.replace(".md", "");
table += `| [${filename}](references/${outputDir}/${filename}) | ${name} reference |\n`;
}
return table.trim();
}

async function findMarkdownFiles(dir) {
const results = [];
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
if (!entry.isDirectory() && entry.name.endsWith(".md")) {
results.push(join(dir, entry.name));
}
}
return results;
}

function generateTable(result) {
if (result.entries) {
return generateEntryTable(result.entries, result.category.outputDir);
}
return generateCopiedTable(result.files, result.category.outputDir);
}

async function generateSkillIndex(results) {
const templatePath = join(__dirname, "SKILL.template.md");
let template = await readFile(templatePath, "utf-8");

for (const result of results) {
const table = generateTable(result);
template = template.replace(`{{${result.category.templateKey}}}`, table);
}

return template;
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
Loading
Loading