itw-python-builder 0.1.30__tar.gz → 0.1.32__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.
Files changed (21) hide show
  1. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/PKG-INFO +1 -1
  2. itw_python_builder-0.1.32/itw_python_builder/ssr_tasks.py +348 -0
  3. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder.egg-info/PKG-INFO +1 -1
  4. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/pyproject.toml +1 -1
  5. itw_python_builder-0.1.30/itw_python_builder/ssr_tasks.py +0 -210
  6. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/LICENSE +0 -0
  7. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/README.md +0 -0
  8. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder/.pylintrc +0 -0
  9. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder/__init__.py +0 -0
  10. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder/cli.py +0 -0
  11. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder/tasks.py +0 -0
  12. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
  13. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder/templates/sitemap.routes.ts +0 -0
  14. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder/utils.py +0 -0
  15. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder/version.py +0 -0
  16. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder.egg-info/SOURCES.txt +0 -0
  17. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder.egg-info/dependency_links.txt +0 -0
  18. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder.egg-info/entry_points.txt +0 -0
  19. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder.egg-info/requires.txt +0 -0
  20. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/itw_python_builder.egg-info/top_level.txt +0 -0
  21. {itw_python_builder-0.1.30 → itw_python_builder-0.1.32}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.30
3
+ Version: 0.1.32
4
4
  Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
5
5
  Author-email: IT-Works <contact@it-works.io>
6
6
  License: MIT
