@jant/core 0.2.4 → 0.2.5

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/client.d.ts CHANGED
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * Bundles all interactive components:
5
5
  * - Datastar (reactivity)
6
- * - BaseCoat (dialogs, dropdowns, etc.)
6
+ * - BaseCoat (dialogs, dropdowns)
7
+ * - ImageProcessor (media uploads)
7
8
  */
8
9
  import "@sudodevnull/datastar";
9
10
  import "basecoat-css/all";
11
+ import "./lib/image-processor.js";
10
12
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,uBAAuB,CAAC;AAG/B,OAAO,kBAAkB,CAAC"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,uBAAuB,CAAC;AAC/B,OAAO,kBAAkB,CAAC;AAC1B,OAAO,0BAA0B,CAAC"}
package/dist/client.js CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Bundles all interactive components:
5
5
  * - Datastar (reactivity)
6
- * - BaseCoat (dialogs, dropdowns, etc.)
7
- */ // Datastar for reactivity (data-* attributes)
8
- import "@sudodevnull/datastar";
9
- // BaseCoat interactive components
6
+ * - BaseCoat (dialogs, dropdowns)
7
+ * - ImageProcessor (media uploads)
8
+ */ import "@sudodevnull/datastar";
10
9
  import "basecoat-css/all";
10
+ import "./lib/image-processor.js";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Client-side Image Processor
3
+ *
4
+ * Processes images before upload:
5
+ * - Corrects EXIF orientation
6
+ * - Resizes to max dimensions
7
+ * - Strips all metadata (privacy)
8
+ * - Converts to WebP format
9
+ */
10
+ declare const DEFAULT_OPTIONS: {
11
+ maxWidth: number;
12
+ maxHeight: number;
13
+ quality: number;
14
+ mimeType: "image/webp";
15
+ };
16
+ type ProcessOptions = Partial<typeof DEFAULT_OPTIONS>;
17
+ /**
18
+ * Process image file
19
+ */
20
+ declare function process(file: File, options?: ProcessOptions): Promise<Blob>;
21
+ /**
22
+ * Process file and create a new File object
23
+ */
24
+ declare function processToFile(file: File, options?: ProcessOptions): Promise<File>;
25
+ export declare const ImageProcessor: {
26
+ process: typeof process;
27
+ processToFile: typeof processToFile;
28
+ };
29
+ export {};
30
+ //# sourceMappingURL=image-processor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-processor.d.ts","sourceRoot":"","sources":["../../src/lib/image-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,QAAA,MAAM,eAAe;;;;;CAKpB,CAAC;AAEF,KAAK,cAAc,GAAG,OAAO,CAAC,OAAO,eAAe,CAAC,CAAC;AAiHtD;;GAEG;AACH,iBAAe,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgE9E;AAED;;GAEG;AACH,iBAAe,aAAa,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAQpF;AAED,eAAO,MAAM,cAAc;;;CAA6B,CAAC"}
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Client-side Image Processor
3
+ *
4
+ * Processes images before upload:
5
+ * - Corrects EXIF orientation
6
+ * - Resizes to max dimensions
7
+ * - Strips all metadata (privacy)
8
+ * - Converts to WebP format
9
+ */ const DEFAULT_OPTIONS = {
10
+ maxWidth: 1920,
11
+ maxHeight: 1920,
12
+ quality: 0.85,
13
+ mimeType: "image/webp"
14
+ };
15
+ /**
16
+ * EXIF Orientation values and their transformations
17
+ */ const ORIENTATIONS = {
18
+ 1: {
19
+ rotate: 0,
20
+ flip: false
21
+ },
22
+ 2: {
23
+ rotate: 0,
24
+ flip: true
25
+ },
26
+ 3: {
27
+ rotate: 180,
28
+ flip: false
29
+ },
30
+ 4: {
31
+ rotate: 180,
32
+ flip: true
33
+ },
34
+ 5: {
35
+ rotate: 90,
36
+ flip: true
37
+ },
38
+ 6: {
39
+ rotate: 90,
40
+ flip: false
41
+ },
42
+ 7: {
43
+ rotate: 270,
44
+ flip: true
45
+ },
46
+ 8: {
47
+ rotate: 270,
48
+ flip: false
49
+ }
50
+ };
51
+ /**
52
+ * Read EXIF orientation from JPEG file
53
+ */ function readExifOrientation(buffer) {
54
+ const view = new DataView(buffer);
55
+ // Check for JPEG SOI marker
56
+ if (view.getUint16(0) !== 0xffd8) return 1;
57
+ let offset = 2;
58
+ const length = view.byteLength;
59
+ while(offset < length){
60
+ if (view.getUint8(offset) !== 0xff) return 1;
61
+ const marker = view.getUint8(offset + 1);
62
+ // APP1 marker (EXIF)
63
+ if (marker === 0xe1) {
64
+ const exifOffset = offset + 4;
65
+ // Check for "Exif\0\0"
66
+ if (view.getUint32(exifOffset) !== 0x45786966 || view.getUint16(exifOffset + 4) !== 0x0000) {
67
+ return 1;
68
+ }
69
+ const tiffOffset = exifOffset + 6;
70
+ const littleEndian = view.getUint16(tiffOffset) === 0x4949;
71
+ // Validate TIFF header
72
+ if (view.getUint16(tiffOffset + 2, littleEndian) !== 0x002a) return 1;
73
+ const ifdOffset = view.getUint32(tiffOffset + 4, littleEndian);
74
+ const numEntries = view.getUint16(tiffOffset + ifdOffset, littleEndian);
75
+ // Search for orientation tag (0x0112)
76
+ for(let i = 0; i < numEntries; i++){
77
+ const entryOffset = tiffOffset + ifdOffset + 2 + i * 12;
78
+ const tag = view.getUint16(entryOffset, littleEndian);
79
+ if (tag === 0x0112) {
80
+ return view.getUint16(entryOffset + 8, littleEndian);
81
+ }
82
+ }
83
+ return 1;
84
+ }
85
+ // Skip to next marker
86
+ if (marker === 0xd8 || marker === 0xd9) {
87
+ offset += 2;
88
+ } else {
89
+ offset += 2 + view.getUint16(offset + 2);
90
+ }
91
+ }
92
+ return 1;
93
+ }
94
+ /**
95
+ * Load image from file
96
+ */ function loadImage(file) {
97
+ return new Promise((resolve, reject)=>{
98
+ const img = new Image();
99
+ img.onload = ()=>{
100
+ URL.revokeObjectURL(img.src);
101
+ resolve(img);
102
+ };
103
+ img.onerror = ()=>reject(new Error("Failed to load image"));
104
+ img.src = URL.createObjectURL(file);
105
+ });
106
+ }
107
+ /**
108
+ * Calculate output dimensions maintaining aspect ratio
109
+ */ function calculateDimensions(width, height, maxWidth, maxHeight) {
110
+ if (width <= maxWidth && height <= maxHeight) {
111
+ return {
112
+ width,
113
+ height
114
+ };
115
+ }
116
+ const ratio = Math.min(maxWidth / width, maxHeight / height);
117
+ return {
118
+ width: Math.round(width * ratio),
119
+ height: Math.round(height * ratio)
120
+ };
121
+ }
122
+ /**
123
+ * Process image file
124
+ */ async function process(file, options = {}) {
125
+ const opts = {
126
+ ...DEFAULT_OPTIONS,
127
+ ...options
128
+ };
129
+ // Read file buffer for EXIF
130
+ const buffer = await file.arrayBuffer();
131
+ const orientation = readExifOrientation(buffer);
132
+ const transform = ORIENTATIONS[orientation] || ORIENTATIONS[1];
133
+ // Load image
134
+ const img = await loadImage(file);
135
+ // For 90° or 270° rotation, swap dimensions
136
+ const isRotated = transform.rotate === 90 || transform.rotate === 270;
137
+ const srcWidth = isRotated ? img.height : img.width;
138
+ const srcHeight = isRotated ? img.width : img.height;
139
+ // Calculate output size
140
+ const { width, height } = calculateDimensions(srcWidth, srcHeight, opts.maxWidth, opts.maxHeight);
141
+ // Create canvas
142
+ const canvas = document.createElement("canvas");
143
+ canvas.width = width;
144
+ canvas.height = height;
145
+ const ctx = canvas.getContext("2d");
146
+ if (!ctx) throw new Error("Failed to get canvas context");
147
+ // Apply transformations
148
+ ctx.save();
149
+ ctx.translate(width / 2, height / 2);
150
+ if (transform.rotate) {
151
+ ctx.rotate(transform.rotate * Math.PI / 180);
152
+ }
153
+ if (transform.flip) {
154
+ ctx.scale(-1, 1);
155
+ }
156
+ const drawWidth = isRotated ? height : width;
157
+ const drawHeight = isRotated ? width : height;
158
+ ctx.drawImage(img, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
159
+ ctx.restore();
160
+ // Export as WebP
161
+ return new Promise((resolve, reject)=>{
162
+ canvas.toBlob((blob)=>{
163
+ if (blob) {
164
+ resolve(blob);
165
+ } else {
166
+ reject(new Error("Failed to create blob"));
167
+ }
168
+ }, opts.mimeType, opts.quality);
169
+ });
170
+ }
171
+ /**
172
+ * Process file and create a new File object
173
+ */ async function processToFile(file, options = {}) {
174
+ const blob = await process(file, options);
175
+ // Generate new filename with .webp extension
176
+ const originalName = file.name.replace(/\.[^.]+$/, "");
177
+ const newName = `${originalName}.webp`;
178
+ return new File([
179
+ blob
180
+ ], newName, {
181
+ type: "image/webp"
182
+ });
183
+ }
184
+ export const ImageProcessor = {
185
+ process,
186
+ processToFile
187
+ };
188
+ // Expose globally for inline scripts
189
+ if (typeof window !== "undefined") {
190
+ window.ImageProcessor = ImageProcessor;
191
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../../src/routes/dash/media.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAE,QAAQ,EAAS,MAAM,gBAAgB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAOjD,KAAK,GAAG,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,YAAY,CAAA;CAAE,CAAC;AAE3D,eAAO,MAAM,WAAW,kDAAkB,CAAC"}
1
+ {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../../src/routes/dash/media.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAE,QAAQ,EAAS,MAAM,gBAAgB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAMjD,KAAK,GAAG,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,YAAY,CAAA;CAAE,CAAC;AAE3D,eAAO,MAAM,WAAW,kDAAkB,CAAC"}
@@ -10,7 +10,6 @@ import { DashLayout } from "../../theme/layouts/index.js";
10
10
  import { EmptyState, DangerZone } from "../../theme/components/index.js";
11
11
  import * as time from "../../lib/time.js";
12
12
  import { getMediaUrl, getImageUrl } from "../../lib/image.js";
13
- import { getAssets } from "../../lib/assets.js";
14
13
  export const mediaRoutes = new Hono();
15
14
  /**
16
15
  * Format file size for display
@@ -73,7 +72,7 @@ export const mediaRoutes = new Hono();
73
72
  *
74
73
  * Uses plain JavaScript for upload state management (more reliable than Datastar signals
75
74
  * for complex async flows like file uploads with SSE responses).
76
- */ function MediaListContent({ mediaList, r2PublicUrl, imageTransformUrl, imageProcessorUrl }) {
75
+ */ function MediaListContent({ mediaList, r2PublicUrl, imageTransformUrl }) {
77
76
  const { t } = useLingui();
78
77
  const processingText = t({
79
78
  message: "Processing...",
@@ -245,9 +244,6 @@ function processSSEEvent(event) {
245
244
  `.trim();
246
245
  return /*#__PURE__*/ _jsxs(_Fragment, {
247
246
  children: [
248
- /*#__PURE__*/ _jsx("script", {
249
- src: imageProcessorUrl
250
- }),
251
247
  /*#__PURE__*/ _jsx("script", {
252
248
  dangerouslySetInnerHTML: {
253
249
  __html: uploadScript
@@ -535,7 +531,6 @@ mediaRoutes.get("/", async (c)=>{
535
531
  const siteName = await c.var.services.settings.get("SITE_NAME") ?? "Jant";
536
532
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
537
533
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
538
- const assets = getAssets();
539
534
  return c.html(/*#__PURE__*/ _jsx(DashLayout, {
540
535
  c: c,
541
536
  title: "Media",
@@ -544,8 +539,7 @@ mediaRoutes.get("/", async (c)=>{
544
539
  children: /*#__PURE__*/ _jsx(MediaListContent, {
545
540
  mediaList: mediaList,
546
541
  r2PublicUrl: r2PublicUrl,
547
- imageTransformUrl: imageTransformUrl,
548
- imageProcessorUrl: assets.imageProcessor
542
+ imageTransformUrl: imageTransformUrl
549
543
  })
550
544
  }));
551
545
  });
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Provides the HTML shell with meta tags, styles, and scripts.
5
5
  * If Context is provided, automatically wraps children with I18nProvider.
6
+ *
7
+ * Uses vite-ssr-components for automatic dev/prod asset path resolution.
6
8
  */
7
9
  import type { FC, PropsWithChildren } from "hono/jsx";
8
10
  import type { Context } from "hono";
@@ -1 +1 @@
1
- {"version":3,"file":"BaseLayout.d.ts","sourceRoot":"","sources":["../../../src/theme/layouts/BaseLayout.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,EAAE,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAIpC,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,CAAC,EAAE,OAAO,CAAC;CACb;AAED,eAAO,MAAM,UAAU,EAAE,EAAE,CAAC,iBAAiB,CAAC,eAAe,CAAC,CA8B7D,CAAC"}
1
+ {"version":3,"file":"BaseLayout.d.ts","sourceRoot":"","sources":["../../../src/theme/layouts/BaseLayout.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,EAAE,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAIpC,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,CAAC,EAAE,OAAO,CAAC;CACb;AAED,eAAO,MAAM,UAAU,EAAE,EAAE,CAAC,iBAAiB,CAAC,eAAe,CAAC,CA0B7D,CAAC"}
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * Provides the HTML shell with meta tags, styles, and scripts.
5
5
  * If Context is provided, automatically wraps children with I18nProvider.
6
+ *
7
+ * Uses vite-ssr-components for automatic dev/prod asset path resolution.
6
8
  */ import { jsx as _jsx, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
7
- import { getAssets } from "../../lib/assets.js";
9
+ import { Script, Link, ViteClient } from "vite-ssr-components/hono";
8
10
  import { I18nProvider } from "../../i18n/index.js";
9
11
  export const BaseLayout = ({ title, description, lang = "en", c, children })=>{
10
- // Get assets at render time (supports runtime manifest loading)
11
- const assets = getAssets();
12
12
  // Automatically wrap with I18nProvider if Context is provided
13
13
  const content = c ? /*#__PURE__*/ _jsx(I18nProvider, {
14
14
  c: c,
@@ -33,14 +33,13 @@ export const BaseLayout = ({ title, description, lang = "en", c, children })=>{
33
33
  name: "description",
34
34
  content: description
35
35
  }),
36
- assets.styles && /*#__PURE__*/ _jsx("link", {
37
- rel: "stylesheet",
38
- href: assets.styles
36
+ /*#__PURE__*/ _jsx(ViteClient, {}),
37
+ /*#__PURE__*/ _jsx(Link, {
38
+ href: "/src/style.css",
39
+ rel: "stylesheet"
39
40
  }),
40
- /*#__PURE__*/ _jsx("script", {
41
- type: "module",
42
- src: assets.client,
43
- defer: true
41
+ /*#__PURE__*/ _jsx(Script, {
42
+ src: "/src/client.ts"
44
43
  })
45
44
  ]
46
45
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,6 @@
22
22
  "files": [
23
23
  "bin",
24
24
  "dist",
25
- "static",
26
25
  "migrations",
27
26
  "src"
28
27
  ],
@@ -72,6 +71,7 @@
72
71
  "typescript": "^5.9.3",
73
72
  "unplugin-swc": "^1.5.9",
74
73
  "vite": "^7.3.1",
74
+ "vite-ssr-components": "^0.5.2",
75
75
  "wrangler": "^4.61.1"
76
76
  },
77
77
  "repository": {
@@ -105,7 +105,7 @@
105
105
  "build:types": "tsc -p tsconfig.build.json",
106
106
  "deploy": "pnpm build && wrangler deploy",
107
107
  "preview": "vite preview",
108
- "typecheck": "tsc --noEmit",
108
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.client.json",
109
109
  "lint": "eslint src/",
110
110
  "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
111
111
  "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
package/src/client.ts CHANGED
@@ -3,11 +3,10 @@
3
3
  *
4
4
  * Bundles all interactive components:
5
5
  * - Datastar (reactivity)
6
- * - BaseCoat (dialogs, dropdowns, etc.)
6
+ * - BaseCoat (dialogs, dropdowns)
7
+ * - ImageProcessor (media uploads)
7
8
  */
8
9
 
9
- // Datastar for reactivity (data-* attributes)
10
10
  import "@sudodevnull/datastar";
11
-
12
- // BaseCoat interactive components
13
11
  import "basecoat-css/all";
12
+ import "./lib/image-processor.js";
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Client-side Image Processor
3
+ *
4
+ * Processes images before upload:
5
+ * - Corrects EXIF orientation
6
+ * - Resizes to max dimensions
7
+ * - Strips all metadata (privacy)
8
+ * - Converts to WebP format
9
+ */
10
+
11
+ const DEFAULT_OPTIONS = {
12
+ maxWidth: 1920,
13
+ maxHeight: 1920,
14
+ quality: 0.85,
15
+ mimeType: "image/webp" as const,
16
+ };
17
+
18
+ type ProcessOptions = Partial<typeof DEFAULT_OPTIONS>;
19
+
20
+ /**
21
+ * EXIF Orientation values and their transformations
22
+ */
23
+ const ORIENTATIONS: Record<number, { rotate: number; flip: boolean }> = {
24
+ 1: { rotate: 0, flip: false }, // Normal
25
+ 2: { rotate: 0, flip: true }, // Flipped horizontally
26
+ 3: { rotate: 180, flip: false }, // Rotated 180°
27
+ 4: { rotate: 180, flip: true }, // Flipped vertically
28
+ 5: { rotate: 90, flip: true }, // Rotated 90° CCW + flipped
29
+ 6: { rotate: 90, flip: false }, // Rotated 90° CW
30
+ 7: { rotate: 270, flip: true }, // Rotated 90° CW + flipped
31
+ 8: { rotate: 270, flip: false }, // Rotated 90° CCW
32
+ };
33
+
34
+ /**
35
+ * Read EXIF orientation from JPEG file
36
+ */
37
+ function readExifOrientation(buffer: ArrayBuffer): number {
38
+ const view = new DataView(buffer);
39
+
40
+ // Check for JPEG SOI marker
41
+ if (view.getUint16(0) !== 0xffd8) return 1;
42
+
43
+ let offset = 2;
44
+ const length = view.byteLength;
45
+
46
+ while (offset < length) {
47
+ if (view.getUint8(offset) !== 0xff) return 1;
48
+
49
+ const marker = view.getUint8(offset + 1);
50
+
51
+ // APP1 marker (EXIF)
52
+ if (marker === 0xe1) {
53
+ const exifOffset = offset + 4;
54
+
55
+ // Check for "Exif\0\0"
56
+ if (
57
+ view.getUint32(exifOffset) !== 0x45786966 ||
58
+ view.getUint16(exifOffset + 4) !== 0x0000
59
+ ) {
60
+ return 1;
61
+ }
62
+
63
+ const tiffOffset = exifOffset + 6;
64
+ const littleEndian = view.getUint16(tiffOffset) === 0x4949;
65
+
66
+ // Validate TIFF header
67
+ if (view.getUint16(tiffOffset + 2, littleEndian) !== 0x002a) return 1;
68
+
69
+ const ifdOffset = view.getUint32(tiffOffset + 4, littleEndian);
70
+ const numEntries = view.getUint16(tiffOffset + ifdOffset, littleEndian);
71
+
72
+ // Search for orientation tag (0x0112)
73
+ for (let i = 0; i < numEntries; i++) {
74
+ const entryOffset = tiffOffset + ifdOffset + 2 + i * 12;
75
+ const tag = view.getUint16(entryOffset, littleEndian);
76
+
77
+ if (tag === 0x0112) {
78
+ return view.getUint16(entryOffset + 8, littleEndian);
79
+ }
80
+ }
81
+
82
+ return 1;
83
+ }
84
+
85
+ // Skip to next marker
86
+ if (marker === 0xd8 || marker === 0xd9) {
87
+ offset += 2;
88
+ } else {
89
+ offset += 2 + view.getUint16(offset + 2);
90
+ }
91
+ }
92
+
93
+ return 1;
94
+ }
95
+
96
+ /**
97
+ * Load image from file
98
+ */
99
+ function loadImage(file: File): Promise<HTMLImageElement> {
100
+ return new Promise((resolve, reject) => {
101
+ const img = new Image();
102
+ img.onload = () => {
103
+ URL.revokeObjectURL(img.src);
104
+ resolve(img);
105
+ };
106
+ img.onerror = () => reject(new Error("Failed to load image"));
107
+ img.src = URL.createObjectURL(file);
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Calculate output dimensions maintaining aspect ratio
113
+ */
114
+ function calculateDimensions(
115
+ width: number,
116
+ height: number,
117
+ maxWidth: number,
118
+ maxHeight: number
119
+ ): { width: number; height: number } {
120
+ if (width <= maxWidth && height <= maxHeight) {
121
+ return { width, height };
122
+ }
123
+
124
+ const ratio = Math.min(maxWidth / width, maxHeight / height);
125
+ return {
126
+ width: Math.round(width * ratio),
127
+ height: Math.round(height * ratio),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Process image file
133
+ */
134
+ async function process(file: File, options: ProcessOptions = {}): Promise<Blob> {
135
+ const opts = { ...DEFAULT_OPTIONS, ...options };
136
+
137
+ // Read file buffer for EXIF
138
+ const buffer = await file.arrayBuffer();
139
+ const orientation = readExifOrientation(buffer);
140
+ const transform = ORIENTATIONS[orientation] || ORIENTATIONS[1];
141
+
142
+ // Load image
143
+ const img = await loadImage(file);
144
+
145
+ // For 90° or 270° rotation, swap dimensions
146
+ const isRotated = transform.rotate === 90 || transform.rotate === 270;
147
+ const srcWidth = isRotated ? img.height : img.width;
148
+ const srcHeight = isRotated ? img.width : img.height;
149
+
150
+ // Calculate output size
151
+ const { width, height } = calculateDimensions(
152
+ srcWidth,
153
+ srcHeight,
154
+ opts.maxWidth,
155
+ opts.maxHeight
156
+ );
157
+
158
+ // Create canvas
159
+ const canvas = document.createElement("canvas");
160
+ canvas.width = width;
161
+ canvas.height = height;
162
+
163
+ const ctx = canvas.getContext("2d");
164
+ if (!ctx) throw new Error("Failed to get canvas context");
165
+
166
+ // Apply transformations
167
+ ctx.save();
168
+ ctx.translate(width / 2, height / 2);
169
+
170
+ if (transform.rotate) {
171
+ ctx.rotate((transform.rotate * Math.PI) / 180);
172
+ }
173
+
174
+ if (transform.flip) {
175
+ ctx.scale(-1, 1);
176
+ }
177
+
178
+ const drawWidth = isRotated ? height : width;
179
+ const drawHeight = isRotated ? width : height;
180
+ ctx.drawImage(img, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
181
+
182
+ ctx.restore();
183
+
184
+ // Export as WebP
185
+ return new Promise((resolve, reject) => {
186
+ canvas.toBlob(
187
+ (blob) => {
188
+ if (blob) {
189
+ resolve(blob);
190
+ } else {
191
+ reject(new Error("Failed to create blob"));
192
+ }
193
+ },
194
+ opts.mimeType,
195
+ opts.quality
196
+ );
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Process file and create a new File object
202
+ */
203
+ async function processToFile(file: File, options: ProcessOptions = {}): Promise<File> {
204
+ const blob = await process(file, options);
205
+
206
+ // Generate new filename with .webp extension
207
+ const originalName = file.name.replace(/\.[^.]+$/, "");
208
+ const newName = `${originalName}.webp`;
209
+
210
+ return new File([blob], newName, { type: "image/webp" });
211
+ }
212
+
213
+ export const ImageProcessor = { process, processToFile };
214
+
215
+ // Expose globally for inline scripts
216
+ if (typeof window !== "undefined") {
217
+ (window as unknown as { ImageProcessor: typeof ImageProcessor }).ImageProcessor = ImageProcessor;
218
+ }
@@ -13,7 +13,6 @@ import { DashLayout } from "../../theme/layouts/index.js";
13
13
  import { EmptyState, DangerZone } from "../../theme/components/index.js";
14
14
  import * as time from "../../lib/time.js";
15
15
  import { getMediaUrl, getImageUrl } from "../../lib/image.js";
16
- import { getAssets } from "../../lib/assets.js";
17
16
 
18
17
  type Env = { Bindings: Bindings; Variables: AppVariables };
19
18
 
@@ -96,12 +95,10 @@ function MediaListContent({
96
95
  mediaList,
97
96
  r2PublicUrl,
98
97
  imageTransformUrl,
99
- imageProcessorUrl,
100
98
  }: {
101
99
  mediaList: Media[];
102
100
  r2PublicUrl?: string;
103
101
  imageTransformUrl?: string;
104
- imageProcessorUrl: string;
105
102
  }) {
106
103
  const { t } = useLingui();
107
104
 
@@ -265,8 +262,7 @@ function processSSEEvent(event) {
265
262
 
266
263
  return (
267
264
  <>
268
- {/* Scripts - script tags need closing tag, self-closing doesn't work in HTML */}
269
- <script src={imageProcessorUrl}></script>
265
+ {/* Upload script */}
270
266
  <script dangerouslySetInnerHTML={{ __html: uploadScript }}></script>
271
267
 
272
268
  {/* Header */}
@@ -493,7 +489,6 @@ mediaRoutes.get("/", async (c) => {
493
489
  const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
494
490
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
495
491
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
496
- const assets = getAssets();
497
492
 
498
493
  return c.html(
499
494
  <DashLayout c={c} title="Media" siteName={siteName} currentPath="/dash/media">
@@ -501,7 +496,6 @@ mediaRoutes.get("/", async (c) => {
501
496
  mediaList={mediaList}
502
497
  r2PublicUrl={r2PublicUrl}
503
498
  imageTransformUrl={imageTransformUrl}
504
- imageProcessorUrl={assets.imageProcessor}
505
499
  />
506
500
  </DashLayout>
507
501
  );
@@ -3,11 +3,13 @@
3
3
  *
4
4
  * Provides the HTML shell with meta tags, styles, and scripts.
5
5
  * If Context is provided, automatically wraps children with I18nProvider.
6
+ *
7
+ * Uses vite-ssr-components for automatic dev/prod asset path resolution.
6
8
  */
7
9
 
8
10
  import type { FC, PropsWithChildren } from "hono/jsx";
9
11
  import type { Context } from "hono";
10
- import { getAssets } from "../../lib/assets.js";
12
+ import { Script, Link, ViteClient } from "vite-ssr-components/hono";
11
13
  import { I18nProvider } from "../../i18n/index.js";
12
14
 
13
15
  export interface BaseLayoutProps {
@@ -24,9 +26,6 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
24
26
  c,
25
27
  children,
26
28
  }) => {
27
- // Get assets at render time (supports runtime manifest loading)
28
- const assets = getAssets();
29
-
30
29
  // Automatically wrap with I18nProvider if Context is provided
31
30
  const content = c ? <I18nProvider c={c}>{children}</I18nProvider> : children;
32
31
 
@@ -37,10 +36,9 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
37
36
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
38
37
  <title>{title}</title>
39
38
  {description && <meta name="description" content={description} />}
40
- {/* CSS */}
41
- {assets.styles && <link rel="stylesheet" href={assets.styles} />}
42
- {/* Client JS (includes Datastar + BaseCoat) */}
43
- <script type="module" src={assets.client} defer />
39
+ <ViteClient />
40
+ <Link href="/src/style.css" rel="stylesheet" />
41
+ <Script src="/src/client.ts" />
44
42
  </head>
45
43
  <body class="bg-background text-foreground antialiased">
46
44
  {content}
package/src/lib/assets.ts DELETED
@@ -1,49 +0,0 @@
1
- /**
2
- * Asset paths for SSR
3
- *
4
- * Development: Paths injected via vite.config.ts `define`
5
- * Production: Paths replaced at build time with hashed filenames
6
- */
7
-
8
- interface Assets {
9
- /** CSS path */
10
- styles: string;
11
- /** Main client bundle (includes Datastar + BaseCoat) */
12
- client: string;
13
- /** Image processor script (lazy-loaded on media page) */
14
- imageProcessor: string;
15
- }
16
-
17
- // Injected by vite.config.ts via `define`
18
- declare const __JANT_DEV_STYLES__: string;
19
- declare const __JANT_DEV_CLIENT__: string;
20
- declare const __JANT_DEV_IMAGE_PROCESSOR__: string;
21
-
22
- // Production paths - replaced at build time
23
- const PROD_ASSETS: Assets = {
24
- styles: "__JANT_ASSET_STYLES__",
25
- client: "__JANT_ASSET_CLIENT__",
26
- imageProcessor: "__JANT_ASSET_IMAGE_PROCESSOR__",
27
- };
28
-
29
- /**
30
- * Get assets based on environment
31
- */
32
- export function getAssets(): Assets {
33
- try {
34
- if (import.meta.env?.DEV) {
35
- return {
36
- styles: __JANT_DEV_STYLES__,
37
- client: __JANT_DEV_CLIENT__,
38
- imageProcessor: __JANT_DEV_IMAGE_PROCESSOR__,
39
- };
40
- }
41
- } catch {
42
- // import.meta.env may not exist in all environments
43
- }
44
-
45
- return PROD_ASSETS;
46
- }
47
-
48
- // For static imports
49
- export const ASSETS = PROD_ASSETS;
@@ -1,234 +0,0 @@
1
- /**
2
- * Client-side Image Processor
3
- *
4
- * Processes images before upload:
5
- * - Corrects EXIF orientation
6
- * - Resizes to max dimensions
7
- * - Strips all metadata (privacy)
8
- * - Converts to WebP format
9
- */
10
-
11
- window.ImageProcessor = (() => {
12
- const DEFAULT_OPTIONS = {
13
- maxWidth: 1920,
14
- maxHeight: 1920,
15
- quality: 0.85,
16
- mimeType: 'image/webp',
17
- };
18
-
19
- /**
20
- * EXIF Orientation values and their transformations
21
- * @see https://exiftool.org/TagNames/EXIF.html
22
- */
23
- const ORIENTATIONS = {
24
- 1: { rotate: 0, flip: false }, // Normal
25
- 2: { rotate: 0, flip: true }, // Flipped horizontally
26
- 3: { rotate: 180, flip: false }, // Rotated 180°
27
- 4: { rotate: 180, flip: true }, // Flipped vertically
28
- 5: { rotate: 90, flip: true }, // Rotated 90° CCW + flipped
29
- 6: { rotate: 90, flip: false }, // Rotated 90° CW
30
- 7: { rotate: 270, flip: true }, // Rotated 90° CW + flipped
31
- 8: { rotate: 270, flip: false }, // Rotated 90° CCW
32
- };
33
-
34
- /**
35
- * Read EXIF orientation from JPEG file
36
- * @param {ArrayBuffer} buffer - File buffer
37
- * @returns {number} Orientation value (1-8), defaults to 1
38
- */
39
- function readExifOrientation(buffer) {
40
- const view = new DataView(buffer);
41
-
42
- // Check for JPEG SOI marker
43
- if (view.getUint16(0) !== 0xFFD8) return 1;
44
-
45
- let offset = 2;
46
- const length = view.byteLength;
47
-
48
- while (offset < length) {
49
- if (view.getUint8(offset) !== 0xFF) return 1;
50
-
51
- const marker = view.getUint8(offset + 1);
52
-
53
- // APP1 marker (EXIF)
54
- if (marker === 0xE1) {
55
- const exifOffset = offset + 4;
56
-
57
- // Check for "Exif\0\0"
58
- if (
59
- view.getUint32(exifOffset) !== 0x45786966 ||
60
- view.getUint16(exifOffset + 4) !== 0x0000
61
- ) {
62
- return 1;
63
- }
64
-
65
- const tiffOffset = exifOffset + 6;
66
- const littleEndian = view.getUint16(tiffOffset) === 0x4949;
67
-
68
- // Validate TIFF header
69
- if (view.getUint16(tiffOffset + 2, littleEndian) !== 0x002A) return 1;
70
-
71
- const ifdOffset = view.getUint32(tiffOffset + 4, littleEndian);
72
- const numEntries = view.getUint16(tiffOffset + ifdOffset, littleEndian);
73
-
74
- // Search for orientation tag (0x0112)
75
- for (let i = 0; i < numEntries; i++) {
76
- const entryOffset = tiffOffset + ifdOffset + 2 + i * 12;
77
- const tag = view.getUint16(entryOffset, littleEndian);
78
-
79
- if (tag === 0x0112) {
80
- return view.getUint16(entryOffset + 8, littleEndian);
81
- }
82
- }
83
-
84
- return 1;
85
- }
86
-
87
- // Skip to next marker
88
- if (marker === 0xD8 || marker === 0xD9) {
89
- offset += 2;
90
- } else {
91
- offset += 2 + view.getUint16(offset + 2);
92
- }
93
- }
94
-
95
- return 1;
96
- }
97
-
98
- /**
99
- * Load image from file
100
- * @param {File} file - Image file
101
- * @returns {Promise<HTMLImageElement>}
102
- */
103
- function loadImage(file) {
104
- return new Promise((resolve, reject) => {
105
- const img = new Image();
106
- img.onload = () => {
107
- URL.revokeObjectURL(img.src);
108
- resolve(img);
109
- };
110
- img.onerror = () => reject(new Error('Failed to load image'));
111
- img.src = URL.createObjectURL(file);
112
- });
113
- }
114
-
115
- /**
116
- * Calculate output dimensions maintaining aspect ratio
117
- * @param {number} width - Original width
118
- * @param {number} height - Original height
119
- * @param {number} maxWidth - Maximum width
120
- * @param {number} maxHeight - Maximum height
121
- * @returns {{ width: number, height: number }}
122
- */
123
- function calculateDimensions(width, height, maxWidth, maxHeight) {
124
- if (width <= maxWidth && height <= maxHeight) {
125
- return { width, height };
126
- }
127
-
128
- const ratio = Math.min(maxWidth / width, maxHeight / height);
129
- return {
130
- width: Math.round(width * ratio),
131
- height: Math.round(height * ratio),
132
- };
133
- }
134
-
135
- /**
136
- * Process image file
137
- * @param {File} file - Image file to process
138
- * @param {Object} options - Processing options
139
- * @returns {Promise<Blob>} Processed image as WebP blob
140
- */
141
- async function process(file, options = {}) {
142
- const opts = { ...DEFAULT_OPTIONS, ...options };
143
-
144
- // Read file buffer for EXIF
145
- const buffer = await file.arrayBuffer();
146
- const orientation = readExifOrientation(buffer);
147
- const transform = ORIENTATIONS[orientation] || ORIENTATIONS[1];
148
-
149
- // Load image
150
- const img = await loadImage(file);
151
-
152
- // For 90° or 270° rotation, swap dimensions
153
- const isRotated = transform.rotate === 90 || transform.rotate === 270;
154
- const srcWidth = isRotated ? img.height : img.width;
155
- const srcHeight = isRotated ? img.width : img.height;
156
-
157
- // Calculate output size
158
- const { width, height } = calculateDimensions(
159
- srcWidth,
160
- srcHeight,
161
- opts.maxWidth,
162
- opts.maxHeight
163
- );
164
-
165
- // Create canvas
166
- const canvas = document.createElement('canvas');
167
- canvas.width = width;
168
- canvas.height = height;
169
-
170
- const ctx = canvas.getContext('2d');
171
- if (!ctx) throw new Error('Failed to get canvas context');
172
-
173
- // Apply transformations
174
- ctx.save();
175
-
176
- // Move to center for rotation
177
- ctx.translate(width / 2, height / 2);
178
-
179
- // Apply rotation
180
- if (transform.rotate) {
181
- ctx.rotate((transform.rotate * Math.PI) / 180);
182
- }
183
-
184
- // Apply flip
185
- if (transform.flip) {
186
- ctx.scale(-1, 1);
187
- }
188
-
189
- // Draw image centered
190
- const drawWidth = isRotated ? height : width;
191
- const drawHeight = isRotated ? width : height;
192
- ctx.drawImage(img, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
193
-
194
- ctx.restore();
195
-
196
- // Export as WebP
197
- return new Promise((resolve, reject) => {
198
- canvas.toBlob(
199
- (blob) => {
200
- if (blob) {
201
- resolve(blob);
202
- } else {
203
- reject(new Error('Failed to create blob'));
204
- }
205
- },
206
- opts.mimeType,
207
- opts.quality
208
- );
209
- });
210
- }
211
-
212
- /**
213
- * Process file and create a new File object
214
- * @param {File} file - Original file
215
- * @param {Object} options - Processing options
216
- * @returns {Promise<File>} Processed file with .webp extension
217
- */
218
- async function processToFile(file, options = {}) {
219
- const blob = await process(file, options);
220
-
221
- // Generate new filename with .webp extension
222
- const originalName = file.name.replace(/\.[^.]+$/, '');
223
- const newName = `${originalName}.webp`;
224
-
225
- return new File([blob], newName, { type: 'image/webp' });
226
- }
227
-
228
- return { process, processToFile };
229
- })();
230
-
231
- // Also export for module systems
232
- if (typeof module !== 'undefined' && module.exports) {
233
- module.exports = window.ImageProcessor;
234
- }