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
74 changes: 74 additions & 0 deletions .github/workflows/validate-idea.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Validate Adventure Idea

# Runs on PRs that touch ideas/*.md.
# The * glob excludes dot-folders, so ideas/.implemented/ is already filtered
# by the path pattern. We also filter it explicitly in step 4.
on:
pull_request:
paths:
- 'ideas/*.md'

jobs:
validate:
name: Validate idea file format
runs-on: ubuntu-latest # A fresh Ubuntu VM is spun up for each run.

steps:

# fetch-depth: 0 is required so git diff can compare against the base branch.
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0

# The checkout action does not restore executable bits from git.
- name: Make validation script executable
run: chmod +x scripts/validate-idea.sh

# Validates files added or modified by this PR.
# Excludes the template and .implemented/ as a belt-and-suspenders
# on top of the workflow path filter.
# || true prevents grep from failing the step when no files match.
- name: Find idea files to validate
env:
BASE_REF: ${{ github.base_ref }}
run: |
IDEA_FILES=$(
git diff --name-only --diff-filter=AM "origin/$BASE_REF" HEAD \
| grep "^ideas/" \
| grep "\.md$" \
| grep -v "adventure-idea-template.md" \
| grep -v "\.implemented/" \
|| true
)

if [[ -z "$IDEA_FILES" ]]; then
echo "No idea files to validate in this PR."
else
echo "Idea files to validate:"
echo "$IDEA_FILES"
fi

{
echo "IDEA_FILES<<EOF"
echo "$IDEA_FILES"
echo "EOF"
} >> "$GITHUB_ENV"

- name: Validate idea files
run: |
set -euo pipefail

if [[ -z "$IDEA_FILES" ]]; then
echo "No idea files to validate. Skipping."
exit 0
fi

while IFS= read -r file; do
# Skip blank lines that can appear from the heredoc.
[[ -z "$file" ]] && continue
./scripts/validate-idea.sh "$file"
done <<< "$IDEA_FILES"

echo ""
echo "βœ… All idea files passed validation."
2 changes: 1 addition & 1 deletion ideas/adventure-idea-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@

<!-- =========================================================================
Each level is a standalone challenge. Include Beginner, Intermediate,
and/or Expert - delete any you don't need.
and/or Expert - delete any level block you don't need.

DIFFICULTY GUIDE:
🟒 Beginner - First encounter. Solvable in under an hour with just the docs.
Expand Down
77 changes: 77 additions & 0 deletions lib/scripts/idea-parser.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# idea-parser.sh β€” Shared parsing functions for adventure idea files.
# Sourced by scripts/validate-idea.sh and scripts/new-adventure.sh.
#
# IMPORTANT: The level heading format is enforced here and in both scripts.
# If you change the format, update this file, adventure-idea-template.md,
# and any existing idea files in ideas/.

# Expected format for the # Adventure Idea: header line:
# # Adventure Idea: EMOJI Adventure Name
ADVENTURE_HEADER_PATTERN='^# Adventure Idea: \S+ .+'

# Expected format for a ### level heading line (full line including ###):
# ### EMOJI DIFFICULTY: Level Name
LEVEL_HEADING_PATTERN='^### \S+ \S+: .+'

# parse_adventure_header FILE
# Parses the '# Adventure Idea: EMOJI Name' header line.
# Sets: adventure_emoji, adventure_name
parse_adventure_header() {
local file="$1"
local header
header=$(grep -m1 '^# Adventure Idea:' "$file" | sed 's/^# Adventure Idea: *//')
adventure_emoji=$(echo "$header" | awk '{print $1}')
adventure_name=$(echo "$header" | cut -d' ' -f2-)
}

# parse_level_heading HEADING
# Parses a stripped level heading (without leading "### ") into components.
# Sets: level_emoji, level_difficulty, level_name, level_slug
parse_level_heading() {
local heading="$1"
level_emoji=$(echo "$heading" | awk '{print $1}')
level_difficulty=$(echo "$heading" | awk '{print $2}' | tr -d ':')
level_name=$(echo "$heading" | sed 's/[^ ]* [^:]*: //')
level_slug=$(echo "$level_difficulty" | tr '[:upper:]' '[:lower:]')
}