@@ -0,0 +1,348 @@
1
+ """SSR scaffolding tasks. One-time `itw ssr-init` for Angular SSR + ngx-seo-helper.
2
+
3
+ The build/version tasks (`incrementrc`, `release`, etc.) live in tasks.py and
4
+ take an `ssr=False` kwarg. With --ssr they invoke:
5
+ - `npm run build:ssr` on master
6
+ - `npm run build:ssr:staging` on any other branch
7
+
8
+ These two scripts are a contract the project owner defines in package.json.
9
+ ssr-init intentionally does NOT write them, because the right command depends
10
+ on which Angular SSR setup the project ended up with:
11
+
12
+ legacy browser builder → ng build --configuration=X && ng run <name>:server:X
13
+ modern application builder (Angular 17+, fresh `ng add @angular/ssr`)
14
+ → ng build --configuration=X
15
+ """
16
+ import json
17
+ import os
18
+ import re
19
+ from pathlib import Path
20
+
21
+ from invoke import task, Context
22
+
23
+ from itw_python_builder.utils import detect_project_type, _read_package_name, is_ssr_project
24
+
25
+
26
+ def _angular_major_version() -> int:
27
+ """Read @angular/core's major version from package.json. Returns 0 if not found."""
28
+ pkg_path = os.path.join(os.getcwd(), 'package.json')
29
+ with open(pkg_path, 'r', encoding='utf-8') as fp:
30
+ data = json.load(fp)
31
+ deps = {**data.get('dependencies', {}), **data.get('devDependencies', {})}
32
+ spec = deps.get('@angular/core', '')
33
+ match = re.match(r'[\^~>=<]*(\d+)', spec)
34
+ return int(match.group(1)) if match else 0
35
+
36
+
37
+ def _angular_core_installed_version() -> str:
38
+ """Return the exact installed @angular/core version from node_modules, or '' if not found."""
39
+ pkg_path = os.path.join(os.getcwd(), 'node_modules', '@angular', 'core', 'package.json')
40
+ if not os.path.isfile(pkg_path):
41
+ return ''
42
+ with open(pkg_path, 'r', encoding='utf-8') as fp:
43
+ return json.load(fp).get('version', '')
44
+
45
+
46
+ def _read_angular_json() -> dict:
47
+ """Read angular.json from cwd. Returns empty dict if missing/invalid."""
48
+ path = os.path.join(os.getcwd(), 'angular.json')
49
+ if not os.path.isfile(path):
50
+ return {}
51
+ try:
52
+ with open(path, 'r', encoding='utf-8') as fp:
53
+ return json.load(fp)
54
+ except (OSError, json.JSONDecodeError):
55
+ return {}
56
+
57
+
58
+ def _detect_angular_project(angular_json: dict) -> tuple:
59
+ """Return (project_name, project_data) for the project SSR was scaffolded into.
60
+
61
+ Priority: package.json `name` match → defaultProject → only project in repo.
62
+ Returns (None, None) if angular.json has no usable projects.
63
+ """
64
+ projects = angular_json.get('projects') or {}
65
+ if not projects:
66
+ return (None, None)
67
+ try:
68
+ pkg_name = _read_package_name()
69
+ if pkg_name in projects:
70
+ return (pkg_name, projects[pkg_name])
71
+ except Exception:
72
+ pass
73
+ default = angular_json.get('defaultProject')
74
+ if default and default in projects:
75
+ return (default, projects[default])
76
+ if len(projects) == 1:
77
+ name = next(iter(projects))
78
+ return (name, projects[name])
79
+ return (None, None)
80
+
81
+
82
+ def _maybe_add_ssr_scripts(ctx: Context) -> None:
83
+ """Add build:ssr, build:ssr:staging, build:ssr:dev npm scripts based on angular.json.
84
+
85
+ Reads angular.json to detect:
86
+ - The builder (legacy `:browser` + separate `server` target, or modern `:application`)
87
+ - The project name (for `ng run <name>:server:<config>` in the legacy form)
88
+ - Which configurations exist (only writes scripts for configs that exist in both
89
+ `build` and, for legacy, `server` targets)
90
+
91
+ Never overwrites a script that already exists in package.json. Prints a summary
92
+ of what was added vs preserved vs skipped (no matching config).
93
+ """
94
+ angular_json = _read_angular_json()
95
+ if not angular_json:
96
+ print('[itw] No angular.json found — skipping SSR script setup.')
97
+ return
98
+
99
+ project_name, project = _detect_angular_project(angular_json)
100
+ if not project:
101
+ print('[itw] Could not detect Angular project in angular.json — skipping SSR script setup.')
102
+ return
103
+
104
+ arch = project.get('architect') or project.get('targets') or {}
105
+ build = arch.get('build') or {}
106
+ server = arch.get('server') or {}
107
+ builder = build.get('builder', '')
108
+
109
+ is_legacy = builder == '@angular-devkit/build-angular:browser' and bool(server)
110
+ is_modern = builder == '@angular-devkit/build-angular:application'
111
+ if not (is_legacy or is_modern):
112
+ print(f'[itw] Unrecognized build builder {builder!r} — skipping SSR script setup.')
113
+ return
114
+
115
+ build_configs = set((build.get('configurations') or {}).keys())
116
+ if is_legacy:
117
+ server_configs = set((server.get('configurations') or {}).keys())
118
+ usable_configs = build_configs & server_configs
119
+ builder_label = 'legacy (browser + server targets)'
120
+ else:
121
+ usable_configs = build_configs
122
+ builder_label = 'modern (application builder)'
123
+
124
+ print(f"[itw] Detected {builder_label} for project '{project_name}'.")
125
+
126
+ def cmd_for(config: str) -> str:
127
+ if is_legacy:
128
+ return f'ng build --configuration={config} && ng run {project_name}:server:{config}'
129
+ return f'ng build --configuration={config}'
130
+
131
+ script_map = {
132
+ 'production': 'build:ssr',
133
+ 'staging': 'build:ssr:staging',
134
+ 'development': 'build:ssr:dev',
135
+ }
136
+
137
+ pkg_path = os.path.join(os.getcwd(), 'package.json')
138
+ with open(pkg_path, 'r', encoding='utf-8') as fp:
139
+ data = json.load(fp)
140
+ scripts = data.setdefault('scripts', {})
141
+
142
+ added, preserved, skipped = [], [], []
143
+ for config_name, script_name in script_map.items():
144
+ if config_name not in usable_configs:
145
+ skipped.append((script_name, config_name))
146
+ continue
147
+ if script_name in scripts:
148
+ preserved.append(script_name)
149
+ continue
150
+ scripts[script_name] = cmd_for(config_name)
151
+ added.append((script_name, scripts[script_name]))
152
+
153
+ if added:
154
+ with open(pkg_path, 'w', encoding='utf-8') as fp:
155
+ json.dump(data, fp, indent=2)
156
+ fp.write('\n')
157
+
158
+ print('[itw] SSR npm scripts:')
159
+ if added:
160
+ for name, cmd in added:
161
+ print(f' + added: {name} → {cmd}')
162
+ if preserved:
163
+ for name in preserved:
164
+ print(f' = preserved: {name} (already in package.json, not overwritten)')
165
+ if skipped:
166
+ for name, cfg in skipped:
167
+ print(f" - skipped: {name} (no '{cfg}' config in angular.json)")
168
+ if not (added or preserved):
169
+ print(' (nothing added)')
170
+
171
+
172
+ def _pin_ssr_deps_in_package_json(target_version: str) -> list:
173
+ """Pin @angular/ssr and @angular/platform-server in package.json to exact target_version.
174
+
175
+ Returns the list of packages whose entries were changed (or added). Does NOT touch
176
+ other @angular/* packages — only the two that @angular/ssr's install touches and
177
+ which carry strict peer-dependencies. This prevents npm from re-resolving them to
178
+ a newer patch that wouldn't match the installed @angular/core.
179
+ """
180
+ pkg_path = os.path.join(os.getcwd(), 'package.json')
181
+ with open(pkg_path, 'r', encoding='utf-8') as fp:
182
+ data = json.load(fp)
183
+ deps = data.setdefault('dependencies', {})
184
+ changed = []
185
+ for pkg in ('@angular/ssr', '@angular/platform-server'):
186
+ if deps.get(pkg) != target_version:
187
+ deps[pkg] = target_version
188
+ changed.append(pkg)
189
+ if changed:
190
+ with open(pkg_path, 'w', encoding='utf-8') as fp:
191
+ json.dump(data, fp, indent=2)
192
+ fp.write('\n')
193
+ return changed
194
+
195
+
196
+ TEMPLATES_DIR = Path(__file__).parent / 'templates'
197
+
198
+ SITEMAP_IMPORT_LINE = "import {sitemapEntries} from './src/sitemap.routes';"
199
+
200
+
201
+ def _load_template(name: str) -> str:
202
+ """Read a template file, stripping the leading `// @ts-nocheck` editor hint."""
203
+ content = (TEMPLATES_DIR / name).read_text(encoding='utf-8')
204
+ lines = content.splitlines(keepends=True)
205
+ if lines and lines[0].lstrip().startswith('// @ts-nocheck'):
206
+ lines = lines[1:]
207
+ if lines and lines[0].strip() == '':
208
+ lines = lines[1:]
209
+ return ''.join(lines)
210
+
211
+
212
+ def _run_ng_add_ssr(ctx: Context) -> None:
213
+ """Pin SSR deps to the installed @angular/core version, then run `ng add @angular/ssr`.
214
+
215
+ Why: @angular/platform-server (pulled in by @angular/ssr) has strict per-patch
216
+ peer-deps — platform-server@X.Y.Z demands @angular/core@X.Y.Z *exactly*. If
217
+ package.json declares platform-server as a range like ^X.Y.0, npm resolves to
218
+ the latest patch (often newer than the installed core), which breaks the install.
219
+
220
+ The fix is to pin only the two new packages (@angular/ssr and @angular/platform-server)
221
+ in package.json to the exact patch that matches the installed @angular/core. This
222
+ leaves every other @angular/* package alone — no upgrades, no downgrades — and
223
+ lets ng add → npm install resolve cleanly without --legacy-peer-deps or --force.
224
+
225
+ Flags on `ng add`:
226
+ - Angular 18 and earlier: no `--server-routing` flag (schematic rejects it).
227
+ - Angular 19+: pass `--server-routing=false` to keep the classic setup.
228
+ """
229
+ major = _angular_major_version()
230
+ installed_core = _angular_core_installed_version()
231
+
232
+ if installed_core:
233
+ changed = _pin_ssr_deps_in_package_json(installed_core)
234
+ if changed:
235
+ pkgs = ', '.join(changed)
236
+ print(f'[itw] Pinned {pkgs} to {installed_core} in package.json (matches installed @angular/core).')
237
+ package_spec = f'@angular/ssr@{installed_core}'
238
+ else:
239
+ package_spec = '@angular/ssr'
240
+
241
+ flags = '--skip-confirmation'
242
+ if major >= 19:
243
+ flags += ' --server-routing=false'
244
+ print(f'[itw] Running `ng add {package_spec}` (Angular {major}, this may take a minute)...')
245
+ ctx.run(f'npx ng add {package_spec} {flags}')
246
+
247
+
248
+ def _install_ngx_seo_helper(ctx: Context) -> None:
249
+ print('[itw] Installing ngx-seo-helper...')
250
+ ctx.run('npm install ngx-seo-helper')
251
+
252
+
253
+ def _write_sitemap_template() -> None:
254
+ path = os.path.join(os.getcwd(), 'src', 'sitemap.routes.ts')
255
+ if os.path.exists(path):
256
+ print(f'[itw] Skipping sitemap template — {path} already exists.')
257
+ return
258
+ with open(path, 'w', encoding='utf-8') as fp:
259
+ fp.write(_load_template('sitemap.routes.ts'))
260
+ print('[itw] Wrote src/sitemap.routes.ts (empty tree — fill in your routes).')
261
+
262
+
263
+ def _patch_server_ts() -> None:
264
+ """Inject sitemap import + /sitemap.xml handler into server.ts (idempotent)."""
265
+ path = os.path.join(os.getcwd(), 'server.ts')
266
+ if not os.path.exists(path):
267
+ raise RuntimeError('server.ts missing after `ng add @angular/ssr` — cannot patch.')
268
+
269
+ with open(path, 'r', encoding='utf-8') as fp:
270
+ content = fp.read()
271
+
272
+ if 'sitemap.routes' in content:
273
+ print('[itw] server.ts already references sitemap.routes — skipping patch.')
274
+ return
275
+
276
+ lines = content.splitlines(keepends=True)
277
+ last_import_idx = -1
278
+ for i, line in enumerate(lines):
279
+ if line.startswith('import '):
280
+ last_import_idx = i
281
+ if last_import_idx == -1:
282
+ raise RuntimeError('server.ts has no imports — unexpected shape, aborting patch.')
283
+ lines.insert(last_import_idx + 1, SITEMAP_IMPORT_LINE + '\n')
284
+ content = ''.join(lines)
285
+
286
+ match = re.search(r"(server\.set\('views',\s*distFolder\);\s*\n)", content)
287
+ if not match:
288
+ match = re.search(r"(const\s+commonEngine\s*=\s*new\s+CommonEngine\(\);\s*\n)", content)
289
+ if not match:
290
+ raise RuntimeError(
291
+ 'Could not find an injection point in server.ts (looked for `server.set(\'views\', ...)` '
292
+ 'or `new CommonEngine()`). Patch sitemap handler manually.'
293
+ )
294
+ insert_at = match.end()
295
+ content = content[:insert_at] + _load_template('server.sitemap.snippet.ts') + content[insert_at:]
296
+
297
+ with open(path, 'w', encoding='utf-8') as fp:
298
+ fp.write(content)
299
+ print('[itw] Patched server.ts with /sitemap.xml handler.')
300
+
301
+
302
+ def _print_next_steps(project_name: str) -> None:
303
+ print()
304
+ print('=' * 70)
305
+ print('[itw] SSR scaffolding complete.')
306
+ print('=' * 70)
307
+ print('Next steps you must do manually:')
308
+ print()
309
+ print(' 1. Add `NgxSeoModule` to your AppModule imports.')
310
+ print(' 2. Fill in src/sitemap.routes.ts with your route tree.')
311
+ print(' 3. If `build:ssr:staging` was skipped above, add a `staging` configuration')
312
+ print(f' to angular.json (and the `server` target, if legacy builder), then re-run')
313
+ print(' `itw ssr-init --force` to add the missing scripts.')
314
+ print(' 4. Verify locally: npm run build:ssr && npm run serve:ssr')
315
+ print(' 5. Commit the new files.')
316
+ print(' 6. Deploy with: itw incrementrc --ssr (or incrementpatch/release --ssr)')
317
+ print('=' * 70)
318
+
319
+
320
+ @task(name='ssr-init')
321
+ def ssr_init(ctx: Context, force: bool = False) -> None:
322
+ """One-time SSR scaffold: ng add @angular/ssr, ngx-seo-helper, sitemap stub, server.ts patch.
323
+
324
+ Auto-adds the build:ssr / build:ssr:staging / build:ssr:dev npm scripts based
325
+ on angular.json (detects builder type and available configs). Existing scripts
326
+ are never overwritten.
327
+ """
328
+ if detect_project_type() != 'frontend':
329
+ raise RuntimeError('ssr-init only runs on frontend (Angular) projects.')
330
+
331
+ if is_ssr_project() and not force:
332
+ raise RuntimeError(
333
+ 'SSR scaffolding already present (server.ts + tsconfig.server.json exist). '
334
+ 'Re-run with --force to re-apply the sitemap stub and server.ts patch.'
335
+ )
336
+
337
+ name = _read_package_name()
338
+ print(f'[itw] Scaffolding SSR for project: {name}')
339
+
340
+ if not is_ssr_project():
341
+ _run_ng_add_ssr(ctx)
342
+
343
+ _maybe_add_ssr_scripts(ctx)
344
+ _install_ngx_seo_helper(ctx)
345
+ _write_sitemap_template()
346
+ _patch_server_ts()
347
+
348
+ _print_next_steps(name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.30
3
+ Version: 0.1.32
4
4
  Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
5
5
  Author-email: IT-Works <contact@it-works.io>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "itw_python_builder"
7
- version = "0.1.30"
7
+ version = "0.1.32"
8
8
  description = "Standardized Django deployment pipeline with Docker, testing, and SonarQube integration"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,210 +0,0 @@
1
- """SSR scaffolding tasks. One-time `itw ssr-init` for Angular SSR + ngx-seo-helper.
2
-
3
- The build/version tasks (`incrementrc`, `release`, etc.) live in tasks.py and
4
- take an `ssr=False` kwarg. With --ssr they invoke:
5
- - `npm run build:ssr` on master
6
- - `npm run build:ssr:staging` on any other branch
7
-
8
- These two scripts are a contract the project owner defines in package.json.
9
- ssr-init intentionally does NOT write them, because the right command depends
10
- on which Angular SSR setup the project ended up with:
11
-
12
- legacy browser builder → ng build --configuration=X && ng run <name>:server:X
13
- modern application builder (Angular 17+, fresh `ng add @angular/ssr`)
14
- → ng build --configuration=X
15
- """
16
- import json
17
- import os
18
- import re
19
- from pathlib import Path
20
-
21
- from invoke import task, Context
22
-
23
- from itw_python_builder.utils import detect_project_type, _read_package_name, is_ssr_project
24
-
25
-
26
- def _angular_major_version() -> int:
27
- """Read @angular/core's major version from package.json. Returns 0 if not found."""
28
- pkg_path = os.path.join(os.getcwd(), 'package.json')
29
- with open(pkg_path, 'r', encoding='utf-8') as fp:
30
- data = json.load(fp)
31
- deps = {**data.get('dependencies', {}), **data.get('devDependencies', {})}
32
- spec = deps.get('@angular/core', '')
33
- match = re.match(r'[\^~>=<]*(\d+)', spec)
34
- return int(match.group(1)) if match else 0
35
-
36
-
37
- def _angular_core_installed_version() -> str:
38
- """Return the exact installed @angular/core version from node_modules, or '' if not found.
39
-
40
- Used to pin `ng add @angular/ssr@<this>` so platform-server's strict peer-deps
41
- (which demand the same patch across all @angular/* packages) align with what's
42
- already installed — no need for --legacy-peer-deps or --force.
43
- """
44
- pkg_path = os.path.join(os.getcwd(), 'node_modules', '@angular', 'core', 'package.json')
45
- if not os.path.isfile(pkg_path):
46
- return ''
47
- with open(pkg_path, 'r', encoding='utf-8') as fp:
48
- return json.load(fp).get('version', '')
49
-
50
-
51
- TEMPLATES_DIR = Path(__file__).parent / 'templates'
52
-
53
- SITEMAP_IMPORT_LINE = "import {sitemapEntries} from './src/sitemap.routes';"
54
-
55
-
56
- def _load_template(name: str) -> str:
57
- """Read a template file, stripping the leading `// @ts-nocheck` editor hint."""
58
- content = (TEMPLATES_DIR / name).read_text(encoding='utf-8')
59
- lines = content.splitlines(keepends=True)
60
- if lines and lines[0].lstrip().startswith('// @ts-nocheck'):
61
- lines = lines[1:]
62
- if lines and lines[0].strip() == '':
63
- lines = lines[1:]
64
- return ''.join(lines)
65
-
66
-
67
- def _run_ng_add_ssr(ctx: Context) -> None:
68
- """Run `ng add @angular/ssr` pinned to the project's installed @angular/core patch.
69
-
70
- Angular's @angular/platform-server (pulled in by @angular/ssr) has strict
71
- peer-dependencies — every patch version demands the exact same patch across
72
- all @angular/* packages. Without pinning, `ng add` grabs the latest patch and
73
- breaks any project not on that exact patch. Pinning to the installed core
74
- version aligns everything cleanly without bypassing npm's resolver.
75
-
76
- Flags:
77
- - Angular 18 and earlier: no `--server-routing` flag (schematic rejects it).
78
- - Angular 19+: pass `--server-routing=false` to keep the classic setup,
79
- matching the layout itw expects (single `server` target, not the new
80
- server router).
81
- """
82
- major = _angular_major_version()
83
- installed_core = _angular_core_installed_version()
84
- if installed_core:
85
- package_spec = f'@angular/ssr@{installed_core}'
86
- else:
87
- package_spec = '@angular/ssr'
88
- flags = '--skip-confirmation'
89
- if major >= 19:
90
- flags += ' --server-routing=false'
91
- print(f'[itw] Running `ng add {package_spec}` (Angular {major}, this may take a minute)...')
92
- ctx.run(f'npx ng add {package_spec} {flags}')
93
-
94
-
95
- def _install_ngx_seo_helper(ctx: Context) -> None:
96
- print('[itw] Installing ngx-seo-helper...')
97
- ctx.run('npm install ngx-seo-helper')
98
-
99
-
100
- def _write_sitemap_template() -> None:
101
- path = os.path.join(os.getcwd(), 'src', 'sitemap.routes.ts')
102
- if os.path.exists(path):
103
- print(f'[itw] Skipping sitemap template — {path} already exists.')
104
- return
105
- with open(path, 'w', encoding='utf-8') as fp:
106
- fp.write(_load_template('sitemap.routes.ts'))
107
- print('[itw] Wrote src/sitemap.routes.ts (empty tree — fill in your routes).')
108
-
109
-
110
- def _patch_server_ts() -> None:
111
- """Inject sitemap import + /sitemap.xml handler into server.ts (idempotent)."""
112
- path = os.path.join(os.getcwd(), 'server.ts')
113
- if not os.path.exists(path):
114
- raise RuntimeError('server.ts missing after `ng add @angular/ssr` — cannot patch.')
115
-
116
- with open(path, 'r', encoding='utf-8') as fp:
117
- content = fp.read()
118
-
119
- if 'sitemap.routes' in content:
120
- print('[itw] server.ts already references sitemap.routes — skipping patch.')
121
- return
122
-
123
- lines = content.splitlines(keepends=True)
124
- last_import_idx = -1
125
- for i, line in enumerate(lines):
126
- if line.startswith('import '):
127
- last_import_idx = i
128
- if last_import_idx == -1:
129
- raise RuntimeError('server.ts has no imports — unexpected shape, aborting patch.')
130
- lines.insert(last_import_idx + 1, SITEMAP_IMPORT_LINE + '\n')
131
- content = ''.join(lines)
132
-
133
- match = re.search(r"(server\.set\('views',\s*distFolder\);\s*\n)", content)
134
- if not match:
135
- match = re.search(r"(const\s+commonEngine\s*=\s*new\s+CommonEngine\(\);\s*\n)", content)
136
- if not match:
137
- raise RuntimeError(
138
- 'Could not find an injection point in server.ts (looked for `server.set(\'views\', ...)` '
139
- 'or `new CommonEngine()`). Patch sitemap handler manually.'
140
- )
141
- insert_at = match.end()
142
- content = content[:insert_at] + _load_template('server.sitemap.snippet.ts') + content[insert_at:]
143
-
144
- with open(path, 'w', encoding='utf-8') as fp:
145
- fp.write(content)
146
- print('[itw] Patched server.ts with /sitemap.xml handler.')
147
-
148
-
149
- def _print_next_steps(project_name: str) -> None:
150
- print()
151
- print('=' * 70)
152
- print('[itw] SSR scaffolding complete.')
153
- print('=' * 70)
154
- print('Next steps you must do manually:')
155
- print()
156
- print(' 1. Add `NgxSeoModule` to your AppModule imports.')
157
- print(' 2. Fill in src/sitemap.routes.ts with your route tree.')
158
- print(' 3. Add `staging` (and optionally `development`) configurations to')
159
- print(' angular.json under projects.' + project_name + '.architect.build.configurations.')
160
- print(' ng add @angular/ssr only creates the `production` configuration.')
161
- print()
162
- print(' 4. Add these two npm scripts to package.json — the deploy pipeline')
163
- print(' calls them by name:')
164
- print()
165
- print(' "build:ssr": "<build command for production>"')
166
- print(' "build:ssr:staging": "<build command for staging>"')
167
- print()
168
- print(' The exact command depends on which builder angular.json uses:')
169
- print()
170
- print(' • Modern application builder (default for fresh ng add @angular/ssr):')
171
- print(' "build:ssr": "ng build --configuration=production"')
172
- print(' "build:ssr:staging": "ng build --configuration=staging"')
173
- print()
174
- print(' • Legacy browser builder (older projects with a separate server target):')
175
- print(f' "build:ssr": "ng build --configuration=production && ng run {project_name}:server:production"')
176
- print(f' "build:ssr:staging": "ng build --configuration=staging && ng run {project_name}:server:staging"')
177
- print()
178
- print(' 5. Verify locally: npm run build:ssr && npm run serve:ssr')
179
- print(' 6. Commit the new files.')
180
- print(' 7. Deploy with: itw incrementrc --ssr (or incrementpatch/release --ssr)')
181
- print('=' * 70)
182
-
183
-
184
- @task(name='ssr-init')
185
- def ssr_init(ctx: Context, force: bool = False) -> None:
186
- """One-time SSR scaffold: ng add @angular/ssr, ngx-seo-helper, sitemap stub, server.ts patch.
187
-
188
- Does NOT write package.json scripts — those depend on which builder Angular
189
- chose. The post-run message tells you the two scripts the pipeline expects.
190
- """
191
- if detect_project_type() != 'frontend':
192
- raise RuntimeError('ssr-init only runs on frontend (Angular) projects.')
193
-
194
- if is_ssr_project() and not force:
195
- raise RuntimeError(
196
- 'SSR scaffolding already present (server.ts + tsconfig.server.json exist). '
197
- 'Re-run with --force to re-apply the sitemap stub and server.ts patch.'
198
- )
199
-
200
- name = _read_package_name()
201
- print(f'[itw] Scaffolding SSR for project: {name}')
202
-
203
- if not is_ssr_project():
204
- _run_ng_add_ssr(ctx)
205
-
206
- _install_ngx_seo_helper(ctx)
207
- _write_sitemap_template()
208
- _patch_server_ts()
209
-
210
- _print_next_steps(name)