Compare commits

..

No commits in common. "main" and "v0.6.0" have entirely different histories.
main ... v0.6.0

22 changed files with 1382 additions and 1061 deletions

View file

@ -1,4 +1,3 @@
HOST=0.0.0.0
ORIGIN=https://dl.emersa.it
YTDLP_PATH=./yt-dlp
PUBLIC_VERSION=0.6.3
ORIGIN=http://example.com
HOST=0.0.0.0

View file

@ -17,5 +17,6 @@ jobs:
node-version: '20'
- name: Prepare, Check, Lint and Format
run: |
npm ci
npm install
npm run prepare
npm run check

View file

@ -2,7 +2,7 @@ name: Bump deps (only minor versions)
on:
schedule:
- cron: '0 23 * * *' # Runs every night at midnight
- cron: '0 0 * * *' # Runs every night at midnight (UTC)
workflow_dispatch:
jobs:
@ -23,7 +23,7 @@ jobs:
- name: Update deps, install them (to change package-lock.json) and commit
run: |
npx npm-check-updates --target minor -u
npm ci
npm install
git config --global user.name "forgejo-bot"
git config --global user.email "bot@pweapon.org"
git add package.json || exit 0

View file

@ -21,24 +21,14 @@ jobs:
with:
node-version: '20'
- name: Install JQ for extracting package.json version
- name: Prepare, Check, Lint and Format
run: |
apt-get update
apt-get install -y jq
- name: Create Release
run: |
npm ci
touch .env
echo "HOST=0.0.0.0" >> .env
echo "ORIGIN=https://dl.emersa.it" >> .env
echo "PUBLIC_VERSION=$(cat package.json | jq .version)" >> .env
npm install
npm run build
cp .env.example build/.env
cp package* build/
cp .env build/
mkdir releases
cd build/
tar czvf ../releases/build.tar.gz .
tar czvf releases/build.tar.gz build/
- name: Upload release
uses: actions/forgejo-release@v2

View file

@ -39,4 +39,3 @@ It's a svelte(kit) frontend that uses `yt-dlp` to let the user download things f
- Containerfile for container build
- Source spotify (spotdl)
- Parse URL info without `youtube-dl-exec`
- Dockerfile inside the forgejo release action

View file

@ -5,9 +5,9 @@ After=network.target
[Service]
User=user
Group=user
WorkingDirectory=/home/user/downloader
EnvironmentFile=/home/user/downloader/.env
ExecStart=/usr/bin/node /home/user/downloader
WorkingDirectory=<PROJECT_ROOT>
ExecStart=/usr/bin/node <PROJECT_ROOT>
Environment=ORIGIN=http://example.com
Restart=always
RestartSec=10
StandardOutput=syslog
@ -16,4 +16,3 @@ SyslogIdentifier=downloader
[Install]
WantedBy=multi-user.target

1993
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,12 @@
{
"name": "dl.emersa.it",
"private": true,
"version": "0.8.0",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"download-yt-dlp": "wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O yt-dlp ; chmod +x yt-dlp",
"preview": "vite preview",
"download-yt-dlp": "wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O yt-dlp",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@ -15,32 +14,31 @@
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.7",
"@eslint/js": "^9.21.0",
"@eslint/compat": "^1.2.6",
"@eslint/js": "^9.20.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.17.2",
"@sveltejs/kit": "^2.17.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.8",
"eslint": "^9.21.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.13.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^16.0.0",
"mdsvex": "^0.12.3",
"prettier": "^3.5.2",
"globals": "^15.15.0",
"prettier": "^3.5.1",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.20.2",
"svelte": "^5.20.0",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.0.8",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.1",
"vite": "^6.1.1"
"typescript-eslint": "^8.24.0",
"vite": "^6.1.0"
},
"dependencies": {
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.0.8",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.13.5",
"youtube-dl-exec": "^3.0.15"
}
}

View file

@ -1,5 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {}
tailwindcss: {},
autoprefixer: {}
}
};

View file

