newtex-cli 0.1.1.dev1__tar.gz

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.
@@ -0,0 +1,34 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.so
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+
13
+ # Build artifacts
14
+ build/
15
+ dist/
16
+ *.egg-info/
17
+
18
+ # Local environment files
19
+ .env.*
20
+ !.env
21
+ !.env.example
22
+
23
+ # PyPI credentials (never commit real tokens)
24
+ .pypirc
25
+ .pypirc.*
26
+ !.pypirc.example
27
+
28
+ # OS / editor
29
+ .DS_Store
30
+ .idea/
31
+ .vscode/
32
+
33
+ # Personal
34
+ note.txt
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: newtex-cli
3
+ Version: 0.1.1.dev1
4
+ Summary: Local LaTeX project scaffolder
5
+ Project-URL: Homepage, https://example.internal/newtex
6
+ Project-URL: Repository, https://example.internal/newtex/repo
7
+ License: Proprietary
8
+ Classifier: Environment :: Console
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Topic :: Utilities
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: python-dotenv>=1.0
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: questionary>=2.0
17
+ Requires-Dist: rich>=13.7
18
+ Requires-Dist: typer>=0.12
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Provides-Extra: publish
22
+ Requires-Dist: build>=1.2; extra == 'publish'
23
+ Requires-Dist: twine>=5.0; extra == 'publish'
24
+ Description-Content-Type: text/markdown
25
+
26
+ <h1 align="center">newtex-cli</h1>
27
+
28
+ <p align="center">Scaffold local LaTeX projects from reusable templates.</p>
29
+
30
+ ## 1. Installation
31
+
32
+ ```bash
33
+ pipx install newtex-scaffold
34
+ ```
35
+
36
+ ## 2. Configuration
37
+
38
+ ### 2.1 Local environment file
39
+
40
+ Create `.env.local` in the project root:
41
+
42
+ ```bash
43
+ NEWTEX_DEFAULT_TEMPLATE=acm
44
+ NEWTEX_TEMPLATE_ACM_PATH=/path/to/your/acm-template
45
+ ```
46
+
47
+ ### 2.2 Global template config (recommended)
48
+
49
+ Configure templates once and use `newtex` from anywhere:
50
+
51
+ ```bash
52
+ newtex --template-set acm=/absolute/path/or/url/to/template
53
+ newtex --set-default-template acm
54
+ newtex --templates-list
55
+ ```
56
+
57
+ This writes to `~/.config/newtex/templates.yml`.
58
+
59
+ ## 3. CLI Commands
60
+
61
+ ### 3.1 Command reference
62
+
63
+ | Command | Description |
64
+ | --- | --- |
65
+ | `newtex --help` | Show CLI help |
66
+ | `newtex --tests` | Run the full test suite |
67
+ | `newtex --publish-check` | Validate build/upload prerequisites |
68
+ | `newtex --template-set <alias>=<path-or-url>` | Add or update a global template alias |
69
+ | `newtex --template-set <alias>=<path-or-url> --template-description "..."` | Add alias with description |
70
+ | `newtex --set-default-template <alias>` | Set the global default template alias |
71
+ | `newtex --templates-list` | Show configured global templates |
72
+ | `newtex` | Start interactive project creation |
73
+ | `newtex <project-name> <template>` | Create a project in non-interactive mode |
74
+ | `newtex <project-name> <template> --no-git` | Skip `git init` |
75
+ | `newtex <project-name> <template> --track-pdf` | Keep compiled PDFs tracked |
76
+ | `newtex <project-name> <template> --no-vscode` | Exclude shared `.vscode/` settings |
77
+ | `newtex <project-name> <template> --open` | Open generated project in VS Code |
78
+
79
+ ### 3.2 Quick examples
80
+
81
+ ```bash
82
+ newtex --help
83
+ newtex
84
+ newtex exlang-paper acm
85
+ newtex exlang-paper acm --no-git
86
+ newtex exlang-paper acm --track-pdf
87
+ newtex exlang-paper acm --no-vscode
88
+ newtex exlang-paper acm --open
89
+ ```
90
+
91
+ <details>
92
+ <summary><strong>Advanced commands</strong></summary>
93
+
94
+ ```bash
95
+ newtex --tests
96
+ newtex --publish-check
97
+ newtex --template-set acm=/absolute/path/to/template
98
+ newtex --set-default-template acm
99
+ newtex --templates-list
100
+ ```
101
+
102
+ </details>
103
+
104
+ ## 4. Notes
105
+
106
+ - Project names must be lowercase kebab-case (example: `exlang-paper`).
107
+ - If a template path is invalid, the CLI exits with an error message.
108
+
109
+ ## 5. Build & Publish
110
+
111
+ ### 5.1 Build distributions
112
+
113
+ ```bash
114
+ python -m pip install -e ".[publish]"
115
+ newtex --publish-check
116
+ python -m build
117
+ ```
118
+
119
+ ### 5.2 Upload to PyPI
120
+
121
+ You need a PyPI account to publish public packages.
122
+
123
+ Set token-based auth (recommended):
124
+
125
+ ```bash
126
+ export TWINE_USERNAME=__token__
127
+ export TWINE_PASSWORD=<your-pypi-api-token>
128
+ ```
129
+
130
+ ```bash
131
+ python -m twine upload dist/*
132
+ ```
133
+
134
+ ### 5.3 Install on another Mac
135
+
136
+ ```bash
137
+ pipx install newtex-scaffold
138
+ ```
139
+
140
+ Then configure templates on that machine:
141
+
142
+ ```bash
143
+ newtex --template-set acm=/absolute/path/or/url/to/template
144
+ newtex --set-default-template acm
145
+ ```
@@ -0,0 +1,120 @@
1
+ <h1 align="center">newtex-cli</h1>
2
+
3
+ <p align="center">Scaffold local LaTeX projects from reusable templates.</p>
4
+
5
+ ## 1. Installation
6
+
7
+ ```bash
8
+ pipx install newtex-scaffold
9
+ ```
10
+
11
+ ## 2. Configuration
12
+
13
+ ### 2.1 Local environment file
14
+
15
+ Create `.env.local` in the project root:
16
+
17
+ ```bash
18
+ NEWTEX_DEFAULT_TEMPLATE=acm
19
+ NEWTEX_TEMPLATE_ACM_PATH=/path/to/your/acm-template
20
+ ```
21
+
22
+ ### 2.2 Global template config (recommended)
23
+
24
+ Configure templates once and use `newtex` from anywhere:
25
+
26
+ ```bash
27
+ newtex --template-set acm=/absolute/path/or/url/to/template
28
+ newtex --set-default-template acm
29
+ newtex --templates-list
30
+ ```
31
+
32
+ This writes to `~/.config/newtex/templates.yml`.
33
+
34
+ ## 3. CLI Commands
35
+
36
+ ### 3.1 Command reference
37
+
38
+ | Command | Description |
39
+ | --- | --- |
40
+ | `newtex --help` | Show CLI help |
41
+ | `newtex --tests` | Run the full test suite |
42
+ | `newtex --publish-check` | Validate build/upload prerequisites |
43
+ | `newtex --template-set <alias>=<path-or-url>` | Add or update a global template alias |
44
+ | `newtex --template-set <alias>=<path-or-url> --template-description "..."` | Add alias with description |
45
+ | `newtex --set-default-template <alias>` | Set the global default template alias |
46
+ | `newtex --templates-list` | Show configured global templates |
47
+ | `newtex` | Start interactive project creation |
48
+ | `newtex <project-name> <template>` | Create a project in non-interactive mode |
49
+ | `newtex <project-name> <template> --no-git` | Skip `git init` |
50
+ | `newtex <project-name> <template> --track-pdf` | Keep compiled PDFs tracked |
51
+ | `newtex <project-name> <template> --no-vscode` | Exclude shared `.vscode/` settings |
52
+ | `newtex <project-name> <template> --open` | Open generated project in VS Code |
53
+
54
+ ### 3.2 Quick examples
55
+
56
+ ```bash
57
+ newtex --help
58
+ newtex
59
+ newtex exlang-paper acm
60
+ newtex exlang-paper acm --no-git
61
+ newtex exlang-paper acm --track-pdf
62
+ newtex exlang-paper acm --no-vscode
63
+ newtex exlang-paper acm --open
64
+ ```
65
+
66
+ <details>
67
+ <summary><strong>Advanced commands</strong></summary>
68
+
69
+ ```bash
70
+ newtex --tests
71
+ newtex --publish-check
72
+ newtex --template-set acm=/absolute/path/to/template
73
+ newtex --set-default-template acm
74
+ newtex --templates-list
75
+ ```
76
+
77
+ </details>
78
+
79
+ ## 4. Notes
80
+
81
+ - Project names must be lowercase kebab-case (example: `exlang-paper`).
82
+ - If a template path is invalid, the CLI exits with an error message.
83
+
84
+ ## 5. Build & Publish
85
+
86
+ ### 5.1 Build distributions
87
+
88
+ ```bash
89
+ python -m pip install -e ".[publish]"
90
+ newtex --publish-check
91
+ python -m build
92
+ ```
93
+
94
+ ### 5.2 Upload to PyPI
95
+
96
+ You need a PyPI account to publish public packages.
97
+
98
+ Set token-based auth (recommended):
99
+
100
+ ```bash
101
+ export TWINE_USERNAME=__token__
102
+ export TWINE_PASSWORD=<your-pypi-api-token>
103
+ ```
104
+
105
+ ```bash
106
+ python -m twine upload dist/*
107
+ ```
108
+
109
+ ### 5.3 Install on another Mac
110
+
111
+ ```bash
112
+ pipx install newtex-scaffold
113
+ ```
114
+
115
+ Then configure templates on that machine:
116
+
117
+ ```bash
118
+ newtex --template-set acm=/absolute/path/or/url/to/template
119
+ newtex --set-default-template acm
120
+ ```
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27", "hatch-vcs>=0.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "newtex-cli"
7
+ dynamic = ["version"]
8
+ description = "Local LaTeX project scaffolder"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "Proprietary"}
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Environment :: Console",
17
+ "Topic :: Utilities",
18
+ ]
19
+ dependencies = [
20
+ "typer>=0.12",
21
+ "questionary>=2.0",
22
+ "pyyaml>=6.0",
23
+ "python-dotenv>=1.0",
24
+ "rich>=13.7",
25
+ ]
26
+
27
+ [project.scripts]
28
+ newtex = "newtex.cli:app"
29
+
30
+ [project.urls]
31
+ Homepage = "https://example.internal/newtex"
32
+ Repository = "https://example.internal/newtex/repo"
33
+
34
+ [tool.hatch.version]
35
+ source = "vcs"
36
+
37
+ [tool.hatch.version.raw-options]
38
+ version_scheme = "python-simplified-semver"
39
+ local_scheme = "no-local-version"
40
+ fallback_version = "0.1.0"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/newtex"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = [
47
+ "/src/newtex",
48
+ "/pyproject.toml",
49
+ ]
50
+
51
+ [tool.hatch.build]
52
+ include = [
53
+ "src/newtex/resources/gitignore/*.gitignore",
54
+ ]
55
+
56
+ [project.optional-dependencies]
57
+ dev = [
58
+ "pytest>=8.0",
59
+ ]
60
+ publish = [
61
+ "build>=1.2",
62
+ "twine>=5.0",
63
+ ]
64
+
65
+ [tool.pytest.ini_options]
66
+ pythonpath = ["src"]
67
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __all__ = []
@@ -0,0 +1,303 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import questionary
8
+ import typer
9
+ from questionary import Style
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+
14
+ from .config import load_config, set_default_template, upsert_template
15
+ from .scaffold import scaffold_project
16
+ from .validators import is_kebab_case, suggest_kebab_case
17
+
18
+ app = typer.Typer(help="Create local LaTeX projects from templates.")
19
+ console = Console()
20
+ PROMPT_STYLE = Style(
21
+ [
22
+ ("qmark", "fg:#8b5cf6 bold"),
23
+ ("question", "bold"),
24
+ ("answer", "fg:#22c55e bold"),
25
+ ("pointer", "fg:#06b6d4 bold"),
26
+ ("highlighted", "fg:#06b6d4 bold"),
27
+ ("selected", "fg:#22c55e"),
28
+ ]
29
+ )
30
+
31
+
32
+ def _show_banner() -> None:
33
+ title = Text("newtex", style="bold magenta")
34
+ subtitle = Text("Create local LaTeX projects from templates", style="cyan")
35
+ console.print(Panel.fit(Text.assemble(title, "\n", subtitle), border_style="magenta"))
36
+
37
+
38
+ def _info(message: str) -> None:
39
+ console.print(f"[cyan]•[/cyan] {message}")
40
+
41
+
42
+ def _success(message: str) -> None:
43
+ console.print(f"[green]✔[/green] {message}")
44
+
45
+
46
+ def _error(message: str) -> None:
47
+ console.print(f"[bold red]✖ {message}[/bold red]")
48
+
49
+
50
+ def _run_tests() -> int:
51
+ local_venv_python = Path(".venv/bin/python")
52
+ command = [str(local_venv_python), "-m", "pytest", "-q"] if local_venv_python.exists() else ["pytest", "-q"]
53
+
54
+ try:
55
+ result = subprocess.run(command, check=False)
56
+ except FileNotFoundError:
57
+ result = subprocess.run([sys.executable, "-m", "pytest", "-q"], check=False)
58
+
59
+ return result.returncode
60
+
61
+
62
+ def _run_publish_check() -> int:
63
+ checks_ok = True
64
+
65
+ python_candidates = []
66
+ local_venv_python = Path(".venv/bin/python")
67
+ if local_venv_python.exists():
68
+ python_candidates.append(str(local_venv_python))
69
+ python_candidates.append(sys.executable)
70
+
71
+ if shutil.which("pipx"):
72
+ _success("pipx is available")
73
+ else:
74
+ _error("pipx is not available (recommended for global install)")
75
+ checks_ok = False
76
+
77
+ build_available = False
78
+ for python_exec in python_candidates:
79
+ try:
80
+ build_check = subprocess.run([python_exec, "-m", "build", "--version"], check=False)
81
+ if build_check.returncode == 0:
82
+ build_available = True
83
+ break
84
+ except Exception:
85
+ continue
86
+
87
+ if build_available:
88
+ _success("python -m build is available")
89
+ else:
90
+ _error("python -m build is not available (install with: python -m pip install -e '.[publish]')")
91
+ checks_ok = False
92
+
93
+ twine_available = False
94
+ for python_exec in python_candidates:
95
+ try:
96
+ twine_check = subprocess.run([python_exec, "-m", "twine", "--version"], check=False)
97
+ if twine_check.returncode == 0:
98
+ twine_available = True
99
+ break
100
+ except Exception:
101
+ continue
102
+
103
+ if twine_available:
104
+ _success("twine is available")
105
+ else:
106
+ _error("twine is not available (install with: python -m pip install -e '.[publish]')")
107
+ checks_ok = False
108
+
109
+ token_present = bool(os.getenv("TWINE_PASSWORD"))
110
+ username = os.getenv("TWINE_USERNAME", "")
111
+ trusted_publishing = bool(os.getenv("PYPI_API_TOKEN"))
112
+
113
+ if token_present and username == "__token__":
114
+ _success("Twine token credentials detected (TWINE_USERNAME/TWINE_PASSWORD)")
115
+ elif trusted_publishing:
116
+ _info("PYPI_API_TOKEN found (if your flow uses this env var)")
117
+ else:
118
+ _info("No publish token detected. Set TWINE_USERNAME=__token__ and TWINE_PASSWORD=<pypi-token> before upload")
119
+
120
+ if checks_ok:
121
+ _success("Publish check passed")
122
+ return 0
123
+
124
+ _error("Publish check failed")
125
+ return 1
126
+
127
+
128
+ def _parse_template_set(value: str) -> tuple[str, str]:
129
+ if "=" not in value:
130
+ raise typer.BadParameter("--template-set must be in the form <alias>=<path-or-url>")
131
+
132
+ alias, template_value = value.split("=", 1)
133
+ alias = alias.strip()
134
+ template_value = template_value.strip()
135
+
136
+ if not alias:
137
+ raise typer.BadParameter("Template alias cannot be empty")
138
+ if not template_value:
139
+ raise typer.BadParameter("Template path/url cannot be empty")
140
+
141
+ return alias, template_value
142
+
143
+
144
+ def _pick_template(cfg: dict, template_arg: str | None) -> str:
145
+ templates = cfg["templates"]
146
+
147
+ if template_arg:
148
+ if template_arg not in templates:
149
+ raise typer.BadParameter(f"Unknown template '{template_arg}'. Available: {', '.join(templates.keys())}")
150
+ return template_arg
151
+
152
+ choices = [
153
+ questionary.Choice(title=f"{alias} ({meta.get('description', '')})", value=alias)
154
+ for alias, meta in templates.items()
155
+ ]
156
+
157
+ selected = questionary.select(
158
+ "Which template should I use?",
159
+ choices=choices,
160
+ default=cfg.get("default_template", "acm"),
161
+ style=PROMPT_STYLE,
162
+ ).ask()
163
+
164
+ if not selected:
165
+ raise typer.Exit(code=1)
166
+
167
+ return selected
168
+
169
+
170
+ def _get_project_name(name_arg: str | None) -> str:
171
+ if name_arg:
172
+ if not is_kebab_case(name_arg):
173
+ raise typer.BadParameter("Project name must be lowercase kebab-case (e.g. exlang-paper)")
174
+ return name_arg
175
+
176
+ while True:
177
+ raw = questionary.text("What is your project name?", style=PROMPT_STYLE).ask()
178
+ if not raw:
179
+ raise typer.Exit(code=1)
180
+
181
+ if is_kebab_case(raw):
182
+ return raw
183
+
184
+ suggested = suggest_kebab_case(raw)
185
+ use_suggested = questionary.confirm(f'Use "{suggested}" instead?', style=PROMPT_STYLE).ask()
186
+
187
+ if use_suggested:
188
+ return suggested
189
+
190
+
191
+ @app.command()
192
+ def main(
193
+ project_name: str | None = typer.Argument(None, help="Project folder name (kebab-case)"),
194
+ template: str | None = typer.Argument(None, help="Template alias, e.g. acm"),
195
+ tests: bool = typer.Option(False, "--tests", help="Run full test suite and exit"),
196
+ publish_check: bool = typer.Option(False, "--publish-check", help="Validate publishing prerequisites and exit"),
197
+ templates_list: bool = typer.Option(False, "--templates-list", help="List configured templates and exit"),
198
+ template_set: str | None = typer.Option(None, "--template-set", help="Set template alias/path using alias=path-or-url"),
199
+ set_default: str | None = typer.Option(None, "--set-default-template", help="Set default template alias and exit"),
200
+ template_desc: str | None = typer.Option(None, "--template-description", help="Description used with --template-set"),
201
+ no_git: bool = typer.Option(False, "--no-git", help="Do not run git init"),
202
+ track_pdf: bool = typer.Option(False, "--track-pdf", help="Track compiled PDFs in git"),
203
+ no_vscode: bool = typer.Option(False, "--no-vscode", help="Do not keep shared .vscode settings"),
204
+ open_code: bool = typer.Option(False, "--open", help="Open in VS Code after creation"),
205
+ ) -> None:
206
+ _show_banner()
207
+
208
+ if tests:
209
+ if project_name or template:
210
+ raise typer.BadParameter("Do not pass PROJECT_NAME or TEMPLATE with --tests")
211
+
212
+ _info("Running full test suite")
213
+ test_exit_code = _run_tests()
214
+ if test_exit_code == 0:
215
+ _success("All tests passed")
216
+ return
217
+
218
+ _error("Test run failed")
219
+ raise typer.Exit(code=test_exit_code)
220
+
221
+ if publish_check:
222
+ if project_name or template:
223
+ raise typer.BadParameter("Do not pass PROJECT_NAME or TEMPLATE with --publish-check")
224
+
225
+ _info("Running publish readiness checks")
226
+ publish_exit_code = _run_publish_check()
227
+ if publish_exit_code != 0:
228
+ raise typer.Exit(code=publish_exit_code)
229
+ return
230
+
231
+ if template_set:
232
+ if project_name or template:
233
+ raise typer.BadParameter("Do not pass PROJECT_NAME or TEMPLATE with --template-set")
234
+
235
+ alias, template_value = _parse_template_set(template_set)
236
+ upsert_template(alias=alias, template_path=template_value, description=template_desc)
237
+ _success(f"Saved template alias '{alias}'")
238
+ return
239
+
240
+ if set_default:
241
+ if project_name or template:
242
+ raise typer.BadParameter("Do not pass PROJECT_NAME or TEMPLATE with --set-default-template")
243
+
244
+ try:
245
+ set_default_template(set_default)
246
+ except KeyError as error:
247
+ _error(str(error))
248
+ raise typer.Exit(code=1)
249
+
250
+ _success(f"Default template set to '{set_default}'")
251
+ return
252
+
253
+ if templates_list:
254
+ if project_name or template:
255
+ raise typer.BadParameter("Do not pass PROJECT_NAME or TEMPLATE with --templates-list")
256
+
257
+ cfg = load_config()
258
+ templates = cfg.get("templates", {})
259
+ default_alias = cfg.get("default_template")
260
+
261
+ if not templates:
262
+ _info("No templates configured")
263
+ return
264
+
265
+ _info("Configured templates")
266
+ for alias, meta in templates.items():
267
+ marker = " (default)" if alias == default_alias else ""
268
+ path_value = meta.get("path", "")
269
+ description = meta.get("description", "")
270
+ _info(f"{alias}{marker} -> {path_value} ({description})")
271
+ return
272
+
273
+ cfg = load_config()
274
+
275
+ name = _get_project_name(project_name)
276
+ template_alias = _pick_template(cfg, template)
277
+ template_path = cfg["templates"][template_alias]["path"]
278
+ _info(f"Template: [bold]{template_alias}[/bold]")
279
+ _info(f"Target project: [bold]{name}[/bold]")
280
+
281
+ if not Path(template_path).exists():
282
+ _error(f"Template path not found: {template_path}")
283
+ raise typer.Exit(code=1)
284
+
285
+ try:
286
+ with console.status("[bold cyan]Scaffolding project...[/bold cyan]"):
287
+ scaffold_project(
288
+ template_path=template_path,
289
+ project_name=name,
290
+ init_git=not no_git,
291
+ track_pdf=track_pdf,
292
+ share_vscode=not no_vscode,
293
+ open_code=open_code,
294
+ )
295
+ except Exception as error:
296
+ _error(f"Error: {error}")
297
+ raise typer.Exit(code=1)
298
+
299
+ _success(f"Done. Created ./{name} using template '{template_alias}'")
300
+
301
+
302
+ if __name__ == "__main__":
303
+ app()
@@ -0,0 +1,104 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+ from dotenv import load_dotenv
6
+
7
+ CONFIG_DIR = Path.home() / ".config" / "newtex"
8
+ CONFIG_FILE = CONFIG_DIR / "templates.yml"
9
+ ACM_TEMPLATE_ENV = "NEWTEX_TEMPLATE_ACM_PATH"
10
+ DEFAULT_TEMPLATE_ENV = "NEWTEX_DEFAULT_TEMPLATE"
11
+
12
+
13
+ def _load_environment() -> None:
14
+ load_dotenv(override=False)
15
+ load_dotenv(Path.cwd() / ".env.local", override=True)
16
+
17
+
18
+ def _default_config() -> dict:
19
+ return {
20
+ "default_template": os.getenv(DEFAULT_TEMPLATE_ENV, "acm"),
21
+ "templates": {
22
+ "acm": {
23
+ "path": os.getenv(ACM_TEMPLATE_ENV, ""),
24
+ "description": "ACM Conference Proceedings Primary Article",
25
+ }
26
+ },
27
+ }
28
+
29
+
30
+ def _apply_env_overrides(config: dict) -> dict:
31
+ default_template = os.getenv(DEFAULT_TEMPLATE_ENV)
32
+ acm_path = os.getenv(ACM_TEMPLATE_ENV)
33
+
34
+ if default_template:
35
+ config["default_template"] = default_template
36
+
37
+ templates = config.setdefault("templates", {})
38
+ acm_template = templates.setdefault(
39
+ "acm",
40
+ {
41
+ "path": "",
42
+ "description": "ACM Conference Proceedings Primary Article",
43
+ },
44
+ )
45
+
46
+ if acm_path:
47
+ acm_template["path"] = acm_path
48
+
49
+ return config
50
+
51
+
52
+ def ensure_config() -> None:
53
+ _load_environment()
54
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
55
+ if not CONFIG_FILE.exists():
56
+ with open(CONFIG_FILE, "w", encoding="utf-8") as file:
57
+ yaml.safe_dump(_default_config(), file, sort_keys=False)
58
+
59
+
60
+ def load_persisted_config() -> dict:
61
+ ensure_config()
62
+ with open(CONFIG_FILE, "r", encoding="utf-8") as file:
63
+ return yaml.safe_load(file) or {}
64
+
65
+
66
+ def save_config(config: dict) -> None:
67
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
68
+ with open(CONFIG_FILE, "w", encoding="utf-8") as file:
69
+ yaml.safe_dump(config, file, sort_keys=False)
70
+
71
+
72
+ def upsert_template(alias: str, template_path: str, description: str | None = None) -> dict:
73
+ config = load_persisted_config()
74
+ templates = config.setdefault("templates", {})
75
+ existing = templates.get(alias, {})
76
+
77
+ templates[alias] = {
78
+ "path": template_path,
79
+ "description": description if description is not None else existing.get("description", ""),
80
+ }
81
+
82
+ if "default_template" not in config:
83
+ config["default_template"] = alias
84
+
85
+ save_config(config)
86
+ return config
87
+
88
+
89
+ def set_default_template(alias: str) -> dict:
90
+ config = load_persisted_config()
91
+ templates = config.get("templates", {})
92
+ if alias not in templates:
93
+ raise KeyError(f"Unknown template alias: {alias}")
94
+
95
+ config["default_template"] = alias
96
+ save_config(config)
97
+ return config
98
+
99
+
100
+ def load_config() -> dict:
101
+ _load_environment()
102
+ config = load_persisted_config()
103
+
104
+ return _apply_env_overrides(config)
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+ import importlib.resources as pkg_resources
3
+
4
+
5
+ def _read_base_gitignore() -> str:
6
+ from newtex import resources
7
+
8
+ return pkg_resources.files(resources).joinpath("gitignore/tex-base.gitignore").read_text(encoding="utf-8")
9
+
10
+
11
+ def apply_gitignore(project_dir: Path, track_pdf: bool, share_vscode: bool) -> None:
12
+ gitignore_path = project_dir / ".gitignore"
13
+
14
+ base_content = _read_base_gitignore().strip() + "\n"
15
+
16
+ extras = []
17
+ if not track_pdf:
18
+ extras.append("*.pdf")
19
+ if not share_vscode:
20
+ extras.append(".vscode/")
21
+
22
+ extra_content = ""
23
+ if extras:
24
+ extra_content = "\n# newtex preferences\n" + "\n".join(extras) + "\n"
25
+
26
+ if gitignore_path.exists():
27
+ existing = gitignore_path.read_text(encoding="utf-8")
28
+ merged = existing.rstrip() + "\n\n# --- newtex additions ---\n" + base_content + extra_content
29
+ gitignore_path.write_text(merged, encoding="utf-8")
30
+ else:
31
+ gitignore_path.write_text(base_content + extra_content, encoding="utf-8")
@@ -0,0 +1 @@
1
+ __all__ = []
@@ -0,0 +1,47 @@
1
+ # macOS
2
+ .DS_Store
3
+
4
+ # LaTeX intermediate files
5
+ *.aux
6
+ *.bbl
7
+ *.bcf
8
+ *.blg
9
+ *.fdb_latexmk
10
+ *.fls
11
+ *.lof
12
+ *.log
13
+ *.lot
14
+ *.out
15
+ *.run.xml
16
+ *.synctex.gz
17
+ *.toc
18
+ *.xdv
19
+
20
+ # Bibliography / indexing extras
21
+ *.acn
22
+ *.acr
23
+ *.alg
24
+ *.glg
25
+ *.glo
26
+ *.gls
27
+ *.idx
28
+ *.ilg
29
+ *.ind
30
+ *.ist
31
+
32
+ # Latexmk / temp
33
+ _latexmk*
34
+ latexmk*.log
35
+
36
+ # Minted / pygments
37
+ _minted*
38
+ *.pyg
39
+
40
+ # Build dirs (common)
41
+ build/
42
+ out/
43
+
44
+ # Editor extras
45
+ .idea/
46
+ __pycache__/
47
+ .venv/
@@ -0,0 +1,37 @@
1
+ from pathlib import Path
2
+ import subprocess
3
+
4
+ from .gitignore_utils import apply_gitignore
5
+
6
+
7
+ def run_cmd(cmd: list[str], cwd: Path | None = None) -> None:
8
+ result = subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=False)
9
+ if result.returncode != 0:
10
+ raise RuntimeError(f"Command failed: {' '.join(cmd)}")
11
+
12
+
13
+ def scaffold_project(
14
+ template_path: str,
15
+ project_name: str,
16
+ init_git: bool = True,
17
+ track_pdf: bool = False,
18
+ share_vscode: bool = True,
19
+ open_code: bool = False,
20
+ ) -> None:
21
+ project_dir = Path.cwd() / project_name
22
+
23
+ if project_dir.exists():
24
+ raise FileExistsError(f"Target folder already exists: {project_dir}")
25
+
26
+ run_cmd(["copier", "copy", template_path, project_name])
27
+
28
+ apply_gitignore(project_dir, track_pdf=track_pdf, share_vscode=share_vscode)
29
+
30
+ if init_git:
31
+ run_cmd(["git", "init"], cwd=project_dir)
32
+
33
+ if open_code:
34
+ try:
35
+ run_cmd(["code", "."], cwd=project_dir)
36
+ except (FileNotFoundError, RuntimeError):
37
+ pass
@@ -0,0 +1,14 @@
1
+ import re
2
+
3
+ KEBAB_CASE_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
4
+
5
+
6
+ def is_kebab_case(name: str) -> bool:
7
+ return bool(KEBAB_CASE_PATTERN.fullmatch(name))
8
+
9
+
10
+ def suggest_kebab_case(name: str) -> str:
11
+ s = name.strip().lower()
12
+ s = re.sub(r"[^a-z0-9]+", "-", s)
13
+ s = re.sub(r"-+", "-", s).strip("-")
14
+ return s