tackl 2.0.0 → 2.4.0

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 (3) hide show
  1. package/README.md +7 -3
  2. package/bin/tackl.js +126 -36
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -9,13 +9,17 @@ mkdir my-app && cd my-app # or clone your empty repo and cd in
9
9
  bunx tackl # or: bun create tackl / npm create tackl@latest
10
10
  ```
11
11
 
12
- You'll be asked which CMS to use — **DatoCMS**, **Sanity**, or **None** — and the scaffold is pruned to match: only the chosen adapter, its dependencies, its docs, and its `.env.example` block survive.
12
+ You'll be asked which CMS to use — **DatoCMS**, **Sanity**, or **None** — and the scaffold is pruned to match: only the chosen adapter, its dependencies, its docs, and its `.env.example` block survive. Draft preview ships with both CMS choices (the `app/api/draft-mode/` routes resolve through the adapter seam). **None** keeps the same shape as the others — the `@cms` seam stays, rewired to a stub adapter whose `fetchContent` returns `null` — so wiring up a CMS later never touches app code; only the draft-mode routes and CMS files are removed.
13
+
14
+ Choosing **Sanity** sets the whole thing up: an embedded Studio at `/studio`, example schemas, and draft-mode preview routes ship in the scaffold, and the CLI offers to run `sanity init` for you — logging in, creating/linking a project, and writing your `.env`. Scaffold → `bun run dev` → `/studio` → start editing.
15
+
16
+ Design tokens live in `src/theme` and type styles in `src/css/tackl/_type.scss` — set them from your Figma file before you start building.
13
17
 
14
18
  ## What it does
15
19
 
16
20
  1. Verifies the directory is empty (an existing `.git`, `.gitignore`, `README.md` or `LICENSE` is fine — your repo's `.git` and remote are kept)
17
- 2. Downloads the latest tagged release of the template (falls back to `main` if none)
18
- 3. Prunes repo-only cargo (the CLI itself, `.github`) and the unused CMS adapter
21
+ 2. Downloads the latest GitHub release of the template (falls back to `main` if none can be resolved). The lookup uses `GITHUB_TOKEN` / `GH_TOKEN` when set, and otherwise falls back to github.com's non-rate-limited release redirect — so shared IPs (offices, CI) still get the release
22
+ 3. Prunes repo-only cargo (the CLI itself, `.github` templates, internal `docs/plans`) and the unused CMS adapter
19
23
  4. Sets your package name, initialises git if needed, and runs `bun install`
20
24
 
21
25
  ## Options
package/bin/tackl.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { execSync } from 'child_process';
3
- import fs from 'fs';
4
- import os from 'os';
5
- import path from 'path';
2
+ import { execSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
6
  import pc from 'picocolors';
7
7
  import prompts from 'prompts';
8
8
  import * as tar from 'tar';
@@ -55,18 +55,44 @@ const dirIsScaffoldable = dir => {
55
55
  // SECTION • Template ref resolution
56
56
  // NOTE • Prefer the latest GitHub release so every scaffold is a tested,
57
57
  // reproducible snapshot; fall back to main (with a warning) when no
58
- // release exists. --ref overrides both.
58
+ // release can be resolved. --ref overrides both.
59
+ // The REST API allows only 60 unauthenticated requests/hour per IP — shared
60
+ // IPs (offices, CI) hit that — so a GITHUB_TOKEN is used when present, and
61
+ // the github.com /releases/latest redirect (not rate-limited) is tried next.
62
+ const RELEASE_TAG_PATTERN = /\/releases\/tag\/([^/?#]+)/;
63
+
64
+ const latestReleaseFromApi = async () => {
65
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
66
+ const res = await fetch(`https://api.github.com/repos/${OWNER}/${REPO}/releases/latest`, {
67
+ headers: {
68
+ accept: 'application/vnd.github+json',
69
+ 'user-agent': 'tackl-cli',
70
+ ...(token && { authorization: `Bearer ${token}` }),
71
+ },
72
+ });
73
+ if (!res.ok) return null;
74
+ const json = await res.json();
75
+ return json?.tag_name ?? null;
76
+ };
77
+
78
+ const latestReleaseFromRedirect = async () => {
79
+ const res = await fetch(`https://github.com/${OWNER}/${REPO}/releases/latest`, {
80
+ method: 'HEAD',
81
+ redirect: 'manual',
82
+ headers: { 'user-agent': 'tackl-cli' },
83
+ });
84
+ const match = res.headers.get('location')?.match(RELEASE_TAG_PATTERN);
85
+ return match ? decodeURIComponent(match[1]) : null;
86
+ };
87
+
59
88
  const resolveRef = async explicitRef => {
60
89
  if (explicitRef) return { ref: explicitRef, source: 'flag' };
61
- try {
62
- const res = await fetch(`https://api.github.com/repos/${OWNER}/${REPO}/releases/latest`, {
63
- headers: { accept: 'application/vnd.github+json' },
64
- });
65
- if (res.ok) {
66
- const json = await res.json();
67
- if (json?.tag_name) return { ref: json.tag_name, source: 'release' };
68
- }
69
- } catch {}
90
+ for (const lookup of [latestReleaseFromApi, latestReleaseFromRedirect]) {
91
+ try {
92
+ const tag = await lookup();
93
+ if (tag) return { ref: tag, source: 'release' };
94
+ } catch {}
95
+ }
70
96
  return { ref: FALLBACK_REF, source: 'fallback' };
71
97
  };