@ -2,11 +2,31 @@
__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NO_DELETE=false
SSH_SERVER="example"
PROJECT_ROOT=example_directory
SSH_REMOTE_DIR="${SSH_SERVER}:${PROJECT_ROOT}"
ssh "${SSH_SERVER}" "mkdir -p ${PROJECT_ROOT}"
rsync "${__dir}/install.sh" "${SSH_SERVER}:${PROJECT_ROOT}/install.sh"
ssh "${SSH_SERVER}" "${PROJECT_ROOT}/install.sh ${PROJECT_ROOT}"
ssh "${SSH_SERVER}" "systemctl restart downloader"
for arg in "$@"
do
if [ "$arg" == "--no-delete" ]; then
NO_DELETE=true
fi
done
if [ "$NO_DELETE" = false ]; then
echo "Deleting node_modules..."
rm -rf node_modules/
else
echo "Skipping deletion of node_modules."
fi
npm ci
npm run build
rsync -r --delete --progress build/ "${SSH_REMOTE_DIR}"
rsync package.json "${SSH_REMOTE_DIR}"
rsync package-lock.json "${SSH_REMOTE_DIR}"
ssh "${SSH_SERVER}" "cd ${PROJECT_ROOT}; npm ci"
ssh "${SSH_SERVER}" "systemctl restart downloader"

View file

@ -1,18 +0,0 @@
#!/usr/bin/bash
PROJECT_ROOT=$1
if [ -z ${PROJECT_ROOT+x} ]; then
echo "error no project_root set"
exit 1
fi
mkdir -p "${PROJECT_ROOT}"
cd "${PROJECT_ROOT}" || exit 1
rm -rf ./*
wget https://git.pweapon.org/odo/dl.emersa.it/releases/download/latest/build.tar.gz
tar -xvf build.tar.gz -C .
rm build.tar.gz
npm ci
npm run download-yt-dlp
echo "YTDLP_PATH=$(readlink -f yt-dlp)" >>.env

View file

@ -1,22 +1,3 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

View file

@ -11,9 +11,23 @@
font-family: 'Press Start 2P';
src: url('/fonts/PressStart2P-Regular.ttf');
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
body {
background: black; /* Fallback */
/* background-image:
radial-gradient(circle, #37ff1456 8%, transparent 8%),
radial-gradient(circle, #37ff1456 8%, transparent 8%); */
background-size:
60px 60px,
30px 30px; /* Larger grid sizes for more spacing */
background-position:
0 0,
15px 15px; /* Offset the smaller pattern slightly */
font-family: 'Press Start 2P', sans-serif;
color: #37ff1456; /* Retro green text */
margin: 0;

View file

@ -1,6 +0,0 @@
{
"mp3": "audio/mpeg",
"mp4": "video/mp4",
"opus": "audio/ogg",
"wav": "audio/wav"
}

View file

@ -1,8 +1,6 @@
<div
class=" backdrop-blur-xs bg-black/20 absolute inset-0 z-10 flex items-center justify-center "
class="absolute inset-0 z-10 flex items-center justify-center bg-white bg-opacity-50"
id="spinner"
>
<div class="w-[150px]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><linearGradient id="a3"><stop offset="0" stop-color="#FF156D" stop-opacity="0"></stop><stop offset="1" stop-color="#FF156D"></stop></linearGradient><circle fill="none" stroke="url(#a3)" stroke-width="15" stroke-linecap="round" stroke-dasharray="0 44 0 44 0 44 0 44 0 360" cx="100" cy="100" r="70" transform-origin="center"><animateTransform type="rotate" attributeName="transform" calcMode="discrete" dur="2" values="360;324;288;252;216;180;144;108;72;36" repeatCount="indefinite"></animateTransform></circle></svg>
</div>
<div class="h-20 w-20 animate-spin rounded-full border-b-2 border-t-2 border-gray-900"></div>
</div>

View file

@ -1,13 +0,0 @@
import formats from '$lib/common/supportedFormats.json';
const formatMime = new Map(Object.entries(formats))
export const isURLValid = (url: string) => {
try {
new URL(url)
} catch {
return false
}
return true;
}
export const mimeTypeMap = formatMime;

View file

