contentbase 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.mcp.json +9 -0
  2. package/CLI.md +593 -0
  3. package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
  4. package/MCP-SERVER-SPEC.md +453 -0
  5. package/PRIMER.md +540 -0
  6. package/README.md +289 -13
  7. package/bun.lock +43 -4
  8. package/dist/cnotes +0 -0
  9. package/docs/README.md +110 -0
  10. package/docs/TABLE-OF-CONTENTS.md +7 -0
  11. package/docs/models.ts +38 -0
  12. package/models.ts +38 -0
  13. package/package.json +12 -3
  14. package/public/web-demo/index.html +813 -0
  15. package/showcases/node_modules/.cache/.repl_history +3 -0
  16. package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
  17. package/src/__tests__/semantic-search.integration.test.ts +284 -0
  18. package/src/api/endpoints/actions.ts +34 -0
  19. package/src/api/endpoints/doc.ts +187 -0
  20. package/src/api/endpoints/docs-index.ts +26 -0
  21. package/src/api/endpoints/document.ts +171 -0
  22. package/src/api/endpoints/documents.ts +71 -0
  23. package/src/api/endpoints/inspect.ts +16 -0
  24. package/src/api/endpoints/models.ts +10 -0
  25. package/src/api/endpoints/query.ts +88 -0
  26. package/src/api/endpoints/root.ts +7 -0
  27. package/src/api/endpoints/search-reindex.ts +65 -0
  28. package/src/api/endpoints/search-status.ts +55 -0
  29. package/src/api/endpoints/search.ts +104 -0
  30. package/src/api/endpoints/semantic-search.ts +120 -0
  31. package/src/api/endpoints/text-search.ts +63 -0
  32. package/src/api/endpoints/validate.ts +34 -0
  33. package/src/api/helpers.ts +97 -0
  34. package/src/base-model.ts +12 -0
  35. package/src/cli/commands/action.ts +82 -44
  36. package/src/cli/commands/console.ts +124 -0
  37. package/src/cli/commands/create.ts +179 -53
  38. package/src/cli/commands/embed.ts +323 -0
  39. package/src/cli/commands/export.ts +58 -24
  40. package/src/cli/commands/extract.ts +174 -0
  41. package/src/cli/commands/help.ts +81 -0
  42. package/src/cli/commands/index.ts +17 -0
  43. package/src/cli/commands/init.ts +72 -48
  44. package/src/cli/commands/inspect.ts +56 -46
  45. package/src/cli/commands/mcp.ts +1219 -0
  46. package/src/cli/commands/search.ts +285 -0
  47. package/src/cli/commands/serve.ts +348 -0
  48. package/src/cli/commands/summary.ts +60 -0
  49. package/src/cli/commands/teach.ts +86 -0
  50. package/src/cli/commands/text-search.ts +134 -0
  51. package/src/cli/commands/validate.ts +126 -64
  52. package/src/cli/index.ts +88 -19
  53. package/src/cli/load-collection.ts +144 -17
  54. package/src/cli/registry.ts +28 -0
  55. package/src/collection.ts +361 -10
  56. package/src/define-model.ts +101 -4
  57. package/src/document.ts +47 -1
  58. package/src/extract-sections.ts +1 -1
  59. package/src/index.ts +14 -2
  60. package/src/model-instance.ts +35 -5
  61. package/src/node-shortcuts.ts +1 -1
  62. package/src/query/collection-query.ts +96 -9
  63. package/src/query/index.ts +7 -0
  64. package/src/query/query-dsl.ts +259 -0
  65. package/src/relationships/has-many.ts +39 -0
  66. package/src/relationships/index.ts +7 -2
  67. package/src/section.ts +2 -0
  68. package/src/types.ts +23 -1
  69. package/src/utils/index.ts +1 -0
  70. package/src/utils/match-pattern.ts +65 -0
  71. package/src/validator.ts +18 -1
  72. package/test/collection.test.ts +118 -2
  73. package/test/fixtures/sdlc/MODELS.md +106 -0
  74. package/test/fixtures/sdlc/SKILL.md +404 -0
  75. package/test/fixtures/sdlc/index.ts +9 -0
  76. package/test/fixtures/sdlc/models.ts +8 -6
  77. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
  78. package/test/fixtures/sdlc/templates/epic.md +23 -0
  79. package/test/fixtures/sdlc/templates/story.md +19 -0
  80. package/test/pattern.test.ts +191 -0
  81. package/test/query-dsl.test.ts +431 -0
  82. package/test/query.test.ts +29 -0
  83. package/test/relationships.test.ts +130 -0
  84. package/test/section.test.ts +61 -0
  85. package/test/table-of-contents.test.ts +49 -5