72
98
 
@@ -104,7 +130,24 @@ const stripDeps = (root, deps) => {
104
130
  delete json.dependencies?.[d];
105
131
  delete json.devDependencies?.[d];
106
132
  }
107
- fs.writeFileSync(p, JSON.stringify(json, null, '\t') + '\n');
133
+ fs.writeFileSync(p, `${JSON.stringify(json, null, '\t')}\n`);
134
+ };
135
+
136
+ // NOTE • Everything that exists solely for the embedded Sanity Studio.
137
+ // app/api/draft-mode is NOT here — the draft routes are CMS-agnostic
138
+ // (they resolve through @cms/draft) and only go when no CMS is chosen.
139
+ const SANITY_STUDIO_PATHS = ['app/(studio)', 'sanity.config.ts', 'sanity'];
140
+ const SANITY_DEPS = ['next-sanity', 'sanity', '@sanity/vision'];
141
+
142
+ // NOTE • The two files the CLI rewires to the chosen adapter
143
+ const ADAPTER_ENTRIES = ['src/cms/index.ts', 'src/cms/draft.ts'];
144
+ const rewireAdapter = (root, adapter) => {
145
+ for (const rel of ADAPTER_ENTRIES) {
146
+ const p = path.join(root, rel);
147
+ if (fs.existsSync(p)) {
148
+ fs.writeFileSync(p, fs.readFileSync(p, 'utf8').replaceAll(`'./dato`, `'./${adapter}`));
149
+ }
150
+ }
108
151
  };
109
152
 
