@coywolf/emdash 0.35.14 → 0.35.15
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/README.md +12 -4
- package/docs/releasing.md +3 -3
- package/package.json +1 -1
- package/src/admin/features.tsx +4 -0
- package/src/admin/index.tsx +7 -1
- package/src/admin/update-notice.tsx +198 -0
- package/src/core/features.ts +10 -0
- package/src/core/module.ts +2 -0
- package/src/index.ts +11 -3
- package/src/modules.ts +3 -0
- package/src/options.ts +2 -0
- package/src/pageCache/lib.ts +4 -2
- package/src/updates/check.ts +135 -0
- package/src/updates/core.ts +56 -0
- package/src/updates/pack.ts +54 -0
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -47,13 +47,13 @@ Coywolf Pack is a native (trusted) EmDash plugin: you add it to your site's code
|
|
|
47
47
|
|
|
48
48
|
### 1. Install the package
|
|
49
49
|
|
|
50
|
-
From the site's folder
|
|
50
|
+
From the site's folder:
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
npm install
|
|
53
|
+
npm install @coywolf/emdash
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
The package is
|
|
56
|
+
The package is [`@coywolf/emdash` on npm](https://www.npmjs.com/package/@coywolf/emdash). Each version links to the GitHub commit and workflow run that built it (npm provenance). What changed in each release is under [Releases](https://github.com/coywolf-llc/coywolf-pack/releases).
|
|
57
57
|
|
|
58
58
|
### 2. Add the plugin to `astro.config.mjs`
|
|
59
59
|
|
|
@@ -156,7 +156,15 @@ A good first set for most sites:
|
|
|
156
156
|
|
|
157
157
|
### Updating
|
|
158
158
|
|
|
159
|
-
Install the
|
|
159
|
+
Install the latest version, then deploy:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npm install @coywolf/emdash@latest
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
If your `package.json` points at a GitHub tarball URL from before the pack was on npm (0.35.13 and earlier), this command replaces it with the npm version. Updates never turn features on by themselves: new features start off.
|
|
166
|
+
|
|
167
|
+
**Update notices:** once a day, the pack asks npm for its newest version. When there's an update, a **Coywolf Pack update** card on the EmDash dashboard and a notice at the top of the pack's pages show the command above, with a **Copy command** button. **Dismiss** hides both for everyone on the site until the next version. The check sends nothing about your site and never updates anything by itself. Like every feature, it starts off: turn on **Update check** at the top of **Plugins → Coywolf Pack → Features**.
|
|
160
168
|
|
|
161
169
|
### Local development
|
|
162
170
|
|
package/docs/releasing.md
CHANGED
|
@@ -13,9 +13,9 @@ Each release is a version tag, a GitHub release and an npm version of `@coywolf/
|
|
|
13
13
|
|
|
14
14
|
The workflow uses npm trusted publishing: npm accepts publishes from this repository's workflow without any stored token.
|
|
15
15
|
|
|
16
|
-
1. The npm
|
|
17
|
-
2. The first version is published by hand, because npm sets up a trusted publisher only for a package that exists. From a clean checkout of the release tag, signed in to npm
|
|
18
|
-
3. On npmjs.com, open `@coywolf/emdash` → **Settings** → **Trusted publishing**, choose **GitHub Actions**, and enter organization `coywolf-llc`, repository `coywolf-pack`, workflow `publish.yml` (no environment).
|
|
16
|
+
1. The npm account `coywolf` owns the `@coywolf` scope (a user's scope; no organization needed).
|
|
17
|
+
2. The first version is published by hand, because npm sets up a trusted publisher only for a package that exists. From a clean checkout of the release tag, signed in to npm as `coywolf`: `npm publish --access public` (npm asks for the two-factor code).
|
|
18
|
+
3. On npmjs.com, open `@coywolf/emdash` → **Settings** → **Trusted publishing**, choose **GitHub Actions**, and enter organization `coywolf-llc`, repository `coywolf-pack`, workflow `publish.yml` (no environment). Done for 0.35.13; 0.35.14 was the first version the workflow published.
|
|
19
19
|
4. Optional, recommended: in the package's **Settings → Publishing access**, choose **Require two-factor authentication and disallow tokens**. Trusted publishing keeps working.
|
|
20
20
|
|
|
21
21
|
npm reviews a new package's first versions before they're public: until then `npm view` reports the package as not found and the package page shows a "Temporary Holding Version". A new trusted publisher shows **Pending validation** until the workflow publishes through it once.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coywolf/emdash",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.15",
|
|
4
4
|
"description": "Coywolf Pack for EmDash: one plugin with Coywolf's features for EmDash sites on Cloudflare. Full backups and restore, a redirect manager, and more to come.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Coywolf LLC (https://coywolf.com)",
|
package/src/admin/features.tsx
CHANGED
|
@@ -6,6 +6,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
|
|
6
6
|
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
7
7
|
import * as React from "react";
|
|
8
8
|
|
|
9
|
+
import { resetUpdateStatus } from "./update-notice.js";
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
const API = "/_emdash/api/plugins/coywolf-pack/features";
|
|
11
13
|
|
|
@@ -65,6 +67,8 @@ export function FeaturesPage() {
|
|
|
65
67
|
setModules((current) =>
|
|
66
68
|
current?.map((m) => ({ ...m, features: m.features.map((f) => ({ ...f, enabled: features[f.id] ?? f.enabled })) })) ?? null,
|
|
67
69
|
);
|
|
70
|
+
// The update notices ask again whether to show (the dashboard card follows the manifest below).
|
|
71
|
+
if (id === "updates") resetUpdateStatus();
|
|
68
72
|
// A module's pages show in the sidebar only while it's on: refresh the admin manifest.
|
|
69
73
|
if (!id.includes(".")) {
|
|
70
74
|
if (queryClient) void queryClient.invalidateQueries({ queryKey: ["manifest"] });
|
package/src/admin/index.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import type { PluginAdminExports } from "emdash";
|
|
|
2
2
|
|
|
3
3
|
import { injectAdminCss } from "./admin-css.js";
|
|
4
4
|
import { mountTrashPrompt } from "./trash-prompt.js";
|
|
5
|
+
import { UpdateWidget, withUpdateNotice } from "./update-notice.js";
|
|
5
6
|
|
|
6
7
|
import { AiPage } from "./ai.js";
|
|
7
8
|
import { BackupStatusWidget, BackupsPage } from "./backups.js";
|
|
@@ -30,7 +31,7 @@ injectAdminCss();
|
|
|
30
31
|
// Asks what a trashed or unpublished entry's old URL should do (Redirects → Removed content).
|
|
31
32
|
mountTrashPrompt();
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
const PAGES: PluginAdminExports["pages"] = {
|
|
34
35
|
"/features": FeaturesPage,
|
|
35
36
|
"/performance": PerformancePage,
|
|
36
37
|
"/backups": BackupsPage,
|
|
@@ -55,8 +56,13 @@ export const pages: PluginAdminExports["pages"] = {
|
|
|
55
56
|
"/images": ImagesPage,
|
|
56
57
|
};
|
|
57
58
|
|
|
59
|
+
// Every pack page shows the update notice at the top when a newer Coywolf Pack is out.
|
|
60
|
+
export const pages: PluginAdminExports["pages"] = Object.fromEntries(Object.entries(PAGES).map(([path, Page]) => [path, withUpdateNotice(Page)]));
|
|
61
|
+
|
|
58
62
|
export const widgets: PluginAdminExports["widgets"] = {
|
|
59
63
|
"backup-status": BackupStatusWidget,
|
|
60
64
|
"links-status": LinksWidget,
|
|
61
65
|
"cloudflare-usage": UsageWidget,
|
|
66
|
+
// Listed by the server only while a Coywolf Pack update is waiting.
|
|
67
|
+
update: UpdateWidget,
|
|
62
68
|
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Coywolf Pack x.y.z is available": a card on EmDash's dashboard (listed by
|
|
3
|
+
* the server only while an update is waiting) and a notice at the top of
|
|
4
|
+
* every pack page, each with the command to install it. The server asks npm
|
|
5
|
+
* at most once a day; the pages read the answer once per admin visit.
|
|
6
|
+
* Dismissing hides both for everyone on the site until a newer version.
|
|
7
|
+
*/
|
|
8
|
+
import { Banner, Button } from "@cloudflare/kumo";
|
|
9
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
10
|
+
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
|
|
13
|
+
import type { UpdateStatus } from "../updates/check.js";
|
|
14
|
+
|
|
15
|
+
const API = "/_emdash/api/plugins/coywolf-pack/updates";
|
|
16
|
+
const CHANGED_EVENT = "coywolf-pack:update-changed";
|
|
17
|
+
|
|
18
|
+
type Available = Extract<UpdateStatus, { enabled: true }> & { latest: string };
|
|
19
|
+
|
|
20
|
+
/** One request per admin visit (the admin is a single-page app). */
|
|
21
|
+
let pending: Promise<UpdateStatus | null> | null = null;
|
|
22
|
+
function loadStatus(): Promise<UpdateStatus | null> {
|
|
23
|
+
// Not an admin, the module left out, or offline: no notice for this visit.
|
|
24
|
+
pending ??= apiFetch(`${API}/status`)
|
|
25
|
+
.then((response) => parseApiResponse<UpdateStatus>(response, "Couldn't check for updates"))
|
|
26
|
+
.catch(() => null);
|
|
27
|
+
return pending;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Ask again (after Update check is switched on or off): the notices on the page refresh. */
|
|
31
|
+
export function resetUpdateStatus(): void {
|
|
32
|
+
pending = null;
|
|
33
|
+
window.dispatchEvent(new Event(CHANGED_EVENT));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The EmDash admin's query client, to refresh the dashboard after a dismissal (null if unavailable). */
|
|
37
|
+
function useAdminQueryClient() {
|
|
38
|
+
try {
|
|
39
|
+
return useQueryClient();
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function useUpdate(options: { reloadAfterDismiss?: boolean } = {}): { status: UpdateStatus | null; loaded: boolean; update: Available | null; dismiss: () => Promise<void>; error: string | null } {
|
|
46
|
+
const queryClient = useAdminQueryClient();
|
|
47
|
+
const [status, setStatus] = React.useState<UpdateStatus | null>(null);
|
|
48
|
+
const [loaded, setLoaded] = React.useState(false);
|
|
49
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
let live = true;
|
|
52
|
+
const read = () =>
|
|
53
|
+
void loadStatus().then((s) => {
|
|
54
|
+
if (!live) return;
|
|
55
|
+
setStatus(s);
|
|
56
|
+
setLoaded(true);
|
|
57
|
+
});
|
|
58
|
+
read();
|
|
59
|
+
// A dismissal in one notice hides the others on the page.
|
|
60
|
+
window.addEventListener(CHANGED_EVENT, read);
|
|
61
|
+
return () => {
|
|
62
|
+
live = false;
|
|
63
|
+
window.removeEventListener(CHANGED_EVENT, read);
|
|
64
|
+
};
|
|
65
|
+
}, []);
|
|
66
|
+
const update = status?.enabled && status.available && !status.dismissed && status.latest ? (status as Available) : null;
|
|
67
|
+
const dismiss = async () => {
|
|
68
|
+
if (!update) return;
|
|
69
|
+
setError(null);
|
|
70
|
+
try {
|
|
71
|
+
const response = await apiFetch(`${API}/dismiss`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({ version: update.latest }),
|
|
75
|
+
});
|
|
76
|
+
await parseApiResponse(response, "Couldn't dismiss the notice");
|
|
77
|
+
pending = Promise.resolve({ ...update, dismissed: true });
|
|
78
|
+
window.dispatchEvent(new Event(CHANGED_EVENT));
|
|
79
|
+
// The dashboard card is listed by the admin manifest: refresh it so the card goes. EmDash's admin
|
|
80
|
+
// keeps its own copy of React Query, so its client may be out of reach: then reload, like the Features page.
|
|
81
|
+
if (queryClient) void queryClient.invalidateQueries({ queryKey: ["manifest"] });
|
|
82
|
+
else if (options.reloadAfterDismiss) window.location.reload();
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
setError(cause instanceof Error && cause.message ? cause.message : "Couldn't dismiss the notice");
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return { status, loaded, update, dismiss, error };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Copies the command; if the browser refuses, selects it so ⌘C or Ctrl+C copies it. */
|
|
91
|
+
function useCopyCommand(): [string, React.RefObject<HTMLElement | null>, (text: string) => void] {
|
|
92
|
+
const [status, setStatus] = React.useState("");
|
|
93
|
+
const ref = React.useRef<HTMLElement | null>(null);
|
|
94
|
+
const timer = React.useRef<number | undefined>(undefined);
|
|
95
|
+
React.useEffect(() => () => window.clearTimeout(timer.current), []);
|
|
96
|
+
const copy = (text: string) => {
|
|
97
|
+
const done = (message: string) => {
|
|
98
|
+
setStatus(message);
|
|
99
|
+
window.clearTimeout(timer.current);
|
|
100
|
+
timer.current = window.setTimeout(() => setStatus(""), 4000);
|
|
101
|
+
};
|
|
102
|
+
const select = () => {
|
|
103
|
+
const selection = window.getSelection();
|
|
104
|
+
if (ref.current && selection) {
|
|
105
|
+
selection.selectAllChildren(ref.current);
|
|
106
|
+
done("Couldn't copy: the command is selected, press ⌘C or Ctrl+C");
|
|
107
|
+
} else done("Couldn't copy");
|
|
108
|
+
};
|
|
109
|
+
if (!navigator.clipboard?.writeText) return select();
|
|
110
|
+
navigator.clipboard.writeText(text).then(() => done("Copied"), select);
|
|
111
|
+
};
|
|
112
|
+
return [status, ref, copy];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The command and actions, shared by the dashboard card and the page notice. */
|
|
116
|
+
function UpdateDetails(props: { update: Available; dismiss: () => Promise<void>; error: string | null }) {
|
|
117
|
+
const { update } = props;
|
|
118
|
+
const [copied, commandRef, copy] = useCopyCommand();
|
|
119
|
+
const [dismissing, setDismissing] = React.useState(false);
|
|
120
|
+
return (
|
|
121
|
+
<div className="space-y-3">
|
|
122
|
+
<p>
|
|
123
|
+
This site runs {update.current}. In the site's folder, run this command, then deploy. Updating never turns features on by
|
|
124
|
+
itself.
|
|
125
|
+
</p>
|
|
126
|
+
<pre className="cw-overflow-auto rounded-md border border-kumo-line px-3 py-2 font-mono text-sm">
|
|
127
|
+
<code ref={commandRef}>{update.command}</code>
|
|
128
|
+
</pre>
|
|
129
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
130
|
+
<Button variant="secondary" size="sm" onClick={() => copy(update.command)}>
|
|
131
|
+
Copy command
|
|
132
|
+
</Button>
|
|
133
|
+
<a className="text-kumo-link hover:underline" href={update.releasesUrl} target="_blank" rel="noopener noreferrer">
|
|
134
|
+
What's new<span className="sr-only"> (opens in a new tab)</span>
|
|
135
|
+
</a>
|
|
136
|
+
<Button
|
|
137
|
+
variant="ghost"
|
|
138
|
+
size="sm"
|
|
139
|
+
disabled={dismissing}
|
|
140
|
+
onClick={() => {
|
|
141
|
+
setDismissing(true);
|
|
142
|
+
void props.dismiss().finally(() => setDismissing(false));
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
Dismiss
|
|
146
|
+
</Button>
|
|
147
|
+
<span role="status" aria-live="polite" className="text-sm text-kumo-subtle">
|
|
148
|
+
{props.error ?? copied}
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The notice at the top of a pack page. */
|
|
156
|
+
export function UpdateNotice() {
|
|
157
|
+
const { update, dismiss, error } = useUpdate();
|
|
158
|
+
if (!update) return null;
|
|
159
|
+
return (
|
|
160
|
+
<div style={{ marginBottom: "1.5rem" }}>
|
|
161
|
+
<Banner title={`Coywolf Pack ${update.latest} is available`} description={<UpdateDetails update={update} dismiss={dismiss} error={error} />} />
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The dashboard card (EmDash lists it only while an update is waiting). */
|
|
167
|
+
export function UpdateWidget() {
|
|
168
|
+
const { status, loaded, update, dismiss, error } = useUpdate({ reloadAfterDismiss: true });
|
|
169
|
+
if (!loaded) return null;
|
|
170
|
+
if (!update) {
|
|
171
|
+
// Listed only while an update is waiting: an editor can't read the details (admins only); an admin just dismissed it.
|
|
172
|
+
return (
|
|
173
|
+
<p className="text-sm text-kumo-subtle">
|
|
174
|
+
{status ? "Dismissed. This card goes away the next time the dashboard loads." : "A Coywolf Pack update is waiting. An administrator can install it."}
|
|
175
|
+
</p>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return (
|
|
179
|
+
<div className="space-y-3 text-sm">
|
|
180
|
+
<h3 className="text-base font-semibold">Coywolf Pack {update.latest} is available</h3>
|
|
181
|
+
<UpdateDetails update={update} dismiss={dismiss} error={error} />
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A pack page with the notice above it. */
|
|
187
|
+
export function withUpdateNotice(Page: React.ComponentType): React.ComponentType {
|
|
188
|
+
function PageWithUpdateNotice() {
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
<UpdateNotice />
|
|
192
|
+
<Page />
|
|
193
|
+
</>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
PageWithUpdateNotice.displayName = `WithUpdateNotice(${Page.displayName ?? Page.name ?? "Page"})`;
|
|
197
|
+
return PageWithUpdateNotice;
|
|
198
|
+
}
|
package/src/core/features.ts
CHANGED
|
@@ -374,6 +374,16 @@ export async function readSiteSetting<T = unknown>(key: string, database: Databa
|
|
|
374
374
|
return settings ? { value: (settings.get(key) as T | undefined) ?? null } : null;
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
/**
|
|
378
|
+
* A registered setting as this isolate last read it, synchronously (undefined before any read),
|
|
379
|
+
* for EmDash's admin manifest like knownFeatures. The middleware reads the settings afresh for
|
|
380
|
+
* every manifest request, so it's current there.
|
|
381
|
+
*/
|
|
382
|
+
export function knownSiteSetting<T>(key: string): T | null | undefined {
|
|
383
|
+
if (!cached?.keys?.has(key)) return undefined;
|
|
384
|
+
return (cached.settings?.get(key) as T | undefined) ?? null;
|
|
385
|
+
}
|
|
386
|
+
|
|
377
387
|
/** Update a registered setting in this isolate's cache after writing it (other isolates see it within FEATURES_TTL_MS). */
|
|
378
388
|
export function rememberSiteSetting(key: string, value: unknown): void {
|
|
379
389
|
cached?.settings?.set(key, value);
|
package/src/core/module.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface WidgetDef {
|
|
|
35
35
|
id: string;
|
|
36
36
|
title: string;
|
|
37
37
|
size: "full" | "half" | "third";
|
|
38
|
+
/** Listed on the dashboard only while this returns true (read on every admin manifest request; must be synchronous). */
|
|
39
|
+
when?: () => boolean;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/** A scheduled job. `schedule` is cron syntax or @hourly/@daily/@weekly. */
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { composeHooks, hookCapabilities } from "./core/compose.js";
|
|
|
17
17
|
import { configureContentUrls } from "./core/content-url.js";
|
|
18
18
|
import { PLUGIN_ID, isOn, knownFeatures } from "./core/features.js";
|
|
19
19
|
import { featuresRoutes } from "./core/features-module.js";
|
|
20
|
-
import type { AdminPageDef, PackModule } from "./core/module.js";
|
|
20
|
+
import type { AdminPageDef, PackModule, WidgetDef } from "./core/module.js";
|
|
21
21
|
import { secretSettingsSchema } from "./core/secrets.js";
|
|
22
22
|
import { MODULES } from "./modules.js";
|
|
23
23
|
import type { CoywolfOptions } from "./options.js";
|
|
@@ -56,10 +56,13 @@ function buildModules(options: CoywolfOptions): PackModule[] {
|
|
|
56
56
|
return MODULES.map((factory) => factory(options)).filter((m): m is PackModule => m !== null);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** A widget as EmDash lists it (without the pack's `when`). */
|
|
60
|
+
const widgetEntry = ({ when: _when, ...widget }: WidgetDef) => widget;
|
|
61
|
+
|
|
59
62
|
function surfaces(modules: PackModule[]) {
|
|
60
63
|
return {
|
|
61
64
|
pages: sidebar(modules),
|
|
62
|
-
widgets: modules.flatMap((m) => m.widgets ?? []),
|
|
65
|
+
widgets: modules.flatMap((m) => m.widgets ?? []).map(widgetEntry),
|
|
63
66
|
blocks: modules.flatMap((m) => m.portableTextBlocks ?? []),
|
|
64
67
|
settingsSchema: secretSettingsSchema(modules.map((m) => m.settingsSchema)),
|
|
65
68
|
storage: Object.assign({}, ...modules.map((m) => m.storage ?? {})),
|
|
@@ -113,7 +116,12 @@ function liveAdmin<T extends object>(modules: PackModule[], base: T) {
|
|
|
113
116
|
},
|
|
114
117
|
widgets: {
|
|
115
118
|
enumerable: true,
|
|
116
|
-
get: () =>
|
|
119
|
+
get: () =>
|
|
120
|
+
modules
|
|
121
|
+
.filter(on)
|
|
122
|
+
.flatMap((m) => m.widgets ?? [])
|
|
123
|
+
.filter((w) => !w.when || w.when())
|
|
124
|
+
.map(widgetEntry),
|
|
117
125
|
},
|
|
118
126
|
}) as T & { pages: PackModule["adminPages"]; widgets: PackModule["widgets"] };
|
|
119
127
|
}
|
package/src/modules.ts
CHANGED
|
@@ -23,11 +23,14 @@ import { robotsMiddleware, robotsPack } from "./robots/pack.js";
|
|
|
23
23
|
import { reviewsPack } from "./reviews/pack.js";
|
|
24
24
|
import type { CoywolfOptions } from "./options.js";
|
|
25
25
|
import { videosCaptionsMiddleware, videosPack, videosSitemapMiddleware } from "./videos/pack.js";
|
|
26
|
+
import { updatesPack } from "./updates/pack.js";
|
|
26
27
|
import { wpImportPack } from "./wpImport/pack.js";
|
|
27
28
|
|
|
28
29
|
type Factory = (options: CoywolfOptions) => PackModule | null;
|
|
29
30
|
|
|
30
31
|
export const MODULES: Factory[] = [
|
|
32
|
+
// First on the Features page, where it's easy to find (no hooks or middleware, so its place changes nothing else).
|
|
33
|
+
(o) => (o.updates === false ? null : updatesPack()),
|
|
31
34
|
(o) => (o.backups ? backupsPack(o.backups) : null),
|
|
32
35
|
(o) => (o.redirects === false ? null : redirectsPack(o.redirects ?? {})),
|
|
33
36
|
// Before Headings & TOC: imported heading ids must be in place when anchors are stamped.
|
package/src/options.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface CoywolfOptions {
|
|
|
42
42
|
images?: import("./images/pack.js").ImagesOptions | false;
|
|
43
43
|
/** Page cache upkeep for Cloudflare Workers Cache (no options yet). `false` leaves the module out. */
|
|
44
44
|
pageCache?: Record<string, never> | false;
|
|
45
|
+
/** The daily check for a newer Coywolf Pack on npm (off until turned on on the Features page). `false` leaves the module out. */
|
|
46
|
+
updates?: Record<string, never> | false;
|
|
45
47
|
/** @deprecated Until 0.12.0 the Custom Blocks module was called Content Blocks; `contentBlocks: false` still leaves it out. */
|
|
46
48
|
contentBlocks?: Record<string, never> | false;
|
|
47
49
|
/** WordPress import tools (no options yet). `false` leaves the module out. */
|
package/src/pageCache/lib.ts
CHANGED
|
@@ -21,10 +21,12 @@ export const VERSION_OPTION = "plugin:coywolf-pack:pageCache:version";
|
|
|
21
21
|
*/
|
|
22
22
|
const PUBLIC_ROUTE = /^\/_emdash\/api\/plugins\/coywolf-pack\/(search\/|discovery\/public\/|videos\/(like|play|caption|embed|sitemap)(\/|$))/;
|
|
23
23
|
const PACK_API = "/_emdash/api/plugins/coywolf-pack/";
|
|
24
|
+
/** Admin writes that change nothing a page or a cache shows: no purge and no new settings generation. */
|
|
25
|
+
const NO_SETTINGS_CHANGE = /^\/_emdash\/api\/plugins\/coywolf-pack\/updates\//;
|
|
24
26
|
|
|
25
27
|
/** True when a request is a pack admin write whose success should purge the cache. */
|
|
26
28
|
export function purgesAfter(method: string, pathname: string): boolean {
|
|
27
|
-
return method === "POST" && pathname.startsWith(PACK_API) && !PUBLIC_ROUTE.test(pathname);
|
|
29
|
+
return method === "POST" && pathname.startsWith(PACK_API) && !PUBLIC_ROUTE.test(pathname) && !NO_SETTINGS_CHANGE.test(pathname);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export type PurgeScope = { purgeEverything: true } | { pathPrefixes: string[] } | { tags: string[] };
|
|
@@ -70,7 +72,7 @@ export function keepOffEdge(context: unknown): void {
|
|
|
70
72
|
export function purgeScope(pathname: string): PurgeScope | null {
|
|
71
73
|
const route = pathname.slice(PACK_API.length);
|
|
72
74
|
if (/^redirects\/(?!(list|test|removed)$)|^backups\/(rewind|undo)$/.test(route)) return { tags: [REDIRECTS_TAG] };
|
|
73
|
-
if (/^(redirects|backups|links)\//.test(route) || /^cache\/(media|warm|purge)(\/|$)/.test(route)) return null;
|
|
75
|
+
if (/^(redirects|backups|links|updates)\//.test(route) || /^cache\/(media|warm|purge)(\/|$)/.test(route)) return null;
|
|
74
76
|
// Stored image sizes: new uploads and crops are picked up after a short stopgap lifetime; a backfill run clears the cache itself once when it is done.
|
|
75
77
|
if (/^images\/variants\/(settings|run|bulk|estimate)$/.test(route)) return null;
|
|
76
78
|
// Videos admin reads (library, details, status, storage, settings, captions list) change nothing.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update check: once a day, ask npm which version of Coywolf Pack is the
|
|
3
|
+
* latest, and tell the admin when it's newer than the one this site runs.
|
|
4
|
+
* The request is npm's public dist-tags for the package; it carries nothing
|
|
5
|
+
* about the site.
|
|
6
|
+
*
|
|
7
|
+
* The answer is a plugin setting (an option row), registered with the
|
|
8
|
+
* switches' query, so EmDash's admin manifest can list the dashboard card
|
|
9
|
+
* only while an update is waiting (see updateCardVisible). A daily task
|
|
10
|
+
* keeps it current; the pack pages' notice also checks when it's a day old.
|
|
11
|
+
*/
|
|
12
|
+
import { isOn, knownFeatures, knownSiteSetting, registerSiteSetting, rememberSiteSetting } from "../core/features.js";
|
|
13
|
+
import { VERSION } from "../version.js";
|
|
14
|
+
import { DIST_TAGS_URL, RELEASES_URL, UPDATE_COMMAND, isNewer, parseVersion } from "./core.js";
|
|
15
|
+
|
|
16
|
+
export const UPDATE_STATE_SETTING = "updateState";
|
|
17
|
+
registerSiteSetting(UPDATE_STATE_SETTING);
|
|
18
|
+
|
|
19
|
+
const CHECK_EVERY_MS = 24 * 60 * 60_000;
|
|
20
|
+
/** After a failed check, wait this long before asking npm again. */
|
|
21
|
+
const RETRY_AFTER_MS = 6 * 60 * 60_000;
|
|
22
|
+
const TIMEOUT_MS = 5_000;
|
|
23
|
+
|
|
24
|
+
export interface UpdateState {
|
|
25
|
+
/** When npm last answered. */
|
|
26
|
+
checkedAt: string | null;
|
|
27
|
+
latest: string | null;
|
|
28
|
+
/** When the last check failed (cleared by a successful one). */
|
|
29
|
+
failedAt?: string | null;
|
|
30
|
+
/** The version an admin dismissed the notice for (site-wide; a newer version shows again). */
|
|
31
|
+
dismissed?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type UpdateStatus =
|
|
35
|
+
| { enabled: false; current: string }
|
|
36
|
+
| { enabled: true; current: string; latest: string | null; available: boolean; dismissed: boolean; checkedAt: string | null; releasesUrl: string; command: string };
|
|
37
|
+
|
|
38
|
+
type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
39
|
+
interface Ctx {
|
|
40
|
+
settings: { get<T>(key: string): Promise<T | null | undefined>; set(key: string, value: unknown): Promise<void> };
|
|
41
|
+
log: { warn(message: string, data?: unknown): void };
|
|
42
|
+
http?: { fetch: Fetch } | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const age = (at: string | null | undefined, now: number) => (at ? now - Date.parse(at) : Number.POSITIVE_INFINITY);
|
|
46
|
+
const EMPTY: UpdateState = { checkedAt: null, latest: null };
|
|
47
|
+
|
|
48
|
+
/** True when the state has an update the site hasn't dismissed. */
|
|
49
|
+
export function showsUpdate(state: UpdateState | null | undefined, current = VERSION): boolean {
|
|
50
|
+
return Boolean(state && isNewer(state.latest, current) && state.dismissed !== state.latest);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* For the admin manifest (synchronous): list the dashboard card only while the check is on and an
|
|
55
|
+
* undismissed update is waiting, as this isolate last read them (afresh for every manifest request).
|
|
56
|
+
*/
|
|
57
|
+
export function updateCardVisible(): boolean {
|
|
58
|
+
const features = knownFeatures();
|
|
59
|
+
if (!features || !isOn(features, "updates")) return false;
|
|
60
|
+
return showsUpdate(knownSiteSetting<UpdateState>(UPDATE_STATE_SETTING));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readState(ctx: Ctx): Promise<UpdateState> {
|
|
64
|
+
return (await ctx.settings.get<UpdateState>(UPDATE_STATE_SETTING).catch(() => null)) ?? { ...EMPTY };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function writeState(ctx: Ctx, state: UpdateState): Promise<void> {
|
|
68
|
+
await ctx.settings.set(UPDATE_STATE_SETTING, state);
|
|
69
|
+
rememberSiteSetting(UPDATE_STATE_SETTING, state);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function askNpm(ctx: Ctx): Promise<string> {
|
|
73
|
+
const doFetch = (ctx.http ? ctx.http.fetch.bind(ctx.http) : fetch) as Fetch;
|
|
74
|
+
const timeout = typeof AbortSignal !== "undefined" && "timeout" in AbortSignal ? AbortSignal.timeout(TIMEOUT_MS) : undefined;
|
|
75
|
+
const res = await doFetch(DIST_TAGS_URL, { headers: { Accept: "application/json" }, ...(timeout ? { signal: timeout } : {}) });
|
|
76
|
+
if (!res.ok) throw new Error(`npm answered ${res.status}`);
|
|
77
|
+
const tags = (await res.json()) as { latest?: unknown };
|
|
78
|
+
if (!parseVersion(tags.latest)) throw new Error("npm's answer had no latest version");
|
|
79
|
+
return String(tags.latest).trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let checking: Promise<UpdateState> | null = null;
|
|
83
|
+
|
|
84
|
+
/** The saved answer, or a new one when it's a day old (or six hours after a failure). */
|
|
85
|
+
export async function checkForUpdate(ctx: Ctx, now = Date.now()): Promise<UpdateState> {
|
|
86
|
+
const saved = await readState(ctx);
|
|
87
|
+
if (age(saved.checkedAt, now) < CHECK_EVERY_MS || age(saved.failedAt, now) < RETRY_AFTER_MS) return saved;
|
|
88
|
+
if (checking) return checking;
|
|
89
|
+
checking = (async () => {
|
|
90
|
+
let latest: string;
|
|
91
|
+
try {
|
|
92
|
+
latest = await askNpm(ctx);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
ctx.log.warn("Update check: couldn't ask npm", { error: error instanceof Error ? error.message : String(error) });
|
|
95
|
+
// Keep the last answer (re-read, so a dismissal made meanwhile isn't lost); try again later.
|
|
96
|
+
const current = await readState(ctx);
|
|
97
|
+
const state: UpdateState = { ...current, failedAt: new Date(now).toISOString() };
|
|
98
|
+
await writeState(ctx, state).catch(() => undefined);
|
|
99
|
+
return state;
|
|
100
|
+
}
|
|
101
|
+
// Re-read before writing, so a dismissal made while npm answered isn't lost.
|
|
102
|
+
const current = await readState(ctx);
|
|
103
|
+
const state: UpdateState = { ...current, checkedAt: new Date(now).toISOString(), latest, failedAt: null };
|
|
104
|
+
await writeState(ctx, state);
|
|
105
|
+
return state;
|
|
106
|
+
})().finally(() => {
|
|
107
|
+
checking = null;
|
|
108
|
+
});
|
|
109
|
+
return checking;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a newer Coywolf Pack is on npm. Nothing is asked while the check is off. */
|
|
113
|
+
export async function updateStatus(ctx: Ctx, enabled: boolean, now = Date.now()): Promise<UpdateStatus> {
|
|
114
|
+
if (!enabled) return { enabled: false, current: VERSION };
|
|
115
|
+
const state = await checkForUpdate(ctx, now);
|
|
116
|
+
const available = isNewer(state.latest, VERSION);
|
|
117
|
+
return {
|
|
118
|
+
enabled: true,
|
|
119
|
+
current: VERSION,
|
|
120
|
+
latest: state.latest,
|
|
121
|
+
available,
|
|
122
|
+
dismissed: available && state.dismissed === state.latest,
|
|
123
|
+
checkedAt: state.checkedAt,
|
|
124
|
+
releasesUrl: RELEASES_URL,
|
|
125
|
+
command: UPDATE_COMMAND,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Hide the notice (pack pages and the dashboard card) for this version, for everyone on the site. */
|
|
130
|
+
export async function dismissUpdate(ctx: Ctx, version: string): Promise<UpdateState> {
|
|
131
|
+
const state = await readState(ctx);
|
|
132
|
+
const next: UpdateState = { ...state, dismissed: version };
|
|
133
|
+
await writeState(ctx, next);
|
|
134
|
+
return next;
|
|
135
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update check (pure part): version comparison and the notice's fixed text.
|
|
3
|
+
* No imports, so `node --test` can load this file.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const PACKAGE_NAME = "@coywolf/emdash";
|
|
7
|
+
export const NPM_HOST = "registry.npmjs.org";
|
|
8
|
+
/** npm's dist-tags for the package: a few bytes (`{"latest":"0.35.14"}`). */
|
|
9
|
+
export const DIST_TAGS_URL = `https://${NPM_HOST}/-/package/%40coywolf%2Femdash/dist-tags`;
|
|
10
|
+
export const RELEASES_URL = "https://github.com/coywolf-llc/coywolf-pack/releases";
|
|
11
|
+
export const UPDATE_COMMAND = `npm install ${PACKAGE_NAME}@latest`;
|
|
12
|
+
|
|
13
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
14
|
+
|
|
15
|
+
/** A version npm could have given (x.y.z, optionally -prerelease and +build), or null. */
|
|
16
|
+
export function parseVersion(value: unknown): { core: [number, number, number]; pre: string | null } | null {
|
|
17
|
+
if (typeof value !== "string") return null;
|
|
18
|
+
const m = SEMVER.exec(value.trim());
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function comparePre(a: string, b: string): number {
|
|
24
|
+
const x = a.split(".");
|
|
25
|
+
const y = b.split(".");
|
|
26
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
27
|
+
if (x[i] === undefined) return -1;
|
|
28
|
+
if (y[i] === undefined) return 1;
|
|
29
|
+
const nx = /^\d+$/.test(x[i]) ? Number(x[i]) : null;
|
|
30
|
+
const ny = /^\d+$/.test(y[i]) ? Number(y[i]) : null;
|
|
31
|
+
if (nx !== null && ny !== null) {
|
|
32
|
+
if (nx !== ny) return nx < ny ? -1 : 1;
|
|
33
|
+
} else if (nx !== null) return -1;
|
|
34
|
+
else if (ny !== null) return 1;
|
|
35
|
+
else if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Semver order: -1, 0 or 1. Null when either isn't a version. */
|
|
41
|
+
export function compareVersions(a: string, b: string): number | null {
|
|
42
|
+
const x = parseVersion(a);
|
|
43
|
+
const y = parseVersion(b);
|
|
44
|
+
if (!x || !y) return null;
|
|
45
|
+
for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] < y.core[i] ? -1 : 1;
|
|
46
|
+
if (x.pre === y.pre) return 0;
|
|
47
|
+
// A release is newer than its prereleases.
|
|
48
|
+
if (x.pre === null) return 1;
|
|
49
|
+
if (y.pre === null) return -1;
|
|
50
|
+
return comparePre(x.pre, y.pre);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when `latest` is a newer version than `current`. */
|
|
54
|
+
export function isNewer(latest: string | null | undefined, current: string): boolean {
|
|
55
|
+
return typeof latest === "string" && compareVersions(latest, current) === 1;
|
|
56
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { definePluginRoute } from "emdash";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { ctxFeatures, isOn, registerFeatures } from "../core/features.js";
|
|
5
|
+
import type { PackModule } from "../core/module.js";
|
|
6
|
+
import { parseInput } from "../shared.js";
|
|
7
|
+
import { NPM_HOST, parseVersion } from "./core.js";
|
|
8
|
+
import { checkForUpdate, dismissUpdate, updateCardVisible, updateStatus } from "./check.js";
|
|
9
|
+
|
|
10
|
+
/** Off by default, like every feature: nothing asks npm until the site turns it on. */
|
|
11
|
+
const FEATURES = [
|
|
12
|
+
{
|
|
13
|
+
id: "updates",
|
|
14
|
+
label: "Update check",
|
|
15
|
+
description:
|
|
16
|
+
"Once a day, asks npm for the newest version of Coywolf Pack. When there's an update, a card on the dashboard and a notice on the pack's pages show the command to install it. It sends nothing about your site and never updates anything by itself.",
|
|
17
|
+
default: false,
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
registerFeatures(FEATURES);
|
|
21
|
+
|
|
22
|
+
export const UPDATES_CHECK_TASK = "updates-check";
|
|
23
|
+
|
|
24
|
+
export function updatesPack(): PackModule {
|
|
25
|
+
return {
|
|
26
|
+
id: "updates",
|
|
27
|
+
label: "Update check",
|
|
28
|
+
features: FEATURES,
|
|
29
|
+
routes: {
|
|
30
|
+
/** The running version and npm's latest (asked at most once a day). */
|
|
31
|
+
"updates/status": {
|
|
32
|
+
permission: "plugins:manage" as const,
|
|
33
|
+
// biome-ignore lint/suspicious/noExplicitAny: plugin context.
|
|
34
|
+
handler: async (ctx: any) => updateStatus(ctx, isOn(await ctxFeatures(ctx), "updates")),
|
|
35
|
+
},
|
|
36
|
+
/** Hide the notice for this version, for everyone on the site (a newer version shows again). */
|
|
37
|
+
"updates/dismiss": definePluginRoute({
|
|
38
|
+
permission: "plugins:manage",
|
|
39
|
+
methods: ["POST"],
|
|
40
|
+
request: { body: "json" },
|
|
41
|
+
handler: async (ctx) => {
|
|
42
|
+
const { version } = parseInput(z.object({ version: z.string().refine((v) => parseVersion(v) !== null, "Not a version") }), ctx.input);
|
|
43
|
+
const state = await dismissUpdate(ctx, version.trim());
|
|
44
|
+
return { dismissed: state.dismissed };
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
tasks: [{ name: UPDATES_CHECK_TASK, schedule: "@daily", handler: async (ctx) => void (await checkForUpdate(ctx)) }],
|
|
49
|
+
// Listed on the dashboard only while an update is waiting (see updateCardVisible).
|
|
50
|
+
widgets: [{ id: "update", title: "Coywolf Pack update", size: "full", when: updateCardVisible }],
|
|
51
|
+
capabilities: ["network:request"],
|
|
52
|
+
allowedHosts: [NPM_HOST],
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The pack's version (keep in step with package.json). */
|
|
2
|
-
export const VERSION = "0.35.
|
|
2
|
+
export const VERSION = "0.35.15";
|