@webjsdev/cli 0.8.1

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,397 @@
1
+ /**
2
+ * SaaS template files for `webjs create --template saas`.
3
+ * Extracted to avoid nested template literal escaping issues.
4
+ */
5
+
6
+ import { mkdir, writeFile, readFile } from 'node:fs/promises';
7
+ import { existsSync } from 'node:fs';
8
+ import { join, resolve, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const UI_REGISTRY_ROOT = resolve(
13
+ __dirname, '..', '..', 'ui', 'packages', 'registry',
14
+ );
15
+
16
+ /**
17
+ * Read a registry component and rewrite its `'../lib/utils.ts'` import for
18
+ * the scaffolded app's `components/ui/<name>.ts` layout (two-up to lib/).
19
+ * Mirrors the helper in `create.js`, kept private here to avoid coupling.
20
+ */
21
+ async function readUiComponent(name) {
22
+ const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
23
+ if (!existsSync(src)) return null;
24
+ const raw = await readFile(src, 'utf8');
25
+ return raw
26
+ .replaceAll("'../lib/utils.ts'", "'../../lib/utils.ts'")
27
+ .replaceAll('"../lib/utils.ts"', '"../../lib/utils.ts"');
28
+ }
29
+
30
+ /** Copy named registry components into `<appDir>/components/ui/`. */
31
+ async function copyUiComponents(appDir, names) {
32
+ const uiDir = join(appDir, 'components', 'ui');
33
+ await mkdir(uiDir, { recursive: true });
34
+ for (const n of names) {
35
+ const content = await readUiComponent(n);
36
+ if (content == null) continue;
37
+ await writeFile(join(uiDir, `${n}.ts`), content);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * @param {string} appDir
43
+ */
44
+ export async function writeSaasFiles(appDir) {
45
+ // SaaS pages use auth forms, so copy the extra ui-* components on top of
46
+ // the standard set the full-stack scaffold already wrote. Pre-importing
47
+ // them in login/signup/dashboard pages below means the dev server will
48
+ // SSR these elements with full styling on first paint.
49
+ // `form` and `field` are deferred to v2 (see packages/ui/AGENTS.md) -
50
+ // the saas auth pages use raw <form> + label/input class helpers instead.
51
+ await copyUiComponents(appDir, ['dialog', 'switch', 'checkbox']);
52
+
53
+ // lib/prisma.server.ts
54
+ await mkdir(join(appDir, 'lib'), { recursive: true });
55
+ await writeFile(join(appDir, 'lib', 'prisma.server.ts'), [
56
+ "import { PrismaClient } from '@prisma/client';",
57
+ "",
58
+ "const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };",
59
+ "export const prisma = globalForPrisma.prisma || new PrismaClient();",
60
+ "if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;",
61
+ "",
62
+ ].join('\n'));
63
+
64
+ // lib/password.server.ts
65
+ await writeFile(join(appDir, 'lib', 'password.server.ts'), [
66
+ "import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';",
67
+ "import { promisify } from 'node:util';",
68
+ "",
69
+ "const scryptAsync = promisify(scrypt);",
70
+ "",
71
+ "export async function hash(password: string): Promise<string> {",
72
+ " const salt = randomBytes(16).toString('hex');",
73
+ " const buf = (await scryptAsync(password, salt, 64)) as Buffer;",
74
+ " return salt + ':' + buf.toString('hex');",
75
+ "}",
76
+ "",
77
+ "export async function compare(password: string, stored: string): Promise<boolean> {",
78
+ " const [salt, key] = stored.split(':');",
79
+ " const buf = (await scryptAsync(password, salt, 64)) as Buffer;",
80
+ " return timingSafeEqual(buf, Buffer.from(key, 'hex'));",
81
+ "}",
82
+ "",
83
+ ].join('\n'));
84
+
85
+ // lib/auth.server.ts
86
+ await writeFile(join(appDir, 'lib', 'auth.server.ts'), [
87
+ "import { createAuth, Credentials } from '@webjsdev/server';",
88
+ "import { prisma } from './prisma.server.ts';",
89
+ "import { compare } from './password.server.ts';",
90
+ "",
91
+ "export const { auth, signIn, signOut, handlers } = createAuth({",
92
+ " providers: [",
93
+ " Credentials({",
94
+ " async authorize(credentials: { email: string; password: string }) {",
95
+ " const user = await prisma.user.findUnique({ where: { email: credentials.email } });",
96
+ " if (!user || !await compare(credentials.password, user.passwordHash)) return null;",
97
+ " return { id: String(user.id), name: user.name, email: user.email };",
98
+ " },",
99
+ " }),",
100
+ " ],",
101
+ " secret: process.env.AUTH_SECRET,",
102
+ "});",
103
+ "",
104
+ ].join('\n'));
105
+
106
+ // prisma/schema.prisma
107
+ await mkdir(join(appDir, 'prisma'), { recursive: true });
108
+ await writeFile(join(appDir, 'prisma', 'schema.prisma'), [
109
+ 'datasource db {',
110
+ ' provider = "sqlite"',
111
+ ' url = env("DATABASE_URL")',
112
+ '}',
113
+ '',
114
+ 'generator client {',
115
+ ' provider = "prisma-client-js"',
116
+ '}',
117
+ '',
118
+ 'model User {',
119
+ ' id Int @id @default(autoincrement())',
120
+ ' email String @unique',
121
+ ' name String?',
122
+ ' passwordHash String',
123
+ ' createdAt DateTime @default(now())',
124
+ '}',
125
+ '',
126
+ ].join('\n'));
127
+
128
+ // modules/auth/actions/signup.server.ts
129
+ await mkdir(join(appDir, 'modules', 'auth', 'actions'), { recursive: true });
130
+ await mkdir(join(appDir, 'modules', 'auth', 'queries'), { recursive: true });
131
+
132
+ await writeFile(join(appDir, 'modules', 'auth', 'actions', 'signup.server.ts'), [
133
+ "'use server';",
134
+ "",
135
+ "import { prisma } from '../../../lib/prisma.server.ts';",
136
+ "import { hash } from '../../../lib/password.server.ts';",
137
+ "",
138
+ "export async function signup(input: { name: string; email: string; password: string }) {",
139
+ " const exists = await prisma.user.findUnique({ where: { email: input.email } });",
140
+ " if (exists) return { success: false as const, error: 'Email already registered', status: 409 };",
141
+ " const user = await prisma.user.create({",
142
+ " data: { name: input.name, email: input.email, passwordHash: await hash(input.password) },",
143
+ " });",
144
+ " return { success: true as const, data: { id: user.id, name: user.name, email: user.email } };",
145
+ "}",
146
+ "",
147
+ ].join('\n'));
148
+
149
+ // modules/auth/queries/current-user.server.ts
150
+ await writeFile(join(appDir, 'modules', 'auth', 'queries', 'current-user.server.ts'), [
151
+ "'use server';",
152
+ "",
153
+ "import { auth } from '../../../lib/auth.server.ts';",
154
+ "",
155
+ "export async function currentUser() {",
156
+ " const session = await auth();",
157
+ " return session?.user ?? null;",
158
+ "}",
159
+ "",
160
+ ].join('\n'));
161
+
162
+ // modules/auth/types.ts
163
+ await writeFile(join(appDir, 'modules', 'auth', 'types.ts'), [
164
+ "export interface User {",
165
+ " id: number;",
166
+ " name: string | null;",
167
+ " email: string;",
168
+ "}",
169
+ "",
170
+ "export type ActionResult<T> =",
171
+ " | { success: true; data: T }",
172
+ " | { success: false; error: string; status: number };",
173
+ "",
174
+ ].join('\n'));
175
+
176
+ // test/unit/auth.test.ts: minimal stub so the scaffold passes
177
+ // `webjs check` (tests-exist) and `webjs test` runs cleanly out of the
178
+ // box. The signup/current-user functions import from lib/prisma.server.ts
179
+ // and lib/auth.server.ts, both of which need `prisma generate` to have run before
180
+ // they can be imported, so we deliberately test only the runtime-
181
+ // dependency-free types.ts here. Replace with real tests once Prisma
182
+ // is set up (run `npm install && npx prisma migrate dev --name init`).
183
+ await writeFile(join(appDir, 'test', 'unit', 'auth.test.ts'), [
184
+ "import { test } from 'node:test';",
185
+ "import assert from 'node:assert/strict';",
186
+ "",
187
+ "import type { User, ActionResult } from '../../modules/auth/types.ts';",
188
+ "",
189
+ "test('User shape: id is numeric, email is required', () => {",
190
+ " const u: User = { id: 1, name: 'Test', email: 'test@example.com' };",
191
+ " assert.equal(typeof u.id, 'number');",
192
+ " assert.equal(typeof u.email, 'string');",
193
+ "});",
194
+ "",
195
+ "test('ActionResult: success envelope carries data', () => {",
196
+ " const r: ActionResult<User> = {",
197
+ " success: true,",
198
+ " data: { id: 1, name: 'Test', email: 'test@example.com' },",
199
+ " };",
200
+ " assert.equal(r.success, true);",
201
+ " if (r.success) assert.equal(r.data.email, 'test@example.com');",
202
+ "});",
203
+ "",
204
+ "test('ActionResult: failure envelope carries error + status', () => {",
205
+ " const r: ActionResult<User> = {",
206
+ " success: false,",
207
+ " error: 'Email already registered',",
208
+ " status: 409,",
209
+ " };",
210
+ " assert.equal(r.success, false);",
211
+ " if (!r.success) {",
212
+ " assert.equal(r.status, 409);",
213
+ " assert.ok(r.error.length > 0);",
214
+ " }",
215
+ "});",
216
+ "",
217
+ "// TODO: once you've run `npm install && npx prisma migrate dev` you can",
218
+ "// import { signup } from '../../modules/auth/actions/signup.server.ts'",
219
+ "// and { currentUser } from '../../modules/auth/queries/current-user.server.ts'",
220
+ "// and write real integration tests against a test SQLite DB.",
221
+ "",
222
+ ].join('\n'));
223
+
224
+ // app/api/auth/[...path]/route.ts
225
+ await mkdir(join(appDir, 'app', 'api', 'auth', '[...path]'), { recursive: true });
226
+ await writeFile(join(appDir, 'app', 'api', 'auth', '[...path]', 'route.ts'), [
227
+ "import { handlers } from '../../../../../lib/auth.server.ts';",
228
+ "export const GET = handlers.GET;",
229
+ "export const POST = handlers.POST;",
230
+ "",
231
+ ].join('\n'));
232
+
233
+ // app/login/page.ts
234
+ await mkdir(join(appDir, 'app', 'login'), { recursive: true });
235
+ await writeFile(join(appDir, 'app', 'login', 'page.ts'), [
236
+ "import { html } from '@webjsdev/core';",
237
+ "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '../../components/ui/card.ts';",
238
+ "import { buttonClass } from '../../components/ui/button.ts';",
239
+ "import { inputClass } from '../../components/ui/input.ts';",
240
+ "import { labelClass } from '../../components/ui/label.ts';",
241
+ "",
242
+ "export const metadata = { title: 'Login' };",
243
+ "",
244
+ "export default function LoginPage() {",
245
+ " return html`",
246
+ " <div class=\"max-w-sm mx-auto mt-12\">",
247
+ " <div class=${cardClass()}>",
248
+ " <div class=${cardHeaderClass()}>",
249
+ " <h3 class=${cardTitleClass()}>Sign in</h3>",
250
+ " <p class=${cardDescriptionClass()}>Welcome back: log in to continue.</p>",
251
+ " </div>",
252
+ " <div class=${cardContentClass()}>",
253
+ " <form method=\"POST\" action=\"/api/auth/callback/credentials\" class=\"flex flex-col gap-4\">",
254
+ " <div class=\"flex flex-col gap-1.5\">",
255
+ " <label class=${labelClass()} for=\"email\">Email</label>",
256
+ " <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
257
+ " </div>",
258
+ " <div class=\"flex flex-col gap-1.5\">",
259
+ " <label class=${labelClass()} for=\"password\">Password</label>",
260
+ " <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" required>",
261
+ " </div>",
262
+ " <button class=${buttonClass()} type=\"submit\">Sign in</button>",
263
+ " </form>",
264
+ " </div>",
265
+ " <div class=${cardFooterClass()}>",
266
+ " <p class=\"text-sm text-muted-foreground\">Don't have an account? <a href=\"/signup\" class=\"underline\">Sign up</a></p>",
267
+ " </div>",
268
+ " </div>",
269
+ " </div>",
270
+ " `;",
271
+ "}",
272
+ "",
273
+ ].join('\n'));
274
+
275
+ // app/signup/page.ts
276
+ await mkdir(join(appDir, 'app', 'signup'), { recursive: true });
277
+ await writeFile(join(appDir, 'app', 'signup', 'page.ts'), [
278
+ "import { html } from '@webjsdev/core';",
279
+ "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '../../components/ui/card.ts';",
280
+ "import { buttonClass } from '../../components/ui/button.ts';",
281
+ "import { inputClass } from '../../components/ui/input.ts';",
282
+ "import { labelClass } from '../../components/ui/label.ts';",
283
+ "",
284
+ "export const metadata = { title: 'Sign up' };",
285
+ "",
286
+ "export default function SignupPage() {",
287
+ " return html`",
288
+ " <div class=\"max-w-sm mx-auto mt-12\">",
289
+ " <div class=${cardClass()}>",
290
+ " <div class=${cardHeaderClass()}>",
291
+ " <h3 class=${cardTitleClass()}>Create an account</h3>",
292
+ " <p class=${cardDescriptionClass()}>Get started with your new workspace.</p>",
293
+ " </div>",
294
+ " <div class=${cardContentClass()}>",
295
+ " <form id=\"signup-form\" class=\"flex flex-col gap-4\">",
296
+ " <div class=\"flex flex-col gap-1.5\">",
297
+ " <label class=${labelClass()} for=\"name\">Name</label>",
298
+ " <input class=${inputClass()} id=\"name\" name=\"name\" type=\"text\" required>",
299
+ " </div>",
300
+ " <div class=\"flex flex-col gap-1.5\">",
301
+ " <label class=${labelClass()} for=\"email\">Email</label>",
302
+ " <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
303
+ " </div>",
304
+ " <div class=\"flex flex-col gap-1.5\">",
305
+ " <label class=${labelClass()} for=\"password\">Password</label>",
306
+ " <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" required>",
307
+ " </div>",
308
+ " <button class=${buttonClass()} type=\"submit\">Create account</button>",
309
+ " </form>",
310
+ " </div>",
311
+ " <div class=${cardFooterClass()}>",
312
+ " <p class=\"text-sm text-muted-foreground\">Already have an account? <a href=\"/login\" class=\"underline\">Log in</a></p>",
313
+ " </div>",
314
+ " </div>",
315
+ " </div>",
316
+ " `;",
317
+ "}",
318
+ "",
319
+ ].join('\n'));
320
+
321
+ // app/dashboard/middleware.ts
322
+ await mkdir(join(appDir, 'app', 'dashboard', 'settings'), { recursive: true });
323
+ await writeFile(join(appDir, 'app', 'dashboard', 'middleware.ts'), [
324
+ "import { auth } from '../../lib/auth.server.ts';",
325
+ "",
326
+ "export default async function requireAuth(req: Request, next: () => Promise<Response>) {",
327
+ " const session = await auth();",
328
+ " if (!session?.user) {",
329
+ " return new Response(null, { status: 302, headers: { location: '/login' } });",
330
+ " }",
331
+ " return next();",
332
+ "}",
333
+ "",
334
+ ].join('\n'));
335
+
336
+ // app/dashboard/page.ts
337
+ await writeFile(join(appDir, 'app', 'dashboard', 'page.ts'), [
338
+ "import { html } from '@webjsdev/core';",
339
+ "import { currentUser } from '../../modules/auth/queries/current-user.server.ts';",
340
+ "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '../../components/ui/card.ts';",
341
+ "import { buttonClass } from '../../components/ui/button.ts';",
342
+ "import { badgeClass } from '../../components/ui/badge.ts';",
343
+ "",
344
+ "export const metadata = { title: 'Dashboard' };",
345
+ "",
346
+ "export default async function Dashboard() {",
347
+ " const user = await currentUser();",
348
+ " return html`",
349
+ " <div class=\"flex items-center justify-between mb-6\">",
350
+ " <h1 class=\"text-2xl font-semibold\">Dashboard</h1>",
351
+ " <span class=${badgeClass({ variant: 'secondary' })}>Signed in</span>",
352
+ " </div>",
353
+ " <div class=${cardClass()}>",
354
+ " <div class=${cardHeaderClass()}>",
355
+ " <h3 class=${cardTitleClass()}>Welcome, ${`\\$\\{user?.name || user?.email\\}`}!</h3>",
356
+ " <p class=${cardDescriptionClass()}>You're authenticated. Replace this scaffold with your real app.</p>",
357
+ " </div>",
358
+ " <div class=${cardContentClass()}>",
359
+ " <a class=${buttonClass({ variant: 'outline' })} href=\"/dashboard/settings\">Settings</a>",
360
+ " </div>",
361
+ " </div>",
362
+ " `;",
363
+ "}",
364
+ "",
365
+ ].join('\n'));
366
+
367
+ // app/dashboard/settings/page.ts
368
+ await writeFile(join(appDir, 'app', 'dashboard', 'settings', 'page.ts'), [
369
+ "import { html } from '@webjsdev/core';",
370
+ "import { currentUser } from '../../../modules/auth/queries/current-user.server.ts';",
371
+ "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '../../../components/ui/card.ts';",
372
+ "",
373
+ "export const metadata = { title: 'Settings' };",
374
+ "",
375
+ "export default async function Settings() {",
376
+ " const user = await currentUser();",
377
+ " return html`",
378
+ " <h1 class=\"text-2xl font-semibold mb-6\">Settings</h1>",
379
+ " <div class=${cardClass()}>",
380
+ " <div class=${cardHeaderClass()}>",
381
+ " <h3 class=${cardTitleClass()}>Account</h3>",
382
+ " <p class=${cardDescriptionClass()}>Your basic profile information.</p>",
383
+ " </div>",
384
+ " <div class=${cardContentClass()}>",
385
+ " <dl class=\"grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm\">",
386
+ " <dt class=\"text-muted-foreground\">Email</dt>",
387
+ " <dd>${`\\$\\{user?.email\\}`}</dd>",
388
+ " <dt class=\"text-muted-foreground\">Name</dt>",
389
+ " <dd>${`\\$\\{user?.name || 'Not set'\\}`}</dd>",
390
+ " </dl>",
391
+ " </div>",
392
+ " </div>",
393
+ " `;",
394
+ "}",
395
+ "",
396
+ ].join('\n'));
397
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@webjsdev/cli",
3
+ "version": "0.8.1",
4
+ "type": "module",
5
+ "description": "webjs CLI - dev, start, create, db",
6
+ "bin": {
7
+ "webjs": "bin/webjs.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "templates",
13
+ "README.md"
14
+ ],
15
+ "dependencies": {
16
+ "@webjsdev/server": "^0.7.2",
17
+ "@webjsdev/ui": "^0.3.1"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/vivek7405/webjs.git",
25
+ "directory": "packages/cli"
26
+ },
27
+ "homepage": "https://github.com/vivek7405/webjs#readme",
28
+ "bugs": "https://github.com/vivek7405/webjs/issues",
29
+ "license": "MIT",
30
+ "keywords": [
31
+ "webjs",
32
+ "cli",
33
+ "dev",
34
+ "scaffold"
35
+ ],
36
+ "engines": {
37
+ "node": ">=24.0.0"
38
+ }
39
+ }
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # PreToolUse hook: block prose-punctuation patterns the webjs convention bans.
4
+ #
5
+ # Catches four classes of new content in tool calls:
6
+ #
7
+ # 1. U+2014 em-dash, anywhere.
8
+ # 2. Space-hyphen-space " - " in PROSE contexts (comment lines, markdown
9
+ # lines, headings, blockquotes). Math expressions in code like
10
+ # `Math.abs(a - b)` or `arr.length - 1` are NOT flagged.
11
+ # 3. Space-semicolon-space " ; " in PROSE contexts. JS / CSS statement
12
+ # terminators (`;\n`) are NOT flagged.
13
+ # 4. Code-shaped left-hand side immediately followed by a colon and prose:
14
+ # - `<code>foo()</code>:` (markdown code-LHS in docs)
15
+ # - `<my-tag>:` (custom-element tag with hyphen)
16
+ # - Inline comment `// foo(): description`
17
+ #
18
+ # Why this exists: see AGENTS.md "Invariants", item 10. These patterns
19
+ # confuse AI agents that try to parse the prose as TypeScript / shorthand-
20
+ # method / object-literal syntax, and trip humans reading API docs.
21
+ #
22
+ # Covers two tool-call paths:
23
+ # * Write / Edit / MultiEdit / NotebookEdit. The hook inspects the NEW
24
+ # content fields of the tool payload. Existing glyphs in old_string
25
+ # are not flagged: you can still Edit a line that contains one to
26
+ # remove it.
27
+ # * Bash. The hook inspects the command string, which catches commit
28
+ # messages (`git commit -m "..."`), heredocs, echo / printf, and any
29
+ # other prose typed at the shell.
30
+
31
+ set -euo pipefail
32
+
33
+ payload=$(cat)
34
+
35
+ # Pull every field where prose might land. `// empty` keeps missing
36
+ # fields silent; `[]?` keeps array iteration safe when absent.
37
+ new_content=$(printf '%s' "$payload" | jq -r '
38
+ (.tool_input.content // empty),
39
+ (.tool_input.new_string // empty),
40
+ (.tool_input.new_source // empty),
41
+ (.tool_input.command // empty),
42
+ (.tool_input.edits[]?.new_string // empty)
43
+ ' 2>/dev/null || true)
44
+
45
+ if [ -z "$new_content" ]; then
46
+ exit 0
47
+ fi
48
+
49
+ # --- 1. U+2014 em-dash --------------------------------------------------
50
+ if printf '%s' "$new_content" | grep -q $'\xe2\x80\x94'; then
51
+ cat >&2 <<'EOF'
52
+ BLOCKED: em-dash (U+2014) detected in this tool call.
53
+
54
+ webjs bans em-dashes repo-wide. Replace every U+2014 character with
55
+ a period, comma, colon (on a plain-noun LHS), parentheses, or
56
+ restructured sentence. Do NOT replace it with " - " or " ; " or a
57
+ trailing colon on code: those are also banned. See rule 2 / 3 / 4
58
+ below for the alternatives.
59
+
60
+ Rule: AGENTS.md, Invariants section, item 10.
61
+ Hook: .claude/hooks/block-prose-punctuation.sh.
62
+ EOF
63
+ exit 2
64
+ fi
65
+
66
+ # --- 2. Pause-hyphen " - " in PROSE contexts ----------------------------
67
+ # Only flag lines whose context is clearly prose:
68
+ # - Markdown lines starting with `#`, `>`, `*`, plain text outside code
69
+ # fences (heuristic: line has no `=`, `{`, or `(...)` math)
70
+ # - JSDoc / block comment lines starting with `*`
71
+ # - Single-line comments starting with `//`
72
+ #
73
+ # Math expressions like `Math.abs(a - b)` or `arr.length - 1` are NOT
74
+ # flagged because they appear in code lines (not comments) with code
75
+ # context. The hook trades some false negatives in prose for zero false
76
+ # positives in code-heavy diffs.
77
+
78
+ block_pause_hyphen=0
79
+
80
+ # Comment-line " - " pause: line starts with `//` or ` *` (JSDoc/block) or
81
+ # `*` (markdown bold-start would have a letter after, distinguishable),
82
+ # followed by prose with `\w+ - \w+` pattern. Specifically: catch lines
83
+ # like `// foo - bar`, ` * foo - bar`, `* foo - bar`.
84
+ if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
85
+ block_pause_hyphen=1
86
+ fi
87
+
88
+ # Markdown heading " - " pause: line starts with `#` followed by prose
89
+ # and ` - ` pattern.
90
+ if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
91
+ block_pause_hyphen=1
92
+ fi
93
+
94
+ # Markdown blockquote " - " pause: line starts with `>` followed by prose
95
+ # and ` - ` pattern. (Single `>` blockquote, not table.)
96
+ if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
97
+ block_pause_hyphen=1
98
+ fi
99
+
100
+ # HTML / markdown <p>, <li>, <td> body " - " pause: line contains a
101
+ # closing HTML tag from a prose context, then prose-style ` - `.
102
+ if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
103
+ block_pause_hyphen=1
104
+ fi
105
+
106
+ if [ "$block_pause_hyphen" = "1" ]; then
107
+ cat >&2 <<'EOF'
108
+ BLOCKED: pause-hyphen " - " detected in a prose context.
109
+
110
+ webjs bans plain hyphens used as pause-punctuation in prose. Rewrite
111
+ the sentence with a period, comma, colon (on a plain-noun LHS), or
112
+ restructured phrasing.
113
+
114
+ Bad: // Foo - bar
115
+ Good: // Foo, with bar
116
+ Good: // Foo. Bar.
117
+
118
+ Bad: <li>Foo - bar.</li>
119
+ Good: <li>Foo, with bar.</li>
120
+
121
+ Plain hyphens are still fine in compound words (`AI-first`), CLI
122
+ flags (`--http2`), filenames, ranges, and math expressions in code
123
+ (`arr.length - 1`, `Math.abs(a - b)`). The hook only flags the
124
+ ` < word > - < word > ` pause-pattern in prose contexts (comments,
125
+ markdown headings, blockquotes, HTML prose tags).
126
+
127
+ Rule: AGENTS.md, Invariants section, item 10.
128
+ Hook: .claude/hooks/block-prose-punctuation.sh.
129
+ EOF
130
+ exit 2
131
+ fi
132
+
133
+ # --- 3. Pause-semicolon " ; " in PROSE contexts -------------------------
134
+ # Same prose-context guard as #2.
135
+ block_pause_semicolon=0
136
+
137
+ if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
138
+ block_pause_semicolon=1
139
+ fi
140
+
141
+ if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
142
+ block_pause_semicolon=1
143
+ fi
144
+
145
+ if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
146
+ block_pause_semicolon=1
147
+ fi
148
+
149
+ if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
150
+ block_pause_semicolon=1
151
+ fi
152
+
153
+ if [ "$block_pause_semicolon" = "1" ]; then
154
+ cat >&2 <<'EOF'
155
+ BLOCKED: pause-semicolon " ; " detected in a prose context.
156
+
157
+ webjs bans semicolons used as pause-punctuation in prose. Rewrite as
158
+ two sentences (period) or with a conjunction (", and", ", but", ", so").
159
+
160
+ Bad: // Forms work ; links work too.
161
+ Good: // Forms work. Links work too.
162
+ Good: // Forms work, and links work too.
163
+
164
+ Semicolons stay fine inside code (JS statement terminators, CSS
165
+ declarations) since those are not flagged.
166
+
167
+ Rule: AGENTS.md, Invariants section, item 10.
168
+ Hook: .claude/hooks/block-prose-punctuation.sh.
169
+ EOF
170
+ exit 2
171
+ fi
172
+
173
+ # --- 4a. <code>foo()</code>: prose ---------------------------------------
174
+ # Markdown / HTML definition list with code-call followed by colon and
175
+ # lowercase prose. The `)</code>:` shape is unambiguous: this is markdown,
176
+ # not code, AND the inner code ends in `()` so the colon visually parses
177
+ # as a return-type annotation.
178
+ if printf '%s' "$new_content" | grep -qE '\)</code>:[[:space:]][a-z]'; then
179
+ cat >&2 <<'EOF'
180
+ BLOCKED: code-LHS colon-then-prose detected ("<code>foo()</code>: ...").
181
+
182
+ webjs bans `<code>foo()</code>: <prose>` because the colon visually
183
+ parses as a TypeScript return-type annotation. Rewrite verb-led.
184
+
185
+ Bad: <code>repeat()</code>: keyed list directive
186
+ Good: <code>repeat()</code> is the keyed list directive
187
+ Good: <code>startServer()</code> creates an HTTP(S) server
188
+
189
+ Rule: AGENTS.md, Invariants section, item 10.
190
+ Hook: .claude/hooks/block-prose-punctuation.sh.
191
+ EOF
192
+ exit 2
193
+ fi
194
+
195
+ # --- 4b. Custom-element-tag <my-tag>: prose ------------------------------
196
+ # HTML reserves hyphenated tag names for custom elements (W3C spec), so
197
+ # `<x-y>:` is unambiguous prose, never JSX / TS / CSS.
198
+ if printf '%s' "$new_content" | grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]'; then
199
+ cat >&2 <<'EOF'
200
+ BLOCKED: custom-element-tag colon-then-prose detected ("<my-tag>: ...").
201
+
202
+ webjs bans `<my-tag>: <prose>` in comments and docs. Rewrite verb-led.
203
+
204
+ Bad: // <ui-dialog>: owns open state, focus trap, escape, scroll lock.
205
+ Good: // <ui-dialog> owns open state, focus trap, escape, scroll lock.
206
+ Bad: // <ui-dialog-content>: the centered panel.
207
+ Good: // <ui-dialog-content> is the centered panel.
208
+
209
+ Rule: AGENTS.md, Invariants section, item 10.
210
+ Hook: .claude/hooks/block-prose-punctuation.sh.
211
+ EOF
212
+ exit 2
213
+ fi
214
+
215
+ # --- 4c. Inline / JSDoc comment "foo(): prose" --------------------------
216
+ # Match comment-line prefix (`//` or leading `*`) before `\w+(...): ` and
217
+ # lowercase prose. Avoids TS return-type annotations because those never
218
+ # appear inside comment lines.
219
+ if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]'; then
220
+ cat >&2 <<'EOF'
221
+ BLOCKED: comment-line code-LHS colon-then-prose detected ("// foo(): ...").
222
+
223
+ webjs bans `xyz(): <prose>` inside comments and JSDoc. Rewrite verb-led.
224
+
225
+ Bad: // firstUpdated(): once, on the first render only
226
+ Good: // firstUpdated() runs once, on the first render only
227
+ Bad: // closest(): null if the click wasn't inside a frame
228
+ Good: // closest() returns null when the click wasn't inside a frame
229
+
230
+ Rule: AGENTS.md, Invariants section, item 10.
231
+ Hook: .claude/hooks/block-prose-punctuation.sh.
232
+ EOF
233
+ exit 2
234
+ fi
235
+
236
+ exit 0