110
153
  const CMS = {
@@ -112,8 +155,10 @@ const CMS = {
112
155
  title: 'DatoCMS',
113
156
  prune: root => {
114
157
  rmrf(root, 'src/cms/sanity');
158
+ rmrf(root, 'src/cms/none');
115
159
  rmrf(root, 'docs/Sanity');
116
- stripDeps(root, ['next-sanity']);
160
+ for (const p of SANITY_STUDIO_PATHS) rmrf(root, p);
161
+ stripDeps(root, SANITY_DEPS);
117
162
  stripEnvBlock(root, '# CMS — Sanity');
118
163
  },
119
164
  },
@@ -121,32 +166,46 @@ const CMS = {
121
166
  title: 'Sanity',
122
167
  prune: root => {
123
168
  rmrf(root, 'src/cms/dato');
169
+ rmrf(root, 'src/cms/none');
124
170
  rmrf(root, 'docs/DatoCMS');
125
171
  stripDeps(root, ['@datocms/cda-client', 'react-datocms', 'graphql', 'graphql-tag']);
126
172
  stripEnvBlock(root, '# CMS — DatoCMS');
127
- // Rewire the adapter entry to the kept adapter
128
- const entry = path.join(root, 'src/cms/index.ts');
129
- if (fs.existsSync(entry)) {
130
- fs.writeFileSync(entry, fs.readFileSync(entry, 'utf8').replace(`'./dato'`, `'./sanity'`));
131
- }
173
+ rewireAdapter(root, 'sanity');
132
174
  },
133
175
  },
134
176
  none: {
135
177
  title: 'No CMS',
178
+ // NOTE • Same shape as the other choices — the '@cms' seam survives,
179
+ // rewired to the stub adapter (src/cms/none), so app code and the
180
+ // tsconfig alias stay valid and a CMS can be wired up later. Only the
181
+ // CMS-specific files go: adapters, draft-mode routes, studio, docs.
136
182
  prune: root => {
137
- rmrf(root, 'src/cms');
183
+ rmrf(root, 'src/cms/dato');
184
+ rmrf(root, 'src/cms/sanity');
185
+ rmrf(root, 'src/cms/draft.ts');
138
186
  rmrf(root, 'docs/Sanity');
139
187
  rmrf(root, 'docs/DatoCMS');
140
- rmrf(root, 'docs/CMS.md');
141
- stripDeps(root, ['next-sanity', '@datocms/cda-client', 'react-datocms', 'graphql', 'graphql-tag']);
188
+ rmrf(root, 'app/api/draft-mode');
189
+ for (const p of SANITY_STUDIO_PATHS) rmrf(root, p);
190
+ stripDeps(root, [...SANITY_DEPS, '@datocms/cda-client', 'react-datocms', 'graphql', 'graphql-tag']);
142
191
  stripEnvBlock(root, '# CMS — Sanity');
143
192
  stripEnvBlock(root, '# CMS — DatoCMS');
193
+ rewireAdapter(root, 'none');
144
194
  },
145
195
  },
146
196
  };
147
197
 
148
- // NOTE • Repo-only cargo that should never land in a scaffolded project
149
- const REPO_CARGO = ['tackl', '.github', 'bun.lock'];
198
+ // NOTE • Repo-only cargo that should never land in a scaffolded project.
199
+ // .github is pruned file-by-file — the CI workflow (.github/workflows) ships
200
+ // with every scaffold so quality gates run from the first push.
201
+ const REPO_CARGO = [
202
+ 'tackl',
203
+ 'docs/plans',
204
+ '.github/FUNDING.yml',
205
+ '.github/ISSUE_TEMPLATE.md',
206
+ '.github/PULL_REQUEST_TEMPLATE.md',
207
+ 'bun.lock',
208
+ ];
150
209
 
151
210
  async function main() {
152
211
  const args = process.argv.slice(2);
@@ -182,7 +241,9 @@ ${pc.bold('Repository:')} https://github.com/${OWNER}/${REPO}
182
241
  const targetDir = process.cwd();
183
242
 
184
243
  if (!dirIsScaffoldable(targetDir)) {
185
- log.error('This directory is not empty. Run tackl inside an empty directory (a .git folder, .gitignore, README.md and LICENSE are fine).');
244
+ log.error(
245
+ 'This directory is not empty. Run tackl inside an empty directory (a .git folder, .gitignore, README.md and LICENSE are fine).'
246
+ );
186
247
  process.exit(1);
187
248
  }
188
249
 
@@ -219,10 +280,11 @@ ${pc.bold('Repository:')} https://github.com/${OWNER}/${REPO}
219
280
 
220
281
  const cms = cmsFlag || answers.cms;
221
282
  const rawName = flagValue('--name') || path.basename(targetDir);
222
- const projectName = rawName
223
- .toLowerCase()
224
- .replace(/[^a-z0-9\-._]/g, '-')
225
- .replace(/^[-.]+|[-.]+$/g, '') || 'tackl-app';
283
+ const projectName =
284
+ rawName
285
+ .toLowerCase()
286
+ .replace(/[^a-z0-9\-._]/g, '-')
287
+ .replace(/^[-.]+|[-.]+$/g, '') || 'tackl-app';
226
288
  const doInstall = !args.includes('--no-install');
227
289
  const doGit = !args.includes('--no-git');
228
290
  const hadGit = fs.existsSync(path.join(targetDir, '.git'));
@@ -294,7 +356,7 @@ ${pc.bold('Repository:')} https://github.com/${OWNER}/${REPO}
294
356
  json.version = '0.1.0';
295
357
  delete json.repository;
296
358
  delete json.author;
297
- fs.writeFileSync(pkgPath, JSON.stringify(json, null, '\t') + '\n');
359
+ fs.writeFileSync(pkgPath, `${JSON.stringify(json, null, '\t')}\n`);
298
360
  log.ok('package.json updated.');
299
361
  } catch (e) {
300
362
  log.warn(`Could not update package.json: ${e.message}`);
@@ -330,11 +392,39 @@ ${pc.bold('Repository:')} https://github.com/${OWNER}/${REPO}
330
392
  log.info('Skipped dependency install.');
331
393
  }
