@jant/core 0.3.44 → 0.3.46

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.
Files changed (62) hide show
  1. package/bin/commands/import-site.js +40 -39
  2. package/dist/app-CM7sb3xO.js +5 -0
  3. package/dist/{app-CtJDxZBb.js → app-DB-P66E5.js} +147 -203
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-DDs6NzB3.css +2 -0
  6. package/dist/client/_assets/{client-auth-CXILhW1b.js → client-auth-BLCUje4M.js} +193 -174
  7. package/dist/client/_assets/{client-D95FNDg5.js → client-dSfWfMe9.js} +7 -7
  8. package/dist/{github-sync-7y_nTXx1.js → github-sync-CQ1x271f.js} +3 -0
  9. package/dist/index.js +4 -87
  10. package/dist/node.js +3 -3
  11. package/package.json +1 -1
  12. package/src/__tests__/import-site-command.test.ts +18 -0
  13. package/src/client/components/jant-compose-dialog.ts +94 -15
  14. package/src/client/components/jant-compose-editor.ts +11 -6
  15. package/src/client/components/jant-post-menu.ts +23 -5
  16. package/src/client/compose-bridge.ts +2 -1
  17. package/src/client/random-uuid.ts +23 -0
  18. package/src/client/toast.ts +29 -2
  19. package/src/client/upload-session.ts +1 -1
  20. package/src/db/migrations/0020_free_zaladane.sql +1 -0
  21. package/src/db/migrations/meta/0020_snapshot.json +2129 -0
  22. package/src/db/migrations/meta/_journal.json +7 -0
  23. package/src/db/migrations/pg/0018_red_warlock.sql +1 -0
  24. package/src/db/migrations/pg/meta/0018_snapshot.json +2739 -0
  25. package/src/db/migrations/pg/meta/_journal.json +7 -0
  26. package/src/db/pg/schema.ts +0 -30
  27. package/src/db/schema.ts +0 -39
  28. package/src/i18n/locales/public/en.po +10 -5
  29. package/src/i18n/locales/public/en.ts +1 -1
  30. package/src/i18n/locales/public/zh-Hans.po +10 -5
  31. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  32. package/src/i18n/locales/public/zh-Hant.po +10 -5
  33. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  34. package/src/index.ts +0 -3
  35. package/src/lib/__tests__/resolve-config.test.ts +4 -4
  36. package/src/lib/__tests__/startup-config.test.ts +27 -2
  37. package/src/lib/constants.ts +1 -0
  38. package/src/lib/github-sync-trigger.ts +7 -51
  39. package/src/lib/startup-config.ts +53 -6
  40. package/src/routes/api/github-sync.tsx +36 -14
  41. package/src/routes/pages/home.tsx +2 -0
  42. package/src/routes/pages/latest.tsx +2 -0
  43. package/src/runtime/__tests__/readiness.test.ts +34 -0
  44. package/src/runtime/readiness.ts +8 -4
  45. package/src/services/__tests__/collection.test.ts +13 -11
  46. package/src/services/github-sync.ts +6 -0
  47. package/src/styles/components.css +14 -0
  48. package/src/styles/ui.css +97 -0
  49. package/src/types/bindings.ts +0 -2
  50. package/src/types/config.ts +1 -1
  51. package/src/types/props.ts +2 -0
  52. package/src/ui/__tests__/font-themes.test.ts +2 -2
  53. package/src/ui/dash/settings/SettingsRootContent.tsx +17 -17
  54. package/src/ui/font-themes.ts +17 -17
  55. package/src/ui/pages/HomePage.tsx +18 -5
  56. package/dist/app-BI9bnCkO.js +0 -5
  57. package/dist/client/_assets/client-BQH7AQ24.css +0 -2
  58. package/src/lib/github-sync-queue-handler.ts +0 -69
  59. package/src/lib/github-sync-worker.ts +0 -72
  60. package/src/lib/job-queue-cf.ts +0 -18
  61. package/src/lib/job-queue-db.ts +0 -149
  62. package/src/lib/job-queue.ts +0 -35
package/src/index.ts CHANGED
@@ -82,6 +82,3 @@ export type { MediaContext } from "./lib/view.js";
82
82
 
83
83
  // Default feed renderers (for custom feed implementations)
84
84
  export { defaultFeedRenderer } from "./lib/feed.js";
