* Resolve {{PLACEHOLDER}} / {{NAME:arg}} tokens against the RESOLVERS registry, * honoring host suppression and appliesTo gating, then assert nothing is left * unresolved. Extracted so SKILL.md and section templates resolve through the * exact same path — a security/sanitization fix to one can't
( tmplContent: string, ctx: TemplateContext, hostConfig: HostConfig, relTmplPath: string, )
| 665 | * exact same path — a security/sanitization fix to one can't miss the other. |
| 666 | */ |
| 667 | function resolvePlaceholders( |
| 668 | tmplContent: string, |
| 669 | ctx: TemplateContext, |
| 670 | hostConfig: HostConfig, |
| 671 | relTmplPath: string, |
| 672 | ): string { |
| 673 | // effectiveSuppressedResolvers() honors --respect-detection: when gbrain is |
| 674 | // detected locally, GBRAIN_* resolvers un-suppress. Shared by SKILL.md and |
| 675 | // section generation so both paths get the same gbrain-aware behavior. |
| 676 | const suppressed = effectiveSuppressedResolvers(hostConfig); |
| 677 | const onePass = (input: string): string => |
| 678 | input.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => { |
| 679 | const parts = fullKey.split(':'); |
| 680 | const resolverName = parts[0]; |
| 681 | const args = parts.slice(1); |
| 682 | if (suppressed.has(resolverName)) return ''; |
| 683 | const entry = RESOLVERS[resolverName]; |
| 684 | if (!entry) throw new Error(`Unknown placeholder {{${resolverName}}} in ${relTmplPath}`); |
| 685 | const { resolve, appliesTo } = unwrapResolver(entry); |
| 686 | if (appliesTo && !appliesTo(ctx)) return ''; |
| 687 | return args.length > 0 ? resolve(ctx, args) : resolve(ctx); |
| 688 | }); |
| 689 | |
| 690 | // Multi-pass: a resolver may emit content that itself contains {{TOKENS}} — the |
| 691 | // {{SECTION:id}} resolver inlines a section template (with its own resolvers) |
| 692 | // for non-Claude hosts. .replace() doesn't re-scan inserted text, so loop until |
| 693 | // the output stabilizes. Bounded to avoid an infinite loop if a resolver ever |
| 694 | // emits its own placeholder; 6 passes is far more nesting than any skill needs. |
| 695 | let content = tmplContent; |
| 696 | for (let pass = 0; pass < 6; pass++) { |
| 697 | const next = onePass(content); |
| 698 | if (next === content) break; |
| 699 | content = next; |
| 700 | } |
| 701 | |
| 702 | const remaining = content.match(/\{\{(\w+(?::[^}]+)?)\}\}/g); |
| 703 | if (remaining) { |
| 704 | throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`); |
| 705 | } |
| 706 | return content; |
| 707 | } |
| 708 | |
| 709 | /** |
| 710 | * Build the TemplateContext from a template's frontmatter. Shared by SKILL.md |
no test coverage detected