332
394
 
333
- console.log('\n' + pc.bold(pc.green('Success!')) + ' Project scaffolded.\n');
395
+ // SECTION • Sanity project link
396
+ // NOTE • A Sanity scaffold isn't usable until a project id exists —
397
+ // sanity's own init logs in, creates/links a project and writes .env.
398
+ let sanityLinked = false;
399
+ if (cms === 'sanity' && process.stdout.isTTY) {
400
+ const { link } = await prompts({
401
+ type: 'confirm',
402
+ name: 'link',
403
+ message: 'Link a Sanity project now? (opens a browser login, writes .env)',
404
+ initial: true,
405
+ });
406
+ if (link) {
407
+ try {
408
+ execSync('bunx sanity@latest init --env .env', { cwd: targetDir, stdio: 'inherit' });
409
+ sanityLinked = true;
410
+ log.ok('Sanity project linked!');
411
+ } catch (e) {
412
+ log.warn(`sanity init did not complete: ${e.message}`);
413
+ }
414
+ }
415
+ }
416
+
417
+ console.log(`\n${pc.bold(pc.green('Success!'))} Project scaffolded.\n`);
334
418
  console.log(' Next steps:');
335
- console.log(' 1) cp .env.example .env # then fill in your values');
336
- if (!doInstall) console.log(' 2) bun install');
337
- console.log(` ${doInstall ? '2' : '3'}) bun run dev`);
419
+ let step = 1;
420
+ if (cms === 'sanity' && !sanityLinked) {
421
+ console.log(` ${step++}) bunx sanity init --env .env # create/link your Sanity project`);
422
+ } else if (cms !== 'sanity') {
423
+ console.log(` ${step++}) cp .env.example .env # then fill in your values`);
424
+ }
425
+ if (!doInstall) console.log(` ${step++}) bun install`);
426
+ console.log(` ${step++}) bun run dev # then set your tokens in src/theme`);
427
+ if (cms === 'sanity') console.log(` ${step}) open http://localhost:3000/studio to start editing content`);
338
428
  }
339
429
 
340
430
  main().catch(e => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tackl",
3
- "version": "2.0.0",
4
- "description": "Scaffold a Tackl project into the current directory — Next.js, styled-components, Waffl grid, DatoCMS/Sanity adapters",
3
+ "version": "2.4.0",
4
+ "description": "Scaffold a Tackl project into the current directory — Next.js, SCSS CSS Modules, Waffl grid, DatoCMS/Sanity adapters",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "tackl": "bin/tackl.js"
@@ -17,7 +17,8 @@
17
17
  "template",
18
18
  "grid",
19
19
  "css",
20
- "styled-components",
20
+ "scss",
21
+ "css-modules",
21
22
  "typescript"
22
23
  ],
23
24
  "author": "joebentaylor1995",
@@ -40,6 +41,6 @@
40
41
  "dependencies": {
41
42
  "picocolors": "^1.1.1",
42
43
  "prompts": "^2.4.2",
43
- "tar": "^7.5.1"
44
+ "tar": "^7.5.22"
44
45
  }
45
46
  }