diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..6a154f15
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+.git
+.user
+**/node_modules
+**/build
+**/compile
+**/coverage
diff --git a/.gitignore b/.gitignore
index a9bbad26..e86a4316 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,6 +59,7 @@ pids
.clinic/
.vscode/
.wrangler/
+.react-router
build/
compile/
dist/
diff --git a/app/Dockerfile b/app/Dockerfile
new file mode 100644
index 00000000..4c70e45c
--- /dev/null
+++ b/app/Dockerfile
@@ -0,0 +1,14 @@
+# The build context is the root of the project
+FROM node:24-alpine
+WORKDIR /dpkit
+
+COPY pnpm-lock.yaml .
+RUN corepack enable pnpm
+RUN pnpm fetch
+
+COPY . .
+RUN pnpm install:ci
+RUN pnpm build
+
+EXPOSE 8080
+CMD ["node", "app/rpc.ts"]
diff --git a/app/assets/about.png b/app/assets/about.png
new file mode 100644
index 00000000..7be21fc3
Binary files /dev/null and b/app/assets/about.png differ
diff --git a/app/assets/logo.svg b/app/assets/logo.svg
new file mode 100644
index 00000000..93b348ed
--- /dev/null
+++ b/app/assets/logo.svg
@@ -0,0 +1,56 @@
+
+
diff --git a/app/components/Alert/Alert.tsx b/app/components/Alert/Alert.tsx
new file mode 100644
index 00000000..066cc96b
--- /dev/null
+++ b/app/components/Alert/Alert.tsx
@@ -0,0 +1,18 @@
+import { Alert as MantineAlert, Text, Title } from "@mantine/core"
+
+export function Alert(props: { title: string; description: string }) {
+ return (
+
+ {props.title}
+
+ }
+ >
+ {props.description}
+
+ )
+}
diff --git a/app/components/Dialog/Dialog.module.css b/app/components/Dialog/Dialog.module.css
new file mode 100644
index 00000000..dfa31fa7
--- /dev/null
+++ b/app/components/Dialog/Dialog.module.css
@@ -0,0 +1,38 @@
+.overlay {
+ position: fixed;
+ background-color: rgba(0, 0, 0, 0.4);
+ inset: 0;
+}
+
+.content {
+ z-index: 100;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 100%;
+ max-height: calc(100% - 80px);
+ background-color: light-dark(white, var(--mantine-color-dark-6));
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ padding: var(--mantine-spacing-lg);
+}
+
+.handle {
+ width: 60px;
+ height: 6px;
+ background-color: var(--mantine-color-gray-4);
+ border-radius: 100px;
+ margin: 0 auto;
+}
+
+.title {
+ font-size: 20px;
+ font-weight: bold;
+ margin-bottom: 16px;
+}
+
+.description {
+ margin-bottom: 16px;
+ height: 100%;
+}
diff --git a/app/components/Dialog/Dialog.tsx b/app/components/Dialog/Dialog.tsx
new file mode 100644
index 00000000..bb160989
--- /dev/null
+++ b/app/components/Dialog/Dialog.tsx
@@ -0,0 +1,59 @@
+import { Box, Button, Container, Flex, ScrollArea } from "@mantine/core"
+import { useEffect, useState } from "react"
+import type { ReactNode } from "react"
+import { useTranslation } from "react-i18next"
+import { Drawer as VaulDrawer } from "vaul"
+import classes from "./Dialog.module.css"
+
+export function Dialog(props: {
+ open?: boolean
+ children: ReactNode
+ fullScreen?: boolean
+ onOpenChange: (open: boolean) => void
+}) {
+ const { t } = useTranslation()
+
+ const snapPoints = [0.5, 1] as const
+ const [snap, setSnap] = useState(snapPoints[0])
+
+ useEffect(() => {
+ setSnap(props.fullScreen ? snapPoints[1] : snapPoints[0])
+ }, [props.fullScreen])
+
+ return (
+
+
+
+
+
+
+
+
+ {props.children}
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/components/Dialog/index.ts b/app/components/Dialog/index.ts
new file mode 100644
index 00000000..07af50b9
--- /dev/null
+++ b/app/components/Dialog/index.ts
@@ -0,0 +1 @@
+export { Dialog } from "./Dialog.tsx"
diff --git a/app/components/Form/Form.tsx b/app/components/Form/Form.tsx
new file mode 100644
index 00000000..a7cb4d9a
--- /dev/null
+++ b/app/components/Form/Form.tsx
@@ -0,0 +1,218 @@
+import { Box, Button, CloseButton, Stack, Tabs } from "@mantine/core"
+import { FileInput, TextInput, Textarea } from "@mantine/core"
+import { isJSONString, isNotEmpty, useForm } from "@mantine/form"
+import { useState } from "react"
+import { useTranslation } from "react-i18next"
+import * as icons from "#icons.ts"
+import * as settings from "#settings.ts"
+
+export interface FormProps {
+ onSubmit: (value: string | File | Record) => void
+}
+
+export function Form(props: FormProps) {
+ const { t } = useTranslation()
+ const [activeTab, setActiveTab] = useState("url")
+
+ return (
+
+
+
+ }
+ >
+ {t("URL")}
+
+
+ }
+ >
+ {t("File")}
+
+
+ }
+ >
+ {t("Text")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function UrlForm(props: { onSubmit: (value: string) => void }) {
+ const { t } = useTranslation()
+ const form = useForm({
+ initialValues: {
+ url: "",
+ },
+ validate: {
+ url: value => {
+ const notEmpty = isNotEmpty(t("URL is required"))(value)
+ if (notEmpty) return notEmpty
+ try {
+ new URL(value)
+ return null
+ } catch {
+ return t("Invalid URL format")
+ }
+ },
+ },
+ })
+
+ const handleSubmit = form.onSubmit(() => {
+ props.onSubmit(form.values.url)
+ })
+
+ return (
+
+ )
+}
+
+function FileForm(props: { onSubmit: (value: File) => void }) {
+ const { t } = useTranslation()
+ const form = useForm({
+ initialValues: {
+ file: null as File | null,
+ },
+ validate: {
+ file: isNotEmpty(t("File is required")),
+ },
+ })
+
+ const handleSubmit = form.onSubmit(async () => {
+ const file = form.values.file
+ if (!file) return
+
+ try {
+ const text = await file.text()
+ const json = JSON.parse(text)
+ props.onSubmit(json)
+ } catch (err) {
+ form.setErrors({ file: t("Invalid JSON file") })
+ }
+ })
+
+ return (
+
+ )
+}
+
+function JsonForm(props: { onSubmit: (value: Record) => void }) {
+ const { t } = useTranslation()
+ const form = useForm({
+ initialValues: {
+ text: "",
+ },
+ validate: {
+ text: value => {
+ const notEmpty = isNotEmpty(t("Text is required"))(value)
+ if (notEmpty) return notEmpty
+ return isJSONString(t("Invalid JSON format"))(value)
+ },
+ },
+ })
+
+ const handleSubmit = form.onSubmit(() => {
+ const parsed = JSON.parse(form.values.text)
+ props.onSubmit(parsed)
+ })
+
+ return (
+
+ )
+}
+
+function SubmitButton(props: { disabled: boolean }) {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
diff --git a/app/components/Form/index.ts b/app/components/Form/index.ts
new file mode 100644
index 00000000..86d916de
--- /dev/null
+++ b/app/components/Form/index.ts
@@ -0,0 +1 @@
+export { Form, type FormProps } from "./Form.tsx"
diff --git a/app/components/Layout/About.module.css b/app/components/Layout/About.module.css
new file mode 100644
index 00000000..e60faa46
--- /dev/null
+++ b/app/components/Layout/About.module.css
@@ -0,0 +1,6 @@
+.imageWrapper {
+ padding: 5px;
+ border-radius: 15px;
+ border: dashed 1px
+ light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-1));
+}
diff --git a/app/components/Layout/About.tsx b/app/components/Layout/About.tsx
new file mode 100644
index 00000000..bbb5960f
--- /dev/null
+++ b/app/components/Layout/About.tsx
@@ -0,0 +1,47 @@
+import { Anchor, Box, Container, Image, Stack, Text } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+import aboutImage from "#assets/about.png"
+import { Link } from "#components/Link/index.ts"
+import classes from "./About.module.css"
+
+export function About() {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ {t("Brought to you by")}
+
+
+
+
+
+
+
+ {t(
+ "We are bringing technological innovation and consultancy services to the open data field",
+ )}
+
+
+
+ )
+}
diff --git a/app/components/Layout/Banner.module.css b/app/components/Layout/Banner.module.css
new file mode 100644
index 00000000..99e5abf8
--- /dev/null
+++ b/app/components/Layout/Banner.module.css
@@ -0,0 +1,8 @@
+.banner {
+ background-color: light-dark(
+ var(--mantine-color-blue-6),
+ var(--mantine-color-blue-7)
+ );
+ color: var(--mantine-color-white);
+ text-align: center;
+}
diff --git a/app/components/Layout/Banner.tsx b/app/components/Layout/Banner.tsx
new file mode 100644
index 00000000..812dcf3a
--- /dev/null
+++ b/app/components/Layout/Banner.tsx
@@ -0,0 +1,41 @@
+import { Anchor, Box, Text } from "@mantine/core"
+import { Container } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+import { Link } from "#components/Link/index.ts"
+import classes from "./Banner.module.css"
+
+export function Banner() {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ {t("Support the project by")}{" "}
+
+ {t("becoming a sponsor")}
+ {" "}
+ {t("or")}{" "}
+
+ {t("adding a star")}
+ {" "}
+ {t("on GitHub!")}
+
+
+
+ )
+}
diff --git a/app/components/Layout/Breadcrumbs.tsx b/app/components/Layout/Breadcrumbs.tsx
new file mode 100644
index 00000000..b6455eae
--- /dev/null
+++ b/app/components/Layout/Breadcrumbs.tsx
@@ -0,0 +1,42 @@
+import { Anchor, Breadcrumbs as MantineBreadcrumbs } from "@mantine/core"
+import { Text } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+import { TypeAnimation } from "react-type-animation"
+import { Link } from "#components/Link/index.ts"
+import { usePayload } from "#components/System/index.ts"
+import { useMakeLink } from "#components/System/index.ts"
+
+export function Breadcrumbs() {
+ const { t } = useTranslation()
+ const payload = usePayload()
+ const makeLink = useMakeLink()
+
+ return (
+
+
+ {t("Tools")}
+
+ {payload.page.pageId === "home" ? (
+
+
+
+ ) : (
+
+ {payload.page.title[payload.language.languageId]}
+
+ )}
+
+ )
+}
diff --git a/app/components/Layout/Content.tsx b/app/components/Layout/Content.tsx
new file mode 100644
index 00000000..8143e5c7
--- /dev/null
+++ b/app/components/Layout/Content.tsx
@@ -0,0 +1,9 @@
+import { Container } from "@mantine/core"
+
+export function Content(props: { children: React.ReactNode }) {
+ return (
+
+ {props.children}
+
+ )
+}
diff --git a/app/components/Layout/Footer.module.css b/app/components/Layout/Footer.module.css
new file mode 100644
index 00000000..6ce38a7e
--- /dev/null
+++ b/app/components/Layout/Footer.module.css
@@ -0,0 +1,11 @@
+.links {
+ font-size: var(--mantine-font-size-sm);
+ border-top: 1px solid
+ light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-9));
+ border-bottom: 1px solid
+ light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-9));
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-6)
+ );
+}
diff --git a/app/components/Layout/Footer.tsx b/app/components/Layout/Footer.tsx
new file mode 100644
index 00000000..07301468
--- /dev/null
+++ b/app/components/Layout/Footer.tsx
@@ -0,0 +1,12 @@
+import { Stack } from "@mantine/core"
+import { About } from "./About.tsx"
+import { Sitemap } from "./Sitemap.tsx"
+
+export function Footer() {
+ return (
+
+
+
+
+ )
+}
diff --git a/app/components/Layout/Header.module.css b/app/components/Layout/Header.module.css
new file mode 100644
index 00000000..11cf5381
--- /dev/null
+++ b/app/components/Layout/Header.module.css
@@ -0,0 +1,10 @@
+.root {
+ height: 64px;
+ position: sticky;
+ border-bottom: 1px solid
+ light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-9));
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-6)
+ );
+}
diff --git a/app/components/Layout/Header.tsx b/app/components/Layout/Header.tsx
new file mode 100644
index 00000000..f1130679
--- /dev/null
+++ b/app/components/Layout/Header.tsx
@@ -0,0 +1,40 @@
+import { Box, Flex } from "@mantine/core"
+import { Breadcrumbs } from "./Breadcrumbs.tsx"
+import classes from "./Header.module.css"
+import { Language } from "./Language.tsx"
+import { Logo } from "./Logo.tsx"
+import { Navigation } from "./Navigation.tsx"
+import { Repository } from "./Repository.tsx"
+import { Share } from "./Share.tsx"
+import { Theme } from "./Theme.tsx"
+
+export function Header() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/components/Layout/Language.module.css b/app/components/Layout/Language.module.css
new file mode 100644
index 00000000..0d9f9ca0
--- /dev/null
+++ b/app/components/Layout/Language.module.css
@@ -0,0 +1,35 @@
+.control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: rem(7px);
+ border-radius: var(--mantine-radius-lg);
+ border: rem(1px) solid
+ light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
+ transition: background-color 150ms ease;
+ background-color: light-dark(
+ var(--mantine-color-white),
+ var(--mantine-color-dark-6)
+ );
+
+ &[data-expanded] {
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-5)
+ );
+ }
+
+ @mixin hover {
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-5)
+ );
+ }
+}
+
+.label {
+ font-weight: 600;
+ font-size: var(--mantine-font-size-sm);
+ margin-right: 0.5em;
+ /*color: light-dark(var(--mantine-color-black), var(--mantine-color-white));*/
+}
diff --git a/app/components/Layout/Language.tsx b/app/components/Layout/Language.tsx
new file mode 100644
index 00000000..b9533102
--- /dev/null
+++ b/app/components/Layout/Language.tsx
@@ -0,0 +1,84 @@
+import { Box, Group, Menu, Tooltip, UnstyledButton } from "@mantine/core"
+import { useState } from "react"
+import { De, Es, Fr, Gb, It, Pt, Ru, Ua } from "react-flags-select"
+import { useTranslation } from "react-i18next"
+import { usePayload } from "#components/System/index.ts"
+import { useMakeLink } from "#components/System/index.ts"
+import { Languages } from "#constants/language.ts"
+import * as icons from "#icons.ts"
+import * as settings from "#settings.ts"
+import type { LanguageId } from "#types/index.ts"
+import classes from "./Language.module.css"
+
+const LANGUAGE_FLAGS = {
+ de: De,
+ en: Gb,
+ es: Es,
+ fr: Fr,
+ it: It,
+ pt: Pt,
+ ru: Ru,
+ uk: Ua,
+} as const
+
+export function Language(props: { fullWidth?: boolean }) {
+ const { t } = useTranslation()
+ const payload = usePayload()
+ const makeLink = useMakeLink()
+
+ const [opened, setOpened] = useState(false)
+
+ const items = Object.values(Languages).map(item => {
+ const Flag = LANGUAGE_FLAGS[item.languageId as LanguageId]
+ return (
+ {
+ onLanguageChange(item.languageId)
+ }}
+ >
+
+
+ {item.title}
+
+
+ )
+ })
+
+ const onLanguageChange = (languageId: LanguageId) => {
+ const location = globalThis.location
+ if (location) {
+ // We intentionally do not use client-side routing here
+ location.href = makeLink({ languageId, pageId: payload.page.pageId })
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/app/components/Layout/Layout.tsx b/app/components/Layout/Layout.tsx
new file mode 100644
index 00000000..ff33a543
--- /dev/null
+++ b/app/components/Layout/Layout.tsx
@@ -0,0 +1,22 @@
+import { Box, Stack } from "@mantine/core"
+import { Banner } from "./Banner.tsx"
+import { Content } from "./Content.tsx"
+import { Footer } from "./Footer.tsx"
+import { Header } from "./Header.tsx"
+import { Meta } from "./Meta.tsx"
+
+export function Layout(props: {
+ children?: React.ReactNode
+}) {
+ return (
+
+
+
+
+
+
+ {props.children}
+
+
+ )
+}
diff --git a/app/components/Layout/Logo.module.css b/app/components/Layout/Logo.module.css
new file mode 100644
index 00000000..40a51ce7
--- /dev/null
+++ b/app/components/Layout/Logo.module.css
@@ -0,0 +1,19 @@
+.title {
+ color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
+}
+
+.loader {
+ animation: rotateAnimation 2s linear infinite;
+}
+
+@keyframes rotateAnimation {
+ 0% {
+ transform: rotate(0deg);
+ /* Initial position */
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ /* Full rotation */
+ }
+}
diff --git a/app/components/Layout/Logo.tsx b/app/components/Layout/Logo.tsx
new file mode 100644
index 00000000..ed6ea753
--- /dev/null
+++ b/app/components/Layout/Logo.tsx
@@ -0,0 +1,53 @@
+import { Anchor, Group, ThemeIcon, Title } from "@mantine/core"
+import { useNavigation } from "react-router"
+// @ts-ignore
+import LogoIcon from "#assets/logo.svg?react"
+import { Link } from "#components/Link/index.ts"
+import { useMakeLink } from "#components/System/index.ts"
+import * as icons from "#icons.ts"
+import classes from "./Logo.module.css"
+
+export function Logo(props: {
+ title?: string
+ Icon?: any
+ to?: string
+}) {
+ const makeLink = useMakeLink()
+ const navigation = useNavigation()
+ const isProgress = navigation?.state === "loading"
+
+ const to = props.to ?? makeLink({ pageId: "home" })
+ const title = props.title ?? "dpkit"
+ const Icon = isProgress ? icons.Pending : props.Icon || LogoIcon
+ const iconClassName = isProgress ? classes.loader : undefined
+
+ const PlainLogo = () => {
+ return (
+
+
+
+
+
+ {title}
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/app/components/Layout/Meta.tsx b/app/components/Layout/Meta.tsx
new file mode 100644
index 00000000..59a25456
--- /dev/null
+++ b/app/components/Layout/Meta.tsx
@@ -0,0 +1,18 @@
+import { usePayload } from "#components/System/index.ts"
+import * as settings from "#settings.ts"
+
+export function Meta() {
+ const { page, languageId } = usePayload()
+
+ const title = [page.title[languageId], settings.TITLE].join(" - ")
+ const description = page.description[languageId]
+
+ return (
+ <>
+ {title}
+
+
+
+ >
+ )
+}
diff --git a/app/components/Layout/Navigation.tsx b/app/components/Layout/Navigation.tsx
new file mode 100644
index 00000000..44862e0f
--- /dev/null
+++ b/app/components/Layout/Navigation.tsx
@@ -0,0 +1,12 @@
+import { Box, Group } from "@mantine/core"
+import { Link } from "#components/Link/index.ts"
+
+export function Navigation() {
+ return (
+
+ Cloud
+ Terminal
+ TypeScript
+
+ )
+}
diff --git a/app/components/Layout/Repository.module.css b/app/components/Layout/Repository.module.css
new file mode 100644
index 00000000..5a186fee
--- /dev/null
+++ b/app/components/Layout/Repository.module.css
@@ -0,0 +1,4 @@
+.icon {
+ width: rem(20px);
+ height: rem(20px);
+}
diff --git a/app/components/Layout/Repository.tsx b/app/components/Layout/Repository.tsx
new file mode 100644
index 00000000..98b49985
--- /dev/null
+++ b/app/components/Layout/Repository.tsx
@@ -0,0 +1,49 @@
+import { Box, Button, Group, Tooltip } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+import { Link } from "#components/Link/index.ts"
+import * as icons from "#icons.ts"
+import * as settings from "#settings.ts"
+import classes from "./Repository.module.css"
+
+export function Repository(props: {
+ fullWidth?: boolean
+}) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ )
+}
diff --git a/app/components/Layout/Share.module.css b/app/components/Layout/Share.module.css
new file mode 100644
index 00000000..f1f8b6e2
--- /dev/null
+++ b/app/components/Layout/Share.module.css
@@ -0,0 +1,34 @@
+.control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: rem(7px);
+ border-radius: var(--mantine-radius-lg);
+ border: rem(1px) solid
+ light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
+ transition: background-color 150ms ease;
+ background-color: light-dark(
+ var(--mantine-color-white),
+ var(--mantine-color-dark-6)
+ );
+
+ &[data-expanded] {
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-5)
+ );
+ }
+
+ @mixin hover {
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-5)
+ );
+ }
+}
+
+.label {
+ font-weight: 600;
+ font-size: var(--mantine-font-size-sm);
+ margin-right: 0.5em;
+}
diff --git a/app/components/Layout/Share.tsx b/app/components/Layout/Share.tsx
new file mode 100644
index 00000000..65d6cd9b
--- /dev/null
+++ b/app/components/Layout/Share.tsx
@@ -0,0 +1,187 @@
+import { Box, Group, Menu, Tooltip, UnstyledButton } from "@mantine/core"
+import { Center } from "@mantine/core"
+import { useState } from "react"
+import { useTranslation } from "react-i18next"
+import * as share from "react-share"
+import * as icons from "#icons.ts"
+import * as settings from "#settings.ts"
+import classes from "./Share.module.css"
+
+export function Share(props: { fullWidth?: boolean }) {
+ const { t } = useTranslation()
+ const [opened, setOpened] = useState(false)
+
+ const currentUrl = globalThis.location?.href || ""
+ const items = SHARE_PROVIDERS.map(provider => {
+ const Component = provider.component
+ return (
+
+
+
+ )
+ })
+
+ return (
+
+ )
+}
+
+function Facebook(props: { url: string }) {
+ return (
+
+
+
+ Facebook
+
+
+ )
+}
+
+function Twitter(props: { url: string }) {
+ return (
+
+
+
+ Twitter
+
+
+ )
+}
+
+function LinkedIn(props: { url: string }) {
+ return (
+
+
+
+ LinkedIn
+
+
+ )
+}
+
+function WhatsApp(props: { url: string }) {
+ return (
+
+
+
+ WhatsApp
+
+
+ )
+}
+
+function Telegram(props: { url: string }) {
+ return (
+
+
+
+ Telegram
+
+
+ )
+}
+
+function Reddit(props: { url: string }) {
+ return (
+
+
+
+ Reddit
+
+
+ )
+}
+
+function Email(props: { url: string }) {
+ return (
+
+
+
+ Email
+
+
+ )
+}
+
+const SHARE_PROVIDERS = [
+ { name: "Facebook", component: Facebook },
+ { name: "Twitter", component: Twitter },
+ { name: "LinkedIn", component: LinkedIn },
+ { name: "WhatsApp", component: WhatsApp },
+ { name: "Telegram", component: Telegram },
+ { name: "Reddit", component: Reddit },
+ { name: "Email", component: Email },
+]
diff --git a/app/components/Layout/Sitemap.tsx b/app/components/Layout/Sitemap.tsx
new file mode 100644
index 00000000..fe0d453c
--- /dev/null
+++ b/app/components/Layout/Sitemap.tsx
@@ -0,0 +1,27 @@
+import { Box, Container, Group } from "@mantine/core"
+import { Link } from "#components/Link/index.ts"
+import { useMakeLink } from "#components/System/index.ts"
+import { usePayload } from "#components/System/index.ts"
+import { Pages } from "#constants/page.ts"
+import classes from "./Footer.module.css"
+
+export function Sitemap() {
+ const makeLink = useMakeLink()
+ const { languageId } = usePayload()
+
+ return (
+
+
+
+ {Object.values(Pages)
+ .filter(page => !!page.Icon)
+ .map(page => (
+
+ {page.title[languageId]}
+
+ ))}
+
+
+
+ )
+}
diff --git a/app/components/Layout/Theme.module.css b/app/components/Layout/Theme.module.css
new file mode 100644
index 00000000..5a186fee
--- /dev/null
+++ b/app/components/Layout/Theme.module.css
@@ -0,0 +1,4 @@
+.icon {
+ width: rem(20px);
+ height: rem(20px);
+}
diff --git a/app/components/Layout/Theme.tsx b/app/components/Layout/Theme.tsx
new file mode 100644
index 00000000..99f2d7d1
--- /dev/null
+++ b/app/components/Layout/Theme.tsx
@@ -0,0 +1,55 @@
+import { Box, Button, Group, Tooltip } from "@mantine/core"
+import { useComputedColorScheme, useMantineColorScheme } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+import * as icons from "#icons.ts"
+import * as settings from "#settings.ts"
+import classes from "./Theme.module.css"
+
+export function Theme(props: {
+ fullWidth?: boolean
+}) {
+ const { t } = useTranslation()
+
+ const { setColorScheme } = useMantineColorScheme()
+ const computedColorScheme = useComputedColorScheme("light", {
+ getInitialValueInEffect: true,
+ })
+
+ const handleToggle = () => {
+ setColorScheme(computedColorScheme === "light" ? "dark" : "light")
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/app/components/Layout/index.ts b/app/components/Layout/index.ts
new file mode 100644
index 00000000..df531019
--- /dev/null
+++ b/app/components/Layout/index.ts
@@ -0,0 +1 @@
+export { Layout } from "./Layout.tsx"
diff --git a/app/components/Link/Link.module.css b/app/components/Link/Link.module.css
new file mode 100644
index 00000000..657b9145
--- /dev/null
+++ b/app/components/Link/Link.module.css
@@ -0,0 +1,3 @@
+.root {
+ color: var(--mantine-color-blue-6);
+}
diff --git a/app/components/Link/Link.tsx b/app/components/Link/Link.tsx
new file mode 100644
index 00000000..cb7db39b
--- /dev/null
+++ b/app/components/Link/Link.tsx
@@ -0,0 +1,34 @@
+import { omit } from "es-toolkit"
+import type { ComponentProps } from "react"
+import { Link as ReactRouterLink } from "react-router"
+import { useMakeLink } from "#components/System/index.ts"
+import classes from "./Link.module.css"
+
+export function Link(
+ props: ComponentProps & {
+ to: string | Parameters>[0]
+ target?: "_blank"
+ },
+) {
+ const makeLink = useMakeLink()
+ const propsWithoutTo = omit(props, ["to"])
+
+ let href = undefined
+ if (typeof props.to === "string") {
+ href = props.to
+ } else if ("pageId" in props.to) {
+ href = makeLink(props.to)
+ } else {
+ throw new Error(`Invalid Link props: ${props}`)
+ }
+
+ return (
+
+ )
+}
diff --git a/app/components/Link/index.ts b/app/components/Link/index.ts
new file mode 100644
index 00000000..f70b331b
--- /dev/null
+++ b/app/components/Link/index.ts
@@ -0,0 +1 @@
+export { Link } from "./Link.tsx"
diff --git a/app/components/Report/Error/Cell.tsx b/app/components/Report/Error/Cell.tsx
new file mode 100644
index 00000000..0cbb5cbb
--- /dev/null
+++ b/app/components/Report/Error/Cell.tsx
@@ -0,0 +1,246 @@
+import type * as errorTypes from "@dpkit/lib"
+import { Code, Text } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+
+export function CellTypeError(props: { error: errorTypes.CellTypeError }) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Value of the cell")}{" "}
+
+ {props.error.cell}
+ {" "}
+ {t("in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("has a wrong type")}
+
+ )
+}
+
+export function CellRequiredError(props: {
+ error: errorTypes.CellRequiredError
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t("A required cell in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("is missing")}
+
+ )
+}
+
+export function CellMinimumError(props: {
+ error: errorTypes.CellMinimumError
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Value of the cell")}{" "}
+
+ {props.error.cell}
+ {" "}
+ {t("in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("is less than minimum")}
+
+ )
+}
+
+export function CellMaximumError(props: {
+ error: errorTypes.CellMaximumError
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Value of the cell")}{" "}
+
+ {props.error.cell}
+ {" "}
+ {t("in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("is more than maximum")}
+
+ )
+}
+
+export function CellExclusiveMinimumError(props: {
+ error: errorTypes.CellExclusiveMinimumError
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Value of the cell")}{" "}
+
+ {props.error.cell}
+ {" "}
+ {t("in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("is less or equal to exclusive minimum")}
+
+ )
+}
+
+export function CellExclusiveMaximumError(props: {
+ error: errorTypes.CellExclusiveMaximumError
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Value of the cell")}{" "}
+
+ {props.error.cell}
+ {" "}
+ {t("in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("is more or equal to exclusive maximum")}
+
+ )
+}
+
+export function CellMinLengthError(props: {
+ error: errorTypes.CellMinLengthError
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Length of the cell")}{" "}
+
+ {props.error.cell}
+ {" "}
+ {t("in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("is less than minimum")}
+
+ )
+}
+
+export function CellMaxLengthError(props: {
+ error: errorTypes.CellMaxLengthError
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Length of the cell")}{" "}
+
+ {props.error.cell}
+ {" "}
+ {t("in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("is more than maximum")}
+
+ )
+}
+
+export function CellPatternError(props: {
+ error: errorTypes.CellPatternError
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Value of the cell")}{" "}
+
+ {props.error.cell}
+ {" "}
+ {t("in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("does not match the pattern")}
+
+ )
+}
+
+export function CellUniqueError(props: { error: errorTypes.CellUniqueError }) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Value of the cell")}{" "}
+
+ {props.error.cell}
+ {" "}
+ {t("in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("is not unique")}
+
+ )
+}
+
+export function CellEnumError(props: { error: errorTypes.CellEnumError }) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Value of the cell")}{" "}
+
+ {props.error.cell}
+ {" "}
+ {t("in field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("of row")}{" "}
+
+ {props.error.rowNumber}
+ {" "}
+ {t("is not in allowed values")}
+
+ )
+}
diff --git a/app/components/Report/Error/Error.tsx b/app/components/Report/Error/Error.tsx
new file mode 100644
index 00000000..db079bd3
--- /dev/null
+++ b/app/components/Report/Error/Error.tsx
@@ -0,0 +1,67 @@
+import type * as errorTypes from "@dpkit/lib"
+import {
+ CellEnumError,
+ CellExclusiveMaximumError,
+ CellExclusiveMinimumError,
+ CellMaxLengthError,
+ CellMaximumError,
+ CellMinLengthError,
+ CellMinimumError,
+ CellPatternError,
+ CellRequiredError,
+ CellTypeError,
+ CellUniqueError,
+} from "./Cell.tsx"
+import { FieldNameError, FieldTypeError } from "./Field.tsx"
+import { FieldsExtraError, FieldsMissingError } from "./Fields.tsx"
+import { BytesError, HashError } from "./File.tsx"
+import { MetadataError } from "./Metadata.tsx"
+import { RowUniqueError } from "./Row.tsx"
+
+export function Error(props: {
+ // TODO: should be lib.Error
+ error: errorTypes.MetadataError | errorTypes.FileError | errorTypes.TableError
+}) {
+ const { error } = props
+
+ switch (error.type) {
+ case "metadata":
+ return
+ case "file/bytes":
+ return
+ case "file/hash":
+ return
+ case "fields/missing":
+ return
+ case "fields/extra":
+ return
+ case "field/name":
+ return
+ case "field/type":
+ return
+ case "row/unique":
+ return
+ case "cell/type":
+ return
+ case "cell/required":
+ return
+ case "cell/minimum":
+ return
+ case "cell/maximum":
+ return
+ case "cell/exclusiveMinimum":
+ return
+ case "cell/exclusiveMaximum":
+ return
+ case "cell/minLength":
+ return
+ case "cell/maxLength":
+ return
+ case "cell/pattern":
+ return
+ case "cell/unique":
+ return
+ case "cell/enum":
+ return
+ }
+}
diff --git a/app/components/Report/Error/Field.tsx b/app/components/Report/Error/Field.tsx
new file mode 100644
index 00000000..d8321342
--- /dev/null
+++ b/app/components/Report/Error/Field.tsx
@@ -0,0 +1,39 @@
+import type * as errorTypes from "@dpkit/lib"
+import { Code, Text } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+
+export function FieldNameError(props: { error: errorTypes.FieldNameError }) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Field name")} {t("is expected to be")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("but it is actually")}{" "}
+
+ {props.error.actualFieldName}
+
+
+ )
+}
+
+export function FieldTypeError(props: { error: errorTypes.FieldTypeError }) {
+ const { t } = useTranslation()
+ return (
+
+ {t("Field")}{" "}
+
+ {props.error.fieldName}
+ {" "}
+ {t("is expected to be")}{" "}
+
+ {props.error.fieldType}
+ {" "}
+ {t("but it is actually")}{" "}
+
+ {props.error.actualFieldType}
+
+
+ )
+}
diff --git a/app/components/Report/Error/Fields.tsx b/app/components/Report/Error/Fields.tsx
new file mode 100644
index 00000000..79f6fb17
--- /dev/null
+++ b/app/components/Report/Error/Fields.tsx
@@ -0,0 +1,33 @@
+import type * as errorTypes from "@dpkit/lib"
+import { Code, Text } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+
+export function FieldsMissingError(props: {
+ error: errorTypes.FieldsMissingError
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t("The fields")}{" "}
+
+ {props.error.fieldNames.join(", ")}
+ {" "}
+ {t("are missing")}
+
+ )
+}
+
+export function FieldsExtraError(props: {
+ error: errorTypes.FieldsExtraError
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t("The fields")}{" "}
+
+ {props.error.fieldNames.join(", ")}
+ {" "}
+ {t("are not expected")}
+
+ )
+}
diff --git a/app/components/Report/Error/File.tsx b/app/components/Report/Error/File.tsx
new file mode 100644
index 00000000..2e907788
--- /dev/null
+++ b/app/components/Report/Error/File.tsx
@@ -0,0 +1,37 @@
+import type * as errorTypes from "@dpkit/lib"
+import { Code, Text } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+
+export function BytesError(props: { error: errorTypes.BytesError }) {
+ const { t } = useTranslation()
+
+ return (
+
+ {t("File size")} {t("is expected to be")}{" "}
+
+ {props.error.bytes} bytes
+ {" "}
+ {t("but it is actually")}{" "}
+
+ {props.error.actualBytes} bytes
+
+
+ )
+}
+
+export function HashError(props: { error: errorTypes.HashError }) {
+ const { t } = useTranslation()
+
+ return (
+
+ {t("File hash")} {t("is expected to be")}{" "}
+
+ {props.error.hash}
+ {" "}
+ {t("but it is actually")}{" "}
+
+ {props.error.actualHash}
+
+
+ )
+}
diff --git a/app/components/Report/Error/Metadata.tsx b/app/components/Report/Error/Metadata.tsx
new file mode 100644
index 00000000..d9e0fb3b
--- /dev/null
+++ b/app/components/Report/Error/Metadata.tsx
@@ -0,0 +1,25 @@
+import type * as errorTypes from "@dpkit/lib"
+import { Code, Text } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+
+export function MetadataError(props: { error: errorTypes.MetadataError }) {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {props.error.keyword}
+
+ {props.error.message && ` ${t(props.error.message as any)}`}
+ {props.error.instancePath && (
+ <>
+ {" "}
+ {t("at")}{" "}
+
+ {props.error.instancePath}
+
+ >
+ )}
+
+ )
+}
diff --git a/app/components/Report/Error/Row.tsx b/app/components/Report/Error/Row.tsx
new file mode 100644
index 00000000..0ee6f372
--- /dev/null
+++ b/app/components/Report/Error/Row.tsx
@@ -0,0 +1,16 @@
+import type * as errorTypes from "@dpkit/lib"
+import { Code, Text } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+
+export function RowUniqueError(props: { error: errorTypes.RowUniqueError }) {
+ const { t } = useTranslation()
+ return (
+
+ {t("The cell values of the fields")}{" "}
+
+ {props.error.fieldNames.join(", ")}
+ {" "}
+ {t("are not unique")}
+
+ )
+}
diff --git a/app/components/Report/Report.tsx b/app/components/Report/Report.tsx
new file mode 100644
index 00000000..5dfed360
--- /dev/null
+++ b/app/components/Report/Report.tsx
@@ -0,0 +1,78 @@
+import type { FileError, MetadataError, TableError } from "@dpkit/lib"
+import { Card, Divider, ScrollArea, Stack, Tabs } from "@mantine/core"
+import { groupBy } from "es-toolkit"
+import { useState } from "react"
+import { useTranslation } from "react-i18next"
+import { objectKeys } from "ts-extras"
+import { Error } from "./Error/Error.tsx"
+
+export function Report(props: {
+ errors?: (MetadataError | FileError | TableError)[]
+}) {
+ const { t } = useTranslation()
+ const { errors } = props
+
+ const errorsByType = {
+ all: errors ?? [],
+ ...groupBy(errors ?? [], error => error.type),
+ }
+
+ const errorTypes = objectKeys(errorsByType)
+ const [selectedType, setSelectedType] = useState(
+ errorTypes?.[0] ?? "all",
+ )
+
+ if (!errors?.length) {
+ return null
+ }
+
+ return (
+ setSelectedType(value ?? "all")}
+ >
+
+
+
+ {errorTypes.map(type => {
+ return (
+
+ {t(type as any)} ({errorsByType[type].length})
+
+ )
+ })}
+
+
+ {errorTypes.map(type => {
+ return (
+
+
+
+ {errorsByType[type].map((error, index) => (
+
+
+
+ ))}
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/app/components/Report/index.ts b/app/components/Report/index.ts
new file mode 100644
index 00000000..8171de19
--- /dev/null
+++ b/app/components/Report/index.ts
@@ -0,0 +1 @@
+export { Report } from "./Report.tsx"
diff --git a/app/components/Status/Status.module.css b/app/components/Status/Status.module.css
new file mode 100644
index 00000000..0bb96497
--- /dev/null
+++ b/app/components/Status/Status.module.css
@@ -0,0 +1,74 @@
+.container {
+ flex-direction: column;
+ gap: 24px;
+
+ @mixin smaller-than $mantine-breakpoint-sm {
+ gap: 12px;
+ margin-top: 20px;
+ margin-bottom: 20px;
+ }
+}
+
+.loaderStarting {
+ animation: spin 1s linear infinite;
+ color: var(--mantine-color-blue-6);
+ width: 80px;
+ height: 80px;
+
+ @mixin smaller-than $mantine-breakpoint-sm {
+ width: 40px;
+ height: 40px;
+ }
+}
+
+.loaderPending {
+ animation: spin 1s linear infinite;
+ color: var(--mantine-color-yellow-6);
+ width: 80px;
+ height: 80px;
+
+ @mixin smaller-than $mantine-breakpoint-sm {
+ width: 40px;
+ height: 40px;
+ }
+}
+
+.iconSuccess {
+ color: var(--mantine-color-green-6);
+ width: 80px;
+ height: 80px;
+
+ @mixin smaller-than $mantine-breakpoint-sm {
+ width: 40px;
+ height: 40px;
+ }
+}
+
+.iconError {
+ color: var(--mantine-color-red-6);
+ width: 80px;
+ height: 80px;
+
+ @mixin smaller-than $mantine-breakpoint-sm {
+ width: 40px;
+ height: 40px;
+ }
+}
+
+.title {
+ font-size: 40px;
+ font-weight: 600;
+
+ @mixin smaller-than $mantine-breakpoint-sm {
+ font-size: 20px;
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/app/components/Status/Status.tsx b/app/components/Status/Status.tsx
new file mode 100644
index 00000000..9a590ed4
--- /dev/null
+++ b/app/components/Status/Status.tsx
@@ -0,0 +1,57 @@
+import { Center, Flex } from "@mantine/core"
+import type { ReactNode } from "react"
+import { useEffect, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { Error, Pending, Starting, Success } from "#icons.ts"
+import classes from "./Status.module.css"
+
+export interface StatusProps {
+ status?: "pending" | "success" | "error"
+ pendingTitle: string
+ successTitle: string
+ errorTitle: string
+}
+
+export function Status(props: StatusProps) {
+ const { t } = useTranslation()
+ const [isStarting, setIsStarting] = useState(true)
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setIsStarting(false)
+ }, 2000)
+ return () => clearTimeout(timer)
+ }, [])
+
+ const status =
+ props.status === "pending" && isStarting ? "starting" : props.status
+
+ const getIcon = (): ReactNode => {
+ if (status === "starting")
+ return
+ if (status === "pending")
+ return
+ if (status === "success")
+ return
+ if (status === "error")
+ return
+ return null
+ }
+
+ const getTitle = (): string => {
+ if (status === "starting") return t("Starting private container...")
+ if (status === "pending") return props.pendingTitle
+ if (status === "success") return props.successTitle
+ if (status === "error") return props.errorTitle
+ return ""
+ }
+
+ return (
+
+
+ {getIcon()}
+ {getTitle()}
+
+
+ )
+}
diff --git a/app/components/Status/index.ts b/app/components/Status/index.ts
new file mode 100644
index 00000000..77b44c68
--- /dev/null
+++ b/app/components/Status/index.ts
@@ -0,0 +1 @@
+export { Status, type StatusProps } from "./Status.tsx"
diff --git a/app/components/System/Error.module.css b/app/components/System/Error.module.css
new file mode 100644
index 00000000..9f4a7cf6
--- /dev/null
+++ b/app/components/System/Error.module.css
@@ -0,0 +1,35 @@
+.container {
+ padding-top: rem(80px);
+ padding-bottom: rem(80px);
+}
+
+.label {
+ text-align: center;
+ font-weight: 900;
+ line-height: 1;
+ font-size: rem(32px);
+ margin-bottom: calc(1.5 * var(--mantine-spacing-xl));
+ color: var(--mantine-color-gray-4);
+
+ @media (min-width: $mantine-breakpoint-sm) {
+ font-size: rem(38px);
+ }
+}
+
+.description {
+ max-width: rem(500px);
+ margin: auto;
+ margin-top: var(--mantine-spacing-xl);
+ margin-bottom: calc(1.5 * var(--mantine-spacing-xl));
+}
+
+.title {
+ font-family: var(--mantine-font-family);
+ text-align: center;
+ font-weight: 900;
+ font-size: rem(32px);
+
+ @media (min-width: $mantine-breakpoint-sm) {
+ font-size: rem(38px);
+ }
+}
diff --git a/app/components/System/Error.tsx b/app/components/System/Error.tsx
new file mode 100644
index 00000000..0063643c
--- /dev/null
+++ b/app/components/System/Error.tsx
@@ -0,0 +1,49 @@
+import { Button, Container, Group, Text, Title } from "@mantine/core"
+import { useLocation } from "react-router"
+import { arrayIncludes } from "ts-extras"
+import { objectKeys } from "ts-extras"
+import { LanguageIdDefault, Languages } from "#constants/language.ts"
+import { makeLink } from "#helpers/link.ts"
+import { i18n } from "#i18n.ts"
+import classes from "./Error.module.css"
+
+export function Error(props: { code: number }) {
+ const { code } = props
+ const location = useLocation()
+
+ const languageId = extractLanguageId(location.pathname)
+ const t = i18n.getFixedT(languageId)
+
+ const title = code === 404 ? t("Page not found") : t("Something went wrong")
+ const text = code === 404 ? t("error404") : t("error500")
+
+ return (
+
+ {code}
+ {title}
+
+ {text}
+
+
+
+
+
+ )
+}
+
+function extractLanguageId(path: string) {
+ const possibleLanguageId = path.split("/")[1]
+
+ const languageId = arrayIncludes(objectKeys(Languages), possibleLanguageId)
+ ? possibleLanguageId
+ : LanguageIdDefault
+
+ return languageId
+}
diff --git a/app/components/System/System.tsx b/app/components/System/System.tsx
new file mode 100644
index 00000000..1c19fc37
--- /dev/null
+++ b/app/components/System/System.tsx
@@ -0,0 +1,48 @@
+import { ColorSchemeScript, MantineProvider } from "@mantine/core"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { omit } from "es-toolkit"
+import { useMemo, useState } from "react"
+import { I18nextProvider } from "react-i18next"
+import { getRevisionStaleTime } from "#helpers/revision.ts"
+import { i18n } from "#i18n.ts"
+import { theme } from "#theme.ts"
+import type * as types from "#types/index.ts"
+import { SystemContext } from "./context.ts"
+
+export function System(props: {
+ payload: types.Payload
+ children: React.ReactNode
+}) {
+ const { payload } = props
+ const [queryClient] = useState(createQueryClient)
+
+ const i18nForLanguage = useMemo(() => {
+ return i18n.cloneInstance({ lng: payload.language.languageId })
+ }, [payload.language.languageId])
+
+ return (
+
+
+
+
+
+ {props.children}
+
+
+
+
+ )
+}
+
+function createQueryClient() {
+ const staleTime = getRevisionStaleTime()
+
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { staleTime, gcTime: staleTime } },
+ })
+
+ // @ts-ignore (enabling devtools)
+ globalThis.__TANSTACK_QUERY_CLIENT__ = queryClient
+
+ return queryClient
+}
diff --git a/app/components/System/context.ts b/app/components/System/context.ts
new file mode 100644
index 00000000..a29a958e
--- /dev/null
+++ b/app/components/System/context.ts
@@ -0,0 +1,9 @@
+import type { ComponentProps } from "react"
+import { createContext } from "#helpers/context.ts"
+import type { System } from "./System.tsx"
+
+export type SystemContext = Omit, "children"> & {
+ // Add your custom properties here
+}
+
+export const [SystemContext, useSystemContext] = createContext()
diff --git a/app/components/System/index.ts b/app/components/System/index.ts
new file mode 100644
index 00000000..588b073d
--- /dev/null
+++ b/app/components/System/index.ts
@@ -0,0 +1,4 @@
+export { System } from "./System.tsx"
+export { Error } from "./Error.tsx"
+export { usePayload } from "./selectors.ts"
+export { useMakeLink } from "./selectors.ts"
diff --git a/app/components/System/selectors.ts b/app/components/System/selectors.ts
new file mode 100644
index 00000000..76edd19b
--- /dev/null
+++ b/app/components/System/selectors.ts
@@ -0,0 +1,26 @@
+import { makeLink } from "#helpers/link.ts"
+import { useSystemContext } from "./context.ts"
+
+export function usePayload() {
+ const { payload } = useSystemContext()
+
+ return {
+ ...payload,
+ languageId: payload.language.languageId,
+ pageId: payload.page.pageId,
+ }
+}
+
+export function useMakeLink() {
+ const payload = usePayload()
+
+ return (options: Partial[0]>) => {
+ const { pageId, ...restOptions } = options
+
+ return makeLink({
+ languageId: payload.language.languageId,
+ pageId: pageId ?? payload.page.pageId,
+ ...restOptions,
+ })
+ }
+}
diff --git a/app/constants/language.ts b/app/constants/language.ts
new file mode 100644
index 00000000..60a3446a
--- /dev/null
+++ b/app/constants/language.ts
@@ -0,0 +1,17 @@
+export const LanguageIdDefault = "en"
+
+export const Languages = {
+ en: { languageId: "en", title: "English" },
+ de: { languageId: "de", title: "Deutsch" },
+ es: { languageId: "es", title: "Español" },
+ fr: { languageId: "fr", title: "Français" },
+ it: { languageId: "it", title: "Italiano" },
+ pt: { languageId: "pt", title: "Português" },
+ ru: { languageId: "ru", title: "Русский" },
+ uk: { languageId: "uk", title: "Українська" },
+} as const satisfies Record
+
+interface AbstractLanguage {
+ languageId: string
+ title: string
+}
diff --git a/app/constants/page.ts b/app/constants/page.ts
new file mode 100644
index 00000000..8720d405
--- /dev/null
+++ b/app/constants/page.ts
@@ -0,0 +1,226 @@
+import { Box, FileSliders, Grid2x2, RefreshCw } from "lucide-react"
+import type { LucideIcon } from "lucide-react"
+import type * as types from "#types/index.ts"
+
+export const PageIdDefault = "home"
+
+export const Pages = {
+ home: {
+ pageId: "home",
+ file: "home/route.tsx",
+ path: undefined,
+ Icon: undefined,
+ color: undefined,
+ title: {
+ en: "Home",
+ de: "Home",
+ es: "Inicio",
+ fr: "Accueil",
+ it: "Home",
+ pt: "Início",
+ ru: "Домашняя страница",
+ uk: "Домашня сторінка",
+ },
+ description: {
+ en: "Home",
+ de: "Home",
+ es: "Inicio",
+ fr: "Accueil",
+ it: "Home",
+ pt: "Início",
+ ru: "Домашняя страница",
+ uk: "Домашня сторінка",
+ },
+ },
+ packageValidate: {
+ pageId: "packageValidate",
+ file: "package/validate/route.tsx",
+ path: {
+ en: "/validate-data-package",
+ de: "/paket-validieren",
+ es: "/validar-paquete",
+ fr: "/valider-paquet",
+ it: "/convalidare-pacchetto",
+ pt: "/validar-pacote",
+ ru: "/proverit-paket",
+ uk: "/pereviryty-paket",
+ },
+ Icon: Box,
+ color: "#40c057",
+ title: {
+ en: "Validate Data Package",
+ de: "Datenpaket validieren",
+ es: "Validar Paquete de Datos",
+ fr: "Valider le paquet de données",
+ it: "Convalidare il pacchetto dati",
+ pt: "Validar Pacote de Dados",
+ ru: "Проверить пакет данных",
+ uk: "Перевірити пакет даних",
+ },
+ description: {
+ en: "Validate data packages quickly and easily. Free online tool for validating data packages and tabular data packages.",
+ de: "Validieren Sie Datenpakete schnell und einfach. Kostenloses Online-Tool zur Validierung von Datenpaketen und tabellarischen Datenpaketen.",
+ es: "Valide paquetes de datos de forma rápida y sencilla. Herramienta online gratuita para validar paquetes de datos y paquetes de datos tabulares.",
+ fr: "Validez des paquets de données rapidement et facilement. Outil en ligne gratuit pour valider les paquets de données et les paquets de données tabulaires.",
+ it: "Convalida pacchetti dati in modo rapido e semplice. Strumento online gratuito per la convalida di pacchetti dati e pacchetti dati tabulari.",
+ pt: "Valide pacotes de dados de forma rápida e fácil. Ferramenta online gratuita para validar pacotes de dados e pacotes de dados tabulares.",
+ ru: "Проверяйте пакеты данных быстро и легко. Бесплатный онлайн-инструмент для проверки пакетов данных и табличных пакетов данных.",
+ uk: "Перевіряйте пакети даних швидко та легко. Безкоштовний онлайн-інструмент для перевірки пакетів даних та табличних пакетів даних.",
+ },
+ },
+ schemaInfer: {
+ pageId: "schemaInfer",
+ file: "schema/infer/route.tsx",
+ path: {
+ en: "/infer-table-schema",
+ de: "/schema-ableiten",
+ es: "/inferir-esquema",
+ fr: "/inferer-schema",
+ it: "/inferire-schema",
+ pt: "/inferir-esquema",
+ ru: "/opredelit-skhemu",
+ uk: "/vyznachyty-skhemu",
+ },
+ Icon: FileSliders,
+ color: "#228be6",
+ title: {
+ en: "Infer Table Schema",
+ de: "Tabellenschema ableiten",
+ es: "Inferir Esquema de Tabla",
+ fr: "Inférer le schéma de table",
+ it: "Inferire schema tabella",
+ pt: "Inferir Esquema de Tabela",
+ ru: "Определить схему таблицы",
+ uk: "Визначити схему таблиці",
+ },
+ description: {
+ en: "Infer table schema quickly and easily. Free online tool for generating table schemas.",
+ de: "Tabellenschema schnell und einfach ableiten. Kostenloses Online-Tool zur Generierung von Tabellenschemas.",
+ es: "Infiera el esquema de tabla de forma rápida y sencilla. Herramienta online gratuita para generar esquemas de tablas.",
+ fr: "Inférez le schéma de table rapidement et facilement. Outil en ligne gratuit pour générer des schémas de tables.",
+ it: "Inferisci lo schema tabella in modo rapido e semplice. Strumento online gratuito per generare schemi di tabelle.",
+ pt: "Infira o esquema de tabela de forma rápida e fácil. Ferramenta online gratuita para gerar esquemas de tabelas.",
+ ru: "Определяйте схему таблицы быстро и легко. Бесплатный онлайн-инструмент для создания схем таблиц.",
+ uk: "Визначайте схему таблиці швидко та легко. Безкоштовний онлайн-інструмент для створення схем таблиць.",
+ },
+ },
+ tableValidate: {
+ pageId: "tableValidate",
+ file: "table/validate/route.tsx",
+ path: {
+ en: "/validate-table-data",
+ de: "/tabelle-validieren",
+ es: "/validar-tabla",
+ fr: "/valider-table",
+ it: "/convalidare-tabella",
+ pt: "/validar-tabela",
+ ru: "/proverit-tablitsu",
+ uk: "/pereviryty-tablitsyu",
+ },
+ Icon: Grid2x2,
+ color: "#fa5252",
+ title: {
+ en: "Validate Table Data",
+ de: "Tabellendaten validieren",
+ es: "Validar Datos de Tabla",
+ fr: "Valider les données de table",
+ it: "Convalidare dati tabella",
+ pt: "Validar Dados de Tabela",
+ ru: "Проверить данные таблицы",
+ uk: "Перевірити дані таблиці",
+ },
+ description: {
+ en: "Validate table data quickly and easily. Free online tool for validating tabular data.",
+ de: "Validieren Sie Tabellendaten schnell und einfach. Kostenloses Online-Tool zur Validierung von Tabellendaten.",
+ es: "Valide datos de tabla de forma rápida y sencilla. Herramienta online gratuita para validar datos tabulares.",
+ fr: "Validez les données de table rapidement et facilement. Outil en ligne gratuit pour valider les données tabulaires.",
+ it: "Convalida dati tabella in modo rapido e semplice. Strumento online gratuito per convalidare dati tabulari.",
+ pt: "Valide dados de tabela de forma rápida e fácil. Ferramenta online gratuita para validar dados tabulares.",
+ ru: "Проверяйте данные таблицы быстро и легко. Бесплатный онлайн-инструмент для проверки табличных данных.",
+ uk: "Перевіряйте дані таблиці швидко та легко. Безкоштовний онлайн-інструмент для перевірки табличних даних.",
+ },
+ },
+ // TODO: update translations
+ tableConvert: {
+ pageId: "tableConvert",
+ file: "table/convert/route.tsx",
+ path: {
+ en: "/convert-table-format",
+ de: "/konvertieren-tabelle",
+ es: "/convertir-tabla",
+ fr: "/convertir-table",
+ it: "/convertire-tabella",
+ pt: "/converter-tabela",
+ ru: "/konvertirovat-tablitsu",
+ uk: "/konvertuvaty-tablitsyu",
+ },
+ Icon: RefreshCw,
+ color: "#fd7e14",
+ title: {
+ en: "Convert Table Format",
+ de: "Tabelle Konvertieren",
+ es: "Convertir Tabla",
+ fr: "Convertir Table",
+ it: "Convertire Tabella",
+ pt: "Converter Tabela",
+ ru: "Конвертировать Таблицу",
+ uk: "Конвертувати Таблицю",
+ },
+ description: {
+ en: "Convert tables between CSV, Excel, JSON, Parquet and other formats quickly and easily. Free online tool for seamless data transformation.",
+ de: "Konvertieren Sie Tabellen schnell und einfach zwischen CSV, Excel, JSON, Parquet und anderen Formaten. Kostenloses Online-Tool für Datenkonvertierung.",
+ es: "Convierte tablas entre CSV, Excel, JSON, Parquet y otros formatos de forma rápida y sencilla. Herramienta online gratuita para transformación de datos.",
+ fr: "Convertissez des tables entre CSV, Excel, JSON, Parquet et autres formats rapidement et facilement. Outil en ligne gratuit pour la transformation de données.",
+ it: "Converti tabelle tra CSV, Excel, JSON, Parquet e altri formati in modo rapido e semplice. Strumento online gratuito per la trasformazione dei dati.",
+ pt: "Converta tabelas entre CSV, Excel, JSON, Parquet e outros formatos de forma rápida e fácil. Ferramenta online gratuita para transformação de dados.",
+ ru: "Конвертируйте таблицы между CSV, Excel, JSON, Parquet и другими форматами быстро и легко. Бесплатный онлайн-инструмент для преобразования данных.",
+ uk: "Конвертуйте таблиці між CSV, Excel, JSON, Parquet та іншими форматами швидко та легко. Безкоштовний онлайн-інструмент для перетворення даних.",
+ },
+ },
+ about: {
+ pageId: "about",
+ file: "about/route.tsx",
+ path: {
+ en: "/about",
+ de: "/ueber",
+ es: "/acerca-de",
+ fr: "/a-propos",
+ it: "/chi-siamo",
+ pt: "/sobre",
+ ru: "/o-nas",
+ uk: "/pro-nas",
+ },
+ Icon: undefined,
+ color: undefined,
+ title: {
+ en: "About",
+ de: "Über",
+ es: "Acerca de",
+ fr: "À propos",
+ it: "Chi siamo",
+ pt: "Sobre",
+ ru: "О нас",
+ uk: "Про нас",
+ },
+ description: {
+ en: "About",
+ de: "Über",
+ es: "Acerca de",
+ fr: "À propos",
+ it: "Chi siamo",
+ pt: "Sobre",
+ ru: "О нас",
+ uk: "Про нас",
+ },
+ },
+} as const satisfies Record
+
+interface AbstractPage {
+ pageId: string
+ file: string
+ path?: Record
+ Icon?: LucideIcon
+ color?: string
+ title: Record
+ description: Record
+}
diff --git a/app/helpers/context.ts b/app/helpers/context.ts
new file mode 100644
index 00000000..977a7544
--- /dev/null
+++ b/app/helpers/context.ts
@@ -0,0 +1,18 @@
+import { createContext as reactCreateContext } from "react"
+import { useContext as reactUseContext } from "react"
+
+export function createContext() {
+ const Context = reactCreateContext(undefined)
+
+ const useContext = () => {
+ const context = reactUseContext(Context)
+
+ if (!context) {
+ throw new Error("Missing context provider")
+ }
+
+ return context
+ }
+
+ return [Context, useContext] as const
+}
diff --git a/app/helpers/link.ts b/app/helpers/link.ts
new file mode 100644
index 00000000..ccca6e31
--- /dev/null
+++ b/app/helpers/link.ts
@@ -0,0 +1,63 @@
+import { href } from "react-router"
+import { Languages } from "#constants/language.ts"
+import { Pages } from "#constants/page.ts"
+import * as settings from "#settings.ts"
+import type * as types from "#types/index.ts"
+
+export function makeLink(options: {
+ languageId: types.LanguageId
+ pageId: types.PageId
+ absolute?: boolean
+ fragment?: string
+}) {
+ const { languageId, pageId, absolute, fragment } = options
+
+ const page = Pages[pageId]
+ const path = href(`/:languageId${page.path?.[languageId] ?? ""}`, {
+ languageId,
+ })
+
+ const url = new URL(path, settings.URL)
+ if (fragment) {
+ url.hash = fragment
+ }
+
+ return absolute ? url.toString() : url.pathname
+}
+
+export function makeHeadLinks(options: {
+ languageId: types.LanguageId
+ pageId: types.PageId
+}) {
+ const canonical = {
+ rel: "canonical",
+ hreflang: undefined,
+ href: makeLink({ ...options, absolute: true }),
+ }
+
+ const xdefault = {
+ rel: "alternate",
+ hreflang: "x-default",
+ href: makeLink({
+ ...options,
+ languageId: "en",
+ absolute: true,
+ }),
+ }
+
+ const alternate = Object.values(Languages).map(language => {
+ return {
+ rel: "alternate",
+ hreflang: language.languageId,
+ href: makeLink({
+ ...options,
+ languageId: language.languageId,
+ absolute: true,
+ }),
+ }
+ })
+
+ return alternate.length > 1
+ ? [canonical, xdefault, ...alternate]
+ : [canonical]
+}
diff --git a/app/helpers/media.ts b/app/helpers/media.ts
new file mode 100644
index 00000000..263aa421
--- /dev/null
+++ b/app/helpers/media.ts
@@ -0,0 +1,6 @@
+import { theme } from "#theme.ts"
+
+export function getIsDesktop() {
+ if (!theme.breakpoints) return false
+ return globalThis.matchMedia(`(min-width: ${theme.breakpoints.md})`).matches
+}
diff --git a/app/helpers/platform.ts b/app/helpers/platform.ts
new file mode 100644
index 00000000..2a4c939b
--- /dev/null
+++ b/app/helpers/platform.ts
@@ -0,0 +1,12 @@
+export function detectPlatform() {
+ if (typeof navigator === "undefined") return "other"
+ const userAgent = navigator.userAgent.toLowerCase()
+ if (
+ userAgent.includes("mac") ||
+ userAgent.includes("iphone") ||
+ userAgent.includes("ipad")
+ ) {
+ return "ios"
+ }
+ return "other"
+}
diff --git a/app/helpers/revision.ts b/app/helpers/revision.ts
new file mode 100644
index 00000000..aefb2d0d
--- /dev/null
+++ b/app/helpers/revision.ts
@@ -0,0 +1,17 @@
+export function getRevisionCacheControl() {
+ return import.meta.env.PROD
+ ? `public, max-age=${getRevisionMaxAge()}`
+ : "private"
+}
+
+export function getRevisionMaxAge() {
+ return Math.floor(getRevisionStaleTime() / 1000) // Convert milliseconds to seconds
+}
+
+export function getRevisionStaleTime() {
+ const now = new Date()
+ const nextHour = new Date(now)
+ nextHour.setMinutes(0, 0, 0) // Reset minutes, seconds, and ms
+ nextHour.setHours(now.getHours() + 1) // Set to the next hour
+ return nextHour.getTime() - now.getTime()
+}
diff --git a/app/helpers/store.ts b/app/helpers/store.ts
new file mode 100644
index 00000000..2b343314
--- /dev/null
+++ b/app/helpers/store.ts
@@ -0,0 +1,10 @@
+import { create } from "zustand"
+import { immer } from "zustand/middleware/immer"
+
+export function createStore(initialState?: T) {
+ const useState = create(immer(() => initialState ?? ({} as T)))
+ const getState = useState.getState
+ const setState = useState.setState
+
+ return { useState, getState, setState }
+}
diff --git a/app/i18n.ts b/app/i18n.ts
new file mode 100644
index 00000000..709d7b30
--- /dev/null
+++ b/app/i18n.ts
@@ -0,0 +1,36 @@
+import i18next from "i18next"
+import { initReactI18next } from "react-i18next"
+import de from "#locales/de.json" with { type: "json" }
+import en from "#locales/en.json" with { type: "json" }
+import es from "#locales/es.json" with { type: "json" }
+import fr from "#locales/fr.json" with { type: "json" }
+import it from "#locales/it.json" with { type: "json" }
+import pt from "#locales/pt.json" with { type: "json" }
+import ru from "#locales/ru.json" with { type: "json" }
+import uk from "#locales/uk.json" with { type: "json" }
+
+export const locales = { de, en, es, fr, it, pt, ru, uk }
+
+export const i18n = i18next.createInstance()
+i18n.use(initReactI18next).init({
+ // debug: true,
+ defaultNS: "main",
+ fallbackLng: "en",
+ resources: {
+ de: { main: de },
+ en: { main: en },
+ es: { main: es },
+ fr: { main: fr },
+ it: { main: it },
+ pt: { main: pt },
+ ru: { main: ru },
+ uk: { main: uk },
+ },
+})
+
+declare module "i18next" {
+ interface CustomTypeOptions {
+ defaultNS: "main"
+ resources: { main: typeof en }
+ }
+}
diff --git a/app/icons.ts b/app/icons.ts
new file mode 100644
index 00000000..c5af56db
--- /dev/null
+++ b/app/icons.ts
@@ -0,0 +1,18 @@
+import { Share2, Upload } from "lucide-react"
+import { detectPlatform } from "#helpers/platform.ts"
+
+export { Sun as LightTheme } from "lucide-react"
+export { Moon as DarkTheme } from "lucide-react"
+export { Loader as Pending } from "lucide-react"
+export { Languages as Language } from "lucide-react"
+export { Github as GitHub } from "lucide-react"
+export { ExternalLink } from "lucide-react"
+export { CheckCircle as Success } from "lucide-react"
+export { XCircle as Error } from "lucide-react"
+export { LoaderCircle as Starting } from "lucide-react"
+export { FileJson as Json } from "lucide-react"
+export { Globe as Url } from "lucide-react"
+export { Upload as File } from "lucide-react"
+export { FileText as Text } from "lucide-react"
+
+export const Share = detectPlatform() === "ios" ? Upload : Share2
diff --git a/app/locales/de.json b/app/locales/de.json
new file mode 100644
index 00000000..ded77482
--- /dev/null
+++ b/app/locales/de.json
@@ -0,0 +1,102 @@
+{
+ "Page not found": "Seite nicht gefunden",
+ "Something went wrong": "Etwas ist schief gelaufen",
+ "Take me back to home page": "Zurück zur Startseite",
+ "error404": "Möglicherweise haben Sie die Adresse falsch eingegeben oder die Seite wurde an eine andere URL verschoben.",
+ "error500": "Etwas ist schief gelaufen. Bitte versuchen Sie es später erneut.",
+ "Change Theme": "Design ändern",
+ "Light": "Hell",
+ "Dark": "Dunkel",
+ "Change Language": "Sprache ändern",
+ "View Repository": "Repository anzeigen",
+ "Share Page": "Seite teilen",
+ "Tools": "Werkzeuge",
+ "Select from the tools below": "Wählen Sie aus den folgenden Werkzeugen",
+ "Validate data package": "Datenpaket validieren",
+ "Validate Data Package": "Datenpaket validieren",
+ "Validate table data": "Tabellendaten validieren",
+ "Infer table schema": "Tabellenschema ableiten",
+ "Convert table format": "Tabellenformat konvertieren",
+ "Select a tool below": "Wählen Sie unten ein Werkzeug",
+ "Validating data package...": "Datenpaket wird validiert...",
+ "Valid data package": "Gültiges Datenpaket",
+ "Invalid data package": "Ungültiges Datenpaket",
+ "Starting private container...": "Privater Container wird gestartet...",
+ "Close": "Schließen",
+ "Value of the cell": "Wert der Zelle",
+ "in field": "im Feld",
+ "of row": "der Zeile",
+ "has a wrong type": "hat einen falschen Typ",
+ "A required cell in field": "Eine erforderliche Zelle im Feld",
+ "is missing": "fehlt",
+ "is less than minimum": "ist kleiner als das Minimum",
+ "is more than maximum": "ist größer als das Maximum",
+ "is less or equal to exclusive minimum": "ist kleiner oder gleich dem exklusiven Minimum",
+ "is more or equal to exclusive maximum": "ist größer oder gleich dem exklusiven Maximum",
+ "Length of the cell": "Länge der Zelle",
+ "does not match the pattern": "stimmt nicht mit dem Muster überein",
+ "is not unique": "ist nicht eindeutig",
+ "is not in allowed values": "ist nicht in den erlaubten Werten",
+ "Field name is expected to be": "Feldname soll sein",
+ "but it is actually": "aber es ist tatsächlich",
+ "Field": "Feld",
+ "is expected to be": "soll sein",
+ "The fields": "Die Felder",
+ "are missing": "fehlen",
+ "are not expected": "werden nicht erwartet",
+ "File size": "Dateigröße",
+ "File hash": "Datei-Hash",
+ "Field name": "Feldname",
+ "at": "bei",
+ "The cell values of the fields": "Die Zellwerte der Felder",
+ "are not unique": "sind nicht eindeutig",
+ "Under Construction": "In Bearbeitung",
+ "This tool is currently under construction and not yet available": "Dieses Werkzeug befindet sich derzeit in der Entwicklung und ist noch nicht verfügbar",
+ "Brought to you by": "Präsentiert von",
+ "We are bringing technological innovation and consultancy services to the open data field": "Wir bringen technologische Innovation und Beratungsdienstleistungen in den Bereich offener Daten",
+ "Support the project by": "Unterstützen Sie das Projekt durch",
+ "becoming a sponsor": "Sponsor werden",
+ "or": "oder",
+ "adding a star": "einen Stern hinzufügen",
+ "on GitHub!": "auf GitHub!",
+ "URL": "URL",
+ "File": "Datei",
+ "Text": "Text",
+ "URL is required": "URL ist erforderlich",
+ "Invalid URL format": "Ungültiges URL-Format",
+ "Enter data package URL": "Datenpaket-URL eingeben",
+ "Data Package URL": "Datenpaket-URL",
+ "File is required": "Datei ist erforderlich",
+ "Invalid JSON file": "Ungültige JSON-Datei",
+ "Select data package file": "Datenpaket-Datei auswählen",
+ "Data Package File": "Datenpaket-Datei",
+ "Text is required": "Text ist erforderlich",
+ "Invalid JSON format": "Ungültiges JSON-Format",
+ "Paste data package JSON": "Datenpaket-JSON einfügen",
+ "Data Package JSON": "Datenpaket-JSON",
+ "dpkit Cloud": "dpkit Cloud",
+ "Free online tools for": "Kostenlose Online-Tools für",
+ "converting and validating data": "Konvertierung und Validierung von Daten",
+ "Unlike others, dpkit Cloud is": "Im Gegensatz zu anderen ist dpkit Cloud",
+ "privacy-first": "datenschutzorientiert",
+ "and completely": "und vollständig",
+ "open source": "Open Source",
+ "allowing you to review the code or": "damit Sie den Code überprüfen oder",
+ "self-host": "selbst hosten",
+ "the service. In 2025, the project was funded by": "den Dienst können. Im Jahr 2025 wurde das Projekt finanziert von",
+ "European Commission": "Europäische Kommission",
+ "Errors": "Fehler",
+ "all": "alle",
+ "metadata": "Metadaten",
+ "about-dpkit-cloud-title": "Über dpkit Cloud",
+ "about-dpkit-cloud-description": "dpkit Cloud bietet kostenlose Online-Tools für die Konvertierung und Validierung von Daten mit Fokus auf Datenschutz. Im Gegensatz zu anderen ist dpkit Cloud kostenlos und vollständig Open Source, sodass Sie den Code überprüfen oder den Dienst selbst hosten können. Im Jahr 2025 wurde das Projekt von der Europäischen Kommission finanziert.",
+ "about-privacy-first-title": "Datenschutzorientiert",
+ "about-privacy-first-description": "dpkit Cloud führt alle Operationen in vollständiger Isolation durch und verwendet nur flüchtige Speicher. Für jede Anfrage wird ein privater Container erstellt und nach Abschluss der Anfrage zerstört. Dies stellt sicher, dass Ihre Daten nicht auf unseren Servern gesammelt werden und nur während der Verarbeitung Ihrer Anfrage für Sie zugänglich sind.",
+ "about-self-hosting-title": "Selbst-Hosting",
+ "about-self-hosting-description": "dpkit Cloud ist vollständig Open Source und kann selbst gehostet werden. Besuchen Sie unser GitHub-Repository, um loszulegen. Für Unterstützung beim Selbst-Hosting oder bei benutzerdefinierten Bereitstellungen kontaktieren Sie uns bitte.",
+ "about-github-repository": "GitHub-Repository",
+ "about-get-in-touch": "kontaktieren Sie uns",
+ "about-description-intro": "dpkit Cloud bietet kostenlose Online-Tools mit Datenschutz-Fokus zum Konvertieren und Validieren von Daten. Im Gegensatz zu anderen ist dpkit Cloud kostenlos und vollständig",
+ "about-self-hosting-intro": "dpkit Cloud ist vollständig Open Source und kann selbst gehostet werden. Besuchen Sie unser",
+ "about-self-hosting-middle": "um zu beginnen. Für Hilfe beim Self-Hosting oder bei benutzerdefinierten Bereitstellungen,"
+}
diff --git a/app/locales/en.json b/app/locales/en.json
new file mode 100644
index 00000000..cbbc40fc
--- /dev/null
+++ b/app/locales/en.json
@@ -0,0 +1,102 @@
+{
+ "Page not found": "Page not found",
+ "Something went wrong": "Something went wrong",
+ "Take me back to home page": "Take me back to home page",
+ "error404": "You may have mistyped the address, or the page has been moved to another URL.",
+ "error500": "Something went wrong. Please try again later.",
+ "Change Theme": "Change Theme",
+ "Light": "Light",
+ "Dark": "Dark",
+ "Change Language": "Change Language",
+ "View Repository": "View Repository",
+ "Share Page": "Share Page",
+ "Tools": "Tools",
+ "Select from the tools below": "Select from the tools below",
+ "Validate data package": "Validate data package",
+ "Validate Data Package": "Validate Data Package",
+ "Validate table data": "Validate table data",
+ "Infer table schema": "Infer table schema",
+ "Convert table format": "Convert table format",
+ "Select a tool below": "Select a tool below",
+ "Validating data package...": "Validating data package...",
+ "Valid data package": "Valid data package",
+ "Invalid data package": "Invalid data package",
+ "Starting private container...": "Starting private container...",
+ "Close": "Close",
+ "Value of the cell": "Value of the cell",
+ "in field": "in field",
+ "of row": "of row",
+ "has a wrong type": "has a wrong type",
+ "A required cell in field": "A required cell in field",
+ "is missing": "is missing",
+ "is less than minimum": "is less than minimum",
+ "is more than maximum": "is more than maximum",
+ "is less or equal to exclusive minimum": "is less or equal to exclusive minimum",
+ "is more or equal to exclusive maximum": "is more or equal to exclusive maximum",
+ "Length of the cell": "Length of the cell",
+ "does not match the pattern": "does not match the pattern",
+ "is not unique": "is not unique",
+ "is not in allowed values": "is not in allowed values",
+ "Field name is expected to be": "Field name is expected to be",
+ "but it is actually": "but it is actually",
+ "Field": "Field",
+ "is expected to be": "is expected to be",
+ "The fields": "The fields",
+ "are missing": "are missing",
+ "are not expected": "are not expected",
+ "File size": "File size",
+ "File hash": "File hash",
+ "Field name": "Field name",
+ "at": "at",
+ "The cell values of the fields": "The cell values of the fields",
+ "are not unique": "are not unique",
+ "Under Construction": "Under Construction",
+ "This tool is currently under construction and not yet available": "This tool is currently under construction and not yet available",
+ "Brought to you by": "Brought to you by",
+ "We are bringing technological innovation and consultancy services to the open data field": "We are bringing technological innovation and consultancy services to the open data field",
+ "Support the project by": "Support the project by",
+ "becoming a sponsor": "becoming a sponsor",
+ "or": "or",
+ "adding a star": "adding a star",
+ "on GitHub!": "on GitHub!",
+ "URL": "URL",
+ "File": "File",
+ "Text": "Text",
+ "URL is required": "URL is required",
+ "Invalid URL format": "Invalid URL format",
+ "Enter data package URL": "Enter data package URL",
+ "Data Package URL": "Data Package URL",
+ "File is required": "File is required",
+ "Invalid JSON file": "Invalid JSON file",
+ "Select data package file": "Select data package file",
+ "Data Package File": "Data Package File",
+ "Text is required": "Text is required",
+ "Invalid JSON format": "Invalid JSON format",
+ "Paste data package JSON": "Paste data package JSON",
+ "Data Package JSON": "Data Package JSON",
+ "dpkit Cloud": "dpkit Cloud",
+ "Free online tools for": "Free online tools for",
+ "converting and validating data": "converting and validating data",
+ "Unlike others, dpkit Cloud is": "Unlike others, dpkit Cloud is",
+ "privacy-first": "privacy-first",
+ "and completely": "and completely",
+ "open source": "open source",
+ "allowing you to review the code or": "allowing you to review the code or",
+ "self-host": "self-host",
+ "the service. In 2025, the project was funded by": "the service. In 2025, the project was funded by",
+ "European Commission": "European Commission",
+ "Errors": "Errors",
+ "all": "all",
+ "metadata": "metadata",
+ "about-dpkit-cloud-title": "About dpkit Cloud",
+ "about-dpkit-cloud-description": "dpkit Cloud provides free online privacy-first tools for converting and validating data. Unlike others, dpkit Cloud is free and completely open source allowing you to review the code or self-host the service. In 2025, the project was funded by European Commission.",
+ "about-privacy-first-title": "Privacy-first",
+ "about-privacy-first-description": "dpkit Cloud performs all the operation in complete isolation using only ephemeral storages. For each request, a private container is created and destroyed after the request is completed. This ensures that your data is not collected on our servers and is only accessible to you during the processing of your request.",
+ "about-self-hosting-title": "Self-hosting",
+ "about-self-hosting-description": "dpkit Cloud is completely open source and can be self-hosted. Visit our GitHub repository to get started. For assistance with self-hosting or custom deployments, please get in touch with us.",
+ "about-github-repository": "GitHub repository",
+ "about-get-in-touch": "get in touch with us",
+ "about-description-intro": "dpkit Cloud provides free online privacy-first tools for converting and validating data. Unlike others, dpkit Cloud is free and completely",
+ "about-self-hosting-intro": "dpkit Cloud is completely open source and can be self-hosted. Visit our",
+ "about-self-hosting-middle": "to get started. For assistance with self-hosting or custom deployments, please"
+}
diff --git a/app/locales/es.json b/app/locales/es.json
new file mode 100644
index 00000000..f278971e
--- /dev/null
+++ b/app/locales/es.json
@@ -0,0 +1,102 @@
+{
+ "Page not found": "Página no encontrada",
+ "Something went wrong": "Algo salió mal",
+ "Take me back to home page": "Volver a la página de inicio",
+ "error404": "Es posible que haya escrito mal la dirección o que la página se haya movido a otra URL.",
+ "error500": "Algo salió mal. Por favor, inténtelo de nuevo más tarde.",
+ "Change Theme": "Cambiar tema",
+ "Light": "Claro",
+ "Dark": "Oscuro",
+ "Change Language": "Cambiar idioma",
+ "View Repository": "Ver repositorio",
+ "Share Page": "Compartir página",
+ "Tools": "Herramientas",
+ "Select from the tools below": "Seleccione de las herramientas a continuación",
+ "Validate data package": "Validar paquete de datos",
+ "Validate Data Package": "Validar paquete de datos",
+ "Validate table data": "Validar datos de tabla",
+ "Infer table schema": "Inferir esquema de tabla",
+ "Convert table format": "Convertir formato de tabla",
+ "Select a tool below": "Seleccione una herramienta a continuación",
+ "Validating data package...": "Validando paquete de datos...",
+ "Valid data package": "Paquete de datos válido",
+ "Invalid data package": "Paquete de datos inválido",
+ "Starting private container...": "Iniciando contenedor privado...",
+ "Close": "Cerrar",
+ "Value of the cell": "Valor de la celda",
+ "in field": "en el campo",
+ "of row": "de la fila",
+ "has a wrong type": "tiene un tipo incorrecto",
+ "A required cell in field": "Una celda requerida en el campo",
+ "is missing": "falta",
+ "is less than minimum": "es menor que el mínimo",
+ "is more than maximum": "es mayor que el máximo",
+ "is less or equal to exclusive minimum": "es menor o igual al mínimo exclusivo",
+ "is more or equal to exclusive maximum": "es mayor o igual al máximo exclusivo",
+ "Length of the cell": "Longitud de la celda",
+ "does not match the pattern": "no coincide con el patrón",
+ "is not unique": "no es único",
+ "is not in allowed values": "no está en los valores permitidos",
+ "Field name is expected to be": "Se espera que el nombre del campo sea",
+ "but it is actually": "pero en realidad es",
+ "Field": "Campo",
+ "is expected to be": "se espera que sea",
+ "The fields": "Los campos",
+ "are missing": "faltan",
+ "are not expected": "no se esperan",
+ "File size": "Tamaño del archivo",
+ "File hash": "Hash del archivo",
+ "Field name": "Nombre del campo",
+ "at": "en",
+ "The cell values of the fields": "Los valores de celda de los campos",
+ "are not unique": "no son únicos",
+ "Under Construction": "En construcción",
+ "This tool is currently under construction and not yet available": "Esta herramienta está actualmente en construcción y aún no está disponible",
+ "Brought to you by": "Presentado por",
+ "We are bringing technological innovation and consultancy services to the open data field": "Llevamos innovación tecnológica y servicios de consultoría al campo de los datos abiertos",
+ "Support the project by": "Apoya el proyecto",
+ "becoming a sponsor": "convirtiéndote en patrocinador",
+ "or": "o",
+ "adding a star": "agregando una estrella",
+ "on GitHub!": "en GitHub!",
+ "URL": "URL",
+ "File": "Archivo",
+ "Text": "Texto",
+ "URL is required": "La URL es obligatoria",
+ "Invalid URL format": "Formato de URL inválido",
+ "Enter data package URL": "Ingrese la URL del paquete de datos",
+ "Data Package URL": "URL del paquete de datos",
+ "File is required": "El archivo es obligatorio",
+ "Invalid JSON file": "Archivo JSON inválido",
+ "Select data package file": "Seleccione el archivo del paquete de datos",
+ "Data Package File": "Archivo del paquete de datos",
+ "Text is required": "El texto es obligatorio",
+ "Invalid JSON format": "Formato JSON inválido",
+ "Paste data package JSON": "Pegue el JSON del paquete de datos",
+ "Data Package JSON": "JSON del paquete de datos",
+ "dpkit Cloud": "dpkit Cloud",
+ "Free online tools for": "Herramientas en línea gratuitas para",
+ "converting and validating data": "convertir y validar datos",
+ "Unlike others, dpkit Cloud is": "A diferencia de otros, dpkit Cloud es",
+ "privacy-first": "centrado en la privacidad",
+ "and completely": "y completamente",
+ "open source": "código abierto",
+ "allowing you to review the code or": "permitiéndole revisar el código o",
+ "self-host": "alojar por su cuenta",
+ "the service. In 2025, the project was funded by": "el servicio. En 2025, el proyecto fue financiado por",
+ "European Commission": "Comisión Europea",
+ "Errors": "Errores",
+ "all": "todos",
+ "metadata": "metadatos",
+ "about-dpkit-cloud-title": "Acerca de dpkit Cloud",
+ "about-dpkit-cloud-description": "dpkit Cloud proporciona herramientas en línea gratuitas centradas en la privacidad para convertir y validar datos. A diferencia de otros, dpkit Cloud es gratuito y completamente de código abierto, lo que le permite revisar el código o alojar el servicio por su cuenta. En 2025, el proyecto fue financiado por la Comisión Europea.",
+ "about-privacy-first-title": "Centrado en la privacidad",
+ "about-privacy-first-description": "dpkit Cloud realiza todas las operaciones en completo aislamiento utilizando únicamente almacenamientos efímeros. Para cada solicitud, se crea un contenedor privado que se destruye después de completar la solicitud. Esto garantiza que sus datos no se recopilen en nuestros servidores y solo sean accesibles para usted durante el procesamiento de su solicitud.",
+ "about-self-hosting-title": "Auto-alojamiento",
+ "about-self-hosting-description": "dpkit Cloud es completamente de código abierto y puede ser auto-alojado. Visite nuestro repositorio de GitHub para comenzar. Para obtener asistencia con el auto-alojamiento o implementaciones personalizadas, por favor póngase en contacto con nosotros.",
+ "about-github-repository": "repositorio de GitHub",
+ "about-get-in-touch": "póngase en contacto con nosotros",
+ "about-description-intro": "dpkit Cloud proporciona herramientas en línea gratuitas centradas en la privacidad para convertir y validar datos. A diferencia de otros, dpkit Cloud es gratuito y completamente",
+ "about-self-hosting-intro": "dpkit Cloud es completamente de código abierto y puede ser auto-alojado. Visite nuestro",
+ "about-self-hosting-middle": "para comenzar. Para obtener ayuda con el auto-alojamiento o implementaciones personalizadas, por favor"
+}
diff --git a/app/locales/fr.json b/app/locales/fr.json
new file mode 100644
index 00000000..097dba86
--- /dev/null
+++ b/app/locales/fr.json
@@ -0,0 +1,102 @@
+{
+ "Page not found": "Page non trouvée",
+ "Something went wrong": "Quelque chose s'est mal passé",
+ "Take me back to home page": "Retour à la page d'accueil",
+ "error404": "Vous avez peut-être mal saisi l'adresse ou la page a été déplacée vers une autre URL.",
+ "error500": "Quelque chose s'est mal passé. Veuillez réessayer plus tard.",
+ "Change Theme": "Changer le thème",
+ "Light": "Clair",
+ "Dark": "Sombre",
+ "Change Language": "Changer la langue",
+ "View Repository": "Voir le dépôt",
+ "Share Page": "Partager la page",
+ "Tools": "Outils",
+ "Select from the tools below": "Sélectionnez parmi les outils ci-dessous",
+ "Validate data package": "Valider le paquet de données",
+ "Validate Data Package": "Valider le paquet de données",
+ "Validate table data": "Valider les données de table",
+ "Infer table schema": "Déduire le schéma de table",
+ "Convert table format": "Convertir le format de table",
+ "Select a tool below": "Sélectionnez un outil ci-dessous",
+ "Validating data package...": "Validation du paquet de données...",
+ "Valid data package": "Paquet de données valide",
+ "Invalid data package": "Paquet de données invalide",
+ "Starting private container...": "Démarrage du conteneur privé...",
+ "Close": "Fermer",
+ "Value of the cell": "Valeur de la cellule",
+ "in field": "dans le champ",
+ "of row": "de la ligne",
+ "has a wrong type": "a un type incorrect",
+ "A required cell in field": "Une cellule requise dans le champ",
+ "is missing": "est manquante",
+ "is less than minimum": "est inférieur au minimum",
+ "is more than maximum": "est supérieur au maximum",
+ "is less or equal to exclusive minimum": "est inférieur ou égal au minimum exclusif",
+ "is more or equal to exclusive maximum": "est supérieur ou égal au maximum exclusif",
+ "Length of the cell": "Longueur de la cellule",
+ "does not match the pattern": "ne correspond pas au modèle",
+ "is not unique": "n'est pas unique",
+ "is not in allowed values": "n'est pas dans les valeurs autorisées",
+ "Field name is expected to be": "Le nom du champ devrait être",
+ "but it is actually": "mais il est en fait",
+ "Field": "Champ",
+ "is expected to be": "devrait être",
+ "The fields": "Les champs",
+ "are missing": "sont manquants",
+ "are not expected": "ne sont pas attendus",
+ "File size": "Taille du fichier",
+ "File hash": "Hash du fichier",
+ "Field name": "Nom du champ",
+ "at": "à",
+ "The cell values of the fields": "Les valeurs de cellule des champs",
+ "are not unique": "ne sont pas uniques",
+ "Under Construction": "En construction",
+ "This tool is currently under construction and not yet available": "Cet outil est actuellement en construction et n'est pas encore disponible",
+ "Brought to you by": "Proposé par",
+ "We are bringing technological innovation and consultancy services to the open data field": "Nous apportons l'innovation technologique et les services de conseil dans le domaine des données ouvertes",
+ "Support the project by": "Soutenez le projet en",
+ "becoming a sponsor": "devenant sponsor",
+ "or": "ou",
+ "adding a star": "ajoutant une étoile",
+ "on GitHub!": "sur GitHub!",
+ "URL": "URL",
+ "File": "Fichier",
+ "Text": "Texte",
+ "URL is required": "L'URL est requise",
+ "Invalid URL format": "Format d'URL invalide",
+ "Enter data package URL": "Entrez l'URL du paquet de données",
+ "Data Package URL": "URL du paquet de données",
+ "File is required": "Le fichier est requis",
+ "Invalid JSON file": "Fichier JSON invalide",
+ "Select data package file": "Sélectionnez le fichier du paquet de données",
+ "Data Package File": "Fichier du paquet de données",
+ "Text is required": "Le texte est requis",
+ "Invalid JSON format": "Format JSON invalide",
+ "Paste data package JSON": "Collez le JSON du paquet de données",
+ "Data Package JSON": "JSON du paquet de données",
+ "dpkit Cloud": "dpkit Cloud",
+ "Free online tools for": "Outils en ligne gratuits pour",
+ "converting and validating data": "convertir et valider les données",
+ "Unlike others, dpkit Cloud is": "Contrairement aux autres, dpkit Cloud est",
+ "privacy-first": "axé sur la confidentialité",
+ "and completely": "et entièrement",
+ "open source": "open source",
+ "allowing you to review the code or": "vous permettant de consulter le code ou",
+ "self-host": "auto-héberger",
+ "the service. In 2025, the project was funded by": "le service. En 2025, le projet a été financé par",
+ "European Commission": "Commission européenne",
+ "Errors": "Erreurs",
+ "all": "tous",
+ "metadata": "métadonnées",
+ "about-dpkit-cloud-title": "À propos de dpkit Cloud",
+ "about-dpkit-cloud-description": "dpkit Cloud fournit des outils en ligne gratuits axés sur la confidentialité pour convertir et valider les données. Contrairement aux autres, dpkit Cloud est gratuit et entièrement open source, vous permettant de consulter le code ou d'auto-héberger le service. En 2025, le projet a été financé par la Commission européenne.",
+ "about-privacy-first-title": "Confidentialité d'abord",
+ "about-privacy-first-description": "dpkit Cloud effectue toutes les opérations en isolation complète en utilisant uniquement des stockages éphémères. Pour chaque requête, un conteneur privé est créé et détruit après la fin de la requête. Cela garantit que vos données ne sont pas collectées sur nos serveurs et ne sont accessibles que par vous pendant le traitement de votre requête.",
+ "about-self-hosting-title": "Auto-hébergement",
+ "about-self-hosting-description": "dpkit Cloud est entièrement open source et peut être auto-hébergé. Visitez notre dépôt GitHub pour commencer. Pour obtenir de l'aide avec l'auto-hébergement ou des déploiements personnalisés, veuillez nous contacter.",
+ "about-github-repository": "dépôt GitHub",
+ "about-get-in-touch": "contactez-nous",
+ "about-description-intro": "dpkit Cloud fournit des outils en ligne gratuits axés sur la confidentialité pour convertir et valider les données. Contrairement aux autres, dpkit Cloud est gratuit et entièrement",
+ "about-self-hosting-intro": "dpkit Cloud est entièrement open source et peut être auto-hébergé. Visitez notre",
+ "about-self-hosting-middle": "pour commencer. Pour obtenir de l'aide avec l'auto-hébergement ou des déploiements personnalisés, veuillez"
+}
diff --git a/app/locales/it.json b/app/locales/it.json
new file mode 100644
index 00000000..718b8839
--- /dev/null
+++ b/app/locales/it.json
@@ -0,0 +1,102 @@
+{
+ "Page not found": "Pagina non trovata",
+ "Something went wrong": "Qualcosa è andato storto",
+ "Take me back to home page": "Torna alla home page",
+ "error404": "Potresti aver digitato male l'indirizzo o la pagina è stata spostata a un altro URL.",
+ "error500": "Qualcosa è andato storto. Per favore riprova più tardi.",
+ "Change Theme": "Cambia tema",
+ "Light": "Chiaro",
+ "Dark": "Scuro",
+ "Change Language": "Cambia lingua",
+ "View Repository": "Visualizza repository",
+ "Share Page": "Condividi pagina",
+ "Tools": "Strumenti",
+ "Select from the tools below": "Seleziona dagli strumenti sottostanti",
+ "Validate data package": "Valida pacchetto dati",
+ "Validate Data Package": "Valida pacchetto dati",
+ "Validate table data": "Valida dati tabella",
+ "Infer table schema": "Deduci schema tabella",
+ "Convert table format": "Converti formato tabella",
+ "Select a tool below": "Seleziona uno strumento sottostante",
+ "Validating data package...": "Validazione pacchetto dati...",
+ "Valid data package": "Pacchetto dati valido",
+ "Invalid data package": "Pacchetto dati non valido",
+ "Starting private container...": "Avvio container privato...",
+ "Close": "Chiudi",
+ "Value of the cell": "Valore della cella",
+ "in field": "nel campo",
+ "of row": "della riga",
+ "has a wrong type": "ha un tipo errato",
+ "A required cell in field": "Una cella richiesta nel campo",
+ "is missing": "manca",
+ "is less than minimum": "è inferiore al minimo",
+ "is more than maximum": "è superiore al massimo",
+ "is less or equal to exclusive minimum": "è inferiore o uguale al minimo esclusivo",
+ "is more or equal to exclusive maximum": "è superiore o uguale al massimo esclusivo",
+ "Length of the cell": "Lunghezza della cella",
+ "does not match the pattern": "non corrisponde al pattern",
+ "is not unique": "non è univoco",
+ "is not in allowed values": "non è tra i valori consentiti",
+ "Field name is expected to be": "Il nome del campo dovrebbe essere",
+ "but it is actually": "ma in realtà è",
+ "Field": "Campo",
+ "is expected to be": "dovrebbe essere",
+ "The fields": "I campi",
+ "are missing": "mancano",
+ "are not expected": "non sono previsti",
+ "File size": "Dimensione file",
+ "File hash": "Hash file",
+ "Field name": "Nome campo",
+ "at": "a",
+ "The cell values of the fields": "I valori delle celle dei campi",
+ "are not unique": "non sono univoci",
+ "Under Construction": "In costruzione",
+ "This tool is currently under construction and not yet available": "Questo strumento è attualmente in costruzione e non ancora disponibile",
+ "Brought to you by": "Offerto da",
+ "We are bringing technological innovation and consultancy services to the open data field": "Portiamo innovazione tecnologica e servizi di consulenza nel campo dei dati aperti",
+ "Support the project by": "Sostieni il progetto",
+ "becoming a sponsor": "diventando sponsor",
+ "or": "o",
+ "adding a star": "aggiungendo una stella",
+ "on GitHub!": "su GitHub!",
+ "URL": "URL",
+ "File": "File",
+ "Text": "Testo",
+ "URL is required": "L'URL è obbligatorio",
+ "Invalid URL format": "Formato URL non valido",
+ "Enter data package URL": "Inserisci l'URL del pacchetto dati",
+ "Data Package URL": "URL del pacchetto dati",
+ "File is required": "Il file è obbligatorio",
+ "Invalid JSON file": "File JSON non valido",
+ "Select data package file": "Seleziona il file del pacchetto dati",
+ "Data Package File": "File del pacchetto dati",
+ "Text is required": "Il testo è obbligatorio",
+ "Invalid JSON format": "Formato JSON non valido",
+ "Paste data package JSON": "Incolla il JSON del pacchetto dati",
+ "Data Package JSON": "JSON del pacchetto dati",
+ "dpkit Cloud": "dpkit Cloud",
+ "Free online tools for": "Strumenti online gratuiti per",
+ "converting and validating data": "convertire e validare dati",
+ "Unlike others, dpkit Cloud is": "A differenza di altri, dpkit Cloud è",
+ "privacy-first": "orientato alla privacy",
+ "and completely": "e completamente",
+ "open source": "open source",
+ "allowing you to review the code or": "permettendoti di rivedere il codice o",
+ "self-host": "auto-ospitare",
+ "the service. In 2025, the project was funded by": "il servizio. Nel 2025, il progetto è stato finanziato da",
+ "European Commission": "Commissione europea",
+ "Errors": "Errori",
+ "all": "tutti",
+ "metadata": "metadati",
+ "about-dpkit-cloud-title": "Informazioni su dpkit Cloud",
+ "about-dpkit-cloud-description": "dpkit Cloud fornisce strumenti online gratuiti orientati alla privacy per convertire e validare dati. A differenza di altri, dpkit Cloud è gratuito e completamente open source, permettendoti di rivedere il codice o auto-ospitare il servizio. Nel 2025, il progetto è stato finanziato dalla Commissione europea.",
+ "about-privacy-first-title": "Orientato alla privacy",
+ "about-privacy-first-description": "dpkit Cloud esegue tutte le operazioni in completo isolamento utilizzando solo archiviazioni effimere. Per ogni richiesta, viene creato un contenitore privato che viene distrutto dopo il completamento della richiesta. Questo garantisce che i tuoi dati non vengano raccolti sui nostri server e siano accessibili solo a te durante l'elaborazione della tua richiesta.",
+ "about-self-hosting-title": "Auto-ospitare",
+ "about-self-hosting-description": "dpkit Cloud è completamente open source e può essere auto-ospitato. Visita il nostro repository GitHub per iniziare. Per assistenza con l'auto-ospitare o implementazioni personalizzate, per favore contattaci.",
+ "about-github-repository": "repository GitHub",
+ "about-get-in-touch": "contattaci",
+ "about-description-intro": "dpkit Cloud fornisce strumenti online gratuiti orientati alla privacy per convertire e validare dati. A differenza di altri, dpkit Cloud è gratuito e completamente",
+ "about-self-hosting-intro": "dpkit Cloud è completamente open source e può essere auto-ospitato. Visita il nostro",
+ "about-self-hosting-middle": "per iniziare. Per assistenza con l'auto-ospitare o implementazioni personalizzate, per favore"
+}
diff --git a/app/locales/pt.json b/app/locales/pt.json
new file mode 100644
index 00000000..623a6416
--- /dev/null
+++ b/app/locales/pt.json
@@ -0,0 +1,102 @@
+{
+ "Page not found": "Página não encontrada",
+ "Something went wrong": "Algo deu errado",
+ "Take me back to home page": "Voltar para a página inicial",
+ "error404": "Você pode ter digitado o endereço incorretamente ou a página foi movida para outro URL.",
+ "error500": "Algo deu errado. Por favor, tente novamente mais tarde.",
+ "Change Theme": "Mudar tema",
+ "Light": "Claro",
+ "Dark": "Escuro",
+ "Change Language": "Mudar idioma",
+ "View Repository": "Ver repositório",
+ "Share Page": "Compartilhar página",
+ "Tools": "Ferramentas",
+ "Select from the tools below": "Selecione das ferramentas abaixo",
+ "Validate data package": "Validar pacote de dados",
+ "Validate Data Package": "Validar pacote de dados",
+ "Validate table data": "Validar dados da tabela",
+ "Infer table schema": "Inferir esquema da tabela",
+ "Convert table format": "Converter formato da tabela",
+ "Select a tool below": "Selecione uma ferramenta abaixo",
+ "Validating data package...": "Validando pacote de dados...",
+ "Valid data package": "Pacote de dados válido",
+ "Invalid data package": "Pacote de dados inválido",
+ "Starting private container...": "Iniciando contêiner privado...",
+ "Close": "Fechar",
+ "Value of the cell": "Valor da célula",
+ "in field": "no campo",
+ "of row": "da linha",
+ "has a wrong type": "tem um tipo errado",
+ "A required cell in field": "Uma célula obrigatória no campo",
+ "is missing": "está faltando",
+ "is less than minimum": "é menor que o mínimo",
+ "is more than maximum": "é maior que o máximo",
+ "is less or equal to exclusive minimum": "é menor ou igual ao mínimo exclusivo",
+ "is more or equal to exclusive maximum": "é maior ou igual ao máximo exclusivo",
+ "Length of the cell": "Comprimento da célula",
+ "does not match the pattern": "não corresponde ao padrão",
+ "is not unique": "não é único",
+ "is not in allowed values": "não está nos valores permitidos",
+ "Field name is expected to be": "O nome do campo deveria ser",
+ "but it is actually": "mas na verdade é",
+ "Field": "Campo",
+ "is expected to be": "deveria ser",
+ "The fields": "Os campos",
+ "are missing": "estão faltando",
+ "are not expected": "não são esperados",
+ "File size": "Tamanho do arquivo",
+ "File hash": "Hash do arquivo",
+ "Field name": "Nome do campo",
+ "at": "em",
+ "The cell values of the fields": "Os valores das células dos campos",
+ "are not unique": "não são únicos",
+ "Under Construction": "Em construção",
+ "This tool is currently under construction and not yet available": "Esta ferramenta está atualmente em construção e ainda não está disponível",
+ "Brought to you by": "Apresentado por",
+ "We are bringing technological innovation and consultancy services to the open data field": "Estamos trazendo inovação tecnológica e serviços de consultoria para o campo de dados abertos",
+ "Support the project by": "Apoie o projeto",
+ "becoming a sponsor": "tornando-se um patrocinador",
+ "or": "ou",
+ "adding a star": "adicionando uma estrela",
+ "on GitHub!": "no GitHub!",
+ "URL": "URL",
+ "File": "Arquivo",
+ "Text": "Texto",
+ "URL is required": "A URL é obrigatória",
+ "Invalid URL format": "Formato de URL inválido",
+ "Enter data package URL": "Digite a URL do pacote de dados",
+ "Data Package URL": "URL do pacote de dados",
+ "File is required": "O arquivo é obrigatório",
+ "Invalid JSON file": "Arquivo JSON inválido",
+ "Select data package file": "Selecione o arquivo do pacote de dados",
+ "Data Package File": "Arquivo do pacote de dados",
+ "Text is required": "O texto é obrigatório",
+ "Invalid JSON format": "Formato JSON inválido",
+ "Paste data package JSON": "Cole o JSON do pacote de dados",
+ "Data Package JSON": "JSON do pacote de dados",
+ "dpkit Cloud": "dpkit Cloud",
+ "Free online tools for": "Ferramentas online gratuitas para",
+ "converting and validating data": "converter e validar dados",
+ "Unlike others, dpkit Cloud is": "Ao contrário de outros, dpkit Cloud é",
+ "privacy-first": "focado em privacidade",
+ "and completely": "e completamente",
+ "open source": "código aberto",
+ "allowing you to review the code or": "permitindo que você revise o código ou",
+ "self-host": "auto-hospede",
+ "the service. In 2025, the project was funded by": "o serviço. Em 2025, o projeto foi financiado pela",
+ "European Commission": "Comissão Europeia",
+ "Errors": "Erros",
+ "all": "todos",
+ "metadata": "metadados",
+ "about-dpkit-cloud-title": "Sobre dpkit Cloud",
+ "about-dpkit-cloud-description": "dpkit Cloud fornece ferramentas online gratuitas focadas em privacidade para converter e validar dados. Ao contrário de outros, dpkit Cloud é gratuito e completamente de código aberto, permitindo que você revise o código ou auto-hospede o serviço. Em 2025, o projeto foi financiado pela Comissão Europeia.",
+ "about-privacy-first-title": "Focado em privacidade",
+ "about-privacy-first-description": "dpkit Cloud executa todas as operações em completo isolamento usando apenas armazenamentos efêmeros. Para cada solicitação, um contêiner privado é criado e destruído após a conclusão da solicitação. Isso garante que seus dados não sejam coletados em nossos servidores e sejam acessíveis apenas para você durante o processamento de sua solicitação.",
+ "about-self-hosting-title": "Auto-hospedagem",
+ "about-self-hosting-description": "dpkit Cloud é completamente de código aberto e pode ser auto-hospedado. Visite nosso repositório GitHub para começar. Para assistência com auto-hospedagem ou implantações personalizadas, por favor entre em contato conosco.",
+ "about-github-repository": "repositório GitHub",
+ "about-get-in-touch": "entre em contato conosco",
+ "about-description-intro": "dpkit Cloud fornece ferramentas online gratuitas focadas em privacidade para converter e validar dados. Ao contrário de outros, dpkit Cloud é gratuito e completamente",
+ "about-self-hosting-intro": "dpkit Cloud é completamente de código aberto e pode ser auto-hospedado. Visite nosso",
+ "about-self-hosting-middle": "para começar. Para assistência com auto-hospedagem ou implantações personalizadas, por favor"
+}
diff --git a/app/locales/ru.json b/app/locales/ru.json
new file mode 100644
index 00000000..113b0e68
--- /dev/null
+++ b/app/locales/ru.json
@@ -0,0 +1,102 @@
+{
+ "Page not found": "Страница не найдена",
+ "Something went wrong": "Что-то пошло не так",
+ "Take me back to home page": "Вернуться на главную страницу",
+ "error404": "Возможно, вы неправильно ввели адрес, или страница была перемещена на другой URL.",
+ "error500": "Что-то пошло не так. Пожалуйста, попробуйте позже.",
+ "Change Theme": "Изменить тему",
+ "Light": "Светлая",
+ "Dark": "Тёмная",
+ "Change Language": "Изменить язык",
+ "View Repository": "Просмотреть репозиторий",
+ "Share Page": "Поделиться страницей",
+ "Tools": "Инструменты",
+ "Select from the tools below": "Выберите из инструментов ниже",
+ "Validate data package": "Проверить пакет данных",
+ "Validate Data Package": "Проверить пакет данных",
+ "Validate table data": "Проверить данные таблицы",
+ "Infer table schema": "Вывести схему таблицы",
+ "Convert table format": "Конвертировать формат таблицы",
+ "Select a tool below": "Выберите инструмент ниже",
+ "Validating data package...": "Проверка пакета данных...",
+ "Valid data package": "Корректный пакет данных",
+ "Invalid data package": "Некорректный пакет данных",
+ "Starting private container...": "Запуск приватного контейнера...",
+ "Close": "Закрыть",
+ "Value of the cell": "Значение ячейки",
+ "in field": "в поле",
+ "of row": "строки",
+ "has a wrong type": "имеет неправильный тип",
+ "A required cell in field": "Обязательная ячейка в поле",
+ "is missing": "отсутствует",
+ "is less than minimum": "меньше минимума",
+ "is more than maximum": "больше максимума",
+ "is less or equal to exclusive minimum": "меньше или равно эксклюзивному минимуму",
+ "is more or equal to exclusive maximum": "больше или равно эксклюзивному максимуму",
+ "Length of the cell": "Длина ячейки",
+ "does not match the pattern": "не соответствует шаблону",
+ "is not unique": "не уникально",
+ "is not in allowed values": "не входит в допустимые значения",
+ "Field name is expected to be": "Ожидается, что имя поля будет",
+ "but it is actually": "но на самом деле",
+ "Field": "Поле",
+ "is expected to be": "ожидается",
+ "The fields": "Поля",
+ "are missing": "отсутствуют",
+ "are not expected": "не ожидаются",
+ "File size": "Размер файла",
+ "File hash": "Хеш файла",
+ "Field name": "Имя поля",
+ "at": "в",
+ "The cell values of the fields": "Значения ячеек полей",
+ "are not unique": "не уникальны",
+ "Under Construction": "В разработке",
+ "This tool is currently under construction and not yet available": "Этот инструмент в настоящее время находится в разработке и пока недоступен",
+ "Brought to you by": "Представлено",
+ "We are bringing technological innovation and consultancy services to the open data field": "Мы привносим технологические инновации и консалтинговые услуги в область открытых данных",
+ "Support the project by": "Поддержите проект",
+ "becoming a sponsor": "став спонсором",
+ "or": "или",
+ "adding a star": "поставив звезду",
+ "on GitHub!": "на GitHub!",
+ "URL": "URL",
+ "File": "Файл",
+ "Text": "Текст",
+ "URL is required": "URL обязателен",
+ "Invalid URL format": "Неверный формат URL",
+ "Enter data package URL": "Введите URL пакета данных",
+ "Data Package URL": "URL пакета данных",
+ "File is required": "Файл обязателен",
+ "Invalid JSON file": "Неверный JSON-файл",
+ "Select data package file": "Выберите файл пакета данных",
+ "Data Package File": "Файл пакета данных",
+ "Text is required": "Текст обязателен",
+ "Invalid JSON format": "Неверный формат JSON",
+ "Paste data package JSON": "Вставьте JSON пакета данных",
+ "Data Package JSON": "JSON пакета данных",
+ "dpkit Cloud": "dpkit Cloud",
+ "Free online tools for": "Бесплатные онлайн-инструменты для",
+ "converting and validating data": "конвертации и валидации данных",
+ "Unlike others, dpkit Cloud is": "В отличие от других, dpkit Cloud",
+ "privacy-first": "ориентирован на конфиденциальность",
+ "and completely": "и полностью",
+ "open source": "с открытым исходным кодом",
+ "allowing you to review the code or": "позволяя вам просмотреть код или",
+ "self-host": "разместить самостоятельно",
+ "the service. In 2025, the project was funded by": "сервис. В 2025 году проект финансировался",
+ "European Commission": "Европейской комиссией",
+ "Errors": "Ошибки",
+ "all": "все",
+ "metadata": "метаданные",
+ "about-dpkit-cloud-title": "О dpkit Cloud",
+ "about-dpkit-cloud-description": "dpkit Cloud предоставляет бесплатные онлайн-инструменты с упором на конфиденциальность для конвертации и валидации данных. В отличие от других, dpkit Cloud бесплатен и полностью открыт, что позволяет вам просмотреть код или разместить сервис самостоятельно. В 2025 году проект финансировался Европейской комиссией.",
+ "about-privacy-first-title": "Ориентация на конфиденциальность",
+ "about-privacy-first-description": "dpkit Cloud выполняет все операции в полной изоляции, используя только временные хранилища. Для каждого запроса создается приватный контейнер, который уничтожается после завершения запроса. Это гарантирует, что ваши данные не собираются на наших серверах и доступны только вам во время обработки вашего запроса.",
+ "about-self-hosting-title": "Самостоятельное размещение",
+ "about-self-hosting-description": "dpkit Cloud полностью открыт и может быть размещен самостоятельно. Посетите наш репозиторий GitHub, чтобы начать. Для помощи в самостоятельном размещении или индивидуальных развертываниях, пожалуйста, свяжитесь с нами.",
+ "about-github-repository": "репозиторий GitHub",
+ "about-get-in-touch": "свяжитесь с нами",
+ "about-description-intro": "dpkit Cloud предоставляет бесплатные онлайн-инструменты с упором на конфиденциальность для конвертации и валидации данных. В отличие от других, dpkit Cloud бесплатен и полностью",
+ "about-self-hosting-intro": "dpkit Cloud полностью открыт и может быть размещен самостоятельно. Посетите наш",
+ "about-self-hosting-middle": "чтобы начать. Для помощи в самостоятельном размещении или индивидуальных развертываниях, пожалуйста,"
+}
diff --git a/app/locales/uk.json b/app/locales/uk.json
new file mode 100644
index 00000000..a0b41f68
--- /dev/null
+++ b/app/locales/uk.json
@@ -0,0 +1,102 @@
+{
+ "Page not found": "Сторінку не знайдено",
+ "Something went wrong": "Щось пішло не так",
+ "Take me back to home page": "Повернутися на головну сторінку",
+ "error404": "Можливо, ви неправильно ввели адресу, або сторінку було переміщено на іншу URL-адресу.",
+ "error500": "Щось пішло не так. Будь ласка, спробуйте пізніше.",
+ "Change Theme": "Змінити тему",
+ "Light": "Світла",
+ "Dark": "Темна",
+ "Change Language": "Змінити мову",
+ "View Repository": "Переглянути репозиторій",
+ "Share Page": "Поділитися сторінкою",
+ "Tools": "Інструменти",
+ "Select from the tools below": "Виберіть із інструментів нижче",
+ "Validate data package": "Перевірити пакет даних",
+ "Validate Data Package": "Перевірити пакет даних",
+ "Validate table data": "Перевірити дані таблиці",
+ "Infer table schema": "Вивести схему таблиці",
+ "Convert table format": "Конвертувати формат таблиці",
+ "Select a tool below": "Виберіть інструмент нижче",
+ "Validating data package...": "Перевірка пакету даних...",
+ "Valid data package": "Коректний пакет даних",
+ "Invalid data package": "Некоректний пакет даних",
+ "Starting private container...": "Запуск приватного контейнера...",
+ "Close": "Закрити",
+ "Value of the cell": "Значення комірки",
+ "in field": "в полі",
+ "of row": "рядка",
+ "has a wrong type": "має неправильний тип",
+ "A required cell in field": "Обов'язкова комірка в полі",
+ "is missing": "відсутня",
+ "is less than minimum": "менше мінімуму",
+ "is more than maximum": "більше максимуму",
+ "is less or equal to exclusive minimum": "менше або дорівнює ексклюзивному мінімуму",
+ "is more or equal to exclusive maximum": "більше або дорівнює ексклюзивному максимуму",
+ "Length of the cell": "Довжина комірки",
+ "does not match the pattern": "не відповідає шаблону",
+ "is not unique": "не унікальне",
+ "is not in allowed values": "не входить до допустимих значень",
+ "Field name is expected to be": "Очікується, що ім'я поля буде",
+ "but it is actually": "але насправді",
+ "Field": "Поле",
+ "is expected to be": "очікується",
+ "The fields": "Поля",
+ "are missing": "відсутні",
+ "are not expected": "не очікуються",
+ "File size": "Розмір файлу",
+ "File hash": "Хеш файлу",
+ "Field name": "Ім'я поля",
+ "at": "в",
+ "The cell values of the fields": "Значення комірок полів",
+ "are not unique": "не унікальні",
+ "Under Construction": "В розробці",
+ "This tool is currently under construction and not yet available": "Цей інструмент наразі перебуває в розробці і ще недоступний",
+ "Brought to you by": "Представлено",
+ "We are bringing technological innovation and consultancy services to the open data field": "Ми привносимо технологічні інновації та консалтингові послуги в галузь відкритих даних",
+ "Support the project by": "Підтримайте проект",
+ "becoming a sponsor": "ставши спонсором",
+ "or": "або",
+ "adding a star": "поставивши зірку",
+ "on GitHub!": "на GitHub!",
+ "URL": "URL",
+ "File": "Файл",
+ "Text": "Текст",
+ "URL is required": "URL обов'язковий",
+ "Invalid URL format": "Невірний формат URL",
+ "Enter data package URL": "Введіть URL пакету даних",
+ "Data Package URL": "URL пакету даних",
+ "File is required": "Файл обов'язковий",
+ "Invalid JSON file": "Невірний JSON-файл",
+ "Select data package file": "Виберіть файл пакету даних",
+ "Data Package File": "Файл пакету даних",
+ "Text is required": "Текст обов'язковий",
+ "Invalid JSON format": "Невірний формат JSON",
+ "Paste data package JSON": "Вставте JSON пакету даних",
+ "Data Package JSON": "JSON пакету даних",
+ "dpkit Cloud": "dpkit Cloud",
+ "Free online tools for": "Безкоштовні онлайн-інструменти для",
+ "converting and validating data": "конвертації та валідації даних",
+ "Unlike others, dpkit Cloud is": "На відміну від інших, dpkit Cloud",
+ "privacy-first": "орієнтований на конфіденційність",
+ "and completely": "і повністю",
+ "open source": "з відкритим вихідним кодом",
+ "allowing you to review the code or": "дозволяючи вам переглянути код або",
+ "self-host": "розмістити самостійно",
+ "the service. In 2025, the project was funded by": "сервіс. У 2025 році проект фінансувався",
+ "European Commission": "Європейською комісією",
+ "Errors": "Помилки",
+ "all": "все",
+ "metadata": "метадані",
+ "about-dpkit-cloud-title": "Про dpkit Cloud",
+ "about-dpkit-cloud-description": "dpkit Cloud надає безкоштовні онлайн-інструменти з акцентом на конфіденційність для конвертації та валідації даних. На відміну від інших, dpkit Cloud безкоштовний і повністю відкритий, що дозволяє вам переглянути код або розмістити сервіс самостійно. У 2025 році проект фінансувався Європейською комісією.",
+ "about-privacy-first-title": "Орієнтація на конфіденційність",
+ "about-privacy-first-description": "dpkit Cloud виконує всі операції в повній ізоляції, використовуючи лише тимчасові сховища. Для кожного запиту створюється приватний контейнер, який знищується після завершення запиту. Це гарантує, що ваші дані не збираються на наших серверах і доступні лише вам під час обробки вашого запиту.",
+ "about-self-hosting-title": "Самостійне розміщення",
+ "about-self-hosting-description": "dpkit Cloud повністю відкритий і може бути розміщений самостійно. Відвідайте наш репозиторій GitHub, щоб почати. Для допомоги в самостійному розміщенні або індивідуальних розгортаннях, будь ласка, зв'яжіться з нами.",
+ "about-github-repository": "репозиторій GitHub",
+ "about-get-in-touch": "зв'яжіться з нами",
+ "about-description-intro": "dpkit Cloud надає безкоштовні онлайн-інструменти з акцентом на конфіденційність для конвертації та валідації даних. На відміну від інших, dpkit Cloud безкоштовний і повністю",
+ "about-self-hosting-intro": "dpkit Cloud повністю відкритий і може бути розміщений самостійно. Відвідайте наш",
+ "about-self-hosting-middle": "щоб почати. Для допомоги в самостійному розміщенні або індивідуальних розгортаннях, будь ласка,"
+}
diff --git a/app/main.ts b/app/main.ts
new file mode 100644
index 00000000..ce9d75ad
--- /dev/null
+++ b/app/main.ts
@@ -0,0 +1,43 @@
+import { randomUUID } from "node:crypto"
+import { Container, getContainer } from "@cloudflare/containers"
+import { createRequestHandler } from "react-router"
+
+export interface Env {
+ RPC: DurableObjectNamespace
+}
+
+export class Rpc extends Container {
+ defaultPort = 8080
+ sleepAfter = import.meta.env.PROD ? "0h" : "1h"
+}
+
+declare module "react-router" {
+ export interface AppLoadContext {
+ cloudflare: {
+ env: Env
+ ctx: ExecutionContext
+ }
+ }
+}
+
+const requestHandler = createRequestHandler(
+ // @ts-ignore
+ () => import("virtual:react-router/server-build"),
+ import.meta.env.MODE,
+)
+
+export default {
+ async fetch(request, env, ctx) {
+ const path = new URL(request.url).pathname
+
+ if (path.startsWith("/rpc")) {
+ const name = import.meta.env.PROD ? randomUUID() : path
+ const containerInstance = getContainer(env.RPC, name)
+ return containerInstance.fetch(request)
+ }
+
+ return requestHandler(request, {
+ cloudflare: { env, ctx },
+ })
+ },
+} satisfies ExportedHandler
diff --git a/app/package.json b/app/package.json
index d9961538..21a6cdca 100644
--- a/app/package.json
+++ b/app/package.json
@@ -2,5 +2,56 @@
"name": "@dpkit/app",
"type": "module",
"version": "0.0.0-dev",
- "private": true
+ "private": true,
+ "imports": {
+ "#*": "./*"
+ },
+ "scripts": {
+ "build": "react-router build",
+ "generate": "pnpm generate:cf && pnpm generate:rr",
+ "generate:cf": "wrangler types",
+ "generate:rr": "react-router typegen",
+ "start": "react-router dev --port 4000 --host",
+ "type": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@cloudflare/containers": "0.0.28",
+ "@dpkit/lib": "workspace:*",
+ "@mantine/core": "8.3.5",
+ "@mantine/form": "8.3.5",
+ "@mantine/hooks": "8.3.5",
+ "@mantine/modals": "8.3.5",
+ "@tanstack/react-query": "5.90.3",
+ "capnweb": "0.1.0",
+ "es-toolkit": "1.39.10",
+ "i18next": "25.6.0",
+ "immer": "10.1.3",
+ "isbot": "5.1.31",
+ "lucide-react": "0.545.0",
+ "react": "19.2.0",
+ "react-dom": "19.2.0",
+ "react-flags-select": "2.5.0",
+ "react-i18next": "16.0.1",
+ "react-router": "7.9.4",
+ "react-schemaorg": "2.0.0",
+ "react-share": "5.2.2",
+ "react-type-animation": "3.2.0",
+ "remix-utils": "9.0.0",
+ "ts-extras": "^0.14.0",
+ "vaul": "1.1.2",
+ "xml-js": "1.6.11",
+ "zustand": "5.0.8"
+ },
+ "devDependencies": {
+ "@cloudflare/vite-plugin": "1.13.13",
+ "@react-router/dev": "7.9.4",
+ "@types/react": "19.2.2",
+ "@types/react-dom": "^19.2.0",
+ "postcss": "8.5.6",
+ "postcss-preset-mantine": "1.18.0",
+ "postcss-simple-vars": "7.0.1",
+ "vite-plugin-devtools-json": "1.0.0",
+ "vite-plugin-svgr": "4.5.0",
+ "wrangler": "4.43.0"
+ }
}
diff --git a/app/payload.ts b/app/payload.ts
new file mode 100644
index 00000000..e06d712a
--- /dev/null
+++ b/app/payload.ts
@@ -0,0 +1,37 @@
+import { LanguageIdDefault, Languages } from "#constants/language.ts"
+import { PageIdDefault, Pages } from "#constants/page.ts"
+import type * as types from "#types/index.ts"
+
+export type Payload = {
+ language: types.Language
+ page: types.Page
+}
+
+export function createPayload(options?: {
+ pageId?: types.PageId
+ params?: Partial>
+}) {
+ const page = Pages[options?.pageId ?? PageIdDefault]
+
+ let language: types.Language | undefined = Languages[LanguageIdDefault]
+ if (options?.params?.languageId) {
+ language = Object.values(Languages).find(
+ language => language.languageId === options.params?.languageId,
+ )
+ }
+
+ if (!language) {
+ throw new Response(null, { status: 404, statusText: "Not Found" })
+ }
+
+ const payload: Payload = {
+ language,
+ page,
+ }
+
+ return {
+ payload,
+ languageId: language.languageId,
+ pageId: page.pageId,
+ }
+}
diff --git a/app/postcss.config.ts b/app/postcss.config.ts
new file mode 100644
index 00000000..87751ccc
--- /dev/null
+++ b/app/postcss.config.ts
@@ -0,0 +1,14 @@
+export default {
+ plugins: {
+ "postcss-preset-mantine": {},
+ "postcss-simple-vars": {
+ variables: {
+ "mantine-breakpoint-xs": "0em",
+ "mantine-breakpoint-sm": "30em",
+ "mantine-breakpoint-md": "50em",
+ "mantine-breakpoint-lg": "60em",
+ "mantine-breakpoint-xl": "80em",
+ },
+ },
+ },
+}
diff --git a/app/public/favicon.png b/app/public/favicon.png
new file mode 100644
index 00000000..88a2c1f9
Binary files /dev/null and b/app/public/favicon.png differ
diff --git a/app/public/robots.txt b/app/public/robots.txt
new file mode 100644
index 00000000..fc606bcf
--- /dev/null
+++ b/app/public/robots.txt
@@ -0,0 +1,5 @@
+User-agent: *
+
+Allow: /
+
+Sitemap: https://cloud.dpkit/sitemap.xml
diff --git a/app/react-router.config.ts b/app/react-router.config.ts
new file mode 100644
index 00000000..00e9ad8e
--- /dev/null
+++ b/app/react-router.config.ts
@@ -0,0 +1,9 @@
+import type { Config } from "@react-router/dev/config"
+
+export default {
+ appDirectory: "routes",
+ ssr: true,
+ future: {
+ unstable_viteEnvironmentApi: true,
+ },
+} satisfies Config
diff --git a/app/routes/about/route.tsx b/app/routes/about/route.tsx
new file mode 100644
index 00000000..58318247
--- /dev/null
+++ b/app/routes/about/route.tsx
@@ -0,0 +1,49 @@
+import { Stack, Text, Title } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+import { Link } from "#components/Link/index.ts"
+import { createPayload } from "#payload.ts"
+import type { Route } from "./+types/route.tsx"
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const { payload } = createPayload({ pageId: "about", params })
+
+ return { payload }
+}
+
+export default function Page(_props: Route.ComponentProps) {
+ const { t } = useTranslation()
+
+ return (
+
+ {t("about-dpkit-cloud-title")}
+
+ {t("about-description-intro")}{" "}
+ {t("open source")}{" "}
+ {t("allowing you to review the code or")} {t("self-host")}{" "}
+ {t("the service. In 2025, the project was funded by")}{" "}
+
+ {t("European Commission")}
+
+ .
+
+
+ {t("about-privacy-first-title")}
+ {t("about-privacy-first-description")}
+
+
+ {t("about-self-hosting-title")}
+
+ {t("about-self-hosting-intro")}{" "}
+
+ {t("about-github-repository")}
+ {" "}
+ {t("about-self-hosting-middle")}{" "}
+
+ {t("about-get-in-touch")}
+
+ .
+
+
+
+ )
+}
diff --git a/app/routes/entry.client.tsx b/app/routes/entry.client.tsx
new file mode 100644
index 00000000..3dd1e01a
--- /dev/null
+++ b/app/routes/entry.client.tsx
@@ -0,0 +1,12 @@
+import { StrictMode, startTransition } from "react"
+import { hydrateRoot } from "react-dom/client"
+import { HydratedRouter } from "react-router/dom"
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ )
+})
diff --git a/app/routes/entry.server.tsx b/app/routes/entry.server.tsx
new file mode 100644
index 00000000..d18dcc54
--- /dev/null
+++ b/app/routes/entry.server.tsx
@@ -0,0 +1,43 @@
+import { isbot } from "isbot"
+import { renderToReadableStream } from "react-dom/server"
+import type { AppLoadContext, EntryContext } from "react-router"
+import { ServerRouter } from "react-router"
+
+export default async function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ routerContext: EntryContext,
+ _loadContext: AppLoadContext,
+) {
+ let shellRendered = false
+ const userAgent = request.headers.get("user-agent")
+
+ const body = await renderToReadableStream(
+ ,
+ {
+ onError(error: unknown) {
+ responseStatusCode = 500
+ // Log streaming rendering errors from inside the shell. Don't log
+ // errors encountered during initial shell rendering since they'll
+ // reject and get logged in handleDocumentRequest.
+ if (shellRendered) {
+ console.error(error)
+ }
+ },
+ },
+ )
+ shellRendered = true
+
+ // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
+ // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
+ if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
+ await body.allReady
+ }
+
+ responseHeaders.set("Content-Type", "text/html")
+ return new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+}
diff --git a/app/routes/home/route.module.css b/app/routes/home/route.module.css
new file mode 100644
index 00000000..a5486fe1
--- /dev/null
+++ b/app/routes/home/route.module.css
@@ -0,0 +1,23 @@
+.card {
+ transition: background-color 0.5s ease, border-color 0.5s ease;
+}
+
+.card:hover {
+ background-color: light-dark(
+ var(--mantine-color-blue-0),
+ var(--mantine-color-dark-5)
+ );
+ border-color: var(--mantine-color-blue-6);
+}
+
+.card:hover .icon {
+ transform: rotate(360deg);
+ transition: transform 0.5s ease;
+}
+
+.title {
+ font-size: var(--mantine-h3-font-size);
+ @mixin smaller-than $mantine-breakpoint-md {
+ font-size: var(--mantine-h4-font-size);
+ }
+}
diff --git a/app/routes/home/route.tsx b/app/routes/home/route.tsx
new file mode 100644
index 00000000..56ba063d
--- /dev/null
+++ b/app/routes/home/route.tsx
@@ -0,0 +1,84 @@
+import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core"
+import { useTranslation } from "react-i18next"
+import { Link } from "#components/Link/index.ts"
+import { usePayload } from "#components/System/index.ts"
+import { useMakeLink } from "#components/System/index.ts"
+import { Pages } from "#constants/page.ts"
+import { createPayload } from "#payload.ts"
+import * as settings from "#settings.ts"
+import type { Route } from "./+types/route.tsx"
+import classes from "./route.module.css"
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const { payload } = createPayload({ pageId: "home", params })
+
+ return { payload }
+}
+
+export default function Page(_props: Route.ComponentProps) {
+ const { t } = useTranslation()
+ const payload = usePayload()
+ const makeLink = useMakeLink()
+
+ const tools = Object.values(Pages).filter(page => page.pageId !== "home")
+
+ return (
+
+
+
+ {t("dpkit Cloud")}
+
+
+ {t("Free online tools for")}{" "}
+
+ {t("converting and validating data")}
+
+ . {t("Unlike others, dpkit Cloud is")}{" "}
+ {t("privacy-first")}{" "}
+ {t("and completely")}{" "}
+ {t("open source")}{" "}
+ {t("allowing you to review the code or")}{" "}
+ {t("self-host")}{" "}
+ {t("the service. In 2025, the project was funded by")}{" "}
+
+ {t("European Commission")}
+
+ .
+
+
+
+ {tools.map(tool => {
+ if (!tool.Icon) return null
+ return (
+
+
+
+
+ {tool.title[payload.language.languageId]}
+
+
+
+ {tool.description[payload.language.languageId]}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/app/routes/package/validate/Dialog.tsx b/app/routes/package/validate/Dialog.tsx
new file mode 100644
index 00000000..91011423
--- /dev/null
+++ b/app/routes/package/validate/Dialog.tsx
@@ -0,0 +1,38 @@
+import { useTranslation } from "react-i18next"
+import { Dialog } from "#components/Dialog/index.ts"
+import { Report } from "#components/Report/index.ts"
+import { Status } from "#components/Status/index.ts"
+import { store } from "./store.ts"
+
+export function ValidatePackageDialog() {
+ const { t } = useTranslation()
+ const report = store.useState(state => state.report)
+ const isPending = store.useState(state => state.isPending)
+ const isDialogOpen = store.useState(state => state.isDialogOpen)
+
+ const handleOpenChange = (isDialogOpen: boolean) => {
+ store.setState({ isDialogOpen, report: undefined })
+ }
+
+ const getStatus = () => {
+ if (report) return report.valid ? "success" : "error"
+ return isPending ? "pending" : undefined
+ }
+
+ return (
+
+ )
+}
diff --git a/app/routes/package/validate/Form.tsx b/app/routes/package/validate/Form.tsx
new file mode 100644
index 00000000..a3121182
--- /dev/null
+++ b/app/routes/package/validate/Form.tsx
@@ -0,0 +1,17 @@
+import type { Descriptor } from "@dpkit/lib"
+import { Form } from "#components/Form/index.ts"
+import { useValidatePackage } from "./queries.ts"
+
+export function ValidatePackageForm() {
+ const validatePackage = useValidatePackage()
+
+ const handleSubmit = (value: string | File | Descriptor) => {
+ if (value instanceof File) {
+ throw new Error("File upload not implemented")
+ }
+
+ validatePackage.mutate(value)
+ }
+
+ return
+}
diff --git a/app/routes/package/validate/queries.ts b/app/routes/package/validate/queries.ts
new file mode 100644
index 00000000..ae456703
--- /dev/null
+++ b/app/routes/package/validate/queries.ts
@@ -0,0 +1,21 @@
+import { useMutation } from "@tanstack/react-query"
+import { newHttpBatchRpcSession } from "capnweb"
+import type { Rpc } from "#rpc.ts"
+import type { validatePackage } from "./services.ts"
+import { store } from "./store.ts"
+
+export function useValidatePackage() {
+ return useMutation({
+ mutationKey: ["validatePackage"],
+ mutationFn: async (source: Parameters[0]) => {
+ store.setState({ isDialogOpen: true })
+ store.setState({ isPending: true })
+
+ const rpc = newHttpBatchRpcSession("/rpc")
+ const report = await rpc.validatePackage(source)
+
+ store.setState({ isPending: false })
+ store.setState({ report })
+ },
+ })
+}
diff --git a/app/routes/package/validate/route.tsx b/app/routes/package/validate/route.tsx
new file mode 100644
index 00000000..c02abdd3
--- /dev/null
+++ b/app/routes/package/validate/route.tsx
@@ -0,0 +1,30 @@
+import { Box, Stack, Text, Title } from "@mantine/core"
+import { usePayload } from "#components/System/index.ts"
+import { Pages } from "#constants/page.ts"
+import { createPayload } from "#payload.ts"
+import type { Route } from "./+types/route.tsx"
+import { ValidatePackageDialog } from "./Dialog.tsx"
+import { ValidatePackageForm } from "./Form.tsx"
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const { payload } = createPayload({ pageId: "packageValidate", params })
+
+ return { payload }
+}
+
+export default function Page(_props: Route.ComponentProps) {
+ const { languageId } = usePayload()
+ const page = Pages.packageValidate
+
+ return (
+
+
+ {page.title[languageId]}
+ {page.description[languageId]}
+
+
+
+
+
+ )
+}
diff --git a/app/routes/package/validate/services.ts b/app/routes/package/validate/services.ts
new file mode 100644
index 00000000..1c822b7f
--- /dev/null
+++ b/app/routes/package/validate/services.ts
@@ -0,0 +1,8 @@
+import * as dpkit from "@dpkit/lib"
+
+export async function validatePackage(
+ source: Parameters[0],
+) {
+ const result = await dpkit.validatePackage(source)
+ return result
+}
diff --git a/app/routes/package/validate/store.ts b/app/routes/package/validate/store.ts
new file mode 100644
index 00000000..10073474
--- /dev/null
+++ b/app/routes/package/validate/store.ts
@@ -0,0 +1,10 @@
+import { createStore } from "#helpers/store.ts"
+import type { validatePackage } from "./services.ts"
+
+export interface State {
+ isDialogOpen?: boolean
+ isPending?: boolean
+ report?: Awaited>
+}
+
+export const store = createStore()
diff --git a/app/routes/root.tsx b/app/routes/root.tsx
new file mode 100644
index 00000000..ff7855ac
--- /dev/null
+++ b/app/routes/root.tsx
@@ -0,0 +1,100 @@
+// Use comments to prevent sorting
+// ---
+import "#styles/index.ts"
+// ---
+import { useMatches } from "react-router"
+import { Outlet, Scripts, ScrollRestoration } from "react-router"
+import { isRouteErrorResponse } from "react-router"
+import { JsonLd } from "react-schemaorg"
+import { Layout as LayoutComponent } from "#components/Layout/index.ts"
+import { Error } from "#components/System/index.ts"
+import { System } from "#components/System/index.ts"
+import { makeHeadLinks } from "#helpers/link.ts"
+import { getRevisionCacheControl } from "#helpers/revision.ts"
+import { createPayload } from "#payload.ts"
+import * as settings from "#settings.ts"
+import type * as types from "#types/index.ts"
+import type { Route } from "./+types/root.tsx"
+
+export function headers() {
+ return {
+ "Cache-Control": getRevisionCacheControl(),
+ }
+}
+
+export default function Page() {
+ return
+}
+
+export function ErrorBoundary(props: Route.ErrorBoundaryProps) {
+ const code = isRouteErrorResponse(props.error) ? props.error.status : 500
+
+ return
+}
+
+export function Layout(props: { children: React.ReactNode }) {
+ const matches = useMatches()
+ const route = matches.at(-1) as any
+
+ const payload: types.Payload = route?.data?.payload ?? createPayload().payload
+ const languageId = payload.language.languageId
+
+ const pageId = payload.page.pageId
+ const links = makeHeadLinks({ languageId, pageId })
+
+ return (
+
+
+
+
+
+ {settings.TITLE}
+
+
+
+
+
+
+
+
+
+
+ {links.map(link => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {props.children}
+
+
+
+
+
+
+ )
+}
diff --git a/app/routes/routes.ts b/app/routes/routes.ts
new file mode 100644
index 00000000..943a578d
--- /dev/null
+++ b/app/routes/routes.ts
@@ -0,0 +1,26 @@
+import { type RouteConfig, route } from "@react-router/dev/routes"
+import { objectEntries, objectKeys } from "ts-extras"
+import { Languages } from "#constants/language.ts"
+import { Pages } from "#constants/page.ts"
+
+const routes: RouteConfig = []
+
+routes.push(
+ route("", "system/redirects/home.ts"),
+
+ route("sitemap.xml", "sitemap/root.ts"),
+ route(":languageId/sitemap.xml", "sitemap/page.ts"),
+)
+
+for (const [pageId, page] of objectEntries(Pages)) {
+ for (const languageId of objectKeys(Languages)) {
+ const id = [languageId, pageId].join("/")
+ const path = [":languageId", page.path?.[languageId]]
+ .filter(Boolean)
+ .join("")
+
+ routes.push(route(path, page.file, { id }))
+ }
+}
+
+export default routes
diff --git a/app/routes/schema/infer/route.tsx b/app/routes/schema/infer/route.tsx
new file mode 100644
index 00000000..0e3cd1e3
--- /dev/null
+++ b/app/routes/schema/infer/route.tsx
@@ -0,0 +1,22 @@
+import { useTranslation } from "react-i18next"
+import { Alert } from "#components/Alert/Alert.tsx"
+import { createPayload } from "#payload.ts"
+import type { Route } from "./+types/route.tsx"
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const { payload } = createPayload({ pageId: "schemaInfer", params })
+
+ return { payload }
+}
+
+export default function Page(_props: Route.ComponentProps) {
+ const { t } = useTranslation()
+ return (
+
+ )
+}
diff --git a/app/routes/sitemap/page.ts b/app/routes/sitemap/page.ts
new file mode 100644
index 00000000..87ec404d
--- /dev/null
+++ b/app/routes/sitemap/page.ts
@@ -0,0 +1,19 @@
+import { xml } from "remix-utils/responses"
+import { Pages } from "#constants/page.ts"
+import { createPayload } from "#payload.ts"
+import type { Route } from "./+types/page.ts"
+import { PartitionSitemap } from "./services.ts"
+
+export async function loader(props: Route.ComponentProps) {
+ const { languageId } = createPayload({ params: props.params })
+
+ const sitemap = new PartitionSitemap({
+ languageId,
+ })
+
+ for (const page of Object.values(Pages)) {
+ sitemap.addPage({ pageId: page.pageId })
+ }
+
+ return xml(sitemap.toXml())
+}
diff --git a/app/routes/sitemap/root.ts b/app/routes/sitemap/root.ts
new file mode 100644
index 00000000..cc996ddb
--- /dev/null
+++ b/app/routes/sitemap/root.ts
@@ -0,0 +1,16 @@
+import { xml } from "remix-utils/responses"
+import { objectKeys } from "ts-extras"
+import { Languages } from "#constants/language.ts"
+import * as settings from "#settings.ts"
+import { IndexSitemap } from "./services.ts"
+
+export async function loader() {
+ const sitemap = new IndexSitemap()
+
+ for (const languageId of objectKeys(Languages)) {
+ const prefix = `${settings.URL}/${languageId}`
+ sitemap.locations.push(`${prefix}/sitemap.xml`)
+ }
+
+ return xml(sitemap.toXml())
+}
diff --git a/app/routes/sitemap/services.ts b/app/routes/sitemap/services.ts
new file mode 100644
index 00000000..84f08920
--- /dev/null
+++ b/app/routes/sitemap/services.ts
@@ -0,0 +1,77 @@
+import { js2xml } from "xml-js"
+import { Languages } from "#constants/language.ts"
+import { makeLink } from "#helpers/link.ts"
+import type * as types from "#types/index.ts"
+
+class BaseSitemap {
+ locations: string[] = []
+
+ addLink(location: string) {
+ this.locations.push(location)
+ }
+
+ toXml(options: { contents: Record }) {
+ const sitemap = {
+ _declaration: { _attributes: { version: "1.0", encoding: "UTF-8" } },
+ ...options.contents,
+ }
+
+ return js2xml(sitemap, { compact: true, spaces: 2 })
+ }
+}
+
+export class IndexSitemap extends BaseSitemap {
+ toXml() {
+ const contents = {
+ sitemapindex: {
+ _attributes: { xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9" },
+ sitemap: this.locations.map(location => ({ loc: { _text: location } })),
+ },
+ }
+
+ return super.toXml({ contents })
+ }
+}
+
+export class PartitionSitemap extends BaseSitemap {
+ languageId: types.LanguageId
+
+ constructor(options: {
+ languageId: types.LanguageId
+ }) {
+ super()
+ this.languageId = options.languageId
+ }
+
+ toXml() {
+ const contents = {
+ urlset: {
+ _attributes: {
+ xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
+ "xmlns:xhtml": "http://www.w3.org/1999/xhtml",
+ "xmlns:news": "http://www.google.com/schemas/sitemap-news/0.9",
+ "xmlns:image": "http://www.google.com/schemas/sitemap-image/1.1",
+ "xmlns:video": "http://www.google.com/schemas/sitemap-video/1.1",
+ },
+ url: this.locations.map(location => ({ loc: { _text: location } })),
+ },
+ }
+
+ return super.toXml({ contents })
+ }
+
+ addPage(options: {
+ pageId: types.PageId
+ params?: Record
+ }) {
+ const language = Languages[this.languageId]
+
+ const link = makeLink({
+ languageId: language.languageId,
+ pageId: options.pageId,
+ absolute: true,
+ })
+
+ this.addLink(link)
+ }
+}
diff --git a/app/routes/system/redirects/home.ts b/app/routes/system/redirects/home.ts
new file mode 100644
index 00000000..79eb3df9
--- /dev/null
+++ b/app/routes/system/redirects/home.ts
@@ -0,0 +1,16 @@
+import { type LoaderFunctionArgs, redirect } from "react-router"
+import { arrayIncludes, objectKeys } from "ts-extras"
+import { Languages } from "#constants/language.ts"
+import { LanguageIdDefault } from "#constants/language.ts"
+
+export function loader(props: LoaderFunctionArgs) {
+ const possibleLanguageId =
+ props?.request.headers.get("accept-language")?.split(",")[0]?.slice(0, 2) ||
+ ""
+
+ const languageId = arrayIncludes(objectKeys(Languages), possibleLanguageId)
+ ? possibleLanguageId
+ : LanguageIdDefault
+
+ throw redirect(`/${languageId}`)
+}
diff --git a/app/routes/table/convert/route.tsx b/app/routes/table/convert/route.tsx
new file mode 100644
index 00000000..02eb18fa
--- /dev/null
+++ b/app/routes/table/convert/route.tsx
@@ -0,0 +1,22 @@
+import { useTranslation } from "react-i18next"
+import { Alert } from "#components/Alert/Alert.tsx"
+import { createPayload } from "#payload.ts"
+import type { Route } from "./+types/route.tsx"
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const { payload } = createPayload({ pageId: "tableConvert", params })
+
+ return { payload }
+}
+
+export default function Page(_props: Route.ComponentProps) {
+ const { t } = useTranslation()
+ return (
+
+ )
+}
diff --git a/app/routes/table/validate/route.tsx b/app/routes/table/validate/route.tsx
new file mode 100644
index 00000000..0f04b335
--- /dev/null
+++ b/app/routes/table/validate/route.tsx
@@ -0,0 +1,22 @@
+import { useTranslation } from "react-i18next"
+import { Alert } from "#components/Alert/Alert.tsx"
+import { createPayload } from "#payload.ts"
+import type { Route } from "./+types/route.tsx"
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const { payload } = createPayload({ pageId: "tableValidate", params })
+
+ return { payload }
+}
+
+export default function Page(_props: Route.ComponentProps) {
+ const { t } = useTranslation()
+ return (
+
+ )
+}
diff --git a/app/rpc.ts b/app/rpc.ts
new file mode 100644
index 00000000..a97cb25d
--- /dev/null
+++ b/app/rpc.ts
@@ -0,0 +1,19 @@
+import http from "node:http"
+import { RpcTarget, nodeHttpBatchRpcResponse } from "capnweb"
+import { validatePackage } from "#routes/package/validate/services.ts"
+
+export class Rpc extends RpcTarget {
+ validatePackage(...args: Parameters) {
+ return validatePackage(...args)
+ }
+}
+
+const server = http.createServer(async (request, response) => {
+ try {
+ await nodeHttpBatchRpcResponse(request, response, new Rpc())
+ } catch (err: any) {
+ response.end(String(err?.stack || err))
+ }
+})
+
+server.listen(8080)
diff --git a/app/settings.ts b/app/settings.ts
new file mode 100644
index 00000000..689282bc
--- /dev/null
+++ b/app/settings.ts
@@ -0,0 +1,7 @@
+export const URL = "https://cloud.dpkit.dev"
+export const TITLE = "dpkit Cloud"
+export const DESCRIPTION = "dpkit Cloud"
+
+export const ICON_SIZE = 20
+export const ICON_STROKE_WIDTH = 1.5
+export const ICON_SIZE_SMALL = 12
diff --git a/app/styles/index.ts b/app/styles/index.ts
new file mode 100644
index 00000000..ddbfbec5
--- /dev/null
+++ b/app/styles/index.ts
@@ -0,0 +1,5 @@
+// Use comments to prevent sorting
+// ---
+import "@mantine/core/styles.css"
+// ---
+import "./main.css"
diff --git a/app/styles/main.css b/app/styles/main.css
new file mode 100644
index 00000000..db555e18
--- /dev/null
+++ b/app/styles/main.css
@@ -0,0 +1,3 @@
+html[data-mantine-color-scheme="light"] {
+ scrollbar-color: var(--mantine-color-gray-3) var(--mantine-color-gray-0);
+}
diff --git a/app/theme.ts b/app/theme.ts
new file mode 100644
index 00000000..d2babd8f
--- /dev/null
+++ b/app/theme.ts
@@ -0,0 +1,21 @@
+import { createTheme } from "@mantine/core"
+
+export const theme = createTheme({
+ fontFamily: "system-ui, sans-serif",
+ cursorType: "pointer",
+ defaultRadius: "lg",
+ // postcss.config.js needs to be changed to synced with these values
+ breakpoints: {
+ xs: "0em", // 0px - mobile
+ sm: "30em", // 480px - tablet
+ md: "50em", // 800px - desktop
+ lg: "60em", // 960px
+ xl: "80em", // 1280px
+ },
+ headings: {
+ sizes: {
+ h1: { fontSize: "26px" },
+ h2: { fontSize: "24px" },
+ },
+ },
+})
diff --git a/app/tsconfig.json b/app/tsconfig.json
index 3c43903c..0cca24e3 100644
--- a/app/tsconfig.json
+++ b/app/tsconfig.json
@@ -1,3 +1,18 @@
{
- "extends": "../tsconfig.json"
+ "extends": "../tsconfig.json",
+ "include": [
+ "${configDir}/**/*.ts",
+ "${configDir}/**/*.tsx",
+ "${configDir}.react-router/types/**/*"
+ ],
+ "compilerOptions": {
+ "rootDir": "${configDir}",
+ "rootDirs": ["${configDir}", "${configDir}/.react-router/types"],
+ "lib": ["ESNext", "DOM"],
+ "types": ["vite/client"],
+ "jsx": "react-jsx",
+ "declaration": false,
+ "allowImportingTsExtensions": true,
+ "rewriteRelativeImportExtensions": false
+ }
}
diff --git a/app/types/index.ts b/app/types/index.ts
new file mode 100644
index 00000000..6fe071b1
--- /dev/null
+++ b/app/types/index.ts
@@ -0,0 +1,3 @@
+export type * from "./language.ts"
+export type * from "./page.ts"
+export type * from "./payload.ts"
diff --git a/app/types/language.ts b/app/types/language.ts
new file mode 100644
index 00000000..57220023
--- /dev/null
+++ b/app/types/language.ts
@@ -0,0 +1,4 @@
+import type { Languages } from "#constants/language.ts"
+
+export type LanguageId = keyof typeof Languages
+export type Language = (typeof Languages)[LanguageId]
diff --git a/app/types/page.ts b/app/types/page.ts
new file mode 100644
index 00000000..fde069d8
--- /dev/null
+++ b/app/types/page.ts
@@ -0,0 +1,4 @@
+import type { Pages } from "#constants/page.ts"
+
+export type PageId = keyof typeof Pages
+export type Page = (typeof Pages)[PageId]
diff --git a/app/types/payload.ts b/app/types/payload.ts
new file mode 100644
index 00000000..1d48790f
--- /dev/null
+++ b/app/types/payload.ts
@@ -0,0 +1 @@
+export type { Payload } from "#payload.ts"
diff --git a/app/vite.config.ts b/app/vite.config.ts
new file mode 100644
index 00000000..0dfc2ef6
--- /dev/null
+++ b/app/vite.config.ts
@@ -0,0 +1,18 @@
+import { cloudflare } from "@cloudflare/vite-plugin"
+import { reactRouter } from "@react-router/dev/vite"
+import { defineConfig } from "vite"
+import devtoolsJson from "vite-plugin-devtools-json"
+import svgr from "vite-plugin-svgr"
+
+export default defineConfig({
+ assetsInclude: ["**/*.md"],
+ plugins: [
+ cloudflare({ viteEnvironment: { name: "ssr" } }),
+ devtoolsJson(),
+ reactRouter(),
+ svgr(),
+ ],
+ resolve: {
+ alias: [{ find: "@dpkit/app", replacement: import.meta.dirname }],
+ },
+})
diff --git a/app/worker-configuration.d.ts b/app/worker-configuration.d.ts
new file mode 100644
index 00000000..8c0031c6
--- /dev/null
+++ b/app/worker-configuration.d.ts
@@ -0,0 +1,8372 @@
+/* eslint-disable */
+// Generated by Wrangler by running `wrangler types` (hash: 7a90e30e29a92b9cda235c21099f739d)
+// Runtime types generated with workerd@1.20251008.0 2025-08-15 nodejs_compat
+declare namespace Cloudflare {
+ interface GlobalProps {
+ mainModule: typeof import("./main");
+ }
+ interface Env {
+ }
+}
+interface Env extends Cloudflare.Env {}
+
+// Begin runtime types
+/*! *****************************************************************************
+Copyright (c) Cloudflare. All rights reserved.
+Copyright (c) Microsoft Corporation. All rights reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at http://www.apache.org/licenses/LICENSE-2.0
+THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
+WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+MERCHANTABLITY OR NON-INFRINGEMENT.
+See the Apache Version 2.0 License for specific language governing permissions
+and limitations under the License.
+***************************************************************************** */
+/* eslint-disable */
+// noinspection JSUnusedGlobalSymbols
+declare var onmessage: never;
+/**
+ * An abnormal event (called an exception) which occurs as a result of calling a method or accessing a property of a web API.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException)
+ */
+declare class DOMException extends Error {
+ constructor(message?: string, name?: string);
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) */
+ readonly message: string;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) */
+ readonly name: string;
+ /**
+ * @deprecated
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
+ */
+ readonly code: number;
+ static readonly INDEX_SIZE_ERR: number;
+ static readonly DOMSTRING_SIZE_ERR: number;
+ static readonly HIERARCHY_REQUEST_ERR: number;
+ static readonly WRONG_DOCUMENT_ERR: number;
+ static readonly INVALID_CHARACTER_ERR: number;
+ static readonly NO_DATA_ALLOWED_ERR: number;
+ static readonly NO_MODIFICATION_ALLOWED_ERR: number;
+ static readonly NOT_FOUND_ERR: number;
+ static readonly NOT_SUPPORTED_ERR: number;
+ static readonly INUSE_ATTRIBUTE_ERR: number;
+ static readonly INVALID_STATE_ERR: number;
+ static readonly SYNTAX_ERR: number;
+ static readonly INVALID_MODIFICATION_ERR: number;
+ static readonly NAMESPACE_ERR: number;
+ static readonly INVALID_ACCESS_ERR: number;
+ static readonly VALIDATION_ERR: number;
+ static readonly TYPE_MISMATCH_ERR: number;
+ static readonly SECURITY_ERR: number;
+ static readonly NETWORK_ERR: number;
+ static readonly ABORT_ERR: number;
+ static readonly URL_MISMATCH_ERR: number;
+ static readonly QUOTA_EXCEEDED_ERR: number;
+ static readonly TIMEOUT_ERR: number;
+ static readonly INVALID_NODE_TYPE_ERR: number;
+ static readonly DATA_CLONE_ERR: number;
+ get stack(): any;
+ set stack(value: any);
+}
+type WorkerGlobalScopeEventMap = {
+ fetch: FetchEvent;
+ scheduled: ScheduledEvent;
+ queue: QueueEvent;
+ unhandledrejection: PromiseRejectionEvent;
+ rejectionhandled: PromiseRejectionEvent;
+};
+declare abstract class WorkerGlobalScope extends EventTarget {
+ EventTarget: typeof EventTarget;
+}
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) */
+interface Console {
+ "assert"(condition?: boolean, ...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static) */
+ clear(): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) */
+ count(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) */
+ countReset(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) */
+ debug(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) */
+ dir(item?: any, options?: any): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) */
+ dirxml(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) */
+ error(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) */
+ group(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) */
+ groupCollapsed(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) */
+ groupEnd(): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) */
+ info(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) */
+ log(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static) */
+ table(tabularData?: any, properties?: string[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) */
+ time(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) */
+ timeEnd(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) */
+ timeLog(label?: string, ...data: any[]): void;
+ timeStamp(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) */
+ trace(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) */
+ warn(...data: any[]): void;
+}
+declare const console: Console;
+type BufferSource = ArrayBufferView | ArrayBuffer;
+type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array;
+declare namespace WebAssembly {
+ class CompileError extends Error {
+ constructor(message?: string);
+ }
+ class RuntimeError extends Error {
+ constructor(message?: string);
+ }
+ type ValueType = "anyfunc" | "externref" | "f32" | "f64" | "i32" | "i64" | "v128";
+ interface GlobalDescriptor {
+ value: ValueType;
+ mutable?: boolean;
+ }
+ class Global {
+ constructor(descriptor: GlobalDescriptor, value?: any);
+ value: any;
+ valueOf(): any;
+ }
+ type ImportValue = ExportValue | number;
+ type ModuleImports = Record;
+ type Imports = Record;
+ type ExportValue = Function | Global | Memory | Table;
+ type Exports = Record;
+ class Instance {
+ constructor(module: Module, imports?: Imports);
+ readonly exports: Exports;
+ }
+ interface MemoryDescriptor {
+ initial: number;
+ maximum?: number;
+ shared?: boolean;
+ }
+ class Memory {
+ constructor(descriptor: MemoryDescriptor);
+ readonly buffer: ArrayBuffer;
+ grow(delta: number): number;
+ }
+ type ImportExportKind = "function" | "global" | "memory" | "table";
+ interface ModuleExportDescriptor {
+ kind: ImportExportKind;
+ name: string;
+ }
+ interface ModuleImportDescriptor {
+ kind: ImportExportKind;
+ module: string;
+ name: string;
+ }
+ abstract class Module {
+ static customSections(module: Module, sectionName: string): ArrayBuffer[];
+ static exports(module: Module): ModuleExportDescriptor[];
+ static imports(module: Module): ModuleImportDescriptor[];
+ }
+ type TableKind = "anyfunc" | "externref";
+ interface TableDescriptor {
+ element: TableKind;
+ initial: number;
+ maximum?: number;
+ }
+ class Table {
+ constructor(descriptor: TableDescriptor, value?: any);
+ readonly length: number;
+ get(index: number): any;
+ grow(delta: number, value?: any): number;
+ set(index: number, value?: any): void;
+ }
+ function instantiate(module: Module, imports?: Imports): Promise;
+ function validate(bytes: BufferSource): boolean;
+}
+/**
+ * This ServiceWorker API interface represents the global execution context of a service worker.
+ * Available only in secure contexts.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope)
+ */
+interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
+ DOMException: typeof DOMException;
+ WorkerGlobalScope: typeof WorkerGlobalScope;
+ btoa(data: string): string;
+ atob(data: string): string;
+ setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
+ setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+ clearTimeout(timeoutId: number | null): void;
+ setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
+ setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+ clearInterval(timeoutId: number | null): void;
+ queueMicrotask(task: Function): void;
+ structuredClone(value: T, options?: StructuredSerializeOptions): T;
+ reportError(error: any): void;
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+ self: ServiceWorkerGlobalScope;
+ crypto: Crypto;
+ caches: CacheStorage;
+ scheduler: Scheduler;
+ performance: Performance;
+ Cloudflare: Cloudflare;
+ readonly origin: string;
+ Event: typeof Event;
+ ExtendableEvent: typeof ExtendableEvent;
+ CustomEvent: typeof CustomEvent;
+ PromiseRejectionEvent: typeof PromiseRejectionEvent;
+ FetchEvent: typeof FetchEvent;
+ TailEvent: typeof TailEvent;
+ TraceEvent: typeof TailEvent;
+ ScheduledEvent: typeof ScheduledEvent;
+ MessageEvent: typeof MessageEvent;
+ CloseEvent: typeof CloseEvent;
+ ReadableStreamDefaultReader: typeof ReadableStreamDefaultReader;
+ ReadableStreamBYOBReader: typeof ReadableStreamBYOBReader;
+ ReadableStream: typeof ReadableStream;
+ WritableStream: typeof WritableStream;
+ WritableStreamDefaultWriter: typeof WritableStreamDefaultWriter;
+ TransformStream: typeof TransformStream;
+ ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy;
+ CountQueuingStrategy: typeof CountQueuingStrategy;
+ ErrorEvent: typeof ErrorEvent;
+ MessageChannel: typeof MessageChannel;
+ MessagePort: typeof MessagePort;
+ EventSource: typeof EventSource;
+ ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest;
+ ReadableStreamDefaultController: typeof ReadableStreamDefaultController;
+ ReadableByteStreamController: typeof ReadableByteStreamController;
+ WritableStreamDefaultController: typeof WritableStreamDefaultController;
+ TransformStreamDefaultController: typeof TransformStreamDefaultController;
+ CompressionStream: typeof CompressionStream;
+ DecompressionStream: typeof DecompressionStream;
+ TextEncoderStream: typeof TextEncoderStream;
+ TextDecoderStream: typeof TextDecoderStream;
+ Headers: typeof Headers;
+ Body: typeof Body;
+ Request: typeof Request;
+ Response: typeof Response;
+ WebSocket: typeof WebSocket;
+ WebSocketPair: typeof WebSocketPair;
+ WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair;
+ AbortController: typeof AbortController;
+ AbortSignal: typeof AbortSignal;
+ TextDecoder: typeof TextDecoder;
+ TextEncoder: typeof TextEncoder;
+ navigator: Navigator;
+ Navigator: typeof Navigator;
+ URL: typeof URL;
+ URLSearchParams: typeof URLSearchParams;
+ URLPattern: typeof URLPattern;
+ Blob: typeof Blob;
+ File: typeof File;
+ FormData: typeof FormData;
+ Crypto: typeof Crypto;
+ SubtleCrypto: typeof SubtleCrypto;
+ CryptoKey: typeof CryptoKey;
+ CacheStorage: typeof CacheStorage;
+ Cache: typeof Cache;
+ FixedLengthStream: typeof FixedLengthStream;
+ IdentityTransformStream: typeof IdentityTransformStream;
+ HTMLRewriter: typeof HTMLRewriter;
+}
+declare function addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void;
+declare function removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void;
+/**
+ * Dispatches a synthetic event event to target and returns true if either event's cancelable attribute value is false or its preventDefault() method was not invoked, and false otherwise.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
+ */
+declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap]): boolean;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */
+declare function btoa(data: string): string;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */
+declare function atob(data: string): string;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
+declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
+declare function setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */
+declare function clearTimeout(timeoutId: number | null): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
+declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
+declare function setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */
+declare function clearInterval(timeoutId: number | null): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */
+declare function queueMicrotask(task: Function): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */
+declare function structuredClone(value: T, options?: StructuredSerializeOptions): T;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */
+declare function reportError(error: any): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */
+declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+declare const self: ServiceWorkerGlobalScope;
+/**
+* The Web Crypto API provides a set of low-level functions for common cryptographic tasks.
+* The Workers runtime implements the full surface of this API, but with some differences in
+* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)
+* compared to those implemented in most browsers.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)
+*/
+declare const crypto: Crypto;
+/**
+* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
+*/
+declare const caches: CacheStorage;
+declare const scheduler: Scheduler;
+/**
+* The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
+* as well as timing of subrequests and other operations.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
+*/
+declare const performance: Performance;
+declare const Cloudflare: Cloudflare;
+declare const origin: string;
+declare const navigator: Navigator;
+interface TestController {
+}
+interface ExecutionContext {
+ waitUntil(promise: Promise): void;
+ passThroughOnException(): void;
+ readonly props: Props;
+}
+type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise;
+type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise;
+type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise;
+interface ExportedHandler {
+ fetch?: ExportedHandlerFetchHandler;
+ tail?: ExportedHandlerTailHandler;
+ trace?: ExportedHandlerTraceHandler;
+ tailStream?: ExportedHandlerTailStreamHandler;
+ scheduled?: ExportedHandlerScheduledHandler;
+ test?: ExportedHandlerTestHandler;
+ email?: EmailExportedHandler;
+ queue?: ExportedHandlerQueueHandler;
+}
+interface StructuredSerializeOptions {
+ transfer?: any[];
+}
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent) */
+declare abstract class PromiseRejectionEvent extends Event {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise) */
+ readonly promise: Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason) */
+ readonly reason: any;
+}
+declare abstract class Navigator {
+ sendBeacon(url: string, body?: (ReadableStream | string | (ArrayBuffer | ArrayBufferView) | Blob | FormData | URLSearchParams | URLSearchParams)): boolean;
+ readonly userAgent: string;
+ readonly hardwareConcurrency: number;
+ readonly language: string;
+ readonly languages: string[];
+}
+interface AlarmInvocationInfo {
+ readonly isRetry: boolean;
+ readonly retryCount: number;
+}
+interface Cloudflare {
+ readonly compatibilityFlags: Record;
+}
+interface DurableObject {
+ fetch(request: Request): Response | Promise;
+ alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise;
+ webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise;
+ webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise;
+ webSocketError?(ws: WebSocket, error: unknown): void | Promise;
+}
+type DurableObjectStub = Fetcher & {
+ readonly id: DurableObjectId;
+ readonly name?: string;
+};
+interface DurableObjectId {
+ toString(): string;
+ equals(other: DurableObjectId): boolean;
+ readonly name?: string;
+}
+declare abstract class DurableObjectNamespace {
+ newUniqueId(options?: DurableObjectNamespaceNewUniqueIdOptions): DurableObjectId;
+ idFromName(name: string): DurableObjectId;
+ idFromString(id: string): DurableObjectId;
+ get(id: DurableObjectId, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub;
+ getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub;
+ jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace;
+}
+type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high";
+interface DurableObjectNamespaceNewUniqueIdOptions {
+ jurisdiction?: DurableObjectJurisdiction;
+}
+type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me";
+interface DurableObjectNamespaceGetDurableObjectOptions {
+ locationHint?: DurableObjectLocationHint;
+}
+interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> {
+}
+interface DurableObjectState {
+ waitUntil(promise: Promise): void;
+ readonly props: Props;
+ readonly id: DurableObjectId;
+ readonly storage: DurableObjectStorage;
+ container?: Container;
+ blockConcurrencyWhile(callback: () => Promise): Promise;
+ acceptWebSocket(ws: WebSocket, tags?: string[]): void;
+ getWebSockets(tag?: string): WebSocket[];
+ setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void;
+ getWebSocketAutoResponse(): WebSocketRequestResponsePair | null;
+ getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null;
+ setHibernatableWebSocketEventTimeout(timeoutMs?: number): void;
+ getHibernatableWebSocketEventTimeout(): number | null;
+ getTags(ws: WebSocket): string[];
+ abort(reason?: string): void;
+}
+interface DurableObjectTransaction {
+ get(key: string, options?: DurableObjectGetOptions): Promise;
+ get(keys: string[], options?: DurableObjectGetOptions): Promise