Skip to content
Closed
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
26 changes: 24 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Next Commerce Theme Kit

CLI tool (`ntk`) for building and maintaining storefront themes on the Next Commerce platform. Supports Sass processing via `libsass`.
CLI tool (`ntk`) for building and maintaining storefront themes on the Next Commerce platform. Supports Sass processing via `libsass` and Tailwind CSS compilation.

## Project Structure

```
ntk/
__main__.py # Entry point
command.py # All CLI commands (watch, push, pull, checkout, init, list, sass)
command.py # All CLI commands (watch, push, pull, checkout, init, list, sass, tailwind)
conf.py # Config loading and constants
decorator.py # @parser_config decorator for command validation
gateway.py # API client for Next Commerce store
Expand All @@ -17,6 +17,7 @@ tests/
test_gateway.py
test_config.py
test_installer.py
test_tailwind.py
```

## Development Setup
Expand Down Expand Up @@ -71,6 +72,27 @@ pytest --cov=ntk --cov-report xml

Requires Python >= 3.10. Tested against 3.10, 3.11, 3.12, 3.13, 3.14 via tox and GitHub Actions.

## Tailwind CSS Integration

ntk supports Tailwind CSS themes alongside traditional Sass themes:

- **Auto-detection:** `ntk watch` and `ntk tailwind` auto-detect Tailwind v4 (`@import "tailwindcss"` in `css/input.css`) or v3 (`tailwind.config.js`)
- **Binary resolution:** Checks `./tailwindcss` (local binary), `tailwindcss` (PATH), `npx tailwindcss` in order
- **sass-compat:** If `scripts/sass-compat.py` exists, runs it automatically after Tailwind compilation (strips `@property`, converts oklch to hex, replaces `color-mix()`)
- **Config:** Optional `tailwind:` section in `config.yml`:
```yaml
development:
tailwind:
input: css/input.css # default
output: assets/main.css # default
binary: ./tailwindcss # auto-detected
sass_compat: scripts/sass-compat.py # auto-detected
```
- **Commands:**
- `ntk tailwind` — compile once + push CSS to store
- `ntk tailwind --minify` — compile minified + push
- `ntk watch` — auto-starts Tailwind watcher alongside file watcher, runs sass-compat on CSS output changes

## Important Conventions

- Use `asyncio.run()` for async entry points — not `get_event_loop()` (broke in Python 3.12+)
Expand Down
149 changes: 147 additions & 2 deletions ntk/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import glob
import logging
import os
import shlex
import subprocess
import sys
import time
import sass

Expand Down Expand Up @@ -151,6 +154,109 @@ def _compile_sass(self):
logging.error(f'[{self.config.env}] Sass processing failed, see error below.')
logging.error(f'[{self.config.env}] {error}')

def _compile_tailwind(self, minify=False):
"""Compile Tailwind CSS using the detected or configured binary."""
if not self.config.detect_tailwind():
logging.warning(f'[{self.config.env}] Tailwind CSS not detected. '
'Ensure css/input.css exists with @import "tailwindcss" '
'and a tailwindcss binary is available.')
return False

binary = self.config.tailwind_binary
input_file = self.config.tailwind_input
output_file = self.config.tailwind_output

cmd = f'{binary} -i {input_file} -o {output_file}'
if minify:
cmd += ' --minify'

logging.info(f'[{self.config.env}] Compiling Tailwind CSS: {input_file} -> {output_file}')

