diff --git a/README.md b/README.md index 09f3532f..e64ac26d 100644 --- a/README.md +++ b/README.md @@ -42,3 +42,26 @@ It should: - How to build and run locally. - Example usage (frontend and API). - Any notes or assumptions. + +## How to run the application + +Prerequisites: + +- Docker (with Docker Compose) +- JDK 21 (required for local backend build/test outside Docker) + +- From the repo root, run: docker compose up --build -d +- Open frontend at http://localhost +- Backend API is at http://localhost:8585 +- Redis is at localhost:6379 (primarily internal, but exposed) + +- Network: defined in docker-compose.yml +- Volume: defined in docker-compose.yml +- Port mappings: + defined per service in docker-compose.yml + +## Notes / Assumptions + +- In `backend/src/main/resources/application-docker.yml`, Redis host is set to `host.docker.internal` as a default. +- In `docker-compose.yml`, backend sets `SPRING_DATA_REDIS_HOST=redis` (and port `6379`) via environment variables. +- This is intentional and works in Docker Compose because Spring Boot resolves environment variables with higher precedence than values in `application-docker.yml`. diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..480bdf52 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,39 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..eaa19249 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,25 @@ +# Stage 1: Build the application +FROM maven:3.9-eclipse-temurin-21-alpine AS build +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline -B +COPY src ./src +RUN mvn clean package -DskipTests + + +# Stage 2: Create the runtime image using Java 21 +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +RUN addgroup -S spring && adduser -S spring -G spring +RUN mkdir -p /app/data /app/logs +COPY --from=build /app/target/*.jar app.jar + +VOLUME /tmp +VOLUME /app/data +VOLUME /app/logs + +EXPOSE 8585 + +ENTRYPOINT ["java", "-Dspring.profiles.active=docker", "-jar", "app.jar"] + + diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 00000000..95a3b375 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,118 @@ + + + 4.0.0 + + com.tpx.urlshort + backend + 1.0-SNAPSHOT + + + 21 + 21 + UTF-8 + 2.6.0 + 3.46.0.0 + 1.15.0 + + + + org.springframework.boot + spring-boot-starter-parent + 3.3.2 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${openapi-version} + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.xerial + sqlite-jdbc + ${sqlite-version} + + + + org.hibernate.orm + hibernate-community-dialects + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + true + + + + + + + + + \ No newline at end of file diff --git a/backend/src/main/java/com/tpx/urlshort/AppStarter.java b/backend/src/main/java/com/tpx/urlshort/AppStarter.java new file mode 100644 index 00000000..49ec0b7f --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/AppStarter.java @@ -0,0 +1,26 @@ +package com.tpx.urlshort; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AppStarter implements CommandLineRunner { + + private static final Logger logger = LoggerFactory.getLogger(AppStarter.class); + + @Value("${spring.application.name:URL Shortener}") + private String appName; + + public static void main(String[] args) { + SpringApplication.run(AppStarter.class, args); + } + + @Override + public void run(String... args) throws Exception { + logger.info("Application **** {} **** started", appName); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/tpx/urlshort/cache/redis/RedisUrlCacheService.java b/backend/src/main/java/com/tpx/urlshort/cache/redis/RedisUrlCacheService.java new file mode 100644 index 00000000..f9e1e25d --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/cache/redis/RedisUrlCacheService.java @@ -0,0 +1,79 @@ +package com.tpx.urlshort.cache.redis; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Optional; + +@Service +public class RedisUrlCacheService implements UrlCacheService { + + private static final Logger logger = LoggerFactory.getLogger(RedisUrlCacheService.class); + + private final StringRedisTemplate redisTemplate; + private final Duration ttl; + + public RedisUrlCacheService(StringRedisTemplate redisTemplate, + @Value("${app.cache.ttl-seconds:600}") long ttlSeconds) { + this.redisTemplate = redisTemplate; + this.ttl = Duration.ofSeconds(ttlSeconds); + } + + @Override + public Optional get(String alias) { + try { + validateAlias(alias); + String value = redisTemplate.opsForValue().get(buildKey(alias)); + return Optional.ofNullable(value); + } catch (IllegalArgumentException e) { + logger.warn("Redis cache get failed for alias '{}': {}", alias, e.getMessage()); + return Optional.empty(); + } catch (DataAccessException e) { + logger.warn("Redis cache get failed for alias '{}': {}", alias, e.getMessage()); + return Optional.empty(); + } + } + + @Override + public void put(String alias, String actualUrl) { + try { + validateAlias(alias); + if (actualUrl == null || actualUrl.isBlank()) { + throw new IllegalArgumentException("Actual URL must not be null or empty"); + } + redisTemplate.opsForValue().set(buildKey(alias), actualUrl, ttl); + } catch (IllegalArgumentException e) { + logger.warn("Redis cache put failed for alias '{}': {}", alias, e.getMessage()); + } catch (DataAccessException e) { + logger.warn("Redis cache put failed for alias '{}': {}", alias, e.getMessage()); + } + } + + @Override + public void evict(String alias) { + try { + validateAlias(alias); + redisTemplate.delete(buildKey(alias)); + } catch (IllegalArgumentException e) { + logger.warn("Redis cache evict failed for alias '{}': {}", alias, e.getMessage()); + } catch (DataAccessException e) { + logger.warn("Redis cache evict failed for alias '{}': {}", alias, e.getMessage()); + } + } + + private String buildKey(String alias) { + return "url:" + alias; + } + + private void validateAlias(String alias) { + if (alias == null || alias.isBlank()) { + throw new IllegalArgumentException("Alias must not be null or empty"); + } + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/cache/redis/UrlCacheService.java b/backend/src/main/java/com/tpx/urlshort/cache/redis/UrlCacheService.java new file mode 100644 index 00000000..339c103e --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/cache/redis/UrlCacheService.java @@ -0,0 +1,12 @@ +package com.tpx.urlshort.cache.redis; + +import java.util.Optional; + +public interface UrlCacheService { + + Optional get(String alias); + + void put(String alias, String actualUrl); + + void evict(String alias); +} diff --git a/backend/src/main/java/com/tpx/urlshort/config/CorsConfig.java b/backend/src/main/java/com/tpx/urlshort/config/CorsConfig.java new file mode 100644 index 00000000..a9b7c92d --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/config/CorsConfig.java @@ -0,0 +1,31 @@ +package com.tpx.urlshort.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig { + + private final CorsProperties corsProperties; + + public CorsConfig(CorsProperties corsProperties) { + this.corsProperties = corsProperties; + } + + @Bean + public WebMvcConfigurer corsConfigurer() { + + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(corsProperties.getAllowedOrigins().toArray(new String[0])) + .allowedMethods(corsProperties.getAllowedMethods().toArray(new String[0])) + .allowedHeaders(corsProperties.getAllowedHeaders().toArray(new String[0])) + .allowCredentials(corsProperties.isAllowCredentials()); + } + }; + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/config/CorsProperties.java b/backend/src/main/java/com/tpx/urlshort/config/CorsProperties.java new file mode 100644 index 00000000..f23d32d0 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/config/CorsProperties.java @@ -0,0 +1,51 @@ +package com.tpx.urlshort.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@ConfigurationProperties(prefix = "app.cors") +public class CorsProperties { + + private List allowedOrigins; + private List allowedMethods; + private List allowedHeaders; + private boolean allowCredentials; + + public CorsProperties() { + } + + public List getAllowedOrigins() { + return allowedOrigins; + } + + public void setAllowedOrigins(List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + public List getAllowedMethods() { + return allowedMethods; + } + + public void setAllowedMethods(List allowedMethods) { + this.allowedMethods = allowedMethods; + } + + public List getAllowedHeaders() { + return allowedHeaders; + } + + public void setAllowedHeaders(List allowedHeaders) { + this.allowedHeaders = allowedHeaders; + } + + public boolean isAllowCredentials() { + return allowCredentials; + } + + public void setAllowCredentials(boolean allowCredentials) { + this.allowCredentials = allowCredentials; + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/config/OpenApiConfig.java b/backend/src/main/java/com/tpx/urlshort/config/OpenApiConfig.java new file mode 100644 index 00000000..26b6d92a --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/config/OpenApiConfig.java @@ -0,0 +1,18 @@ +package com.tpx.urlshort.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("URL Shortener API") + .version("1.0.0") + .description("Simple RESTful API for shortening URLs. Built with Spring Boot 3.3.2.")); + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/controller/UrlController.java b/backend/src/main/java/com/tpx/urlshort/controller/UrlController.java new file mode 100644 index 00000000..bf1b6415 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/controller/UrlController.java @@ -0,0 +1,84 @@ +package com.tpx.urlshort.controller; + +import com.tpx.urlshort.dto.UrlRequestDTO; +import com.tpx.urlshort.dto.UrlResponseDTO; +import com.tpx.urlshort.exception.IllegalParametersException; +import com.tpx.urlshort.service.UrlService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@RestController +@RequestMapping("/api/v1") +@Tag(name = "URL Shortener API", description = "Simple RESTful API for shortening URLs.") +@Validated +public class UrlController { + + private static final Logger logger = LoggerFactory.getLogger(UrlController.class); + + private final UrlService urlService; + + public UrlController(UrlService urlService) { + this.urlService = urlService; + } + + @PostMapping("/shorten") + @Operation(summary = "Shorten a URL") + @ApiResponse(responseCode = "201", description = "URL successfully shortened") + @ApiResponse(responseCode = "400", description = "Invalid input or alias already taken") + public ResponseEntity shortenUrl(@Valid @RequestBody UrlRequestDTO request) { + return ResponseEntity.status(HttpStatus.CREATED).body(urlService.shortenAndPersistURL(request)); + } + + @GetMapping("/{alias}") + @Operation(summary = "Redirect to full URL") + @ApiResponse(responseCode = "302", description = "Redirect to the original URL") + @ApiResponse(responseCode = "404", description = "Alias not found") + public ResponseEntity redirectToFullUrl( + @PathVariable @NotBlank(message = "Alias cannot be empty") @Size(min = 3, max = 50, message = "Alias must be between 3 and 50 characters") String alias) { + String destinationUrl = urlService.findByAlias(alias).actualUrl(); + logger.info("{} redirecting to the url {} ", alias, destinationUrl); + return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(destinationUrl)).build(); + } + + @DeleteMapping("/{alias}") + @Operation(summary = "Delete a shortened URL") + @ApiResponse(responseCode = "204", description = "Successfully deleted") + @ApiResponse(responseCode = "404", description = "Alias not found") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteUrl( + @PathVariable @NotBlank(message = "Alias cannot be empty") @Size(min = 3, max = 50, message = "Alias must be between 3 and 50 characters") String alias) { + urlService.delete(alias); + } + + @GetMapping("/urls") + @Operation(summary = "List all shortened URLs") + @ApiResponse(responseCode = "200", description = "A list of shortened URLs") + public ResponseEntity> listAllUrls( + @ParameterObject @PageableDefault(size = 10) Pageable pageable) { + // Validate page size to prevent DOS attacks + final int MAX_PAGE_SIZE = 100; + if (pageable.getPageSize() > MAX_PAGE_SIZE) { + throw new IllegalParametersException( + String.format("Page size cannot exceed %d. Requested size: %d", MAX_PAGE_SIZE, + pageable.getPageSize())); + } + return ResponseEntity.ok(urlService.getAll(pageable)); + } + +} diff --git a/backend/src/main/java/com/tpx/urlshort/domain/UrlDetails.java b/backend/src/main/java/com/tpx/urlshort/domain/UrlDetails.java new file mode 100644 index 00000000..c5fcf467 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/domain/UrlDetails.java @@ -0,0 +1,94 @@ +package com.tpx.urlshort.domain; + +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "url_details") +public class UrlDetails { + @Id + @Column(name = "id", nullable = false, unique = true) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "actual_url", nullable = false, length = 2048) + private String actualUrl; + + @Column(name = "short_url", nullable = false, unique = true, length = 255) + private String shortUrl; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public UrlDetails() { + } + + + public UrlDetails(Long id, String actualUrl, String shortUrl) { + this.id = id; + this.actualUrl = actualUrl; + this.shortUrl = shortUrl; + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PrePersist + public void prePersist() { + LocalDateTime now = LocalDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + } + + @PreUpdate + public void preUpdate() { + updatedAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getActualUrl() { + return actualUrl; + } + + public void setActualUrl(String actualUrl) { + this.actualUrl = actualUrl; + } + + public String getShortUrl() { + return shortUrl; + } + + public void setShortUrl(String shortUrl) { + this.shortUrl = shortUrl; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/dto/UrlRequestDTO.java b/backend/src/main/java/com/tpx/urlshort/dto/UrlRequestDTO.java new file mode 100644 index 00000000..702970f3 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/dto/UrlRequestDTO.java @@ -0,0 +1,11 @@ +package com.tpx.urlshort.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import org.hibernate.validator.constraints.URL; + +public record UrlRequestDTO( + @NotBlank(message = "The full URL is required") @URL(message = "Please provide a valid URL format") String fullUrl, + @Size(min = 3, max = 50, message = "Custom alias must be between 3 and 50 characters") @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "Custom alias can contain only alphanumeric characters") String customAlias) { +} diff --git a/backend/src/main/java/com/tpx/urlshort/dto/UrlResponseDTO.java b/backend/src/main/java/com/tpx/urlshort/dto/UrlResponseDTO.java new file mode 100644 index 00000000..f710a7db --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/dto/UrlResponseDTO.java @@ -0,0 +1,4 @@ +package com.tpx.urlshort.dto; + +public record UrlResponseDTO(String shortUrl, String actualUrl) { +} diff --git a/backend/src/main/java/com/tpx/urlshort/exception/AliasAlreadyPresentException.java b/backend/src/main/java/com/tpx/urlshort/exception/AliasAlreadyPresentException.java new file mode 100644 index 00000000..33dfc144 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/exception/AliasAlreadyPresentException.java @@ -0,0 +1,14 @@ +package com.tpx.urlshort.exception; + +public class AliasAlreadyPresentException extends AppBaseException { + public AliasAlreadyPresentException() { + } + + public AliasAlreadyPresentException(String message) { + super(message); + } + + public AliasAlreadyPresentException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/exception/AppBaseException.java b/backend/src/main/java/com/tpx/urlshort/exception/AppBaseException.java new file mode 100644 index 00000000..e4455d8a --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/exception/AppBaseException.java @@ -0,0 +1,14 @@ +package com.tpx.urlshort.exception; + +public class AppBaseException extends RuntimeException { + public AppBaseException() { + } + + public AppBaseException(String message) { + super(message); + } + + public AppBaseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/exception/ConfigMissingException.java b/backend/src/main/java/com/tpx/urlshort/exception/ConfigMissingException.java new file mode 100644 index 00000000..a3af3d9e --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/exception/ConfigMissingException.java @@ -0,0 +1,16 @@ +package com.tpx.urlshort.exception; + +public class ConfigMissingException extends AppBaseException { + + public ConfigMissingException() { + } + + public ConfigMissingException(String message) { + super(message); + } + + public ConfigMissingException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/backend/src/main/java/com/tpx/urlshort/exception/ErrorResponse.java b/backend/src/main/java/com/tpx/urlshort/exception/ErrorResponse.java new file mode 100644 index 00000000..9bd6962c --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/exception/ErrorResponse.java @@ -0,0 +1,36 @@ +package com.tpx.urlshort.exception; + +import java.time.LocalDateTime; + + +public class ErrorResponse { + private final int status; + private final String error; + private final String message; + private final LocalDateTime timestamp; + + public ErrorResponse(int status, String error, String message) { + this.status = status; + this.error = error; + this.message = message; + this.timestamp = LocalDateTime.now(); + } + + public int getStatus() { + return status; + } + + public String getError() { + return error; + } + + public String getMessage() { + return message; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + +} diff --git a/backend/src/main/java/com/tpx/urlshort/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/tpx/urlshort/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..c4e310b6 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/exception/GlobalExceptionHandler.java @@ -0,0 +1,72 @@ +package com.tpx.urlshort.exception; + +import jakarta.validation.ConstraintViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final String MESSAGE_FAILURE_ITEM_MISSING = "Alias not found"; + private static final String MESSAGE_FAILURE_ALIAS_ALREADY_TAKEN = "Invalid input or alias already taken"; + private static final String MESSAGE_FAILURE_VALIDATION = "Validation Failed"; + + @ExceptionHandler(ItemNotFoundException.class) + public ResponseEntity handleItemNotFound(ItemNotFoundException itemNotFoundException) { + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.NOT_FOUND.value(), MESSAGE_FAILURE_ITEM_MISSING, + itemNotFoundException.getMessage()); + return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(AliasAlreadyPresentException.class) + public ResponseEntity handleAliasAlreadyPresentException( + AliasAlreadyPresentException aliasAlreadyPresentException) { + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), + MESSAGE_FAILURE_ALIAS_ALREADY_TAKEN, aliasAlreadyPresentException.getMessage()); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodValidation( + MethodArgumentNotValidException methodArgumentNotValidException) { + String allErrors = methodArgumentNotValidException + .getBindingResult() + .getFieldErrors() + .stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), + MESSAGE_FAILURE_VALIDATION, allErrors); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException constraintViolationException) { + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), + MESSAGE_FAILURE_VALIDATION, constraintViolationException.getMessage()); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(IllegalParametersException.class) + public ResponseEntity handleIllegalParametersException( + IllegalParametersException illegalParametersException) { + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), + MESSAGE_FAILURE_VALIDATION, illegalParametersException.getMessage()); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception exception) { + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), + "An unexpected error occurred", "Internal server error"); + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + +} diff --git a/backend/src/main/java/com/tpx/urlshort/exception/IllegalParametersException.java b/backend/src/main/java/com/tpx/urlshort/exception/IllegalParametersException.java new file mode 100644 index 00000000..d21c7419 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/exception/IllegalParametersException.java @@ -0,0 +1,14 @@ +package com.tpx.urlshort.exception; + +public class IllegalParametersException extends AppBaseException { + public IllegalParametersException() { + } + + public IllegalParametersException(String message) { + super(message); + } + + public IllegalParametersException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/exception/ItemNotFoundException.java b/backend/src/main/java/com/tpx/urlshort/exception/ItemNotFoundException.java new file mode 100644 index 00000000..6639df16 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/exception/ItemNotFoundException.java @@ -0,0 +1,15 @@ +package com.tpx.urlshort.exception; + +public class ItemNotFoundException extends AppBaseException { + + public ItemNotFoundException() { + } + + public ItemNotFoundException(String message) { + super(message); + } + + public ItemNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/health/DatabaseHealthIndicator.java b/backend/src/main/java/com/tpx/urlshort/health/DatabaseHealthIndicator.java new file mode 100644 index 00000000..5ddc3053 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/health/DatabaseHealthIndicator.java @@ -0,0 +1,31 @@ +package com.tpx.urlshort.health; + +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; + +@Component +public class DatabaseHealthIndicator implements HealthIndicator { + + private final DataSource dataSource; + + public DatabaseHealthIndicator(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public Health health() { + try (Connection connection = dataSource.getConnection()) { + if (connection.isValid(2)) { + return Health.up().withDetail("database", "SQLite").withDetail("connection", "successful").build(); + } else { + return Health.down().withDetail("database", "SQLite").withDetail("reason", "Connection validation failed").build(); + } + } catch (Exception e) { + return Health.down().withDetail("database", "SQLite").withException(e).build(); + } + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/health/RedisHealthIndicator.java b/backend/src/main/java/com/tpx/urlshort/health/RedisHealthIndicator.java new file mode 100644 index 00000000..614cd065 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/health/RedisHealthIndicator.java @@ -0,0 +1,39 @@ +package com.tpx.urlshort.health; + +import com.tpx.urlshort.cache.redis.UrlCacheService; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component +public class RedisHealthIndicator implements HealthIndicator { + + private final UrlCacheService urlCacheService; + + public RedisHealthIndicator(UrlCacheService urlCacheService) { + this.urlCacheService = urlCacheService; + } + + @Override + public Health health() { + try { + // Test Redis connectivity by putting and getting a test value + String testKey = "_health_check_" + System.currentTimeMillis(); + String testValue = "health_check"; + + urlCacheService.put(testKey, testValue); + var result = urlCacheService.get(testKey); + + if (result.isPresent() && result.get().equals(testValue)) { + // Clean up the test key + urlCacheService.evict(testKey); + + return Health.up().withDetail("cache", "Redis").withDetail("connection", "successful").build(); + } else { + return Health.down().withDetail("cache", "Redis").withDetail("reason", "Test value mismatch").build(); + } + } catch (Exception e) { + return Health.down().withDetail("cache", "Redis").withException(e).build(); + } + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/mapper/DTOMapper.java b/backend/src/main/java/com/tpx/urlshort/mapper/DTOMapper.java new file mode 100644 index 00000000..ad9268f9 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/mapper/DTOMapper.java @@ -0,0 +1,19 @@ +package com.tpx.urlshort.mapper; + +import com.tpx.urlshort.domain.UrlDetails; +import com.tpx.urlshort.dto.UrlRequestDTO; +import com.tpx.urlshort.dto.UrlResponseDTO; + +public class DTOMapper { + + public static UrlDetails mapToResponse(UrlRequestDTO requestDTO, String shortUrl) { + UrlDetails urlDetails = new UrlDetails(); + urlDetails.setActualUrl(requestDTO.fullUrl()); + urlDetails.setShortUrl(shortUrl); + return urlDetails; + } + + public static UrlResponseDTO mapToResponse(UrlDetails urlDetails, String domain) { + return new UrlResponseDTO(domain + urlDetails.getShortUrl(), urlDetails.getActualUrl()); + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/repository/UrlRepository.java b/backend/src/main/java/com/tpx/urlshort/repository/UrlRepository.java new file mode 100644 index 00000000..06bb749b --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/repository/UrlRepository.java @@ -0,0 +1,19 @@ +package com.tpx.urlshort.repository; + +import com.tpx.urlshort.domain.UrlDetails; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UrlRepository extends JpaRepository { + + Optional findByShortUrl(String shortUrl); + + boolean existsByShortUrl(String shortUrl); + + Page findAllByOrderByCreatedAtDescShortUrlAsc(Pageable pageable); +} diff --git a/backend/src/main/java/com/tpx/urlshort/service/UrlService.java b/backend/src/main/java/com/tpx/urlshort/service/UrlService.java new file mode 100644 index 00000000..dd22d577 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/service/UrlService.java @@ -0,0 +1,116 @@ +package com.tpx.urlshort.service; + +import com.tpx.urlshort.cache.redis.UrlCacheService; +import com.tpx.urlshort.domain.UrlDetails; +import com.tpx.urlshort.dto.UrlRequestDTO; +import com.tpx.urlshort.dto.UrlResponseDTO; +import com.tpx.urlshort.exception.AliasAlreadyPresentException; +import com.tpx.urlshort.exception.IllegalParametersException; +import com.tpx.urlshort.exception.ItemNotFoundException; +import com.tpx.urlshort.mapper.DTOMapper; +import com.tpx.urlshort.repository.UrlRepository; +import com.tpx.urlshort.service.alias.AliasResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataAccessException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +public class UrlService { + + private static final Logger logger = LoggerFactory.getLogger(UrlService.class); + private final UrlRepository urlRepository; + private final AliasResolver aliasResolver; + private final UrlCacheService urlCacheService; + + @Value("${app.base-url:http://localhost:8080/}") + String appBaseUrl; + + public UrlService(UrlRepository urlRepository, AliasResolver resolver, UrlCacheService urlCacheService) { + this.urlRepository = urlRepository; + this.aliasResolver = resolver; + this.urlCacheService = urlCacheService; + } + + // for shortening the url + @Transactional + public UrlResponseDTO shortenAndPersistURL(UrlRequestDTO urlRequestDTO) { + String finalAlias = aliasResolver.resolveAndGenerate(urlRequestDTO); + try { + logger.info("final alias - {} generated for the url - {}", finalAlias, urlRequestDTO.fullUrl()); + UrlDetails savedDetails = urlRepository.saveAndFlush(DTOMapper.mapToResponse(urlRequestDTO, finalAlias)); + try { + urlCacheService.put(finalAlias, savedDetails.getActualUrl()); + } catch (RuntimeException e) { + logger.warn("Cache write skipped for alias '{}': {}", finalAlias, e.getMessage()); + } + return DTOMapper.mapToResponse(savedDetails, appBaseUrl); + } catch (DataAccessException e) { + String message = String.format("The alias -%s already exist", finalAlias); + logger.error("The alias -{} already exist , reason: {}", finalAlias, e.getMessage()); + throw new AliasAlreadyPresentException(message); + } + } + + public UrlResponseDTO findByAlias(String aliasName) { + try { + Optional cachedActualUrl = urlCacheService.get(aliasName); + if (cachedActualUrl.isPresent()) { + return new UrlResponseDTO(appBaseUrl + aliasName, cachedActualUrl.get()); + } + } catch (RuntimeException e) { + logger.warn("Cache read skipped for alias '{}': {}", aliasName, e.getMessage()); + } + + Optional urlDetailsByShortUrl = findByAliasCommon(aliasName); + if (urlDetailsByShortUrl.isEmpty()) { + String message = String.format("No details found for the alias %s", aliasName); + logger.error(message); + throw new ItemNotFoundException(message); + } + + UrlDetails urlDetails = urlDetailsByShortUrl.get(); + try { + urlCacheService.put(aliasName, urlDetails.getActualUrl()); + } catch (RuntimeException e) { + logger.warn("Cache save skipped for alias '{}': {}", aliasName, e.getMessage()); + } + return DTOMapper.mapToResponse(urlDetails, appBaseUrl); + } + + public Page getAll(Pageable pageable) { + return urlRepository.findAllByOrderByCreatedAtDescShortUrlAsc(pageable) + .map(urlDetails -> DTOMapper.mapToResponse(urlDetails, appBaseUrl)); + } + + @Transactional + public void delete(String alias) { + Optional urlDetailsByAlias = findByAliasCommon(alias); + if (urlDetailsByAlias.isEmpty()) { + String message = String.format("No details found for the alias %s", alias); + logger.debug(message); + throw new ItemNotFoundException(message); + } + urlRepository.deleteById(urlDetailsByAlias.get().getId()); + try { + urlCacheService.evict(alias); + } catch (RuntimeException e) { + logger.warn("Cache eviction skipped for alias '{}': {}", alias, e.getMessage()); + } + } + + Optional findByAliasCommon(String aliasName) { + if (aliasName == null || aliasName.isEmpty()) { + String message = String.format("Invalid parameter alias - %s", aliasName); + logger.error(message); + throw new IllegalParametersException(message); + } + return urlRepository.findByShortUrl(aliasName); + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/service/alias/AliasGenerator.java b/backend/src/main/java/com/tpx/urlshort/service/alias/AliasGenerator.java new file mode 100644 index 00000000..8099c68c --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/service/alias/AliasGenerator.java @@ -0,0 +1,11 @@ +package com.tpx.urlshort.service.alias; + +import com.tpx.urlshort.dto.UrlRequestDTO; + +public interface AliasGenerator { + + String generate(UrlRequestDTO requestDTO); + + boolean supports(UrlRequestDTO requestDTO); + +} diff --git a/backend/src/main/java/com/tpx/urlshort/service/alias/AliasResolver.java b/backend/src/main/java/com/tpx/urlshort/service/alias/AliasResolver.java new file mode 100644 index 00000000..df845f9d --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/service/alias/AliasResolver.java @@ -0,0 +1,36 @@ +package com.tpx.urlshort.service.alias; + +import com.tpx.urlshort.dto.UrlRequestDTO; +import com.tpx.urlshort.exception.ConfigMissingException; +import com.tpx.urlshort.exception.IllegalParametersException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class AliasResolver { + + private static final Logger logger = LoggerFactory.getLogger(AliasResolver.class); + private final List generators; + + public AliasResolver(List generators) { + this.generators = generators; + } + + public String resolveAndGenerate(UrlRequestDTO urlRequestDTO) { + logger.debug("Inside the resolveAndGenerate, "); + if (urlRequestDTO == null || urlRequestDTO.fullUrl() == null || urlRequestDTO.fullUrl().isEmpty()) { + logger.error("Invalid parameters , request or actual url cannot be null or empty "); + throw new IllegalParametersException("Invalid parameters , request or actual url cannot be null or empty"); + } + return generators + .stream() + .filter(gen -> gen.supports(urlRequestDTO)) + .findFirst() + .map(gen -> gen.generate(urlRequestDTO)) + .orElseThrow(() -> new ConfigMissingException("No suitable alias generator found!")); + } + +} diff --git a/backend/src/main/java/com/tpx/urlshort/service/alias/Base62Encoder.java b/backend/src/main/java/com/tpx/urlshort/service/alias/Base62Encoder.java new file mode 100644 index 00000000..1fd9a906 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/service/alias/Base62Encoder.java @@ -0,0 +1,59 @@ +package com.tpx.urlshort.service.alias; + +import com.tpx.urlshort.exception.IllegalParametersException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * To generate a 7 length long alphabet + */ +@Component +public class Base62Encoder { + + private static final Logger logger = LoggerFactory.getLogger(Base62Encoder.class); + private static final char[] ALPHA_NUMERICS_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + .toCharArray(); + private static final Integer ALPHA_NUMERICS_LENGTH = ALPHA_NUMERICS_CHARS.length; + private static final int FIXED_SIZE_OF_SHORTENED_URL = 7; + private static final long TOKEN_THRESHOLD = 3521614606207L; // 62^7 - 1 + + public String encode(long number) { + + logger.debug("Encoding started for {}", number); + + if (number < 2) { + String message = String.format("Invalid input too less to consider %d", number); + logger.error(message); + throw new IllegalParametersException(message); + } + + if (number > TOKEN_THRESHOLD) { + String message = String.format("Value %d exceeds maximum capacity for %d chars ( %d )", number, + FIXED_SIZE_OF_SHORTENED_URL, TOKEN_THRESHOLD); + logger.error(message); + throw new IllegalParametersException(message); + + } + + char[] numCharHolder = new char[FIXED_SIZE_OF_SHORTENED_URL]; + int placer = FIXED_SIZE_OF_SHORTENED_URL; + + while (number > 0) { + int index = (int) (number % ALPHA_NUMERICS_LENGTH); + numCharHolder[--placer] = ALPHA_NUMERICS_CHARS[index]; + number = number / ALPHA_NUMERICS_LENGTH; + } + + // To keep the generated shortened url fixed length + while (placer > 0) { + // filler will be 'a', + numCharHolder[--placer] = ALPHA_NUMERICS_CHARS[10]; + } + + String finalEncodedValue = new String(numCharHolder); + logger.debug("Encoded completed for {} , final value is {} ", number, finalEncodedValue); + return finalEncodedValue; + } + +} diff --git a/backend/src/main/java/com/tpx/urlshort/service/alias/CustomAliasGenerator.java b/backend/src/main/java/com/tpx/urlshort/service/alias/CustomAliasGenerator.java new file mode 100644 index 00000000..b584ad35 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/service/alias/CustomAliasGenerator.java @@ -0,0 +1,22 @@ +package com.tpx.urlshort.service.alias; + +import com.tpx.urlshort.dto.UrlRequestDTO; +import org.springframework.stereotype.Component; + +@Component +public class CustomAliasGenerator implements AliasGenerator { + + + @Override + public String generate(UrlRequestDTO requestDTO) { + if (requestDTO.customAlias() == null || requestDTO.customAlias().isEmpty()) { + throw new IllegalArgumentException("Custom alias cannot be null or empty"); + } + return requestDTO.customAlias().trim(); + } + + @Override + public boolean supports(UrlRequestDTO requestDTO) { + return requestDTO.customAlias() != null && !requestDTO.customAlias().isBlank(); + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/service/alias/SnowflakeAliasGenerator.java b/backend/src/main/java/com/tpx/urlshort/service/alias/SnowflakeAliasGenerator.java new file mode 100644 index 00000000..de88f523 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/service/alias/SnowflakeAliasGenerator.java @@ -0,0 +1,36 @@ +package com.tpx.urlshort.service.alias; + +import com.tpx.urlshort.dto.UrlRequestDTO; +import com.tpx.urlshort.service.alias.snowflake.SnowflakeIdGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class SnowflakeAliasGenerator implements AliasGenerator { + + private static final Logger logger = LoggerFactory.getLogger(SnowflakeAliasGenerator.class); + private final SnowflakeIdGenerator snowflakeIdGenerator; + private final Base62Encoder encoder; + + public SnowflakeAliasGenerator(SnowflakeIdGenerator snowflakeIdGenerator, Base62Encoder encoder) { + this.snowflakeIdGenerator = snowflakeIdGenerator; + this.encoder = encoder; + + } + + @Override + public String generate(UrlRequestDTO requestDTO) { + long nextId = snowflakeIdGenerator.getNextId(); + logger.debug("The id -{}- is generated for the url -{}-", nextId, requestDTO.fullUrl()); + String finalAlias = encoder.encode(nextId); + logger.debug("The final alias -{}- is generated for the url -{}-", finalAlias, requestDTO.fullUrl()); + return finalAlias; + } + + @Override + public boolean supports(UrlRequestDTO requestDTO) { + logger.debug("Inside supports"); + return requestDTO == null || requestDTO.customAlias() == null || requestDTO.customAlias().isBlank(); + } +} diff --git a/backend/src/main/java/com/tpx/urlshort/service/alias/snowflake/SnowflakeIdGenerator.java b/backend/src/main/java/com/tpx/urlshort/service/alias/snowflake/SnowflakeIdGenerator.java new file mode 100644 index 00000000..baea74d9 --- /dev/null +++ b/backend/src/main/java/com/tpx/urlshort/service/alias/snowflake/SnowflakeIdGenerator.java @@ -0,0 +1,121 @@ +package com.tpx.urlshort.service.alias.snowflake; + +import com.tpx.urlshort.exception.IllegalParametersException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +@Component +public class SnowflakeIdGenerator { + + /** + * The combination of timestamp difference ( (from a reference value - current ) + * in sec) + * + the workstation id + dataCenterId + a generating sequence number + * ensure the uniqueness at each server level + *