85
-
86
- // GitHub Sync queue handler (for Cloudflare Workers queue consumer)
87
- export { handleQueueBatch as handleGitHubSyncQueueBatch } from "./lib/github-sync-queue-handler.js";
@@ -307,16 +307,16 @@ describe("resolveConfig", () => {
307
307
  });
308
308
 
309
309
  it("fontThemeId falls through DB → ENV → hardcoded default", () => {
310
- const c1 = resolveConfig(makeEnv({ DEFAULT_FONT_THEME: "classic" }), {
310
+ const c1 = resolveConfig(makeEnv({ DEFAULT_FONT_THEME: "tufte" }), {
311
311
  FONT_THEME: "geometric",
312
312
  });
313
313
  expect(c1.fontThemeId).toBe("geometric");
314
314
 
315
- const c2 = resolveConfig(makeEnv({ DEFAULT_FONT_THEME: "classic" }), {});
316
- expect(c2.fontThemeId).toBe("classic");
315
+ const c2 = resolveConfig(makeEnv({ DEFAULT_FONT_THEME: "tufte" }), {});
316
+ expect(c2.fontThemeId).toBe("tufte");
317
317
 
318
318
  const c3 = resolveConfig(makeEnv(), {});
319
- expect(c3.fontThemeId).toBe("tufte");
319
+ expect(c3.fontThemeId).toBe("classic");
320
320
  });
321
321
 
322
322
  it("uses unprefixed env names across the config surface", () => {
@@ -16,10 +16,10 @@ const VALID_HOST_BASED_ENV = {
16
16
  };
17
17
 
18
18
  describe("getStartupConfigurationErrorPage", () => {
19
- it("does not block startup when AUTH_SECRET is present", () => {
19
+ it("does not block startup when AUTH_SECRET is present and long enough", () => {
20
20
  expect(
21
21
  getStartupConfigurationErrorPage({
22
- AUTH_SECRET: "test-secret",
22
+ AUTH_SECRET: "test-secret-with-enough-entropy-for-startup-checks",
23
23
  DEV_API_TOKEN: "jnt_dev_test123",
24
24
  }),
25
25
  ).toBeNull();
@@ -34,10 +34,35 @@ describe("getStartupConfigurationErrorPage", () => {
34
34
  expect(page).toContain(
35
35
  "Set <code>AUTH_SECRET=...</code> in the environment used to start Jant.",
36
36
  );
37
+ expect(page).toContain("openssl rand -base64 32");
37
38
  expect(page).toContain("wrangler secret put AUTH_SECRET");
38
39
  expect(page).toContain("Open configuration instructions");
39
40
  });
40
41
 
42
+ it("returns an error page when AUTH_SECRET is shorter than 32 characters", () => {
43
+ const page = getStartupConfigurationErrorPage({
44
+ AUTH_SECRET: "too-short",
45
+ DEV_API_TOKEN: "jnt_dev_test123",
46
+ });
47
+
48
+ expect(page).toContain("AUTH_SECRET is too short");
49
+ expect(page).toContain("at least 32 characters");
50
+ expect(page).toContain("openssl rand -base64 32");
51
+ });
52
+
53
+ it("returns an error page when AUTH_SECRET still uses the .env.example placeholder", () => {
54
+ const page = getStartupConfigurationErrorPage({
55
+ AUTH_SECRET: "replace-me-replace-me-replace-me-replace-me-replace-me",
56
+ DEV_API_TOKEN: "jnt_dev_test123",
57
+ });
58
+
59
+ expect(page).toContain(
60
+ "AUTH_SECRET is still the placeholder from .env.example",
61
+ );
62
+ expect(page).toContain("publicly known");
63
+ expect(page).toContain("openssl rand -base64 32");
64
+ });
65
+
41
66
  it("does not block startup when host-based required variables are present", () => {
42
67
  expect(getStartupConfigurationErrorPage(VALID_HOST_BASED_ENV)).toBeNull();
43
68
  });
@@ -22,6 +22,7 @@ export const RESERVED_PATHS = [
22
22
  "reset",
23
23
  "collections",
24
24
  "compose",
25
+ "new",
25
26
  "static",
26
27
  "assets",
27
28
  "_assets",
@@ -1,20 +1,14 @@
1
1
  /**
2
2
  * GitHub Sync Trigger
3
3
  *
4
- * Two dispatch paths:
4
+ * `triggerGitHubSyncInline` runs pushFullSync in the current worker
5
+ * invocation via `c.executionCtx.waitUntil`. Works uniformly on
6
+ * Workers and Node, no queue binding required.
5
7
  *
6
- * - `triggerGitHubSync` (queue-based): the original design, kept for
7
- * future use when a Cloudflare Queue binding is actually wired up.
8
- * Falls back to a no-op queue today, which silently drops jobs.
9
- * - `triggerGitHubSyncInline` (inline): runs pushFullSync in the
10
- * current worker invocation via `c.executionCtx.waitUntil`. Works
11
- * uniformly on Workers and Node, no queue binding required. This
12
- * is what every caller uses today.
13
- *
14
- * Both paths debounce through a PENDING flag. When a new trigger
15
- * arrives while a sync is running, the inline runner records it via
16
- * DIRTY; the running sync re-runs once more after completion so the
17
- * new edits land.
8
+ * Debounces through a PENDING flag. When a new trigger arrives while
9
+ * a sync is running, the inline runner records it via DIRTY; the
10
+ * running sync re-runs once more after completion so the new edits
11
+ * land.
18
12
  */
19
13
 
20
14
  import type { Context } from "hono";
@@ -22,23 +16,10 @@ import type { SettingsService } from "../services/settings.js";
22
16
  import type { GitHubSyncService } from "../services/github-sync.js";
23
17
  import type { AppVariables } from "../types/app-context.js";
24
18
  import type { Bindings } from "../types/bindings.js";
25
- import { noopQueue, type JobQueue } from "./job-queue.js";
26
- import { createCfJobQueue } from "./job-queue-cf.js";
27
19
  import { buildSyncSiteConfig } from "./github-sync-site-config.js";
28
20
 
29
21
  type Env = { Bindings: Bindings; Variables: AppVariables };
30
22
 
31
- /**
32
- * Resolve the appropriate job queue from the environment.
33
- * Returns the CF Queue adapter if available, otherwise noop.
34
- */
35
- export function resolveJobQueue(env: { GITHUB_SYNC_QUEUE?: Queue }): JobQueue {
36
- if (env.GITHUB_SYNC_QUEUE) {
37
- return createCfJobQueue(env.GITHUB_SYNC_QUEUE);
38
- }
39
- return noopQueue;
40
- }
41
-
42
23
  /**
43
24
  * Maximum time a sync is allowed to be "in flight" before we consider
44
25
  * the PENDING flag stale. Covers worker crashes, timeouts, and any
@@ -89,31 +70,6 @@ export async function markSyncPending(
89
70
  await settings.set("GITHUB_SYNC_DIRTY", "");
90
71
  }
91
72
 
92
- /**
93
- * Queue-based trigger. Safe to call on every post mutation. Kept for
94
- * compatibility with the original design but currently enqueues to a
95
- * noop queue on every known deployment — callers should use
96
- * `triggerGitHubSyncInline` instead.
97
- */
98
- export async function triggerGitHubSync(
99
- queue: JobQueue,
100
- settings: SettingsService,
101
- siteId: string,
102
- ): Promise<void> {
103
- const enabled = await settings.get("GITHUB_SYNC_ENABLED");
104
- if (enabled !== "true") return;
105
-
106
- const alreadyPending = await settings.get("GITHUB_SYNC_PENDING");
107
- if (alreadyPending === "true") return;
108
-
109
- await settings.set("GITHUB_SYNC_PENDING", "true");
110
- await queue.enqueue({
111
- kind: "github-sync-push",
112
- siteId,
113
- data: {},
114
- });
115
- }
116
-
117
73
  /**
118
74
  * Run a full GitHub Sync push in the background, managing the lifecycle
119
75
  * flags (`GITHUB_SYNC_PENDING`, `GITHUB_SYNC_DIRTY`, `GITHUB_SYNC_LAST_ERROR`).
@@ -12,6 +12,40 @@ import {
12
12
  import type { Bindings } from "../types.js";
13
13
 
14
14
  const HOSTED_SHARED_SECRET_MIN_LENGTH = 32;
15
+ export const AUTH_SECRET_MIN_LENGTH = 32;
16
+
17
+ const AUTH_SECRET_GENERATION_HINT =
18
+ "Generate one with `openssl rand -base64 32`.";
19
+
20
+ const AUTH_SECRET_PLACEHOLDER_MARKER = "replace-me";
21
+
22
+ type AuthSecretIssueKind = "missing" | "placeholder" | "too-short";
23
+
24
+ export function getAuthSecretIssueKind(
25
+ env: Pick<Bindings, "AUTH_SECRET">,
26
+ ): AuthSecretIssueKind | null {
27
+ const secret = getAuthSecret(env);
28
+ if (!secret) {
29
+ return "missing";
30
+ }
31
+ if (secret.toLowerCase().includes(AUTH_SECRET_PLACEHOLDER_MARKER)) {
32
+ return "placeholder";
33
+ }
34
+ if (secret.length < AUTH_SECRET_MIN_LENGTH) {
35
+ return "too-short";
36
+ }
37
+ return null;
38
+ }
39
+
40
+ export function getAuthSecretReadinessError(kind: AuthSecretIssueKind): string {
41
+ if (kind === "placeholder") {
42
+ return `AUTH_SECRET still uses the placeholder value from .env.example. ${AUTH_SECRET_GENERATION_HINT}`;
43
+ }
44
+ if (kind === "too-short") {
45
+ return `AUTH_SECRET must be at least ${AUTH_SECRET_MIN_LENGTH} characters before Jant can accept traffic. ${AUTH_SECRET_GENERATION_HINT}`;
46
+ }
47
+ return "AUTH_SECRET must be set before Jant can accept traffic.";
48
+ }
15
49
 
16
50
  interface StartupConfigurationIssue {
17
51
  message: string;
@@ -41,13 +75,25 @@ ${input.bodyHtml}
41
75
  </html>`;
42
76
  }
43
77
 
44
- function getAuthSecretErrorHtml(): string {
45
- const runtimeInstructions = `<p>Set <code>AUTH_SECRET=...</code> in the environment used to start Jant.</p>
78
+ function getAuthSecretErrorHtml(kind: AuthSecretIssueKind): string {
79
+ const runtimeInstructions = `<p>Set <code>AUTH_SECRET=...</code> in the environment used to start Jant. Generate one with <code>openssl rand -base64 32</code>.</p>
46
80
  <p><strong>Cloudflare Workers:</strong> add <code>AUTH_SECRET</code> as a Worker secret in the dashboard under Variables and Secrets, or run <code>wrangler secret put AUTH_SECRET</code>.</p>`;
47
81
 
82
+ const titleByKind: Record<AuthSecretIssueKind, string> = {
83
+ missing: "AUTH_SECRET is not set",
84
+ placeholder: "AUTH_SECRET is still the placeholder from .env.example",
85
+ "too-short": `AUTH_SECRET is too short (must be at least ${AUTH_SECRET_MIN_LENGTH} characters)`,
86
+ };
87
+
88
+ const leadByKind: Record<AuthSecretIssueKind, string> = {
89
+ missing: `<p>Jant needs a ${AUTH_SECRET_MIN_LENGTH}+ character auth secret to sign sessions.</p>`,
90
+ placeholder: `<p>The current <code>AUTH_SECRET</code> still contains the <code>replace-me</code> placeholder from <code>.env.example</code>. This value is publicly known and unsafe to use; replace it with a real secret before serving traffic.</p>`,
91
+ "too-short": `<p>Jant needs an auth secret of at least ${AUTH_SECRET_MIN_LENGTH} characters to sign sessions. The current value is too short.</p>`,
92
+ };
93
+
48
94
  return renderConfigurationErrorPage({
49
- title: "AUTH_SECRET is not set",
50
- bodyHtml: `<p>Jant needs a 32+ character auth secret to sign sessions.</p>${runtimeInstructions}`,
95
+ title: titleByKind[kind],
96
+ bodyHtml: `${leadByKind[kind]}${runtimeInstructions}`,
51
97
  docsHref:
52
98
  "https://github.com/jant-me/jant/blob/main/docs/configuration.md#required",
53
99
  });
@@ -231,8 +277,9 @@ export function getStartupConfigurationErrorPage(
231
277
  | "SITE_RESOLUTION_MODE"
232
278
  >,
233
279
  ): string | null {
234
- if (!getAuthSecret(env)) {
235
- return getAuthSecretErrorHtml();
280
+ const authSecretIssue = getAuthSecretIssueKind(env);
281
+ if (authSecretIssue) {
282
+ return getAuthSecretErrorHtml(authSecretIssue);
236
283
  }
237
284
 
238
285
  const hostBasedIssues = collectHostBasedStartupConfigurationIssues(env);
@@ -10,8 +10,8 @@ import { z } from "zod";
10
10
  import type { Bindings } from "../../types.js";
11
11
  import type { AppVariables } from "../../types/app-context.js";
12
12
  import { requireAuthApi } from "../../middleware/auth.js";
13
+ import { withConfig } from "../../middleware/config.js";
13
14
  import { verifyGitHubWebhookSignature } from "../../lib/webhook-signature.js";
14
- import { resolveJobQueue } from "../../lib/github-sync-trigger.js";
15
15
  import { createGitHubClient, parseRepoSlug } from "../../lib/github-api.js";
16
16
  import {
17
17
  createGitHubSyncService,
@@ -36,7 +36,12 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
36
36
 
37
37
  export const githubSyncWebhookRoutes = new Hono<Env>();
38
38
 
39
- githubSyncWebhookRoutes.post("/webhook", async (c) => {
39
+ // `withConfig` here loads `appConfig`/`allSettings`/`themeStyle` for
40
+ // `buildSyncSiteConfig`. The webhook subrouter is mounted before the
41
+ // global `withConfig` middleware (it must skip auth/onboarding), so we
42
+ // apply it route-locally rather than per-subrouter — the sibling
43
+ // `/app-webhook` is host-agnostic and doesn't need site config.
44
+ githubSyncWebhookRoutes.post("/webhook", withConfig(), async (c) => {
40
45
  // Prefer an app-level webhook secret when configured (GitHub App deployments
41
46
  // can set a single shared secret on the App and skip per-site secrets);
42
47
  // otherwise fall back to the per-site secret saved during setup.
@@ -72,18 +77,35 @@ githubSyncWebhookRoutes.post("/webhook", async (c) => {
72
77
  return c.json({ ok: true, skipped: "jant-sync commits" });
73
78
  }
74
79
 
75
- // Enqueue pull job
76
- const queue = resolveJobQueue(c.env);
77
- await queue.enqueue({
78
- kind: "github-sync-pull",
79
- siteId: c.var.currentSite.id,
80
- data: {
81
- ref: payload.ref,
82
- before: payload.before,
83
- after: payload.after,
84
- commits: payload.commits,
85
- },
86
- });
80
+ // Run the pull inline. Self-hosted Node deployments don't have a
81
+ // CF Queue binding, and the legacy queue path silently dropped jobs
82
+ // through `noopQueue`. Mirroring `triggerGitHubSyncInline`, we hand
83
+ // the work to `executionCtx.waitUntil` so the HTTP response returns
84
+ // immediately while the sync runs in the background.
85
+ const syncService = createGitHubSyncService(
86
+ c.var.services,
87
+ c.var.currentSite.id,
88
+ await buildSyncSiteConfig(c),
89
+ { storage: c.var.storage, githubApp: getGitHubAppConfig(c.env) },
90
+ );
91
+
92
+ const settings = c.var.services.settings;
93
+ const run = (async () => {
94
+ try {
95
+ await syncService.handleWebhookPush(payload);
96
+ await settings.set("GITHUB_SYNC_LAST_ERROR", "");
97
+ } catch (err) {
98
+ const message = err instanceof Error ? err.message : String(err);
99
+ await settings.set("GITHUB_SYNC_LAST_ERROR", message);
100
+ }
101
+ })();
102
+
103
+ try {
104
+ c.executionCtx?.waitUntil(run);
105
+ } catch {
106
+ // executionCtx not available (e.g. tests) — promise still resolves
107
+ // on its own; HTTP response returns immediately either way.
108
+ }
87
109
 
88
110
  return c.json({ ok: true, queued: true });
89
111
  });
@@ -104,6 +104,8 @@ homeRoutes.get("/", async (c) => {
104
104
  baseUrl={toPublicPath("/", navData.sitePathPrefix)}
105
105
  currentPage={currentPage}
106
106
  totalPages={totalPages}
107
+ isAuthenticated={isAuthenticated}
108
+ signinUrl={`${toPublicPath("/signin", navData.sitePathPrefix)}?redirect=${encodeURIComponent(toPublicPath("/", navData.sitePathPrefix))}`}
107
109
  />
108
110
  ),
109
111
  });
@@ -59,6 +59,8 @@ latestRoutes.get("/", async (c) => {
59
59
  baseUrl={toPublicPath("/latest", navData.sitePathPrefix)}
60
60
  currentPage={currentPage}
61
61
  totalPages={totalPages}
62
+ isAuthenticated={navData.isAuthenticated}
63
+ signinUrl={`${toPublicPath("/signin", navData.sitePathPrefix)}?redirect=${encodeURIComponent(toPublicPath("/latest", navData.sitePathPrefix))}`}
62
64
  />
63
65
  ),
64
66
  });
@@ -50,6 +50,40 @@ describe("getInstanceReadiness", () => {
50
50
  });
51
51
  });
52
52
 
53
+ it("reports startup configuration failures when AUTH_SECRET is too short", async () => {
54
+ const { sqlite } = createTestDatabase();
55
+
56
+ const result = await getInstanceReadiness({
57
+ AUTH_SECRET: "too-short",
58
+ NODE_SQLITE: sqlite,
59
+ });
60
+
61
+ expect(result.status).toBe("error");
62
+ expect(result.checks.database).toEqual({ ok: true });
63
+ expect(result.checks.startupConfig.ok).toBe(false);
64
+ expect(result.checks.startupConfig.error).toContain(
65
+ "AUTH_SECRET must be at least 32 characters",
66
+ );
67
+ expect(result.checks.startupConfig.error).toContain(
68
+ "openssl rand -base64 32",
69
+ );
70
+ });
71
+
72
+ it("reports startup configuration failures when AUTH_SECRET is still the placeholder", async () => {
73
+ const { sqlite } = createTestDatabase();
74
+
75
+ const result = await getInstanceReadiness({
76
+ AUTH_SECRET: "replace-me-replace-me-replace-me-replace-me-replace-me",
77
+ NODE_SQLITE: sqlite,
78
+ });
79
+
80
+ expect(result.status).toBe("error");
81
+ expect(result.checks.startupConfig.ok).toBe(false);
82
+ expect(result.checks.startupConfig.error).toContain(
83
+ "AUTH_SECRET still uses the placeholder value from .env.example",
84
+ );
85
+ });
86
+
53
87
  it("reports host-based startup issues when required env is missing", async () => {
54
88
  const { sqlite } = createTestDatabase();
55
89
 
@@ -1,7 +1,10 @@
1
1
  import { createDatabase, createNodeDatabase } from "../db/index.js";
2
2
  import { sqliteSchemaBundle } from "../db/schema-bundle.js";
3
- import { getAuthSecret } from "../lib/env.js";
4
- import { getHostBasedStartupConfigurationIssues } from "../lib/startup-config.js";
3
+ import {
4
+ getAuthSecretIssueKind,
5
+ getAuthSecretReadinessError,
6
+ getHostBasedStartupConfigurationIssues,
7
+ } from "../lib/startup-config.js";
5
8
  import { createSiteService } from "../services/site.js";
6
9
  import type { Bindings } from "../types/bindings.js";
7
10
 
@@ -33,8 +36,9 @@ function getStartupConfigurationReadiness(
33
36
  ): ReadinessCheckStatus {
34
37
  const errors: string[] = [];
35
38
 
36
- if (!getAuthSecret(env)) {
37
- errors.push("AUTH_SECRET must be set before Jant can accept traffic.");
39
+ const authSecretIssue = getAuthSecretIssueKind(env);
40
+ if (authSecretIssue) {
41
+ errors.push(getAuthSecretReadinessError(authSecretIssue));
38
42
  }
39
43
 
40
44
  for (const issue of getHostBasedStartupConfigurationIssues(env)) {
@@ -127,25 +127,27 @@ describe("CollectionService", () => {
127
127
  ).rejects.toThrow("Use lowercase letters, numbers, and hyphens only.");
128
128
  });
129
129
 
130
- it("allows root-level slugs that only conflicted in the old namespace", async () => {
130
+ it("allows valid non-reserved collection slugs", async () => {
131
131
  await expect(
132
132
  collectionService.create({
133
- slug: "new",
134
- title: "New",
133
+ slug: "favorites",
134
+ title: "Favorites",
135
135
  }),
136
136
  ).resolves.toMatchObject({
137
- slug: "new",
138
- title: "New",
137
+ slug: "favorites",
138
+ title: "Favorites",
139
139
  });
140
140
  });
141
141
 
142
142
  it("rejects slugs reserved by top-level routes", async () => {
143
- await expect(
144
- collectionService.create({
145
- slug: "collections",
146
- title: "Collections",
147
- }),
148
- ).rejects.toThrow("This link is reserved. Choose something else.");
143
+ for (const slug of ["collections", "new", "compose"]) {
144
+ await expect(
145
+ collectionService.create({
146
+ slug,
147
+ title: slug,
148
+ }),
149
+ ).rejects.toThrow("This link is reserved. Choose something else.");
150
+ }
149
151
  });
150
152
  });
151
153
 
@@ -651,9 +651,15 @@ export function createGitHubSyncService(
651
651
  if (frontMatter.link_url !== undefined) {
652
652
  updateData.url = frontMatter.link_url;
653
653
  }
654
+ if (frontMatter.source_name !== undefined)
655
+ updateData.sourceName = frontMatter.source_name;
656
+ if (frontMatter.source_url !== undefined)
657
+ updateData.sourceUrl = frontMatter.source_url;
654
658
  if (frontMatter.quote_text !== undefined) {
655
659
  updateData.quoteText = frontMatter.quote_text;
656
660
  }
661
+ if (frontMatter.rating !== undefined)
662
+ updateData.rating = frontMatter.rating;
657
663
 
658
664
  if (Object.keys(updateData).length > 0) {
659
665
  await services.posts.update(existingPost.id, updateData);
@@ -106,6 +106,20 @@ svg[stroke-width].icon-fine {
106
106
  }
107
107
  }
108
108
 
109
+ .toast-copy {
110
+ @apply shrink-0 translate-y-0.5 cursor-pointer rounded-sm p-0 border-0 bg-transparent;
111
+ color: var(--color-muted-foreground);
112
+ transition: color 0.15s;
113
+
114
+ &:hover {
115
+ color: var(--color-foreground);
116
+ }
117
+
118
+ > svg {
119
+ @apply size-3.5;
120
+ }
121
+ }
122
+
109
123
  .toast-out {
110
124
  animation: toast-out 0.3s ease-in forwards;
111
125
  }
package/src/styles/ui.css CHANGED
@@ -5796,6 +5796,87 @@
5796
5796
  min-width: 0;
5797
5797
  }
5798
5798
 
5799
+ .compose-quick-actions-row {
5800
+ display: flex;
5801
+ justify-content: flex-end;
5802
+ padding: 2px 12px 12px;
5803
+ background-color: var(--compose-paper-bg);
5804
+ }
5805
+
5806
+ .compose-action-row:has(+ .compose-quick-actions-row) {
5807
+ padding-bottom: 4px;
5808
+ }
5809
+
5810
+ @media (min-width: 700px) {
5811
+ .compose-quick-actions-row {
5812
+ padding: 2px 18px 14px;
5813
+ }
5814
+
5815
+ .compose-action-row:has(+ .compose-quick-actions-row) {
5816
+ padding-bottom: 4px;
5817
+ }
5818
+ }
5819
+
5820
+ .compose-publish-quick-toggle {
5821
+ display: inline-flex;
5822
+ align-items: center;
5823
+ gap: 0.4rem;
5824
+ padding: 0.5rem 0.55rem;
5825
+ margin: 0 -0.4rem;
5826
+ min-height: 36px;
5827
+ font-size: var(--type-ui-caption);
5828
+ color: color-mix(in srgb, var(--site-text-secondary) 78%, transparent);
5829
+ cursor: pointer;
5830
+ user-select: none;
5831
+ line-height: 1.1;
5832
+ border-radius: 6px;
5833
+ -webkit-tap-highlight-color: transparent;
5834
+ transition:
5835
+ color 0.15s ease,
5836
+ background-color 0.15s ease;
5837
+ }
5838
+
5839
+ .compose-publish-quick-toggle:active {
5840
+ background-color: color-mix(
5841
+ in srgb,
5842
+ var(--site-text-primary) 6%,
5843
+ transparent
5844
+ );
5845
+ }
5846
+
5847
+ @media (min-width: 700px) {
5848
+ .compose-publish-quick-toggle {
5849
+ padding: 0.3rem 0.35rem;
5850
+ margin: 0 -0.2rem;
5851
+ min-height: 0;
5852
+ }
5853
+ }
5854
+
5855
+ .compose-publish-quick-toggle:hover {
5856
+ color: var(--site-text-secondary);
5857
+ }
5858
+
5859
+ .compose-publish-quick-toggle:has(input:checked) {
5860
+ color: color-mix(in srgb, var(--site-text-primary) 85%, transparent);
5861
+ }
5862
+
5863
+ .compose-publish-quick-toggle-input {
5864
+ width: 0.88rem !important;
5865
+ height: 0.88rem !important;
5866
+ border-radius: 3px;
5867
+ }
5868
+
5869
+ .compose-publish-quick-toggle-input:checked:after {
5870
+ width: 0.74rem !important;
5871
+ height: 0.74rem !important;
5872
+ mask-size: 0.74rem !important;
5873
+ }
5874
+
5875
+ .compose-publish-quick-toggle:has(input:disabled) {
5876
+ opacity: 0.5;
5877
+ cursor: not-allowed;
5878
+ }
5879
+
5799
5880
  .compose-publish-summaries {
5800
5881
  display: flex;
5801
5882
  flex-wrap: wrap;
@@ -6838,6 +6919,9 @@
6838
6919
  border: none;
6839
6920
  padding: 8px;
6840
6921
  transition: background-color 0.15s;
6922
+ flex-direction: column;
6923
+ justify-content: center;
6924
+ gap: 6px;
6841
6925
  }
6842
6926
 
6843
6927
  .compose-attachment-retry:hover {
@@ -6867,6 +6951,19 @@
6867
6951
  opacity: 0.9;
6868
6952
  }
6869
6953
 
6954
+ .compose-attachment-error-msg {
6955
+ font-size: 10px;
6956
+ line-height: 1.3;
6957
+ color: white;
6958
+ text-align: center;
6959
+ word-break: break-all;
6960
+ padding: 0 4px;
6961
+ user-select: text;
6962
+ text-shadow:
6963
+ 0 1px 3px rgba(0, 0, 0, 0.9),
6964
+ 0 0 6px rgba(0, 0, 0, 0.7);
6965
+ }
6966
+
6870
6967
  .compose-attachment-remove {
6871
6968
  position: absolute;
6872
6969
  top: 6px;
@@ -87,6 +87,4 @@ export interface Bindings {
87
87
  CORS_ORIGINS?: EnvBindingValue;
88
88
  HOST?: string;
89
89
  PORT?: string;
90
- // GitHub Sync queue (Cloudflare Queues)
91
- GITHUB_SYNC_QUEUE?: Queue;
92
90
  }
@@ -63,7 +63,7 @@ export const CONFIG_FIELDS = {
63
63
  envKeys: ["DEFAULT_THEME"],
64
64
  },
65
65
  DEFAULT_FONT_THEME: {
66
- defaultValue: "tufte",
66
+ defaultValue: "classic",
67
67
  envOnly: true,
68
68
  envKeys: ["DEFAULT_FONT_THEME"],
69
69
  },
@@ -22,6 +22,8 @@ export interface HomePageProps {
22
22
  currentPage: number;
23
23
  totalPages: number;
24
24
  baseUrl: string;
25
+ isAuthenticated: boolean;
26
+ signinUrl: string;
25
27
  }
26
28
 
27
29
  /** Props for the single post page component */
@@ -11,8 +11,8 @@ describe("BUILTIN_FONT_THEMES", () => {
11
11
  expect(BUILTIN_FONT_THEMES).toHaveLength(7);
12
12
  });
13
13
 
14
- it("has 'tufte' as the first theme", () => {
15
- expect(BUILTIN_FONT_THEMES[0].id).toBe("tufte");
14
+ it("has 'classic' as the first theme", () => {
15
+ expect(BUILTIN_FONT_THEMES[0].id).toBe("classic");
16
16
  });
17
17
 
18
18
  it("each theme has required fields", () => {