@@ -51,6 +51,7 @@ export class HasManyRelationship<
51
51
  */
52
52
  private extractChildNodes(): ChildNode[] {
53
53
  const { astQuery } = this.#document;
54
+ if (!this.#definition.heading) return [];
54
55
  const parentHeading = astQuery.findHeadingByText(this.#definition.heading);
55
56
  if (!parentHeading) return [];
56
57
 
@@ -100,6 +101,14 @@ export class HasManyRelationship<
100
101
 
101
102
  fetchAll(): InferModelInstance<TTarget>[] {
102
103
  const targetDef = this.#definition.target();
104
+
105
+ // Foreign key mode: query for target documents where meta[foreignKey] matches this document's slug
106
+ // Used when foreignKey is explicitly set, or when no heading is specified (convention-based)
107
+ if (this.#definition.foreignKey || !this.#definition.heading) {
108
+ return this.#fetchByForeignKey(targetDef);
109
+ }
110
+
111
+ // Heading mode: extract child nodes from parent document AST
103
112
  const childNodes = this.extractChildNodes();
104
113
 
105
114
  return childNodes.map(({ id, ast }) => {
@@ -119,6 +128,36 @@ export class HasManyRelationship<
119
128
  });
120
129
  }
121
130
 
131
+ #fetchByForeignKey(targetDef: TTarget): InferModelInstance<TTarget>[] {
132
+ // If foreignKey is explicitly set, use it. Otherwise infer from the parent document's model.
133
+ // Convention: look for meta[lowercase(parentModelName)] on target documents.
134
+ // e.g. Project hasMany Plans → looks for meta.project on Plan documents.
135
+ const fk = this.#definition.foreignKey || this.#inferForeignKey();
136
+ const slug = this.#document.slug;
137
+ const prefix = targetDef.prefix;
138
+ const results: InferModelInstance<TTarget>[] = [];
139
+
140
+ for (const pathId of this.#collection.available) {
141
+ if (!pathId.startsWith(prefix + "/")) continue;
142
+ const doc = this.#collection.document(pathId);
143
+ if (doc.meta[fk] === slug) {
144
+ results.push(this.#factory(doc, targetDef, this.#collection));
145
+ }
146
+ }
147
+
148
+ return results;
149
+ }
150
+
151
+ /**
152
+ * Infer the foreign key name from the parent document's prefix.
153
+ * e.g. if the parent is in "projects/", the FK is "project" (singularized, lowercased).
154
+ */
155
+ #inferForeignKey(): string {
156
+ const parentPrefix = this.#document.id.split("/")[0];
157
+ // Simple singularize: strip trailing "s"
158
+ return parentPrefix.replace(/s$/, "");
159
+ }
160
+
122
161
  first(): InferModelInstance<TTarget> | undefined {
123
162
  return this.fetchAll()[0];
124
163
  }
@@ -7,7 +7,10 @@ import type {
7
7
 
8
8
  /**
9
9
  * Declare a hasMany relationship.
10
- * Child models are extracted from sub-headings under a parent heading.
10
+ *
11
+ * Two modes:
12
+ * - `heading`: Children are extracted from sub-headings under a parent heading in the document.
13
+ * - `foreignKey`: Children are found by querying target documents where meta[foreignKey] matches this document's slug.
11
14
  *
12
15
  * The target parameter is a thunk (() => ModelDef) to allow circular references.
13
16
  */
@@ -16,7 +19,8 @@ export function hasMany<
16
19
  >(
17
20
  target: () => TTarget,
18
21
  options: {
19
- heading: string;
22
+ heading?: string;
23
+ foreignKey?: string;
20
24
  meta?: (self: any) => Record<string, unknown>;
21
25
  id?: (slug: string) => string;
22
26
  }
@@ -25,6 +29,7 @@ export function hasMany<
25
29
  type: "hasMany",
26
30
  target,
27
31
  heading: options.heading,
32
+ foreignKey: options.foreignKey,
28
33
  meta: options.meta,
29
34
  id: options.id,
30
35
  };
package/src/section.ts CHANGED
@@ -19,10 +19,12 @@ export function section<T>(
19
19
  options: {
20
20
  extract: (query: AstQuery) => T;
21
21
  schema?: z.ZodType<T>;
22
+ alternatives?: string[];
22
23
  }
23
24
  ): SectionDefinition<T> {
24
25
  return {
25
26
  heading,
27
+ alternatives: options.alternatives,
26
28
  extract: options.extract,
27
29
  schema: options.schema,
28
30
  };
package/src/types.ts CHANGED
@@ -12,6 +12,7 @@ export interface CollectionItem {
12
12
  path: string;
13
13
  createdAt: Date;
14
14
  updatedAt: Date;
15
+ size: number;
15
16
  }
16
17
 
17
18
  /** Options when constructing a Collection */
@@ -19,6 +20,8 @@ export interface CollectionOptions {
19
20
  rootPath: string;
20
21
  extensions?: string[];
21
22
  name?: string;
23
+ /** When true (default), load() looks for a models.{ts,js,mjs} in rootPath and auto-registers exported model definitions. */
24
+ autoDiscover?: boolean;
22
25
  }
23
26
 
24
27
  // ─── Section system ───
@@ -30,6 +33,8 @@ export interface CollectionOptions {
30
33
  export interface SectionDefinition<T = unknown> {
31
34
  /** The heading text to find in the document */
32
35
  heading: string;
36
+ /** Alternative heading texts to try if the primary heading is not found */
37
+ alternatives?: string[];
33
38
  /** Extract structured data from the section's AST query */
34
39
  extract: (query: AstQuery) => T;
35
40
  /** Optional Zod schema to validate the extracted value */
@@ -43,7 +48,10 @@ export interface HasManyDefinition<
43
48
  > {
44
49
  type: "hasMany";
45
50
  target: () => TTarget;
46
- heading: string;
51
+ /** Extract children from sub-headings under this heading in the parent document */
52
+ heading?: string;
53
+ /** Find children by querying for target documents where meta[foreignKey] matches this document's slug */
54
+ foreignKey?: string;
47
55
  meta?: (self: any) => Record<string, unknown>;
48
56
  id?: (slug: string) => string;
49
57
  }
@@ -100,8 +108,17 @@ export interface ModelDefinition<
100
108
  sections: TSections;
101
109
  relationships: TRelationships;
102
110
  computed: TComputed;
111
+ /** Human-readable description of this model, auto-generated if not provided */
112
+ description: string;
113
+ /** Named scopes — reusable query presets */
114
+ scopes: Record<string, (query: any) => any>;
103
115
  match?: (doc: DocumentRef) => boolean;
104
116
  defaults?: Partial<z.input<TMeta>>;
117
+ pattern?: string | string[];
118
+ /** Glob patterns or RegExp to exclude matching pathIds from queries, listings, and TOC */
119
+ exclude?: (string | RegExp)[];
120
+ /** When true, documents are not required to have an H1 title */
121
+ titleOptional?: boolean;
105
122
 
106
123
  /** The inferred Zod schema for convenience (same as meta) */
107
124
  schema: TMeta;
@@ -131,6 +148,11 @@ export type InferModelInstance<
131
148
  readonly document: import("./document.js").Document;
132
149
  readonly collection: import("./collection.js").Collection;
133
150
 
151
+ // File metadata
152
+ readonly createdAt: Date | undefined;
153
+ readonly updatedAt: Date | undefined;
154
+ readonly size: number | undefined;
155
+
134
156
  // Typed meta from Zod schema
135
157
  readonly meta: z.infer<TMeta>;
136
158
 
@@ -9,3 +9,4 @@ export { stringifyAst } from "./stringify-ast";
9
9
  export { parseTable } from "./parse-table";
10
10
  export { normalizeHeadings } from "./normalize-headings";
11
11
  export { readDirectory } from "./read-directory";
12
+ export { matchPattern, matchPatterns } from "./match-pattern";
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Express-style path pattern matching for extracting named parameters
3
+ * from document pathIds.
4
+ *
5
+ * Patterns use `:param` syntax for named segments and literal segments
6
+ * for exact matching. Segment counts must match exactly.
7
+ *
8
+ * Examples:
9
+ * matchPattern("plans/:project/:slug", "plans/acme/launch")
10
+ * // => { project: "acme", slug: "launch" }
11
+ *
12
+ * matchPattern("plans/:project/:slug", "stories/acme/launch")
13
+ * // => null (literal mismatch)
14
+ */
15
+
16
+ /**
17
+ * Match a single pattern against a pathId.
18
+ * Returns extracted params on match, null on mismatch.
19
+ */
20
+ export function matchPattern(
21
+ pattern: string,
22
+ pathId: string
23
+ ): Record<string, string> | null {
24
+ const patternParts = pattern.split("/").filter(Boolean);
25
+ const pathParts = pathId.split("/").filter(Boolean);
26
+
27
+ if (patternParts.length !== pathParts.length) {
28
+ return null;
29
+ }
30
+
31
+ const params: Record<string, string> = {};
32
+
33
+ for (let i = 0; i < patternParts.length; i++) {
34
+ const pat = patternParts[i];
35
+ const val = pathParts[i];
36
+
37
+ if (pat.startsWith(":")) {
38
+ params[pat.slice(1)] = val;
39
+ } else if (pat !== val) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ return params;
45
+ }
46
+
47
+ /**
48
+ * Try multiple patterns against a pathId, returning the first match.
49
+ * Accepts a single pattern string or an array of patterns.
50
+ */
51
+ export function matchPatterns(
52
+ patterns: string | string[],
53
+ pathId: string
54
+ ): Record<string, string> | null {
55
+ const list = Array.isArray(patterns) ? patterns : [patterns];
56
+
57
+ for (const pattern of list) {
58
+ const result = matchPattern(pattern, pathId);
59
+ if (result !== null) {
60
+ return result;
61
+ }
62
+ }
63
+
64
+ return null;
65
+ }
package/src/validator.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  ValidationResult,
6
6
  } from "./types";
7
7
  import type { Document } from "./document";
8
+ import { matchPatterns } from "./utils/match-pattern";
8
9
 
9
10
  /**
10
11
  * Standalone validator that checks a document against a model definition's
@@ -19,12 +20,28 @@ export function validateDocument(
19
20
  const errors: z.ZodIssue[] = [];
20
21
 
21
22
  // Validate meta
22
- const rawMeta = { ...(definition.defaults ?? {}), ...document.meta };
23
+ const patternMeta = definition.pattern
24
+ ? matchPatterns(definition.pattern, document.id) ?? {}
25
+ : {};
26
+ const rawMeta = { ...(definition.defaults ?? {}), ...patternMeta, ...document.meta };
23
27
  const metaResult = definition.meta.safeParse(rawMeta);
24
28
  if (!metaResult.success) {
25
29
  errors.push(...metaResult.error.issues);
26
30
  }
27
31
 
32
+ // Validate title: require H1 heading unless titleOptional or meta.title exists
33
+ if (!definition.titleOptional) {
34
+ const hasMetaTitle = !!document.meta.title;
35
+ const hasH1 = !!document.astQuery.select("heading");
36
+ if (!hasMetaTitle && !hasH1) {
37
+ errors.push({
38
+ code: "custom",
39
+ path: ["title"],
40
+ message: `Document "${document.id}" is missing a title. Add an H1 heading or set meta.title.`,
41
+ });
42
+ }
43
+ }
44
+
28
45
  // Validate sections
29
46
  if (definition.sections) {
30
47
  for (const [key, sd] of Object.entries(definition.sections)) {
@@ -1,4 +1,6 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "fs/promises";
3
+ import path from "path";
2
4
  import { Collection } from "../src/collection";
3
5
  import { createTestCollection, FIXTURES_PATH } from "./helpers";
4
6
  import { Epic, Story } from "./fixtures/sdlc/models";
@@ -51,7 +53,8 @@ describe("Collection", () => {
51
53
  it("registers model definitions", () => {
52
54
  expect(collection.getModelDefinition("Epic")).toBeDefined();
53
55
  expect(collection.getModelDefinition("Story")).toBeDefined();
54
- expect(collection.modelDefinitions.length).toBe(2);
56
+ expect(collection.getModelDefinition("Base")).toBeDefined();
57
+ expect(collection.modelDefinitions.length).toBe(3);
55
58
  });
56
59
 
57
60
  it("finds model definition by pathId prefix", () => {
@@ -96,4 +99,117 @@ describe("Collection", () => {
96
99
  });
97
100
  expect(pluginCalled).toBe(true);
98
101
  });
102
+
103
+ describe("refresh after delete", () => {
104
+ let tmpDir: string;
105
+ let tmpCollection: Collection;
106
+
107
+ beforeEach(async () => {
108
+ tmpDir = await fs.mkdtemp(path.join(import.meta.dirname, ".tmp-refresh-"));
109
+ // Create two documents
110
+ const epicDir = path.join(tmpDir, "epics");
111
+ await fs.mkdir(epicDir, { recursive: true });
112
+ await fs.writeFile(
113
+ path.join(epicDir, "one.mdx"),
114
+ "---\nstatus: created\npriority: 1\n---\n# One\n"
115
+ );
116
+ await fs.writeFile(
117
+ path.join(epicDir, "two.mdx"),
118
+ "---\nstatus: created\npriority: 2\n---\n# Two\n"
119
+ );
120
+
121
+ tmpCollection = new Collection({ rootPath: tmpDir });
122
+ tmpCollection.register(Epic);
123
+ await tmpCollection.load();
124
+ });
125
+
126
+ afterEach(async () => {
127
+ await fs.rm(tmpDir, { recursive: true });
128
+ });
129
+
130
+ it("evicts cached documents whose files were deleted", async () => {
131
+ // Access a document so it's in the #documents cache
132
+ const doc = tmpCollection.document("epics/one");
133
+ expect(doc.title).toBe("One");
134
+
135
+ // Delete the file externally
136
+ await fs.unlink(path.join(tmpDir, "epics", "one.mdx"));
137
+
138
+ // Refresh should succeed without ENOENT
139
+ await tmpCollection.load({ refresh: true });
140
+
141
+ // The deleted document should no longer be available
142
+ expect(tmpCollection.available).not.toContain("epics/one");
143
+ expect(tmpCollection.available).toContain("epics/two");
144
+ });
145
+ });
146
+
147
+ describe("generateModelSummary", () => {
148
+ let tmpDir: string;
149
+ let tmpCollection: Collection;
150
+
151
+ beforeEach(async () => {
152
+ tmpDir = await fs.mkdtemp(path.join(import.meta.dirname, ".tmp-summary-"));
153
+ tmpCollection = new Collection({ rootPath: tmpDir });
154
+ tmpCollection.register(Epic);
155
+ tmpCollection.register(Story);
156
+ await tmpCollection.load();
157
+ });
158
+
159
+ afterEach(async () => {
160
+ await fs.rm(tmpDir, { recursive: true });
161
+ });
162
+
163
+ it("generates inspect-style text with model info", () => {
164
+ const text = tmpCollection.generateModelSummary();
165
+
166
+ // Collection header
167
+ expect(text).toContain("Collection:");
168
+ expect(text).toContain("Root:");
169
+ expect(text).toContain("Items:");
170
+
171
+ // Model entries
172
+ expect(text).toContain("Model: Epic");
173
+ expect(text).toContain("Model: Story");
174
+
175
+ // Prefixes
176
+ expect(text).toContain("Prefix: epics");
177
+ expect(text).toContain("Prefix: stories");
178
+
179
+ // Meta attributes
180
+ expect(text).toContain("priority");
181
+ expect(text).toContain("status");
182
+
183
+ // Sections
184
+ expect(text).toContain("Sections:");
185
+
186
+ // Relationships
187
+ expect(text).toContain("Relationships:");
188
+ });
189
+
190
+ it("saveModelSummary writes README.md to rootPath", async () => {
191
+ await tmpCollection.saveModelSummary();
192
+ const content = await fs.readFile(path.join(tmpDir, "README.md"), "utf8");
193
+ expect(content).toContain("# Models");
194
+ expect(content).toContain("## Overview");
195
+ expect(content).toContain("## Summary");
196
+ });
197
+
198
+ it("saveModelSummary preserves existing Overview content", async () => {
199
+ // Write an initial README.md with user content in Overview
200
+ const initial = "# Models\n\n## Overview\n\nThis is my custom overview.\n\n## Summary\n\n```\nold summary\n```\n";
201
+ await fs.writeFile(path.join(tmpDir, "README.md"), initial, "utf8");
202
+
203
+ await tmpCollection.saveModelSummary();
204
+ const content = await fs.readFile(path.join(tmpDir, "README.md"), "utf8");
205
+ expect(content).toContain("This is my custom overview.");
206
+ expect(content).toContain("Model: Epic");
207
+ });
208
+
209
+ it("includes collection actions", () => {
210
+ tmpCollection.action("deploy", () => {});
211
+ const text = tmpCollection.generateModelSummary();
212
+ expect(text).toContain("Actions: deploy");
213
+ });
214
+ });
99
215
  });
@@ -0,0 +1,106 @@
1
+ # Models
2
+
3
+ Models define the structure of markdown documents in this collection. Each document is a markdown file with YAML frontmatter (metadata attributes) and a heading-based structure (sections). Models specify the expected frontmatter fields via a schema, named sections that map to `##` headings in the document body, relationships to other models, and computed properties derived at query time.
4
+
5
+ ## Epics
6
+
7
+ **Prefix:** `epics`
8
+
9
+ ### Attributes
10
+
11
+ | Field | Type | Required | Default | Description |
12
+ |-------|------|----------|---------|-------------|
13
+ | priority | enum(`low`, `medium`, `high`) | optional | — | Importance level for prioritization |
14
+ | status | enum(`created`, `in-progress`, `complete`) | optional | `"created"` | Current workflow state |
15
+
16
+ ### Relationships
17
+
18
+ | Name | Type | Target |
19
+ |------|------|--------|
20
+ | stories | hasMany | Story |
21
+
22
+ ### Computed Properties
23
+
24
+ - `isComplete`
25
+
26
+ ### Example
27
+
28
+ ```markdown
29
+ ---
30
+ priority: medium
31
+ status: created
32
+ ---
33
+
34
+ # Epic Title
35
+
36
+ A brief description of this epic and its goals.
37
+
38
+ ## Stories
39
+
40
+ ### Story Title
41
+
42
+ A brief description of this story.
43
+
44
+ #### Acceptance Criteria
45
+
46
+ - First acceptance criterion
47
+ - Second acceptance criterion
48
+
49
+ #### Mockups
50
+
51
+ [Wireframe](https://example.com/wireframe)
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Stories
57
+
58
+ **Prefix:** `stories`
59
+
60
+ ### Attributes
61
+
62
+ | Field | Type | Required | Default | Description |
63
+ |-------|------|----------|---------|-------------|
64
+ | status | enum(`created`, `in-progress`, `complete`) | optional | `"created"` | Current workflow state |
65
+ | epic | string | optional | — | Slug of the parent epic |
66
+
67
+ ### Sections
68
+
69
+ | Name | Heading | Alternatives | Description |
70
+ |------|---------|--------------|-------------|
71
+ | acceptanceCriteria | Acceptance Criteria | — | List of acceptance criteria as plain text strings |
72
+ | mockups | Mockups | — | Map of mockup label to URL |
73
+
74
+ ### Relationships
75
+
76
+ | Name | Type | Target |
77
+ |------|------|--------|
78
+ | epic | belongsTo | Epic |
79
+
80
+ ### Computed Properties
81
+
82
+ - `isComplete`
83
+
84
+ ### Example
85
+
86
+ ```markdown
87
+ ---
88
+ status: created
89
+ epic: epic-slug
90
+ ---
91
+
92
+ # Story Title
93
+
94
+ A brief description of what this story accomplishes.
95
+
96
+ ## Acceptance Criteria
97
+
98
+ - First acceptance criterion
99
+ - Second acceptance criterion
100
+ - Third acceptance criterion
101
+
102
+ ## Mockups
103
+
104
+ [Main View](https://example.com/main-view)
105
+ [Detail View](https://example.com/detail-view)
106
+ ```