@ -1,9 +1,6 @@
import { create } from 'youtube-dl-exec';
import { env } from '$env/dynamic/private';
import { spawn } from 'node:child_process';
import supportedFormats from '$lib/common/supportedFormats.json';
import { mimeTypeMap } from '$lib/server/helpers';
const YTDLP_PATH: string = env.YTDLP_PATH as string;
export const ytdl = create(YTDLP_PATH);
@ -24,28 +21,20 @@ export async function getYouTubeMetadata(link: string) {
* Streams the YouTube video/audio using youtube-dl-exec
*/
export function streamYouTube(link: string, format: string): ReadableStream<Uint8Array> {
const mimeType: string | undefined = mimeTypeMap.get(format)
if (!mimeType) {
throw new Error("Unsupported format");
}
return new ReadableStream({
start(controller) {
const args = [
'-o',
'-',
format === 'mp3' ? '--embed-metadata' : '',
'--format',
format === 'mp3' ? 'bestaudio' : 'best',
'--audio-format',
format === 'mp3' ? 'mp3' : '',
'--no-playlist'
].filter(Boolean);
if(mimeType?.includes('audio')) {
args.push(...['--extract-audio', '--embed-metadata', '--embed-thumbnail', '--audio-format', format])
} else if (mimeType.includes('video')) {
args.push(...['--embed-metadata', '--embed-thumbnail', '--format', format])
}
console.info(`${YTDLP_PATH} ${args.join(' ')} ${link}`)
const process = spawn(YTDLP_PATH, [...args, link], { stdio: ['ignore', 'pipe', 'ignore'] });
const process = spawn('yt-dlp', [...args, link], { stdio: ['ignore', 'pipe', 'ignore'] });
process.stdout.on('data', (chunk) => controller.enqueue(chunk));
process.stdout.on('end', () => controller.close());

View file

@ -1,80 +1,34 @@
<script lang="ts">
import { PUBLIC_VERSION } from '$env/static/public';
import supportedFormats from '$lib/common/supportedFormats.json';
import Loader from '$lib/components/Loader.svelte';
import { onMount } from 'svelte';
let source = $state('youtube');
let link = $state('');
let format = $state('mp3');
let showModal = $state(false);
let href = $state('');
let disabled = $state(true);
let metadata = $state(true);
let downloading = $state(false);
const formats = Object.keys(supportedFormats).map((f) => {
return { value: f, label: f.toUpperCase() };
});
// let formats = ['ogg', 'mp3', 'mp4']
const formats = [{ value: 'mp3', label: 'MP3' }];
const toggleModal = () => {
showModal = !showModal;
};
onMount(() => {
document.cookie = 'downloading=0'
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
const onClick = () => {
link = '';
downloading = true;
document.cookie = 'downloading=1'
const id = setInterval(() => {
if (document.cookie.includes('downloading=0')) {
console.log("FINITO!")
downloading = false && clearInterval(id);
}
}, 1000);
};
const createAnchor = () => {
console.log({
source,
link,
format,
metadata
format
});
if (!(source && link && format)) {
disabled = true;
return;
}
try {
new URL(link);
disabled = false;
} catch (err) {
/*
if (err.code === 'ERR_INVALID_URL') {
}
*/
disabled = true;
return;
}
const searchParams = new URLSearchParams();
searchParams.append('source', source);
searchParams.append('link', link);
searchParams.append('format', format);
searchParams.append('metadata', metadata);
href = `/download?${searchParams.toString()}`;
};
$effect(() => {
createAnchor();
// Auto selected the radio button based on url regex
if (link.includes('spotify')) {
source = 'spotify';
@ -83,18 +37,15 @@
}
});
</script>
<div
id="wrapper"
class="relative mx-auto rounded-lg bg-black p-6 text-[#00ff7f] shadow-lg sm:max-w-sm sm:border-4 sm:border-[#00ff7f] md:mt-10 md:max-w-md lg:max-w-lg 2xl:max-w-2xl"
class="relative mx-auto rounded-lg bg-black p-6 text-green-500 shadow-lg sm:max-w-sm sm:border-4 sm:border-green-500 md:mt-10 md:max-w-md lg:max-w-lg 2xl:max-w-2xl"
>
<div id="loader" class={[{ downloading }]}>
<Loader />
</div>
<!-- Info Icon -->
<button
onclick={toggleModal}
class="absolute top-3 right-3 text-[#ff007f] transition hover:text-[#ff3399]"
class="absolute right-3 top-3 text-pink-500 transition hover:text-pink-300"
aria-label="Open Info Modal"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="h-6 w-6" viewBox="0 0 24 24">
@ -104,11 +55,11 @@
</svg>
</button>
<h1 id="title" class="mb-6 text-center text-2xl text-[#00e5ff]">🐙 Scaricatore 🐙</h1>
<form class="space-y-6">
<h1 id="title" class="mb-6 text-center text-xl">🐙 Scaricatore 🐙</h1>
<form class="space-y-6" onsubmit={handleSubmit}>
<!-- Source Selection -->
<fieldset class="space-y-4">
<legend class="text-[#00e5ff]">Choose Source:</legend>
<legend class="text-green-400">Choose Source:</legend>
<label class="flex items-center space-x-3">
<input type="radio" name="source" value="youtube" bind:group={source} class="retro-radio" />
@ -130,23 +81,20 @@
<label class="flex items-center space-x-3">
<input type="radio" name="source" value="other" bind:group={source} class="retro-radio" />
<span>
Other
<!--
(<a
Other (<a
href="https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md"
target="_blank"
rel="noopener noreferrer"
class="text-[#ff3399] hover:underline"
class="text-pink-500 hover:underline"
>supported sites
</a>)
-->
</span>
</label>
</fieldset>
<!-- Link Input -->
<div>
<label for="link" class="mb-2 block text-[#00e5ff]"> Enter Playlist or Video Link: </label>
<label for="link" class="mb-2 block text-green-400"> Enter Playlist or Video Link: </label>
<input
name="link"
type="url"
@ -154,18 +102,18 @@
bind:value={link}
required
placeholder="Paste your link here"
class="w-full rounded-lg border-4 border-[#00ff7f] bg-[#001a00] px-4 py-3 text-[#00ff7f] focus:border-[#ff3399] focus:outline-none"
class="w-full rounded-lg border-4 border-green-500 bg-green-200 px-4 py-3 text-black focus:border-pink-500 focus:outline-none"
/>
</div>
<!-- Format Selection -->
<div>
<label for="format" class="mb-2 block text-[#00e5ff]"> Choose Format: </label>
<label for="format" class="mb-2 block text-green-400"> Choose Format: </label>
<select
id="format"
name="format"
bind:value={format}
class="w-full rounded-lg border-4 border-[#00ff7f] bg-[#001a00] px-4 py-3 text-[#00ff7f] focus:border-[#ff3399] focus:outline-none"
class="w-full rounded-lg border-4 border-green-500 bg-green-200 px-4 py-3 text-black focus:border-pink-500 focus:outline-none"
>
{#each formats as format}
<option value={format.value}>{format.label}</option>
@ -173,50 +121,39 @@
</select>
</div>
<!-- Metadata -->
<div>
<label for="metadata" class="mb-2 block text-[#00e5ff]"> Filename </label>
<input
type="checkbox"
id="metadata"
name="metadata"
bind:checked={metadata}
class="rounded-lg border-4 border-[#00ff7f] bg-[#001a00] px-4 py-3 text-[#00ff7f] focus:border-[#ff3399] focus:outline-none"
/>
</div>
<a
id="btn-download"
{href}
onclick={onClick}
class="{disabled
? 'disabled'
: ''} block w-full rounded-lg border-4 border-[#ff3399] bg-[#ff007f] px-4 py-3 text-center text-4xl text-black transition hover:bg-[#ff3399] active:border-yellow-500"
<!-- Submit Button -->
<button
type="submit"
class="w-full rounded-lg border-4 border-pink-700 bg-pink-500 px-4 py-3 text-black transition hover:bg-pink-600 active:border-yellow-500"
>
DOWNLOAD
</a>
Create download link
</button>
{#if href}
<a class="download-link" {href}> Download </a>
{/if}
</form>
</div>
<!-- Modal -->
{#if showModal}
<div class="bg-opacity-80 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-80">
<div
class="w-4/5 max-w-lg rounded-lg border-4 border-[#00ff7f] bg-[#002b00] p-6 text-center text-[#00ff7f]"
class="w-4/5 max-w-lg rounded-lg border-4 border-green-500 bg-green-900 p-6 text-center text-green-100"
>
<h2 class="mb-4 text-lg">🐙 Scaricatore v{PUBLIC_VERSION} 🐙</h2>
<h2 class="mb-4 text-lg">🐙 Scaricatore 🐙</h2>
<p>
This app allows you to download Spotify playlists and YouTube videos directly. Choose your
source, paste the link, and select a format to start downloading!
</p>
<span class="mt-10 block">
<a class="underline text-[#00e5ff]" href="https://git.pweapon.org/odo/dl.emersa.it"
<a class="underline" href="https://git.pweapon.org/odo/dl.emersa.it"
>Click here for the source code</a
>
</span>
<button
onclick={toggleModal}
class="mt-6 rounded-lg border-4 border-[#ff3399] bg-[#ff007f] px-4 py-2 text-black hover:bg-[#ff3399]"
class="mt-6 rounded-lg border-4 border-pink-700 bg-pink-500 px-4 py-2 text-black hover:bg-pink-600"
>
Close
</button>
@ -226,15 +163,15 @@
<style>
* {
font-size: 14px;
font-family: 'Press Start 2P', cursive;
font-size: 12px;
}
#loader {
display: none;
}
#loader.downloading {
.download-link {
margin: 0 auto;
padding: 5px;
display: block;
text-decoration: underline;
text-align: center;
}
.not-available {
text-decoration-line: line-through;
@ -244,17 +181,17 @@
.retro-radio {
appearance: none;
background-color: #000;
border: 2px solid #00ff7f;
border: 2px solid #39ff14;
width: 20px;
height: 20px;
cursor: pointer;
}
.retro-radio:checked {
background-color: #00ff7f;
background-color: #39ff14;
box-shadow:
0 0 4px #00ff7f,
0 0 10px #00ff7f;
0 0 4px #39ff14,
0 0 10px #39ff14;
}
input[type='url'],
@ -263,16 +200,6 @@
}
#title {
font-size: 24px;
}
#btn-download {
font-size: 26px;
transition: opacity 0.3s ease;
}
a.disabled {
pointer-events: none;
cursor: default;
opacity: 0.5;
font-size: 22px;
}
</style>

View file

@ -1,14 +1,12 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getYouTubeMetadata, streamYouTube, ytdl } from '$lib/server/ytdlp';
import { isURLValid, mimeTypeMap } from '$lib/server/helpers';
import { getYouTubeMetadata, streamYouTube } from '$lib/server/ytdlp';
const validateRequest = (url: URL) => {
export const GET: RequestHandler = async ({ url }) => {
// Get query params
const link = url.searchParams.get('link');
const format = url.searchParams.get('format'); // mp3, mp4
const source = url.searchParams.get('source'); // youtube or spotify
const metadata = url.searchParams.get('metadata');
// Validate input
if (!link || !format || !source) {
@ -19,50 +17,23 @@ const validateRequest = (url: URL) => {
throw error(400, 'Currently, only YouTube is supported');
}
if (!isURLValid(link)) {
throw error(400, 'URL not valid');
}
if (!mimeTypeMap.get(format)) {
throw error(400, 'format not valid');
}
return {
link, format, source, metadata
}
}
export const GET: RequestHandler = async ({ url }) => {
const { format, source, metadata, link } = validateRequest(url)
let filename = `you-clicked-no-metadata-so-i-cant-put-a-correct-name.${format}`;
if (!!metadata) {
try {
// Fetch metadata for filename
const ytMetadata = await getYouTubeMetadata(link);
const { title, uploader } = ytMetadata;
const safeTitle = `${uploader} - ${title}`;
filename = `${safeTitle}.${format}`;
} catch (err) {
console.error(err)
console.error('Error fetching metadata:');
throw error(500, 'Failed to fetch video metadata');
}
}
try {
// Fetch metadata for filename
const metadata = await getYouTubeMetadata(link);
const { title, uploader } = metadata;
const safeTitle = `${uploader} - ${title}`;
const filename = `${safeTitle}.${format}`;
console.log(filename);
// Stream video/audio
return new Response(streamYouTube(link, format), {
headers: {
'Content-Type': "text/event-stream",
'Content-Disposition': `attachment; filename="${filename}"`,
'Set-Cookie': 'downloading=0'
'Content-Type': format === 'mp3' ? 'audio/mpeg' : 'video/mp4',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
} catch (err) {
console.error(err)
console.error('Filed to stream file');
throw error(500, 'Failed to stream file');
console.error('Error fetching metadata:', err);
throw error(500, 'Failed to fetch video metadata');
}
};

View file

@ -1,4 +1,3 @@
import { mdsvex } from 'mdsvex';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
@ -6,13 +5,14 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: [vitePreprocess(), mdsvex()],
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
},
extensions: ['.svelte', '.svx']
}
};
export default config;

13
tailwind.config.ts Normal file
View file

@ -0,0 +1,13 @@
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: [typography, forms]
} satisfies Config;

View file

@ -1,7 +1,6 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit(), tailwindcss()]
plugins: [sveltekit()]
});