+ * Why this complicated logic is required.. + * -> There is no need for using any external system for uniqueness + * -> Scalable + * -> uniqueness is guaranteed at the server level + * -> Standard approach used in the industry + */ + + private static final Logger logger = LoggerFactory.getLogger(SnowflakeIdGenerator.class); + // Bit distribution to fit exactly under 62^7 (Max 41 bits total) + private static final long DATA_CENTER_ID_BITS = 2L; + private static final long WORKER_ID_BITS = 3L; + private static final long SEQUENCE_BITS = 5L; + private static final long MAX_DATA_CENTER_ID = (1L << DATA_CENTER_ID_BITS) - 1; // 3 + private static final long MAX_WORKER_ID = (1L << WORKER_ID_BITS) - 1; // 7 + private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1; // 31 + private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; + private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; + private static final long TIMESTAMP_SHIFT = DATA_CENTER_ID_SHIFT + DATA_CENTER_ID_BITS; + private final long workerId; + private final long dataCenterId; + private final long timestampBaseReference; + private final Lock lock = new ReentrantLock(); + private long sequence = 0L; + private long lastTimestamp = -1L; + + public SnowflakeIdGenerator(@Value("${snowflake.worker-id:0}") long workerId, + @Value("${snowflake.datacenter-id:0}") long dataCenterId, + @Value("${snowflake.epoch-reference:1767225600}") long timestampBaseReference) { + + if (workerId < 0 || workerId > MAX_WORKER_ID) { + throw new IllegalParametersException("Worker ID must be between 0 and " + MAX_WORKER_ID); + } + if (dataCenterId < 0 || dataCenterId > MAX_DATA_CENTER_ID) { + throw new IllegalParametersException("Data Center ID must be between 0 and " + MAX_DATA_CENTER_ID); + } + + this.workerId = workerId; + this.dataCenterId = dataCenterId; + this.timestampBaseReference = timestampBaseReference; + } + + public long getNextId() { + + try { + logger.debug("Next Id requested"); + lock.lock(); + + // calculate the current timestamp.. + long currentTimeStamp = getCurrentTimestamp(); + + // the current timestamp is less than previous , that means the clock is reset + if (currentTimeStamp < lastTimestamp) { + String message = String.format("Clock moved backwards. Refusing to generate id for %s seconds.", + (lastTimestamp - currentTimeStamp)); + logger.error(message); + throw new IllegalStateException(message); + } + + // if sequence are generate at the same time, increment it + logger.debug("Calculating current timestamp and sequence"); + if (currentTimeStamp == lastTimestamp) { + // if the sequence reached the max seq, reset it to 0 and get a new timestamp in + // future + sequence = (sequence + 1) & MAX_SEQUENCE; + if (sequence == 0L) { + // Sequence exhausted for this second, block until the clock ticks forward + currentTimeStamp = waitAndGetNextTimestamp(currentTimeStamp); + } + } else { + // if timestamp is greater use 0 + sequence = 0L; + } + logger.debug("current timestamp and sequence {} , {} ", currentTimeStamp, sequence); + lastTimestamp = currentTimeStamp; + + // Compound 41-bit ID calculation + long nextIdGenerated = ((currentTimeStamp - timestampBaseReference) << TIMESTAMP_SHIFT) + | (dataCenterId << DATA_CENTER_ID_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; + logger.debug("nextIdGenerated {}", nextIdGenerated); + return nextIdGenerated; + + } finally { + lock.unlock(); + } + + } + + long getCurrentTimestamp() { + return Instant.now().getEpochSecond(); // Extracted in seconds + } + + long waitAndGetNextTimestamp(long lastTimestamp) { + long timestamp = getCurrentTimestamp(); + while (timestamp <= lastTimestamp) { + timestamp = getCurrentTimestamp(); + } + return timestamp; + } + +} diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml new file mode 100644 index 00000000..1a9d058e --- /dev/null +++ b/backend/src/main/resources/application-docker.yml @@ -0,0 +1,35 @@ +# Enable Java 21 Virtual Threads for embedded Tomcat +spring: + threads: + virtual: + enabled: true + jpa: + show-sql: false + hibernate: + ddl-auto: update + datasource: + driver-class-name: org.sqlite.JDBC + url: jdbc:sqlite:/app/data/url_shortener.db + data: + redis: + host: host.docker.internal + port: 6379 + timeout: 2000ms + +springdoc: + swagger-ui: + enabled: false + api-docs: + enabled: false + +app: + base-url: "${APP_BASE_URL:http://localhost:8585/api/v1/}" + cors: + allowed-origins: + - "${CORS_ALLOWED_ORIGINS:http://localhost}" + - "http://localhost:80" + +logging: + level: + root: WARN + com.tpx.urlshort: INFO diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml new file mode 100644 index 00000000..bf5930cf --- /dev/null +++ b/backend/src/main/resources/application-local.yml @@ -0,0 +1,34 @@ +spring: + jpa: + show-sql: true + hibernate: + ddl-auto: update + datasource: + driver-class-name: org.sqlite.JDBC + url: jdbc:sqlite:data/url_shortener.db + data: + redis: + host: localhost + port: 6379 + timeout: 2000ms + +springdoc: + swagger-ui: + enabled: true + api-docs: + enabled: true + path: /api/v1/api-docs + +app: + base-url: http://localhost:8585/api/v1/ + cors: + allowed-origins: + - http://localhost:5173 + - http://localhost:5174 + +logging: + level: + root: INFO + com.tpx.urlshort: DEBUG + org.springframework.web: DEBUG + diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 00000000..65219294 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,61 @@ +spring: + application: + name: "TPX Impact URL Shortener" + profiles: + active: + - local + flyway: + clean-disabled: true + baseline-on-migrate: true + enabled: true + jpa: + hibernate: + ddl-auto: update + database-platform: org.hibernate.community.dialect.SQLiteDialect + +server: + port: 8585 + tomcat: + max-http-post-size: 1MB + max-http-request-header-size: 8KB + +springdoc: + packages-to-scan: com.tpx.urlshort.controller + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + probes: + enabled: true + metrics: + enable: + jvm: true + +snowflake: + worker-id: 2 + datacenter-id: 2 + epoch-reference: 1767225600 + +app: + cache: + ttl-seconds: 600 + cors: + allowed-methods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allowed-headers: + - "*" + allow-credentials: true + +logging: + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" diff --git a/backend/src/test/java/com/tpx/urlshort/controller/UrlControllerIT.java b/backend/src/test/java/com/tpx/urlshort/controller/UrlControllerIT.java new file mode 100644 index 00000000..0dbe8fee --- /dev/null +++ b/backend/src/test/java/com/tpx/urlshort/controller/UrlControllerIT.java @@ -0,0 +1,169 @@ +package com.tpx.urlshort.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tpx.urlshort.cache.redis.UrlCacheService; +import com.tpx.urlshort.domain.UrlDetails; +import com.tpx.urlshort.dto.UrlRequestDTO; +import com.tpx.urlshort.repository.UrlRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UrlControllerIT { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UrlRepository urlRepository; + + @MockBean + private UrlCacheService urlCacheService; + + @BeforeEach + void cleanUp() { + urlRepository.deleteAll(); + // Mock cache to always return empty (cache miss) - forces DB lookup + when(urlCacheService.get(anyString())).thenReturn(Optional.empty()); + } + + @Test + void shortenUrl_shouldReturn201AndPersist() throws Exception { + UrlRequestDTO request = new UrlRequestDTO("https://example.com/some/path", "mycustomalias"); + + mockMvc.perform(post("/api/v1/shorten") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.shortUrl").value("http://localhost:8080/mycustomalias")) + .andExpect(jsonPath("$.actualUrl").value("https://example.com/some/path")); + + Assertions.assertTrue(urlRepository.findByShortUrl("mycustomalias").isPresent()); + } + + @Test + void shortenUrl_whenCustomAliasHasSpecialCharacters_shouldReturn400() throws Exception { + UrlRequestDTO request = new UrlRequestDTO("https://example.com/some/path", "my alias!"); + + mockMvc.perform(post("/api/v1/shorten") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void shortenUrl_whenAliasAlreadyExists_shouldReturn400() throws Exception { + UrlDetails existing = new UrlDetails(); + existing.setActualUrl("https://already.com"); + existing.setShortUrl("dupalias"); + urlRepository.saveAndFlush(existing); + + UrlRequestDTO request = new UrlRequestDTO("https://example.com/new", "dupalias"); + + mockMvc.perform(post("/api/v1/shorten") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.error").value("Invalid input or alias already taken")); + } + + @Test + void redirectToFullUrl_shouldReturn302AndLocation() throws Exception { + UrlDetails existing = new UrlDetails(); + existing.setActualUrl("https://lookup.com/path"); + existing.setShortUrl("lookup-alias"); + urlRepository.saveAndFlush(existing); + + mockMvc.perform(get("/api/v1/lookup-alias")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "https://lookup.com/path")); + } + + @Test + void redirectToFullUrl_whenAliasMissing_shouldReturn404() throws Exception { + mockMvc.perform(get("/api/v1/missing-alias")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.error").value("Alias not found")); + } + + @Test + void deleteUrl_shouldReturn204AndRemoveEntry() throws Exception { + UrlDetails existing = new UrlDetails(); + existing.setActualUrl("https://delete-me.com"); + existing.setShortUrl("to-delete"); + urlRepository.saveAndFlush(existing); + + mockMvc.perform(delete("/api/v1/to-delete")) + .andExpect(status().isNoContent()); + + Assertions.assertTrue(urlRepository.findByShortUrl("to-delete").isEmpty()); + } + + @Test + void deleteUrl_whenAliasMissing_shouldReturn404() throws Exception { + mockMvc.perform(delete("/api/v1/not-here")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.error").value("Alias not found")); + } + + @Test + void listAllUrls_shouldReturn200AndPagedContent() throws Exception { + UrlDetails first = new UrlDetails(); + first.setActualUrl("https://site1.com"); + first.setShortUrl("AB"); + first.setCreatedAt(LocalDateTime.of(2026, 8, 26, 11, 55, 0)); + first.setUpdatedAt(first.getCreatedAt()); + + UrlDetails second = new UrlDetails(); + second.setActualUrl("https://site2.com"); + second.setShortUrl("pq"); + second.setCreatedAt(LocalDateTime.of(2026, 8, 26, 11, 58, 0)); + second.setUpdatedAt(second.getCreatedAt()); + + UrlDetails third = new UrlDetails(); + third.setActualUrl("https://site3.com"); + third.setShortUrl("xy"); + third.setCreatedAt(LocalDateTime.of(2026, 8, 26, 11, 59, 0)); + third.setUpdatedAt(third.getCreatedAt()); + + urlRepository.saveAll(java.util.List.of(first, second, third)); + + mockMvc.perform(get("/api/v1/urls") + .param("page", "0") + .param("size", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].shortUrl").value("http://localhost:8080/xy")) + .andExpect(jsonPath("$.content[0].actualUrl").value("https://site3.com")) + .andExpect(jsonPath("$.content[1].shortUrl").value("http://localhost:8080/pq")) + .andExpect(jsonPath("$.content[1].actualUrl").value("https://site2.com")) + .andExpect(jsonPath("$.totalElements").value(3)); + } +} diff --git a/backend/src/test/java/com/tpx/urlshort/controller/UrlControllerTest.java b/backend/src/test/java/com/tpx/urlshort/controller/UrlControllerTest.java new file mode 100644 index 00000000..71d730a5 --- /dev/null +++ b/backend/src/test/java/com/tpx/urlshort/controller/UrlControllerTest.java @@ -0,0 +1,130 @@ +package com.tpx.urlshort.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tpx.urlshort.dto.UrlRequestDTO; +import com.tpx.urlshort.dto.UrlResponseDTO; +import com.tpx.urlshort.exception.AliasAlreadyPresentException; +import com.tpx.urlshort.exception.ItemNotFoundException; +import com.tpx.urlshort.service.UrlService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(UrlController.class) +class UrlControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private UrlService urlService; + + @Test + void shortenUrl_shouldReturn201AndBody() throws Exception { + String fullUrl = "https://example.com/some/path"; + String alias = "mycustomalias"; + String expectedShortUrl = "http://localhost:8080/mycustomalias"; + UrlRequestDTO request = new UrlRequestDTO(fullUrl, alias); + UrlResponseDTO response = new UrlResponseDTO(expectedShortUrl, fullUrl); + Mockito.when(urlService.shortenAndPersistURL(request)).thenReturn(response); + mockMvc.perform(post("/api/v1/shorten").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))).andExpect(status().isCreated()) + .andExpect(jsonPath("$.shortUrl").value(expectedShortUrl)) + .andExpect(jsonPath("$.actualUrl").value(fullUrl)); + } + + @Test + void shortenUrl_whenAliasTaken_shouldReturn400() throws Exception { + String fullUrl = "https://example.com/some/path"; + String alias = "mycustomalias"; + UrlRequestDTO request = new UrlRequestDTO(fullUrl, alias); + Mockito.when(urlService.shortenAndPersistURL(request)) + .thenThrow(new AliasAlreadyPresentException("The alias -mycustomalias already exist")); + + mockMvc.perform(post("/api/v1/shorten").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.error").value("Invalid input or alias already taken")) + .andExpect(jsonPath("$.message").value("The alias -mycustomalias already exist")); + } + + @Test + void shortenUrl_whenCustomAliasHasSpecialCharacters_shouldReturn400() throws Exception { + String fullUrl = "https://example.com/some/path"; + UrlRequestDTO request = new UrlRequestDTO(fullUrl, "my alias!"); + + mockMvc.perform(post("/api/v1/shorten").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void redirectToFullUrl_shouldReturn302AndLocationHeader() throws Exception { + String shortUrl = "http://localhost:8080/mycustomalias"; + String actualUrl = "https://example.com/some/path"; + String alias = "mycustomalias"; + UrlResponseDTO response = new UrlResponseDTO(shortUrl, actualUrl); + Mockito.when(urlService.findByAlias(alias)).thenReturn(response); + mockMvc.perform(get("/api/v1/mycustomalias")).andExpect(status().isFound()) + .andExpect(header().string("Location", actualUrl)); + } + + @Test + void redirectToFullUrl_whenAliasMissing_shouldReturn404() throws Exception { + Mockito.when(urlService.findByAlias("missing-alias")) + .thenThrow(new ItemNotFoundException("No details found for the alias missing-alias")); + mockMvc.perform(get("/api/v1/missing-alias")).andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)).andExpect(jsonPath("$.error").value("Alias not found")) + .andExpect(jsonPath("$.message").value("No details found for the alias missing-alias")); + } + + @Test + void deleteUrl_shouldReturn204() throws Exception { + mockMvc.perform(delete("/api/v1/to-delete")).andExpect(status().isNoContent()); + Mockito.verify(urlService).delete("to-delete"); + } + + @Test + void deleteUrl_whenAliasMissing_shouldReturn404() throws Exception { + + Mockito.doThrow(new ItemNotFoundException("No details found for the alias not-here")).when(urlService) + .delete("not-here"); + mockMvc.perform(delete("/api/v1/not-here")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.error").value("Alias not found")) + .andExpect(jsonPath("$.message").value("No details found for the alias not-here")); + } + + @Test + void listAllUrls_shouldReturn200AndPagedBody() throws Exception { + List content = List.of( + new UrlResponseDTO("http://localhost:8080/xy", "https://site1.com"), + new UrlResponseDTO("http://localhost:8080/ab", "https://site2.com")); + Page page = new PageImpl<>(content, PageRequest.of(0, 10), 2); + Mockito.when(urlService.getAll(Mockito.any())).thenReturn(page); + + mockMvc.perform(get("/api/v1/urls").param("page", "0").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].shortUrl").value("http://localhost:8080/xy")) + .andExpect(jsonPath("$.content[0].actualUrl").value("https://site1.com")) + .andExpect(jsonPath("$.content[1].shortUrl").value("http://localhost:8080/ab")) + .andExpect(jsonPath("$.content[1].actualUrl").value("https://site2.com")) + .andExpect(jsonPath("$.totalElements").value(2)); + } +} diff --git a/backend/src/test/java/com/tpx/urlshort/repository/UrlRepositoryIT.java b/backend/src/test/java/com/tpx/urlshort/repository/UrlRepositoryIT.java new file mode 100644 index 00000000..9c7cc433 --- /dev/null +++ b/backend/src/test/java/com/tpx/urlshort/repository/UrlRepositoryIT.java @@ -0,0 +1,138 @@ +package com.tpx.urlshort.repository; + +import com.tpx.urlshort.domain.UrlDetails; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.List; +import java.util.Optional; + +/** + * This test class is created for future reason + * if someone is creating any api using complex custom sql + */ +@DataJpaTest +//to stop defaulting to H2 +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class UrlRepositoryIT { + + + @Autowired + UrlRepository urlRepository; + + @Test + void testSave() { + UrlDetails urlDetails = new UrlDetails(); + urlDetails.setActualUrl("abcd"); + urlDetails.setShortUrl("ab"); + UrlDetails persistedUrlDetails = urlRepository.save(urlDetails); + Assertions.assertNotNull(persistedUrlDetails.getId()); + Assertions.assertNotNull(persistedUrlDetails.getCreatedAt()); + Assertions.assertNotNull(persistedUrlDetails.getUpdatedAt()); + Assertions.assertEquals("abcd", persistedUrlDetails.getActualUrl()); + Assertions.assertEquals("ab", persistedUrlDetails.getShortUrl()); + } + + @Test + void testGetById() { + UrlDetails urlDetails = new UrlDetails(); + urlDetails.setActualUrl("abcd"); + urlDetails.setShortUrl("ab"); + UrlDetails persistedUrlDetails = urlRepository.save(urlDetails); + Assertions.assertNotNull(persistedUrlDetails.getId()); + Assertions.assertNotNull(persistedUrlDetails.getCreatedAt()); + Assertions.assertNotNull(persistedUrlDetails.getUpdatedAt()); + Assertions.assertEquals("abcd", persistedUrlDetails.getActualUrl()); + Assertions.assertEquals("ab", persistedUrlDetails.getShortUrl()); + Optional resultById = urlRepository.findById(persistedUrlDetails.getId()); + if (resultById.isEmpty()) { + Assertions.fail("Expected persisted value , but found none"); + } + UrlDetails urlDetailsFound = resultById.get(); + Assertions.assertEquals("abcd", urlDetailsFound.getActualUrl()); + Assertions.assertEquals("ab", urlDetailsFound.getShortUrl()); + Assertions.assertNotNull(persistedUrlDetails.getId()); + } + + @Test + void testDelete() { + UrlDetails urlDetails = new UrlDetails(); + urlDetails.setActualUrl("abcd"); + urlDetails.setShortUrl("ab"); + UrlDetails persistedUrlDetails = urlRepository.save(urlDetails); + Assertions.assertNotNull(persistedUrlDetails.getId()); + Assertions.assertNotNull(persistedUrlDetails.getCreatedAt()); + Assertions.assertNotNull(persistedUrlDetails.getUpdatedAt()); + Assertions.assertEquals("abcd", persistedUrlDetails.getActualUrl()); + Assertions.assertEquals("ab", persistedUrlDetails.getShortUrl()); + //start deleting + urlRepository.deleteById(persistedUrlDetails.getId()); + Optional resultById = urlRepository.findById(persistedUrlDetails.getId()); + Assertions.assertTrue(resultById.isEmpty()); + } + + @Test + void testGetAll_paginated() { + List urlDetailsLst = getUrlDetails(); + urlRepository.saveAll(urlDetailsLst); + Pageable pageable = PageRequest.of(0, 2); + Page allResp = urlRepository.findAllByOrderByCreatedAtDescShortUrlAsc(pageable); + Assertions.assertNotNull(allResp); + Assertions.assertEquals(2, allResp.getContent().size()); + Assertions.assertEquals(2, allResp.getTotalPages()); + Assertions.assertEquals(4, allResp.getTotalElements()); + } + + @Test + void testFindByShortUrl() { + UrlDetails urlDetails = new UrlDetails(); + urlDetails.setActualUrl("abcd"); + urlDetails.setShortUrl("ab"); + UrlDetails persistedUrlDetails = urlRepository.save(urlDetails); + + //Optional findByShortUrl(String shortUrl); + //boolean existsByShortUrl(String shortUrl); + Optional byShortUrl = urlRepository.findByShortUrl(persistedUrlDetails.getShortUrl()); + Assertions.assertFalse(byShortUrl.isEmpty()); + UrlDetails urlDetailsBySearch = byShortUrl.get(); + Assertions.assertNotNull(urlDetailsBySearch); + Assertions.assertEquals("abcd", urlDetailsBySearch.getActualUrl()); + } + + @Test + void testExistsByShortUrl() { + UrlDetails urlDetails = new UrlDetails(); + urlDetails.setActualUrl("abcd"); + urlDetails.setShortUrl("ab"); + UrlDetails persistedUrlDetails = urlRepository.save(urlDetails); + Assertions.assertTrue(urlRepository.existsByShortUrl(persistedUrlDetails.getShortUrl())); + } + + //for pagination test + List getUrlDetails() { + //set data + UrlDetails urlDetails1 = new UrlDetails(); + urlDetails1.setActualUrl("http://abcd/123243"); + urlDetails1.setShortUrl("ab"); + + UrlDetails urlDetails2 = new UrlDetails(); + urlDetails2.setActualUrl("http://pqrst/123243"); + urlDetails2.setShortUrl("pq"); + + UrlDetails urlDetails3 = new UrlDetails(); + urlDetails3.setActualUrl("http://pqrst12345/123243"); + urlDetails3.setShortUrl("pq12"); + + UrlDetails urlDetails4 = new UrlDetails(); + urlDetails4.setActualUrl("http://ZZZpqrst12345/123243"); + urlDetails4.setShortUrl("ZZ12"); + return List.of(urlDetails1, urlDetails2, urlDetails3, urlDetails4); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/tpx/urlshort/service/UrlServiceIT.java b/backend/src/test/java/com/tpx/urlshort/service/UrlServiceIT.java new file mode 100644 index 00000000..8000a09a --- /dev/null +++ b/backend/src/test/java/com/tpx/urlshort/service/UrlServiceIT.java @@ -0,0 +1,195 @@ +package com.tpx.urlshort.service; + +import com.tpx.urlshort.domain.UrlDetails; +import com.tpx.urlshort.dto.UrlRequestDTO; +import com.tpx.urlshort.dto.UrlResponseDTO; +import com.tpx.urlshort.exception.AliasAlreadyPresentException; +import com.tpx.urlshort.exception.IllegalParametersException; +import com.tpx.urlshort.exception.ItemNotFoundException; +import com.tpx.urlshort.repository.UrlRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class UrlServiceIT { + + + + @Autowired + private UrlService urlService; + + @Autowired + private UrlRepository urlRepository; + + @Value("${app.base-url:http://localhost:8080/}") + String appBaseUrl; + + @BeforeEach + void cleanUp() { + urlRepository.deleteAll(); + } + + @Test + void shortenAndPersistURL_shouldSaveAndReturnMappedResponse() { + String actualUrl = "https://example.com/some/path"; + String customAlias = "my-custom-alias"; + UrlRequestDTO request = new UrlRequestDTO(actualUrl, customAlias); + UrlResponseDTO response = urlService.shortenAndPersistURL(request); + + Assertions.assertNotNull(response); + Assertions.assertEquals(appBaseUrl+customAlias, response.shortUrl()); + Assertions.assertEquals(actualUrl, response.actualUrl()); + + Optional persisted = urlRepository.findByShortUrl(customAlias); + Assertions.assertTrue(persisted.isPresent()); + Assertions.assertEquals(actualUrl, persisted.get().getActualUrl()); + } + + @Test + void shortenAndPersistURL_whenAliasAlreadyExists_shouldThrowAliasAlreadyPresentException() { + UrlDetails existing = new UrlDetails(); + String actualUrl = "https://already.com"; + String shortUrl = "dup-alias"; + String newUrlToShorten = "https://example.com/new"; + String expectedErrorMessage = "The alias -dup-alias already exist"; + + existing.setActualUrl(actualUrl); + existing.setShortUrl(shortUrl); + + urlRepository.saveAndFlush(existing); + UrlRequestDTO request = new UrlRequestDTO(newUrlToShorten, shortUrl); + AliasAlreadyPresentException aliasAlreadyPresentException = Assertions + .assertThrows(AliasAlreadyPresentException.class, () -> urlService.shortenAndPersistURL(request)); + Assertions.assertEquals(expectedErrorMessage, aliasAlreadyPresentException.getMessage()); + } + + @Test + void findByAlias_shouldReturnResponseWhenAliasExists() { + UrlDetails existing = new UrlDetails(); + + String actualUrl = "https://lookup.com"; + String shortUrl = "lookup-alias"; + + existing.setActualUrl(actualUrl); + existing.setShortUrl(shortUrl); + urlRepository.saveAndFlush(existing); + + UrlResponseDTO response = urlService.findByAlias(shortUrl); + + Assertions.assertEquals(appBaseUrl+shortUrl, response.shortUrl()); + Assertions.assertEquals(actualUrl, response.actualUrl()); + } + + @Test + void findByAlias_whenAliasDoesNotExist_shouldThrowItemNotFoundException() { + String expectedMessage = "No details found for the alias missing-alias"; + ItemNotFoundException itemNotFoundException = Assertions.assertThrows(ItemNotFoundException.class, + () -> urlService.findByAlias("missing-alias")); + Assertions.assertEquals(expectedMessage, itemNotFoundException.getMessage()); + } + + @Test + void findByAlias_whenAliasIsNull_shouldThrowIllegalParametersException() { + String expectedMessage = "Invalid parameter alias - null"; + IllegalParametersException illegalParametersException = Assertions + .assertThrows(IllegalParametersException.class, () -> urlService.findByAlias(null)); + Assertions.assertEquals(expectedMessage, illegalParametersException.getMessage()); + } + + @Test + void delete_shouldRemoveEntryWhenAliasExists() { + + String actualUrl = "https://delete-me.com"; + String shortUrl = "to-delete"; + + UrlDetails existing = new UrlDetails(); + existing.setActualUrl(actualUrl); + existing.setShortUrl(shortUrl); + urlRepository.saveAndFlush(existing); + + urlService.delete(shortUrl); + + Assertions.assertFalse(urlRepository.findByShortUrl(shortUrl).isPresent()); + } + + @Test + void delete_whenAliasDoesNotExist_noException() { + Assertions.assertThrows(ItemNotFoundException.class,()->urlService.delete("not-here")); + } + + @Test + void testGetAll() { + Pageable pageable = PageRequest.of(0, 3); + urlRepository.saveAll(prepareDataForPagination()); + + Page page0 = urlService.getAll(pageable); + Assertions.assertEquals(3, page0.getSize()); + Assertions.assertEquals(3, page0.getContent().size()); + + // createdAt desc: latest timestamp first; then shortUrl asc when createdAt ties + Assertions.assertEquals(appBaseUrl+"xy", page0.getContent().get(0).shortUrl()); + Assertions.assertEquals(appBaseUrl+"pq", page0.getContent().get(1).shortUrl()); + Assertions.assertEquals(appBaseUrl+"AB", page0.getContent().get(2).shortUrl()); + + UrlDetails urlDetails = new UrlDetails(); + urlDetails.setActualUrl("http://abcd/123243"); + urlDetails.setShortUrl("zzz"); + urlDetails.setCreatedAt(LocalDateTime.of(2026, 8, 26, 12, 0, 0)); + urlDetails.setUpdatedAt(urlDetails.getCreatedAt()); + + // add one more and see that is coming at the beginning + urlRepository.save(urlDetails); + + Page page1 = urlService.getAll(pageable); + Assertions.assertEquals(3, page1.getSize()); + Assertions.assertEquals(appBaseUrl+"zzz", page1.getContent().get(0).shortUrl()); + Assertions.assertEquals(appBaseUrl+"xy", page1.getContent().get(1).shortUrl()); + Assertions.assertEquals(appBaseUrl+"pq", page1.getContent().get(2).shortUrl()); + + } + + private List prepareDataForPagination() { + // for pagination test + // set data + UrlDetails urlDetails1 = new UrlDetails(); + urlDetails1.setActualUrl("http://abcd/123243"); + urlDetails1.setShortUrl("AB"); + urlDetails1.setCreatedAt(LocalDateTime.of(2026, 8, 26, 11, 55, 0)); + urlDetails1.setUpdatedAt(urlDetails1.getCreatedAt()); + + UrlDetails urlDetails2 = new UrlDetails(); + urlDetails2.setActualUrl("http://pqrst/123243"); + urlDetails2.setShortUrl("pq"); + urlDetails2.setCreatedAt(LocalDateTime.of(2026, 8, 26, 11, 58, 0)); + urlDetails2.setUpdatedAt(urlDetails2.getCreatedAt()); + + UrlDetails urlDetails3 = new UrlDetails(); + urlDetails3.setActualUrl("http://pqrst12345/123243"); + urlDetails3.setShortUrl("xy"); + urlDetails3.setCreatedAt(LocalDateTime.of(2026, 8, 26, 11, 59, 0)); + urlDetails3.setUpdatedAt(urlDetails3.getCreatedAt()); + + UrlDetails urlDetails4 = new UrlDetails(); + urlDetails4.setActualUrl("http://ZZZpqrst12345/123243"); + urlDetails4.setShortUrl("ZZ12"); + urlDetails4.setCreatedAt(LocalDateTime.of(2026, 8, 26, 11, 55, 0)); + urlDetails4.setUpdatedAt(urlDetails4.getCreatedAt()); + + return List.of(urlDetails1, urlDetails2, urlDetails3, urlDetails4); + } +} diff --git a/backend/src/test/java/com/tpx/urlshort/service/UrlServiceTest.java b/backend/src/test/java/com/tpx/urlshort/service/UrlServiceTest.java new file mode 100644 index 00000000..0d53f2c4 --- /dev/null +++ b/backend/src/test/java/com/tpx/urlshort/service/UrlServiceTest.java @@ -0,0 +1,233 @@ +package com.tpx.urlshort.service; + +import com.tpx.urlshort.cache.redis.UrlCacheService; +import com.tpx.urlshort.domain.UrlDetails; +import com.tpx.urlshort.dto.UrlRequestDTO; +import com.tpx.urlshort.dto.UrlResponseDTO; +import com.tpx.urlshort.exception.AliasAlreadyPresentException; +import com.tpx.urlshort.exception.IllegalParametersException; +import com.tpx.urlshort.exception.ItemNotFoundException; +import com.tpx.urlshort.repository.UrlRepository; +import com.tpx.urlshort.service.alias.AliasResolver; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; + +class UrlServiceTest { + + @Test + void testShortenAndPersists() { + UrlRepository urlRepositoryMock = Mockito.mock(UrlRepository.class); + AliasResolver aliasResolverMock = Mockito.mock(AliasResolver.class); + UrlCacheService urlCacheServiceMock = Mockito.mock(UrlCacheService.class); + + String finalAlias = "my-final-alias"; + String expectedShortUrl = "http://localhost:8080/my-final-alias"; + + UrlRequestDTO urlRequestDTO = new UrlRequestDTO("my-full-url", "alias"); + UrlDetails savedUrlDetails = new UrlDetails(1L, "my-full-url", finalAlias); + + // mock handling + Mockito.when(aliasResolverMock.resolveAndGenerate(Mockito.any(UrlRequestDTO.class))).thenReturn(finalAlias); + Mockito.when(urlRepositoryMock.saveAndFlush(Mockito.any(UrlDetails.class))).thenReturn(savedUrlDetails); + + UrlService urlService = new UrlService(urlRepositoryMock, aliasResolverMock, urlCacheServiceMock); + urlService.appBaseUrl = "http://localhost:8080/"; + UrlResponseDTO urlResponseDTO = urlService.shortenAndPersistURL(urlRequestDTO); + Mockito.verify(urlCacheServiceMock).put(finalAlias, "my-full-url"); + Assertions.assertEquals(expectedShortUrl, urlResponseDTO.shortUrl()); + } + + @Test + void testShortenAndPersists_for_exception() { + UrlRepository urlRepositoryMock = Mockito.mock(UrlRepository.class); + AliasResolver aliasResolverMock = Mockito.mock(AliasResolver.class); + UrlCacheService urlCacheServiceMock = Mockito.mock(UrlCacheService.class); + + String finalAlias = "my-final-alias"; + String exceptionMessage = "The alias -my-final-alias already exist"; + UrlRequestDTO urlRequestDTO = new UrlRequestDTO("my-full-url", "alias"); + // mock handling + Mockito.when(aliasResolverMock.resolveAndGenerate(Mockito.any(UrlRequestDTO.class))).thenReturn(finalAlias); + Mockito.when(urlRepositoryMock.saveAndFlush(Mockito.any(UrlDetails.class))) + .thenThrow(new DataIntegrityViolationException("Already present")); + UrlService urlService = new UrlService(urlRepositoryMock, aliasResolverMock, urlCacheServiceMock); + urlService.appBaseUrl = "http://localhost:8080/"; + AliasAlreadyPresentException aliasAlreadyPresentException = Assertions.assertThrows( + AliasAlreadyPresentException.class, + () -> urlService.shortenAndPersistURL(urlRequestDTO)); + Assertions.assertEquals(exceptionMessage, aliasAlreadyPresentException.getMessage()); + + } + + @Test + void testFindByAlias() { + UrlRepository urlRepositoryMock = Mockito.mock(UrlRepository.class); + AliasResolver aliasResolverMock = Mockito.mock(AliasResolver.class); + UrlCacheService urlCacheServiceMock = Mockito.mock(UrlCacheService.class); + + // Implement the second API + String finalAlias = "my-final-alias"; + String actualUrl = "my-full-url"; + UrlDetails savedUrlDetails = new UrlDetails(1L, "my-full-url", finalAlias); + Optional savedUrlDetailsOptional = Optional.of(savedUrlDetails); + + // handle mock + Mockito.when(urlRepositoryMock.findByShortUrl(finalAlias)).thenReturn(savedUrlDetailsOptional); + + Mockito.when(urlCacheServiceMock.get(finalAlias)).thenReturn(Optional.empty()); + + UrlService urlService = new UrlService(urlRepositoryMock, aliasResolverMock, urlCacheServiceMock); + urlService.appBaseUrl = "http://localhost:8080/"; + UrlResponseDTO byAlias = urlService.findByAlias(finalAlias); + Mockito.verify(urlCacheServiceMock).put(finalAlias, actualUrl); + Assertions.assertEquals(actualUrl, byAlias.actualUrl()); + } + + @Test + void testFindByAlias_input_null() { + UrlRepository urlRepositoryMock = Mockito.mock(UrlRepository.class); + AliasResolver aliasResolverMock = Mockito.mock(AliasResolver.class); + UrlCacheService urlCacheServiceMock = Mockito.mock(UrlCacheService.class); + + String finalAlias = null; + String expectedErrorMessage = "Invalid parameter alias - null"; + UrlService urlService = new UrlService(urlRepositoryMock, aliasResolverMock, urlCacheServiceMock); + urlService.appBaseUrl = "http://localhost:8080/"; + IllegalParametersException illegalParametersException = Assertions.assertThrows( + IllegalParametersException.class, + () -> urlService.findByAlias(finalAlias)); + Assertions.assertEquals(expectedErrorMessage, illegalParametersException.getMessage()); + } + + @Test + void testFindByAlias_return_empty() { + UrlRepository urlRepositoryMock = Mockito.mock(UrlRepository.class); + AliasResolver aliasResolverMock = Mockito.mock(AliasResolver.class); + UrlCacheService urlCacheServiceMock = Mockito.mock(UrlCacheService.class); + + // Implement the second API + String finalAlias = "my-final-alias"; + String expectedErrorMessage = "No details found for the alias my-final-alias"; + Optional savedUrlDetailsOptional = Optional.empty(); + + // handle mock + Mockito.when(urlRepositoryMock.findByShortUrl(finalAlias)).thenReturn(savedUrlDetailsOptional); + Mockito.when(urlCacheServiceMock.get(finalAlias)).thenReturn(Optional.empty()); + + UrlService urlService = new UrlService(urlRepositoryMock, aliasResolverMock, urlCacheServiceMock); + urlService.appBaseUrl = "http://localhost:8080/"; + ItemNotFoundException itemNotFoundException = Assertions.assertThrows(ItemNotFoundException.class, + () -> urlService.findByAlias(finalAlias)); + + Assertions.assertEquals(expectedErrorMessage, itemNotFoundException.getMessage()); + } + + @Test + void testDelete_aliasExists() { + UrlRepository urlRepositoryMock = Mockito.mock(UrlRepository.class); + AliasResolver aliasResolverMock = Mockito.mock(AliasResolver.class); + UrlCacheService urlCacheServiceMock = Mockito.mock(UrlCacheService.class); + + String alias = "my-final-alias"; + UrlDetails savedUrlDetails = new UrlDetails(1L, "my-full-url", alias); + + Mockito.when(urlRepositoryMock.findByShortUrl(alias)).thenReturn(Optional.of(savedUrlDetails)); + + UrlService urlService = new UrlService(urlRepositoryMock, aliasResolverMock, urlCacheServiceMock); + urlService.appBaseUrl = "http://localhost:8080/"; + urlService.delete(alias); + + Mockito.verify(urlRepositoryMock).deleteById(1L); + Mockito.verify(urlCacheServiceMock).evict(alias); + } + + @Test + void testFindByAlias_whenCacheReadFails_shouldStillUseDatabase() { + UrlRepository urlRepositoryMock = Mockito.mock(UrlRepository.class); + AliasResolver aliasResolverMock = Mockito.mock(AliasResolver.class); + UrlCacheService urlCacheServiceMock = Mockito.mock(UrlCacheService.class); + + String finalAlias = "my-final-alias"; + String actualUrl = "my-full-url"; + UrlDetails savedUrlDetails = new UrlDetails(1L, actualUrl, finalAlias); + + Mockito.when(urlCacheServiceMock.get(finalAlias)).thenThrow(new RuntimeException("Redis down")); + Mockito.when(urlRepositoryMock.findByShortUrl(finalAlias)).thenReturn(Optional.of(savedUrlDetails)); + + UrlService urlService = new UrlService(urlRepositoryMock, aliasResolverMock, urlCacheServiceMock); + urlService.appBaseUrl = "http://localhost:8080/"; + + UrlResponseDTO byAlias = urlService.findByAlias(finalAlias); + + Assertions.assertEquals(actualUrl, byAlias.actualUrl()); + Mockito.verify(urlCacheServiceMock).put(finalAlias, actualUrl); + } + + @Test + void testShortenAndPersist_whenCacheWriteFails_shouldStillPersist() { + UrlRepository urlRepositoryMock = Mockito.mock(UrlRepository.class); + AliasResolver aliasResolverMock = Mockito.mock(AliasResolver.class); + UrlCacheService urlCacheServiceMock = Mockito.mock(UrlCacheService.class); + + String finalAlias = "my-final-alias"; + String actualUrl = "my-full-url"; + UrlRequestDTO urlRequestDTO = new UrlRequestDTO(actualUrl, "alias"); + UrlDetails savedUrlDetails = new UrlDetails(1L, actualUrl, finalAlias); + + Mockito.when(aliasResolverMock.resolveAndGenerate(Mockito.any(UrlRequestDTO.class))).thenReturn(finalAlias); + Mockito.when(urlRepositoryMock.saveAndFlush(Mockito.any(UrlDetails.class))).thenReturn(savedUrlDetails); + Mockito.doThrow(new RuntimeException("Redis down")).when(urlCacheServiceMock).put(finalAlias, actualUrl); + + UrlService urlService = new UrlService(urlRepositoryMock, aliasResolverMock, urlCacheServiceMock); + urlService.appBaseUrl = "http://localhost:8080/"; + + UrlResponseDTO result = urlService.shortenAndPersistURL(urlRequestDTO); + + Assertions.assertEquals("http://localhost:8080/my-final-alias", result.shortUrl()); + Assertions.assertEquals(actualUrl, result.actualUrl()); + } + + @Test + void testDelete_aliasMissing_shouldThrowItemNotFoundException() { + UrlRepository urlRepositoryMock = Mockito.mock(UrlRepository.class); + AliasResolver aliasResolverMock = Mockito.mock(AliasResolver.class); + UrlCacheService urlCacheServiceMock = Mockito.mock(UrlCacheService.class); + + String alias = "missing-alias"; + String expectedErrorMessage = "No details found for the alias missing-alias"; + + Mockito.when(urlRepositoryMock.findByShortUrl(alias)).thenReturn(Optional.empty()); + + UrlService urlService = new UrlService(urlRepositoryMock, aliasResolverMock, urlCacheServiceMock); + urlService.appBaseUrl = "http://localhost:8080/"; + + ItemNotFoundException itemNotFoundException = Assertions.assertThrows(ItemNotFoundException.class, + () -> urlService.delete(alias)); + Assertions.assertEquals(expectedErrorMessage, itemNotFoundException.getMessage()); + Mockito.verify(urlRepositoryMock, Mockito.never()).deleteById(Mockito.anyLong()); + } + + @Test + void testDelete_aliasNull_shouldThrowIllegalParametersException() { + UrlRepository urlRepositoryMock = Mockito.mock(UrlRepository.class); + AliasResolver aliasResolverMock = Mockito.mock(AliasResolver.class); + UrlCacheService urlCacheServiceMock = Mockito.mock(UrlCacheService.class); + + String expectedErrorMessage = "Invalid parameter alias - null"; + + UrlService urlService = new UrlService(urlRepositoryMock, aliasResolverMock, urlCacheServiceMock); + urlService.appBaseUrl = "http://localhost:8080/"; + + IllegalParametersException illegalParametersException = Assertions.assertThrows( + IllegalParametersException.class, + () -> urlService.delete(null)); + + Assertions.assertEquals(expectedErrorMessage, illegalParametersException.getMessage()); + + } + +} \ No newline at end of file diff --git a/backend/src/test/java/com/tpx/urlshort/service/alias/AliasResolverTest.java b/backend/src/test/java/com/tpx/urlshort/service/alias/AliasResolverTest.java new file mode 100644 index 00000000..a59b1ac1 --- /dev/null +++ b/backend/src/test/java/com/tpx/urlshort/service/alias/AliasResolverTest.java @@ -0,0 +1,73 @@ +package com.tpx.urlshort.service.alias; + +import com.tpx.urlshort.dto.UrlRequestDTO; +import com.tpx.urlshort.exception.ConfigMissingException; +import com.tpx.urlshort.exception.IllegalParametersException; +import com.tpx.urlshort.service.alias.snowflake.SnowflakeIdGenerator; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; + +class AliasResolverTest { + + @Test + void testCustomGenerator() { + AliasGenerator customGenerator = new CustomAliasGenerator(); + SnowflakeIdGenerator snowflakeIdGeneratorMock = Mockito.mock(SnowflakeIdGenerator.class); + Base62Encoder base62EncoderMock = Mockito.mock(Base62Encoder.class); + AliasGenerator snowFlakeIdGenerator = new SnowflakeAliasGenerator(snowflakeIdGeneratorMock, base62EncoderMock); + AliasResolver aliasResolver = new AliasResolver(List.of(customGenerator, snowFlakeIdGenerator)); + UrlRequestDTO urlRequestDTO = new UrlRequestDTO("my-long-url", "mlu"); + String finalAlias = aliasResolver.resolveAndGenerate(urlRequestDTO); + Assertions.assertEquals("mlu", finalAlias); + } + + @Test + void testCustomGenerator_when_both_missing() { + AliasResolver aliasResolver = new AliasResolver(List.of()); + UrlRequestDTO urlRequestDTO = new UrlRequestDTO("my-long-url", "mlu"); + String expectedExceptionMessage = "No suitable alias generator found!"; + ConfigMissingException configMissingException = Assertions.assertThrows(ConfigMissingException.class, () -> { + aliasResolver.resolveAndGenerate(urlRequestDTO); + }); + Assertions.assertEquals(expectedExceptionMessage, configMissingException.getMessage()); + } + + @Test + void testCustomGenerator_actual_url_isNull() { + AliasGenerator customGenerator = new CustomAliasGenerator(); + SnowflakeIdGenerator snowflakeIdGeneratorMock = Mockito.mock(SnowflakeIdGenerator.class); + Base62Encoder base62EncoderMock = Mockito.mock(Base62Encoder.class); + AliasGenerator snowFlakeIdGenerator = new SnowflakeAliasGenerator(snowflakeIdGeneratorMock, base62EncoderMock); + AliasResolver aliasResolver = new AliasResolver(List.of(customGenerator, snowFlakeIdGenerator)); + UrlRequestDTO urlRequestDTO = new UrlRequestDTO(null, null); + String expectedExceptionMessage = "Invalid parameters , request or actual url cannot be null or empty"; + IllegalParametersException illegalParametersException = Assertions + .assertThrows(IllegalParametersException.class, () -> { + aliasResolver.resolveAndGenerate(urlRequestDTO); + }); + Assertions.assertEquals(expectedExceptionMessage, illegalParametersException.getMessage()); + } + + @Test + void testCustomGenerator_alias_isEmpty() { + SnowflakeIdGenerator snowflakeIdGeneratorMock = Mockito.mock(SnowflakeIdGenerator.class); + Base62Encoder base62EncoderMock = Mockito.mock(Base62Encoder.class); + AliasGenerator customGenerator = new CustomAliasGenerator(); + AliasGenerator snowFlakeIdGenerator = new SnowflakeAliasGenerator(snowflakeIdGeneratorMock, base62EncoderMock); + long uniqueNumber = 564654654L; + String expectedAlias = "helowld"; + // handle the mock for the snowFlake and the base62 encoder + Mockito.when(snowflakeIdGeneratorMock.getNextId()).thenReturn(uniqueNumber); + Mockito.when(base62EncoderMock.encode(uniqueNumber)).thenReturn(expectedAlias); + + AliasResolver aliasResolver = new AliasResolver(List.of(customGenerator, snowFlakeIdGenerator)); + UrlRequestDTO urlRequestDTO = new UrlRequestDTO("my-long-url", null); + + String finalAlias = aliasResolver.resolveAndGenerate(urlRequestDTO); + Assertions.assertEquals(expectedAlias, finalAlias); + } + +} \ No newline at end of file diff --git a/backend/src/test/java/com/tpx/urlshort/service/alias/Base62EncoderTest.java b/backend/src/test/java/com/tpx/urlshort/service/alias/Base62EncoderTest.java new file mode 100644 index 00000000..b81b24f7 --- /dev/null +++ b/backend/src/test/java/com/tpx/urlshort/service/alias/Base62EncoderTest.java @@ -0,0 +1,53 @@ +package com.tpx.urlshort.service.alias; + +import com.tpx.urlshort.exception.IllegalParametersException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class Base62EncoderTest { + + @Test + void testEncode() { + Base62Encoder base62Encoder = new Base62Encoder(); + String encodeFirst = base62Encoder.encode(123); + String expectedValue = "aaaaa1Z"; + Assertions.assertEquals(expectedValue, encodeFirst); + + // This is expected to be deterministic + String encodeSecond = base62Encoder.encode(123); + Assertions.assertEquals(encodeFirst, encodeSecond); + + // its length must be 7 + Assertions.assertEquals(7, encodeFirst.length()); + Assertions.assertEquals(7, encodeSecond.length()); + } + + @Test + void testEncodeWhenNumber_negative() { + Base62Encoder base62Encoder = new Base62Encoder(); + String expectedError = "Invalid input too less to consider -123"; + IllegalParametersException illegalParametersException = Assertions + .assertThrows(IllegalParametersException.class, () -> base62Encoder.encode(-123)); + Assertions.assertEquals(expectedError, illegalParametersException.getMessage()); + } + + @Test + void testEncode_more_than_upper_threshold() { + Base62Encoder base62Encoder = new Base62Encoder(); + long input = 3521614606208L; + String expectedError = "Value 3521614606208 exceeds maximum capacity for 7 chars ( 3521614606207 )"; + IllegalParametersException illegalParametersException = Assertions + .assertThrows(IllegalParametersException.class, () -> base62Encoder.encode(input)); + Assertions.assertEquals(expectedError, illegalParametersException.getMessage()); + } + + @Test + void testEncode_largeValue_shouldNotOverflowIndex() { + Base62Encoder base62Encoder = new Base62Encoder(); + long input = 3_000_000_000L; + + String encoded = Assertions.assertDoesNotThrow(() -> base62Encoder.encode(input)); + + Assertions.assertEquals(7, encoded.length()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/tpx/urlshort/service/alias/snowflake/SnowflakeIdGeneratorTest.java b/backend/src/test/java/com/tpx/urlshort/service/alias/snowflake/SnowflakeIdGeneratorTest.java new file mode 100644 index 00000000..91e59149 --- /dev/null +++ b/backend/src/test/java/com/tpx/urlshort/service/alias/snowflake/SnowflakeIdGeneratorTest.java @@ -0,0 +1,37 @@ +package com.tpx.urlshort.service.alias.snowflake; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +class SnowflakeIdGeneratorTest { + + private static final long EPOCH_SECONDS = 1767225600L; + + @Test + void testGetCurrentTimestamp() { + SnowflakeIdGenerator snowflakeIdGenerator = new SnowflakeIdGenerator(1L, 1L, EPOCH_SECONDS); + long currentTimestamp = snowflakeIdGenerator.getCurrentTimestamp(); + Assertions.assertTrue(currentTimestamp > 0L); + } + + @Test + void testWaitAndGetNextTimestamp() { + SnowflakeIdGenerator snowflakeIdGenerator = new SnowflakeIdGenerator(1L, 1L, EPOCH_SECONDS); + long epochCurrentSecond = Instant.now().getEpochSecond(); + long newTimestamp = snowflakeIdGenerator.waitAndGetNextTimestamp(epochCurrentSecond); + Assertions.assertTrue(epochCurrentSecond < newTimestamp); + } + + @Test + void testGetNextId(){ + SnowflakeIdGenerator snowflakeIdGenerator = new SnowflakeIdGenerator(1L, 1L, EPOCH_SECONDS); + long first = snowflakeIdGenerator.getNextId(); + long second = snowflakeIdGenerator.getNextId(); + System.out.println(first); + System.out.println(second); + Assertions.assertNotEquals(first, second); + } + +} \ No newline at end of file diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 00000000..66ec27e5 --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,25 @@ +spring: + jpa: + show-sql: true + hibernate: + ddl-auto: 'create-drop' + database-platform: org.hibernate.community.dialect.SQLiteDialect + datasource: + driver-class-name: org.sqlite.JDBC + url: 'jdbc:sqlite::memory:' + data: + redis: + host: localhost + port: 6379 + timeout: 2000ms +app: + base-url: http://localhost:8080/ + cors: + allowed-origins: + - http://localhost:5173 + - http://localhost:5174 + +snowflake: + worker-id: 2 + datacenter-id: 2 + epoch-reference: 1767225600 diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml new file mode 100644 index 00000000..41b7cd9d --- /dev/null +++ b/backend/src/test/resources/application.yml @@ -0,0 +1,19 @@ +spring: + profiles: + active: + - test + + +app: + cache: + ttl-seconds: 600 + cors: + allowed-methods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allowed-headers: + - "*" + allow-credentials: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..90563393 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +services: + # 1. Database/Cache Layer + redis: + image: redis:7-alpine + container_name: spring-redis-cache + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - app-network + + # 2. Backend Layer (Spring Boot) + backend: + build: + context: ./backend # Path to your Spring Boot project folder + dockerfile: Dockerfile + container_name: spring-boot-api + ports: + - "8585:8585" + environment: + - SPRING_DATA_REDIS_HOST=redis + - SPRING_DATA_REDIS_PORT=6379 + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost} + - APP_BASE_URL=${APP_BASE_URL:-http://localhost:8585/api/v1/} + depends_on: + - redis + networks: + - app-network + + # 3. Frontend Layer (React) + frontend: + build: + context: ./frontend # Path to your React project folder + dockerfile: Dockerfile + container_name: react-ui + ports: + - "80:80" + environment: + - VITE_API_PROTOCOL=${VITE_API_PROTOCOL:-http} + - VITE_API_HOST=${VITE_API_HOST:-localhost} + - VITE_API_PORT=${VITE_API_PORT:-8585} + - VITE_API_BASE_URL=${VITE_API_BASE_URL:-} + depends_on: + - backend + networks: + - app-network + +volumes: + redis_data: + +networks: + app-network: + driver: bridge diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..73712cbd --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,7 @@ +# Option 1: split protocol/host/port +VITE_API_PROTOCOL=http +VITE_API_HOST=localhost +VITE_API_PORT=8585 + +# Option 2 (overrides the values above): full API base URL +# VITE_API_BASE_URL=http://localhost:8585 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 00000000..6fa991da --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..99a57542 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +# Stage 1: Build the React application +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +# Stage 2: Serve the production static files using Nginx +FROM nginx:stable-alpine +# Copy the compiled static assets from Stage 1 to Nginx public folder +COPY --from=build /app/dist /usr/share/nginx/html +# Copy custom nginx configuration to handle routing if needed +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY docker-entrypoint-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh +RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..18e05e6e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,80 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +## API configuration + +Frontend API endpoint settings are read from Vite environment variables so backend host/port changes do not require code changes. + +Create a `.env` file in the project root with one of the following options: + +Option 1: Configure host/port separately + +```env +VITE_API_PROTOCOL=http +VITE_API_HOST=localhost +VITE_API_PORT=8585 +``` + +Option 2: Configure complete base URL directly + +```env +VITE_API_BASE_URL=http://localhost:8585 +``` + +`VITE_API_BASE_URL` takes precedence when set. + +Important: for Vite apps this value is baked into static assets at build time. In Docker, if you change these values, rebuild the image and redeploy the container. + +## Docker runtime configuration (no rebuild needed) + +This project also supports runtime API configuration in Docker using `config.js` generated at container startup. + +After building once, you can change API settings by passing container environment variables: + +```bash +docker run -p 3000:80 \ + -e VITE_API_PROTOCOL=http \ + -e VITE_API_HOST=host.docker.internal \ + -e VITE_API_PORT=8585 \ + code-exercise-java-frontend +``` + +Or use a full base URL override: + +```bash +docker run -p 3000:80 \ + -e VITE_API_BASE_URL=http://host.docker.internal:8585 \ + code-exercise-java-frontend +``` + +With this runtime mode, container restarts pick up new values without rebuilding the image. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/docker-entrypoint-runtime-config.sh b/frontend/docker-entrypoint-runtime-config.sh new file mode 100644 index 00000000..6c7f0d69 --- /dev/null +++ b/frontend/docker-entrypoint-runtime-config.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +TEMPLATE_PATH="/usr/share/nginx/html/config.js.template" +OUTPUT_PATH="/usr/share/nginx/html/config.js" + +API_PROTOCOL="${VITE_API_PROTOCOL:-http}" +API_HOST="${VITE_API_HOST:-localhost}" +API_PORT="${VITE_API_PORT:-8585}" +API_BASE_URL="${VITE_API_BASE_URL:-}" + +escape_for_sed() { + printf '%s' "$1" | sed 's/[&|]/\\&/g' +} + +if [ -f "$TEMPLATE_PATH" ]; then + sed \ + -e "s|__API_PROTOCOL__|$(escape_for_sed "$API_PROTOCOL")|g" \ + -e "s|__API_HOST__|$(escape_for_sed "$API_HOST")|g" \ + -e "s|__API_PORT__|$(escape_for_sed "$API_PORT")|g" \ + -e "s|__API_BASE_URL__|$(escape_for_sed "$API_BASE_URL")|g" \ + "$TEMPLATE_PATH" > "$OUTPUT_PATH" +fi diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..911e98ea --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + frontend + + +

+ + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..2b87a60d --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,14 @@ +server { + listen 80; + + location = /config.js { + root /usr/share/nginx/html; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always; + } + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..8ca8e5bb --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4175 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "jsdom": "^26.1.0", + "oxlint": "^1.79.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.2.2", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz", + "integrity": "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz", + "integrity": "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz", + "integrity": "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz", + "integrity": "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz", + "integrity": "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz", + "integrity": "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz", + "integrity": "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz", + "integrity": "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz", + "integrity": "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz", + "integrity": "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz", + "integrity": "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz", + "integrity": "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz", + "integrity": "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz", + "integrity": "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz", + "integrity": "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz", + "integrity": "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz", + "integrity": "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz", + "integrity": "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz", + "integrity": "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/oxlint": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.80.0.tgz", + "integrity": "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.80.0", + "@oxlint/binding-android-arm64": "1.80.0", + "@oxlint/binding-darwin-arm64": "1.80.0", + "@oxlint/binding-darwin-x64": "1.80.0", + "@oxlint/binding-freebsd-x64": "1.80.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.80.0", + "@oxlint/binding-linux-arm-musleabihf": "1.80.0", + "@oxlint/binding-linux-arm64-gnu": "1.80.0", + "@oxlint/binding-linux-arm64-musl": "1.80.0", + "@oxlint/binding-linux-ppc64-gnu": "1.80.0", + "@oxlint/binding-linux-riscv64-gnu": "1.80.0", + "@oxlint/binding-linux-riscv64-musl": "1.80.0", + "@oxlint/binding-linux-s390x-gnu": "1.80.0", + "@oxlint/binding-linux-x64-gnu": "1.80.0", + "@oxlint/binding-linux-x64-musl": "1.80.0", + "@oxlint/binding-openharmony-arm64": "1.80.0", + "@oxlint/binding-win32-arm64-msvc": "1.80.0", + "@oxlint/binding-win32-ia32-msvc": "1.80.0", + "@oxlint/binding-win32-x64-msvc": "1.80.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..28a6053c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "jsdom": "^26.1.0", + "oxlint": "^1.79.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.2.2", + "vitest": "^3.2.4" + } +} diff --git a/frontend/public/config.js b/frontend/public/config.js new file mode 100644 index 00000000..b39992f1 --- /dev/null +++ b/frontend/public/config.js @@ -0,0 +1,6 @@ +window.__APP_CONFIG__ = { + API_PROTOCOL: 'http', + API_HOST: 'localhost', + API_PORT: '8585', + API_BASE_URL: '', +}; diff --git a/frontend/public/config.js.template b/frontend/public/config.js.template new file mode 100644 index 00000000..364df2e8 --- /dev/null +++ b/frontend/public/config.js.template @@ -0,0 +1,6 @@ +window.__APP_CONFIG__ = { + API_PROTOCOL: "__API_PROTOCOL__", + API_HOST: "__API_HOST__", + API_PORT: "__API_PORT__", + API_BASE_URL: "__API_BASE_URL__" +}; diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..7e779386 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import './App.css' +import URLShortner from './components/ULShortner' +import UrlList from './components/UrlList' + +function App() { + const [urlListRefreshKey, setUrlListRefreshKey] = React.useState(0); + const [activeView, setActiveView] = React.useState<'shortener' | 'list'>('shortener'); + + const handleUrlCreated = () => { + setUrlListRefreshKey((prev) => prev + 1); + }; + + return ( +
+ {activeView === 'shortener' ? ( + <> + +
+ +
+ + ) : ( + <> +
+ +
+ + + )} +
+ ) +} + +export default App diff --git a/frontend/src/api/apiCaller.integration.test.ts b/frontend/src/api/apiCaller.integration.test.ts new file mode 100644 index 00000000..183baab5 --- /dev/null +++ b/frontend/src/api/apiCaller.integration.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { shortenUrl, deleteUrl, ApiError } from './apiCaller'; + +describe('apiCaller integration', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('returns URL response for successful shortenUrl call', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response( + JSON.stringify({ + customAlias: 'abc', + fullUrl: 'https://example.com', + shortUrl: 'http://localhost:8081/abc', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + + const response = await shortenUrl({ fullUrl: 'https://example.com', customAlias: 'abc' }); + + expect(response.shortUrl).toBe('http://localhost:8081/abc'); + }); + + it('throws ApiError with ApiErrorResponse details for JSON error body', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response( + JSON.stringify({ + status: 409, + error: 'Conflict', + message: 'Alias already exists', + timestamp: '2026-08-31T00:00:00.000Z', + }), + { + status: 409, + statusText: 'Conflict', + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + + await expect(shortenUrl({ fullUrl: 'https://example.com', customAlias: 'abc' })).rejects.toMatchObject({ + name: 'ApiError', + details: { + status: 409, + error: 'Conflict', + message: 'Alias already exists', + }, + }); + }); + + it('throws fallback ApiError when deleteUrl receives non-JSON error response', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response('Delete failed on server', { + status: 500, + statusText: 'Internal Server Error', + headers: { 'Content-Type': 'text/plain' }, + }), + ); + + try { + await deleteUrl('abc'); + throw new Error('Expected deleteUrl to throw'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.details.status).toBe(500); + expect(apiError.details.error).toBe('Internal Server Error'); + expect(apiError.details.message).toBe('Delete failed on server'); + expect(apiError.details.timestamp.length).toBeGreaterThan(0); + } + }); +}); diff --git a/frontend/src/api/apiCaller.ts b/frontend/src/api/apiCaller.ts new file mode 100644 index 00000000..da59ab95 --- /dev/null +++ b/frontend/src/api/apiCaller.ts @@ -0,0 +1,116 @@ +import type { ApiErrorResponse } from '../types/ApiErrorResponse'; +import type { UrlRequest, UrlResponse, UrlItem } from '../types/url'; +import type { UrlRecord } from '../types/UrlRecord'; +import { API_BASE_URL } from './config'; + +export type PageResponse = { + content: T[]; + totalPages: number; + totalElements: number; + number: number; + size: number; + first: boolean; + last: boolean; + numberOfElements: number; +}; + +export class ApiError extends Error { + details: ApiErrorResponse; + + constructor(details: ApiErrorResponse) { + super(details.message || details.error || 'Request failed'); + this.name = 'ApiError'; + this.details = details; + } +} + +function isApiErrorResponse(value: unknown): value is ApiErrorResponse { + if (!value || typeof value !== 'object') { + return false; + } + + const obj = value as Record; + return ( + typeof obj.status === 'number' && + typeof obj.error === 'string' && + typeof obj.message === 'string' && + typeof obj.timestamp === 'string' + ); +} + +async function parseApiError(response: Response, fallbackMessage: string): Promise { + const text = await response.text().catch(() => ''); + + if (text) { + try { + const body = JSON.parse(text) as unknown; + if (isApiErrorResponse(body)) { + return body; + } + } catch { + // Fall through to plain text error body handling. + } + } + + return { + status: response.status, + error: response.statusText || 'Request failed', + message: text || fallbackMessage, + timestamp: new Date().toISOString(), + }; +} + +async function handleResponse(response: Response, fallbackMessage = 'Request failed'): Promise { + if (!response.ok) { + const errorBody = await parseApiError(response, fallbackMessage); + throw new ApiError(errorBody); + } + + return response.json() as Promise; +} + +export async function shortenUrl(payload: UrlRequest): Promise { + const response = await fetch(`${API_BASE_URL}/api/v1/shorten`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + return handleResponse(response); +} + +export async function getAllUrls(): Promise { + const response = await fetch(`${API_BASE_URL}/api/v1/urls`); + + return handleResponse(response); +} + +export async function getUrlsPage(query: { + page: number; + size: number; + alias?: string; +}): Promise> { + const params = new URLSearchParams({ + page: String(query.page), + size: String(query.size), + }); + + if (query.alias) { + params.set('alias', query.alias); + } + + const response = await fetch(`${API_BASE_URL}/api/v1/urls?${params.toString()}`); + return handleResponse>(response, 'Failed to fetch URLs'); +} + +export async function deleteUrl(alias: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/v1/${encodeURIComponent(alias)}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const errorBody = await parseApiError(response, 'Failed to delete URL'); + throw new ApiError(errorBody); + } +} diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 00000000..742437c6 --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,23 @@ +type RuntimeConfig = { + API_PROTOCOL?: string; + API_HOST?: string; + API_PORT?: string; + API_BASE_URL?: string; +}; + +function getRuntimeConfig(): RuntimeConfig { + const value = (globalThis as { __APP_CONFIG__?: RuntimeConfig }).__APP_CONFIG__; + return value || {}; +} + +const runtime = getRuntimeConfig(); + +const protocol = runtime.API_PROTOCOL || import.meta.env.VITE_API_PROTOCOL || 'http'; +const host = runtime.API_HOST || import.meta.env.VITE_API_HOST || 'localhost'; +const port = runtime.API_PORT || import.meta.env.VITE_API_PORT || '8585'; + +// Runtime API_BASE_URL overrides all; then build-time VITE_API_BASE_URL; then compose from protocol/host/port. +export const API_BASE_URL = + runtime.API_BASE_URL || + import.meta.env.VITE_API_BASE_URL || + `${protocol}://${host}:${port}`; diff --git a/frontend/src/components/ULShortner.tsx b/frontend/src/components/ULShortner.tsx new file mode 100644 index 00000000..d47c1df9 --- /dev/null +++ b/frontend/src/components/ULShortner.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { ApiError, shortenUrl } from '../api/apiCaller'; +import type { ApiErrorResponse } from '../types/ApiErrorResponse'; +import ErrorModal from './error/ErrorModal'; + +type URLShortnerProps = { + onUrlCreated?: () => void; +}; + +const initialForm = { + fullUrl: '', + customAlias: '', +}; + +//for checking the validity of the URL +// as part of input validation before sending the request to the backend +function isValidUrl(value: string) { + try { + new URL(value); + return true; + } catch { + return false; + } +} + + +function URLShortner({ onUrlCreated }: URLShortnerProps) : React.ReactElement { + + const [form, setForm] = React.useState(initialForm); + const [loading, setLoading] = React.useState(false); + const [apiError, setApiError] = React.useState(null); + const [error, setError] = React.useState(''); + const [result, setResult] = React.useState(''); + const [copyMessage, setCopyMessage] = React.useState(''); + + const handleChange = (field: 'fullUrl' | 'customAlias', value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + setApiError(null); + + // get the trimmed URL from the form state + const trimmedUrl = form.fullUrl.trim(); + + //input validation - BOC + //check if the trimmed URL is empty or invalid + if (!trimmedUrl) { + document.getElementById('fullUrl')?.focus(); + setError('Original URL is required.'); + return; + } + + // check if the trimmed URL is valid + if (!isValidUrl(trimmedUrl)) { + setError('Please enter a valid URL.'); + return; + } + + //input validation - EOC + + setLoading(true); + setResult(''); + setCopyMessage(''); + + try { + console.log('Submitting:', { fullUrl: trimmedUrl, customAlias: form.customAlias.trim() || undefined }); + const response = await shortenUrl({ + fullUrl: trimmedUrl, + customAlias: form.customAlias.trim() || undefined, + }); + + if (!response?.shortUrl) { + setError('Invalid response from server'); + return; + } + setResult(`${response.shortUrl}`); + setForm(initialForm); + onUrlCreated?.(); + } catch (err) { + if (err instanceof ApiError) { + setApiError(err.details); + } else { + setError(err instanceof Error ? err.message : 'Something went wrong'); + } + } finally { + setLoading(false); + } + }; + + const copyToClipboard = async () => { + if (!result) return; + try { + await navigator.clipboard.writeText(result); + setCopyMessage('Short URL copied to clipboard.'); + } catch { + setCopyMessage('Unable to copy automatically. Please copy the URL manually.'); + } + }; + + + function handleReset() { + setForm(initialForm); + setError(''); + setResult(''); + setApiError(null); + setCopyMessage(''); + } + + return ( +
+
+
+

Link Utility

+

URL Shortener

+

Paste a long URL and get a compact link you can share quickly.

+
+ +
+ {error && !apiError && ( +

+ {error} +

+ )} + {apiError && ( +
+ setApiError(null)} + /> +
+ )} +
+ +
+
+ + handleChange('fullUrl', e.target.value)} + placeholder="https://example.com" + className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-cyan-500 focus:ring-4 focus:ring-cyan-100" + /> +
+ +
+ + handleChange('customAlias', e.target.value)} + className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-cyan-500 focus:ring-4 focus:ring-cyan-100" + /> +
+ + {result && ( +
+ Short URL: + + + {copyMessage && ( +

{copyMessage}

+ )} +
+ )} + +
+ + +
+ +
+ {loading &&

Loading...

} +
+
+
+
+ ); +} +export default URLShortner; + diff --git a/frontend/src/components/ULShortner.unit.test.tsx b/frontend/src/components/ULShortner.unit.test.tsx new file mode 100644 index 00000000..0f092e0f --- /dev/null +++ b/frontend/src/components/ULShortner.unit.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import URLShortner from './ULShortner'; +import { ApiError, shortenUrl } from '../api/apiCaller'; + +vi.mock('../api/apiCaller', async () => { + const actual = await vi.importActual('../api/apiCaller'); + return { + ...actual, + shortenUrl: vi.fn(), + }; +}); + +describe('URLShortner unit', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows validation error when URL is empty', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Shorten URL' })); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Original URL is required.'); + expect(shortenUrl).not.toHaveBeenCalled(); + }); + + it('shows validation error for invalid URL', async () => { + render(); + + fireEvent.change(screen.getByLabelText('Enter URL to shorten:'), { + target: { value: 'not-a-valid-url' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Shorten URL' })); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Please enter a valid URL.'); + expect(shortenUrl).not.toHaveBeenCalled(); + }); + + it('renders shortened URL on success', async () => { + vi.mocked(shortenUrl).mockResolvedValue({ + customAlias: 'my-alias', + fullUrl: 'https://example.com/path', + shortUrl: 'http://localhost:8081/my-alias', + }); + + render(); + + fireEvent.change(screen.getByLabelText('Enter URL to shorten:'), { + target: { value: 'https://example.com/path' }, + }); + + fireEvent.change(screen.getByLabelText('Custom alias (optional):'), { + target: { value: 'my-alias' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Shorten URL' })); + + await waitFor(() => { + expect(shortenUrl).toHaveBeenCalledWith({ + fullUrl: 'https://example.com/path', + customAlias: 'my-alias', + }); + }); + + const link = await screen.findByRole('link', { name: 'http://localhost:8081/my-alias' }); + expect(link.getAttribute('href')).toBe('http://localhost:8081/my-alias'); + }); + + it('shows ErrorModal when ApiError is thrown and closes it', async () => { + vi.mocked(shortenUrl).mockRejectedValue( + new ApiError({ + status: 409, + error: 'Conflict', + message: 'Alias already exists', + timestamp: '2026-08-31T00:00:00.000Z', + }), + ); + + render(); + + fireEvent.change(screen.getByLabelText('Enter URL to shorten:'), { + target: { value: 'https://example.com/path' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Shorten URL' })); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Alias already exists'); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeNull(); + }); + }); +}); diff --git a/frontend/src/components/UrlList.integration.test.tsx b/frontend/src/components/UrlList.integration.test.tsx new file mode 100644 index 00000000..3aa39769 --- /dev/null +++ b/frontend/src/components/UrlList.integration.test.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import UrlList from './UrlList'; +import type { UrlRecord } from '../types/UrlRecord'; + +type PageResponse = { + content: T[]; + totalPages: number; + totalElements: number; + number: number; + size: number; + first: boolean; + last: boolean; + numberOfElements: number; +}; + +function buildPageResponse(content: UrlRecord[], page = 0, totalPages = 1, last = true): PageResponse { + return { + content, + totalPages, + totalElements: content.length, + number: page, + size: 10, + first: page === 0, + last, + numberOfElements: content.length, + }; +} + +describe('UrlList integration', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('re-fetches data when refreshToken prop changes', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response( + JSON.stringify( + buildPageResponse([ + { + alias: 'one', + shortUrl: 'http://localhost:8081/one', + actualUrl: 'https://example.com/one', + }, + ]), + ), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const { rerender } = render(); + await screen.findByText('one'); + + expect(global.fetch).toHaveBeenCalledTimes(1); + + rerender(); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + }); + + it('fetches with filter and next-page parameters', async () => { + vi.mocked(global.fetch).mockImplementation(async (input) => { + const requestUrl = String(input); + + if (requestUrl.includes('page=1')) { + return new Response( + JSON.stringify( + buildPageResponse( + [ + { + alias: 'app-2', + shortUrl: 'http://localhost:8081/app-2', + actualUrl: 'https://example.com/app-2', + }, + ], + 1, + 2, + true, + ), + ), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response( + JSON.stringify( + buildPageResponse( + [ + { + alias: 'app-1', + shortUrl: 'http://localhost:8081/app-1', + actualUrl: 'https://example.com/app-1', + }, + ], + 0, + 2, + false, + ), + ), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + + render(); + + await screen.findByText('app-1'); + + fireEvent.change(screen.getByPlaceholderText('Filter by alias'), { + target: { value: 'app' }, + }); + + await waitFor(() => { + expect(vi.mocked(global.fetch).mock.calls.some((call) => String(call[0]).includes('alias=app'))).toBe(true); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + await screen.findByText('app-2'); + + expect(vi.mocked(global.fetch).mock.calls.some((call) => String(call[0]).includes('page=1'))).toBe(true); + }); + + it('opens delete dialog and sends delete request on confirmation', async () => { + vi.mocked(global.fetch).mockImplementation(async (input, init) => { + const requestUrl = String(input); + const method = init?.method ?? 'GET'; + + if (method === 'DELETE' && requestUrl.includes('/api/v1/remove-me')) { + return new Response(null, { status: 204 }); + } + + return new Response( + JSON.stringify( + buildPageResponse([ + { + alias: 'remove-me', + shortUrl: 'http://localhost:8081/remove-me', + actualUrl: 'https://example.com/remove-me', + }, + ]), + ), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + + render(); + + await screen.findByText('remove-me'); + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + + const confirm = await screen.findByRole('button', { name: 'Confirm Delete' }); + fireEvent.click(confirm); + + await waitFor(() => { + expect( + vi.mocked(global.fetch).mock.calls.some((call) => { + const method = (call[1] as RequestInit | undefined)?.method; + return method === 'DELETE' && String(call[0]).includes('/api/v1/remove-me'); + }), + ).toBe(true); + }); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull(); + }); + }); +}); diff --git a/frontend/src/components/UrlList.tsx b/frontend/src/components/UrlList.tsx new file mode 100644 index 00000000..60be4187 --- /dev/null +++ b/frontend/src/components/UrlList.tsx @@ -0,0 +1,260 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { ApiError, deleteUrl, getUrlsPage, type PageResponse } from '../api/apiCaller'; +import type { UrlRecord } from '../types/UrlRecord'; + +type UrlListProps = { + refreshToken?: number; +}; + +function getAlias(item: UrlRecord): string { + if (item.alias) return item.alias; + + try { + const url = new URL(item.shortUrl); + return url.pathname.split('/').filter(Boolean).pop() || item.shortUrl; + } catch { + return item.shortUrl; + } +} + +function UrlList({ refreshToken = 0 }: UrlListProps): React.ReactElement { + const [filter, setFilter] = useState(''); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [reloadToken, setReloadToken] = useState(0); + const [pageData, setPageData] = useState>({ + content: [], + totalPages: 0, + totalElements: 0, + number: 0, + size: 10, + first: true, + last: true, + numberOfElements: 0, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); + + useEffect(() => { + const fetchUrls = async () => { + setLoading(true); + setError(null); + + try { + const data = await getUrlsPage({ + page, + size: pageSize, + alias: filter.trim() || undefined, + }); + setPageData(data); + } catch (err) { + if (err instanceof ApiError) { + setError(err.details.message); + } else { + setError(err instanceof Error ? err.message : 'Something went wrong'); + } + } finally { + setLoading(false); + } + }; + + fetchUrls(); + }, [page, pageSize, filter, refreshToken, reloadToken]); + + const filteredUrls = useMemo(() => { + return pageData.content.filter((item) => { + const alias = getAlias(item).toLowerCase(); + return alias.includes(filter.trim().toLowerCase()); + }); + }, [pageData.content, filter]); + + const handleDelete = async () => { + if (!confirmDelete) return; + + const alias = getAlias(confirmDelete); + + try { + await deleteUrl(alias); + + setConfirmDelete(null); + + if (pageData.content.length === 1 && page > 0) { + setPage(page - 1); + } else { + setReloadToken((prev) => prev + 1); + } + } catch (err) { + if (err instanceof ApiError) { + setError(err.details.message); + } else { + setError(err instanceof Error ? err.message : 'Unable to delete'); + } + } + }; + + return ( +
+
+
+
+

