From 1608eb4d7014262848569b82e53d7896f35f6f55 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sun, 12 Apr 2026 16:58:23 +0200 Subject: [PATCH] Initial microservice-importer setup with NestJS backend and Next.js frontend --- .gitignore | 14 ++ README.md | 45 ++++ backend/Dockerfile | 27 +++ backend/nest-cli.json | 4 + backend/package.json | 26 +++ backend/src/app.module.ts | 11 + .../common/filters/global-exception.filter.ts | 66 ++++++ backend/src/common/utils/normalize-name.ts | 9 + backend/src/main.ts | 26 +++ .../src/quick-import/parsers/base.parser.ts | 158 +++++++++++++ .../quick-import/parsers/generic.parser.ts | 148 ++++++++++++ .../src/quick-import/parsers/ica.parser.ts | 138 +++++++++++ .../quick-import/quick-import.controller.ts | 14 ++ .../src/quick-import/quick-import.module.ts | 9 + .../src/quick-import/quick-import.service.ts | 204 ++++++++++++++++ backend/src/recipes/dto/parse-markdown.dto.ts | 7 + backend/src/recipes/recipes.controller.ts | 13 ++ backend/src/recipes/recipes.module.ts | 9 + backend/src/recipes/recipes.service.ts | 220 ++++++++++++++++++ backend/tsconfig.json | 21 ++ compose.yml | 34 +++ frontend/Dockerfile | 22 ++ frontend/app/Navigation.tsx | 49 ++++ .../app/api/parse-markdown-proxy/route.ts | 21 ++ frontend/app/import/page.tsx | 168 +++++++++++++ frontend/app/layout.tsx | 20 ++ frontend/app/page.tsx | 16 ++ frontend/lib/api.ts | 27 +++ frontend/lib/error-handler.ts | 45 ++++ frontend/next.config.js | 6 + frontend/package.json | 20 ++ frontend/tsconfig.json | 22 ++ 32 files changed, 1619 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/nest-cli.json create mode 100644 backend/package.json create mode 100644 backend/src/app.module.ts create mode 100644 backend/src/common/filters/global-exception.filter.ts create mode 100644 backend/src/common/utils/normalize-name.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/quick-import/parsers/base.parser.ts create mode 100644 backend/src/quick-import/parsers/generic.parser.ts create mode 100644 backend/src/quick-import/parsers/ica.parser.ts create mode 100644 backend/src/quick-import/quick-import.controller.ts create mode 100644 backend/src/quick-import/quick-import.module.ts create mode 100644 backend/src/quick-import/quick-import.service.ts create mode 100644 backend/src/recipes/dto/parse-markdown.dto.ts create mode 100644 backend/src/recipes/recipes.controller.ts create mode 100644 backend/src/recipes/recipes.module.ts create mode 100644 backend/src/recipes/recipes.service.ts create mode 100644 backend/tsconfig.json create mode 100644 compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/app/Navigation.tsx create mode 100644 frontend/app/api/parse-markdown-proxy/route.ts create mode 100644 frontend/app/import/page.tsx create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/lib/api.ts create mode 100644 frontend/lib/error-handler.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package.json create mode 100644 frontend/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd1ac1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +dist/ +.next/ +.env.local +.env*.local +*.log +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +*~ +build/ +coverage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..044cdf6 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Microservice Importer + +Recipe import microservice för snabb-import av recept från webben. + +## Features + +- **Quick Import från URL**: Importera recept direkt från ICA.se eller andra webbsidor +- **Automatisk parsing**: Extraherar receptnamn, beskrivning, ingredienser och instruktioner +- **Markdown-format**: Returnerar recept i standardiserad Markdown-format +- **Flersidig parsning**: Stöd för JSON-LD structured data och HTML-parsing + +## Arkitektur + +### Backend (NestJS) +- `src/quick-import/` — URL-scraping och parsing +- `src/recipes/` — Markdown-parsing service +- Parsers för site-specifik extraction (ICA, Generic fallback) + +### Frontend (Next.js) +- `app/import/page.tsx` — Import UI +- `app/api/parse-markdown-proxy/` — API proxy till backend + +## Setup + +```bash +# Installera beroenden +cd backend && npm install +cd ../frontend && npm install + +# Kör i development-läge +cd backend && npm run start:dev +cd ../frontend && npm run dev +``` + +Backend: http://localhost:3001 +Frontend: http://localhost:3000 + +## Docker + +```bash +docker-compose up -d +``` + +frontend: http://localhost:3000 +backend: http://localhost:3001 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1d34461 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +# Byggas från projektets rot: docker build -f backend/Dockerfile -t recipe-importer-api:local . + +# Stage 1: Bygg applikationen +FROM node:22-alpine AS builder +WORKDIR /app + +# Kopiera backend-filer +COPY backend/package.json ./ +COPY backend/src ./src +COPY backend/tsconfig.json ./ +COPY backend/nest-cli.json ./ + +# Köra npm install +RUN npm install +RUN npm run build + +# Stage 2: Kör applikationen +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist + +EXPOSE 3001 +CMD ["node", "dist/main"] diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..f591957 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,4 @@ +{ + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..3809871 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,26 @@ +{ + "name": "recipe-importer-api", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "start": "node dist/main", + "start:dev": "nest start --watch" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/core": "^10.3.0", + "@nestjs/platform-express": "^10.3.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@nestjs/schematics": "^10.1.1", + "@types/express": "^4.17.21", + "@types/node": "^22.15.29", + "typescript": "^5.4.5" + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..6cc60d5 --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { QuickImportModule } from './quick-import/quick-import.module'; +import { RecipesModule } from './recipes/recipes.module'; + +@Module({ + imports: [ + QuickImportModule, + RecipesModule, + ], +}) +export class AppModule {} diff --git a/backend/src/common/filters/global-exception.filter.ts b/backend/src/common/filters/global-exception.filter.ts new file mode 100644 index 0000000..b665fb3 --- /dev/null +++ b/backend/src/common/filters/global-exception.filter.ts @@ -0,0 +1,66 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Response } from 'express'; + +interface ErrorResponse { + statusCode: number; + message: string; + timestamp: string; + path?: string; +} + +/** + * Global exception filter som formaterar alla errors konsistent + * Returnerar tydliga, svenska felmeddelanden utan att läcka känslig info + */ +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); + + catch(exception: any, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const path = request.url; + + let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Ett internt serverfel inträffade.'; + + // Hantera HTTP-exceptions (t.ex. NotFoundException) + if (exception instanceof HttpException) { + statusCode = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + if (typeof exceptionResponse === 'object' && 'message' in exceptionResponse) { + message = (exceptionResponse as any).message || message; + } else if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } + } else if (exception instanceof Error) { + // Hantera vanliga Error-instanser + message = exception.message || message; + statusCode = HttpStatus.BAD_REQUEST; + + // Log interna fel för debugging + this.logger.error(`Error: ${exception.message}`, exception.stack); + } else { + // Okänd typ av exception + this.logger.error('Unknown exception type', exception); + } + + const errorResponse: ErrorResponse = { + statusCode, + message, + timestamp: new Date().toISOString(), + path, + }; + + response.status(statusCode).json(errorResponse); + } +} diff --git a/backend/src/common/utils/normalize-name.ts b/backend/src/common/utils/normalize-name.ts new file mode 100644 index 0000000..cc5776b --- /dev/null +++ b/backend/src/common/utils/normalize-name.ts @@ -0,0 +1,9 @@ +export function normalizeName(value: string): string { + return value + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ''); +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..3dc2b2f --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,26 @@ +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.setGlobalPrefix('api'); + + // Registrera global exception filter + app.useGlobalFilters(new GlobalExceptionFilter()); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const port = process.env.PORT || 3001; + await app.listen(port); + console.log(`🚀 Microservice importer running on port ${port}`); +} +bootstrap(); diff --git a/backend/src/quick-import/parsers/base.parser.ts b/backend/src/quick-import/parsers/base.parser.ts new file mode 100644 index 0000000..b698495 --- /dev/null +++ b/backend/src/quick-import/parsers/base.parser.ts @@ -0,0 +1,158 @@ +/** + * Bas-parser för receptsidor + * Alla site-specifika parsers bör extenda denna + */ +export interface ParsedRecipe { + name: string; + description?: string; + ingredients: Array<{ + quantity: number; + unit: string; + name: string; + note?: string; + }>; + instructions?: string; +} + +export abstract class RecipeParser { + /** + * Kontrollera om denna parser kan hantera denna URL + */ + abstract canHandle(url: string): boolean; + + /** + * Parsa HTML och extrahera receptdata + */ + abstract parse(html: string): ParsedRecipe; + + /** + * Hjälpfunktion: parsa ingrediens-rad + * Hanterar format som: + * - "3 ägg" + * - "150 g lax" + * - "1/2 citron" + * - "1 msk senap" + * - "salt och peppar" + * - "1 förp handskalade räkor i lake (à 570 g)" + */ + protected parseIngredientLine(line: string): { + quantity: number; + unit: string; + name: string; + note?: string; + } | null { + let cleaned = line.replace(/<[^>]+>/g, '').trim(); + if (!cleaned) return null; + + // Kända enheter + const knownUnits = [ + 'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl', + 'st', 'tsk', 'msk', 'krm', 'matsked', 'tesked', + 'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta', + ]; + + // Extrahera parentetisk info + let parentheticalText = ''; + const parentheteMatch = cleaned.match(/\s*\(([^)]*)\)/); + if (parentheteMatch) { + parentheticalText = parentheteMatch[1].trim(); + cleaned = cleaned.replace(/\s*\([^)]*\)/, '').trim(); + } + + // Hantera bråkdelar: "1/2" eller "1 1/2" eller "1 1 / 2" + // Regex: (optional whole)? numerator / denominator + const fractionMatch = cleaned.match(/^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)/); + let quantity = 0; + let remainingText = cleaned; + + if (fractionMatch) { + if (fractionMatch[1]) { + // Heltal + bråk: "1 1/2" + const whole = parseFloat(fractionMatch[1]); + const numerator = parseFloat(fractionMatch[2]); + const denominator = parseFloat(fractionMatch[3]); + quantity = whole + (numerator / denominator); + } else { + // Bara bråk: "1/2" + const numerator = parseFloat(fractionMatch[2]); + const denominator = parseFloat(fractionMatch[3]); + quantity = numerator / denominator; + } + remainingText = cleaned.substring(fractionMatch[0].length).trim(); + } else { + const numberMatch = remainingText.match(/^([\d.,]+)/); + if (numberMatch) { + quantity = parseFloat(numberMatch[1].replace(',', '.')); + remainingText = remainingText.substring(numberMatch[0].length).trim(); + } + } + + // Extrahera potentiell enhet + let potentialUnit = ''; + let productName = remainingText; + + if (remainingText) { + const unitMatch = remainingText.match(/^([a-zåäö]+)\b/i); + if (unitMatch) { + const candidateUnit = unitMatch[1].toLowerCase(); + if (knownUnits.includes(candidateUnit)) { + potentialUnit = candidateUnit; + productName = remainingText.substring(candidateUnit.length).trim(); + } + } + } + + // Analysera parenthetical text för måttenhet + let parenthHasUnit = false; + if (parentheticalText) { + for (const unit of knownUnits) { + if (parentheticalText.toLowerCase().includes(unit)) { + parenthHasUnit = true; + break; + } + } + } + + let note: string | undefined = undefined; + + // Om vi hade quantity i huvuddelen och parenthetical innehåller unit + // → spara parenthetical som note + if (quantity > 0 && parenthHasUnit) { + note = parentheticalText; + } + + // Om ingen mängd i huvuddelen men parenthetical hade både mängd och unit + // → parse parenthetical som quantity + unit + if (quantity === 0 && parentheticalText) { + const parenthMatch = parentheticalText.match(/^[\D]*?([\d.,]+)?\s*([a-zåäö]*)?\s*(.*)$/i); + if (parenthMatch) { + let pQuantity = parenthMatch[1] ? parseFloat(parenthMatch[1].replace(',', '.')) : 0; + let pUnit = parenthMatch[2]?.toLowerCase() || ''; + let pRest = parenthMatch[3]?.trim() || ''; + + if (knownUnits.includes(pUnit) && pQuantity > 0) { + quantity = pQuantity; + potentialUnit = pUnit; + note = parentheticalText; + } + } + } + + // Om ingen mängd och enhet, bara returna produktnamnet + if (quantity === 0) { + return { + quantity: 0, + unit: '', + name: cleaned, + note: parentheticalText || undefined, + }; + } + + return { + quantity, + unit: potentialUnit, + name: productName, + note: note, + }; + } +} diff --git a/backend/src/quick-import/parsers/generic.parser.ts b/backend/src/quick-import/parsers/generic.parser.ts new file mode 100644 index 0000000..04d86b7 --- /dev/null +++ b/backend/src/quick-import/parsers/generic.parser.ts @@ -0,0 +1,148 @@ +import { RecipeParser, ParsedRecipe } from './base.parser'; + +/** + * Generisk parser för okända receptsidor + * Försöker JSON-LD först, sedan vanlig HTML-parsing + * Denna är mer permissiv än site-specifika parsers + */ +export class GenericRecipeParser extends RecipeParser { + canHandle(url: string): boolean { + // Denna parser hanterar alltid (är fallback) + return true; + } + + parse(html: string): ParsedRecipe { + console.log('[GenericParser] Parsing recipe from unknown site...'); + + // Försöka extrahera JSON-LD recipe data + const jsonLdMatch = html.match( + /]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i + ); + + if (jsonLdMatch) { + try { + const jsonData = JSON.parse(jsonLdMatch[1]); + const recipe = + jsonData['@type'] === 'Recipe' + ? jsonData + : jsonData['@graph']?.find((item: any) => item['@type'] === 'Recipe'); + + if (recipe) { + console.log('[GenericParser] ✓ JSON-LD data found'); + return this.extractFromJsonLd(recipe); + } + } catch (err) { + console.log('[GenericParser] JSON-LD parsing failed'); + } + } + + console.log('[GenericParser] No JSON-LD found, using HTML parsing'); + return this.parseFromHtml(html); + } + + private extractFromJsonLd(recipe: any): ParsedRecipe { + const name = recipe.name || ''; + const description = recipe.description || ''; + + const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = []; + if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) { + for (const ing of recipe.recipeIngredient) { + const parsed = this.parseIngredientLine(ing); + if (parsed) { + ingredients.push(parsed); + } + } + } + + let instructions = ''; + if (recipe.recipeInstructions) { + if (typeof recipe.recipeInstructions === 'string') { + instructions = recipe.recipeInstructions; + } else if (Array.isArray(recipe.recipeInstructions)) { + instructions = recipe.recipeInstructions + .map((step: any) => { + if (typeof step === 'string') return step; + if (step.text) return step.text; + return ''; + }) + .filter((s: string) => s) + .join('\n\n'); + } + } + + return { + name, + description, + ingredients, + instructions, + }; + } + + private parseFromHtml(html: string): ParsedRecipe { + // Försöka hitta titel + let name = ''; + + // Prova olika selector-mönster + let titleMatch = + html.match(/]*>([^<]+)<\/h1>/i) || + html.match(/([^<]+)<\/title>/i); + + if (titleMatch) { + name = titleMatch[1].trim(); + } + + // Försöka extrahera beskrivning från meta-taggar + let description = ''; + const descMatch = html.match( + / = []; + + // Testa olika ingredient-selectors + const ingredientPatterns = [ + /]*>(.*?)<\/li>/gi, + /]*class="ingredient"[^>]*>(.*?)<\/div>/gi, + /]*class="ingredient"[^>]*>(.*?)<\/p>/gi, + ]; + + for (const pattern of ingredientPatterns) { + let match; + while ((match = pattern.exec(html)) !== null) { + const parsed = this.parseIngredientLine(match[1]); + if (parsed && parsed.name.length > 2) { + // Undvik mycket korta ingredienser (troligen brus) + ingredients.push(parsed); + } + } + if (ingredients.length > 0) break; // Om vi hittat några, använd dessa + } + + // Försöka hitta instruktioner + let instructions = ''; + const instructionsPatterns = [ + /<(?:div|section)[^>]*class="[^"]*(?:instruction|method|step)[^"]*"[^>]*>(.*?)<\/(?:div|section)>/is, + /]*>(.*?)<\/ol>/i, + ]; + + for (const pattern of instructionsPatterns) { + const match = html.match(pattern); + if (match) { + instructions = match[1].replace(/<[^>]+>/g, '').trim(); + if (instructions.length > 10) break; + } + } + + return { + name, + description, + ingredients, + instructions, + }; + } +} diff --git a/backend/src/quick-import/parsers/ica.parser.ts b/backend/src/quick-import/parsers/ica.parser.ts new file mode 100644 index 0000000..361a710 --- /dev/null +++ b/backend/src/quick-import/parsers/ica.parser.ts @@ -0,0 +1,138 @@ +import { RecipeParser, ParsedRecipe } from './base.parser'; + +/** + * Parser för ica.se receptsidor + * Använder JSON-LD structured data som primär källa + */ +export class IcaRecipeParser extends RecipeParser { + canHandle(url: string): boolean { + return /ica\.se\/recept/i.test(url); + } + + parse(html: string): ParsedRecipe { + console.log('[IcaParser] Parsing ICA recipe...'); + + // Försöka extrahera JSON-LD recipe data (ICA använder detta) + const jsonLdMatch = html.match( + /]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i + ); + + if (jsonLdMatch) { + try { + const jsonData = JSON.parse(jsonLdMatch[1]); + + // Hitta recipe-objektet + const recipe = + jsonData['@type'] === 'Recipe' + ? jsonData + : jsonData['@graph']?.find((item: any) => item['@type'] === 'Recipe'); + + if (recipe) { + console.log('[IcaParser] ✓ JSON-LD recipe found'); + return this.extractFromJsonLd(recipe); + } + } catch (err) { + console.log('[IcaParser] JSON-LD parsing failed:', err); + } + } + + // Fallback: HTML parsing (sällan nödvändigt för ICA) + console.log('[IcaParser] Falling back to HTML parsing'); + return this.parseFromHtml(html); + } + + private extractFromJsonLd(recipe: any): ParsedRecipe { + // Extrahera titel + const name = recipe.name || ''; + + // Extrahera beskrivning + const description = recipe.description || ''; + + // Extrahera ingredienser + const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = []; + if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) { + for (const ing of recipe.recipeIngredient) { + const parsed = this.parseIngredientLine(ing); + if (parsed) { + ingredients.push(parsed); + } + } + } + + // Extrahera instruktioner + let instructions = ''; + if (recipe.recipeInstructions) { + if (typeof recipe.recipeInstructions === 'string') { + instructions = recipe.recipeInstructions; + } else if (Array.isArray(recipe.recipeInstructions)) { + instructions = recipe.recipeInstructions + .map((step: any) => { + if (typeof step === 'string') return step; + if (step.text) return step.text; + return ''; + }) + .filter((s: string) => s) + .join('\n\n'); + } + } + + return { + name, + description, + ingredients, + instructions, + }; + } + + private parseFromHtml(html: string): ParsedRecipe { + let name = ''; + const titleMatch = html.match(/]*>([^<]+)<\/h1>/i); + if (titleMatch) { + name = titleMatch[1].trim(); + } + + if (!name) { + const ogTitleMatch = html.match( + / = []; + const ingredientRegex = + /]*class="[^"]*ingredient[^"]*"[^>]*>([^<]+)<\/li>/gi; + let match; + while ((match = ingredientRegex.exec(html)) !== null) { + const parsed = this.parseIngredientLine(match[1]); + if (parsed) { + ingredients.push(parsed); + } + } + + let instructions = ''; + const instructionsMatch = html.match( + /<(?:div|section)[^>]*class="[^"]*(?:instruction|howto)[^"]*"[^>]*>([^<]*)<\/(?:div|section)>/is + ); + if (instructionsMatch) { + instructions = instructionsMatch[1].replace(/<[^>]+>/g, '').trim(); + } + + return { + name, + description, + ingredients, + instructions, + }; + } +} diff --git a/backend/src/quick-import/quick-import.controller.ts b/backend/src/quick-import/quick-import.controller.ts new file mode 100644 index 0000000..35fa055 --- /dev/null +++ b/backend/src/quick-import/quick-import.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Post, Body } from '@nestjs/common'; +import { QuickImportService, QuickImportResult } from './quick-import.service'; + +@Controller('quick-import') +export class QuickImportController { + constructor(private readonly quickImportService: QuickImportService) {} + + @Post() + async importFromInput( + @Body() body: { input: string } + ): Promise { + return this.quickImportService.importFromInput(body.input); + } +} diff --git a/backend/src/quick-import/quick-import.module.ts b/backend/src/quick-import/quick-import.module.ts new file mode 100644 index 0000000..0dc61f8 --- /dev/null +++ b/backend/src/quick-import/quick-import.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { QuickImportController } from './quick-import.controller'; +import { QuickImportService } from './quick-import.service'; + +@Module({ + controllers: [QuickImportController], + providers: [QuickImportService], +}) +export class QuickImportModule {} diff --git a/backend/src/quick-import/quick-import.service.ts b/backend/src/quick-import/quick-import.service.ts new file mode 100644 index 0000000..7f876dd --- /dev/null +++ b/backend/src/quick-import/quick-import.service.ts @@ -0,0 +1,204 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { IcaRecipeParser } from './parsers/ica.parser'; +import { GenericRecipeParser } from './parsers/generic.parser'; +import { RecipeParser } from './parsers/base.parser'; + +export interface QuickImportResult { + markdown: string; + source: 'ica' | 'pdf' | 'other'; +} + +@Injectable() +export class QuickImportService { + /** + * Detekterar typ av input (URL eller filsökväg) och importerar från lämplig källa + */ + async importFromInput(input: string): Promise { + input = input.trim(); + console.log('[QuickImport] Mottog input:', input); + + if (!input) { + throw new BadRequestException('Du måste ange en URL eller filsökväg'); + } + + // Detektera typ + const isUrl = this.isUrl(input); + const isPdf = this.isPdfPath(input); + + console.log('[QuickImport] isUrl:', isUrl, 'isPdf:', isPdf); + + if (isUrl) { + console.log('[QuickImport] Detekterade URL, försöker scrapa...'); + return this.scrapeRecipeFromUrl(input); + } else if (isPdf) { + console.log('[QuickImport] Detekterade PDF-fil'); + throw new BadRequestException( + 'PDF-import under utveckling. Försök med en URL från ICA.se eller annat receptsida.' + ); + } else { + console.log('[QuickImport] Input är inte URL eller PDF'); + throw new BadRequestException( + 'Ogültig input. Ange en gyltig URL (t.ex. ica.se/recept/...) eller filsökväg' + ); + } + } + + /** + * Kontrollerar om input är en URL + */ + private isUrl(input: string): boolean { + try { + new URL(input); + return true; + } catch { + return false; + } + } + + /** + * Kontrollerar om input är en PDF-filsökväg + */ + private isPdfPath(input: string): boolean { + const normalized = input.toLowerCase(); + return normalized.endsWith('.pdf'); + } + + /** + * Skrapar recept från en URL + * + * Använder site-specifika parsers om tillgängliga, + * annars fallback till generisk parser. + * + * @param url URL till receptsidan + * @returns Markdown-format + */ + private async scrapeRecipeFromUrl(url: string): Promise { + try { + console.log('[QuickImport] Hämtar HTML från:', url); + + // Hämta HTML från URL + const response = await fetch(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }); + + console.log('[QuickImport] HTTP status:', response.status); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const html = await response.text(); + console.log('[QuickImport] HTML längd:', html.length, 'tecken'); + + // Välj lämplig parser + const parsers: RecipeParser[] = [ + new IcaRecipeParser(), + new GenericRecipeParser(), + ]; + + let recipe = null; + for (const parser of parsers) { + if (parser.canHandle(url)) { + console.log('[QuickImport] Använder parser:', parser.constructor.name); + recipe = parser.parse(html); + break; + } + } + + if (!recipe) { + throw new Error('Ingen parserutrustning tillgänglig'); + } + + console.log('[QuickImport] Parsad recept:', { + name: recipe.name, + ingredienser: recipe.ingredients.length, + }); + + if (!recipe.name) { + throw new Error('Kunde inte hitta receptnamn på sidan. Försök med en annan länk.'); + } + + // Konvertera till Markdown-format + const markdown = this.recipeToMarkdown(recipe, url); + console.log('[QuickImport] Markdown genererad, längd:', markdown.length); + + // Detektera källa från URL + let source: 'ica' | 'pdf' | 'other' = 'other'; + if (/ica\.se/i.test(url)) { + source = 'ica'; + } + + return { + markdown, + source, + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Okänt fel vid scraping'; + console.error('[QuickImport] ERROR:', message); + throw new BadRequestException( + `Kunde inte hämta recept: ${message}. Kontrollera att länken är korrekt och försök igen.` + ); + } + } + + /** + * Konvertera receptobjekt till Markdown-format + */ + private recipeToMarkdown( + recipe: { + name: string; + description?: string; + ingredients: Array<{ + quantity: number; + unit: string; + name: string; + note?: string; + }>; + instructions?: string; + }, + sourceUrl?: string, + ): string { + const lines: string[] = []; + + // Titel + lines.push(`# ${recipe.name}`); + lines.push(''); + + // Beskrivning + if (recipe.description) { + lines.push(recipe.description); + lines.push(''); + } + + // Ingredienser + if (recipe.ingredients.length > 0) { + lines.push('## Ingredienser'); + for (const ing of recipe.ingredients) { + const quantity = ing.quantity > 0 ? `${ing.quantity} ` : ''; + const unit = ing.unit ? `${ing.unit} ` : ''; + const note = ing.note ? ` (${ing.note})` : ''; + lines.push(`- ${quantity}${unit}${ing.name}${note}`); + } + lines.push(''); + } + + // Instruktioner + if (recipe.instructions) { + lines.push('## Tillvägagångssätt'); + lines.push(recipe.instructions); + lines.push(''); + } + + // Källa + if (sourceUrl) { + lines.push('---'); + lines.push(''); + lines.push(`Källa: [${sourceUrl}](${sourceUrl})`); + } + + return lines.join('\n'); + } +} diff --git a/backend/src/recipes/dto/parse-markdown.dto.ts b/backend/src/recipes/dto/parse-markdown.dto.ts new file mode 100644 index 0000000..aaa8024 --- /dev/null +++ b/backend/src/recipes/dto/parse-markdown.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class ParseMarkdownDto { + @IsString() + @MinLength(1) + markdown!: string; +} diff --git a/backend/src/recipes/recipes.controller.ts b/backend/src/recipes/recipes.controller.ts new file mode 100644 index 0000000..08c64b6 --- /dev/null +++ b/backend/src/recipes/recipes.controller.ts @@ -0,0 +1,13 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { RecipesService } from './recipes.service'; +import { ParseMarkdownDto } from './dto/parse-markdown.dto'; + +@Controller('recipes') +export class RecipesController { + constructor(private readonly recipesService: RecipesService) {} + + @Post('parse-markdown') + parseMarkdown(@Body() dto: ParseMarkdownDto) { + return this.recipesService.parseMarkdown(dto); + } +} diff --git a/backend/src/recipes/recipes.module.ts b/backend/src/recipes/recipes.module.ts new file mode 100644 index 0000000..d1fb44b --- /dev/null +++ b/backend/src/recipes/recipes.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RecipesController } from './recipes.controller'; +import { RecipesService } from './recipes.service'; + +@Module({ + controllers: [RecipesController], + providers: [RecipesService], +}) +export class RecipesModule {} diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts new file mode 100644 index 0000000..89d81a2 --- /dev/null +++ b/backend/src/recipes/recipes.service.ts @@ -0,0 +1,220 @@ +import { Injectable } from '@nestjs/common'; +import { ParseMarkdownDto } from './dto/parse-markdown.dto'; + +// ============================================================================ +// Local Type Definitions (previously from recipe-document-converter) +// ============================================================================ + +interface ParsedIngredient { + rawName: string; + quantity: number; + unit: string; + note: string | null; +} + +interface ParsedRecipe { + name: string; + description: string; + instructions: string; + ingredients: ParsedIngredient[]; +} + +// ============================================================================ +// Parser Functions +// ============================================================================ + +/** + * Parsar ett recept i Markdown-format och extraherar namn, beskrivning, + * instruktioner och ingredienser. + * + * Förväntat format: + * # Receptnamn + * Beskrivning (valfritt stycke efter titeln) + * + * ## Ingredienser + * - 400 g kycklingfilé + * - 2 dl grädde (eller crème fraiche) + * + * ## Instruktioner + * 1. Stek kycklingen … + */ +function parseRecipeMarkdown(markdown: string): ParsedRecipe { + const lines = markdown.split('\n'); + + let name = ''; + let description = ''; + let instructions = ''; + const ingredients: ParsedIngredient[] = []; + + let currentSection: 'none' | 'description' | 'ingredients' | 'instructions' = 'none'; + const descriptionLines: string[] = []; + const instructionLines: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // H1 — receptnamn + if (/^#\s+/.test(trimmed) && !trimmed.startsWith('##')) { + name = trimmed.replace(/^#\s+/, '').trim(); + currentSection = 'description'; + continue; + } + + // H2 — sektionsrubriker + if (/^##\s+/.test(trimmed)) { + const heading = trimmed.replace(/^##\s+/, '').trim().toLowerCase(); + if (/ingrediens/.test(heading)) { + currentSection = 'ingredients'; + } else if (/instruktion|tillagning|gör så här|steg|tillväg|metod/.test(heading)) { + currentSection = 'instructions'; + } else { + currentSection = 'none'; + } + continue; + } + + // Samla rader beroende på sektion + switch (currentSection) { + case 'description': + if (trimmed.length > 0) { + descriptionLines.push(trimmed); + } + break; + + case 'ingredients': + if (/^[-*]\s+/.test(trimmed)) { + const ingredientText = trimmed.replace(/^[-*]\s+/, ''); + ingredients.push(parseIngredientLine(ingredientText)); + } + break; + + case 'instructions': + if (trimmed.length > 0) { + instructionLines.push(trimmed); + } + break; + } + } + + description = descriptionLines.join('\n'); + instructions = instructionLines.join('\n'); + + return { name, description, instructions, ingredients }; +} + +/** + * Parsar en ingrediensrad, t.ex.: + * "400 g kycklingfilé" + * "2 dl grädde (eller crème fraiche)" + * "1 1/2 dl crème fraiche" + * "1 polka- eller gulbeta" + * "1 kruka basilika" + * "salt" + */ +function parseIngredientLine(text: string): ParsedIngredient { + const trimmed = text.trim(); + + // Kända enheter + const knownUnits = [ + 'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl', + 'st', 'tsk', 'msk', 'krm', 'matsled', 'tesled', + 'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta', + ]; + + // Extrahera eventuell parentes-not i slutet + let note: string | null = null; + let main = trimmed; + const parenMatch = trimmed.match(/\(([^)]+)\)\s*$/); + if (parenMatch) { + note = parenMatch[1].trim(); + main = trimmed.slice(0, parenMatch.index).trim(); + } + + // Försök matcha bråk först: "1 1/2 dl crème fraiche" eller "1/2 dl" + const fractionMatch = main.match(/^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)\s+(\S+)\s+(.*)$/); + if (fractionMatch) { + let quantity = 0; + if (fractionMatch[1]) { + quantity = parseFloat(fractionMatch[1]) + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]); + } else { + quantity = parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]); + } + const candidateUnit = fractionMatch[4].toLowerCase(); + if (knownUnits.includes(candidateUnit)) { + return { + quantity, + unit: candidateUnit, + rawName: fractionMatch[5].trim(), + note, + }; + } + } + + // Försök matcha "kvantitet enhet namn" — t.ex. "400 g kycklingfilé" eller "2.5 dl grädde" + const fullMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/); + if (fullMatch) { + const candidateUnit = fullMatch[2].toLowerCase(); + // Validera att det andra ordet är en känd enhet + if (knownUnits.includes(candidateUnit)) { + return { + quantity: parseNumber(fullMatch[1]), + unit: candidateUnit, + rawName: fullMatch[3].trim(), + note, + }; + } + // Om inte känd enhet, behandla som "kvantitet namn" utan enhet + return { + quantity: parseNumber(fullMatch[1]), + unit: 'st', + rawName: fullMatch[2] + ' ' + fullMatch[3], + note, + }; + } + + // Försök matcha "kvantitet namn" utan enhet — t.ex. "3 ägg" + const noUnitMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(.+)$/); + if (noUnitMatch) { + return { + quantity: parseNumber(noUnitMatch[1]), + unit: 'st', + rawName: noUnitMatch[2].trim(), + note, + }; + } + + // Bara ett namn, ingen kvantitet — t.ex. "salt" + return { + quantity: 0, + unit: '', + rawName: main, + note, + }; +} + +function parseNumber(s: string): number { + return parseFloat(s.replace(',', '.')); +} + +// ============================================================================ +// Service +// ============================================================================ + +@Injectable() +export class RecipesService { + parseMarkdown(dto: ParseMarkdownDto) { + const parsed = parseRecipeMarkdown(dto.markdown); + + return { + name: parsed.name, + description: parsed.description, + instructions: parsed.instructions, + ingredients: parsed.ingredients.map((ingredient: ParsedIngredient) => ({ + rawName: ingredient.rawName, + quantity: ingredient.quantity, + unit: ingredient.unit, + note: ingredient.note, + })), + }; + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..6226331 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + } +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..b304665 --- /dev/null +++ b/compose.yml @@ -0,0 +1,34 @@ +services: + importer-frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: importer-frontend + restart: unless-stopped + environment: + NEXT_PUBLIC_API_URL_INTERNAL: "http://importer-api:3001" + ports: + - "3000:3000" + networks: + - importer-network + depends_on: + - importer-api + + importer-api: + build: + context: . + dockerfile: backend/Dockerfile + image: recipe-importer-api:local + container_name: importer-api + restart: unless-stopped + environment: + NODE_ENV: "production" + PORT: "3001" + ports: + - "3001:3001" + networks: + - importer-network + +networks: + importer-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..15e0a3c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json ./ +RUN npm install + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV HOSTNAME=0.0.0.0 + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/app/Navigation.tsx b/frontend/app/Navigation.tsx new file mode 100644 index 0000000..b74751b --- /dev/null +++ b/frontend/app/Navigation.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link'; + +export default function Navigation() { + return ( + + ); +} diff --git a/frontend/app/api/parse-markdown-proxy/route.ts b/frontend/app/api/parse-markdown-proxy/route.ts new file mode 100644 index 0000000..dcb35d5 --- /dev/null +++ b/frontend/app/api/parse-markdown-proxy/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://localhost:3001'; + +export async function POST(request: NextRequest) { + const body = await request.text(); + + const res = await fetch(`${API_BASE}/api/recipes/parse-markdown`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + cache: 'no-store', + }); + + const text = await res.text(); + + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/frontend/app/import/page.tsx b/frontend/app/import/page.tsx new file mode 100644 index 0000000..5bb5f97 --- /dev/null +++ b/frontend/app/import/page.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useState } from 'react'; +import Navigation from '../Navigation'; +import { parseErrorResponse } from '../../lib/error-handler'; + +export default function ImportPage() { + const [quickImportUrl, setQuickImportUrl] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const handleQuickImport = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setResult(null); + setIsLoading(true); + + try { + const input = quickImportUrl.trim(); + if (!input) { + setError('Vänligen ange en URL eller filsökväg'); + setIsLoading(false); + return; + } + + // Försök importera från URL eller fil + const res = await fetch('/api/quick-import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input }), + }); + + if (!res.ok) { + const errorMessage = await parseErrorResponse(res); + setError(errorMessage || 'Importen misslyckades. Kontrollera att länken eller filsökvägen är korrekt.'); + setIsLoading(false); + return; + } + + const data = await res.json(); + if (data.markdown) { + setResult(data); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Något oväntad gick fel'; + setError(`Fel: ${message}`); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +

