@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.
- package/bin/commands/import-site.js +40 -39
- package/dist/app-CM7sb3xO.js +5 -0
- package/dist/{app-CtJDxZBb.js → app-DB-P66E5.js} +147 -203
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-DDs6NzB3.css +2 -0
- package/dist/client/_assets/{client-auth-CXILhW1b.js → client-auth-BLCUje4M.js} +193 -174
- package/dist/client/_assets/{client-D95FNDg5.js → client-dSfWfMe9.js} +7 -7
- package/dist/{github-sync-7y_nTXx1.js → github-sync-CQ1x271f.js} +3 -0
- package/dist/index.js +4 -87
- package/dist/node.js +3 -3
- package/package.json +1 -1
- package/src/__tests__/import-site-command.test.ts +18 -0
- package/src/client/components/jant-compose-dialog.ts +94 -15
- package/src/client/components/jant-compose-editor.ts +11 -6
- package/src/client/components/jant-post-menu.ts +23 -5
- package/src/client/compose-bridge.ts +2 -1
- package/src/client/random-uuid.ts +23 -0
- package/src/client/toast.ts +29 -2
- package/src/client/upload-session.ts +1 -1
- package/src/db/migrations/0020_free_zaladane.sql +1 -0
- package/src/db/migrations/meta/0020_snapshot.json +2129 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0018_red_warlock.sql +1 -0
- package/src/db/migrations/pg/meta/0018_snapshot.json +2739 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +0 -30
- package/src/db/schema.ts +0 -39
- package/src/i18n/locales/public/en.po +10 -5
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +10 -5
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +10 -5
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/index.ts +0 -3
- package/src/lib/__tests__/resolve-config.test.ts +4 -4
- package/src/lib/__tests__/startup-config.test.ts +27 -2
- package/src/lib/constants.ts +1 -0
- package/src/lib/github-sync-trigger.ts +7 -51
- package/src/lib/startup-config.ts +53 -6
- package/src/routes/api/github-sync.tsx +36 -14
- package/src/routes/pages/home.tsx +2 -0
- package/src/routes/pages/latest.tsx +2 -0
- package/src/runtime/__tests__/readiness.test.ts +34 -0
- package/src/runtime/readiness.ts +8 -4
- package/src/services/__tests__/collection.test.ts +13 -11
- package/src/services/github-sync.ts +6 -0
- package/src/styles/components.css +14 -0
- package/src/styles/ui.css +97 -0
- package/src/types/bindings.ts +0 -2
- package/src/types/config.ts +1 -1
- package/src/types/props.ts +2 -0
- package/src/ui/__tests__/font-themes.test.ts +2 -2
- package/src/ui/dash/settings/SettingsRootContent.tsx +17 -17
- package/src/ui/font-themes.ts +17 -17
- package/src/ui/pages/HomePage.tsx +18 -5
- package/dist/app-BI9bnCkO.js +0 -5
- package/dist/client/_assets/client-BQH7AQ24.css +0 -2
- package/src/lib/github-sync-queue-handler.ts +0 -69
- package/src/lib/github-sync-worker.ts +0 -72
- package/src/lib/job-queue-cf.ts +0 -18
- package/src/lib/job-queue-db.ts +0 -149
- 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: "
|
|
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: "
|
|
316
|
-
expect(c2.fontThemeId).toBe("
|
|
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("
|
|
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
|
});
|
package/src/lib/constants.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GitHub Sync Trigger
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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:
|
|
50
|
-
bodyHtml:
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
package/src/runtime/readiness.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createDatabase, createNodeDatabase } from "../db/index.js";
|
|
2
2
|
import { sqliteSchemaBundle } from "../db/schema-bundle.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
130
|
+
it("allows valid non-reserved collection slugs", async () => {
|
|
131
131
|
await expect(
|
|
132
132
|
collectionService.create({
|
|
133
|
-
slug: "
|
|
134
|
-
title: "
|
|
133
|
+
slug: "favorites",
|
|
134
|
+
title: "Favorites",
|
|
135
135
|
}),
|
|
136
136
|
).resolves.toMatchObject({
|
|
137
|
-
slug: "
|
|
138
|
-
title: "
|
|
137
|
+
slug: "favorites",
|
|
138
|
+
title: "Favorites",
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
141
|
|
|
142
142
|
it("rejects slugs reserved by top-level routes", async () => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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;
|
package/src/types/bindings.ts
CHANGED
package/src/types/config.ts
CHANGED
package/src/types/props.ts
CHANGED
|
@@ -11,8 +11,8 @@ describe("BUILTIN_FONT_THEMES", () => {
|
|
|
11
11
|
expect(BUILTIN_FONT_THEMES).toHaveLength(7);
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
it("has '
|
|
15
|
-
expect(BUILTIN_FONT_THEMES[0].id).toBe("
|
|
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", () => {
|