From 9dc593af2bd58e9a28543a0309a2a04b8c450463 Mon Sep 17 00:00:00 2001 From: Emmanuel Raviart <emmanuel@raviart.com> Date: Wed, 11 Aug 2021 23:32:27 +0200 Subject: [PATCH] Add link sharing. --- example.env | 3 + package-lock.json | 13 +++ package.json | 1 + src/lib/auditors/config.ts | 1 + src/lib/components/CopyClipboard.svelte | 14 +++ src/lib/components/ShareLinkModal.svelte | 51 ++++++++++ .../components/test_cases/TestCaseView.svelte | 2 +- src/lib/server/config.ts | 2 + src/lib/situations.ts | 57 +++++++++++ src/routes/__layout.svelte | 72 +++++--------- src/routes/index.svelte | 66 +++++++++---- src/routes/simulations/[simulation].json.ts | 92 ++++++++++++++++++ src/routes/simulations/[simulation].svelte | 41 ++++++++ src/routes/simulations/index.json.ts | 95 +++++++++++++++++++ svelte.config.js | 8 ++ 15 files changed, 451 insertions(+), 67 deletions(-) create mode 100644 src/lib/components/CopyClipboard.svelte create mode 100644 src/lib/components/ShareLinkModal.svelte create mode 100644 src/routes/simulations/[simulation].json.ts create mode 100644 src/routes/simulations/[simulation].svelte create mode 100644 src/routes/simulations/index.json.ts diff --git a/example.env b/example.env index c1dd4a048..ed965d086 100644 --- a/example.env +++ b/example.env @@ -57,6 +57,9 @@ PORTAL_URL="https://leximpact.an.fr/" # Set to true when application is behind a proxy PROXY=false +# Directory containing JSON of simulations saved by users +SIMULATIONS_DIR="../simulations" + TITLE="Simulateur socio-fiscal" # Path to file containing description of waterfalls in JSON format diff --git a/package-lock.json b/package-lock.json index 306a36092..1de8d15c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "svelte-check": "^2.0.0", "svelte-dnd-action": "^0.9.8", "svelte-json-tree": "github:eraviart/svelte-json-tree", + "svelte-modals": "^1.0.4", "svelte-preprocess": "^4.6.9", "tailwindcss": "^2.0.3", "tslib": "^2.0.0", @@ -6303,6 +6304,12 @@ "dev": true, "license": "MIT" }, + "node_modules/svelte-modals": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/svelte-modals/-/svelte-modals-1.0.4.tgz", + "integrity": "sha512-nwYEF7PlxX4uqKU1zRCi+fcECxlEOEFGNGcazdyGsFK0LMngrxwqq3q77xBabGNU7DXYxGyDXq/9YHzyHyCAFg==", + "dev": true + }, "node_modules/svelte-preprocess": { "version": "4.7.4", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-4.7.4.tgz", @@ -11496,6 +11503,12 @@ "dev": true, "from": "svelte-json-tree@github:eraviart/svelte-json-tree" }, + "svelte-modals": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/svelte-modals/-/svelte-modals-1.0.4.tgz", + "integrity": "sha512-nwYEF7PlxX4uqKU1zRCi+fcECxlEOEFGNGcazdyGsFK0LMngrxwqq3q77xBabGNU7DXYxGyDXq/9YHzyHyCAFg==", + "dev": true + }, "svelte-preprocess": { "version": "4.7.4", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-4.7.4.tgz", diff --git a/package.json b/package.json index b1bb72974..bdf3a071f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "svelte-check": "^2.0.0", "svelte-dnd-action": "^0.9.8", "svelte-json-tree": "github:eraviart/svelte-json-tree", + "svelte-modals": "^1.0.4", "svelte-preprocess": "^4.6.9", "tailwindcss": "^2.0.3", "tslib": "^2.0.0", diff --git a/src/lib/auditors/config.ts b/src/lib/auditors/config.ts index 90c8ed974..122f80aee 100644 --- a/src/lib/auditors/config.ts +++ b/src/lib/auditors/config.ts @@ -70,6 +70,7 @@ export function auditConfig( "decompositionsPath", "familyEntityKey", "jsonDir", + "simulationsDir", "title", "waterfallsPath", ]) { diff --git a/src/lib/components/CopyClipboard.svelte b/src/lib/components/CopyClipboard.svelte new file mode 100644 index 000000000..01341090a --- /dev/null +++ b/src/lib/components/CopyClipboard.svelte @@ -0,0 +1,14 @@ +<script lang="ts"> + import { onMount } from "svelte" + + export let value + + let textarea + + onMount(() => { + textarea.select() + document.execCommand("copy") + }) +</script> + +<textarea bind:value bind:this={textarea} /> diff --git a/src/lib/components/ShareLinkModal.svelte b/src/lib/components/ShareLinkModal.svelte new file mode 100644 index 000000000..7b376518c --- /dev/null +++ b/src/lib/components/ShareLinkModal.svelte @@ -0,0 +1,51 @@ +<script lang="ts"> + import { session } from "$app/stores" + + import { closeModal } from "svelte-modals" + + import CopyClipboard from "$lib/components/CopyClipboard.svelte" + + export let isOpen: boolean // provided by Modals + export let token: string + + let clipboardElement: HTMLElement + + $: url = new URL(`/simulations/${token}`, $session.baseUrl).toString() + + function copyLink() { + const copyClipboard = new CopyClipboard({ + target: clipboardElement, + props: { value: url }, + }) + copyClipboard.$destroy() + } +</script> + +{#if isOpen} + <div + role="dialog" + class="bottom-0 fixed flex items-center justify-center left-0 pointer-events-none right-0 top-0" + > + <div + class="bg-white flex flex-col justify-between max-w-3xl min-w-[240px] p-4 pointer-events-auto rounded" + > + <h2 class="text-2xl text-center">Sauvegarder / partager</h2> + <section class="text-base text-center"> + <p>Votre simulation est enregistrée.</p> + <p>Vous pouvez y accéder à tout moment à cette adresse :</p> + <code class="text-xs">{url}</code> + <div bind:this={clipboardElement} /> + <button + class="bg-le-bleu p-2 rounded shadow-md text-sm text-white uppercase hover:bg-blue-900" + on:click={copyLink}>Copier le lien</button + > + <p> + Ce lien est public, vous pouvez le partager sur les réseaux sociaux. + </p> + </section> + <div class="flex justify-end mt-8"> + <button on:click={closeModal}>OK</button> + </div> + </div> + </div> +{/if} diff --git a/src/lib/components/test_cases/TestCaseView.svelte b/src/lib/components/test_cases/TestCaseView.svelte index 3780271fb..874ee6df4 100644 --- a/src/lib/components/test_cases/TestCaseView.svelte +++ b/src/lib/components/test_cases/TestCaseView.svelte @@ -441,7 +441,7 @@ </div> </div> <div> - {#if open} + {#if open && variableSummaryByName !== undefined} <div class="flex justify-between bg-gray-100 text-gray-500 "> {#each waterfalls as { advanced: advancedOnly, icon, label, name }} {#if !advancedOnly || advanced} diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index cbc0a482f..5261d1211 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -36,6 +36,7 @@ export interface Config { } portalUrl: string proxy: boolean + simulationsDir: string title: string waterfallsPath: string } @@ -78,6 +79,7 @@ const [validConfig, error] = validateConfig({ }, portalUrl: process.env["PORTAL_URL"], proxy: process.env["PROXY"], + simulationsDir: process.env["SIMULATIONS_DIR"], waterfallsPath: process.env["WATERFALLS_PATH"], title: process.env["TITLE"], }) diff --git a/src/lib/situations.ts b/src/lib/situations.ts index cc3a29d4f..135dfbb4e 100644 --- a/src/lib/situations.ts +++ b/src/lib/situations.ts @@ -38,6 +38,63 @@ export type SituationWithAxes = Situation & { axes?: Axis[][] } +export function buildTestCasesWithoutNonInputVariables( + entityByKey: EntityByKey, + inputInstantsByVariableNameArray: Array<{ + [name: string]: Set<string> + }>, + testCases: Situation[], +): Situation[] { + const entities = Object.values(entityByKey) + const situations = [...testCases] + for (const [situationIndex, situation] of situations.entries()) { + const inputInstantsByVariableName = + inputInstantsByVariableNameArray[situationIndex] + for (const entity of entities) { + let entitySituation = situation[entity.key_plural] + if (entitySituation === undefined) { + continue + } + entitySituation = situation[entity.key_plural] = { ...entitySituation } + const reservedKeys = getPopulationReservedKeys(entity) + for (let [populationId, population] of Object.entries( + entitySituation, + ).sort(([populationId1], [populationId2]) => + populationId1.localeCompare(populationId2), + )) { + population = entitySituation[populationId] = { ...population } + for (const [variableName, variableValueByInstant] of Object.entries( + population, + )) { + if (reservedKeys.has(variableName)) { + continue + } + const inputVariableValueByInstant: { + [instant: string]: boolean | number | string | null + } = {} + const inputInstants = + inputInstantsByVariableName[variableName] ?? new Set<string>() + for (const [instant, variableValue] of Object.entries( + variableValueByInstant, + )) { + if (!inputInstants.has(instant)) { + // Remove calculated value. + continue + } + inputVariableValueByInstant[instant] = variableValue + } + if (Object.keys(inputVariableValueByInstant).length > 0) { + population[variableName] = inputVariableValueByInstant + } else { + delete population[variableName] + } + } + } + } + } + return testCases +} + export function getPopulationReservedKeys(entity: Entity): Set<string> { return new Set( entity.is_person diff --git a/src/routes/__layout.svelte b/src/routes/__layout.svelte index 5f81a663b..3462664e7 100644 --- a/src/routes/__layout.svelte +++ b/src/routes/__layout.svelte @@ -6,6 +6,7 @@ import type { Writable } from "svelte/store" import { writable } from "svelte/store" import Sockette from "sockette" + import { Modals, closeModal } from "svelte-modals" import { browser } from "$app/env" import { page, session } from "$app/stores" @@ -23,7 +24,10 @@ } from "$lib/decompositions" import type { Reform } from "$lib/reforms" import type { PopulationWithoutId, Situation } from "$lib/situations" - import { getPopulationReservedKeys } from "$lib/situations" + import { + buildTestCasesWithoutNonInputVariables, + getPopulationReservedKeys, + } from "$lib/situations" import type { VariableValues } from "$lib/variables" import type { WebSocketByName, WebSocketOpenByName } from "$lib/websockets" @@ -205,54 +209,16 @@ $: if (browser) { // Remove non input variables from test cases. - const entities = Object.values($session.entityByKey as EntityByKey) - const situations = [...$testCases] - for (const [situationIndex, situation] of situations.entries()) { - const inputInstantsByVariableName = - $inputInstantsByVariableNameArray[situationIndex] - for (const entity of entities) { - let entitySituation = situation[entity.key_plural] - if (entitySituation === undefined) { - continue - } - entitySituation = situation[entity.key_plural] = { ...entitySituation } - const reservedKeys = getPopulationReservedKeys(entity) - for (let [populationId, population] of Object.entries( - entitySituation, - ).sort(([populationId1], [populationId2]) => - populationId1.localeCompare(populationId2), - )) { - population = entitySituation[populationId] = { ...population } - for (const [variableName, variableValueByInstant] of Object.entries( - population, - )) { - if (reservedKeys.has(variableName)) { - continue - } - const inputVariableValueByInstant: { - [instant: string]: boolean | number | string | null - } = {} - const inputInstants = - inputInstantsByVariableName[variableName] ?? new Set<string>() - for (const [instant, variableValue] of Object.entries( - variableValueByInstant, - )) { - if (!inputInstants.has(instant)) { - // Remove calculated value. - continue - } - inputVariableValueByInstant[instant] = variableValue - } - if (Object.keys(inputVariableValueByInstant).length > 0) { - population[variableName] = inputVariableValueByInstant - } else { - delete population[variableName] - } - } - } - } - } - localStorage.setItem("testCases", JSON.stringify(situations)) + localStorage.setItem( + "testCases", + JSON.stringify( + buildTestCasesWithoutNonInputVariables( + $session.entityByKey, + $inputInstantsByVariableNameArray, + $testCases, + ), + ), + ) } $: if (browser && matomoConfig !== undefined && $page) { @@ -580,3 +546,11 @@ <div class="mt-[5.5rem]"> <slot /> </div> + +<Modals> + <div + class="bg-black bg-opacity-50 bottom-0 fixed left-0 right-0 top-0" + on:click={closeModal} + slot="backdrop" + /> +</Modals> diff --git a/src/routes/index.svelte b/src/routes/index.svelte index 0c95f8a5d..b7a03c476 100644 --- a/src/routes/index.svelte +++ b/src/routes/index.svelte @@ -11,6 +11,7 @@ import type { EntityByKey, GroupEntity, Waterfall } from "@openfisca/ast" import { getRolePersonsIdKey } from "@openfisca/ast" import { getContext, setContext } from "svelte" + import { openModal } from "svelte-modals" import type { Writable } from "svelte/store" import { writable } from "svelte/store" import { v4 as uuidv4 } from "uuid" @@ -19,6 +20,7 @@ import { page, session } from "$app/stores" import type { ValidSimulationQuery } from "$lib/calculations" import { newSimulationUrl } from "$lib/calculations" + import ShareLinkModal from "$lib/components/ShareLinkModal.svelte" import TestCaseEdit from "$lib/components/test_cases/TestCaseEdit.svelte" import TestCasesPane from "$lib/components/test_cases/TestCasesPane.svelte" import VariableReferredInputsPane from "$lib/components/variables/VariableReferredInputsPane.svelte" @@ -42,7 +44,10 @@ Situation, SituationWithAxes, } from "$lib/situations" - import { getPopulationReservedKeys } from "$lib/situations" + import { + buildTestCasesWithoutNonInputVariables, + getPopulationReservedKeys, + } from "$lib/situations" import type { SelfTargetAProps } from "$lib/urls" import type { WebSocketByName, WebSocketOpenByName } from "$lib/websockets" @@ -403,6 +408,34 @@ ) } + async function shareLink(): Promise<string | null> { + const url = "/simulations.json" + const res = await fetch(url, { + body: JSON.stringify({ + reform: $reform, + testCases: buildTestCasesWithoutNonInputVariables( + $session.entityByKey, + $inputInstantsByVariableNameArray, + $testCases, + ), + }), + headers: { "Content-Type": "application/json; charset=utf-8" }, + method: "POST", + }) + if (!res.ok) { + console.error( + `Error ${ + res.status + } while creating a share link at ${url}\n\n${await res.text()}`, + ) + return + } + const { token } = await res.json() + openModal(ShareLinkModal, { + token, + }) + } + function submit() { // Aggregate every situations into a single one without calculated variables. const aggregatedSituation: SituationWithAxes = {} @@ -691,23 +724,22 @@ <div class="flex"> <button - class="bg-gray-200 text-gray-900 shadow-md hover:bg-gray-400 px-5 mr-2 rounded p-2 uppercase text-sm" - on:click={submit} + class="bg-gray-200 flex justify-center text-gray-900 shadow-md hover:bg-gray-400 px-5 mr-2 rounded p-2 uppercase text-sm" + on:click={shareLink} > - <div class="flex justify-center"> - <!-- Material Icon Share --><svg - class="fill-current lg:mr-2" - xmlns="http://www.w3.org/2000/svg" - height="18px" - viewBox="0 0 24 24" - width="18px" - fill="#000000" - ><path d="M0 0h24v24H0z" fill="none" /><path - d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z" - /></svg - > - <span class="hidden lg:flex"> Sauvegarder / partager</span> - </div> + <!-- Material Icon Share --> + <svg + class="fill-current lg:mr-2" + xmlns="http://www.w3.org/2000/svg" + height="18px" + viewBox="0 0 24 24" + width="18px" + fill="#000000" + ><path d="M0 0h24v24H0z" fill="none" /><path + d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z" + /> + </svg> + <span class="hidden lg:flex"> Sauvegarder / partager</span> </button> <div class="justify-self-end"> diff --git a/src/routes/simulations/[simulation].json.ts b/src/routes/simulations/[simulation].json.ts new file mode 100644 index 000000000..702fb5fb6 --- /dev/null +++ b/src/routes/simulations/[simulation].json.ts @@ -0,0 +1,92 @@ +import type { Audit } from "@auditors/core" +import { + auditRequire, + auditTest, + auditTrimString, + cleanAudit, +} from "@auditors/core" +import type { JsonValue } from "@openfisca/ast" +import type { RequestHandler } from "@sveltejs/kit" +import { createHash } from "crypto" +import fs from "fs-extra" +import path from "path" +import sanitizeFilename from "sanitize-filename" + +import { walkDecompositionsCoreName } from "$lib/decompositions" +import type { Reform } from "$lib/reforms" +import config from "$lib/server/config" +import { decompositionCoreByName, waterfalls } from "$lib/server/decompositions" +import { iterVariableInputVariables } from "$lib/server/variables" +import type { Situation } from "$lib/situations" + +const { simulationsDir } = config + +function auditParams(audit: Audit, dataUnknown: unknown): [unknown, unknown] { + if (dataUnknown == null) { + return [dataUnknown, null] + } + if (typeof dataUnknown !== "object") { + return audit.unexpectedType(dataUnknown, "object") + } + + const data = { ...dataUnknown } + const errors: { [key: string]: unknown } = {} + const remainingKeys = new Set(Object.keys(data)) + + audit.attribute( + data, + "simulation", + true, + errors, + remainingKeys, + auditTrimString, + auditTest( + (value) => /^[0-9a-f]{64}$/.test(value), + "Invalid simulation token", + ), + auditRequire, + ) + + return audit.reduceRemaining(data, errors, remainingKeys) +} + +export const get: RequestHandler = async ({ + params: requestParams, + path: requestPath, +}) => { + const [params, paramsError] = auditParams(cleanAudit, requestParams) + if (paramsError !== null) { + console.error( + `Error in ${requestPath} params:\n${JSON.stringify( + params, + null, + 2, + )}\n\nError:\n${JSON.stringify(paramsError, null, 2)}`, + ) + return { + status: 400, + body: { + error: { + code: 400, + details: paramsError as JsonValue, + message: "Invalid parameters", + path: requestPath, + }, + params: params as JsonValue, + }, + } + } + const { simulation: digest } = params as { simulation: string } + + const simulationFilePath = path.join( + simulationsDir, + digest.substring(0, 2), + `${digest}.json`, + ) + if (!(await fs.pathExists(simulationFilePath))) { + return { body: null, status: 404 } + } + return { + body: await fs.readJson(simulationFilePath), + } +} diff --git a/src/routes/simulations/[simulation].svelte b/src/routes/simulations/[simulation].svelte new file mode 100644 index 000000000..525c19c87 --- /dev/null +++ b/src/routes/simulations/[simulation].svelte @@ -0,0 +1,41 @@ +<script context="module" lang="ts"> + import type { LoadInput, LoadOutput } from "@sveltejs/kit/types/page" + + export async function load({ fetch, page }: LoadInput): Promise<LoadOutput> { + const { simulation: token } = page.params + const url = `/simulations/${token}.json` + const res = await fetch(url) + if (!res.ok) { + return { + status: res.status, + error: new Error(`Could not load ${url}`), + } + } + const simulation = await res.json() + return { + props: { + simulation, + }, + } + } +</script> + +<script lang="ts"> + import { getContext, onMount } from "svelte" + import type { Writable } from "svelte/store" + + import { goto } from "$app/navigation" + import type { Reform } from "$lib/reforms" + import type { Situation } from "$lib/situations" + + export let simulation: { reform: Reform; testCases: Situation[] } + + const reform = getContext("reform") as Writable<Reform> + const testCases = getContext("testCases") as Writable<Situation[]> + + onMount(() => { + $reform = simulation.reform + $testCases = simulation.testCases + goto("/") + }) +</script> diff --git a/src/routes/simulations/index.json.ts b/src/routes/simulations/index.json.ts new file mode 100644 index 000000000..854f28f65 --- /dev/null +++ b/src/routes/simulations/index.json.ts @@ -0,0 +1,95 @@ +import type { Audit } from "@auditors/core" +import { auditCleanArray, auditRequire, cleanAudit } from "@auditors/core" +import type { JsonValue } from "@openfisca/ast" +import type { RequestHandler } from "@sveltejs/kit" +import { createHash } from "crypto" +import fs from "fs-extra" +import path from "path" +import sanitizeFilename from "sanitize-filename" + +import { walkDecompositionsCoreName } from "$lib/decompositions" +import type { Reform } from "$lib/reforms" +import config from "$lib/server/config" +import { decompositionCoreByName, waterfalls } from "$lib/server/decompositions" +import { iterVariableInputVariables } from "$lib/server/variables" +import type { Situation } from "$lib/situations" + +const { simulationsDir } = config + +function auditBody(audit: Audit, dataUnknown: unknown): [unknown, unknown] { + if (dataUnknown == null) { + return [dataUnknown, null] + } + if (typeof dataUnknown !== "object") { + return audit.unexpectedType(dataUnknown, "object") + } + + const data = { ...dataUnknown } + const errors: { [key: string]: unknown } = {} + const remainingKeys = new Set(Object.keys(data)) + + audit.attribute( + data, + "reform", + true, + errors, + remainingKeys, + // TODO + auditRequire, + ) + audit.attribute( + data, + "testCases", + true, + errors, + remainingKeys, + auditCleanArray(), + // TODO + auditRequire, + ) + + return audit.reduceRemaining(data, errors, remainingKeys) +} + +export const post: RequestHandler = async ({ + body: requestBody, + path: requestPath, +}) => { + const [body, bodyError] = auditBody(cleanAudit, requestBody) + if (bodyError !== null) { + console.error( + `Error in ${requestPath} body:\n${JSON.stringify( + body, + null, + 2, + )}\n\nError:\n${JSON.stringify(bodyError, null, 2)}`, + ) + return { + status: 400, + body: { + error: { + code: 400, + details: bodyError as JsonValue, + message: "Invalid body", + path: requestPath, + }, + body: body as JsonValue, + }, + } + } + const bodyJson = JSON.stringify(body, null, 2) + const hash = createHash("sha256") + hash.update(bodyJson) + const digest = hash.digest("hex") + + const simulationDir = path.join(simulationsDir, digest.substring(0, 2)) + const simulationFilePath = path.join(simulationDir, `${digest}.json`) + if (!(await fs.pathExists(simulationFilePath))) { + await fs.ensureDir(simulationDir) + await fs.writeFile(simulationFilePath, bodyJson) + } + + return { + body: { token: digest }, + } +} diff --git a/svelte.config.js b/svelte.config.js index a5a2d5b65..070e68dff 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -13,6 +13,14 @@ const config = { // hydrate the <div id="svelte"> element in src/app.html target: "#svelte", + + vite: { + optimizeDeps: { + // See https://svelte-modals.mattjennings.io/ + // and https://github.com/sveltejs/vite-plugin-svelte/issues/124. + exclude: ["svelte-modals"], + }, + }, }, // Consult https://github.com/sveltejs/svelte-preprocess -- GitLab