History

+

Shortened URLs

+
+

{pageData.totalElements} total records

+
+ +
+ { + setFilter(e.target.value); + setPage(0); + }} + placeholder="Filter by alias" + className="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-cyan-500 focus:ring-4 focus:ring-cyan-100" + /> + + +
+ + {error && ( +

{error}

+ )} + + {loading ? ( +

Loading...

+ ) : ( + <> +
+ + + + + + + + + + + {filteredUrls.length === 0 ? ( + + + + ) : ( + filteredUrls.map((item) => { + const alias = getAlias(item); + + return ( + + + + + + + ); + }) + )} + +
AliasOriginal URLShort URLAction
No records found
{alias} + + {item.actualUrl} + + + + {item.shortUrl} + + + +
+
+ +
+ + Page {pageData.number + 1} of {pageData.totalPages || 1} + + +
+ + + +
+
+ + )} + + {confirmDelete && ( +
+

+ Delete alias {getAlias(confirmDelete)}? +

+
+ + +
+
+ )} +
+
+ ); +} + +export default UrlList; \ No newline at end of file diff --git a/frontend/src/components/UrlList.unit.test.tsx b/frontend/src/components/UrlList.unit.test.tsx new file mode 100644 index 00000000..f504a6a6 --- /dev/null +++ b/frontend/src/components/UrlList.unit.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import UrlList from './UrlList'; +import type { UrlRecord } from '../types/UrlRecord'; + +type PageResponse = { + content: T[]; + totalPages: number; + totalElements: number; + number: number; + size: number; + first: boolean; + last: boolean; + numberOfElements: number; +}; + +function buildPageResponse(content: UrlRecord[]): PageResponse { + return { + content, + totalPages: 1, + totalElements: content.length, + number: 0, + size: 10, + first: true, + last: true, + numberOfElements: content.length, + }; +} + +describe('UrlList unit', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('renders a derived alias when alias field is missing', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response( + JSON.stringify( + buildPageResponse([ + { + shortUrl: 'http://localhost:8081/my-alias', + actualUrl: 'https://example.com/articles/1', + }, + ]), + ), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + render(); + + const aliasCell = await screen.findByText('my-alias'); + expect(aliasCell.textContent).toBe('my-alias'); + expect(screen.getByText('https://example.com/articles/1')).toBeTruthy(); + expect(screen.getByText('http://localhost:8081/my-alias')).toBeTruthy(); + }); + + it('shows empty-state message when no records are returned', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response(JSON.stringify(buildPageResponse([])), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + render(); + + const empty = await screen.findByText('No records found'); + expect(empty.textContent).toContain('No records found'); + }); + + it('shows an error message when fetch fails', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response('server failed', { + status: 500, + statusText: 'Internal Server Error', + }), + ); + + render(); + + const errorText = await screen.findByText('server failed'); + expect(errorText.textContent).toContain('server failed'); + }); +}); diff --git a/frontend/src/components/error/ErrorModal.tsx b/frontend/src/components/error/ErrorModal.tsx new file mode 100644 index 00000000..489c58bb --- /dev/null +++ b/frontend/src/components/error/ErrorModal.tsx @@ -0,0 +1,33 @@ + +import React from "react"; + +type ErrorModalProps = { + title: string; + message: string; + status: number; + error: string; + timestamp: string; + onConfirm: () => void; +}; + +function ErrorModal({ title, message, status, error, timestamp, onConfirm}: ErrorModalProps): React.ReactElement { + console.log('ErrorModal props:', { title, message, status, error, timestamp }); + return ( +
+

{title} - {error}

+

{message}

+

Status: {status} | {new Date(timestamp).toLocaleString()}

+

+ +

+
+ ); +} + +export default ErrorModal; \ No newline at end of file diff --git a/frontend/src/components/todo.txt b/frontend/src/components/todo.txt new file mode 100644 index 00000000..902f1548 --- /dev/null +++ b/frontend/src/components/todo.txt @@ -0,0 +1,10 @@ + +==================================================================== +-> Apply Error handling :: done +-> Write Unit testing for the URLShortner :: done +-> Add the second part of the code + -> The URL display :: done + -> Delete :: done + -> Unit testing +-> Add the CSS file for the styling +==================================================================== diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 00000000..252e3691 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,13 @@ +@import "tailwindcss"; + +@layer base { + body { + margin: 0; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + background: + radial-gradient(circle at 18% 8%, #e7f3ff 0%, transparent 38%), + radial-gradient(circle at 80% 94%, #f4f8e7 0%, transparent 34%), + #f8fafc; + color: #0f172a; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..bef5202a --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 00000000..66eef4d6 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,6 @@ +import { afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +afterEach(() => { + cleanup(); +}); diff --git a/frontend/src/types/ApiErrorResponse.ts b/frontend/src/types/ApiErrorResponse.ts new file mode 100644 index 00000000..9c90089f --- /dev/null +++ b/frontend/src/types/ApiErrorResponse.ts @@ -0,0 +1,8 @@ +type ApiErrorResponse = { + status: number; + error: string; + message: string; + timestamp: string; +}; + +export type { ApiErrorResponse }; \ No newline at end of file diff --git a/frontend/src/types/UrlRecord.ts b/frontend/src/types/UrlRecord.ts new file mode 100644 index 00000000..497614a4 --- /dev/null +++ b/frontend/src/types/UrlRecord.ts @@ -0,0 +1,5 @@ +export type UrlRecord = { + shortUrl: string; + actualUrl: string; + alias?: string; +}; diff --git a/frontend/src/types/url.ts b/frontend/src/types/url.ts new file mode 100644 index 00000000..2c793eac --- /dev/null +++ b/frontend/src/types/url.ts @@ -0,0 +1,18 @@ +export type UrlRequest = { + fullUrl: string; + customAlias?: string; +}; + +export type UrlResponse = { + customAlias: string; + fullUrl: string; + shortUrl: string; + createdAt?: string; +}; + +export type UrlItem = { + customAlias: string; + fullUrl: string; + shortUrl?: string; + createdAt?: string; +}; diff --git a/frontend/src/types/urlApi.ts b/frontend/src/types/urlApi.ts new file mode 100644 index 00000000..f65666c2 --- /dev/null +++ b/frontend/src/types/urlApi.ts @@ -0,0 +1,18 @@ +export type ShortenUrlRequest = { + fullUrl: string; + customAlias?: string; +}; + +export type ShortenUrlResponse = { + customAlias: string; + fullUrl: string; + shortUrl: string; + createdAt?: string; +}; + +export type UrlListItem = { + customAlias: string; + fullUrl: string; + shortUrl?: string; + createdAt?: string; +}; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 00000000..cec28ce8 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 00000000..8455dcbc --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..a3a66285 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..fca0ac0b --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + clearMocks: true, + }, +}); diff --git a/my-apporach.md b/my-apporach.md new file mode 100644 index 00000000..ac7bccbd --- /dev/null +++ b/my-apporach.md @@ -0,0 +1,70 @@ +# URL Shortener System Design + +## 1. Core Workflows + +### 1.1 Short URL Generation (No Alias Provided) + +When a user requests a shortened URL without specifying a custom alias, the system handles the creation automatically: + +- **Client Request:** The user submits a request containing the destination `long_url`. +- **Code Generation:** The system automatically computes a unique, 7-character alphanumeric string. +- **URL Construction:** This 7-character string is appended to the base domain to create the final short URL. +- **Persistence:** The mapping of the generated key, `long_url`, and metadata is saved to the SQLite database. + +### 1.2 Short URL Generation (Custom Alias Provided) + +When a user wants a personalized or branded link, the application performs an explicit verification: + +- **Availability Check:** The application queries the SQLite database to see if the requested alias string already exists. +- **Handling Duplicates:** If the alias is already allocated, the system rejects the request and throws a conflict error. +- **Successful Assignment:** If available, the application reserves the alias, links it to the target `long_url`, and commits the record to the database. + +--- + +## 2. Alphanumeric Code Generation Strategy + +The generation of the unique 7-character string relies on a two-step translation process: + +### Step 1: Unique Numeric ID Generation + +To ensure sequential distribution and eliminate collisions, the application uses a lightweight variant of the **Twitter Snowflake ID** algorithm. + +- It generates a **64-bit unsigned integer** that guarantees global uniqueness without requiring expensive database lookups. + +### Step 2: Base62 Alphanumeric Encoding + +The unique 64-bit integer is converted into a compact, URL-safe string format: + +- **Character Set:** A pre-defined character array containing 62 characters: numbers (`0-9`), lowercase letters (`a-z`), and uppercase letters (`A-Z`). +- **Conversion Math:** The system loops exactly 7 times to enforce a strict **7-character fixed size** string. In each iteration, a modulo 62 operation (`id % 62`) determines the remainder. +- **Array Indexing:** This remainder acts as the exact array index to extract the character, after which the numeric ID is divided by 62 (`id / 62`) for the subsequent iteration step. + +--- + +## 3. Cross-Cutting Concerns + +### 3.1 Global Exception Handling + +- An application-wide exception middleware interceptor captures runtime errors. +- It formats errors gracefully into standardized JSON payloads (e.g., handling validation errors for taken aliases or unexpected database connection losses). + +### 3.2 In-Memory Caching (Dual LRU Strategy) + +To optimize data retrieval times and minimize raw database reads, the system uses two separate **Least Recently Used (LRU)** caches: + +- **Short-to-Long Cache:** Bypasses database lookups during incoming user redirections by mapping `short_url` -> `long_url`. +- **Long-to-Short Cache:** Avoids duplicating work during write operations by immediately resolving `long_url` -> `short_url` if the link was recently processed. + +--- + +## 4. Database Schema + +### Table: `url_details` + +| Column Name | Data Type | Constraints | Notes | +| :------------- | :-------- | :------------------------ | :--------------------------------------------- | +| `id` | INTEGER | PRIMARY KEY | Unique system ID | +| `actual_url` | TEXT | UNIQUE, NOT NULL | The destination web address | +| `shortend_url` | TEXT | UNIQUE, NOT NULL | The generated 7-character code or custom alias | +| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | Automatically set on creation | +| `updated_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | Automatically modified on update |