Add worktree-specific favicon branding
This commit is contained in:
@@ -110,6 +110,7 @@ describe("worktree helpers", () => {
|
|||||||
const env = buildWorktreeEnvEntries(paths);
|
const env = buildWorktreeEnvEntries(paths);
|
||||||
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
||||||
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
||||||
|
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
|
||||||
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<strin
|
|||||||
PAPERCLIP_INSTANCE_ID: paths.instanceId,
|
PAPERCLIP_INSTANCE_ID: paths.instanceId,
|
||||||
PAPERCLIP_CONFIG: paths.configPath,
|
PAPERCLIP_CONFIG: paths.configPath,
|
||||||
PAPERCLIP_CONTEXT: paths.contextPath,
|
PAPERCLIP_CONTEXT: paths.contextPath,
|
||||||
|
PAPERCLIP_IN_WORKTREE: "true",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ Seed modes:
|
|||||||
|
|
||||||
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
||||||
|
|
||||||
|
That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon.
|
||||||
|
|
||||||
Print shell exports explicitly when needed:
|
Print shell exports explicitly when needed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
38
server/src/__tests__/ui-branding.test.ts
Normal file
38
server/src/__tests__/ui-branding.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyUiBranding, isWorktreeUiBrandingEnabled, renderFaviconLinks } from "../ui-branding.js";
|
||||||
|
|
||||||
|
const TEMPLATE = `<!doctype html>
|
||||||
|
<head>
|
||||||
|
<!-- PAPERCLIP_FAVICON_START -->
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<!-- PAPERCLIP_FAVICON_END -->
|
||||||
|
</head>`;
|
||||||
|
|
||||||
|
describe("ui branding", () => {
|
||||||
|
it("detects worktree mode from PAPERCLIP_IN_WORKTREE", () => {
|
||||||
|
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "true" })).toBe(true);
|
||||||
|
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "1" })).toBe(true);
|
||||||
|
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the worktree favicon asset set when enabled", () => {
|
||||||
|
const links = renderFaviconLinks(true);
|
||||||
|
expect(links).toContain("/worktree-favicon.ico");
|
||||||
|
expect(links).toContain("/worktree-favicon.svg");
|
||||||
|
expect(links).toContain("/worktree-favicon-32x32.png");
|
||||||
|
expect(links).toContain("/worktree-favicon-16x16.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites the favicon block for worktree instances only", () => {
|
||||||
|
const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true" });
|
||||||
|
expect(branded).toContain("/worktree-favicon.svg");
|
||||||
|
expect(branded).not.toContain('href="/favicon.svg"');
|
||||||
|
|
||||||
|
const defaultHtml = applyUiBranding(TEMPLATE, {});
|
||||||
|
expect(defaultHtml).toContain('href="/favicon.svg"');
|
||||||
|
expect(defaultHtml).not.toContain("/worktree-favicon.svg");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
|||||||
import { llmRoutes } from "./routes/llms.js";
|
import { llmRoutes } from "./routes/llms.js";
|
||||||
import { assetRoutes } from "./routes/assets.js";
|
import { assetRoutes } from "./routes/assets.js";
|
||||||
import { accessRoutes } from "./routes/access.js";
|
import { accessRoutes } from "./routes/access.js";
|
||||||
|
import { applyUiBranding } from "./ui-branding.js";
|
||||||
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
||||||
|
|
||||||
type UiMode = "none" | "static" | "vite-dev";
|
type UiMode = "none" | "static" | "vite-dev";
|
||||||
@@ -135,7 +136,7 @@ export async function createApp(
|
|||||||
];
|
];
|
||||||
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
|
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
|
||||||
if (uiDist) {
|
if (uiDist) {
|
||||||
const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8");
|
const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"));
|
||||||
app.use(express.static(uiDist));
|
app.use(express.static(uiDist));
|
||||||
app.get(/.*/, (_req, res) => {
|
app.get(/.*/, (_req, res) => {
|
||||||
res.status(200).set("Content-Type", "text/html").end(indexHtml);
|
res.status(200).set("Content-Type", "text/html").end(indexHtml);
|
||||||
@@ -168,7 +169,7 @@ export async function createApp(
|
|||||||
try {
|
try {
|
||||||
const templatePath = path.resolve(uiRoot, "index.html");
|
const templatePath = path.resolve(uiRoot, "index.html");
|
||||||
const template = fs.readFileSync(templatePath, "utf-8");
|
const template = fs.readFileSync(templatePath, "utf-8");
|
||||||
const html = await vite.transformIndexHtml(req.originalUrl, template);
|
const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template));
|
||||||
res.status(200).set({ "Content-Type": "text/html" }).end(html);
|
res.status(200).set({ "Content-Type": "text/html" }).end(html);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
41
server/src/ui-branding.ts
Normal file
41
server/src/ui-branding.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const FAVICON_BLOCK_START = "<!-- PAPERCLIP_FAVICON_START -->";
|
||||||
|
const FAVICON_BLOCK_END = "<!-- PAPERCLIP_FAVICON_END -->";
|
||||||
|
|
||||||
|
const DEFAULT_FAVICON_LINKS = [
|
||||||
|
'<link rel="icon" href="/favicon.ico" sizes="48x48" />',
|
||||||
|
'<link rel="icon" href="/favicon.svg" type="image/svg+xml" />',
|
||||||
|
'<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />',
|
||||||
|
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const WORKTREE_FAVICON_LINKS = [
|
||||||
|
'<link rel="icon" href="/worktree-favicon.ico" sizes="48x48" />',
|
||||||
|
'<link rel="icon" href="/worktree-favicon.svg" type="image/svg+xml" />',
|
||||||
|
'<link rel="icon" type="image/png" sizes="32x32" href="/worktree-favicon-32x32.png" />',
|
||||||
|
'<link rel="icon" type="image/png" sizes="16x16" href="/worktree-favicon-16x16.png" />',
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
function isTruthyEnvValue(value: string | undefined): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorktreeUiBrandingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
|
return isTruthyEnvValue(env.PAPERCLIP_IN_WORKTREE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFaviconLinks(worktree: boolean): string {
|
||||||
|
return worktree ? WORKTREE_FAVICON_LINKS : DEFAULT_FAVICON_LINKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyUiBranding(html: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
const start = html.indexOf(FAVICON_BLOCK_START);
|
||||||
|
const end = html.indexOf(FAVICON_BLOCK_END);
|
||||||
|
if (start === -1 || end === -1 || end < start) return html;
|
||||||
|
|
||||||
|
const before = html.slice(0, start + FAVICON_BLOCK_START.length);
|
||||||
|
const after = html.slice(end);
|
||||||
|
const links = renderFaviconLinks(isWorktreeUiBrandingEnabled(env));
|
||||||
|
return `${before}\n${links}\n ${after}`;
|
||||||
|
}
|
||||||
@@ -8,10 +8,12 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Paperclip" />
|
<meta name="apple-mobile-web-app-title" content="Paperclip" />
|
||||||
<title>Paperclip</title>
|
<title>Paperclip</title>
|
||||||
|
<!-- PAPERCLIP_FAVICON_START -->
|
||||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<!-- PAPERCLIP_FAVICON_END -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
BIN
ui/public/worktree-favicon-16x16.png
Normal file
BIN
ui/public/worktree-favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 790 B |
BIN
ui/public/worktree-favicon-32x32.png
Normal file
BIN
ui/public/worktree-favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
ui/public/worktree-favicon.ico
Normal file
BIN
ui/public/worktree-favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
9
ui/public/worktree-favicon.svg
Normal file
9
ui/public/worktree-favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<style>
|
||||||
|
path { stroke: #db2777; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path { stroke: #f472b6; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path stroke-width="2" d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 410 B |
Reference in New Issue
Block a user