Importera recept

+ + {/* IMPORT-SEKTION */} +
+

+ ⚡ Snabbimport +

+

+ Klistra in en receptlänk från ICA eller annan webbsida: +

+ +
+
+ setQuickImportUrl(e.target.value)} + placeholder="https://www.ica.se/recept/..." + style={{ + padding: '0.75rem', + border: '1px solid #d97706', + borderRadius: '4px', + fontSize: '0.95rem', + boxSizing: 'border-box', + }} + disabled={isLoading} + /> + +
+ + {error && ( +

+ ⚠️ {error} +

+ )} +
+
+ + {/* RESULT */} + {result && ( +
+

✓ Recept importerat

+
+
+              {result.markdown}
+            
+
+

+ Källa: {result.source === 'ica' ? 'ICA' : 'Annan webbsida'} +

+
+ )} +
+ ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..5789c2b --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Microservice Importer', + description: 'Snabbimport av recept från webben', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..e6beb70 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,16 @@ +import Link from 'next/link'; +import Navigation from './Navigation'; + +export default function HomePage() { + return ( +
+ +

Microservice Importer

+
+ + Importera recept från URL + +
+
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..3de329d --- /dev/null +++ b/frontend/lib/api.ts @@ -0,0 +1,27 @@ +const API_BASE = + process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://localhost:3001'; + +export async function fetchJson(path: string, init?: RequestInit): Promise { + // Använd alltid relativ path i webbläsaren för att undvika mixed content + const url = typeof window === 'undefined' + ? (path.startsWith('http') ? path : `${API_BASE}${path}`) + : path; + + const res = await fetch(url, { + ...init, + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + ...(init?.headers || {}), + }, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`API ${res.status}: ${text}`); + } + + return res.json(); +} + +export { API_BASE }; diff --git a/frontend/lib/error-handler.ts b/frontend/lib/error-handler.ts new file mode 100644 index 0000000..7280bd9 --- /dev/null +++ b/frontend/lib/error-handler.ts @@ -0,0 +1,45 @@ +/** + * Utility för att parse HTTP-responses och extrahera tydliga felmeddelanden + */ +export async function parseErrorResponse(response: Response): Promise { + const status = response.status; + + try { + const data = await response.json(); + + // Om backend skickade ett felmeddelande + if (data.message) { + return data.message; + } + if (data.error) { + return data.error; + } + if (data.details) { + return data.details; + } + } catch { + // Inte JSON, försök text + try { + const text = await response.text(); + if (text && text.length < 200) { + return text; + } + } catch { + // Inget text-innehål + } + } + + // Fallback baserat på HTTP-status + const defaultMessages: Record = { + 400: 'Ogiltiga data. Kontrollera dina inmatningar.', + 401: 'Du är inte autentiserad. Logga in.', + 403: 'Du har inte behörighet till detta.', + 404: 'Resursen hittades inte.', + 409: 'Konflikten med befintlig data.', + 422: 'Valideringen misslyckades. Kontrollera dina inmatningar.', + 500: 'Serverfel. Försök igen senare.', + 503: 'Tjänsten är inte tillgänglig.', + }; + + return defaultMessages[status] || `Fel (${status}). Försök igen senare.`; +} diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..79a469d --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', +}; + +module.exports = nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1781a47 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "recipe-importer-frontend", + "private": true, + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "start": "next start -p 3000" + }, + "dependencies": { + "next": "16.2", + "react": "19.2", + "react-dom": "19.2" + }, + "devDependencies": { + "@types/node": "22.15.29", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.4.5" + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..dd5dded --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { "name": "next" } + ] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}