Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .cursor/rules/const-arrow-functions.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
description: Prefer const arrow functions; no function declarations in apps/web TS/TSX
globs:
- apps/web/**/*.ts
- apps/web/**/*.tsx
alwaysApply: false
---

# Function style (`apps/web`)

- Use **`const name = (…) => { … }`** (or `async` arrows) for named functions, including exports. Do not use **`function name()`** declarations at module scope or nested in source files.
- **Biome** `complexity/useArrowFunction` rewrites **function expressions** to arrows; it does **not** flag top-level `function` declarations ([Biome discussion #7108](https://github.com/biomejs/biome/discussions/7108)).
- **`pnpm lint`** (and **`pnpm --filter web lint`**) also runs **`scripts/assert-no-function-declarations.mjs`**, which fails if a declaration line appears in hand-written code under `src/` (excluding `generated/`) or in `vite.config.ts` / `openapi-ts.config.ts`.
- Generated OpenAPI output under **`src/api/generated/`** is exempt from that script.
13 changes: 13 additions & 0 deletions .cursor/rules/path-alias-typescript.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
description: Path alias @/ vs relative imports in apps/web TypeScript
globs:
- apps/web/**/*.ts
- apps/web/**/*.tsx
alwaysApply: false
---

# Path imports (`apps/web`)

- **`@/…`** — Use for anything that would otherwise need **`../`** (imports from a parent directory). Configured in `tsconfig.app.json` and Vite.
- **`./…`** — **Allowed** for same-folder / colocated modules (e.g. `./Page.css` next to a page, `./App` from a test in the same directory). Do not require rewriting these to `@/`.
- **`../…`** — Disallowed by Biome `style/noRestrictedImports` (`../**`); use `@/` instead.
7 changes: 4 additions & 3 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ Tasks and subtasks for building the bread-recipes app (SolidJS + Python REST + O
- [x] **4.1** Scaffold SolidJS + Vite + TypeScript in `apps/web`; **Biome** (lint + format + import organise); Vitest configured; exact dependency versions only; `README.md` for the app.
- [x] **4.2** Generate or synchronise typed API usage from the OpenAPI spec (client/types) so API calls stay strictly typed.
- [x] **4.3** App shell: router, layout, and global styles (clean, minimalist, bread-appropriate palette, responsive).
- [ ] **4.4** Home page: fetch and list bread recipes with overview + thumbnail; navigate to detail on click.
- [ ] **4.5** Recipe page: full recipe content and larger image; deep-linkable route (e.g. by id).
- [ ] **4.6** MSW for tests; knip configured; Vitest coverage at 100% with a CI gate.
- [x] **4.4** Home page: fetch and list bread recipes with overview + thumbnail; navigate to detail on click.
- [ ] **4.5** Component library: evaluate options for SolidJS (e.g. **shadcn-solid** with Tailwind and Kobalte primitives vs smaller headless stacks); record the decision; add the chosen tooling and migrate or adopt components on at least one real screen so the pattern is established.
- [x] **4.6** Recipe page: full recipe content and larger image; deep-linkable route (e.g. by id).
- [ ] **4.7** MSW for tests; knip configured; Vitest coverage at 100% with a CI gate.

## 5. Contract testing (Pact)

Expand Down
12 changes: 12 additions & 0 deletions apps/api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse

Expand Down Expand Up @@ -34,6 +35,17 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)

app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)


@app.exception_handler(RecipeNotFoundError)
def recipe_not_found_handler(_request: Request, _exc: RecipeNotFoundError) -> JSONResponse:
Expand Down
13 changes: 12 additions & 1 deletion apps/web/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,18 @@
"useExplicitType": "error"
},
"style": {
"noDefaultExport": "error"
"noDefaultExport": "error",
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": ["../**"],
"message": "Use the @/ path alias (see tsconfig paths) instead of ../ — same-folder imports like ./file are fine."
}
]
}
}
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "biome check .",
"lint": "biome check . && node scripts/assert-no-function-declarations.mjs",
"lint:fix": "biome check --write .",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
58 changes: 58 additions & 0 deletions apps/web/scripts/assert-no-function-declarations.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* Biome's useArrowFunction does not flag top-level `function` declarations (see https://github.com/biomejs/biome/discussions/7108).
* This check enforces `const name = (...) =>` for named functions in hand-written TS/TSX.
* Generated by Cursor.
*/
import fs from 'node:fs';
import path from 'node:path';

const rootDir = path.join(import.meta.dirname, '..');
/** Top-level / named `function` declarations (not `typeof x === 'function'`). */
const declarationLine =
/^\s*(?:export\s+(?:default\s+)?(?:async\s+)?|async\s+)?function\s*\*?\s*(?:[$\w]|\()/;

function walkTsFiles(dir, out) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) {
if (ent.name === 'generated') {
continue;
}
walkTsFiles(p, out);
} else if (/\.(ts|tsx)$/.test(ent.name)) {
out.push(p);
}
}
}

