13 Commits

Author SHA1 Message Date
681b5a857c Merge pull request #77 from lordmathis/dependabot/npm_and_yarn/app/rehype-mathjax-7.1.0
Bump rehype-mathjax from 6.0.0 to 7.1.0 in /app
2025-11-04 23:53:33 +01:00
dependabot[bot]
bc97c21b1d Bump rehype-mathjax from 6.0.0 to 7.1.0 in /app
Bumps [rehype-mathjax](https://github.com/remarkjs/remark-math) from 6.0.0 to 7.1.0.
- [Release notes](https://github.com/remarkjs/remark-math/releases)
- [Commits](https://github.com/remarkjs/remark-math/compare/rehype-mathjax@6.0.0...rehype-mathjax@7.1.0)

---
updated-dependencies:
- dependency-name: rehype-mathjax
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-04 22:41:26 +00:00
a2cd260bca Merge pull request #72 from lordmathis/dependabot/npm_and_yarn/app/minor-and-patch-406c606fe0
Bump the minor-and-patch group in /app with 18 updates
2025-11-04 20:34:51 +01:00
dependabot[bot]
85f2bf23c1 Bump the minor-and-patch group in /app with 18 updates
Bumps the minor-and-patch group in /app with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.6.2` | `6.10.0` |
| [@codemirror/lang-markdown](https://github.com/codemirror/lang-markdown) | `6.2.5` | `6.5.0` |
| [@codemirror/state](https://github.com/codemirror/state) | `6.4.1` | `6.5.2` |
| [@codemirror/theme-one-dark](https://github.com/codemirror/theme-one-dark) | `6.1.2` | `6.1.3` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.34.0` | `6.38.6` |
| [@tabler/icons-react](https://github.com/tabler/tabler-icons/tree/HEAD/packages/icons-react) | `3.19.0` | `3.35.0` |
| [codemirror](https://github.com/codemirror/basic-setup) | `6.0.1` | `6.0.2` |
| [react-arborist](https://github.com/brimdata/react-arborist) | `3.4.0` | `3.4.3` |
| [remark-rehype](https://github.com/remarkjs/remark-rehype) | `11.1.1` | `11.1.2` |
| [@eslint/compat](https://github.com/eslint/rewrite/tree/HEAD/packages/compat) | `1.2.9` | `1.4.1` |
| [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) | `6.6.3` | `6.9.1` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.32.1` | `8.46.3` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.32.1` | `8.46.3` |
| [eslint](https://github.com/eslint/eslint) | `9.27.0` | `9.39.1` |
| [postcss](https://github.com/postcss/postcss) | `8.5.3` | `8.5.6` |
| [postcss-preset-mantine](https://github.com/mantinedev/postcss-preset-mantine) | `1.17.0` | `1.18.0` |
| [sass](https://github.com/sass/dart-sass) | `1.80.4` | `1.93.3` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.8.3` | `5.9.3` |


Updates `@codemirror/commands` from 6.6.2 to 6.10.0
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/compare/6.6.2...6.10.0)

Updates `@codemirror/lang-markdown` from 6.2.5 to 6.5.0
- [Changelog](https://github.com/codemirror/lang-markdown/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/lang-markdown/compare/6.2.5...6.5.0)

Updates `@codemirror/state` from 6.4.1 to 6.5.2
- [Changelog](https://github.com/codemirror/state/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/state/compare/6.4.1...6.5.2)

Updates `@codemirror/theme-one-dark` from 6.1.2 to 6.1.3
- [Changelog](https://github.com/codemirror/theme-one-dark/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/theme-one-dark/compare/6.1.2...6.1.3)

Updates `@codemirror/view` from 6.34.0 to 6.38.6
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.34.0...6.38.6)

Updates `@tabler/icons-react` from 3.19.0 to 3.35.0
- [Release notes](https://github.com/tabler/tabler-icons/releases)
- [Commits](https://github.com/tabler/tabler-icons/commits/v3.35.0/packages/icons-react)

Updates `codemirror` from 6.0.1 to 6.0.2
- [Changelog](https://github.com/codemirror/basic-setup/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/basic-setup/compare/6.0.1...6.0.2)

Updates `react-arborist` from 3.4.0 to 3.4.3
- [Release notes](https://github.com/brimdata/react-arborist/releases)
- [Changelog](https://github.com/brimdata/react-arborist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/brimdata/react-arborist/compare/v3.4.0...v3.4.3)

Updates `remark-rehype` from 11.1.1 to 11.1.2
- [Release notes](https://github.com/remarkjs/remark-rehype/releases)
- [Commits](https://github.com/remarkjs/remark-rehype/compare/11.1.1...11.1.2)

Updates `@eslint/compat` from 1.2.9 to 1.4.1
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/packages/compat/CHANGELOG.md)
- [Commits](https://github.com/eslint/rewrite/commits/compat-v1.4.1/packages/compat)

Updates `@testing-library/jest-dom` from 6.6.3 to 6.9.1
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.6.3...v6.9.1)

Updates `@typescript-eslint/eslint-plugin` from 8.32.1 to 8.46.3
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.3/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.32.1 to 8.46.3
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.3/packages/parser)

Updates `eslint` from 9.27.0 to 9.39.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.27.0...v9.39.1)

Updates `postcss` from 8.5.3 to 8.5.6
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.3...8.5.6)

Updates `postcss-preset-mantine` from 1.17.0 to 1.18.0
- [Commits](https://github.com/mantinedev/postcss-preset-mantine/commits)

Updates `sass` from 1.80.4 to 1.93.3
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.80.4...1.93.3)

Updates `typescript` from 5.8.3 to 5.9.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.3)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/lang-markdown"
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/state"
  dependency-version: 6.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/theme-one-dark"
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/view"
  dependency-version: 6.38.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@tabler/icons-react"
  dependency-version: 3.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: codemirror
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-arborist
  dependency-version: 3.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: remark-rehype
  dependency-version: 11.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@eslint/compat"
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 6.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.46.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.46.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 9.39.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: postcss-preset-mantine
  dependency-version: 1.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: sass
  dependency-version: 1.93.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: typescript
  dependency-version: 5.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-04 18:20:06 +00:00
8f06d39a71 Merge pull request #73 from lordmathis/fix/compression
Fix asset compression
2025-11-04 19:17:20 +01:00
93f95f22ef Merge branch 'main' into fix/compression 2025-11-04 19:12:50 +01:00
0a4abdb48c Add Vary header 2025-11-04 19:04:26 +01:00
4ad5e682a8 Enhance StaticHandler to support brotli compression and update content type handling 2025-11-04 19:02:38 +01:00
dd55c81b51 Merge pull request #71 from lordmathis/lordmathis-patch-1
Create dependabot.yml
2025-11-04 18:59:49 +01:00
0939bc7213 Fix generating compressed assets 2025-11-04 18:58:33 +01:00
b8a9cee04d Create dependabot.yml 2025-11-04 18:55:51 +01:00
2259e7400a Merge pull request #69 from lordmathis/fix/drag-and-drop
Fix FileTree drag and drop
2025-11-04 18:25:09 +01:00
2045d36211 Fix FileTree drag and drop 2025-11-04 18:19:54 +01:00
7 changed files with 640 additions and 664 deletions

21
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/app"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"

1026
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,54 +29,54 @@
}, },
"homepage": "https://github.com/LordMathis/Lemma#readme", "homepage": "https://github.com/LordMathis/Lemma#readme",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.6.2", "@codemirror/commands": "^6.10.0",
"@codemirror/lang-markdown": "^6.2.5", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.34.0", "@codemirror/view": "^6.38.6",
"@mantine/core": "^7.13.2", "@mantine/core": "^7.13.2",
"@mantine/hooks": "^7.13.2", "@mantine/hooks": "^7.13.2",
"@mantine/modals": "^7.13.2", "@mantine/modals": "^7.13.2",
"@mantine/notifications": "^7.13.2", "@mantine/notifications": "^7.13.2",
"@react-hook/resize-observer": "^2.0.2", "@react-hook/resize-observer": "^2.0.2",
"@tabler/icons-react": "^3.19.0", "@tabler/icons-react": "^3.35.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "^3.4.0", "react-arborist": "^3.4.3",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"rehype-mathjax": "^6.0.0", "rehype-mathjax": "^7.1.0",
"rehype-react": "^8.0.0", "rehype-react": "^8.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1", "remark-rehype": "^11.1.2",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-visit": "^5.0.0" "unist-util-visit": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.9", "@eslint/compat": "^1.4.1",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^18.3.20", "@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6", "@types/react-dom": "^18.3.6",
"@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.32.1", "@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.1.4", "@vitest/coverage-v8": "^3.1.4",
"eslint": "^9.27.0", "eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"postcss": "^8.4.47", "postcss": "^8.5.6",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"sass": "^1.80.4", "sass": "^1.93.3",
"typescript": "^5.8.2", "typescript": "^5.9.3",
"vite": "^6.4.1", "vite": "^6.4.1",
"vite-plugin-compression2": "^1.3.0", "vite-plugin-compression2": "^2.3.1",
"vitest": "^3.1.4" "vitest": "^3.1.4"
}, },
"browserslist": { "browserslist": {

View File

@@ -6,7 +6,7 @@ import {
IconFolderOpen, IconFolderOpen,
IconUpload, IconUpload,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Tooltip, Text, Box } from '@mantine/core'; import { Text, Box } from '@mantine/core';
import useResizeObserver from '@react-hook/resize-observer'; import useResizeObserver from '@react-hook/resize-observer';
import { useFileOperations } from '../../hooks/useFileOperations'; import { useFileOperations } from '../../hooks/useFileOperations';
import type { FileNode } from '@/types/models'; import type { FileNode } from '@/types/models';
@@ -53,13 +53,12 @@ function Node({
style, style,
dragHandle, dragHandle,
onNodeClick, onNodeClick,
...rest
}: { }: {
node: NodeApi<FileNode>; node: NodeApi<FileNode>;
style: React.CSSProperties; style: React.CSSProperties;
dragHandle?: React.Ref<HTMLDivElement>; dragHandle?: React.Ref<HTMLDivElement>;
onNodeClick?: (node: NodeApi<FileNode>) => void; onNodeClick?: (node: NodeApi<FileNode>) => void;
} & Record<string, unknown>) { }) {
const handleClick = () => { const handleClick = () => {
if (node.isInternal) { if (node.isInternal) {
node.toggle(); node.toggle();
@@ -69,37 +68,40 @@ function Node({
}; };
return ( return (
<Tooltip label={node.data.name} openDelay={500}> <div
<div ref={dragHandle} // This enables dragging for the node
ref={dragHandle} // This enables dragging for the node style={{
...style,
paddingLeft: `${node.level * 20}px`,
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
// Add visual feedback when being dragged
opacity: node.state?.isDragging ? 0.5 : 1,
// Highlight when this node will receive the drop
backgroundColor: node.state?.willReceiveDrop
? 'rgba(0, 123, 255, 0.2)'
: 'transparent',
borderRadius: '4px',
}}
onClick={handleClick}
title={node.data.name}
>
<FileIcon node={node} />
<span
style={{ style={{
...style, marginLeft: '8px',
paddingLeft: `${node.level * 20}px`, fontSize: '14px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
// Add visual feedback when being dragged textOverflow: 'ellipsis',
opacity: node.state?.isDragging ? 0.5 : 1, flexGrow: 1,
}} }}
onClick={handleClick}
{...rest}
> >
<FileIcon node={node} /> {node.data.name}
<span </span>
style={{ </div>
marginLeft: '8px',
fontSize: '14px',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexGrow: 1,
}}
>
{node.data.name}
</span>
</div>
</Tooltip>
); );
} }
@@ -205,41 +207,46 @@ export const FileTree: React.FC<FileTreeProps> = ({
// External file drag and drop handlers // External file drag and drop handlers
const handleDragEnter = useCallback((e: React.DragEvent) => { const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Check if drag contains files (not internal tree nodes) // Check if drag contains files (not internal tree nodes)
if (e.dataTransfer.types.includes('Files')) { if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true); setIsDragOver(true);
} }
}, []); }, []);
const handleDragLeave = useCallback((e: React.DragEvent) => { const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault(); // Only handle if it's an external file drag
e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
// Only hide overlay when leaving the container itself // Only hide overlay when leaving the container itself
if (e.currentTarget === e.target) { if (e.currentTarget === e.target) {
setIsDragOver(false); setIsDragOver(false);
}
} }
}, []); }, []);
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); // Only handle external file drags
e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) {
// Set the drop effect to indicate this is a valid drop target e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.stopPropagation();
// Set the drop effect to indicate this is a valid drop target
e.dataTransfer.dropEffect = 'copy';
}
}, []); }, []);
const handleDrop = useCallback( const handleDrop = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const { files } = e.dataTransfer; const { files } = e.dataTransfer;
// Only handle if it's an external file drop
if (files && files.length > 0) { if (files && files.length > 0) {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const uploadFiles = async () => { const uploadFiles = async () => {
try { try {
const success = await handleUpload(files); const success = await handleUpload(files);
@@ -305,7 +312,10 @@ export const FileTree: React.FC<FileTreeProps> = ({
height={size.height} height={size.height}
indent={24} indent={24}
rowHeight={28} rowHeight={28}
idAccessor="id"
onMove={handleTreeMove} onMove={handleTreeMove}
disableDrag={() => false}
disableDrop={() => false}
onActivate={(node) => { onActivate={(node) => {
const fileNode = node.data; const fileNode = node.data;
if (!node.isInternal) { if (!node.isInternal) {

View File

@@ -11,7 +11,10 @@ export default defineConfig(({ mode }) => ({
react({ react({
include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'], include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
}), }),
compression(), compression({
threshold: 1024, // Only compress files > 1KB
deleteOriginalAssets: false, // Keep original files
}),
], ],
root: 'src', root: 'src',

View File

@@ -24,6 +24,28 @@ func getStaticLogger() logging.Logger {
return logging.WithGroup("static") return logging.WithGroup("static")
} }
// getContentType returns the appropriate content type based on file extension
func getContentType(path string) string {
switch filepath.Ext(path) {
case ".js":
return "application/javascript"
case ".css":
return "text/css"
case ".html":
return "text/html"
case ".json":
return "application/json"
case ".svg":
return "image/svg+xml"
case ".xml":
return "application/xml"
case ".yaml", ".yml":
return "application/x-yaml"
default:
return "application/octet-stream"
}
}
// ServeHTTP serves the static files // ServeHTTP serves the static files
func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log := getStaticLogger().With( log := getStaticLogger().With(
@@ -77,23 +99,28 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// Check for pre-compressed version // Check for pre-compressed versions (prefer brotli over gzip)
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { acceptEncoding := r.Header.Get("Accept-Encoding")
// Try brotli first (better compression ratio)
if strings.Contains(acceptEncoding, "br") {
brPath := cleanPath + ".br"
if _, err := os.Stat(brPath); err == nil {
w.Header().Set("Content-Encoding", "br")
w.Header().Set("Content-Type", getContentType(cleanPath))
w.Header().Set("Vary", "Accept-Encoding")
http.ServeFile(w, r, brPath)
return
}
}
// Fall back to gzip
if strings.Contains(acceptEncoding, "gzip") {
gzPath := cleanPath + ".gz" gzPath := cleanPath + ".gz"
if _, err := os.Stat(gzPath); err == nil { if _, err := os.Stat(gzPath); err == nil {
w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Type", getContentType(cleanPath))
// Set proper content type based on original file w.Header().Set("Vary", "Accept-Encoding")
contentType := "application/octet-stream"
switch filepath.Ext(cleanPath) {
case ".js":
contentType = "application/javascript"
case ".css":
contentType = "text/css"
case ".html":
contentType = "text/html"
}
w.Header().Set("Content-Type", contentType)
http.ServeFile(w, r, gzPath) http.ServeFile(w, r, gzPath)
return return
} }

View File

@@ -26,8 +26,12 @@ func TestStaticHandler_Integration(t *testing.T) {
"index.html": []byte("<html><body>Index</body></html>"), "index.html": []byte("<html><body>Index</body></html>"),
"assets/style.css": []byte("body { color: blue; }"), "assets/style.css": []byte("body { color: blue; }"),
"assets/style.css.gz": []byte("gzipped css content"), "assets/style.css.gz": []byte("gzipped css content"),
"assets/style.css.br": []byte("brotli css content"),
"assets/script.js": []byte("console.log('test');"), "assets/script.js": []byte("console.log('test');"),
"assets/script.js.gz": []byte("gzipped js content"), "assets/script.js.gz": []byte("gzipped js content"),
"assets/script.js.br": []byte("brotli js content"),
"assets/app.js": []byte("console.log('app');"),
"assets/app.js.br": []byte("brotli app content"),
"subdir/page.html": []byte("<html><body>Page</body></html>"), "subdir/page.html": []byte("<html><body>Page</body></html>"),
"subdir/page.html.gz": []byte("gzipped html content"), "subdir/page.html.gz": []byte("gzipped html content"),
} }
@@ -52,6 +56,7 @@ func TestStaticHandler_Integration(t *testing.T) {
wantType string wantType string
wantEncoding string wantEncoding string
wantCacheHeader string wantCacheHeader string
wantVary string
}{ }{
{ {
name: "serve index.html", name: "serve index.html",
@@ -69,6 +74,7 @@ func TestStaticHandler_Integration(t *testing.T) {
wantType: "text/css", wantType: "text/css",
wantEncoding: "gzip", wantEncoding: "gzip",
wantCacheHeader: "public, max-age=31536000", wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
}, },
{ {
name: "serve JS with gzip support", name: "serve JS with gzip support",
@@ -79,6 +85,7 @@ func TestStaticHandler_Integration(t *testing.T) {
wantType: "application/javascript", wantType: "application/javascript",
wantEncoding: "gzip", wantEncoding: "gzip",
wantCacheHeader: "public, max-age=31536000", wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
}, },
{ {
name: "serve CSS without gzip", name: "serve CSS without gzip",
@@ -114,6 +121,50 @@ func TestStaticHandler_Integration(t *testing.T) {
wantBody: []byte("<html><body>Index</body></html>"), wantBody: []byte("<html><body>Index</body></html>"),
wantType: "text/html; charset=utf-8", wantType: "text/html; charset=utf-8",
}, },
{
name: "serve CSS with brotli support",
path: "/assets/style.css",
acceptEncoding: "br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli css content"),
wantType: "text/css",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
{
name: "serve JS with brotli support",
path: "/assets/script.js",
acceptEncoding: "br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli js content"),
wantType: "application/javascript",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
{
name: "prefer brotli over gzip when both supported",
path: "/assets/script.js",
acceptEncoding: "gzip, br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli js content"),
wantType: "application/javascript",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
{
name: "fallback to gzip when brotli not available",
path: "/assets/app.js",
acceptEncoding: "gzip, br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli app content"),
wantType: "application/javascript",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -139,6 +190,10 @@ func TestStaticHandler_Integration(t *testing.T) {
if tc.wantCacheHeader != "" { if tc.wantCacheHeader != "" {
assert.Equal(t, tc.wantCacheHeader, w.Header().Get("Cache-Control")) assert.Equal(t, tc.wantCacheHeader, w.Header().Get("Cache-Control"))
} }
if tc.wantVary != "" {
assert.Equal(t, tc.wantVary, w.Header().Get("Vary"))
}
} }
}) })
} }