# extract_overview_field FILE FIELD
# Prints the content of a **FIELD:** block: any inline text on the same line,
# plus continuation lines, until the next bold field (**...**) or ## heading.
extract_overview_field() {
local file="$1" field="$2"
awk -v field="$field" '
$0 ~ ("^\\*\\*" field ":\\*\\*") {
sub("^\\*\\*" field ":\\*\\* *", "")
if (NF > 0) print
in_field = 1
next
}
in_field && (/^\*\*/ || /^##/ || /^---/) { exit }
in_field { print }
' "$file"
}

# extract_level_section FILE LEVEL SECTION
# Prints the content of a #### subsection within a ### level block.
extract_level_section() {
local file="$1" level="$2" section="$3"
awk -v level="$level" -v section="$section" '
/^### / { in_level = ($0 == "### " level); next }
in_level && $0 == "#### " section { in_section=1; next }
in_level && in_section && (/^#### / || /^---/) { in_section=0 }
in_level && in_section { print }
' "$file"
}

# extract_level_description FILE LEVEL
# Prints the Description line for a level block.
extract_level_description() {
local file="$1" level="$2"
awk -v level="$level" '
/^### / { in_level = ($0 == "### " level); in_desc = 0; next }
in_level && /^#### / { if (/^#### Description/) { in_desc = 1 } else { in_desc = 0 }; next }
in_level && in_desc && NF > 0 { print; exit }
' "$file"
}
45 changes: 12 additions & 33 deletions scripts/new-adventure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IDEAS_DIR="$REPO_ROOT/ideas"

# shellcheck source=../lib/scripts/idea-parser.sh
source "$REPO_ROOT/lib/scripts/idea-parser.sh"

# ─── Select adventure ────────────────────────────────────────────────────────

selected_slug=$(find "$IDEAS_DIR" -maxdepth 1 -name "*.md" ! -name "adventure-idea-template.md" -exec basename {} .md \; | sort \
| gum choose --header "Which adventure do you want to scaffold?")
selected_file="$IDEAS_DIR/$selected_slug.md"
adventure_header=$(grep -m1 '^# Adventure Idea:' "$selected_file" | sed 's/^# Adventure Idea: *//')
adventure_emoji=$(echo "$adventure_header" | awk '{print $1}')
adventure_name=$(echo "$adventure_header" | cut -d' ' -f2-)
parse_adventure_header "$selected_file"

# ─── Select level ────────────────────────────────────────────────────────────

Expand All @@ -20,10 +21,7 @@ selected_level=$(echo "$level_lines" | gum choose --header "Which level do you w

# ─── Parse level metadata ─────────────────────────────────────────────────────

level_emoji=$(echo "$selected_level" | awk '{print $1}')
level_difficulty=$(echo "$selected_level" | awk '{print $2}' | tr -d ':')
level_name=$(echo "$selected_level" | sed 's/[^ ]* [^:]*: //')
level_slug=$(echo "$level_difficulty" | tr '[:upper:]' '[:lower:]')
parse_level_heading "$selected_level"

# position of selected level among ### headings (1st = 01, 2nd = 02, ...)
level_number=$(echo "$level_lines" \
Expand All @@ -38,12 +36,8 @@ echo ""
# ─── Scaffold adventure base ──────────────────────────────────────────────────

ADVENTURE_DIR="$REPO_ROOT/adventures/planned/00-$selected_slug"
adventure_technologies=$(grep -m1 '^\*\*Technologies:\*\*' "$selected_file" | sed 's/^\*\*Technologies:\*\* *//')
adventure_theme=$(awk '
/^\*\*Theme:\*\*/ { sub(/^\*\*Theme:\*\* */, ""); p=1; print; next }
p && /^\*\*/ { exit }
p { print }
' "$selected_file")
adventure_technologies=$(extract_overview_field "$selected_file" "Technologies")
adventure_theme=$(extract_overview_field "$selected_file" "Theme")

if [[ ! -d "$ADVENTURE_DIR" ]]; then
echo "Creating adventure base at adventures/planned/00-$selected_slug/ ..."
Expand Down Expand Up @@ -96,26 +90,11 @@ fi

# ─── Scaffold level doc ───────────────────────────────────────────────────────

# Extracts a #### subsection for the selected level from the idea file
extract_level_section() {
local section="$1"
awk -v level="$selected_level" -v section="$section" '
/^### / { in_level = ($0 == "### " level); next }
in_level && $0 == "#### " section { in_section=1; next }
in_level && in_section && (/^#### / || /^---/) { in_section=0 }
in_level && in_section { print }
' "$selected_file"
}

level_summary=$(awk -v level="$selected_level" '
/^### / { in_level = ($0 == "### " level); next }
in_level && /^#### Description/ { in_desc = 1; next }
in_desc && NF > 0 { print; exit }
' "$selected_file")
level_story=$(extract_level_section "Story")
level_objective=$(extract_level_section "Objective")
level_learnings=$(extract_level_section "What You'll Learn")
level_tools=$(extract_level_section "Tools & Infrastructure")
level_summary=$(extract_level_description "$selected_file" "$selected_level")
level_story=$(extract_level_section "$selected_file" "$selected_level" "Story")
level_objective=$(extract_level_section "$selected_file" "$selected_level" "Objective")
level_learnings=$(extract_level_section "$selected_file" "$selected_level" "What You'll Learn")
level_tools=$(extract_level_section "$selected_file" "$selected_level" "Tools & Infrastructure")

LEVEL_DOC="$ADVENTURE_DIR/docs/$level_slug.md"

Expand Down
Loading