@jant/core 0.6.10 → 0.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{app-CGHkOdme.js → app-CpmficmQ.js} +531 -204
- package/dist/app-DqKkZenB.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BhHHVvSY.css +2 -0
- package/dist/client/_assets/{client-DYrWuaIk.js → client-Dd9U383b.js} +1 -1
- package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-DkpSdIDz.js} +80 -80
- package/dist/{export-DY1v5Iqu.js → export-Ba7NJImL.js} +92 -92
- package/dist/{github-sync-LefaslGJ.js → github-sync-BD4w2m8-.js} +2 -2
- package/dist/{github-sync-2_T7nbOv.js → github-sync-Cb4_6_i7.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +62 -0
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +139 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +4 -0
- package/src/client/tiptap/inline-image-upload.ts +174 -50
- package/src/client/tiptap/rehost-images.ts +104 -0
- package/src/i18n/locales/public/en.po +10 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +10 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +10 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/upload-sideload.test.ts +78 -0
- package/src/lib/__tests__/url-fetch.test.ts +181 -0
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/media.ts +111 -0
- package/src/styles/ui.css +1 -1
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/layouts/BaseLayout.tsx +12 -0
- package/dist/app-D24n0DoH.js +0 -6
- package/dist/client/_assets/client-xWDl78yi.css +0 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { _ as toPublicPath, a as getSitePathPrefix, b as __exportAll, c as normalizePath, d as sanitizeUrl, f as slugify, g as toPublicHref, h as toAbsoluteSiteUrl, i as getSiteOrigin, m as toAbsoluteAssetUrl, n as extractDisplayDomain, o as isFullUrl, p as stripSitePathPrefix, r as extractDomain, s as isSafeInternalRedirect, t as buildSiteUrl, u as normalizeSiteUrl, v as toSameSitePath } from "./url-BMYO-Zlt.js";
|
|
2
|
-
import { A as JANT_POSITIVE_LOGO_PNG_FILENAME, B as getJantLogoHref, C as
|
|
2
|
+
import { A as JANT_POSITIVE_LOGO_PNG_FILENAME, B as getJantLogoHref, C as toISOString, D as HOME_BRANDING_LINK_LABEL, E as getPublicUrlForProvider, F as getJantBundledAsset, G as base64ToUint8Array, H as JANT_LOGO_PATH_DATA, I as getJantIconFilename, L as getJantIconHref, M as getDefaultJantAppleTouchIconBytes, N as getDefaultJantFaviconIcoBytes, O as HOME_BRANDING_PREFIX, P as getJantBrandPackHref, R as getJantLogoFilename, T as getMediaUrl, U as JANT_LOGO_VIEW_BOX, V as getJantPositiveLogoPngHref, W as arrayBufferToBase64, _ as formatRelativeAge, b as formatYearMonth, d as extractSummaryHtml, f as renderTiptapDocument, g as formatDate, h as escapeHtml, i as tiptapJsonToMarkdown, j as JANT_REPO_URL, k as JANT_BRAND_PACK_FILENAME, l as extractBodyText, m as trimTiptapBody, o as render, p as renderTiptapJson, s as toPlainText, t as createExportService, u as extractSummary, v as formatRelativeTime, w as getImageUrl, x as now, y as formatTime, z as getJantLogoFills } from "./export-Ba7NJImL.js";
|
|
3
3
|
import { S as getTelegramWebhookSecret, T as coalesceDisplayText, _ as getInternalAdminToken, a as getConfiguredSingleSiteUrl, b as getSiteResolutionMode, c as getDevApiToken, d as getHostedControlPlaneBaseUrl, f as getHostedControlPlaneDomainCheckSecret, g as getHostedControlPlaneSsoSecret, h as getHostedControlPlaneProviderLabel$1, i as getConfiguredSingleSitePathPrefix, l as getEnvString, m as getHostedControlPlaneInternalToken, n as getAuthSecret, o as getConfiguredStorageDriver, p as getHostedControlPlaneInternalBaseUrl, r as getConfiguredSingleSiteOrigin, s as getCorsOrigins, u as getGitHubAppConfig, v as getLocalStoragePath, w as shouldUseSecureCookies, x as getTelegramBotPool } from "./env-OHRKGcMj.js";
|
|
4
|
-
import { l as markdownToTiptapJson, o as createGitHubSyncService } from "./github-sync-
|
|
4
|
+
import { l as markdownToTiptapJson, o as createGitHubSyncService } from "./github-sync-Cb4_6_i7.js";
|
|
5
5
|
import { a as listInstallationReposPage, n as getInstallation, o as searchInstallationRepos, t as buildInstallUrl } from "./github-app-BbklkFmU.js";
|
|
6
6
|
import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BgSiE71w.js";
|
|
7
7
|
import { I18n } from "@lingui/core";
|
|
@@ -1913,7 +1913,7 @@ var Hono = class extends Hono$1 {
|
|
|
1913
1913
|
}
|
|
1914
1914
|
//#endregion
|
|
1915
1915
|
//#region src/i18n/locales/public/en.ts
|
|
1916
|
-
var messages$3 = JSON.parse("{\"+4u2g6\":[\"A ready-made 1:1 PNG for decks, mockups, directories, and other square placements.\"],\"+DPYOZ\":[\"Add a link to your main RSS feed. Change what /feed returns in General.\"],\"+G8qqW\":[\"Collection saved.\"],\"+IJm1Z\":[\"Muted\"],\"+Irvp3\":[\"Everything on this page is ready to use for articles, launch posts, directories, and product coverage.\"],\"+Qaboy\":[\"Favicon\"],\"+fWu2O\":[\"A calmer, warmer accent makes the default theme feel quieter and more intentional.\"],\"+nHhRH\":[\"Use \",[\"brandColorName\"]],\"+siMqD\":[\"Journal\"],\"/DFKdU\":[\"Type the quote...\"],\"/PfPLc\":[\"Label (optional)\"],\"/PoNoq\":[\"Edit link\"],\"/Ui2OV\":[\"Use the reverse logo on dark backgrounds.\"],\"/Ybds4\":[\"Primary Jant logo for websites, docs, press coverage, and editorial layouts.\"],\"/rTz0M\":[\"Audio\"],\"0EcUWz\":[\"Discard changes?\"],\"0Lj7or\":[\"Save text attachment?\"],\"0XDp7X\":[\"Links should read clearly without glowing against the page.\"],\"0ieXE7\":[\"Highest rated\"],\"11h9eK\":[\"Includes\"],\"15++NM\":[\"Inline emphasis\"],\"1DBGsz\":[\"Notes\"],\"1NeeWI\":[\"Square assets for avatars, apps, browsers, and shared links\"],\"1THMr2\":[\"Brand pack\"],\"1njn7W\":[\"Light\"],\"2B7HLH\":[\"New post\"],\"2C7mSG\":[\"Collection link\"],\"2ETv7R\":[\"Tune color in a real reading context\"],\"2HbvFp\":[\"Real post components\"],\"2MXb5X\":[\"Field notes on quiet design\"],\"2koDOQ\":[\"Thread accents\"],\"2lKpcz\":[\"Write your first post to get started.\"],\"2q/Q7x\":[\"Visibility\"],\"2sCqzD\":[\"Use this for websites, docs, articles, and other light or neutral surfaces.\"],\"33DClx\":[\"Link to your latest posts. If it comes before Featured, the homepage opens here.\"],\"3Cw1AI\":[\"Add Collection\"],\"3lJk5u\":[\"Keep the artwork unchanged.\"],\"3mdteM\":[\"before deciding whether the accent is carrying too much product energy.\"],\"3neqtf\":[\"Thread accent\"],\"3qkggm\":[\"Fullscreen\"],\"3vMdv3\":[\"This link is reserved. Choose something else.\"],\"3wKq0C\":[\"Couldn't save. Try again in a moment.\"],\"3xi01/\":[\"Look at the footer metadata last, to make sure the accent is not fighting the typography.\"],\"47iMgt\":[\"Editorial interfaces worth borrowing from\"],\"4D09NB\":[\"Link to your collections page\"],\"4HLTdq\":[\"without media\"],\"4J/OYU\":[\"Collection created.\"],\"4eiXo+\":[\"Leave blank to generate one automatically.\"],\"4pV0kE\":[\"Avatar-ready\"],\"51EYZX\":[\"without title\"],\"5dcjwM\":[\"Choose today or an earlier date, or leave it blank to publish now.\"],\"5pAjd8\":[\"Accent should feel present, not loud.\"],\"5sEkBi\":[\"Open raw asset\"],\"6UTABI\":[\"Collection order updated.\"],\"6WAK+2\":[\"Use current date\"],\"6Y4BBO\":[\"An abstract editorial layout in warm paper colors\"],\"6cjUDB\":[\"Brand assets\"],\"6lGV3K\":[\"Show less\"],\"6p0JeQ\":[\"to make sure both still feel like they belong to the same product.\"],\"6sVyMq\":[\"Add a URL before posting this link.\"],\"6yCv8j\":[\"Save these changes to the text attachment, discard them, or keep editing.\"],\"74kJNs\":[\"View earlier notes in this thread\"],\"7DvUqV\":[\"Read the page from top to bottom without looking at the swatches.\"],\"7aris6\":[\"March 15\"],\"7d1a0d\":[\"Public\"],\"7hYXO0\":[\"Use this on dark backgrounds, image-backed surfaces, and any placement where the green logo would lose contrast.\"],\"7kMW54\":[\"Open raw SVG\"],\"7nGhhM\":[\"What's on your mind?\"],\"7vhWI8\":[\"New Password\"],\"87a/t/\":[\"Label\"],\"8Btgys\":[\"Draft deleted.\"],\"8WX0J+\":[\"Your thoughts (optional)\"],\"8ZsakT\":[\"Password\"],\"8bpHix\":[\"Couldn't create your account. Check the details and try again.\"],\"8eC78s\":[\"A calmer accent makes\"],\"8tM8+a\":[\"Save as draft\"],\"90IRF2\":[\"This article is here to answer a specific question: does the default accent still feel calm once it has to carry a full reading experience?\"],\"9SHZas\":[\"Shows 'Settings' when logged in, 'Sign in' when logged out\"],\"9aloPG\":[\"References\"],\"9dr9Nh\":[\"Open external link\"],\"9qWoxS\":[\"Feed\"],\"A1D8Yt\":[\"What the accent should do\"],\"A1taO8\":[\"Search\"],\"A2Vg/u\":[\"Navigation and reading states\"],\"AjHkcv\":[\"Default preview image for social shares and link unfurls.\"],\"AyHO4m\":[\"What's this collection about?\"],\"B1FFMj\":[\"Download Brand Pack\"],\"B495Gs\":[\"Archive\"],\"BdjLtf\":[\"thread\"],\"Bmaby2\":[\"All formats\"],\"C+9df9\":[\"Quoted or highlighted passages should feel like annotations, not warnings.\"],\"C0/57J\":[\"This is the last part of the collection link.\"],\"C4TjpG\":[\"Read less\"],\"CAh1km\":[\"collections\"],\"CH3bgf\":[\"RSS feed for this view\"],\"CT7H2e\":[\"Link to your featured posts. If it comes before Latest, the homepage opens here.\"],\"CmBCXY\":[\"Link updated.\"],\"D4em/+\":[\"Logos\"],\"DHhJ7s\":[\"Previous\"],\"DJLY+/\":[\" and try again.\"],\"DOx286\":[\"Draft restored.\"],\"DPfwMq\":[\"Done\"],\"DSJXZM\":[\"Enter a valid date.\"],\"DYlMYF\":[\"Built-in background\"],\"DoJzLz\":[\"Collections\"],\"Du2B9f\":[\"The default accent should support reading first. Start by comparing it against the\"],\"DxwUcG\":[\"Read the palette as content first\"],\"E3NcGH\":[\"Square logo PNG\"],\"EEYbdt\":[\"Publish\"],\"EGwzOK\":[\"Complete Setup\"],\"EHWwm1\":[\"The default accent should feel written, not branded.\"],\"EO3I6h\":[\"Upload didn't go through. Try again in a moment.\"],\"EQNPYo\":[\"Featured on \",[\"date\"],\" at \",[\"time\"]],\"EQtz4D\":[\"Open a few links and check whether they still feel native to the page.\"],\"EU3tBD\":[\"Link removed.\"],\"EetoJL\":[\"Guide the eye without taking over the layout.\"],\"Eiv3bO\":[\"Buttons can stay steady, but links, thread markers, and subtle emphasis should feel closer to ink on paper than dashboard chrome.\"],\"ElTnWL\":[\"Published on\"],\"EmQw8O\":[\"If this article still feels like a page you want to keep reading, the palette is probably close.\"],\"EsJdRp\":[\"Save theme\"],\"FESYvt\":[\"Describe this for people with visual impairments...\"],\"FEr96N\":[\"Theme\"],\"FGySZL\":[\"The default accent works best when it reads like a fountain-pen underline. Compare it against the\"],\"FM+KeU\":[\"No drafts yet. Save a draft to find it here.\"],\"Fdv5k7\":[\"What to look for while tuning it\"],\"FkMol5\":[\"Featured\"],\"FqCHF/\":[\"Threads\"],\"Fxf4jq\":[\"Description (optional)\"],\"G2u/aQ\":[\"Download official Jant logos, icons, and preview assets.\"],\"GBJzTZ\":[\"Archive\"],\"GX2VMa\":[\"Create your admin account.\"],\"GY/1J4\":[\"Jant fallback canary string\"],\"GbIOhd\":[\"Reply quietly\"],\"GiRWtR\":[\"Why the default accent should feel written, not branded\"],\"GkpIs2\":[\"Remove this link from Collections? The destination won't change.\"],\"GorKul\":[\"Welcome to Jant\"],\"GxkJXS\":[\"Uploading...\"],\"H29JXm\":[\"+ ALT\"],\"H4lgRd\":[\"Authentication isn't set up. Check your server config.\"],\"HFPGej\":[\"No threads match these filters. Try adjusting your selection or clear all filters.\"],\"HG79RB\":[\"Post as Private\"],\"HNEHJP\":[\"Demo credentials are pre-filled — hit Sign In to continue.\"],\"HbAIQc\":[\"A reference link for checking whether the accent feels editorial instead of promotional.\"],\"HrC0ab\":[\"All posts\"],\"Ht1V3q\":[\"For the same reason, inline code should stay neutral. Something like theme.siteAccent = soften(green, 12%) should not suddenly become the loudest thing on the page.\"],\"I22eN0\":[\"Shared links\"],\"I6zLrz\":[\"Use these when you need a transparent square logo, a shaped tile with a built-in background, a browser icon, or a default preview image.\"],\"ICsA6P\":[\"You have unsaved changes\"],\"IUX7p+\":[\"White logo on the Jant green rounded tile for app icon mockups, touch icons, directory listings, and other square placements that should feel softer.\"],\"IagCbF\":[\"URL\"],\"IjnQHI\":[\"with title\"],\"ImOQa9\":[\"Reply\"],\"IsI3kE\":[\"Nothing here yet. Add posts to one of these collections to fill this view.\"],\"J+2Rls\":[\"Leave blank to publish now. Use an earlier date when importing older posts.\"],\"J4tAHl\":[\"Headings should keep their hierarchy even when the accent gets softer.\"],\"JYj5R2\":[\"Browse files\"],\"JcD7qf\":[\"More actions\"],\"JqJ5Xv\":[\"Latest\"],\"JuN5GC\":[\"No file selected. Choose a file to upload.\"],\"JwLPQ/\":[\"This sign-in link has expired. Return to \"],\"KOqvXP\":[\"Do not recolor, stretch, rotate, outline, or add effects to the logo.\"],\"KbS2K9\":[\"Reset Password\"],\"KdSsVl\":[\"Author (optional)\"],\"Khu3PV\":[\"Publish settings\"],\"KiJn9B\":[\"Note\"],\"KlZ+t+\":[\"%name% + %count% more\"],\"KsvRin\":[\"Hide from Latest\"],\"KzmC5L\":[\"Controls\"],\"L7svJg\":[\"Reading\"],\"Lbkbwy\":[\"A quote card for judging accent color against softer, citation-heavy content.\"],\"LcvzvX\":[\"Tap to retry\"],\"LkA8jz\":[\"Add alt text\"],\"LxRg6f\":[\"live theme controls\"],\"M4tzVU\":[\"Latest posts\"],\"M8kJqa\":[\"Drafts\"],\"MHrjPM\":[\"Title\"],\"MILa7n\":[\"Square tile\"],\"MSc/Yq\":[\"Do you want to publish your changes or discard them?\"],\"Mc7+6G\":[\"Enter a valid URL starting with http://, https://, or mailto:.\"],\"MdMyne\":[\"Source link (optional)\"],\"MiMY3Q\":[\"Apple touch icon\"],\"MiyoI7\":[\"default note sample\"],\"MqghUt\":[\"Search posts...\"],\"Myqkib\":[\"Create a collection to get started.\"],\"N8UzTV\":[\"Replies\"],\"NAFbuE\":[\"Search snippet\"],\"NH9Z1R\":[\"Start here\"],\"NqsRbb\":[\"Jant logo\"],\"NvXuWk\":[\"Won't move the thread to the top of latest.\"],\"O1367B\":[\"All collections\"],\"O3oNi5\":[\"Email\"],\"OEdMhi\":[\"The best default color is the one you notice only after reading for a while.\"],\"OEt/to\":[\"Guidelines\"],\"OJxdgi\":[\"Keep this link under 200 characters.\"],\"OaoJcz\":[\"Social preview\"],\"OmfDbR\":[\"Site accent\"],\"Ovks1h\":[\"A softer blue feels more like ink than product chrome.\"],\"P/sHNL\":[\"Use this page to judge buttons, links, cards, forms, thread accents, and quiet surfaces before changing a theme globally.\"],\"Q/uoSA\":[\"Quiet here for now.\"],\"Q2mGA7\":[\"Clear filter\"],\"QBqVyM\":[\"Home screen icon for iPhone and iPad shortcuts.\"],\"QebAts\":[\"Link added.\"],\"Qgbxdw\":[\"Designing a calmer default accent for Jant\"],\"Qn9Ao8\":[\"Circle tile\"],\"Qoq+GP\":[\"Read more\"],\"QyDt3L\":[\"File uploaded.\"],\"R5CMuK\":[\"Jant looks best when the accent feels editorial. Buttons can stay sturdy, but inline emphasis should feel like a pen mark, not a dashboard highlight.\"],\"R8AthW\":[\"Divider\"],\"R9Khdg\":[\"Auto\"],\"RAv3u7\":[\"Compare it against the theme controls\"],\"ROa4Ti\":[\"Interfaces for reading should guide the eye, not keep asking for attention.\"],\"RZOWDv\":[\"Add a custom shortcut to any page or site.\"],\"RdmNnl\":[\"Browser tab\"],\"RfGczC\":[\"Square logo\"],\"Rj01Fz\":[\"Links\"],\"S37om9\":[\"Included assets\"],\"S8NCfs\":[\"Save to drafts to edit and post at a later time.\"],\"SJGVAw\":[\"Feel editorial and slightly quieter.\"],\"SJmfuf\":[\"Site Name\"],\"SaNhJE\":[\"feel deliberate instead of washed out.\"],\"SpTWH3\":[\"Download SVG\"],\"SvRuJt\":[\"Field Notes on Interface Tone\"],\"T/R+Qz\":[\"Primary\"],\"TNZKpI\":[\"Danger\"],\"TvaTxw\":[\"Doesn't appear in Latest. Still appears in collections you add it to.\"],\"UIMXHD\":[\"Remove Divider\"],\"UaZwcz\":[\"More options are available after you create it.\"],\"Uc5y7o\":[\"Choose the standard logo for websites, docs, directories, and editorial layouts.\"],\"V18SVO\":[\"Use the logo on light backgrounds.\"],\"V4WsyL\":[\"Add Link\"],\"VCA6B2\":[\"These are actual feed components with real footers, summaries, and inline links. Use this section to judge whether the theme still feels calm once it is applied to realistic content.\"],\"VNqFYa\":[\"Loading post...\"],\"WCOanD\":[\"This reference is useful because it treats links and citations as part of the reading rhythm. Keep that in mind while tuning the\"],\"WbIbzR\":[\"Checking link...\"],\"WcWS//\":[\"Download file\"],\"WhsN3P\":[\"A good default accent in Jant should feel like editorial structure, not product branding. That means links, emphasis, and thread cues can be visible without turning the page into UI chrome.\"],\"Wn+/rH\":[\"Transparent square\"],\"XU7b+L\":[\"Primary logo files\"],\"XV1mAn\":[\"Only visible when signed in.\"],\"XrnWzN\":[\"Published!\"],\"YIix5Y\":[\"Search...\"],\"YUglt2\":[\"Generating a link...\"],\"YXiA6e\":[\"Primary button\"],\"Ygx3Yl\":[\"Small browser icon used in tabs and bookmarks.\"],\"Z6NwTi\":[\"Save as Draft\"],\"ZGs2so\":[\"Delete this collection permanently? Posts inside won't be removed.\"],\"ZV5ykW\":[\"Download PNG\"],\"ZhhOwV\":[\"Quote\"],\"ZmSeP+\":[\"Save to drafts?\"],\"ZxFuun\":[[\"count\",\"plural\",{\"one\":[\"Found \",\"#\",\" result\"],\"other\":[\"Found \",\"#\",\" results\"]}]],\"a5j82I\":[\"No collections match that search. Try a different name.\"],\"aHTB7P\":[\"Supplementary content attached to your post\"],\"aMEyv0\":[\"Stay sturdy and readable.\"],\"aN6wx0\":[\"Nothing in Featured yet. Mark a post as featured to show it here.\"],\"aYpXKS\":[\"and checking whether the accent is guiding attention or pulling too hard.\"],\"aaGV/9\":[\"New Link\"],\"af+9p6\":[\"Quiet metadata\"],\"an5hVd\":[\"Images\"],\"ao77hr\":[[\"count\",\"plural\",{\"one\":[\"#\",\" hidden post\"],\"other\":[\"#\",\" hidden posts\"]}]],\"auFlOr\":[\"Icons and previews\"],\"avuFKG\":[\"threads\"],\"bFpC86\":[\"Everything in one download\"],\"bGtMpA\":[\"Add a label and URL.\"],\"bHOiy1\":[\"Password changes are off in demo mode. Sign in with the shared demo credentials.\"],\"bbdNeX\":[\"Sign in\"],\"bfCbdi\":[\"Current post\"],\"bkBJmZ\":[\"This is useful as a color check because it puts the accent next to quotation styling, metadata, and a quieter explanatory paragraph. Compare it back to the\"],\"bzSI52\":[\"Discard\"],\"c2JRUS\":[\"Generate automatically\"],\"cIoW7X\":[\"Inline link\"],\"cTUByn\":[\"Newest first\"],\"cb7FR8\":[\"White logo on the Jant green square tile for platforms and layouts that expect a true edge-to-edge square.\"],\"cgmi4V\":[\"Delete Draft\"],\"cnGeoo\":[\"Delete\"],\"d+F4pf\":[\"The image should sit quietly inside the article instead of feeling like a card preview.\"],\"d/o/BH\":[\"Couldn't publish. Saved as draft.\"],\"dD7NPy\":[\"Outline\"],\"dEgA5A\":[\"Cancel\"],\"dHko2w\":[\"single posts\"],\"dUsGbd\":[\"The right accent should disappear into the writing until you need it.\"],\"dXoieq\":[\"Summary\"],\"dYKrp3\":[\"Hidden from Latest\"],\"dbUuAj\":[\"Appears in Latest.\"],\"df4a/r\":[\"Couldn't load this post. Try again.\"],\"ePK91l\":[\"Edit\"],\"eWLklq\":[\"Quotes\"],\"f4MAoA\":[\"Some uploads failed. Saved as draft.\"],\"f5s9EI\":[\"Press N to write\"],\"f6Hub0\":[\"Sort\"],\"f8fH8W\":[\"Design\"],\"fD+f7T\":[\"RSS feed\"],\"fKrDxS\":[\"Brand tile\"],\"fMPkxb\":[\"Show more\"],\"fqDzSu\":[\"Rate\"],\"fttd2R\":[\"My Collection\"],\"gCcxP/\":[\"Threads can include up to \",[\"count\"],\" posts.\"],\"gFdWl+\":[\"A long-form article sample for checking the default palette in a true reading context.\"],\"gNKz6Z\":[\"Collection deleted.\"],\"gXH9r/\":[\"Open raw PNG\"],\"gj52YE\":[\"This collection is empty. Add posts from the editor.\"],\"gpaPhA\":[\"Helps screen readers describe the image\"],\"h5RcXU\":[\"Post hidden\"],\"hLlWo5\":[\"A few simple rules.\"],\"hWpUeY\":[\"Auto link\"],\"hXzOVo\":[\"Next\"],\"heSQoS\":[\"Paste a URL...\"],\"hqeXKW\":[\"Single posts\"],\"hrkGms\":[\"Search\"],\"i0vDGK\":[\"Sort Order\"],\"i5+Y7d\":[\"Download the official Jant logo, icons, and preview files.\"],\"i6kro6\":[\"Edit custom link\"],\"i6nDCI\":[\"Choose a new password.\"],\"iG7KNr\":[\"Logo\"],\"iH8pgl\":[\"Back\"],\"ilSmIt\":[\"Hard edge\"],\"iu7tUI\":[\"Breadcrumb\"],\"jAXE5p\":[\"Reverse logo\"],\"jAqB/k\":[\"Post privately\"],\"jQflRT\":[\"This uses the real single-post detail rendering with a longer article, inline image, tables, lists, quotes, and code. The content column stays at the same width as the live site.\"],\"jd+8Mm\":[\"Social preview image\"],\"jdJOV1\":[\"Settings\"],\"ji7oVU\":[\"Edit post\"],\"jpctdh\":[\"View\"],\"jrsUoG\":[\"Type / for commands\"],\"jvyYZG\":[\"What's on your mind...\"],\"k3Iw35\":[\"Switch to the white logo when the standard green version would lose contrast.\"],\"kPMIr+\":[\"Give it a title...\"],\"kj6ppi\":[\"entry\"],\"kr39oD\":[\"No collections yet. Start one to organize posts by topic.\"],\"kzvWob\":[\"Link to the post archive\"],\"laT1IJ\":[\"iOS home screen\"],\"lb+Xwx\":[\"Custom link\"],\"m16xKo\":[\"Add\"],\"mKT7g0\":[\"Text attachment\"],\"mc/vLq\":[\"This link is already in use. Choose something else.\"],\"muKqfV\":[\"Featured\"],\"n1ekoW\":[\"Sign In\"],\"n3ReIn\":[\"Collections\"],\"n6QD94\":[\"Oldest first\"],\"nFukaP\":[\"Wrong email or password. Check your credentials and try again.\"],\"nV6twc\":[\"Organize\"],\"nd8Puv\":[\"White logo on the Jant green circle for profile images, badges, and other round placements where you want a ready-made asset.\"],\"ndrEYW\":[\"When the accent is slightly warmer and less literal, the whole page feels more like a writing space and less like product UI.\"],\"o21Y+P\":[\"entries\"],\"oO0hKx\":[[\"count\",\"plural\",{\"one\":[\"#\",\" more post\"],\"other\":[\"#\",\" more posts\"]}]],\"oTu7Wt\":[\"Combined Collections\"],\"ode0+L\":[\"Theme sample\"],\"ogssnn\":[\"with media\"],\"ovBPCi\":[\"Default\"],\"p1Z67P\":[\"When primary is too rigid, the whole page starts reading like product UI instead of writing space.\"],\"p2/GCq\":[\"Confirm Password\"],\"pB0OKE\":[\"New Divider\"],\"pBHx39\":[\"Dark backgrounds\"],\"pVrU5x\":[\"If this page feels too branded, the first place to soften is the default theme’s site accent, not the border or body text.\"],\"pvnfJD\":[\"Dark\"],\"q+hNag\":[\"Collection\"],\"q5YRzz\":[\"Color check\"],\"q8RviX\":[\"Titled\"],\"qcawwg\":[\"Publish now\"],\"qiN9NB\":[\"Surface\"],\"qt89I8\":[\"Draft saved.\"],\"quvfGs\":[\"instead of judging it as an isolated swatch.\"],\"r7kcaA\":[\"Drag collections, links, and dividers into the order you want.\"],\"rA2TFI\":[\"Switch the palette and mode without opening settings or changing the active site theme.\"],\"rV8ZnP\":[\"Edit publish date\"],\"rdUucN\":[\"Preview\"],\"s8G5Or\":[\"This upload would exceed your shared hosted media limit. Remove files or upgrade storage to continue.\"],\"s9gHf5\":[\"your-post-link\"],\"sER+bs\":[\"Files\"],\"sQpDn6\":[\"Exit fullscreen\"],\"sgr2wQ\":[\"collection\"],\"slujBW\":[\"Use lowercase letters, numbers, and hyphens only.\"],\"syiAKf\":[\"note treatment\"],\"t42hIC\":[\"Everything most people need is in one ZIP.\"],\"tCctex\":[\"The brand pack includes SVG logos, a transparent square PNG, rounded, square, and circle tiles, plus favicon, Apple touch icon, and the default social preview image.\"],\"tKlWWY\":[\"Emoji\"],\"tSWVu5\":[\"Published on \",[\"date\"],\" at \",[\"time\"]],\"tfDRzk\":[\"Save\"],\"tg5MRw\":[\"Sign in to start writing.\"],\"tgSBSE\":[\"Remove Link\"],\"uowbPn\":[\"Remove attachment\"],\"v3E8iS\":[\"A practical checklist\"],\"vSJd18\":[\"Video\"],\"vSYKYI\":[\"Main feed\"],\"vXCC6J\":[\"Something doesn't look right. Check the form and try again.\"],\"vcpc5o\":[\"Close menu\"],\"vdFnYM\":[\"Reset link\"],\"vdvpU5\":[\"/archive?format=quote or https://example.com\"],\"vgpfCi\":[\"Save draft\"],\"vpSPA1\":[\"Auth secret is missing. Check your environment variables.\"],\"vzU4k9\":[\"New Collection\"],\"w0Emel\":[\"Suggested link\"],\"w6mlns\":[\"Article detail page\"],\"wJ+GRy\":[\"All visibility\"],\"wL3cK8\":[\"Latest\"],\"wja8aL\":[\"Untitled\"],\"wlnK1t\":[\"A single ZIP with the main logo, reverse logo, square PNG, rounded, square, and circle tiles, plus favicon, Apple touch icon, and social preview image.\"],\"wm3Zlr\":[\"All years\"],\"xCWek4\":[\"File storage isn't set up. Check your server config.\"],\"xVrkxi\":[\"quiet design\"],\"xVvw1i\":[\"This reset link is no longer valid. Request a new one to continue.\"],\"xYilR2\":[\"Media\"],\"xeiujy\":[\"Text\"],\"xhTx3y\":[\"Choose the standard logo for most placements and the reverse logo when you need more contrast.\"],\"xjHB3/\":[\"Continue →\"],\"y28hnO\":[\"Post\"],\"y2o/Y0\":[\"This Link Has Expired\"],\"yGZVl1\":[\"More\"],\"yQ2kGp\":[\"Load more\"],\"yUtAh2\":[\"New Thread\"],\"ycM1Xg\":[\"No results. Try different keywords.\"],\"ynMAhG\":[\"Default logo\"],\"yzF66j\":[\"Link\"],\"zBFr9G\":[\"Paste a long article, AI response, or any text...\\n\\nMarkdown formatting will be preserved.\"],\"zJDAbh\":[\"Don't save\"],\"zcDmsG\":[\"Featured posts\"],\"zoK+eO\":[\"Add a title before posting this link.\"],\"zucql+\":[\"Menu\"],\"zwBp5t\":[\"Private\"]}");
|
|
1916
|
+
var messages$3 = JSON.parse("{\"+4u2g6\":[\"A ready-made 1:1 PNG for decks, mockups, directories, and other square placements.\"],\"+DPYOZ\":[\"Add a link to your main RSS feed. Change what /feed returns in General.\"],\"+G8qqW\":[\"Collection saved.\"],\"+IJm1Z\":[\"Muted\"],\"+Irvp3\":[\"Everything on this page is ready to use for articles, launch posts, directories, and product coverage.\"],\"+Qaboy\":[\"Favicon\"],\"+fWu2O\":[\"A calmer, warmer accent makes the default theme feel quieter and more intentional.\"],\"+nHhRH\":[\"Use \",[\"brandColorName\"]],\"+siMqD\":[\"Journal\"],\"/DFKdU\":[\"Type the quote...\"],\"/PfPLc\":[\"Label (optional)\"],\"/PoNoq\":[\"Edit link\"],\"/Ui2OV\":[\"Use the reverse logo on dark backgrounds.\"],\"/Ybds4\":[\"Primary Jant logo for websites, docs, press coverage, and editorial layouts.\"],\"/rTz0M\":[\"Audio\"],\"0EcUWz\":[\"Discard changes?\"],\"0Lj7or\":[\"Save text attachment?\"],\"0XDp7X\":[\"Links should read clearly without glowing against the page.\"],\"0ieXE7\":[\"Highest rated\"],\"11h9eK\":[\"Includes\"],\"15++NM\":[\"Inline emphasis\"],\"1DBGsz\":[\"Notes\"],\"1NeeWI\":[\"Square assets for avatars, apps, browsers, and shared links\"],\"1THMr2\":[\"Brand pack\"],\"1njn7W\":[\"Light\"],\"2B7HLH\":[\"New post\"],\"2C7mSG\":[\"Collection link\"],\"2ETv7R\":[\"Tune color in a real reading context\"],\"2HbvFp\":[\"Real post components\"],\"2MXb5X\":[\"Field notes on quiet design\"],\"2koDOQ\":[\"Thread accents\"],\"2lKpcz\":[\"Write your first post to get started.\"],\"2q/Q7x\":[\"Visibility\"],\"2sCqzD\":[\"Use this for websites, docs, articles, and other light or neutral surfaces.\"],\"33DClx\":[\"Link to your latest posts. If it comes before Featured, the homepage opens here.\"],\"3Cw1AI\":[\"Add Collection\"],\"3lJk5u\":[\"Keep the artwork unchanged.\"],\"3mdteM\":[\"before deciding whether the accent is carrying too much product energy.\"],\"3neqtf\":[\"Thread accent\"],\"3qkggm\":[\"Fullscreen\"],\"3vMdv3\":[\"This link is reserved. Choose something else.\"],\"3wKq0C\":[\"Couldn't save. Try again in a moment.\"],\"3xi01/\":[\"Look at the footer metadata last, to make sure the accent is not fighting the typography.\"],\"47iMgt\":[\"Editorial interfaces worth borrowing from\"],\"4D09NB\":[\"Link to your collections page\"],\"4HLTdq\":[\"without media\"],\"4J/OYU\":[\"Collection created.\"],\"4eiXo+\":[\"Leave blank to generate one automatically.\"],\"4pV0kE\":[\"Avatar-ready\"],\"51EYZX\":[\"without title\"],\"5dcjwM\":[\"Choose today or an earlier date, or leave it blank to publish now.\"],\"5pAjd8\":[\"Accent should feel present, not loud.\"],\"5sEkBi\":[\"Open raw asset\"],\"6UTABI\":[\"Collection order updated.\"],\"6WAK+2\":[\"Use current date\"],\"6Y4BBO\":[\"An abstract editorial layout in warm paper colors\"],\"6cjUDB\":[\"Brand assets\"],\"6lGV3K\":[\"Show less\"],\"6p0JeQ\":[\"to make sure both still feel like they belong to the same product.\"],\"6sVyMq\":[\"Add a URL before posting this link.\"],\"6yCv8j\":[\"Save these changes to the text attachment, discard them, or keep editing.\"],\"74kJNs\":[\"View earlier notes in this thread\"],\"7DvUqV\":[\"Read the page from top to bottom without looking at the swatches.\"],\"7aris6\":[\"March 15\"],\"7d1a0d\":[\"Public\"],\"7hYXO0\":[\"Use this on dark backgrounds, image-backed surfaces, and any placement where the green logo would lose contrast.\"],\"7kMW54\":[\"Open raw SVG\"],\"7nGhhM\":[\"What's on your mind?\"],\"7vhWI8\":[\"New Password\"],\"87a/t/\":[\"Label\"],\"8Btgys\":[\"Draft deleted.\"],\"8WX0J+\":[\"Your thoughts (optional)\"],\"8ZsakT\":[\"Password\"],\"8bpHix\":[\"Couldn't create your account. Check the details and try again.\"],\"8eC78s\":[\"A calmer accent makes\"],\"8tM8+a\":[\"Save as draft\"],\"90IRF2\":[\"This article is here to answer a specific question: does the default accent still feel calm once it has to carry a full reading experience?\"],\"9SHZas\":[\"Shows 'Settings' when logged in, 'Sign in' when logged out\"],\"9aloPG\":[\"References\"],\"9dr9Nh\":[\"Open external link\"],\"9qWoxS\":[\"Feed\"],\"A1D8Yt\":[\"What the accent should do\"],\"A1taO8\":[\"Search\"],\"A2Vg/u\":[\"Navigation and reading states\"],\"AjHkcv\":[\"Default preview image for social shares and link unfurls.\"],\"AyHO4m\":[\"What's this collection about?\"],\"B1FFMj\":[\"Download Brand Pack\"],\"B495Gs\":[\"Archive\"],\"BdjLtf\":[\"thread\"],\"Bmaby2\":[\"All formats\"],\"C+9df9\":[\"Quoted or highlighted passages should feel like annotations, not warnings.\"],\"C0/57J\":[\"This is the last part of the collection link.\"],\"C4TjpG\":[\"Read less\"],\"CAh1km\":[\"collections\"],\"CH3bgf\":[\"RSS feed for this view\"],\"CT7H2e\":[\"Link to your featured posts. If it comes before Latest, the homepage opens here.\"],\"CmBCXY\":[\"Link updated.\"],\"D4em/+\":[\"Logos\"],\"DHhJ7s\":[\"Previous\"],\"DJLY+/\":[\" and try again.\"],\"DOx286\":[\"Draft restored.\"],\"DPfwMq\":[\"Done\"],\"DSJXZM\":[\"Enter a valid date.\"],\"DYlMYF\":[\"Built-in background\"],\"DoJzLz\":[\"Collections\"],\"Du2B9f\":[\"The default accent should support reading first. Start by comparing it against the\"],\"DxwUcG\":[\"Read the palette as content first\"],\"E3NcGH\":[\"Square logo PNG\"],\"EEYbdt\":[\"Publish\"],\"EGwzOK\":[\"Complete Setup\"],\"EHWwm1\":[\"The default accent should feel written, not branded.\"],\"EO3I6h\":[\"Upload didn't go through. Try again in a moment.\"],\"EQNPYo\":[\"Featured on \",[\"date\"],\" at \",[\"time\"]],\"EQtz4D\":[\"Open a few links and check whether they still feel native to the page.\"],\"EU3tBD\":[\"Link removed.\"],\"EetoJL\":[\"Guide the eye without taking over the layout.\"],\"Eiv3bO\":[\"Buttons can stay steady, but links, thread markers, and subtle emphasis should feel closer to ink on paper than dashboard chrome.\"],\"ElTnWL\":[\"Published on\"],\"EmQw8O\":[\"If this article still feels like a page you want to keep reading, the palette is probably close.\"],\"EsJdRp\":[\"Save theme\"],\"FESYvt\":[\"Describe this for people with visual impairments...\"],\"FEr96N\":[\"Theme\"],\"FGySZL\":[\"The default accent works best when it reads like a fountain-pen underline. Compare it against the\"],\"FM+KeU\":[\"No drafts yet. Save a draft to find it here.\"],\"Fdv5k7\":[\"What to look for while tuning it\"],\"FkMol5\":[\"Featured\"],\"FqCHF/\":[\"Threads\"],\"Fxf4jq\":[\"Description (optional)\"],\"G2u/aQ\":[\"Download official Jant logos, icons, and preview assets.\"],\"GBJzTZ\":[\"Archive\"],\"GX2VMa\":[\"Create your admin account.\"],\"GY/1J4\":[\"Jant fallback canary string\"],\"GbIOhd\":[\"Reply quietly\"],\"GiRWtR\":[\"Why the default accent should feel written, not branded\"],\"GkpIs2\":[\"Remove this link from Collections? The destination won't change.\"],\"GorKul\":[\"Welcome to Jant\"],\"GxkJXS\":[\"Uploading...\"],\"H29JXm\":[\"+ ALT\"],\"H4lgRd\":[\"Authentication isn't set up. Check your server config.\"],\"HFPGej\":[\"No threads match these filters. Try adjusting your selection or clear all filters.\"],\"HG79RB\":[\"Post as Private\"],\"HNEHJP\":[\"Demo credentials are pre-filled — hit Sign In to continue.\"],\"HbAIQc\":[\"A reference link for checking whether the accent feels editorial instead of promotional.\"],\"HrC0ab\":[\"All posts\"],\"Ht1V3q\":[\"For the same reason, inline code should stay neutral. Something like theme.siteAccent = soften(green, 12%) should not suddenly become the loudest thing on the page.\"],\"I22eN0\":[\"Shared links\"],\"I6zLrz\":[\"Use these when you need a transparent square logo, a shaped tile with a built-in background, a browser icon, or a default preview image.\"],\"ICsA6P\":[\"You have unsaved changes\"],\"IUX7p+\":[\"White logo on the Jant green rounded tile for app icon mockups, touch icons, directory listings, and other square placements that should feel softer.\"],\"IagCbF\":[\"URL\"],\"IjnQHI\":[\"with title\"],\"ImOQa9\":[\"Reply\"],\"IsI3kE\":[\"Nothing here yet. Add posts to one of these collections to fill this view.\"],\"J+2Rls\":[\"Leave blank to publish now. Use an earlier date when importing older posts.\"],\"J4tAHl\":[\"Headings should keep their hierarchy even when the accent gets softer.\"],\"JYj5R2\":[\"Browse files\"],\"JcD7qf\":[\"More actions\"],\"JqJ5Xv\":[\"Latest\"],\"JuN5GC\":[\"No file selected. Choose a file to upload.\"],\"JwLPQ/\":[\"This sign-in link has expired. Return to \"],\"KOqvXP\":[\"Do not recolor, stretch, rotate, outline, or add effects to the logo.\"],\"KbS2K9\":[\"Reset Password\"],\"KdSsVl\":[\"Author (optional)\"],\"Khu3PV\":[\"Publish settings\"],\"KiJn9B\":[\"Note\"],\"KlZ+t+\":[\"%name% + %count% more\"],\"KsvRin\":[\"Hide from Latest\"],\"KzmC5L\":[\"Controls\"],\"L7svJg\":[\"Reading\"],\"Lbkbwy\":[\"A quote card for judging accent color against softer, citation-heavy content.\"],\"LcvzvX\":[\"Tap to retry\"],\"LkA8jz\":[\"Add alt text\"],\"LxRg6f\":[\"live theme controls\"],\"M4tzVU\":[\"Latest posts\"],\"M8kJqa\":[\"Drafts\"],\"MHrjPM\":[\"Title\"],\"MILa7n\":[\"Square tile\"],\"MSc/Yq\":[\"Do you want to publish your changes or discard them?\"],\"Mc7+6G\":[\"Enter a valid URL starting with http://, https://, or mailto:.\"],\"MdMyne\":[\"Source link (optional)\"],\"MiMY3Q\":[\"Apple touch icon\"],\"MiyoI7\":[\"default note sample\"],\"MqghUt\":[\"Search posts...\"],\"Myqkib\":[\"Create a collection to get started.\"],\"N8UzTV\":[\"Replies\"],\"NAFbuE\":[\"Search snippet\"],\"NH9Z1R\":[\"Start here\"],\"NqsRbb\":[\"Jant logo\"],\"NvXuWk\":[\"Won't move the thread to the top of latest.\"],\"O1367B\":[\"All collections\"],\"O3oNi5\":[\"Email\"],\"OEdMhi\":[\"The best default color is the one you notice only after reading for a while.\"],\"OEt/to\":[\"Guidelines\"],\"OJxdgi\":[\"Keep this link under 200 characters.\"],\"OaoJcz\":[\"Social preview\"],\"OmfDbR\":[\"Site accent\"],\"Ovks1h\":[\"A softer blue feels more like ink than product chrome.\"],\"P/sHNL\":[\"Use this page to judge buttons, links, cards, forms, thread accents, and quiet surfaces before changing a theme globally.\"],\"Q/uoSA\":[\"Quiet here for now.\"],\"Q2mGA7\":[\"Clear filter\"],\"QBqVyM\":[\"Home screen icon for iPhone and iPad shortcuts.\"],\"QebAts\":[\"Link added.\"],\"Qgbxdw\":[\"Designing a calmer default accent for Jant\"],\"Qn9Ao8\":[\"Circle tile\"],\"Qoq+GP\":[\"Read more\"],\"QyDt3L\":[\"File uploaded.\"],\"R5CMuK\":[\"Jant looks best when the accent feels editorial. Buttons can stay sturdy, but inline emphasis should feel like a pen mark, not a dashboard highlight.\"],\"R8AthW\":[\"Divider\"],\"R9Khdg\":[\"Auto\"],\"RAv3u7\":[\"Compare it against the theme controls\"],\"ROa4Ti\":[\"Interfaces for reading should guide the eye, not keep asking for attention.\"],\"RZOWDv\":[\"Add a custom shortcut to any page or site.\"],\"RdmNnl\":[\"Browser tab\"],\"RfGczC\":[\"Square logo\"],\"Rj01Fz\":[\"Links\"],\"S37om9\":[\"Included assets\"],\"S8NCfs\":[\"Save to drafts to edit and post at a later time.\"],\"SJGVAw\":[\"Feel editorial and slightly quieter.\"],\"SJmfuf\":[\"Site Name\"],\"SaNhJE\":[\"feel deliberate instead of washed out.\"],\"SpTWH3\":[\"Download SVG\"],\"SvRuJt\":[\"Field Notes on Interface Tone\"],\"T/R+Qz\":[\"Primary\"],\"TNZKpI\":[\"Danger\"],\"TvaTxw\":[\"Doesn't appear in Latest. Still appears in collections you add it to.\"],\"UIMXHD\":[\"Remove Divider\"],\"UaZwcz\":[\"More options are available after you create it.\"],\"Uc5y7o\":[\"Choose the standard logo for websites, docs, directories, and editorial layouts.\"],\"V18SVO\":[\"Use the logo on light backgrounds.\"],\"V4WsyL\":[\"Add Link\"],\"VCA6B2\":[\"These are actual feed components with real footers, summaries, and inline links. Use this section to judge whether the theme still feels calm once it is applied to realistic content.\"],\"VNqFYa\":[\"Loading post...\"],\"WCOanD\":[\"This reference is useful because it treats links and citations as part of the reading rhythm. Keep that in mind while tuning the\"],\"WbIbzR\":[\"Checking link...\"],\"WcWS//\":[\"Download file\"],\"WhsN3P\":[\"A good default accent in Jant should feel like editorial structure, not product branding. That means links, emphasis, and thread cues can be visible without turning the page into UI chrome.\"],\"Wn+/rH\":[\"Transparent square\"],\"XU7b+L\":[\"Primary logo files\"],\"XV1mAn\":[\"Only visible when signed in.\"],\"XrnWzN\":[\"Published!\"],\"Y7WAtz\":[\"An image couldn't be saved to your library — its original link was kept.\"],\"YIix5Y\":[\"Search...\"],\"YUglt2\":[\"Generating a link...\"],\"YXiA6e\":[\"Primary button\"],\"Ygx3Yl\":[\"Small browser icon used in tabs and bookmarks.\"],\"Z6NwTi\":[\"Save as Draft\"],\"ZGs2so\":[\"Delete this collection permanently? Posts inside won't be removed.\"],\"ZV5ykW\":[\"Download PNG\"],\"ZhhOwV\":[\"Quote\"],\"ZmSeP+\":[\"Save to drafts?\"],\"ZxFuun\":[[\"count\",\"plural\",{\"one\":[\"Found \",\"#\",\" result\"],\"other\":[\"Found \",\"#\",\" results\"]}]],\"a5j82I\":[\"No collections match that search. Try a different name.\"],\"aHTB7P\":[\"Supplementary content attached to your post\"],\"aMEyv0\":[\"Stay sturdy and readable.\"],\"aN6wx0\":[\"Nothing in Featured yet. Mark a post as featured to show it here.\"],\"aYpXKS\":[\"and checking whether the accent is guiding attention or pulling too hard.\"],\"aaGV/9\":[\"New Link\"],\"af+9p6\":[\"Quiet metadata\"],\"an5hVd\":[\"Images\"],\"ao77hr\":[[\"count\",\"plural\",{\"one\":[\"#\",\" hidden post\"],\"other\":[\"#\",\" hidden posts\"]}]],\"auFlOr\":[\"Icons and previews\"],\"avuFKG\":[\"threads\"],\"bFpC86\":[\"Everything in one download\"],\"bGtMpA\":[\"Add a label and URL.\"],\"bHOiy1\":[\"Password changes are off in demo mode. Sign in with the shared demo credentials.\"],\"bbdNeX\":[\"Sign in\"],\"bfCbdi\":[\"Current post\"],\"bkBJmZ\":[\"This is useful as a color check because it puts the accent next to quotation styling, metadata, and a quieter explanatory paragraph. Compare it back to the\"],\"bzSI52\":[\"Discard\"],\"c2JRUS\":[\"Generate automatically\"],\"cIoW7X\":[\"Inline link\"],\"cTUByn\":[\"Newest first\"],\"cb7FR8\":[\"White logo on the Jant green square tile for platforms and layouts that expect a true edge-to-edge square.\"],\"cgmi4V\":[\"Delete Draft\"],\"cnGeoo\":[\"Delete\"],\"d+F4pf\":[\"The image should sit quietly inside the article instead of feeling like a card preview.\"],\"d/o/BH\":[\"Couldn't publish. Saved as draft.\"],\"dD7NPy\":[\"Outline\"],\"dEgA5A\":[\"Cancel\"],\"dHko2w\":[\"single posts\"],\"dUsGbd\":[\"The right accent should disappear into the writing until you need it.\"],\"dXoieq\":[\"Summary\"],\"dYKrp3\":[\"Hidden from Latest\"],\"dbUuAj\":[\"Appears in Latest.\"],\"df4a/r\":[\"Couldn't load this post. Try again.\"],\"ePK91l\":[\"Edit\"],\"eWLklq\":[\"Quotes\"],\"f4MAoA\":[\"Some uploads failed. Saved as draft.\"],\"f5s9EI\":[\"Press N to write\"],\"f6Hub0\":[\"Sort\"],\"f8fH8W\":[\"Design\"],\"fD+f7T\":[\"RSS feed\"],\"fKrDxS\":[\"Brand tile\"],\"fMPkxb\":[\"Show more\"],\"fqDzSu\":[\"Rate\"],\"fttd2R\":[\"My Collection\"],\"gCcxP/\":[\"Threads can include up to \",[\"count\"],\" posts.\"],\"gFdWl+\":[\"A long-form article sample for checking the default palette in a true reading context.\"],\"gNKz6Z\":[\"Collection deleted.\"],\"gXH9r/\":[\"Open raw PNG\"],\"gj52YE\":[\"This collection is empty. Add posts from the editor.\"],\"gpaPhA\":[\"Helps screen readers describe the image\"],\"h5RcXU\":[\"Post hidden\"],\"hLlWo5\":[\"A few simple rules.\"],\"hWpUeY\":[\"Auto link\"],\"hXzOVo\":[\"Next\"],\"heSQoS\":[\"Paste a URL...\"],\"hqeXKW\":[\"Single posts\"],\"hrkGms\":[\"Search\"],\"i0vDGK\":[\"Sort Order\"],\"i5+Y7d\":[\"Download the official Jant logo, icons, and preview files.\"],\"i6kro6\":[\"Edit custom link\"],\"i6nDCI\":[\"Choose a new password.\"],\"iG7KNr\":[\"Logo\"],\"iH8pgl\":[\"Back\"],\"ilSmIt\":[\"Hard edge\"],\"iu7tUI\":[\"Breadcrumb\"],\"jAXE5p\":[\"Reverse logo\"],\"jAqB/k\":[\"Post privately\"],\"jQflRT\":[\"This uses the real single-post detail rendering with a longer article, inline image, tables, lists, quotes, and code. The content column stays at the same width as the live site.\"],\"jd+8Mm\":[\"Social preview image\"],\"jdJOV1\":[\"Settings\"],\"ji7oVU\":[\"Edit post\"],\"jpctdh\":[\"View\"],\"jrsUoG\":[\"Type / for commands\"],\"jvyYZG\":[\"What's on your mind...\"],\"k3Iw35\":[\"Switch to the white logo when the standard green version would lose contrast.\"],\"kPMIr+\":[\"Give it a title...\"],\"kj6ppi\":[\"entry\"],\"kr39oD\":[\"No collections yet. Start one to organize posts by topic.\"],\"kzvWob\":[\"Link to the post archive\"],\"laT1IJ\":[\"iOS home screen\"],\"lb+Xwx\":[\"Custom link\"],\"m16xKo\":[\"Add\"],\"mKT7g0\":[\"Text attachment\"],\"mc/vLq\":[\"This link is already in use. Choose something else.\"],\"muKqfV\":[\"Featured\"],\"n1ekoW\":[\"Sign In\"],\"n3ReIn\":[\"Collections\"],\"n6QD94\":[\"Oldest first\"],\"nFukaP\":[\"Wrong email or password. Check your credentials and try again.\"],\"nJ4U1U\":[[\"count\"],\" images couldn't be saved to your library — their original links were kept.\"],\"nV6twc\":[\"Organize\"],\"nd8Puv\":[\"White logo on the Jant green circle for profile images, badges, and other round placements where you want a ready-made asset.\"],\"ndrEYW\":[\"When the accent is slightly warmer and less literal, the whole page feels more like a writing space and less like product UI.\"],\"o21Y+P\":[\"entries\"],\"oO0hKx\":[[\"count\",\"plural\",{\"one\":[\"#\",\" more post\"],\"other\":[\"#\",\" more posts\"]}]],\"oTu7Wt\":[\"Combined Collections\"],\"ode0+L\":[\"Theme sample\"],\"ogssnn\":[\"with media\"],\"ovBPCi\":[\"Default\"],\"p1Z67P\":[\"When primary is too rigid, the whole page starts reading like product UI instead of writing space.\"],\"p2/GCq\":[\"Confirm Password\"],\"pB0OKE\":[\"New Divider\"],\"pBHx39\":[\"Dark backgrounds\"],\"pVrU5x\":[\"If this page feels too branded, the first place to soften is the default theme’s site accent, not the border or body text.\"],\"pvnfJD\":[\"Dark\"],\"q+hNag\":[\"Collection\"],\"q5YRzz\":[\"Color check\"],\"q8RviX\":[\"Titled\"],\"qcawwg\":[\"Publish now\"],\"qiN9NB\":[\"Surface\"],\"qt89I8\":[\"Draft saved.\"],\"quvfGs\":[\"instead of judging it as an isolated swatch.\"],\"r7kcaA\":[\"Drag collections, links, and dividers into the order you want.\"],\"rA2TFI\":[\"Switch the palette and mode without opening settings or changing the active site theme.\"],\"rV8ZnP\":[\"Edit publish date\"],\"rdUucN\":[\"Preview\"],\"s8G5Or\":[\"This upload would exceed your shared hosted media limit. Remove files or upgrade storage to continue.\"],\"s9gHf5\":[\"your-post-link\"],\"sER+bs\":[\"Files\"],\"sQpDn6\":[\"Exit fullscreen\"],\"sgr2wQ\":[\"collection\"],\"slujBW\":[\"Use lowercase letters, numbers, and hyphens only.\"],\"syiAKf\":[\"note treatment\"],\"t42hIC\":[\"Everything most people need is in one ZIP.\"],\"tCctex\":[\"The brand pack includes SVG logos, a transparent square PNG, rounded, square, and circle tiles, plus favicon, Apple touch icon, and the default social preview image.\"],\"tKlWWY\":[\"Emoji\"],\"tSWVu5\":[\"Published on \",[\"date\"],\" at \",[\"time\"]],\"tfDRzk\":[\"Save\"],\"tg5MRw\":[\"Sign in to start writing.\"],\"tgSBSE\":[\"Remove Link\"],\"uowbPn\":[\"Remove attachment\"],\"v3E8iS\":[\"A practical checklist\"],\"vSJd18\":[\"Video\"],\"vSYKYI\":[\"Main feed\"],\"vXCC6J\":[\"Something doesn't look right. Check the form and try again.\"],\"vcpc5o\":[\"Close menu\"],\"vdFnYM\":[\"Reset link\"],\"vdvpU5\":[\"/archive?format=quote or https://example.com\"],\"vgpfCi\":[\"Save draft\"],\"vpSPA1\":[\"Auth secret is missing. Check your environment variables.\"],\"vzU4k9\":[\"New Collection\"],\"w0Emel\":[\"Suggested link\"],\"w6mlns\":[\"Article detail page\"],\"wJ+GRy\":[\"All visibility\"],\"wL3cK8\":[\"Latest\"],\"wja8aL\":[\"Untitled\"],\"wlnK1t\":[\"A single ZIP with the main logo, reverse logo, square PNG, rounded, square, and circle tiles, plus favicon, Apple touch icon, and social preview image.\"],\"wm3Zlr\":[\"All years\"],\"xCWek4\":[\"File storage isn't set up. Check your server config.\"],\"xVrkxi\":[\"quiet design\"],\"xVvw1i\":[\"This reset link is no longer valid. Request a new one to continue.\"],\"xYilR2\":[\"Media\"],\"xeiujy\":[\"Text\"],\"xhTx3y\":[\"Choose the standard logo for most placements and the reverse logo when you need more contrast.\"],\"xjHB3/\":[\"Continue →\"],\"y28hnO\":[\"Post\"],\"y2o/Y0\":[\"This Link Has Expired\"],\"yGZVl1\":[\"More\"],\"yQ2kGp\":[\"Load more\"],\"yUtAh2\":[\"New Thread\"],\"ycM1Xg\":[\"No results. Try different keywords.\"],\"ynMAhG\":[\"Default logo\"],\"yzF66j\":[\"Link\"],\"zBFr9G\":[\"Paste a long article, AI response, or any text...\\n\\nMarkdown formatting will be preserved.\"],\"zJDAbh\":[\"Don't save\"],\"zcDmsG\":[\"Featured posts\"],\"zoK+eO\":[\"Add a title before posting this link.\"],\"zucql+\":[\"Menu\"],\"zwBp5t\":[\"Private\"]}");
|
|
1917
1917
|
//#endregion
|
|
1918
1918
|
//#region src/i18n/locales/settings/en.ts
|
|
1919
1919
|
var messages$2 = JSON.parse("{\"++YsxG\":[\"Readers and search engines see\"],\"+4Z6iP\":[\"Create the repository on GitHub first — it can be empty.\"],\"+9JI/F\":[\"Connecting will sync your site onto \",[\"repo\"],\"'s default branch on top of its existing history. Existing files outside Jant's managed paths are kept. This can't be undone.\"],\"+AXdXp\":[\"Label and URL are required\"],\"+K0AvT\":[\"Disconnect\"],\"+wgt7C\":[\"Muted red-brown background, earthy and warm.\"],\"+zy2Nq\":[\"Type\"],\"/3H2/s\":[\"This hosted site signs in through \",[\"providerLabel\"],\". Manage password and hosted access there.\"],\"/JnyjR\":[\"Toggle built-in navigation items. Their order controls what shows in the header and which feed the homepage opens first.\"],\"/PXXBT\":[\"Nearly white, with a touch of warmth.\"],\"/zOUxl\":[\"QR code linking to the Telegram bot\"],\"0OGSSc\":[\"Avatar display updated.\"],\"0UzCUX\":[\"Update the password you use to sign in\"],\"0bdA9b\":[\"Open Telegram to connect\"],\"0uIjy/\":[\"Light sage-green with a matching green accent.\"],\"10UtuM\":[\"CJK Font\"],\"1F6Mzc\":[\"No navigation items yet. Add links or enable system items below.\"],\"1H7gng\":[\"Dashboard language\"],\"1mbBbL\":[\"Connect manually\"],\"1njn7W\":[\"Light\"],\"1qGdnL\":[\"The default. Calm and easy for long reading.\"],\"2B7t+s\":[\"Sessions and password\"],\"2DoBvq\":[\"Feeds\"],\"2FYpfJ\":[\"More\"],\"2Ithfh\":[\"Message the bot any text and it's published as a note.\"],\"2PTjMB\":[\"I want to delete \",[\"siteName\"]],\"2cFU6q\":[\"Site Footer\"],\"2fPEPI\":[\"A calm, cool backdrop.\"],\"2lkk2l\":[\"Yellow-tinted paper with an olive-green accent.\"],\"2oWZo7\":[\"Last commit\"],\"2uuy4H\":[\"Connected via Personal Access Token\"],\"35x8eZ\":[\"Showing \",[\"shown\"],\" of \",[\"total\"]],\"39QGku\":[\"Open the bot and send the binding code, then anything you message it becomes a note.\"],\"3Cw1AI\":[\"Add Collection\"],\"3VrybB\":[\"Redirect\"],\"3Yvsaz\":[\"302 (Temporary)\"],\"3n0zbB\":[\"Session management is off in demo mode. Use the shared demo session instead.\"],\"3sYJi5\":[\"Download a Hugo-compatible archive — host it statically or move to another Jant.\"],\"3wKq0C\":[\"Couldn't save. Try again in a moment.\"],\"49Bsal\":[\"Feed settings updated.\"],\"4Jge8E\":[\"Active Sessions\"],\"4KIa+q\":[\"Export downloaded.\"],\"4cEClj\":[\"Sessions\"],\"4yyXWu\":[\"Pale bone-white with a muted green accent.\"],\"4zGJ5E\":[\"Delete Account Permanently\"],\"5QlUIt\":[\"Empty repository. Ready to connect.\"],\"5VQnR3\":[\"Use these when you want a feed URL that never changes.\"],\"5dpcN1\":[\"type to search all\"],\"5f1Wo9\":[\"Connected as \",[\"account\"]],\"5o8R81\":[\"Very light ivory with a faint tea-green accent.\"],\"6ArdBh\":[\"Uses featured posts for /feed.\"],\"6DjeBT\":[\"Demo sites always stay hidden from search engines.\"],\"6E3aK4\":[\"Manage Hosting\"],\"6FFB7q\":[\"Uses the latest public posts for /feed.\"],\"6K1Vef\":[\"Delete this blog permanently? This cannot be undone.\"],\"6NpNLc\":[\"This repository has existing content.\"],\"6V3Ea3\":[\"Copied\"],\"71WIgc\":[\"Get a new code\"],\"746NHh\":[\"this blog\"],\"7811AW\":[\"This repository is already backing up this site.\"],\"7FCZPo\":[\"Content language\"],\"7FaY4u\":[\"Usage\"],\"7G9YLi\":[\"Allow search engines to index my site\"],\"7GISOt\":[\"Save bot token\"],\"7MZxzw\":[\"Password changed.\"],\"7vhWI8\":[\"New Password\"],\"81nFIS\":[\"Passwords don't match. Make sure both fields are identical.\"],\"87a/t/\":[\"Label\"],\"89Upyo\":[\"That theme isn't available. Pick another one.\"],\"8BfEpW\":[\"Hosted account\"],\"8N/Mcp\":[\"Archive filter parameters (e.g. format=note&view=list)\"],\"8T46pB\":[\"Bot token\"],\"8U2Z7f\":[\"New Custom URL\"],\"8ZsakT\":[\"Password\"],\"8gEghq\":[\"Bright warm-white with a moss-green accent.\"],\"9+vGLh\":[\"Custom CSS\"],\"9As8Nu\":[\"Create one on GitHub\"],\"9EjI4j\":[\"Warm sand\"],\"9Lsvt5\":[\"Signed in \",[\"date\"]],\"9T7Cwm\":[\"Redirects, vanity paths, and URL control\"],\"9aUyym\":[\"See where you're signed in and revoke old sessions\"],\"A1taO8\":[\"Search\"],\"AeXO77\":[\"Account\"],\"AnY+O9\":[\"Show \\\"Build with Jant\\\" at the bottom of the home page\"],\"ApZDMk\":[\"This is used for your favicon and apple-touch-icon. For best results, upload a square PNG with a solid background at least 512x512 pixels.\"],\"B495Gs\":[\"Archive\"],\"B4ESok\":[\"API reference\"],\"BzEFor\":[\"or\"],\"CDAdlf\":[\"Remove bot\"],\"CTAEes\":[\"Select a repository\"],\"CjZZgz\":[\"This repository already has commits\"],\"D8k2s6\":[\"Connect Telegram\"],\"DCKkhU\":[\"Current Password\"],\"DKKKeF\":[\"Manage password and hosted access in \",[\"providerLabel\"]],\"DVFXa6\":[\"The cleanest, most neutral option.\"],\"EO3I6h\":[\"Upload didn't go through. Try again in a moment.\"],\"Eax/et\":[\"Accent\"],\"Enslfm\":[\"Destination\"],\"F53UDu\":[\"Cool white\"],\"F7FKwe\":[\"Anything you paste here has full access to your visitors' browsers. Only use code from sources you trust.\"],\"F8SNqr\":[\"Warm orange\"],\"Fk3SSD\":[\"Calm and natural, easy on the eyes.\"],\"FkMol5\":[\"Featured\"],\"G/1oP+\":[\"Remove the webhook and stop syncing. Your repository content will not be deleted.\"],\"G0qJsQ\":[\"Security token missing. Refresh the page and try again.\"],\"G39wnK\":[\"Back up and sync content with a GitHub repository\"],\"GMMWcy\":[\"Name, metadata, language, and search defaults\"],\"GXsAby\":[\"Revoke\"],\"GxkJXS\":[\"Uploading...\"],\"GzKzUa\":[\"Demo limits\"],\"H4x7Sk\":[\"The language this admin dashboard shows in. Available in English, 简体中文, and 繁體中文.\"],\"HCNlq3\":[\"Clean and high-contrast, close to white.\"],\"HHDyGw\":[\"Soft green\"],\"HKH+W+\":[\"Data\"],\"Hp1l6f\":[\"Current\"],\"HxlY7t\":[\"Changing this updates what subscribers get from /feed.\"],\"HxuOlm\":[\"Site Header\"],\"I4HRxU\":[\"Every published post, including ones hidden from Latest.\"],\"I6gXOa\":[\"Path\"],\"I76CzF\":[\"Neutral gray\"],\"ID38tA\":[\"Account deletion is off in demo mode. The shared demo resets separately.\"],\"IF9tPu\":[\"When to use site export, database backups, and recovery drills.\"],\"IW5PBo\":[\"Copy Token\"],\"IagCbF\":[\"URL\"],\"IfB3m6\":[\"The language your posts are written in. Announced to readers and search engines through HTML lang and your RSS feed. Any BCP 47 tag works.\"],\"IreQBq\":[\"Repository\"],\"J6bLeg\":[\"Add a custom link to any URL\"],\"JL7LF5\":[\"available CSS variables, data attributes, and examples.\"],\"JTviaO\":[\"Manage sign-in security, exports, and irreversible actions.\"],\"JcD7qf\":[\"More actions\"],\"JjX0OO\":[\"Copy your token now — it won't be shown again.\"],\"JrFTcr\":[\"Connecting…\"],\"JuN5GC\":[\"No file selected. Choose a file to upload.\"],\"KDw4GX\":[\"Try again\"],\"KSgo21\":[\"Pick a repository\"],\"KVVYBh\":[\"Add collection to navigation\"],\"KiJn9B\":[\"Note\"],\"KwOLJF\":[\"The original Jant palette, and a solid everyday pick.\"],\"L3DEwT\":[\"Remove this avatar? Your favicon and header icon will go back to the default.\"],\"L4t4/q\":[\"March 14\"],\"LdyooL\":[\"link\"],\"M/D8PK\":[\"+ Install on another account\"],\"M/haSd\":[\"Always show the light version of the theme.\"],\"M2kIWU\":[\"Font theme\"],\"M6CbAU\":[\"Toggle edit panel\"],\"MaYYE6\":[\"Post notes by messaging a Telegram bot\"],\"Me5t5H\":[\"Connect a GitHub repository to automatically back up your posts as Markdown files. Edits on GitHub sync back to your site.\"],\"Mr4QPw\":[\"Disconnect Telegram? You can reconnect any time with a new binding code.\"],\"MtENL9\":[\"Tune how your site looks, reads, and runs.\"],\"N/8NPV\":[\"Before deleting, download a site export. You won't be able to recover this account after deletion.\"],\"N7UNHY\":[\"Featured feed\"],\"NHnUHF\":[\"Favicon and the profile mark in your header\"],\"NU2Fqi\":[\"Save CSS\"],\"NVjhde\":[\"Warm parchment\"],\"Nldjdr\":[\"No custom URLs yet. Create one to add redirects or custom paths for posts.\"],\"O7rgs6\":[\"Header RSS points to your \",[\"feed\"],\" feed (/feed). Change what /feed returns in General.\"],\"OSJXFg\":[\"Applies to your entire site, including admin pages. Pick a palette, then choose whether it follows the system or stays fixed.\"],\"Ox3+3h\":[\"No matches.\"],\"PEUV5I\":[\"Code injection updated.\"],\"PHh52z\":[\"Warm and rich, with strong brown tones.\"],\"PXj9lw\":[\"Stop accepting posts from Telegram. Your existing notes stay published.\"],\"PZ7HJ8\":[\"Blog Avatar\"],\"Pwqkdw\":[\"Loading…\"],\"PxJ9W6\":[\"Generate Token\"],\"Q/6Y+2\":[\"Needs Contents (read/write) and Webhooks (read/write) on the target repository.\"],\"Q30z/l\":[\"Remove this collection from navigation? The collection itself won't be deleted.\"],\"Q99OtV\":[\"Pin a collection to your navigation bar. An asterisk (*) appears next to collections updated in the last 48 hours.\"],\"QCwsv1\":[\"Warm off-white\"],\"QKvrmL\":[\"Warm-neutral and easy for long reading.\"],\"QZmz0H\":[\"Built-in links\"],\"Qnrzvb\":[\"Active Tokens\"],\"R6Z4LE\":[\"Download failed. Please try again.\"],\"R9Khdg\":[\"Auto\"],\"RcdDOS\":[\"Create a bot by messaging @BotFather on Telegram, then paste the token it gives you.\"],\"RdVIcf\":[\"Cream and coffee brown\"],\"RxsRD6\":[\"Time Zone\"],\"SJmfuf\":[\"Site Name\"],\"SKZhW9\":[\"Token name\"],\"SVQQPe\":[\"Couldn't connect. Check the error and try again.\"],\"SchpMp\":[\"Telegram\"],\"SrGs4T\":[\"Light blue-gray background, cool and even.\"],\"TpF3v+\":[\"Injected before </head>. Use for analytics, custom meta tags, and styles that must load early.\"],\"Tz0i8g\":[\"Settings\"],\"UFK415\":[\"Site-wide HTML for analytics and widgets\"],\"UTvFQq\":[\"Open \",[\"linkOpen\"],\"@\",[\"botUsername\"],[\"linkClose\"],\" and send:\"],\"Uj/btJ\":[\"Display avatar in my site header\"],\"UsODUn\":[\"Select an account\"],\"UxKoFf\":[\"Navigation\"],\"V+bhUy\":[\"Install GitHub App\"],\"V4WsyL\":[\"Add Link\"],\"V5pZwT\":[\"Search settings updated.\"],\"VXUPla\":[\"Connect with GitHub App\"],\"VhMDMg\":[\"Change Password\"],\"Vn3jYy\":[\"Navigation items\"],\"VoZYGU\":[\"This will permanently delete all your data — posts, media, collections, settings, and your account. Your blog will be reset to its initial setup state. This cannot be undone.\"],\"WUnFK2\":[\"Pale cool-white with deep indigo text. High contrast.\"],\"Wa9q4P\":[\"Pure white\"],\"Weq9zb\":[\"General\"],\"Wi9i06\":[\"Follow each visitor's system preference.\"],\"Wx1M8N\":[\"Install the GitHub App to grant access without managing personal tokens. Permissions are scoped per repository and revocable from GitHub.\"],\"X+8FMk\":[\"Current password doesn't match. Try again.\"],\"X1G9eY\":[\"Navigation Preview\"],\"X2Cbc2\":[\"Archive feed\"],\"X9Hujr\":[\"Manual Push\"],\"XtBJV8\":[\"Checking repository…\"],\"Xtc16w\":[\"Refresh repository list\"],\"Y/F35r\":[\"Create a post with curl:\"],\"Y/N5N7\":[\"Soft cream background with a muted green accent.\"],\"YF6zHf\":[\"Site settings updated.\"],\"YdG2RF\":[\"Export Site\"],\"YkgZi7\":[\"Connect a Telegram bot, then anything you message it gets published as a note.\"],\"YwhjRx\":[\"Manage Account\"],\"Yxp859\":[\"Warm cream\"],\"ZDY7Fy\":[\"Syncing…\"],\"ZQKLI1\":[\"Danger Zone\"],\"ZS/CBL\":[\"Delete this navigation link? Visitors won't see it in your site header anymore.\"],\"ZgZX+d\":[\"Pure white with neutral grays. No color tint.\"],\"ZhhOwV\":[\"Quote\"],\"ZiooJI\":[\"API Tokens\"],\"Zm7Qb0\":[\"Backup & Restore Guide\"],\"ZmUkwN\":[\"Add custom link to navigation\"],\"a14mj8\":[\"Unknown device\"],\"a1iEgy\":[\"Warm cream background with deep coffee-brown accents.\"],\"a3LDKx\":[\"Security\"],\"aAIQg2\":[\"Appearance\"],\"aFkzVF\":[\"The slug of the target post or collection\"],\"alKG0+\":[\"Font Theme\"],\"anibOb\":[\"About this blog\"],\"any7NR\":[\"Theming guide\"],\"b+/jO6\":[\"301 (Permanent)\"],\"bHOiy1\":[\"Password changes are off in demo mode. Sign in with the shared demo credentials.\"],\"bHYIks\":[\"Sign Out\"],\"bV2vng\":[\"Warm terracotta\"],\"bbR5vW\":[\"This palette\"],\"bmrL08\":[\"Demo mode hides sessions, password changes, and account deletion. Export still works.\"],\"bviiKV\":[\"Headings, body text, and links in this theme.\"],\"c3MN2z\":[\"all available endpoints and request formats.\"],\"cS7/bk\":[\"Remove the saved bot token? Its webhook is deleted and any connected account is disconnected.\"],\"cSDy01\":[\"Custom CSS updated.\"],\"clzoNp\":[\"Always show the dark version of the theme.\"],\"cnGeoo\":[\"Delete\"],\"d3FRkY\":[\"Could not copy. Try again.\"],\"d5oGUo\":[\"Create a new repository on GitHub\"],\"dEgA5A\":[\"Cancel\"],\"dTXUY+\":[\"Confirm account deletion\"],\"dYKrp3\":[\"Hidden from Latest\"],\"dk7TCH\":[\"Permanently delete all data and reset the blog\"],\"drodVV\":[\"No collections yet. Create one first, then add it to your navigation.\"],\"dsWkIw\":[\"Disconnect from GitHub? The webhook will be removed. Your repository content will not be deleted.\"],\"e/tSI5\":[\"Navigation order updated.\"],\"eOL7vn\":[\"Minimal color, low distraction.\"],\"ePK91l\":[\"Edit\"],\"ebQKK7\":[\"Site\"],\"egK+Yy\":[\"Bearer tokens for scripts and automation\"],\"ehj/zN\":[\"Redirect Type\"],\"eneWvv\":[\"Draft\"],\"erTMh7\":[\"Last synced\"],\"f+m8jj\":[\"Feed URL copied.\"],\"f8fH8W\":[\"Design\"],\"fKRAwQ\":[\"Sandy background with a green accent.\"],\"fWYqkz\":[\"Code Injection\"],\"fYXQnC\":[\"Cozy and bold among the warm tones.\"],\"gOWiTY\":[\"Load a serif font optimized for Chinese, Japanese, or Korean content.\"],\"gZ5owP\":[\"Search repositories\"],\"gbqbh6\":[\"Safe to leave this page — syncing continues in the background.\"],\"gkFvVN\":[\"Injected before </body>. Use for chat widgets and scripts that should not block page load.\"],\"gtQsRO\":[\"Create Custom URL\"],\"hBO/y4\":[\"Security token expired. Refresh the page and try again.\"],\"hGmyDl\":[\"Tokens let you access the API from scripts, shortcuts, and other tools without signing in.\"],\"hIHkRy\":[\"Connected via GitHub App\"],\"hdSi1b\":[\"Type \",[\"repo\"],\" to confirm\"],\"he3ygx\":[\"Copy\"],\"i0qMbr\":[\"Home\"],\"iEUzMn\":[\"system\"],\"iSLIjg\":[\"Connect\"],\"iVOMRi\":[\"Home settings updated.\"],\"icB4Cv\":[\"Drag links here to show them under the More menu\"],\"id3vuh\":[\"Telegram is set up, but the bot couldn't be reached. Check the bot token and try again.\"],\"ihn4zD\":[\"Search…\"],\"iiDXZc\":[\"Displayed at the bottom of all posts and pages.\"],\"itS31B\":[\"A warmer look, like slightly aged paper.\"],\"iwQZvS\":[\"Cool blue-gray\"],\"j4VrG6\":[\"Download Export ZIP\"],\"j5nQL2\":[\"e.g. iOS Shortcuts\"],\"jUV7CU\":[\"Upload Avatar\"],\"jVUmOK\":[\"Markdown supported\"],\"jdVYS8\":[\"Pick the one that fits your writing.\"],\"jgBjXJ\":[\"Revoke this token? Any scripts using it will stop working.\"],\"jpctdh\":[\"View\"],\"k1ifdL\":[\"Processing...\"],\"kLw03Y\":[\"Bright paper white\"],\"kMXclu\":[\"Download a site export\"],\"kNiQp6\":[\"Pinned\"],\"kRhzWq\":[\"GitHub Sync\"],\"kVQs7s\":[\"Fine-grained styling overrides\"],\"ke1gWS\":[\"Custom URLs\"],\"kfcRb0\":[\"Avatar\"],\"kxDZ2i\":[\"This code runs on every page of your site.\"],\"l2Op2p\":[\"Query Parameters\"],\"lLW3vJ\":[\"Target Slug\"],\"lV04bQ\":[\"Warm and earthy, but still light.\"],\"lYHJih\":[\"Revoke this session? That device will need to sign in again.\"],\"mLOk1i\":[\"Push all posts to GitHub right now instead of waiting for the next automatic sync.\"],\"mSNmrX\":[\"List posts:\"],\"nG2qTk\":[\"Example link\"],\"nK07ni\":[\"Choose a typographic direction for your site. Each theme changes both the font pairing and the reading rhythm.\"],\"nbfdhU\":[\"Integrations\"],\"ntJYyh\":[\"Domains, plan, and billing in \",[\"providerLabel\"]],\"o/vNDE\":[\"lets you override any theme variable.\"],\"oGC9uP\":[\"owner/repo\"],\"oH2JHg\":[\"We'll prefill the name \",[\"name\"],\". The list refreshes on return.\"],\"oKOOsY\":[\"Color Theme\"],\"oL535e\":[\"Not synced yet\"],\"oNA4If\":[\"All collections are already in your navigation.\"],\"oUn9Z7\":[\"Near-neutral light gray with a steel-blue accent.\"],\"ofdy2l\":[\"Orange-tinted background. The warmest palette.\"],\"pZq3aX\":[\"Upload failed. Please try again.\"],\"pgTIrt\":[\"Choose the GitHub account and repository to sync with this site.\"],\"psoxDF\":[\"That font theme isn't available. Pick another one.\"],\"pvnfJD\":[\"Dark\"],\"q+hNag\":[\"Collection\"],\"qdcESc\":[\"Create a new repository\"],\"r5EW6f\":[\"This repository is already backing up another Jant site (\",[\"host\"],\"). Pick a different repository.\"],\"rEspiY\":[\"Navigation placement updated.\"],\"rFmBG3\":[\"Color theme\"],\"rlonmB\":[\"Couldn't delete. Try again in a moment.\"],\"satWc6\":[\"Main RSS feed\"],\"sgr2wQ\":[\"collection\"],\"soRdOu\":[\"Off-white paper with a deep forest-green accent.\"],\"sqxcaY\":[\"Created \",[\"date\"]],\"sxkWRg\":[\"Advanced\"],\"t/YqKh\":[\"Remove\"],\"t3hvHq\":[\"Sync Now\"],\"tJ4H0O\":[\"your Telegram account\"],\"tfDRzk\":[\"Save\"],\"tvgAq5\":[\"No accounts authorized yet\"],\"u1VTd3\":[\"Palette, surface tone, and overall mood\"],\"u3wRF+\":[\"Published\"],\"u6KOjV\":[\"Want more control?\"],\"udPwLB\":[\"Header\"],\"ui6aMF\":[\"These devices are currently signed in to your account. Revoke any session you don't recognize.\"],\"vBEKwo\":[\"Manage this site's active sessions here. Password and hosted access are managed through \",[\"providerLabel\"],\".\"],\"vRldcl\":[\"Typography choices and reading texture\"],\"vSYKYI\":[\"Main feed\"],\"vTuib7\":[\"This controls what /feed returns.\"],\"vmQmHx\":[\"Add custom CSS to override any styles. Use data attributes like [data-page], [data-post], [data-format] to target specific elements.\"],\"vzX5FB\":[\"Delete Account\"],\"w8Rv8T\":[\"Label is required\"],\"wL3cK8\":[\"Latest\"],\"wW6NCp\":[\"Last error\"],\"wc+17X\":[\"/* Your custom CSS here */\"],\"wuLtXn\":[\"No active sessions right now. Signed-in devices show up here.\"],\"xCWek4\":[\"File storage isn't set up. Check your server config.\"],\"xHt036\":[\"Personal Access Token\"],\"xKeZ0l\":[\"An earthier, warmer tone.\"],\"xYbozN\":[\"Soft ivory\"],\"xqViCq\":[\"Warm ivory\"],\"xxaahd\":[\"When you want a cooler, sharper look.\"],\"y28hnO\":[\"Post\"],\"y8Md/V\":[\"Language and time updated.\"],\"yNCqOt\":[\"Latest feed\"],\"yQ3kNF\":[\"Type the following phrase to confirm:\"],\"ydq1k2\":[\"Pick an account first\"],\"yjjCV8\":[\"Fixed feed URLs\"],\"yjkELF\":[\"Confirm New Password\"],\"yzF66j\":[\"Link\"],\"z6wakA\":[\"A short intro shown on your home page.\"],\"zEizrk\":[\"Last used \",[\"date\"]],\"zSURJW\":[\"No repositories match.\"],\"zXH2jX\":[\"Language & Time\"],\"zlcDd2\":[\"Delete this custom URL? Visitors using it won't be redirected anymore.\"],\"zwBp5t\":[\"Private\"],\"zxRN6H\":[\"Header links, home feed, and overflow menu\"]}");
|
|
@@ -3452,10 +3452,10 @@ function normalizeThemeColorForMeta(color) {
|
|
|
3452
3452
|
* internal paths (e.g. `/_assets/client-HASH.js`) embedded by the Worker build
|
|
3453
3453
|
* from the Vite client manifest. Used only in production (IS_VITE_DEV=false).
|
|
3454
3454
|
*/ var IS_VITE_DEV = typeof __JANT_DEV__ !== "undefined" && __JANT_DEV__ === true;
|
|
3455
|
-
var CORE_VERSION = "0.6.
|
|
3456
|
-
var CLIENT_JS_FILE = "/_assets/client-
|
|
3457
|
-
var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-
|
|
3458
|
-
var CLIENT_CSS_FILE = "/_assets/client-
|
|
3455
|
+
var CORE_VERSION = "0.6.11-6b74cf79ce7cd678";
|
|
3456
|
+
var CLIENT_JS_FILE = "/_assets/client-Dd9U383b.js";
|
|
3457
|
+
var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-DkpSdIDz.js";
|
|
3458
|
+
var CLIENT_CSS_FILE = "/_assets/client-BhHHVvSY.css";
|
|
3459
3459
|
var CLIENT_CJK_CSS_FILE = "/_assets/client-cjk-B7Z0snDu.css";
|
|
3460
3460
|
var CLIENT_CJK_TC_CSS_FILE = "/_assets/client-cjk-tc-BesJYrb2.css";
|
|
3461
3461
|
var CLIENT_CJK_JP_CSS_FILE = "/_assets/client-cjk-jp-DZwrTzQC.css";
|
|
@@ -3753,6 +3753,7 @@ var IconSprite = () => {
|
|
|
3753
3753
|
const resolvedNoindex = noindex ?? appConfig?.noindex;
|
|
3754
3754
|
const sitePathPrefix = appConfig?.sitePathPrefix || "";
|
|
3755
3755
|
const assetBasePath = IS_VITE_DEV ? "/" : appConfig?.assetBasePath || getPublicAssetBasePath(sitePathPrefix);
|
|
3756
|
+
const mediaBase = getPublicUrlForProvider(appConfig?.storageDriver ?? "", appConfig?.r2PublicUrl, appConfig?.s3PublicUrl, appConfig?.localPublicUrl) ?? "";
|
|
3756
3757
|
const currentUrl = c ? c.get("publicRequestUrl") : void 0;
|
|
3757
3758
|
const rawPath = c?.req?.path ?? "/";
|
|
3758
3759
|
const manifestStartPath = sitePathPrefix ? rawPath.replace(new RegExp(`^${sitePathPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`), "") || "/" : rawPath;
|
|
@@ -3773,7 +3774,7 @@ var IconSprite = () => {
|
|
|
3773
3774
|
const cjkSerifFont = appConfig?.cjkSerifFont ?? "off";
|
|
3774
3775
|
const cjkStylesheetPath = cjkSerifFont === "zh-Hans" ? IS_VITE_DEV ? assetPath("/src/style-cjk.css") : toPublicAssetPath(CLIENT_CJK_CSS_FILE, assetBasePath) : cjkSerifFont === "zh-Hant" ? IS_VITE_DEV ? assetPath("/src/style-cjk-tc.css") : toPublicAssetPath(CLIENT_CJK_TC_CSS_FILE, assetBasePath) : cjkSerifFont === "ja" ? IS_VITE_DEV ? assetPath("/src/style-cjk-jp.css") : toPublicAssetPath(CLIENT_CJK_JP_CSS_FILE, assetBasePath) : cjkSerifFont === "ko" ? IS_VITE_DEV ? assetPath("/src/style-cjk-kr.css") : toPublicAssetPath(CLIENT_CJK_KR_CSS_FILE, assetBasePath) : null;
|
|
3775
3776
|
const clientScriptPath = IS_VITE_DEV ? resolvedClientBundle === "full" ? assetPath("/src/client-auth.ts") : assetPath("/src/client.ts") : toPublicAssetPath(resolvedClientBundle === "full" ? CLIENT_AUTH_JS_FILE : CLIENT_JS_FILE, assetBasePath);
|
|
3776
|
-
const faviconAssetVersion = resolvedFaviconVersion || "0.6.
|
|
3777
|
+
const faviconAssetVersion = resolvedFaviconVersion || "0.6.11-6b74cf79ce7cd678";
|
|
3777
3778
|
const resolvedFaviconHref = faviconHref ?? (faviconAssetVersion ? toPublicPath(`/favicon.ico?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/favicon.ico", sitePathPrefix));
|
|
3778
3779
|
const resolvedAppleTouchHref = appleTouchHref ?? (faviconAssetVersion ? toPublicPath(`/apple-touch-icon.png?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/apple-touch-icon.png", sitePathPrefix));
|
|
3779
3780
|
const socialImageHref = resolvedSocialImagePath ? toAbsoluteAssetUrl(resolvedSocialImagePath, appConfig?.siteUrl || "", sitePathPrefix) : "";
|
|
@@ -3801,6 +3802,7 @@ var IconSprite = () => {
|
|
|
3801
3802
|
"data-theme-mode": themeMode,
|
|
3802
3803
|
"data-site-path-prefix": sitePathPrefix,
|
|
3803
3804
|
"data-asset-base-path": assetBasePath,
|
|
3805
|
+
"data-media-base": mediaBase,
|
|
3804
3806
|
children: [/* @__PURE__ */ jsxDEV$1("head", { children: [
|
|
3805
3807
|
/* @__PURE__ */ jsxDEV$1("meta", { charset: "UTF-8" }),
|
|
3806
3808
|
/* @__PURE__ */ jsxDEV$1("meta", {
|
|
@@ -7268,6 +7270,8 @@ var getCollectionMutationLabels = (i18n) => ({
|
|
|
7268
7270
|
const { i18n } = useLingui();
|
|
7269
7271
|
const labels = JSON.stringify({
|
|
7270
7272
|
cancel: i18n._({ id: "dEgA5A" }),
|
|
7273
|
+
imageNotRehosted: i18n._({ id: "Y7WAtz" }),
|
|
7274
|
+
imagesNotRehosted: i18n._({ id: "nJ4U1U" }),
|
|
7271
7275
|
note: i18n._({ id: "KiJn9B" }),
|
|
7272
7276
|
link: i18n._({ id: "yzF66j" }),
|
|
7273
7277
|
quote: i18n._({ id: "ZhhOwV" }),
|
|
@@ -8674,6 +8678,72 @@ var INLINE_SIGNATURE_MIME_TYPES = new Set([
|
|
|
8674
8678
|
*/ function isImageMimeType(mimeType) {
|
|
8675
8679
|
return mimeType.startsWith("image/");
|
|
8676
8680
|
}
|
|
8681
|
+
/** Image MIME types accepted by the remote-image sideload path. */ var SIDELOAD_IMAGE_MIME_TYPES = new Set(IMAGE_MIME_TYPES);
|
|
8682
|
+
/** Map of sideload-accepted image MIME types to their file extensions. */ var IMAGE_MIME_EXTENSIONS = {
|
|
8683
|
+
"image/jpeg": "jpg",
|
|
8684
|
+
"image/png": "png",
|
|
8685
|
+
"image/gif": "gif",
|
|
8686
|
+
"image/webp": "webp",
|
|
8687
|
+
"image/svg+xml": "svg",
|
|
8688
|
+
"image/avif": "avif",
|
|
8689
|
+
"image/bmp": "bmp",
|
|
8690
|
+
"image/x-icon": "ico"
|
|
8691
|
+
};
|
|
8692
|
+
/**
|
|
8693
|
+
* Whether a MIME type may be rehosted via remote-image sideload.
|
|
8694
|
+
*
|
|
8695
|
+
* Unlike {@link getStoredUploadPolicy} (which only allows webp/png/jpeg because
|
|
8696
|
+
* the normal upload path re-encodes everything to WebP client-side), the
|
|
8697
|
+
* sideload path stores the original remote bytes, so it accepts the full set of
|
|
8698
|
+
* image formats Jant can display.
|
|
8699
|
+
*
|
|
8700
|
+
* @param contentType - The MIME type to check
|
|
8701
|
+
* @returns Whether the type is an accepted sideload image
|
|
8702
|
+
* @example
|
|
8703
|
+
* ```ts
|
|
8704
|
+
* isAllowedSideloadImageType("image/gif"); // true
|
|
8705
|
+
* isAllowedSideloadImageType("text/html"); // false
|
|
8706
|
+
* ```
|
|
8707
|
+
*/ function isAllowedSideloadImageType(contentType) {
|
|
8708
|
+
return SIDELOAD_IMAGE_MIME_TYPES.has(contentType);
|
|
8709
|
+
}
|
|
8710
|
+
/**
|
|
8711
|
+
* Returns the file extension for a sideload-accepted image MIME type.
|
|
8712
|
+
*
|
|
8713
|
+
* @param contentType - The image MIME type
|
|
8714
|
+
* @returns Extension without a dot, or null if the type isn't sideloadable
|
|
8715
|
+
* @example
|
|
8716
|
+
* ```ts
|
|
8717
|
+
* imageExtensionForMimeType("image/jpeg"); // "jpg"
|
|
8718
|
+
* ```
|
|
8719
|
+
*/ function imageExtensionForMimeType(contentType) {
|
|
8720
|
+
return IMAGE_MIME_EXTENSIONS[contentType] ?? null;
|
|
8721
|
+
}
|
|
8722
|
+
/**
|
|
8723
|
+
* Identify an image format from its leading bytes (magic numbers), so a remote
|
|
8724
|
+
* sideload can trust the actual content rather than the server's content-type
|
|
8725
|
+
* header. Recognizes every {@link isAllowedSideloadImageType} format.
|
|
8726
|
+
*
|
|
8727
|
+
* @param bytes - The leading bytes of the file (≥ ~256 bytes recommended)
|
|
8728
|
+
* @returns The detected image MIME type, or null if unrecognized
|
|
8729
|
+
* @example
|
|
8730
|
+
* ```ts
|
|
8731
|
+
* sniffImageMimeType(pngBytes); // "image/png"
|
|
8732
|
+
* ```
|
|
8733
|
+
*/ function sniffImageMimeType(bytes) {
|
|
8734
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
|
|
8735
|
+
if (bytes.length >= 8 && bytes[0] === 137 && readAscii(bytes, 1, 3) === "PNG") return "image/png";
|
|
8736
|
+
if (bytes.length >= 6 && /^GIF8[79]a$/.test(readAscii(bytes, 0, 6))) return "image/gif";
|
|
8737
|
+
if (bytes.length >= 12 && readAscii(bytes, 0, 4) === "RIFF" && readAscii(bytes, 8, 4) === "WEBP") return "image/webp";
|
|
8738
|
+
if (bytes.length >= 12 && readAscii(bytes, 4, 4) === "ftyp") {
|
|
8739
|
+
const brand = readAscii(bytes, 8, 4);
|
|
8740
|
+
if (brand === "avif" || brand === "avis") return "image/avif";
|
|
8741
|
+
}
|
|
8742
|
+
if (bytes.length >= 2 && bytes[0] === 66 && bytes[1] === 77) return "image/bmp";
|
|
8743
|
+
if (bytes.length >= 4 && bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0) return "image/x-icon";
|
|
8744
|
+
if (new TextDecoder().decode(bytes.subarray(0, 1024)).toLowerCase().includes("<svg")) return "image/svg+xml";
|
|
8745
|
+
return null;
|
|
8746
|
+
}
|
|
8677
8747
|
/**
|
|
8678
8748
|
* Validates an uploaded file's type and size.
|
|
8679
8749
|
*
|
|
@@ -12658,6 +12728,369 @@ function createLocalDriver(config) {
|
|
|
12658
12728
|
return createR2Driver(env.R2);
|
|
12659
12729
|
}
|
|
12660
12730
|
//#endregion
|
|
12731
|
+
//#region src/lib/image-dimensions.ts
|
|
12732
|
+
/**
|
|
12733
|
+
* Parse intrinsic pixel dimensions from a raw image header buffer.
|
|
12734
|
+
*
|
|
12735
|
+
* Used as a server-side fallback when an upload client does not provide
|
|
12736
|
+
* `width` / `height`. Only the file header is needed — typically the first
|
|
12737
|
+
* few hundred bytes for PNG/JPEG/GIF/WebP, and up to ~64 KB for AVIF where
|
|
12738
|
+
* the `ispe` property can be nested inside a larger `meta` box.
|
|
12739
|
+
*
|
|
12740
|
+
* Returns `null` when the bytes are too short, the format is unsupported,
|
|
12741
|
+
* or the header is malformed.
|
|
12742
|
+
*
|
|
12743
|
+
* @param mimeType MIME type the upload was declared as (e.g. `image/png`).
|
|
12744
|
+
* @param bytes Bytes from the start of the file. Pass at least
|
|
12745
|
+
* {@link IMAGE_DIMENSION_PEEK_BYTES} for AVIF reliability; smaller is fine
|
|
12746
|
+
* for other formats.
|
|
12747
|
+
*/ function parseImageDimensions(mimeType, bytes) {
|
|
12748
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
12749
|
+
switch (mimeType) {
|
|
12750
|
+
case "image/png": return parsePng(view);
|
|
12751
|
+
case "image/jpeg":
|
|
12752
|
+
case "image/jpg": return parseJpeg(view);
|
|
12753
|
+
case "image/gif": return parseGif(view);
|
|
12754
|
+
case "image/webp": return parseWebp(view);
|
|
12755
|
+
case "image/avif": return parseIsoBmff(view, new Set(["avif", "avis"]));
|
|
12756
|
+
default: return null;
|
|
12757
|
+
}
|
|
12758
|
+
}
|
|
12759
|
+
/**
|
|
12760
|
+
* Recommended number of header bytes to feed into {@link parseImageDimensions}.
|
|
12761
|
+
*
|
|
12762
|
+
* Covers AVIF files with EXIF/ICC properties placed before the `ispe` box.
|
|
12763
|
+
* Smaller formats only inspect the first ~30 bytes.
|
|
12764
|
+
*/ var IMAGE_DIMENSION_PEEK_BYTES = 64 * 1024;
|
|
12765
|
+
function readChars(view, offset, length) {
|
|
12766
|
+
let out = "";
|
|
12767
|
+
for (let i = 0; i < length; i += 1) out += String.fromCharCode(view.getUint8(offset + i));
|
|
12768
|
+
return out;
|
|
12769
|
+
}
|
|
12770
|
+
function parsePng(view) {
|
|
12771
|
+
if (view.byteLength < 24) return null;
|
|
12772
|
+
const signature = [
|
|
12773
|
+
137,
|
|
12774
|
+
80,
|
|
12775
|
+
78,
|
|
12776
|
+
71,
|
|
12777
|
+
13,
|
|
12778
|
+
10,
|
|
12779
|
+
26,
|
|
12780
|
+
10
|
|
12781
|
+
];
|
|
12782
|
+
for (let i = 0; i < 8; i += 1) if (view.getUint8(i) !== signature[i]) return null;
|
|
12783
|
+
const width = view.getUint32(16, false);
|
|
12784
|
+
const height = view.getUint32(20, false);
|
|
12785
|
+
if (width === 0 || height === 0) return null;
|
|
12786
|
+
return {
|
|
12787
|
+
width,
|
|
12788
|
+
height
|
|
12789
|
+
};
|
|
12790
|
+
}
|
|
12791
|
+
function parseGif(view) {
|
|
12792
|
+
if (view.byteLength < 10) return null;
|
|
12793
|
+
if (view.getUint8(0) !== 71 || view.getUint8(1) !== 73 || view.getUint8(2) !== 70) return null;
|
|
12794
|
+
const width = view.getUint16(6, true);
|
|
12795
|
+
const height = view.getUint16(8, true);
|
|
12796
|
+
if (width === 0 || height === 0) return null;
|
|
12797
|
+
return {
|
|
12798
|
+
width,
|
|
12799
|
+
height
|
|
12800
|
+
};
|
|
12801
|
+
}
|
|
12802
|
+
function parseWebp(view) {
|
|
12803
|
+
if (view.byteLength < 16) return null;
|
|
12804
|
+
if (readChars(view, 0, 4) !== "RIFF") return null;
|
|
12805
|
+
if (readChars(view, 8, 4) !== "WEBP") return null;
|
|
12806
|
+
const chunk = readChars(view, 12, 4);
|
|
12807
|
+
if (chunk === "VP8 ") {
|
|
12808
|
+
if (view.byteLength < 30) return null;
|
|
12809
|
+
if (view.getUint8(23) !== 157 || view.getUint8(24) !== 1 || view.getUint8(25) !== 42) return null;
|
|
12810
|
+
const width = view.getUint16(26, true) & 16383;
|
|
12811
|
+
const height = view.getUint16(28, true) & 16383;
|
|
12812
|
+
if (width === 0 || height === 0) return null;
|
|
12813
|
+
return {
|
|
12814
|
+
width,
|
|
12815
|
+
height
|
|
12816
|
+
};
|
|
12817
|
+
}
|
|
12818
|
+
if (chunk === "VP8L") {
|
|
12819
|
+
if (view.byteLength < 25) return null;
|
|
12820
|
+
if (view.getUint8(20) !== 47) return null;
|
|
12821
|
+
const b0 = view.getUint8(21);
|
|
12822
|
+
const b1 = view.getUint8(22);
|
|
12823
|
+
const b2 = view.getUint8(23);
|
|
12824
|
+
const b3 = view.getUint8(24);
|
|
12825
|
+
return {
|
|
12826
|
+
width: 1 + ((b1 & 63) << 8 | b0),
|
|
12827
|
+
height: 1 + ((b3 & 15) << 10 | b2 << 2 | (b1 & 192) >> 6)
|
|
12828
|
+
};
|
|
12829
|
+
}
|
|
12830
|
+
if (chunk === "VP8X") {
|
|
12831
|
+
if (view.byteLength < 30) return null;
|
|
12832
|
+
return {
|
|
12833
|
+
width: 1 + (view.getUint8(24) | view.getUint8(25) << 8 | view.getUint8(26) << 16),
|
|
12834
|
+
height: 1 + (view.getUint8(27) | view.getUint8(28) << 8 | view.getUint8(29) << 16)
|
|
12835
|
+
};
|
|
12836
|
+
}
|
|
12837
|
+
return null;
|
|
12838
|
+
}
|
|
12839
|
+
function parseJpeg(view) {
|
|
12840
|
+
const length = view.byteLength;
|
|
12841
|
+
if (length < 4) return null;
|
|
12842
|
+
if (view.getUint8(0) !== 255 || view.getUint8(1) !== 216) return null;
|
|
12843
|
+
let i = 2;
|
|
12844
|
+
while (i < length) {
|
|
12845
|
+
while (i < length && view.getUint8(i) !== 255) i += 1;
|
|
12846
|
+
while (i < length && view.getUint8(i) === 255) i += 1;
|
|
12847
|
+
if (i >= length) return null;
|
|
12848
|
+
const marker = view.getUint8(i);
|
|
12849
|
+
i += 1;
|
|
12850
|
+
if (marker === 0 || marker === 1) continue;
|
|
12851
|
+
if (marker >= 208 && marker <= 217) continue;
|
|
12852
|
+
if (i + 2 > length) return null;
|
|
12853
|
+
const segLen = view.getUint16(i, false);
|
|
12854
|
+
if (segLen < 2) return null;
|
|
12855
|
+
if (marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207) {
|
|
12856
|
+
if (i + 7 > length) return null;
|
|
12857
|
+
const height = view.getUint16(i + 3, false);
|
|
12858
|
+
const width = view.getUint16(i + 5, false);
|
|
12859
|
+
if (width === 0 || height === 0) return null;
|
|
12860
|
+
return {
|
|
12861
|
+
width,
|
|
12862
|
+
height
|
|
12863
|
+
};
|
|
12864
|
+
}
|
|
12865
|
+
i += segLen;
|
|
12866
|
+
}
|
|
12867
|
+
return null;
|
|
12868
|
+
}
|
|
12869
|
+
function readBox(view, pos) {
|
|
12870
|
+
if (pos + 8 > view.byteLength) return null;
|
|
12871
|
+
let size = view.getUint32(pos, false);
|
|
12872
|
+
const type = readChars(view, pos + 4, 4);
|
|
12873
|
+
if (size === 1) return null;
|
|
12874
|
+
if (size === 0) size = view.byteLength - pos;
|
|
12875
|
+
if (size < 8) return null;
|
|
12876
|
+
return {
|
|
12877
|
+
size,
|
|
12878
|
+
type,
|
|
12879
|
+
payloadOffset: pos + 8,
|
|
12880
|
+
end: pos + size
|
|
12881
|
+
};
|
|
12882
|
+
}
|
|
12883
|
+
function findChild(view, start, end, type) {
|
|
12884
|
+
let pos = start;
|
|
12885
|
+
while (pos < end) {
|
|
12886
|
+
const box = readBox(view, pos);
|
|
12887
|
+
if (!box) return null;
|
|
12888
|
+
if (box.type === type) return box;
|
|
12889
|
+
pos = box.end;
|
|
12890
|
+
}
|
|
12891
|
+
return null;
|
|
12892
|
+
}
|
|
12893
|
+
function parseIsoBmff(view, acceptedBrands) {
|
|
12894
|
+
const ftyp = readBox(view, 0);
|
|
12895
|
+
if (!ftyp || ftyp.type !== "ftyp") return null;
|
|
12896
|
+
let isAccepted = false;
|
|
12897
|
+
if (ftyp.payloadOffset + 4 <= ftyp.end) isAccepted = acceptedBrands.has(readChars(view, ftyp.payloadOffset, 4));
|
|
12898
|
+
for (let q = ftyp.payloadOffset + 8; !isAccepted && q + 4 <= ftyp.end; q += 4) if (acceptedBrands.has(readChars(view, q, 4))) isAccepted = true;
|
|
12899
|
+
if (!isAccepted) return null;
|
|
12900
|
+
const meta = findChild(view, ftyp.end, view.byteLength, "meta");
|
|
12901
|
+
if (!meta) return null;
|
|
12902
|
+
const iprp = findChild(view, meta.payloadOffset + 4, meta.end, "iprp");
|
|
12903
|
+
if (!iprp) return null;
|
|
12904
|
+
const ipco = findChild(view, iprp.payloadOffset, iprp.end, "ipco");
|
|
12905
|
+
if (!ipco) return null;
|
|
12906
|
+
const ispe = findChild(view, ipco.payloadOffset, ipco.end, "ispe");
|
|
12907
|
+
if (!ispe) return null;
|
|
12908
|
+
const widthOffset = ispe.payloadOffset + 4;
|
|
12909
|
+
if (widthOffset + 8 > view.byteLength) return null;
|
|
12910
|
+
const width = view.getUint32(widthOffset, false);
|
|
12911
|
+
const height = view.getUint32(widthOffset + 4, false);
|
|
12912
|
+
if (width === 0 || height === 0) return null;
|
|
12913
|
+
return {
|
|
12914
|
+
width,
|
|
12915
|
+
height
|
|
12916
|
+
};
|
|
12917
|
+
}
|
|
12918
|
+
//#endregion
|
|
12919
|
+
//#region src/lib/url-fetch.ts
|
|
12920
|
+
/**
|
|
12921
|
+
* Safe remote URL fetching for server-side image sideloading.
|
|
12922
|
+
*
|
|
12923
|
+
* When an author pastes an article from another site, its `<img>` tags point at
|
|
12924
|
+
* remote URLs. To rehost those images into the site's own storage the server
|
|
12925
|
+
* must fetch the bytes itself (a browser `fetch` of a third-party image is
|
|
12926
|
+
* blocked by CORS for most hosts). Because the URL comes from pasted HTML it is
|
|
12927
|
+
* attacker-influenced, so every fetch passes through an SSRF guard and a bounded
|
|
12928
|
+
* reader that caps size and time.
|
|
12929
|
+
*
|
|
12930
|
+
* Note: DNS is not resolvable from a Cloudflare Worker, so the IP-literal checks
|
|
12931
|
+
* here are defense-in-depth for self-hosted (Node) deployments and for URLs that
|
|
12932
|
+
* embed a literal address. They are not a substitute for network-level egress
|
|
12933
|
+
* controls.
|
|
12934
|
+
*/
|
|
12935
|
+
/** A browser-like UA — many CDNs serving article images block unknown bots. */ var FETCH_USER_AGENT = "Mozilla/5.0 (compatible; Jant image sideloader) AppleWebKit/537.36";
|
|
12936
|
+
/**
|
|
12937
|
+
* Validate that a string is a public http(s) URL safe to fetch server-side.
|
|
12938
|
+
*
|
|
12939
|
+
* Throws {@link ValidationError} for non-http(s) protocols, embedded
|
|
12940
|
+
* credentials, localhost names, and private/loopback/link-local/ULA/CGNAT IP
|
|
12941
|
+
* literals (including the `169.254.169.254` cloud-metadata address).
|
|
12942
|
+
*
|
|
12943
|
+
* @param raw - The candidate URL string
|
|
12944
|
+
* @returns The parsed {@link URL}
|
|
12945
|
+
* @example
|
|
12946
|
+
* ```ts
|
|
12947
|
+
* const url = assertPublicHttpUrl("https://example.com/photo.jpg");
|
|
12948
|
+
* ```
|
|
12949
|
+
*/ function assertPublicHttpUrl(raw) {
|
|
12950
|
+
let url;
|
|
12951
|
+
try {
|
|
12952
|
+
url = new URL(raw);
|
|
12953
|
+
} catch {
|
|
12954
|
+
throw new ValidationError("That doesn't look like a valid image URL.");
|
|
12955
|
+
}
|
|
12956
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new ValidationError("Only http and https image URLs can be fetched.");
|
|
12957
|
+
if (url.username || url.password) throw new ValidationError("Image URLs can't include credentials.");
|
|
12958
|
+
const host = url.hostname.toLowerCase();
|
|
12959
|
+
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || isPrivateIpv4(host) || isPrivateIpv6(host)) throw new ValidationError("That image URL points to a private address.");
|
|
12960
|
+
return url;
|
|
12961
|
+
}
|
|
12962
|
+
/**
|
|
12963
|
+
* Fetch a remote image with an SSRF-checked redirect chain, a size cap, and a
|
|
12964
|
+
* timeout. Reads the body in chunks and aborts the moment it exceeds `maxBytes`
|
|
12965
|
+
* so an untrusted host can't exhaust memory.
|
|
12966
|
+
*
|
|
12967
|
+
* @param startUrl - A URL already validated by {@link assertPublicHttpUrl}
|
|
12968
|
+
* @param options - Size cap, timeout, and redirect budget
|
|
12969
|
+
* @returns The raw bytes and the response content-type
|
|
12970
|
+
* @example
|
|
12971
|
+
* ```ts
|
|
12972
|
+
* const { bytes, contentType } = await fetchImageBytes(url, {
|
|
12973
|
+
* maxBytes: 25 * 1024 * 1024,
|
|
12974
|
+
* timeoutMs: 15000,
|
|
12975
|
+
* });
|
|
12976
|
+
* ```
|
|
12977
|
+
*/ async function fetchImageBytes(startUrl, options) {
|
|
12978
|
+
const maxRedirects = options.maxRedirects ?? 3;
|
|
12979
|
+
const controller = new AbortController();
|
|
12980
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
12981
|
+
try {
|
|
12982
|
+
let url = startUrl;
|
|
12983
|
+
let response = null;
|
|
12984
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
12985
|
+
response = await fetch(url.toString(), {
|
|
12986
|
+
method: "GET",
|
|
12987
|
+
redirect: "manual",
|
|
12988
|
+
signal: controller.signal,
|
|
12989
|
+
headers: {
|
|
12990
|
+
Accept: "image/*,*/*;q=0.8",
|
|
12991
|
+
"User-Agent": FETCH_USER_AGENT,
|
|
12992
|
+
Referer: `${url.origin}/`
|
|
12993
|
+
}
|
|
12994
|
+
}).catch((error) => {
|
|
12995
|
+
if (controller.signal.aborted) throw new ValidationError("Timed out fetching the image.");
|
|
12996
|
+
throw new ValidationError(error instanceof Error ? `Couldn't fetch the image: ${error.message}` : "Couldn't fetch the image.");
|
|
12997
|
+
});
|
|
12998
|
+
if (response.status >= 300 && response.status < 400) {
|
|
12999
|
+
const location = response.headers.get("location");
|
|
13000
|
+
if (!location) break;
|
|
13001
|
+
if (hop === maxRedirects) throw new ValidationError("Too many redirects fetching the image.");
|
|
13002
|
+
url = assertPublicHttpUrl(new URL(location, url).toString());
|
|
13003
|
+
continue;
|
|
13004
|
+
}
|
|
13005
|
+
break;
|
|
13006
|
+
}
|
|
13007
|
+
if (!response) throw new ValidationError("Couldn't fetch the image.");
|
|
13008
|
+
if (!response.ok) throw new ValidationError(`Couldn't fetch the image (HTTP ${response.status}).`);
|
|
13009
|
+
const declared = Number(response.headers.get("content-length"));
|
|
13010
|
+
if (Number.isFinite(declared) && declared > options.maxBytes) throw new ValidationError("That image is too large.");
|
|
13011
|
+
const contentType = normalizeContentType(response.headers.get("content-type"));
|
|
13012
|
+
return {
|
|
13013
|
+
bytes: await readBounded(response, options.maxBytes),
|
|
13014
|
+
contentType
|
|
13015
|
+
};
|
|
13016
|
+
} finally {
|
|
13017
|
+
clearTimeout(timer);
|
|
13018
|
+
}
|
|
13019
|
+
}
|
|
13020
|
+
async function readBounded(response, maxBytes) {
|
|
13021
|
+
const body = response.body;
|
|
13022
|
+
if (!body) {
|
|
13023
|
+
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
13024
|
+
if (buffer.byteLength > maxBytes) throw new ValidationError("That image is too large.");
|
|
13025
|
+
return buffer;
|
|
13026
|
+
}
|
|
13027
|
+
const reader = body.getReader();
|
|
13028
|
+
const chunks = [];
|
|
13029
|
+
let total = 0;
|
|
13030
|
+
for (;;) {
|
|
13031
|
+
const { done, value } = await reader.read();
|
|
13032
|
+
if (done) break;
|
|
13033
|
+
if (!value) continue;
|
|
13034
|
+
total += value.byteLength;
|
|
13035
|
+
if (total > maxBytes) {
|
|
13036
|
+
await reader.cancel().catch(() => {});
|
|
13037
|
+
throw new ValidationError("That image is too large.");
|
|
13038
|
+
}
|
|
13039
|
+
chunks.push(value);
|
|
13040
|
+
}
|
|
13041
|
+
const out = new Uint8Array(total);
|
|
13042
|
+
let offset = 0;
|
|
13043
|
+
for (const chunk of chunks) {
|
|
13044
|
+
out.set(chunk, offset);
|
|
13045
|
+
offset += chunk.byteLength;
|
|
13046
|
+
}
|
|
13047
|
+
return out;
|
|
13048
|
+
}
|
|
13049
|
+
function normalizeContentType(raw) {
|
|
13050
|
+
if (!raw) return null;
|
|
13051
|
+
return raw.split(";")[0]?.trim().toLowerCase() || null;
|
|
13052
|
+
}
|
|
13053
|
+
/**
|
|
13054
|
+
* True when a canonicalized IPv4 dotted-quad host is in a private, loopback,
|
|
13055
|
+
* link-local, CGNAT, or otherwise non-public range. The WHATWG URL parser
|
|
13056
|
+
* already normalizes decimal/octal/hex IPv4 forms to dotted-quad, so checking
|
|
13057
|
+
* `url.hostname` is sufficient.
|
|
13058
|
+
*/ function isPrivateIpv4(host) {
|
|
13059
|
+
const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
13060
|
+
if (!match) return false;
|
|
13061
|
+
const octets = match.slice(1, 5).map(Number);
|
|
13062
|
+
if (octets.some((part) => part > 255)) return true;
|
|
13063
|
+
const a = octets[0] ?? 0;
|
|
13064
|
+
const b = octets[1] ?? 0;
|
|
13065
|
+
if (a === 0) return true;
|
|
13066
|
+
if (a === 10) return true;
|
|
13067
|
+
if (a === 127) return true;
|
|
13068
|
+
if (a === 169 && b === 254) return true;
|
|
13069
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
13070
|
+
if (a === 192 && b === 168) return true;
|
|
13071
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
13072
|
+
if (a === 255 && b === 255) return true;
|
|
13073
|
+
return false;
|
|
13074
|
+
}
|
|
13075
|
+
/** True when an IPv6 host (bracketed or bare) is loopback/link-local/ULA/mapped-private. */ function isPrivateIpv6(host) {
|
|
13076
|
+
let inner = host;
|
|
13077
|
+
if (inner.startsWith("[") && inner.endsWith("]")) inner = inner.slice(1, -1);
|
|
13078
|
+
if (!inner.includes(":")) return false;
|
|
13079
|
+
const lower = inner.toLowerCase();
|
|
13080
|
+
if (lower === "::" || lower === "::1") return true;
|
|
13081
|
+
const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
13082
|
+
if (dotted?.[1]) return isPrivateIpv4(dotted[1]);
|
|
13083
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
13084
|
+
if (hex) {
|
|
13085
|
+
const hi = parseInt(hex[1] ?? "0", 16);
|
|
13086
|
+
const lo = parseInt(hex[2] ?? "0", 16);
|
|
13087
|
+
return isPrivateIpv4(`${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`);
|
|
13088
|
+
}
|
|
13089
|
+
if (/^fe[89ab]/.test(lower)) return true;
|
|
13090
|
+
if (/^f[cd]/.test(lower)) return true;
|
|
13091
|
+
return false;
|
|
13092
|
+
}
|
|
13093
|
+
//#endregion
|
|
12661
13094
|
//#region src/services/media.ts
|
|
12662
13095
|
/**
|
|
12663
13096
|
* Media Service
|
|
@@ -12665,6 +13098,24 @@ function createLocalDriver(config) {
|
|
|
12665
13098
|
* Handles media upload and management with pluggable storage backends.
|
|
12666
13099
|
*/ var DEFAULT_MEDIA_POSITION = "a0";
|
|
12667
13100
|
/**
|
|
13101
|
+
* Derive a display filename from a remote image URL, falling back to
|
|
13102
|
+
* `image.<ext>` when the path has no usable basename.
|
|
13103
|
+
*
|
|
13104
|
+
* @param url - The remote image URL
|
|
13105
|
+
* @param ext - Extension to use when the URL path lacks one
|
|
13106
|
+
* @returns A sanitized original filename
|
|
13107
|
+
*/ function remoteImageName(url, ext) {
|
|
13108
|
+
let base;
|
|
13109
|
+
try {
|
|
13110
|
+
base = decodeURIComponent(url.pathname.split("/").pop() ?? "");
|
|
13111
|
+
} catch {
|
|
13112
|
+
base = url.pathname.split("/").pop() ?? "";
|
|
13113
|
+
}
|
|
13114
|
+
base = base.trim().replace(/[\r\n"\\]+/g, "");
|
|
13115
|
+
if (!base) return `image.${ext}`;
|
|
13116
|
+
return base.includes(".") ? base : `${base}.${ext}`;
|
|
13117
|
+
}
|
|
13118
|
+
/**
|
|
12668
13119
|
* Recycle-window length for deleted media storage objects. On delete we
|
|
12669
13120
|
* hard-remove the DB row but defer deleting the underlying storage object
|
|
12670
13121
|
* until this long afterwards (recorded in `storage_purge`), so an accidental
|
|
@@ -12978,6 +13429,39 @@ function createMediaService(db, siteId, databaseSchema = sqliteSchemaBundle, dat
|
|
|
12978
13429
|
updatedAt: timestamp
|
|
12979
13430
|
}).returning())[0]);
|
|
12980
13431
|
},
|
|
13432
|
+
async ingestFromUrl(input, deps) {
|
|
13433
|
+
const url = assertPublicHttpUrl(input.url);
|
|
13434
|
+
const { bytes } = await fetchImageBytes(url, {
|
|
13435
|
+
maxBytes: deps.maxFileSizeMB * 1024 * 1024,
|
|
13436
|
+
timeoutMs: 15e3
|
|
13437
|
+
});
|
|
13438
|
+
const sniffed = sniffImageMimeType(bytes);
|
|
13439
|
+
const mimeType = sniffed && isAllowedSideloadImageType(sniffed) ? sniffed : null;
|
|
13440
|
+
if (!mimeType) throw new ValidationError("That URL didn't return a supported image. Try a different image.");
|
|
13441
|
+
await assertCanWriteBytes(bytes.byteLength);
|
|
13442
|
+
const dimensions = parseImageDimensions(mimeType, bytes.subarray(0, IMAGE_DIMENSION_PEEK_BYTES));
|
|
13443
|
+
const ext = imageExtensionForMimeType(mimeType) ?? "bin";
|
|
13444
|
+
const { id, filename, storageKey } = generateStorageKey(siteId, `image.${ext}`);
|
|
13445
|
+
const originalName = remoteImageName(url, ext);
|
|
13446
|
+
await deps.storage.put(storageKey, bytes, {
|
|
13447
|
+
contentType: mimeType,
|
|
13448
|
+
contentDisposition: mimeType === "image/svg+xml" ? "attachment" : "inline",
|
|
13449
|
+
cacheControl: "public, max-age=31536000, immutable"
|
|
13450
|
+
});
|
|
13451
|
+
return this.create({
|
|
13452
|
+
id,
|
|
13453
|
+
filename,
|
|
13454
|
+
originalName,
|
|
13455
|
+
mimeType,
|
|
13456
|
+
size: bytes.byteLength,
|
|
13457
|
+
storageKey,
|
|
13458
|
+
provider: deps.storageDriver,
|
|
13459
|
+
mediaKind: "image",
|
|
13460
|
+
width: dimensions?.width,
|
|
13461
|
+
height: dimensions?.height,
|
|
13462
|
+
alt: input.alt?.trim() || void 0
|
|
13463
|
+
});
|
|
13464
|
+
},
|
|
12981
13465
|
async createTextAttachment(data, deps) {
|
|
12982
13466
|
if (!deps.storage) throw new ConfigurationError("File storage isn't set up. Check your server config.");
|
|
12983
13467
|
if (data.contentFormat !== "markdown") throw new ValidationError("Unsupported text attachment format");
|
|
@@ -22421,7 +22905,7 @@ async function syncHostedControlPlaneSiteAvatar(input) {
|
|
|
22421
22905
|
return;
|
|
22422
22906
|
}
|
|
22423
22907
|
await markSyncPending(settings);
|
|
22424
|
-
const { createGitHubSyncService } = await import("./github-sync-
|
|
22908
|
+
const { createGitHubSyncService } = await import("./github-sync-BD4w2m8-.js");
|
|
22425
22909
|
const { getGitHubAppConfig } = await import("./env-OHRKGcMj.js").then((n) => n.t);
|
|
22426
22910
|
const run = runBackgroundSync(settings, createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
|
|
22427
22911
|
storage: c.var.storage,
|
|
@@ -23219,7 +23703,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
|
|
|
23219
23703
|
await c.var.services.settings.set("GITHUB_SYNC_AUTH_MODE", "pat");
|
|
23220
23704
|
await c.var.services.settings.set("GITHUB_SYNC_APP_INSTALLATION_ID", "");
|
|
23221
23705
|
await c.var.services.settings.set("GITHUB_SYNC_ENABLED", "true");
|
|
23222
|
-
const { createGitHubSyncService } = await import("./github-sync-
|
|
23706
|
+
const { createGitHubSyncService } = await import("./github-sync-BD4w2m8-.js");
|
|
23223
23707
|
const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
|
|
23224
23708
|
storage: c.var.storage,
|
|
23225
23709
|
githubApp: getGitHubAppConfig(c.env)
|
|
@@ -23238,7 +23722,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
|
|
|
23238
23722
|
return dsRedirect(publicPath(c, "/settings/github-sync"));
|
|
23239
23723
|
});
|
|
23240
23724
|
settingsRoutes.post("/github-sync/push", async (c) => {
|
|
23241
|
-
const { createGitHubSyncService } = await import("./github-sync-
|
|
23725
|
+
const { createGitHubSyncService } = await import("./github-sync-BD4w2m8-.js");
|
|
23242
23726
|
const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
|
|
23243
23727
|
storage: c.var.storage,
|
|
23244
23728
|
githubApp: getGitHubAppConfig(c.env)
|
|
@@ -23258,7 +23742,7 @@ settingsRoutes.post("/github-sync/push", async (c) => {
|
|
|
23258
23742
|
});
|
|
23259
23743
|
});
|
|
23260
23744
|
settingsRoutes.post("/github-sync/disconnect", async (c) => {
|
|
23261
|
-
const { createGitHubSyncService } = await import("./github-sync-
|
|
23745
|
+
const { createGitHubSyncService } = await import("./github-sync-BD4w2m8-.js");
|
|
23262
23746
|
await createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), { githubApp: getGitHubAppConfig(c.env) }).teardownWebhook();
|
|
23263
23747
|
return dsRedirect(publicPath(c, "/settings/github-sync"));
|
|
23264
23748
|
});
|
|
@@ -23449,7 +23933,7 @@ function buildRepoPickerLabels(c) {
|
|
|
23449
23933
|
const { parseRepoSlug, createGitHubClient } = await import("./github-api-BgSiE71w.js").then((n) => n.n);
|
|
23450
23934
|
const parsed = parseRepoSlug(repo);
|
|
23451
23935
|
if (!parsed) return wantsJson ? c.json({ error: "Invalid repository format." }, 400) : c.text("Invalid repository format.", 400);
|
|
23452
|
-
const { classifyRepoForSync } = await import("./github-sync-
|
|
23936
|
+
const { classifyRepoForSync } = await import("./github-sync-BD4w2m8-.js");
|
|
23453
23937
|
const ghClient = createGitHubClient(() => getInstallationTokenFromApp(app, installationId));
|
|
23454
23938
|
let classification;
|
|
23455
23939
|
try {
|
|
@@ -23479,7 +23963,7 @@ function buildRepoPickerLabels(c) {
|
|
|
23479
23963
|
await c.var.services.settings.set("GITHUB_SYNC_REPO", repo);
|
|
23480
23964
|
await c.var.services.settings.set("GITHUB_SYNC_TOKEN", "");
|
|
23481
23965
|
await c.var.services.settings.set("GITHUB_SYNC_ENABLED", "true");
|
|
23482
|
-
const { createGitHubSyncService } = await import("./github-sync-
|
|
23966
|
+
const { createGitHubSyncService } = await import("./github-sync-BD4w2m8-.js");
|
|
23483
23967
|
const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
|
|
23484
23968
|
storage: c.var.storage,
|
|
23485
23969
|
githubApp: app
|
|
@@ -23595,7 +24079,7 @@ function requireGitHubApp(c) {
|
|
|
23595
24079
|
const { parseRepoSlug, createGitHubClient } = await import("./github-api-BgSiE71w.js").then((n) => n.n);
|
|
23596
24080
|
const parsed = parseRepoSlug(repo);
|
|
23597
24081
|
if (!parsed) return c.json({ error: "Invalid repository format." }, 400);
|
|
23598
|
-
const { classifyRepoForSync } = await import("./github-sync-
|
|
24082
|
+
const { classifyRepoForSync } = await import("./github-sync-BD4w2m8-.js");
|
|
23599
24083
|
const client = createGitHubClient(() => getInstallationTokenFromApp(app, installationId));
|
|
23600
24084
|
try {
|
|
23601
24085
|
const classification = await classifyRepoForSync(client, parsed.owner, parsed.repo, c.var.currentSite.id);
|
|
@@ -24753,194 +25237,6 @@ settingsApiRoutes.delete("/avatar", requireAuthApi(), async (c) => {
|
|
|
24753
25237
|
return c.json({ success: true });
|
|
24754
25238
|
});
|
|
24755
25239
|
//#endregion
|
|
24756
|
-
//#region src/lib/image-dimensions.ts
|
|
24757
|
-
/**
|
|
24758
|
-
* Parse intrinsic pixel dimensions from a raw image header buffer.
|
|
24759
|
-
*
|
|
24760
|
-
* Used as a server-side fallback when an upload client does not provide
|
|
24761
|
-
* `width` / `height`. Only the file header is needed — typically the first
|
|
24762
|
-
* few hundred bytes for PNG/JPEG/GIF/WebP, and up to ~64 KB for AVIF where
|
|
24763
|
-
* the `ispe` property can be nested inside a larger `meta` box.
|
|
24764
|
-
*
|
|
24765
|
-
* Returns `null` when the bytes are too short, the format is unsupported,
|
|
24766
|
-
* or the header is malformed.
|
|
24767
|
-
*
|
|
24768
|
-
* @param mimeType MIME type the upload was declared as (e.g. `image/png`).
|
|
24769
|
-
* @param bytes Bytes from the start of the file. Pass at least
|
|
24770
|
-
* {@link IMAGE_DIMENSION_PEEK_BYTES} for AVIF reliability; smaller is fine
|
|
24771
|
-
* for other formats.
|
|
24772
|
-
*/ function parseImageDimensions(mimeType, bytes) {
|
|
24773
|
-
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
24774
|
-
switch (mimeType) {
|
|
24775
|
-
case "image/png": return parsePng(view);
|
|
24776
|
-
case "image/jpeg":
|
|
24777
|
-
case "image/jpg": return parseJpeg(view);
|
|
24778
|
-
case "image/gif": return parseGif(view);
|
|
24779
|
-
case "image/webp": return parseWebp(view);
|
|
24780
|
-
case "image/avif": return parseIsoBmff(view, new Set(["avif", "avis"]));
|
|
24781
|
-
default: return null;
|
|
24782
|
-
}
|
|
24783
|
-
}
|
|
24784
|
-
/**
|
|
24785
|
-
* Recommended number of header bytes to feed into {@link parseImageDimensions}.
|
|
24786
|
-
*
|
|
24787
|
-
* Covers AVIF files with EXIF/ICC properties placed before the `ispe` box.
|
|
24788
|
-
* Smaller formats only inspect the first ~30 bytes.
|
|
24789
|
-
*/ var IMAGE_DIMENSION_PEEK_BYTES = 64 * 1024;
|
|
24790
|
-
function readChars(view, offset, length) {
|
|
24791
|
-
let out = "";
|
|
24792
|
-
for (let i = 0; i < length; i += 1) out += String.fromCharCode(view.getUint8(offset + i));
|
|
24793
|
-
return out;
|
|
24794
|
-
}
|
|
24795
|
-
function parsePng(view) {
|
|
24796
|
-
if (view.byteLength < 24) return null;
|
|
24797
|
-
const signature = [
|
|
24798
|
-
137,
|
|
24799
|
-
80,
|
|
24800
|
-
78,
|
|
24801
|
-
71,
|
|
24802
|
-
13,
|
|
24803
|
-
10,
|
|
24804
|
-
26,
|
|
24805
|
-
10
|
|
24806
|
-
];
|
|
24807
|
-
for (let i = 0; i < 8; i += 1) if (view.getUint8(i) !== signature[i]) return null;
|
|
24808
|
-
const width = view.getUint32(16, false);
|
|
24809
|
-
const height = view.getUint32(20, false);
|
|
24810
|
-
if (width === 0 || height === 0) return null;
|
|
24811
|
-
return {
|
|
24812
|
-
width,
|
|
24813
|
-
height
|
|
24814
|
-
};
|
|
24815
|
-
}
|
|
24816
|
-
function parseGif(view) {
|
|
24817
|
-
if (view.byteLength < 10) return null;
|
|
24818
|
-
if (view.getUint8(0) !== 71 || view.getUint8(1) !== 73 || view.getUint8(2) !== 70) return null;
|
|
24819
|
-
const width = view.getUint16(6, true);
|
|
24820
|
-
const height = view.getUint16(8, true);
|
|
24821
|
-
if (width === 0 || height === 0) return null;
|
|
24822
|
-
return {
|
|
24823
|
-
width,
|
|
24824
|
-
height
|
|
24825
|
-
};
|
|
24826
|
-
}
|
|
24827
|
-
function parseWebp(view) {
|
|
24828
|
-
if (view.byteLength < 16) return null;
|
|
24829
|
-
if (readChars(view, 0, 4) !== "RIFF") return null;
|
|
24830
|
-
if (readChars(view, 8, 4) !== "WEBP") return null;
|
|
24831
|
-
const chunk = readChars(view, 12, 4);
|
|
24832
|
-
if (chunk === "VP8 ") {
|
|
24833
|
-
if (view.byteLength < 30) return null;
|
|
24834
|
-
if (view.getUint8(23) !== 157 || view.getUint8(24) !== 1 || view.getUint8(25) !== 42) return null;
|
|
24835
|
-
const width = view.getUint16(26, true) & 16383;
|
|
24836
|
-
const height = view.getUint16(28, true) & 16383;
|
|
24837
|
-
if (width === 0 || height === 0) return null;
|
|
24838
|
-
return {
|
|
24839
|
-
width,
|
|
24840
|
-
height
|
|
24841
|
-
};
|
|
24842
|
-
}
|
|
24843
|
-
if (chunk === "VP8L") {
|
|
24844
|
-
if (view.byteLength < 25) return null;
|
|
24845
|
-
if (view.getUint8(20) !== 47) return null;
|
|
24846
|
-
const b0 = view.getUint8(21);
|
|
24847
|
-
const b1 = view.getUint8(22);
|
|
24848
|
-
const b2 = view.getUint8(23);
|
|
24849
|
-
const b3 = view.getUint8(24);
|
|
24850
|
-
return {
|
|
24851
|
-
width: 1 + ((b1 & 63) << 8 | b0),
|
|
24852
|
-
height: 1 + ((b3 & 15) << 10 | b2 << 2 | (b1 & 192) >> 6)
|
|
24853
|
-
};
|
|
24854
|
-
}
|
|
24855
|
-
if (chunk === "VP8X") {
|
|
24856
|
-
if (view.byteLength < 30) return null;
|
|
24857
|
-
return {
|
|
24858
|
-
width: 1 + (view.getUint8(24) | view.getUint8(25) << 8 | view.getUint8(26) << 16),
|
|
24859
|
-
height: 1 + (view.getUint8(27) | view.getUint8(28) << 8 | view.getUint8(29) << 16)
|
|
24860
|
-
};
|
|
24861
|
-
}
|
|
24862
|
-
return null;
|
|
24863
|
-
}
|
|
24864
|
-
function parseJpeg(view) {
|
|
24865
|
-
const length = view.byteLength;
|
|
24866
|
-
if (length < 4) return null;
|
|
24867
|
-
if (view.getUint8(0) !== 255 || view.getUint8(1) !== 216) return null;
|
|
24868
|
-
let i = 2;
|
|
24869
|
-
while (i < length) {
|
|
24870
|
-
while (i < length && view.getUint8(i) !== 255) i += 1;
|
|
24871
|
-
while (i < length && view.getUint8(i) === 255) i += 1;
|
|
24872
|
-
if (i >= length) return null;
|
|
24873
|
-
const marker = view.getUint8(i);
|
|
24874
|
-
i += 1;
|
|
24875
|
-
if (marker === 0 || marker === 1) continue;
|
|
24876
|
-
if (marker >= 208 && marker <= 217) continue;
|
|
24877
|
-
if (i + 2 > length) return null;
|
|
24878
|
-
const segLen = view.getUint16(i, false);
|
|
24879
|
-
if (segLen < 2) return null;
|
|
24880
|
-
if (marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207) {
|
|
24881
|
-
if (i + 7 > length) return null;
|
|
24882
|
-
const height = view.getUint16(i + 3, false);
|
|
24883
|
-
const width = view.getUint16(i + 5, false);
|
|
24884
|
-
if (width === 0 || height === 0) return null;
|
|
24885
|
-
return {
|
|
24886
|
-
width,
|
|
24887
|
-
height
|
|
24888
|
-
};
|
|
24889
|
-
}
|
|
24890
|
-
i += segLen;
|
|
24891
|
-
}
|
|
24892
|
-
return null;
|
|
24893
|
-
}
|
|
24894
|
-
function readBox(view, pos) {
|
|
24895
|
-
if (pos + 8 > view.byteLength) return null;
|
|
24896
|
-
let size = view.getUint32(pos, false);
|
|
24897
|
-
const type = readChars(view, pos + 4, 4);
|
|
24898
|
-
if (size === 1) return null;
|
|
24899
|
-
if (size === 0) size = view.byteLength - pos;
|
|
24900
|
-
if (size < 8) return null;
|
|
24901
|
-
return {
|
|
24902
|
-
size,
|
|
24903
|
-
type,
|
|
24904
|
-
payloadOffset: pos + 8,
|
|
24905
|
-
end: pos + size
|
|
24906
|
-
};
|
|
24907
|
-
}
|
|
24908
|
-
function findChild(view, start, end, type) {
|
|
24909
|
-
let pos = start;
|
|
24910
|
-
while (pos < end) {
|
|
24911
|
-
const box = readBox(view, pos);
|
|
24912
|
-
if (!box) return null;
|
|
24913
|
-
if (box.type === type) return box;
|
|
24914
|
-
pos = box.end;
|
|
24915
|
-
}
|
|
24916
|
-
return null;
|
|
24917
|
-
}
|
|
24918
|
-
function parseIsoBmff(view, acceptedBrands) {
|
|
24919
|
-
const ftyp = readBox(view, 0);
|
|
24920
|
-
if (!ftyp || ftyp.type !== "ftyp") return null;
|
|
24921
|
-
let isAccepted = false;
|
|
24922
|
-
if (ftyp.payloadOffset + 4 <= ftyp.end) isAccepted = acceptedBrands.has(readChars(view, ftyp.payloadOffset, 4));
|
|
24923
|
-
for (let q = ftyp.payloadOffset + 8; !isAccepted && q + 4 <= ftyp.end; q += 4) if (acceptedBrands.has(readChars(view, q, 4))) isAccepted = true;
|
|
24924
|
-
if (!isAccepted) return null;
|
|
24925
|
-
const meta = findChild(view, ftyp.end, view.byteLength, "meta");
|
|
24926
|
-
if (!meta) return null;
|
|
24927
|
-
const iprp = findChild(view, meta.payloadOffset + 4, meta.end, "iprp");
|
|
24928
|
-
if (!iprp) return null;
|
|
24929
|
-
const ipco = findChild(view, iprp.payloadOffset, iprp.end, "ipco");
|
|
24930
|
-
if (!ipco) return null;
|
|
24931
|
-
const ispe = findChild(view, ipco.payloadOffset, ipco.end, "ispe");
|
|
24932
|
-
if (!ispe) return null;
|
|
24933
|
-
const widthOffset = ispe.payloadOffset + 4;
|
|
24934
|
-
if (widthOffset + 8 > view.byteLength) return null;
|
|
24935
|
-
const width = view.getUint32(widthOffset, false);
|
|
24936
|
-
const height = view.getUint32(widthOffset + 4, false);
|
|
24937
|
-
if (width === 0 || height === 0) return null;
|
|
24938
|
-
return {
|
|
24939
|
-
width,
|
|
24940
|
-
height
|
|
24941
|
-
};
|
|
24942
|
-
}
|
|
24943
|
-
//#endregion
|
|
24944
25240
|
//#region src/lib/api-media.ts
|
|
24945
25241
|
function toApiMedia(media, appConfig) {
|
|
24946
25242
|
const { posterKey: _posterKey, storageKey: _storageKey, ...rest } = media;
|
|
@@ -25220,8 +25516,39 @@ var CompleteUploadSchema = z.object({
|
|
|
25220
25516
|
etag: z.string().min(1)
|
|
25221
25517
|
})).optional()
|
|
25222
25518
|
});
|
|
25519
|
+
var SideloadSchema = z.object({
|
|
25520
|
+
url: z.string().url(),
|
|
25521
|
+
alt: z.string().max(2e3).optional()
|
|
25522
|
+
});
|
|
25223
25523
|
var uploadsApiRoutes = new Hono();
|
|
25224
25524
|
uploadsApiRoutes.use("*", requireAuthApi());
|
|
25525
|
+
/**
|
|
25526
|
+
* Rehost a remote image into the site's own storage. Used when an author pastes
|
|
25527
|
+
* an article whose `<img>` tags point at external URLs — the server fetches the
|
|
25528
|
+
* bytes (bypassing browser CORS) and stores them. Returns the new media's
|
|
25529
|
+
* public URL so the editor can swap the node's `src`.
|
|
25530
|
+
*/ uploadsApiRoutes.post("/sideload", async (c) => {
|
|
25531
|
+
const storage = c.var.storage;
|
|
25532
|
+
if (!storage) return c.json({ error: "File storage isn't set up. Check your server config." }, 500);
|
|
25533
|
+
const { url, alt } = parseValidated(SideloadSchema, await c.req.json());
|
|
25534
|
+
const media = await c.var.services.media.ingestFromUrl({
|
|
25535
|
+
url,
|
|
25536
|
+
alt
|
|
25537
|
+
}, {
|
|
25538
|
+
storage,
|
|
25539
|
+
storageDriver: c.var.appConfig.storageDriver,
|
|
25540
|
+
maxFileSizeMB: c.var.appConfig.uploadMaxFileSize
|
|
25541
|
+
});
|
|
25542
|
+
const mediaPublicUrl = getPublicUrlForProvider(c.var.appConfig.storageDriver, c.var.appConfig.r2PublicUrl, c.var.appConfig.s3PublicUrl, c.var.appConfig.localPublicUrl);
|
|
25543
|
+
return c.json({
|
|
25544
|
+
id: media.id,
|
|
25545
|
+
url: getMediaUrl(media.storageKey, mediaPublicUrl, c.var.appConfig.sitePathPrefix),
|
|
25546
|
+
width: media.width,
|
|
25547
|
+
height: media.height,
|
|
25548
|
+
mimeType: media.mimeType,
|
|
25549
|
+
size: media.size
|
|
25550
|
+
});
|
|
25551
|
+
});
|
|
25225
25552
|
function scheduleExpiredUploadCleanup(c, limit) {
|
|
25226
25553
|
const storage = c.var.storage;
|
|
25227
25554
|
if (!storage) return;
|
|
@@ -33326,7 +33653,7 @@ function createSiteAdminService(db, databaseSchema = sqliteSchemaBundle, databas
|
|
|
33326
33653
|
const themeCss = buildThemeStyle(activeTheme, appConfig.themeMode, fontOverrides);
|
|
33327
33654
|
const navItemList = await navItems.list();
|
|
33328
33655
|
const appleTouchKey = allSettings[SETTINGS_KEYS.SITE_FAVICON_APPLE_TOUCH];
|
|
33329
|
-
const { createExportService } = await import("./export-
|
|
33656
|
+
const { createExportService } = await import("./export-Ba7NJImL.js").then((n) => n.n);
|
|
33330
33657
|
const exportService = createExportService({
|
|
33331
33658
|
collections,
|
|
33332
33659
|
media: mediaService,
|