try:
result = subprocess.run(
shlex.split(cmd),
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
logging.error(f'[{self.config.env}] Tailwind compilation failed:')
if result.stderr:
for line in result.stderr.strip().splitlines():
logging.error(f'[{self.config.env}] {line}')
return False

logging.info(f'[{self.config.env}] Tailwind CSS compiled successfully.')

except FileNotFoundError:
logging.error(f'[{self.config.env}] Tailwind CLI not found at "{binary}". '
'Download from https://github.com/tailwindlabs/tailwindcss/releases '
'or install via npm.')
return False
except subprocess.TimeoutExpired:
logging.error(f'[{self.config.env}] Tailwind compilation timed out after 60 seconds.')
return False

# Run sass-compat.py post-processor if present
if self.config.tailwind_sass_compat and os.path.exists(self.config.tailwind_sass_compat):
logging.info(f'[{self.config.env}] Running sass-compat post-processor on {output_file}')
try:
compat_result = subprocess.run(
[sys.executable, self.config.tailwind_sass_compat, output_file],
capture_output=True,
text=True,
timeout=30
)
if compat_result.returncode != 0:
logging.error(f'[{self.config.env}] sass-compat.py failed:')
if compat_result.stderr:
for line in compat_result.stderr.strip().splitlines():
logging.error(f'[{self.config.env}] {line}')
return False
logging.info(f'[{self.config.env}] sass-compat post-processing complete.')
except FileNotFoundError:
logging.warning(f'[{self.config.env}] Python not found for sass-compat.py. Skipping.')
except subprocess.TimeoutExpired:
logging.error(f'[{self.config.env}] sass-compat.py timed out after 30 seconds.')
return False

return True

def _start_tailwind_watch(self):
"""Start Tailwind CLI in watch mode as a background subprocess."""
if not self.config.detect_tailwind():
return None

binary = self.config.tailwind_binary
input_file = self.config.tailwind_input
output_file = self.config.tailwind_output

cmd = f'{binary} -i {input_file} -o {output_file} --watch'

logging.info(f'[{self.config.env}] Starting Tailwind watcher: {input_file} -> {output_file}')

try:
process = subprocess.Popen(
shlex.split(cmd),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
return process
except FileNotFoundError:
logging.error(f'[{self.config.env}] Tailwind CLI not found at "{binary}". '
'Tailwind watch disabled.')
return None

def _stop_tailwind_watch(self, process):
"""Stop the Tailwind watch subprocess."""
if process and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
logging.info(f'[{self.config.env}] Tailwind watcher stopped.')

@parser_config(theme_id_required=False)
def init(self, parser):
if parser.name:
Expand Down Expand Up @@ -196,15 +302,54 @@ def watch(self, parser):
logging.info(f'[{self.config.env}] Current theme id {self.config.theme_id}')
logging.info(f'[{self.config.env}] Preview theme URL {self.config.store}?preview_theme={self.config.theme_id}')
logging.info(f'[{self.config.env}] Watching for file changes in {current_pathfile}')

# Start Tailwind watcher if detected
tailwind_process = None
tailwind_output = None
if self.config.detect_tailwind():
tailwind_output = os.path.abspath(self.config.tailwind_output)
# Do initial compilation before starting watch
self._compile_tailwind()
tailwind_process = self._start_tailwind_watch()
if tailwind_process:
logging.info(f'[{self.config.env}] Tailwind watcher running (PID {tailwind_process.pid})')

logging.info(f'[{self.config.env}] Press Ctrl + C to stop')

async def main():
async for changes in awatch('.'):
self._handle_files_change(changes)
try:
async for changes in awatch('.'):
# If Tailwind output file changed (from Tailwind watcher),
# run sass-compat before pushing
if tailwind_output:
tw_changes = [(t, p) for t, p in changes if os.path.abspath(p) == tailwind_output]
if tw_changes and self.config.tailwind_sass_compat:
logging.info(f'[{self.config.env}] Tailwind output changed, running sass-compat...')
try:
subprocess.run(
[sys.executable, self.config.tailwind_sass_compat, self.config.tailwind_output],
capture_output=True, text=True, timeout=30
)
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
logging.warning(f'[{self.config.env}] sass-compat failed: {e}')

self._handle_files_change(changes)
finally:
self._stop_tailwind_watch(tailwind_process)

asyncio.run(main())

@parser_config()
def compile_sass(self, parser):
logging.info(f'[{self.config.env}] Sass output style {self.config.sass_output_style}.')
self._compile_sass()

@parser_config()
def compile_tailwind(self, parser):
minify = getattr(parser, 'minify', False)
if self._compile_tailwind(minify=minify):
# Push the compiled CSS to the store
output_file = self.config.tailwind_output
if output_file and os.path.exists(output_file):
logging.info(f'[{self.config.env}] Pushing {output_file} to store...')
self._push_templates([output_file])
63 changes: 63 additions & 0 deletions ntk/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
SASS_DESTINATION = 'assets'
SASS_OUTPUT_STYLES = ['nested', 'expanded', 'compact', 'compressed']

# Tailwind CSS defaults
TAILWIND_INPUT = 'css/input.css'
TAILWIND_OUTPUT = 'assets/main.css'
TAILWIND_BINARY_NAMES = ['./tailwindcss', 'tailwindcss', 'npx tailwindcss']
TAILWIND_V4_MARKER = '@import "tailwindcss"'
TAILWIND_V3_CONFIG = 'tailwind.config.js'
TAILWIND_SASS_COMPAT = 'scripts/sass-compat.py'

GLOB_PATTERN = [
"assets/**/*.html",
"assets/**/*.json",
Expand Down Expand Up @@ -58,6 +66,12 @@ class Config(object):
theme_id = None
sass_output_style = None

# Tailwind config
tailwind_input = None
tailwind_output = None
tailwind_binary = None
tailwind_sass_compat = None

env = 'development'

apikey_required = True
Expand Down Expand Up @@ -118,9 +132,58 @@ def read_config(self, update=True):
self.theme_id = configs[self.env].get('theme_id')
if configs[self.env].get('sass'):
self.sass_output_style = configs[self.env]['sass'].get('output_style')
if configs[self.env].get('tailwind') is not None:
tw = configs[self.env]['tailwind']
self.tailwind_input = tw.get('input', TAILWIND_INPUT)
self.tailwind_output = tw.get('output', TAILWIND_OUTPUT)
self.tailwind_binary = tw.get('binary')
self.tailwind_sass_compat = tw.get('sass_compat')

return configs

def detect_tailwind(self):
"""Auto-detect Tailwind CSS setup if not explicitly configured."""
if self.tailwind_input and self.tailwind_binary:
return True # Already configured

# Check for Tailwind v4 (CSS-based config with @import "tailwindcss")
input_file = self.tailwind_input or TAILWIND_INPUT
if os.path.exists(input_file):
try:
with open(input_file, 'r', encoding='utf-8') as f:
content = f.read(1024) # Only need the first kilobyte
if TAILWIND_V4_MARKER in content:
self.tailwind_input = input_file
self.tailwind_output = self.tailwind_output or TAILWIND_OUTPUT
except (OSError, UnicodeDecodeError):
pass

# Check for Tailwind v3 (JS config file)
if not self.tailwind_input and os.path.exists(TAILWIND_V3_CONFIG):
self.tailwind_input = TAILWIND_INPUT if os.path.exists(TAILWIND_INPUT) else None
self.tailwind_output = self.tailwind_output or TAILWIND_OUTPUT

if not self.tailwind_input:
return False

# Find binary if not configured
if not self.tailwind_binary:
import shutil
for binary in TAILWIND_BINARY_NAMES:
if binary.startswith('./'):
if os.path.isfile(binary) and os.access(binary, os.X_OK):
self.tailwind_binary = binary
break
elif shutil.which(binary):
self.tailwind_binary = binary
break

# Detect sass-compat.py if not configured
if not self.tailwind_sass_compat and os.path.exists(TAILWIND_SASS_COMPAT):
self.tailwind_sass_compat = TAILWIND_SASS_COMPAT

return self.tailwind_binary is not None

def write_config(self):
configs = self.read_config(update=False)

Expand Down
17 changes: 17 additions & 0 deletions ntk/ntk_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def create_parser(self):
push Push all theme files from your current direcotry to the store
watch Watch for changes in your current directory and push updates to the store
sass Process Sass files to CSS files in assets directory
tailwind Compile Tailwind CSS and push to store
''' + option_commands,
usage=argparse.SUPPRESS,
epilog='Use "ntk [command] --help" for more information about a command.',
Expand Down Expand Up @@ -139,4 +140,20 @@ def create_parser(self):
formatter_class=argparse.RawTextHelpFormatter)
parser_watch.set_defaults(func=self.command.compile_sass)
self._add_config_arguments(parser_watch)

# create the parser for the "tailwind" command
parser_tailwind = subparsers.add_parser(
'tailwind',
help='Compile Tailwind CSS and push to store',
usage=argparse.SUPPRESS,
description='''
Usage:
ntk tailwind [options]
''' + option_commands,
formatter_class=argparse.RawTextHelpFormatter)
parser_tailwind.set_defaults(func=self.command.compile_tailwind)
parser_tailwind.add_argument(
'--minify', action='store_true', dest='minify', default=False,
help='Minify the Tailwind CSS output')
self._add_config_arguments(parser_tailwind)
return parser
Loading
Loading