td-ai-tools 1.0.2
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.
- package/README.md +49 -0
- package/agents/README.md +10 -0
- package/agents/horizon-component-library/AGENTS.md +184 -0
- package/agents/horizon-component-library/README.md +10 -0
- package/agents/horizon-component-library/scripts/td-guard.sh +49 -0
- package/bin/cli.js +209 -0
- package/package.json +23 -0
- package/scripts/smoke-install.sh +56 -0
- package/skills/README.md +15 -0
- package/skills/cache-reset/SKILL.md +18 -0
- package/skills/cache-reset/agents/openai.yaml +4 -0
- package/skills/car-ticket-generator/SKILL.md +130 -0
- package/skills/car-ticket-generator/agents/openai.yaml +4 -0
- package/skills/everhour-basecamp-estimates/.env.example +2 -0
- package/skills/everhour-basecamp-estimates/SKILL.md +73 -0
- package/skills/everhour-basecamp-estimates/agents/openai.yaml +4 -0
- package/skills/everhour-basecamp-estimates/scripts/update_estimates.py +518 -0
- package/skills/everhour-basecamp-estimates/tests/test_update_estimates.py +93 -0
- package/skills/horizon-component-migration/SKILL.md +59 -0
- package/skills/horizon-component-migration/agents/openai.yaml +4 -0
- package/skills/pr-solver/SKILL.md +50 -0
- package/skills/pr-solver/agents/openai.yaml +4 -0
- package/skills/pr-solver/references/github-pr-reviewthreads-graphql.md +63 -0
- package/skills/pr-solver/scripts/list_unresolved_threads.py +307 -0
- package/skills/pull-request/SKILL.md +216 -0
- package/skills/pull-request/agents/openai.yaml +4 -0
- package/skills/record-changes/SKILL.md +75 -0
- package/skills/record-changes/agents/openai.yaml +4 -0
- package/skills/record-changes/scripts/branch_diff_context.py +190 -0
- package/skills/stylesheet-migration/SKILL.md +36 -0
- package/skills/stylesheet-migration/agents/openai.yaml +6 -0
- package/skills/stylesheet-migration/scripts/__pycache__/liquid_stylesheet_migrator.cpython-312.pyc +0 -0
- package/skills/stylesheet-migration/scripts/__pycache__/test_liquid_stylesheet_migrator.cpython-312.pyc +0 -0
- package/skills/stylesheet-migration/scripts/liquid_stylesheet_migrator.py +204 -0
- package/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py +172 -0
- package/skills/stylesheet-migration/scripts/test_liquid_stylesheet_migrator.py +254 -0
- package/skills/td-js-vanilla-rules/SKILL.md +70 -0
- package/skills/td-js-vanilla-rules/agents/openai.yaml +3 -0
- package/skills/td-review/SKILL.md +122 -0
- package/skills/td-review/agents/openai.yaml +4 -0
- package/skills/td-review/agents/td-theme-reviewer.md +221 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: record-changes
|
|
3
|
+
description: Update `docs/changes.md` by summarizing the current branch against the primary development branch. Use when a developer asks to record branch changes, document theme customizations, refresh the project change log, or write a branch summary into `docs/changes.md`. Prefer comparing against `main`, but fall back to `origin/main`, `master`, or `origin/master` when the repository does not have a local `main` branch.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Record Changes
|
|
7
|
+
|
|
8
|
+
Update `docs/changes.md` from the current branch diff. Preserve the existing document structure and add a concise, human-readable entry for the branch.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
### 1. Collect Branch Context
|
|
13
|
+
|
|
14
|
+
Run the bundled script first:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
python3 .agents/skills/record-changes/scripts/branch_diff_context.py
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If the developer specifies another comparison branch, pass it explicitly:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
python3 .agents/skills/record-changes/scripts/branch_diff_context.py --base release/x.y
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Read the existing `docs/changes.md` before editing so the new entry matches the current ordering, tone, and section layout.
|
|
27
|
+
|
|
28
|
+
### 2. Inspect the Actual Code Changes
|
|
29
|
+
|
|
30
|
+
Use the script output to identify changed files, then inspect the relevant diffs and file contents with `git diff` and targeted file reads.
|
|
31
|
+
|
|
32
|
+
Prioritize:
|
|
33
|
+
|
|
34
|
+
- User-facing behavior changes
|
|
35
|
+
- Theme setting/schema changes
|
|
36
|
+
- CSS or markup changes that affect storefront rendering
|
|
37
|
+
- Renamed files and vendor-theme hotspots
|
|
38
|
+
|
|
39
|
+
Do not summarize the `docs/changes.md` edit itself as part of the branch work. If the branch contains unrelated skill or tooling files, either omit them from the changelog entry or separate them clearly when they are relevant to the project's maintenance history.
|
|
40
|
+
|
|
41
|
+
### 3. Write the Changelog Entry
|
|
42
|
+
|
|
43
|
+
Add a new entry near the top of `docs/changes.md`, directly under the intro, unless the file already uses another ordering convention.
|
|
44
|
+
|
|
45
|
+
Follow the existing pattern:
|
|
46
|
+
|
|
47
|
+
- `## <short title>`
|
|
48
|
+
- `**Date:** YYYY-MM-DD`
|
|
49
|
+
- `### Purpose`
|
|
50
|
+
- `### Files changed`
|
|
51
|
+
- `### Upgrade impact`
|
|
52
|
+
- `### Notes`
|
|
53
|
+
|
|
54
|
+
Guidelines:
|
|
55
|
+
|
|
56
|
+
- Make the title describe the feature or fix, not the branch name.
|
|
57
|
+
- Write `Purpose` in plain language with outcome-focused bullets.
|
|
58
|
+
- Use the `Files changed` table to explain why each file matters.
|
|
59
|
+
- Call out non-`td-` theme or vendor files in a separate subsection when they are upgrade-sensitive.
|
|
60
|
+
- Mention renamed files explicitly.
|
|
61
|
+
- Keep `Upgrade impact` brief and concrete.
|
|
62
|
+
- Use `Notes` for implementation details, edge cases, or assumptions.
|
|
63
|
+
|
|
64
|
+
### 4. Verify Before Finishing
|
|
65
|
+
|
|
66
|
+
Before wrapping up:
|
|
67
|
+
|
|
68
|
+
- Re-read the new markdown entry in context.
|
|
69
|
+
- Confirm every listed file appears in the diff.
|
|
70
|
+
- Confirm the documented behavior matches the code, not just the branch name.
|
|
71
|
+
- Mention any uncertainty if the diff is too broad to summarize with high confidence.
|
|
72
|
+
|
|
73
|
+
## Resource
|
|
74
|
+
|
|
75
|
+
- `scripts/branch_diff_context.py`: Resolve the best available base branch and print a branch summary with file statuses and line counts.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_CANDIDATES = ("main", "origin/main", "master", "origin/master")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_git(*args: str) -> str:
|
|
14
|
+
completed = subprocess.run(
|
|
15
|
+
["git", *args],
|
|
16
|
+
check=True,
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
)
|
|
20
|
+
return completed.stdout.strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ref_exists(ref: str) -> bool:
|
|
24
|
+
completed = subprocess.run(
|
|
25
|
+
["git", "rev-parse", "--verify", f"{ref}^{{commit}}"],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
)
|
|
29
|
+
return completed.returncode == 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_base_ref(explicit_base: str | None) -> str:
|
|
33
|
+
if explicit_base:
|
|
34
|
+
if not ref_exists(explicit_base):
|
|
35
|
+
raise SystemExit(f"Base ref not found: {explicit_base}")
|
|
36
|
+
return explicit_base
|
|
37
|
+
|
|
38
|
+
for candidate in DEFAULT_BASE_CANDIDATES:
|
|
39
|
+
if ref_exists(candidate):
|
|
40
|
+
return candidate
|
|
41
|
+
|
|
42
|
+
searched = ", ".join(DEFAULT_BASE_CANDIDATES)
|
|
43
|
+
raise SystemExit(f"No base ref found. Tried: {searched}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_name_status(output: str) -> list[dict[str, str]]:
|
|
47
|
+
files = []
|
|
48
|
+
for line in output.splitlines():
|
|
49
|
+
if not line:
|
|
50
|
+
continue
|
|
51
|
+
parts = line.split("\t")
|
|
52
|
+
status = parts[0]
|
|
53
|
+
|
|
54
|
+
if status.startswith("R") and len(parts) >= 3:
|
|
55
|
+
files.append(
|
|
56
|
+
{
|
|
57
|
+
"status": status,
|
|
58
|
+
"path": parts[2],
|
|
59
|
+
"old_path": parts[1],
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
path = parts[1] if len(parts) > 1 else ""
|
|
65
|
+
files.append({"status": status, "path": path, "old_path": ""})
|
|
66
|
+
return files
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_numstat(output: str) -> dict[str, dict[str, str]]:
|
|
70
|
+
stats = {}
|
|
71
|
+
for line in output.splitlines():
|
|
72
|
+
if not line:
|
|
73
|
+
continue
|
|
74
|
+
parts = line.split("\t")
|
|
75
|
+
if len(parts) < 3:
|
|
76
|
+
continue
|
|
77
|
+
additions, deletions, path = parts[0], parts[1], parts[2]
|
|
78
|
+
stats[path] = {"additions": additions, "deletions": deletions}
|
|
79
|
+
return stats
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_stats(
|
|
83
|
+
numstat: dict[str, dict[str, str]], path: str, old_path: str
|
|
84
|
+
) -> dict[str, str] | None:
|
|
85
|
+
stats = numstat.get(path)
|
|
86
|
+
if stats or not old_path:
|
|
87
|
+
return stats
|
|
88
|
+
|
|
89
|
+
path_dir = os.path.dirname(path)
|
|
90
|
+
old_dir = os.path.dirname(old_path)
|
|
91
|
+
path_name = os.path.basename(path)
|
|
92
|
+
old_name = os.path.basename(old_path)
|
|
93
|
+
|
|
94
|
+
for candidate_path, candidate_stats in numstat.items():
|
|
95
|
+
if old_path in candidate_path and path in candidate_path:
|
|
96
|
+
return candidate_stats
|
|
97
|
+
if (
|
|
98
|
+
path_dir == old_dir
|
|
99
|
+
and path_dir
|
|
100
|
+
and candidate_path.startswith(f"{path_dir}/")
|
|
101
|
+
and old_name in candidate_path
|
|
102
|
+
and path_name in candidate_path
|
|
103
|
+
):
|
|
104
|
+
return candidate_stats
|
|
105
|
+
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_context(base_ref: str) -> dict[str, object]:
|
|
110
|
+
current_branch = run_git("branch", "--show-current")
|
|
111
|
+
merge_base = run_git("merge-base", base_ref, "HEAD")
|
|
112
|
+
shortstat = run_git("diff", "--shortstat", merge_base, "HEAD")
|
|
113
|
+
name_status = parse_name_status(
|
|
114
|
+
run_git("diff", "--name-status", "--find-renames", merge_base, "HEAD")
|
|
115
|
+
)
|
|
116
|
+
numstat = parse_numstat(run_git("diff", "--numstat", "--find-renames", merge_base, "HEAD"))
|
|
117
|
+
|
|
118
|
+
for entry in name_status:
|
|
119
|
+
stats = resolve_stats(numstat, entry["path"], entry["old_path"])
|
|
120
|
+
entry["additions"] = stats["additions"] if stats else "?"
|
|
121
|
+
entry["deletions"] = stats["deletions"] if stats else "?"
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"current_branch": current_branch,
|
|
125
|
+
"base_ref": base_ref,
|
|
126
|
+
"merge_base": merge_base,
|
|
127
|
+
"shortstat": shortstat,
|
|
128
|
+
"files": name_status,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def print_markdown(context: dict[str, object]) -> None:
|
|
133
|
+
print("# Branch Change Context")
|
|
134
|
+
print()
|
|
135
|
+
print(f"- Current branch: `{context['current_branch']}`")
|
|
136
|
+
print(f"- Base ref: `{context['base_ref']}`")
|
|
137
|
+
print(f"- Merge base: `{context['merge_base']}`")
|
|
138
|
+
print(f"- Summary: {context['shortstat'] or 'No changes detected'}")
|
|
139
|
+
print()
|
|
140
|
+
|
|
141
|
+
files = context["files"]
|
|
142
|
+
if not files:
|
|
143
|
+
print("No changed files detected.")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
print("| Status | Path | +/- |")
|
|
147
|
+
print("|--------|------|-----|")
|
|
148
|
+
|
|
149
|
+
for entry in files:
|
|
150
|
+
status = entry["status"]
|
|
151
|
+
path = entry["path"]
|
|
152
|
+
if entry["old_path"]:
|
|
153
|
+
path = f"{entry['old_path']} -> {entry['path']}"
|
|
154
|
+
delta = f"+{entry['additions']} / -{entry['deletions']}"
|
|
155
|
+
print(f"| `{status}` | `{path}` | {delta} |")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main() -> int:
|
|
159
|
+
parser = argparse.ArgumentParser(
|
|
160
|
+
description="Resolve a base branch and print context for documenting branch changes."
|
|
161
|
+
)
|
|
162
|
+
parser.add_argument(
|
|
163
|
+
"--base",
|
|
164
|
+
help="Explicit base ref to compare against. Defaults to main/origin-main or master/origin-master fallback.",
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--format",
|
|
168
|
+
choices=("markdown", "json"),
|
|
169
|
+
default="markdown",
|
|
170
|
+
help="Output format.",
|
|
171
|
+
)
|
|
172
|
+
args = parser.parse_args()
|
|
173
|
+
|
|
174
|
+
base_ref = resolve_base_ref(args.base)
|
|
175
|
+
context = build_context(base_ref)
|
|
176
|
+
|
|
177
|
+
if args.format == "json":
|
|
178
|
+
print(json.dumps(context, indent=2))
|
|
179
|
+
else:
|
|
180
|
+
print_markdown(context)
|
|
181
|
+
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
try:
|
|
187
|
+
raise SystemExit(main())
|
|
188
|
+
except subprocess.CalledProcessError as error:
|
|
189
|
+
sys.stderr.write(error.stderr or str(error))
|
|
190
|
+
raise SystemExit(error.returncode)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stylesheet-migration
|
|
3
|
+
description: Migrate Shopify Liquid `{% stylesheet %}` blocks into theme CSS assets using bundled Python scripts. Use when moving inline section or snippet stylesheet tags into `assets/*.css`, especially in `sections/` and `snippets/`. By default the scripts only process filenames that start with `td-`, creating `section-<source>.css` for files in `sections/` and `component-<source>.css` for files in `snippets/`, unless the caller specifies a different prefix or disables the prefix filter.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Stylesheet Migration
|
|
7
|
+
|
|
8
|
+
Run the bundled CLI from the theme root:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py --dry-run
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Default behavior
|
|
15
|
+
- Scans `sections/*.liquid` and `snippets/*.liquid`.
|
|
16
|
+
- Only considers filenames that start with `td-`.
|
|
17
|
+
- Extracts CSS from each `{% stylesheet %}...{% endstylesheet %}` block.
|
|
18
|
+
- Writes assets to `assets/section-<stem>.css` or `assets/component-<stem>.css`.
|
|
19
|
+
- Inserts `{{ '...' | asset_url | stylesheet_tag }}` at the top of the source file, below any leading doc comment, and removes all stylesheet blocks from the source file.
|
|
20
|
+
|
|
21
|
+
Common variants
|
|
22
|
+
- Different prefix: `python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py --prefix promo-`
|
|
23
|
+
- Multiple prefixes: `python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py --prefix td- --prefix promo-`
|
|
24
|
+
- No prefix filter: `python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py --no-prefix-filter`
|
|
25
|
+
- Limit the scope to specific files or directories: `python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py sections/td-main-bundle-product.liquid snippets/`
|
|
26
|
+
- Overwrite an existing asset when its contents differ: `python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py --overwrite-assets`
|
|
27
|
+
|
|
28
|
+
Workflow
|
|
29
|
+
1. Run with `--dry-run` first.
|
|
30
|
+
2. Review the planned asset names and any conflicts.
|
|
31
|
+
3. Re-run without `--dry-run` once the scope is correct.
|
|
32
|
+
|
|
33
|
+
Notes
|
|
34
|
+
- Files outside `sections/` and `snippets/` are ignored.
|
|
35
|
+
- Existing asset files are preserved unless `--overwrite-assets` is passed.
|
|
36
|
+
- If the Liquid file already includes the generated asset, the script removes the inline stylesheet block without inserting a duplicate include.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "Stylesheet Migration"
|
|
3
|
+
short_description: "Move Liquid stylesheet blocks into theme CSS assets"
|
|
4
|
+
default_prompt: "Use $stylesheet-migration to move `{% stylesheet %}` blocks from Shopify Liquid files into asset CSS files."
|
|
5
|
+
policy:
|
|
6
|
+
allow_implicit_invocation: true
|
package/skills/stylesheet-migration/scripts/__pycache__/liquid_stylesheet_migrator.cpython-312.pyc
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
DEFAULT_PREFIXES = ("td-",)
|
|
8
|
+
DEFAULT_SOURCE_DIRS = ("sections", "snippets")
|
|
9
|
+
|
|
10
|
+
STYLESHEET_BLOCK_RE = re.compile(
|
|
11
|
+
r"(?P<indent>^[ \t]*)\{%-?\s*stylesheet\s*-?%\}[ \t]*\r?\n?"
|
|
12
|
+
r"(?P<content>.*?)"
|
|
13
|
+
r"(?:(?:\r?\n)?(?P=indent))\{%-?\s*endstylesheet\s*-?%\}[ \t]*(?:\r?\n)?",
|
|
14
|
+
re.DOTALL | re.MULTILINE,
|
|
15
|
+
)
|
|
16
|
+
LIQUID_COMMENT_BLOCK_RE = re.compile(
|
|
17
|
+
r"\{%-?\s*comment\s*-?%\}.*?\{%-?\s*endcomment\s*-?%\}",
|
|
18
|
+
re.DOTALL,
|
|
19
|
+
)
|
|
20
|
+
LEADING_COMMENT_RE = re.compile(
|
|
21
|
+
r"^(?P<comment>\{%-?\s*comment\s*-?%\}.*?\{%-?\s*endcomment\s*-?%\})(?P<spacing>(?:\r?\n)*)",
|
|
22
|
+
re.DOTALL,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class MigrationPlan:
|
|
28
|
+
source_path: Path
|
|
29
|
+
asset_path: Path
|
|
30
|
+
asset_name: str
|
|
31
|
+
asset_content: str
|
|
32
|
+
updated_source: str
|
|
33
|
+
block_count: int
|
|
34
|
+
include_inserted: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MigrationError(RuntimeError):
|
|
38
|
+
"""Raised when a source file cannot be migrated safely."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_text_preserve_newlines(path: Path) -> str:
|
|
42
|
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
43
|
+
return handle.read()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_text_preserve_newlines(path: Path, content: str) -> None:
|
|
47
|
+
with path.open("w", encoding="utf-8", newline="") as handle:
|
|
48
|
+
handle.write(content)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_newline(text: str) -> str:
|
|
52
|
+
return "\r\n" if "\r\n" in text else "\n"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_supported_source(root: Path, path: Path) -> bool:
|
|
56
|
+
try:
|
|
57
|
+
relative_path = path.relative_to(root)
|
|
58
|
+
except ValueError:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
path.is_file()
|
|
63
|
+
and path.suffix == ".liquid"
|
|
64
|
+
and len(relative_path.parts) >= 2
|
|
65
|
+
and relative_path.parts[0] in DEFAULT_SOURCE_DIRS
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def asset_name_for_source(root: Path, source_path: Path) -> str:
|
|
70
|
+
relative_path = source_path.relative_to(root)
|
|
71
|
+
source_dir = relative_path.parts[0]
|
|
72
|
+
|
|
73
|
+
if source_dir == "sections":
|
|
74
|
+
prefix = "section-"
|
|
75
|
+
elif source_dir == "snippets":
|
|
76
|
+
prefix = "component-"
|
|
77
|
+
else:
|
|
78
|
+
raise MigrationError(
|
|
79
|
+
f"{source_path} must live in sections/ or snippets/ to derive an asset name."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return f"{prefix}{source_path.stem}.css"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_asset_include(asset_name: str, indent: str = "") -> str:
|
|
86
|
+
return f"{indent}{{{{ '{asset_name}' | asset_url | stylesheet_tag }}}}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_asset_include_pattern(asset_name: str) -> re.Pattern[str]:
|
|
90
|
+
return re.compile(
|
|
91
|
+
r"\{\{\-?\s*['\"]"
|
|
92
|
+
+ re.escape(asset_name)
|
|
93
|
+
+ r"['\"]\s*\|\s*asset_url\s*\|\s*stylesheet_tag(?:\s*:\s*[^}]*)?\s*\-?\}\}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def normalize_stylesheet_content(content: str, newline: str) -> str:
|
|
98
|
+
normalized = content.replace("\r\n", "\n").strip("\n")
|
|
99
|
+
return normalized.replace("\n", newline)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def find_active_stylesheet_matches(source_text: str) -> list[re.Match[str]]:
|
|
103
|
+
comment_ranges = [
|
|
104
|
+
(match.start(), match.end()) for match in LIQUID_COMMENT_BLOCK_RE.finditer(source_text)
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
return [
|
|
108
|
+
match
|
|
109
|
+
for match in STYLESHEET_BLOCK_RE.finditer(source_text)
|
|
110
|
+
if not any(
|
|
111
|
+
comment_start <= match.start() and match.end() <= comment_end
|
|
112
|
+
for comment_start, comment_end in comment_ranges
|
|
113
|
+
)
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def insert_include_at_top(source_text: str, asset_name: str, newline: str) -> str:
|
|
118
|
+
include = build_asset_include(asset_name)
|
|
119
|
+
match = LEADING_COMMENT_RE.match(source_text)
|
|
120
|
+
|
|
121
|
+
if match:
|
|
122
|
+
comment = match.group("comment")
|
|
123
|
+
body = source_text[match.end() :].lstrip("\r\n")
|
|
124
|
+
return f"{comment}{newline}{newline}{include}{newline}{newline}{body}" if body else (
|
|
125
|
+
f"{comment}{newline}{newline}{include}{newline}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
body = source_text.lstrip("\r\n")
|
|
129
|
+
return f"{include}{newline}{newline}{body}" if body else f"{include}{newline}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def plan_migration(root: Path, source_path: Path) -> MigrationPlan | None:
|
|
133
|
+
source_text = read_text_preserve_newlines(source_path)
|
|
134
|
+
matches = find_active_stylesheet_matches(source_text)
|
|
135
|
+
|
|
136
|
+
if not matches:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
newline = detect_newline(source_text)
|
|
140
|
+
asset_name = asset_name_for_source(root, source_path)
|
|
141
|
+
asset_path = root / "assets" / asset_name
|
|
142
|
+
asset_include_exists = bool(build_asset_include_pattern(asset_name).search(source_text))
|
|
143
|
+
|
|
144
|
+
asset_chunks = [
|
|
145
|
+
normalize_stylesheet_content(match.group("content"), newline)
|
|
146
|
+
for match in matches
|
|
147
|
+
if match.group("content").strip()
|
|
148
|
+
]
|
|
149
|
+
if not asset_chunks:
|
|
150
|
+
raise MigrationError(f"{source_path} contains only empty stylesheet blocks.")
|
|
151
|
+
|
|
152
|
+
updated_parts: list[str] = []
|
|
153
|
+
cursor = 0
|
|
154
|
+
include_inserted = False
|
|
155
|
+
keep_existing_include = asset_include_exists
|
|
156
|
+
|
|
157
|
+
for match in matches:
|
|
158
|
+
updated_parts.append(source_text[cursor : match.start()])
|
|
159
|
+
cursor = match.end()
|
|
160
|
+
|
|
161
|
+
updated_parts.append(source_text[cursor:])
|
|
162
|
+
updated_source = "".join(updated_parts)
|
|
163
|
+
|
|
164
|
+
if not keep_existing_include:
|
|
165
|
+
updated_source = insert_include_at_top(updated_source, asset_name, newline)
|
|
166
|
+
include_inserted = True
|
|
167
|
+
|
|
168
|
+
asset_content = (f"{newline}{newline}".join(asset_chunks)).rstrip("\r\n") + newline
|
|
169
|
+
|
|
170
|
+
return MigrationPlan(
|
|
171
|
+
source_path=source_path,
|
|
172
|
+
asset_path=asset_path,
|
|
173
|
+
asset_name=asset_name,
|
|
174
|
+
asset_content=asset_content,
|
|
175
|
+
updated_source=updated_source,
|
|
176
|
+
block_count=len(matches),
|
|
177
|
+
include_inserted=include_inserted,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def validate_migration(plan: MigrationPlan, overwrite_assets: bool = False) -> None:
|
|
182
|
+
if not plan.asset_path.exists():
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
existing_asset = read_text_preserve_newlines(plan.asset_path)
|
|
186
|
+
if existing_asset != plan.asset_content and not overwrite_assets:
|
|
187
|
+
raise MigrationError(
|
|
188
|
+
f"{plan.asset_path} already exists with different contents. "
|
|
189
|
+
"Re-run with --overwrite-assets to replace it."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def write_migration(plan: MigrationPlan, overwrite_assets: bool = False) -> None:
|
|
194
|
+
plan.asset_path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
validate_migration(plan, overwrite_assets=overwrite_assets)
|
|
196
|
+
|
|
197
|
+
if plan.asset_path.exists():
|
|
198
|
+
existing_asset = read_text_preserve_newlines(plan.asset_path)
|
|
199
|
+
if existing_asset != plan.asset_content:
|
|
200
|
+
write_text_preserve_newlines(plan.asset_path, plan.asset_content)
|
|
201
|
+
else:
|
|
202
|
+
write_text_preserve_newlines(plan.asset_path, plan.asset_content)
|
|
203
|
+
|
|
204
|
+
write_text_preserve_newlines(plan.source_path, plan.updated_source)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from liquid_stylesheet_migrator import (
|
|
9
|
+
DEFAULT_PREFIXES,
|
|
10
|
+
DEFAULT_SOURCE_DIRS,
|
|
11
|
+
MigrationError,
|
|
12
|
+
is_supported_source,
|
|
13
|
+
plan_migration,
|
|
14
|
+
validate_migration,
|
|
15
|
+
write_migration,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_args() -> argparse.Namespace:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
description=(
|
|
22
|
+
"Move Shopify Liquid {% stylesheet %} blocks into assets/*.css files. "
|
|
23
|
+
"By default only files whose names start with td- are processed."
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"paths",
|
|
28
|
+
nargs="*",
|
|
29
|
+
help="Optional files or directories to scan. Defaults to sections/ and snippets/.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--root",
|
|
33
|
+
default=".",
|
|
34
|
+
help="Theme root directory. Defaults to the current working directory.",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--prefix",
|
|
38
|
+
action="append",
|
|
39
|
+
dest="prefixes",
|
|
40
|
+
help="Filename prefix to include. May be passed multiple times. Defaults to td-.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--no-prefix-filter",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Process all supported Liquid files in scope, regardless of filename prefix.",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--overwrite-assets",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="Overwrite an existing generated asset when its contents differ.",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--dry-run",
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="Print the planned migrations without writing any files.",
|
|
56
|
+
)
|
|
57
|
+
return parser.parse_args()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_path(root: Path, raw_path: str) -> Path:
|
|
61
|
+
path = Path(raw_path)
|
|
62
|
+
if not path.is_absolute():
|
|
63
|
+
path = root / path
|
|
64
|
+
return path.resolve()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def collect_source_paths(root: Path, raw_paths: list[str]) -> tuple[list[Path], list[str]]:
|
|
68
|
+
discovered: list[Path] = []
|
|
69
|
+
errors: list[str] = []
|
|
70
|
+
|
|
71
|
+
if raw_paths:
|
|
72
|
+
candidates = [resolve_path(root, raw_path) for raw_path in raw_paths]
|
|
73
|
+
else:
|
|
74
|
+
candidates = [root / source_dir for source_dir in DEFAULT_SOURCE_DIRS]
|
|
75
|
+
|
|
76
|
+
for candidate in candidates:
|
|
77
|
+
if not candidate.exists():
|
|
78
|
+
errors.append(f"{candidate} does not exist.")
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
if candidate.is_dir():
|
|
82
|
+
discovered.extend(sorted(candidate.rglob("*.liquid")))
|
|
83
|
+
elif candidate.is_file():
|
|
84
|
+
discovered.append(candidate)
|
|
85
|
+
else:
|
|
86
|
+
errors.append(f"{candidate} is not a file or directory.")
|
|
87
|
+
|
|
88
|
+
unique_paths = sorted({path.resolve() for path in discovered})
|
|
89
|
+
return unique_paths, errors
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def matches_prefix(path: Path, prefixes: tuple[str, ...], no_prefix_filter: bool) -> bool:
|
|
93
|
+
return no_prefix_filter or any(path.name.startswith(prefix) for prefix in prefixes)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def summarize_count(count: int, singular: str, plural: str | None = None) -> str:
|
|
97
|
+
if count == 1:
|
|
98
|
+
return f"1 {singular}"
|
|
99
|
+
return f"{count} {plural or singular + 's'}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main() -> int:
|
|
103
|
+
args = parse_args()
|
|
104
|
+
root = Path(args.root).resolve()
|
|
105
|
+
prefixes = tuple(args.prefixes or DEFAULT_PREFIXES)
|
|
106
|
+
|
|
107
|
+
source_paths, collection_errors = collect_source_paths(root, args.paths)
|
|
108
|
+
for message in collection_errors:
|
|
109
|
+
print(f"ERROR {message}", file=sys.stderr)
|
|
110
|
+
|
|
111
|
+
if not source_paths:
|
|
112
|
+
if collection_errors:
|
|
113
|
+
return 1
|
|
114
|
+
print("No Liquid files found in scope.", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
eligible_paths = [
|
|
118
|
+
path
|
|
119
|
+
for path in source_paths
|
|
120
|
+
if is_supported_source(root, path) and matches_prefix(path, prefixes, args.no_prefix_filter)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
ignored_count = len(source_paths) - len(eligible_paths)
|
|
124
|
+
migrated_count = 0
|
|
125
|
+
no_block_count = 0
|
|
126
|
+
error_count = len(collection_errors)
|
|
127
|
+
|
|
128
|
+
for source_path in eligible_paths:
|
|
129
|
+
relative_source = source_path.relative_to(root)
|
|
130
|
+
try:
|
|
131
|
+
plan = plan_migration(root, source_path)
|
|
132
|
+
if plan is None:
|
|
133
|
+
no_block_count += 1
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
relative_asset = plan.asset_path.relative_to(root)
|
|
137
|
+
include_note = "reused existing include" if not plan.include_inserted else "inserted include"
|
|
138
|
+
|
|
139
|
+
if args.dry_run:
|
|
140
|
+
validate_migration(plan, overwrite_assets=args.overwrite_assets)
|
|
141
|
+
action = "Would migrate"
|
|
142
|
+
else:
|
|
143
|
+
write_migration(plan, overwrite_assets=args.overwrite_assets)
|
|
144
|
+
action = "Migrated"
|
|
145
|
+
|
|
146
|
+
print(
|
|
147
|
+
f"{action} {relative_source} -> {relative_asset} "
|
|
148
|
+
f"({summarize_count(plan.block_count, 'block')}; {include_note})"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
migrated_count += 1
|
|
152
|
+
except MigrationError as exc:
|
|
153
|
+
print(f"ERROR {relative_source}: {exc}", file=sys.stderr)
|
|
154
|
+
error_count += 1
|
|
155
|
+
|
|
156
|
+
summary = [
|
|
157
|
+
summarize_count(len(source_paths), "candidate"),
|
|
158
|
+
summarize_count(len(eligible_paths), "eligible file"),
|
|
159
|
+
summarize_count(migrated_count, "migration"),
|
|
160
|
+
summarize_count(no_block_count, "file without a stylesheet block", "files without stylesheet blocks"),
|
|
161
|
+
]
|
|
162
|
+
if ignored_count:
|
|
163
|
+
summary.append(summarize_count(ignored_count, "ignored file"))
|
|
164
|
+
if error_count:
|
|
165
|
+
summary.append(summarize_count(error_count, "error"))
|
|
166
|
+
|
|
167
|
+
print("Summary: " + ", ".join(summary))
|
|
168
|
+
return 1 if error_count else 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
sys.exit(main())
|