Skip to content

Commit 83505e2

Browse files
feat: support fields with both hooks and variants API (#6)
* feat: support $dynamic fields with both hooks and variants API Previously, $dynamic fields required values via the onBeforeCompile hook, which caused errors when using the variants API to provide dynamic data. This change allows $dynamic to work seamlessly with both approaches. Changes: - Moved $dynamic validation to occur after all data merging is complete - Added _skipDynamicCheck flag for batch processor's first pass - Validation now checks if originally-$dynamic fields have values after hook/variant data is merged - Added comprehensive tests for variants with $dynamic fields The system now validates that $dynamic fields are resolved regardless of whether the data comes from onBeforeCompile hook or variants API, providing a unified and flexible approach to dynamic content generation. Fixes issue where variants API couldn't be used with $dynamic fields. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: make dynamic check skip truly internal Changed _skipDynamicCheck from a public option to an internal implementation detail: - Created private _processInternal() method with skipDynamicCheck parameter - Added _processForBatch() method for batch processor use - Removed _skipDynamicCheck from public ProcessOptions interface - BatchProcessor now uses _processForBatch() instead of passing flag This keeps the public API clean while maintaining the same functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * docs: document $dynamic support for variants API Added comprehensive documentation showing that $dynamic fields work with: - onBeforeCompile hook (existing) - Variants API (new feature) - Both combined Changes: - Added new subsection "Using $dynamic Fields with Variants" with examples - Updated "Dynamic Variable Injection" section with cross-reference - Updated features list to reflect unified $dynamic support This makes it clear that $dynamic is a flexible keyword that works with multiple data injection methods. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 855011c commit 83505e2

File tree

4 files changed

+205
-33
lines changed

4 files changed

+205
-33
lines changed

README.md

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ docs/getting-started.md:
8080
-**File injection** - Include external files with `{{partials.xxx}}`
8181
-**Glob patterns** - `guides/*.md` expands to multiple files
8282
-**Security** - Path traversal protection, circular dependency detection
83-
-**Dynamic injection** - `onBeforeCompile` hook for runtime variable injection
83+
-**Dynamic fields** - `$dynamic` keyword works with hooks and variants API
8484
-**Multi-variant generation** - One template → many output files with different data
8585
-**Batch processing** - Process entire directories with one API call
8686

@@ -695,7 +695,7 @@ const processor = new BatchProcessor({
695695
});
696696
```
697697

698-
Mark fields as `$dynamic` in frontmatter to require hook injection:
698+
Mark fields as `$dynamic` in frontmatter to require dynamic data:
699699

700700
```markdown
701701
---
@@ -708,6 +708,11 @@ Built at {{buildTime}}
708708
Version: {{version}}
709709
```
710710

711+
**Note:** The `$dynamic` keyword works with both:
712+
- The `onBeforeCompile` hook (as shown above)
713+
- The variants API (see [Multi-Variant Template Generation](#multi-variant-template-generation))
714+
- Both combined (hook + variant data)
715+
711716
## Multi-Variant Template Generation
712717

713718
Generate multiple output files from a single template with different data for each variant. Perfect for creating product pages, documentation in multiple languages, or any scenario where you need many similar files with different values.
@@ -757,6 +762,63 @@ SKU: {{sku}}
757762
- Works with `onBeforeCompile` for additional dynamic data
758763
- File-specific variants via `id` field in frontmatter
759764

765+
### Using `$dynamic` Fields with Variants
766+
767+
The `$dynamic` keyword works seamlessly with the variants API. Mark fields as `$dynamic` in your template, and provide values via the variant data:
768+
769+
**Template file** (`templates/command.md`):
770+
```markdown
771+
---
772+
id: command-template
773+
name: $dynamic
774+
command: $dynamic
775+
description: $dynamic
776+
---
777+
778+
# {{name}}
779+
780+
Command: `{{command}}`
781+
782+
{{description}}
783+
```
784+
785+
**Variant configuration:**
786+
```typescript
787+
const processor = new BatchProcessor({
788+
baseDir: './templates',
789+
outDir: './dist',
790+
variants: {
791+
'command-template': {
792+
data: [
793+
{
794+
name: 'Recipe Command',
795+
command: '/recipe',
796+
description: 'Generate cooking recipes'
797+
},
798+
{
799+
name: 'Code Command',
800+
command: '/code',
801+
description: 'Generate code snippets'
802+
}
803+
],
804+
getOutputPath: (context, data, index) =>
805+
`commands/${data.command.replace('/', '')}.md`
806+
}
807+
}
808+
});
809+
```
810+
811+
**Generated output:**
812+
- `dist/commands/recipe.md` with recipe data
813+
- `dist/commands/code.md` with code data
814+
815+
**Important:** If you mark fields as `$dynamic`, you must provide them via either:
816+
- The variants API (as shown above)
817+
- The `onBeforeCompile` hook
818+
- Both combined (hook values + variant data)
819+
820+
If any `$dynamic` fields remain unresolved, you'll get a clear error message.
821+
760822
## Output Frontmatter Filtering
761823

762824
Control which frontmatter fields appear in the final output:

packages/core/src/batch.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,9 @@ export class BatchProcessor {
154154
const content = readFileSync(file, "utf-8");
155155
const relativePath = relative(baseDir, file);
156156

157-
// First, do a quick parse to get the frontmatter and check for variants
158-
const result = await this.mdi.process({
157+
// First pass: extract frontmatter to detect if this file has variants configured
158+
// Skip $dynamic validation on this pass - variants will provide the data on second pass
159+
const result = await this.mdi._processForBatch({
159160
content,
160161
baseDir,
161162
currentFile: file,

packages/core/src/index.ts

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,13 @@ export class MarkdownDI {
8383
}
8484

8585
/**
86-
* Process markdown content with dependency injection
86+
* Internal process method with additional flags
87+
* @internal - Used by batch processor
8788
*/
88-
async process(options: ProcessOptions): Promise<ProcessResult> {
89+
private async _processInternal(
90+
options: ProcessOptions,
91+
skipDynamicCheck: boolean = false
92+
): Promise<ProcessResult> {
8993
const context: ProcessingContext = {
9094
baseDir: options.baseDir,
9195
mode: options.mode || "build",
@@ -129,24 +133,6 @@ export class MarkdownDI {
129133
dynamicFields,
130134
});
131135

132-
// Validate that all $dynamic fields are provided by hook
133-
const missingFields: string[] = [];
134-
for (const field of dynamicFields) {
135-
if (!hookResult || !(field in hookResult)) {
136-
missingFields.push(field);
137-
}
138-
}
139-
140-
if (missingFields.length > 0) {
141-
allErrors.push({
142-
type: "schema",
143-
message: `Hook must provide these $dynamic fields: ${missingFields.join(
144-
", "
145-
)}`,
146-
location: "onBeforeCompile",
147-
});
148-
}
149-
150136
// Remove $dynamic placeholders before merging
151137
for (const field of dynamicFields) {
152138
delete frontmatter[field];
@@ -163,15 +149,25 @@ export class MarkdownDI {
163149
location: "onBeforeCompile",
164150
});
165151
}
166-
} else if (dynamicFields.length > 0) {
167-
// $dynamic fields declared but no hook provided
168-
allErrors.push({
169-
type: "schema",
170-
message: `Fields marked as $dynamic but no onBeforeCompile hook configured: ${dynamicFields.join(
171-
", "
172-
)}`,
173-
location: "frontmatter",
174-
});
152+
}
153+
154+
// After hook execution, validate that all $dynamic fields were provided
155+
// Check that fields which were originally $dynamic now have values
156+
if (!skipDynamicCheck) {
157+
const missingDynamic: string[] = [];
158+
for (const field of dynamicFields) {
159+
if (!(field in frontmatter) || frontmatter[field] === undefined) {
160+
missingDynamic.push(field);
161+
}
162+
}
163+
164+
if (missingDynamic.length > 0) {
165+
allErrors.push({
166+
type: "schema",
167+
message: `These $dynamic fields were not provided: ${missingDynamic.join(", ")}. Provide values via onBeforeCompile hook or variants API`,
168+
location: "frontmatter",
169+
});
170+
}
175171
}
176172

177173
// Schema validation - supports both AJV (JSON Schema) and Zod (legacy)
@@ -265,6 +261,21 @@ export class MarkdownDI {
265261
};
266262
}
267263

264+
/**
265+
* Process markdown content with dependency injection
266+
*/
267+
async process(options: ProcessOptions): Promise<ProcessResult> {
268+
return this._processInternal(options, false);
269+
}
270+
271+
/**
272+
* Internal method for batch processor to extract frontmatter without validating $dynamic fields
273+
* @internal - Only for use by BatchProcessor
274+
*/
275+
async _processForBatch(options: ProcessOptions): Promise<ProcessResult> {
276+
return this._processInternal(options, true);
277+
}
278+
268279
/**
269280
* Validate markdown content without processing
270281
*/

packages/core/test/batch-processor.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,4 +631,102 @@ name: Check
631631
// No files should be written in check mode
632632
expect(existsSync(OUT_DIR)).toBe(false)
633633
})
634+
635+
test('variants work with $dynamic fields', async () => {
636+
mkdirSync(join(TEST_DIR, 'templates'), { recursive: true })
637+
638+
writeFileSync(
639+
join(TEST_DIR, 'templates', 'dynamic.md'),
640+
`---
641+
id: dynamic-template
642+
name: $dynamic
643+
command: $dynamic
644+
description: $dynamic
645+
---
646+
# {{name}}
647+
648+
Command: {{command}}
649+
650+
{{description}}
651+
`
652+
)
653+
654+
const processor = new BatchProcessor({
655+
baseDir: join(TEST_DIR, 'templates'),
656+
outDir: OUT_DIR,
657+
variants: {
658+
'dynamic-template': {
659+
data: [
660+
{
661+
name: 'Recipe Command',
662+
command: '/recipe',
663+
description: 'Generate cooking recipes'
664+
},
665+
{
666+
name: 'Code Command',
667+
command: '/code',
668+
description: 'Generate code snippets'
669+
}
670+
],
671+
getOutputPath: (_ctx, data, _idx) => {
672+
const slug = (data.name as string).toLowerCase().replace(/\s+/g, '-')
673+
return `${slug}.md`
674+
}
675+
}
676+
}
677+
})
678+
679+
const result = await processor.process()
680+
681+
expect(result.success).toBe(true)
682+
expect(result.changedFiles).toBe(2)
683+
expect(result.totalErrors).toBe(0)
684+
685+
// Verify first variant
686+
const recipe = await Bun.file(join(OUT_DIR, 'recipe-command.md')).text()
687+
expect(recipe).toContain('# Recipe Command')
688+
expect(recipe).toContain('Command: /recipe')
689+
expect(recipe).toContain('Generate cooking recipes')
690+
691+
// Verify second variant
692+
const code = await Bun.file(join(OUT_DIR, 'code-command.md')).text()
693+
expect(code).toContain('# Code Command')
694+
expect(code).toContain('Command: /code')
695+
expect(code).toContain('Generate code snippets')
696+
})
697+
698+
test('variants with $dynamic fields fails when data not provided', async () => {
699+
mkdirSync(join(TEST_DIR, 'templates'), { recursive: true })
700+
701+
writeFileSync(
702+
join(TEST_DIR, 'templates', 'incomplete.md'),
703+
`---
704+
id: incomplete-template
705+
name: $dynamic
706+
description: $dynamic
707+
---
708+
# {{name}}
709+
`
710+
)
711+
712+
const processor = new BatchProcessor({
713+
baseDir: join(TEST_DIR, 'templates'),
714+
outDir: OUT_DIR,
715+
variants: {
716+
'incomplete-template': {
717+
data: [
718+
{ name: 'Test' } // Missing 'description'
719+
],
720+
getOutputPath: () => 'output.md'
721+
}
722+
}
723+
})
724+
725+
const result = await processor.process()
726+
727+
expect(result.success).toBe(false)
728+
expect(result.totalErrors).toBeGreaterThan(0)
729+
expect(result.files[0].errors[0].message).toContain('$dynamic')
730+
expect(result.files[0].errors[0].message).toContain('description')
731+
})
634732
})

0 commit comments

Comments
 (0)