diff --git a/backend/app/api/dto/paste_dto.py b/backend/app/api/dto/paste_dto.py index 32aeab4..133beda 100644 --- a/backend/app/api/dto/paste_dto.py +++ b/backend/app/api/dto/paste_dto.py @@ -28,7 +28,8 @@ class CreatePaste(BaseModel): max_length=config.MAX_CONTENT_LENGTH, description="The content of the paste", ) - content_language: PasteContentLanguage = Field( + content_language: str = Field( + min_length=1, description="The language of the content", default=PasteContentLanguage.plain_text, examples=[PasteContentLanguage.plain_text], @@ -63,8 +64,9 @@ class EditPaste(BaseModel): max_length=config.MAX_CONTENT_LENGTH, description="The content of the paste", ) - content_language: PasteContentLanguage | None = Field( + content_language: str | None = Field( None, + min_length=1, description="The language of the content", examples=[PasteContentLanguage.plain_text], ) @@ -88,7 +90,7 @@ class PasteResponse(BaseModel): content: str | None = Field( description="The content of the paste, possible null if the content couldnt be read.", ) - content_language: PasteContentLanguage = Field( + content_language: str | None = Field( description="The language of the content", ) expires_at: datetime | None = Field( diff --git a/backend/app/services/paste_service.py b/backend/app/services/paste_service.py index 01358df..410d865 100644 --- a/backend/app/services/paste_service.py +++ b/backend/app/services/paste_service.py @@ -242,7 +242,7 @@ async def get_paste_by_id(self, paste_id: UUID4) -> PasteResponse | None: id=result.id, title=result.title, content=content, - content_language=PasteContentLanguage(result.content_language), + content_language=result.content_language, created_at=result.created_at, expires_at=result.expires_at, last_updated_at=result.last_updated_at, @@ -288,7 +288,7 @@ async def edit_paste(self, paste_id: UUID4, edit_paste: EditPaste, edit_token: s if edit_paste.title is not None: # Using ellipsis as sentinel for "not provided" result.title = edit_paste.title if edit_paste.content_language is not None: - result.content_language = edit_paste.content_language.value + result.content_language = edit_paste.content_language if edit_paste.is_expires_at_set(): result.expires_at = edit_paste.expires_at @@ -328,7 +328,7 @@ async def edit_paste(self, paste_id: UUID4, edit_paste: EditPaste, edit_token: s id=result.id, title=result.title, content=content, - content_language=PasteContentLanguage(result.content_language), + content_language=result.content_language, expires_at=result.expires_at, created_at=result.created_at, last_updated_at=result.last_updated_at, @@ -417,7 +417,7 @@ async def create_paste(self, paste: CreatePaste, user_data: UserMetaData) -> Pas id=paste_id, title=paste.title, content_path=paste_path, - content_language=paste.content_language.value, + content_language=paste.content_language, expires_at=paste.expires_at, creator_ip=str(user_data.ip), creator_user_agent=user_data.user_agent, @@ -444,7 +444,7 @@ async def create_paste(self, paste: CreatePaste, user_data: UserMetaData) -> Pas id=entity.id, title=entity.title, content=paste.content, - content_language=PasteContentLanguage(entity.content_language), + content_language=entity.content_language, created_at=entity.created_at, last_updated_at=entity.last_updated_at, expires_at=entity.expires_at, diff --git a/frontend/.env.example b/frontend/.env.example index 1441749..5ff4cf8 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,6 @@ # Backend API config API_URL=http://localhost:8000 ORIGIN=http://localhost:3000 # prevent cross-site post req forbidden from frontend because node can't resolve it's origin + +# frontend config +PUBLIC_CONTENT_CHARACTER_LIMIT=100000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 7cf3dd6..4494477 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,6 +2,13 @@ FROM node:20-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" ENV CI=true +RUN apt-get update && apt-get install -y \ + fontconfig \ + fonts-dejavu-core \ + --no-install-recommends \ + && fc-cache -f \ + && rm -rf /var/lib/apt/lists/* + # enable corepacks for automatic package manager installation. Keeps things simple RUN corepack enable COPY . /app @@ -21,6 +28,15 @@ RUN pnpm prune --prod # We don't want to include artifacts etc. so include only build output FROM node:20-slim AS prod + +# add fontconfig and base fonts for sharp SVG rendering - this is for code preview gen +RUN apt-get update && apt-get install -y \ + fontconfig \ + fonts-dejavu-core \ + --no-install-recommends \ + && fc-cache -f \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY --from=build /app/node_modules node_modules COPY --from=build /app/dist dist diff --git a/frontend/src/lib/assets/fonts/CascadiaCode-VariableFont_wght.ttf b/frontend/src/lib/assets/fonts/CascadiaCode-VariableFont_wght.ttf new file mode 100644 index 0000000..27e0058 Binary files /dev/null and b/frontend/src/lib/assets/fonts/CascadiaCode-VariableFont_wght.ttf differ diff --git a/frontend/src/lib/components/code-editor.svelte b/frontend/src/lib/components/code-editor.svelte index 3fbeb13..9e6cc16 100644 --- a/frontend/src/lib/components/code-editor.svelte +++ b/frontend/src/lib/components/code-editor.svelte @@ -1,12 +1,12 @@ + + + + +
DevBin | {data.title} + + + + + + + + + {#if embed} @@ -196,7 +211,8 @@ {/if} diff --git a/frontend/src/routes/paste/[id]/preview.png/+server.ts b/frontend/src/routes/paste/[id]/preview.png/+server.ts index c3238b8..63f28e1 100644 --- a/frontend/src/routes/paste/[id]/preview.png/+server.ts +++ b/frontend/src/routes/paste/[id]/preview.png/+server.ts @@ -4,112 +4,168 @@ import type { Paste } from "$lib/types"; import { env } from "$env/dynamic/private"; import { getUserIpAddress } from "$lib/utils/ip"; import { createHighlighter, type Highlighter } from "shiki"; +import { languageMap } from "$lib/editor-lang"; import sharp from "sharp"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import { read } from "$app/server"; +// import font path from lib +import CascadiaCodePath from "$lib/assets/fonts/CascadiaCode-VariableFont_wght.ttf?url"; + +const SCALE = 2; +const WIDTH = 1200; +const HEIGHT = 630; +const BG = "#0d1117"; +const HEADER_H = 70; +const FONT_SIZE = 13; +const LINE_HEIGHT = 20; +const CHAR_WIDTH = FONT_SIZE * 0.601; +const GUTTER_W = 52; +const CODE_X = GUTTER_W + 12; +const CODE_Y = HEADER_H + FONT_SIZE + 8; +const FADE_H = 10; +const MAX_LINES = Math.floor((HEIGHT - CODE_Y - FADE_H) / LINE_HEIGHT); +const FONT_PATH = "CascadiaCode-VariableFont_wght.ttf"; +const FONT = "Cascadia Code"; +const THEME = "ayu-dark"; let highlighter: Highlighter; +let fontBase64Cache: string | null | undefined = undefined; + async function getHighlighter() { if (!highlighter) { highlighter = await createHighlighter({ - themes: ["github-dark"], - langs: ["yaml", "typescript", "javascript", "python", "json"], + themes: [THEME], + langs: Object.keys(languageMap).filter((l) => l !== "plain_text"), }); } return highlighter; } +function expandTabs(str: string, size = 2) { + return str.replace(/\t/g, " ".repeat(size)); +} + +function escapeXml(str: string) { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +async function loadFontBase64(relativePath: string): Promise { + if (fontBase64Cache !== undefined) return fontBase64Cache; + try { + // read resolves server directories for svelte because static path is not always guaranteed + const response = read(CascadiaCodePath); + const buffer = await response.arrayBuffer(); + fontBase64Cache = Buffer.from(buffer).toString("base64"); + } catch (e) { + console.warn("Could not load font:", e); + fontBase64Cache = null; + } + return fontBase64Cache; +} + +async function buildSvg( + title: string, + language: string, + tokens: ReturnType, +): Promise { + const fg = tokens.fg ?? "#e6edf3"; + const fontBase64 = await loadFontBase64(FONT_PATH); + const filename = escapeXml(`${title}.${language}`); + + let codeRows = ""; + tokens.tokens.forEach((lineTokens, i) => { + const y = CODE_Y + i * LINE_HEIGHT; + let x = CODE_X; + let tspans = ""; + + codeRows += `${i + 1}`; + + for (const token of lineTokens) { + const text = expandTabs(token.content); + tspans += `${escapeXml(text)}`; + x += text.length * CHAR_WIDTH; + } + + if (tspans) { + codeRows += `${tspans}`; + } + }); + + return ` + + ${fontBase64 ? `` : ""} + + + + + + + + + + ${filename} + + + + + ${codeRows} + + +`; +} + export const GET: RequestHandler = async ({ params, request, getClientAddress, }) => { - const client_ip = getUserIpAddress(request, getClientAddress); const { id } = params; + const clientIp = getUserIpAddress(request, getClientAddress); + let title = "DevBin"; - let content = ""; - let content_language = "yaml"; + let content = "// no content"; + let language = "yaml"; - // 1. Fetch Paste Data if (id) { - const response = await ApiService.getPasteByUuidPastesPasteIdGet({ + const { data } = await ApiService.getPasteByUuidPastesPasteIdGet({ baseUrl: env.API_URL, path: { paste_id: id }, - headers: { "X-Forwarded-For": client_ip }, + headers: { "X-Forwarded-For": clientIp }, }); - if (response.data) { - const data = response.data as Paste; - title = data.title || "DevBin"; - content = data.content; - content_language = data.content_language; + if (data) { + const paste = data as Paste; + title = paste.title || "DevBin"; + content = paste.content; + language = paste.content_language; } } const h = await getHighlighter(); - // Increase line count slightly to match the editor screenshot density - const linesToRender = content.split("\n").slice(0, 22); - const tokenResult = h.codeToTokens(linesToRender.join("\n"), { - lang: "yaml", - theme: "github-dark", - }); - - const defaultColor = tokenResult.fg || "#e6edf3"; - const lineHeight = 26; - const charWidth = 9.6; // Calculated for 16px monospace - const startY = 110; - const codeStartX = 75; - - let codeLinesSvg = ""; - tokenResult.tokens.forEach((line, i) => { - const y = startY + i * lineHeight; - - // Active Line Highlight (matching line 16 in your screenshot) - if (i + 1 === 16) { - codeLinesSvg += ``; - } - - // Line Number - codeLinesSvg += `${i + 1}`; - - // Block Folding Icon (v) - const lineStr = line.map((t) => t.content).join(""); - if (lineStr.trim().endsWith(":")) { - codeLinesSvg += `v`; - } - - // Code Content with precise spacing - let currentX = codeStartX; - line.forEach((token) => { - const escaped = token.content - .replace(/&/g, "&") - .replace(//g, ">"); - - codeLinesSvg += `${escaped}`; - - // Advance X based on character count to fix tab/space issues - currentX += token.content.length * charWidth; - }); - }); - - const svgTemplate = ` - - - - ${title} - - - ${codeLinesSvg} - - - - - - - `; - - const pngBuffer = await sharp(Buffer.from(svgTemplate)).png().toBuffer(); - - return new Response(pngBuffer, { + const lang = h.getLoadedLanguages().includes(language as any) + ? (language as any) + : "text"; + + const allLines = content.split("\n").map(expandTabs); + const lines = + allLines.length <= MAX_LINES ? allLines : allLines.slice(0, MAX_LINES); + const tokens = h.codeToTokens(lines.join("\n"), { lang, theme: THEME }); + const svg = await buildSvg(title, language, tokens); + + const png = await sharp(Buffer.from(svg)) + .resize(WIDTH, HEIGHT, { kernel: sharp.kernel.lanczos3 }) + .png({ compressionLevel: 9 }) + .toBuffer(); + + return new Response(png, { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 555b444..910f8c4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,9 +8,6 @@ console.log(process.env.ALLOWED_HOSTS); export default defineConfig({ server: { port: parseInt(process.env.PORT || "3000"), - allowedHosts: [ - "techno-blink-fields-details.trycloudflare.com" - ], }, build: { rollupOptions: {