const files = [];
walkTsFiles(path.join(rootDir, 'src'), files);

for (const base of ['vite.config.ts', 'openapi-ts.config.ts']) {
const p = path.join(rootDir, base);
if (fs.existsSync(p)) {
files.push(p);
}
}

let failed = false;
for (const file of files) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
const trimmed = line.trimStart();
if (trimmed.startsWith('//') || trimmed.startsWith('*')) {
return;
}
if (declarationLine.test(line)) {
console.error(
`${path.relative(rootDir, file)}:${i + 1}: use \`const name = ...\` instead of a \`function\` declaration`,
);
failed = true;
}
});
}

if (failed) {
process.exit(1);
}
11 changes: 10 additions & 1 deletion apps/web/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { render, screen } from '@solidjs/testing-library';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { App } from './App';

vi.mock('@/api', () => ({
listRecipes: vi.fn().mockResolvedValue({
data: [],
error: undefined,
request: new Request('http://127.0.0.1:8000/recipes'),
response: new Response(),
}),
}));

describe('App', () => {
it('renders the main heading', () => {
render(() => <App />);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
createConfig,
} from './generated/client';

function resolveBaseUrl(): string {
const resolveBaseUrl = (): string => {
const fromEnv = import.meta.env.VITE_API_BASE_URL;
if (fromEnv !== undefined && fromEnv !== '') {
return fromEnv;
Expand All @@ -14,7 +14,7 @@ function resolveBaseUrl(): string {
return 'http://127.0.0.1:8000';
}
return '';
}
};

/** Shared fetch client for generated SDK calls (base URL from `VITE_API_BASE_URL`, or local API in dev). */
export const apiClient: Client = createClient(
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/components/RecipeCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { A } from '@solidjs/router';
import type { JSX } from 'solid-js';
import type { RecipeSummary } from '@/api';

export const RecipeCard = ({
id,
imageUrl,
title,
summary,
}: RecipeSummary): JSX.Element => (
<li class="recipe-grid-item">
<A
href={`/recipes/${id}`}
class="recipe-card"
aria-labelledby={`recipe-title-${id}`}
>
<div class="recipe-card-media">
<img
class="recipe-card-image"
src={imageUrl}
alt=""
width={320}
height={240}
loading="lazy"
decoding="async"
/>
</div>
<div class="recipe-card-body">
<h3 class="recipe-card-title" id={`recipe-title-${id}`}>
{title}
</h3>
<p class="recipe-card-summary">{summary}</p>
</div>
</A>
</li>
);
28 changes: 28 additions & 0 deletions apps/web/src/components/recipe-detail/RecipeDetailBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { JSX } from 'solid-js';
import type { RecipeDetail } from '@/api';
import { RecipeDetailHeader } from './RecipeDetailHeader';
import { RecipeDetailHero } from './RecipeDetailHero';
import { RecipeDetailIngredients } from './RecipeDetailIngredients';
import { RecipeDetailSteps } from './RecipeDetailSteps';

export const RecipeDetailBody = ({
title,
summary,
prepTimeMinutes,
bakeTimeMinutes,
imageUrlLarge,
ingredients,
steps,
}: RecipeDetail): JSX.Element => (
<>
<RecipeDetailHeader
title={title}
summary={summary}
prepTimeMinutes={prepTimeMinutes}
bakeTimeMinutes={bakeTimeMinutes}
/>
<RecipeDetailHero imageUrlLarge={imageUrlLarge} />
<RecipeDetailIngredients ingredients={ingredients} />
<RecipeDetailSteps steps={steps} />
</>
);
32 changes: 32 additions & 0 deletions apps/web/src/components/recipe-detail/RecipeDetailHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { JSX } from 'solid-js';
import { Show } from 'solid-js';
import type { RecipeDetail } from '@/api';

type RecipeDetailHeaderProps = Pick<
RecipeDetail,
'title' | 'summary' | 'prepTimeMinutes' | 'bakeTimeMinutes'
>;

export const RecipeDetailHeader = ({
title,
summary,
prepTimeMinutes,
bakeTimeMinutes,
}: RecipeDetailHeaderProps): JSX.Element => (
<header class="recipe-detail-header">
<h2 id="recipe-title" class="page-title recipe-detail-title">
{title}
</h2>
<p class="lede recipe-detail-summary">{summary}</p>
<Show when={prepTimeMinutes !== undefined || bakeTimeMinutes !== undefined}>
<p class="recipe-detail-times">
<Show when={prepTimeMinutes !== undefined}>
<span class="recipe-detail-time">Prep: {prepTimeMinutes} min</span>
</Show>
<Show when={bakeTimeMinutes !== undefined}>
<span class="recipe-detail-time">Bake: {bakeTimeMinutes} min</span>
</Show>
</p>
</Show>
</header>
);
20 changes: 20 additions & 0 deletions apps/web/src/components/recipe-detail/RecipeDetailHero.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { JSX } from 'solid-js';
import type { RecipeDetail } from '@/api';

type RecipeDetailHeroProps = Pick<RecipeDetail, 'imageUrlLarge'>;

export const RecipeDetailHero = ({
imageUrlLarge,
}: RecipeDetailHeroProps): JSX.Element => (
<div class="recipe-detail-hero">
<img
class="recipe-detail-image"
src={imageUrlLarge}
alt=""
width={720}
height={480}
loading="eager"
decoding="async"
/>
</div>
);
23 changes: 23 additions & 0 deletions apps/web/src/components/recipe-detail/RecipeDetailIngredients.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { JSX } from 'solid-js';
import { For } from 'solid-js';
import type { RecipeDetail } from '@/api';

type RecipeDetailIngredientsProps = Pick<RecipeDetail, 'ingredients'>;

export const RecipeDetailIngredients = ({
ingredients,
}: RecipeDetailIngredientsProps): JSX.Element => (
<section
class="recipe-detail-section"
aria-labelledby="recipe-ingredients-heading"
>
<h3 id="recipe-ingredients-heading" class="recipe-detail-h3">
Ingredients
</h3>
<ul class="recipe-detail-list recipe-detail-list-ingredients">
<For each={ingredients}>
{(line: string): JSX.Element => <li>{line}</li>}
</For>
</ul>
</section>
);
18 changes: 18 additions & 0 deletions apps/web/src/components/recipe-detail/RecipeDetailSteps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { JSX } from 'solid-js';
import { For } from 'solid-js';
import type { RecipeDetail } from '@/api';

type RecipeDetailStepsProps = Pick<RecipeDetail, 'steps'>;

export const RecipeDetailSteps = ({
steps,
}: RecipeDetailStepsProps): JSX.Element => (
<section class="recipe-detail-section" aria-labelledby="recipe-steps-heading">
<h3 id="recipe-steps-heading" class="recipe-detail-h3">
Steps
</h3>
<ol class="recipe-detail-list recipe-detail-list-steps">
<For each={steps}>{(step: string): JSX.Element => <li>{step}</li>}</For>
</ol>
</section>
);
2 changes: 1 addition & 1 deletion apps/web/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { JSX } from 'solid-js';
import { render } from 'solid-js/web';
import './index.css';
import { App } from './App.tsx';
import { App } from './App';

const root: HTMLElement | null = document.getElementById('root');
if (!root) {
Expand Down
Loading
Loading