|
321 | 321 | data = json.load(f) |
322 | 322 | presets = data.get('presets', {}) |
323 | 323 | for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)): |
324 | | - print(pid) |
| 324 | + if meta.get('enabled', True) is not False: |
| 325 | + print(pid) |
325 | 326 | except Exception: |
326 | 327 | sys.exit(1) |
327 | 328 | " 2>/dev/null); then |
@@ -373,3 +374,233 @@ except Exception: |
373 | 374 | return 1 |
374 | 375 | } |
375 | 376 |
|
| 377 | +# Resolve a template name to composed content using composition strategies. |
| 378 | +# Reads strategy metadata from preset manifests and composes content |
| 379 | +# from multiple layers using prepend, append, or wrap strategies. |
| 380 | +# |
| 381 | +# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") |
| 382 | +# Returns composed content string on stdout; exit code 1 if not found. |
| 383 | +resolve_template_content() { |
| 384 | + local template_name="$1" |
| 385 | + local repo_root="$2" |
| 386 | + local base="$repo_root/.specify/templates" |
| 387 | + |
| 388 | + # Collect all layers (highest priority first) |
| 389 | + local -a layer_paths=() |
| 390 | + local -a layer_strategies=() |
| 391 | + |
| 392 | + # Priority 1: Project overrides (always "replace") |
| 393 | + local override="$base/overrides/${template_name}.md" |
| 394 | + if [ -f "$override" ]; then |
| 395 | + layer_paths+=("$override") |
| 396 | + layer_strategies+=("replace") |
| 397 | + fi |
| 398 | + |
| 399 | + # Priority 2: Installed presets (sorted by priority from .registry) |
| 400 | + local presets_dir="$repo_root/.specify/presets" |
| 401 | + if [ -d "$presets_dir" ]; then |
| 402 | + local registry_file="$presets_dir/.registry" |
| 403 | + local sorted_presets="" |
| 404 | + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then |
| 405 | + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " |
| 406 | +import json, sys, os |
| 407 | +try: |
| 408 | + with open(os.environ['SPECKIT_REGISTRY']) as f: |
| 409 | + data = json.load(f) |
| 410 | + presets = data.get('presets', {}) |
| 411 | + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)): |
| 412 | + if meta.get('enabled', True) is not False: |
| 413 | + print(pid) |
| 414 | +except Exception: |
| 415 | + sys.exit(1) |
| 416 | +" 2>/dev/null); then |
| 417 | + if [ -n "$sorted_presets" ]; then |
| 418 | + while IFS= read -r preset_id; do |
| 419 | + # Read strategy and file path from preset manifest |
| 420 | + local strategy="replace" |
| 421 | + local manifest_file="" |
| 422 | + local manifest="$presets_dir/$preset_id/preset.yml" |
| 423 | + if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then |
| 424 | + # Requires PyYAML; falls back to replace/convention if unavailable |
| 425 | + local result |
| 426 | + local py_stderr |
| 427 | + py_stderr=$(mktemp) |
| 428 | + result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " |
| 429 | +import sys, os |
| 430 | +try: |
| 431 | + import yaml |
| 432 | +except ImportError: |
| 433 | + print('yaml_missing', file=sys.stderr) |
| 434 | + print('replace\t') |
| 435 | + sys.exit(0) |
| 436 | +try: |
| 437 | + with open(os.environ['SPECKIT_MANIFEST']) as f: |
| 438 | + data = yaml.safe_load(f) |
| 439 | + for t in data.get('provides', {}).get('templates', []): |
| 440 | + if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': |
| 441 | + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) |
| 442 | + sys.exit(0) |
| 443 | + print('replace\t') |
| 444 | +except Exception: |
| 445 | + print('replace\t') |
| 446 | +" 2>"$py_stderr") |
| 447 | + local parse_status=$? |
| 448 | + if [ $parse_status -eq 0 ] && [ -n "$result" ]; then |
| 449 | + IFS=$'\t' read -r strategy manifest_file <<< "$result" |
| 450 | + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') |
| 451 | + fi |
| 452 | + # Warn only when PyYAML is explicitly missing |
| 453 | + if grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then |
| 454 | + echo "Warning: PyYAML not available; composition strategies in $manifest may be ignored" >&2 |
| 455 | + fi |
| 456 | + rm -f "$py_stderr" |
| 457 | + fi |
| 458 | + # Try manifest file path first, then convention path |
| 459 | + local candidate="" |
| 460 | + if [ -n "$manifest_file" ]; then |
| 461 | + local mf="$presets_dir/$preset_id/$manifest_file" |
| 462 | + [ -f "$mf" ] && candidate="$mf" |
| 463 | + fi |
| 464 | + if [ -z "$candidate" ]; then |
| 465 | + local cf="$presets_dir/$preset_id/templates/${template_name}.md" |
| 466 | + [ -f "$cf" ] && candidate="$cf" |
| 467 | + fi |
| 468 | + if [ -n "$candidate" ]; then |
| 469 | + layer_paths+=("$candidate") |
| 470 | + layer_strategies+=("$strategy") |
| 471 | + fi |
| 472 | + done <<< "$sorted_presets" |
| 473 | + fi |
| 474 | + else |
| 475 | + # python3 failed — fall back to unordered directory scan (replace only) |
| 476 | + for preset in "$presets_dir"/*/; do |
| 477 | + [ -d "$preset" ] || continue |
| 478 | + local candidate="$preset/templates/${template_name}.md" |
| 479 | + if [ -f "$candidate" ]; then |
| 480 | + layer_paths+=("$candidate") |
| 481 | + layer_strategies+=("replace") |
| 482 | + fi |
| 483 | + done |
| 484 | + fi |
| 485 | + else |
| 486 | + # No python3 or registry — fall back to unordered directory scan (replace only) |
| 487 | + for preset in "$presets_dir"/*/; do |
| 488 | + [ -d "$preset" ] || continue |
| 489 | + local candidate="$preset/templates/${template_name}.md" |
| 490 | + if [ -f "$candidate" ]; then |
| 491 | + layer_paths+=("$candidate") |
| 492 | + layer_strategies+=("replace") |
| 493 | + fi |
| 494 | + done |
| 495 | + fi |
| 496 | + fi |
| 497 | + |
| 498 | + # Priority 3: Extension-provided templates (always "replace") |
| 499 | + local ext_dir="$repo_root/.specify/extensions" |
| 500 | + if [ -d "$ext_dir" ]; then |
| 501 | + for ext in "$ext_dir"/*/; do |
| 502 | + [ -d "$ext" ] || continue |
| 503 | + case "$(basename "$ext")" in .*) continue;; esac |
| 504 | + local candidate="$ext/templates/${template_name}.md" |
| 505 | + if [ -f "$candidate" ]; then |
| 506 | + layer_paths+=("$candidate") |
| 507 | + layer_strategies+=("replace") |
| 508 | + fi |
| 509 | + done |
| 510 | + fi |
| 511 | + |
| 512 | + # Priority 4: Core templates (always "replace") |
| 513 | + local core="$base/${template_name}.md" |
| 514 | + if [ -f "$core" ]; then |
| 515 | + layer_paths+=("$core") |
| 516 | + layer_strategies+=("replace") |
| 517 | + fi |
| 518 | + |
| 519 | + local count=${#layer_paths[@]} |
| 520 | + [ "$count" -eq 0 ] && return 1 |
| 521 | + |
| 522 | + # Check if any layer uses a non-replace strategy |
| 523 | + local has_composition=false |
| 524 | + for s in "${layer_strategies[@]}"; do |
| 525 | + [ "$s" != "replace" ] && has_composition=true && break |
| 526 | + done |
| 527 | + |
| 528 | + # If the top (highest-priority) layer is replace, it wins entirely — |
| 529 | + # lower layers are irrelevant regardless of their strategies. |
| 530 | + if [ "${layer_strategies[0]}" = "replace" ]; then |
| 531 | + cat "${layer_paths[0]}" |
| 532 | + return 0 |
| 533 | + fi |
| 534 | + |
| 535 | + if [ "$has_composition" = false ]; then |
| 536 | + cat "${layer_paths[0]}" |
| 537 | + return 0 |
| 538 | + fi |
| 539 | + |
| 540 | + # Compose bottom-up: start from lowest priority |
| 541 | + local content="" |
| 542 | + local has_base=false |
| 543 | + local started=false |
| 544 | + local i |
| 545 | + for (( i=count-1; i>=0; i-- )); do |
| 546 | + local path="${layer_paths[$i]}" |
| 547 | + local strat="${layer_strategies[$i]}" |
| 548 | + local layer_content |
| 549 | + # Preserve trailing newlines: append sentinel, then strip it |
| 550 | + layer_content=$(cat "$path"; printf x) |
| 551 | + layer_content="${layer_content%x}" |
| 552 | + |
| 553 | + if [ "$started" = false ]; then |
| 554 | + if [ "$strat" = "replace" ]; then |
| 555 | + content="$layer_content" |
| 556 | + has_base=true |
| 557 | + fi |
| 558 | + # Keep consuming replace layers from the bottom until we hit a non-replace |
| 559 | + if [ "$strat" != "replace" ]; then |
| 560 | + # No base content to compose onto |
| 561 | + [ "$has_base" = false ] && return 1 |
| 562 | + started=true |
| 563 | + case "$strat" in |
| 564 | + prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; |
| 565 | + append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; |
| 566 | + wrap) |
| 567 | + # Validate placeholder exists |
| 568 | + case "$layer_content" in |
| 569 | + *'{CORE_TEMPLATE}'*) ;; |
| 570 | + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; |
| 571 | + esac |
| 572 | + # Replace all occurrences to match Python/PowerShell behavior |
| 573 | + while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do |
| 574 | + local before="${layer_content%%\{CORE_TEMPLATE\}*}" |
| 575 | + local after="${layer_content#*\{CORE_TEMPLATE\}}" |
| 576 | + layer_content="${before}${content}${after}" |
| 577 | + done |
| 578 | + content="$layer_content" |
| 579 | + ;; |
| 580 | + esac |
| 581 | + fi |
| 582 | + else |
| 583 | + case "$strat" in |
| 584 | + replace) content="$layer_content" ;; |
| 585 | + prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; |
| 586 | + append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; |
| 587 | + wrap) |
| 588 | + case "$layer_content" in |
| 589 | + *'{CORE_TEMPLATE}'*) ;; |
| 590 | + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; |
| 591 | + esac |
| 592 | + while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do |
| 593 | + local before="${layer_content%%\{CORE_TEMPLATE\}*}" |
| 594 | + local after="${layer_content#*\{CORE_TEMPLATE\}}" |
| 595 | + layer_content="${before}${content}${after}" |
| 596 | + done |
| 597 | + content="$layer_content" |
| 598 | + ;; |
| 599 | + esac |
| 600 | + fi |
| 601 | + done |
| 602 | + |
| 603 | + printf '%s' "$content" |
| 604 | + return 0 |
| 605 | +} |
| 606 | + |
0 commit comments