Format
This commit is contained in:
@@ -52,7 +52,7 @@ pnpm --filter @emdash-cms/template-portfolio dev
|
|||||||
Available templates:
|
Available templates:
|
||||||
|
|
||||||
| Template | Filter Name |
|
| Template | Filter Name |
|
||||||
| --------- | ------------------------------ |
|
| --------- | -------------------------------- |
|
||||||
| Blog | `@emdash-cms/template-blog` |
|
| Blog | `@emdash-cms/template-blog` |
|
||||||
| Portfolio | `@emdash-cms/template-portfolio` |
|
| Portfolio | `@emdash-cms/template-portfolio` |
|
||||||
| Marketing | `@emdash-cms/template-marketing` |
|
| Marketing | `@emdash-cms/template-marketing` |
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"include": [
|
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts", "worker-configuration.d.ts"]
|
||||||
"src",
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"emdash-env.d.ts",
|
|
||||||
"worker-configuration.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,7 @@
|
|||||||
"compatibility_date": "2026-01-14",
|
"compatibility_date": "2026-01-14",
|
||||||
// disable_nodejs_process_v2 needed until unenv fix lands in Pages
|
// disable_nodejs_process_v2 needed until unenv fix lands in Pages
|
||||||
// See: https://github.com/withastro/astro/issues/14511
|
// See: https://github.com/withastro/astro/issues/14511
|
||||||
"compatibility_flags": [
|
"compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"],
|
||||||
"nodejs_compat",
|
|
||||||
"disable_nodejs_process_v2"
|
|
||||||
],
|
|
||||||
// Static assets served from dist/
|
// Static assets served from dist/
|
||||||
"assets": {
|
"assets": {
|
||||||
"directory": "./dist",
|
"directory": "./dist",
|
||||||
@@ -24,7 +21,7 @@
|
|||||||
"d1_databases": [
|
"d1_databases": [
|
||||||
{
|
{
|
||||||
"binding": "DB",
|
"binding": "DB",
|
||||||
"database_name": "emdash_db"
|
"database_name": "emdash_db",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// R2 bucket for media storage
|
// R2 bucket for media storage
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/base",
|
"extends": "astro/tsconfigs/base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": [
|
"types": ["node"]
|
||||||
"node"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||||
"src",
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"emdash-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
"name": "emdash-playground",
|
"name": "emdash-playground",
|
||||||
"compatibility_date": "2026-02-24",
|
"compatibility_date": "2026-02-24",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
"nodejs_compat"
|
|
||||||
],
|
|
||||||
// Custom entrypoint that exports EmDashPreviewDB
|
// Custom entrypoint that exports EmDashPreviewDB
|
||||||
"main": "./src/worker.ts",
|
"main": "./src/worker.ts",
|
||||||
"assets": {
|
"assets": {
|
||||||
@@ -21,9 +19,7 @@
|
|||||||
"migrations": [
|
"migrations": [
|
||||||
{
|
{
|
||||||
"tag": "v1",
|
"tag": "v1",
|
||||||
"new_sqlite_classes": [
|
"new_sqlite_classes": ["EmDashPreviewDB"],
|
||||||
"EmDashPreviewDB"
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"observability": {
|
"observability": {
|
||||||
|
|||||||
@@ -3,9 +3,5 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strictNullChecks": true
|
"strictNullChecks": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src/**/*", "astro.config.mjs", "emdash-env.d.ts"]
|
||||||
"src/**/*",
|
|
||||||
"astro.config.mjs",
|
|
||||||
"emdash-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/base",
|
"extends": "astro/tsconfigs/base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": [
|
"types": ["node"]
|
||||||
"node"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||||
"src",
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"emdash-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"include": [
|
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts", "worker-configuration.d.ts"]
|
||||||
"src",
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"emdash-env.d.ts",
|
|
||||||
"worker-configuration.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,7 @@
|
|||||||
// Custom entrypoint that exports EmDashPreviewDB
|
// Custom entrypoint that exports EmDashPreviewDB
|
||||||
"main": "./src/worker.ts",
|
"main": "./src/worker.ts",
|
||||||
"compatibility_date": "2026-01-14",
|
"compatibility_date": "2026-01-14",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"],
|
||||||
"nodejs_compat",
|
|
||||||
"disable_nodejs_process_v2"
|
|
||||||
],
|
|
||||||
"assets": {
|
"assets": {
|
||||||
"directory": "./dist",
|
"directory": "./dist",
|
||||||
},
|
},
|
||||||
@@ -24,9 +21,7 @@
|
|||||||
"migrations": [
|
"migrations": [
|
||||||
{
|
{
|
||||||
"tag": "v1",
|
"tag": "v1",
|
||||||
"new_sqlite_classes": [
|
"new_sqlite_classes": ["EmDashPreviewDB"],
|
||||||
"EmDashPreviewDB"
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"observability": {
|
"observability": {
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/base",
|
"extends": "astro/tsconfigs/base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": [
|
"types": ["node"]
|
||||||
"node"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||||
"src",
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"emdash-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -109,10 +109,10 @@ test.describe("Allowed Domains Settings", () => {
|
|||||||
await expect(successMsg).toBeVisible({ timeout: 5000 });
|
await expect(successMsg).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Clean up via API
|
// Clean up via API
|
||||||
await fetch(
|
await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(testDomain)}`, {
|
||||||
`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(testDomain)}`,
|
method: "DELETE",
|
||||||
{ method: "DELETE", headers },
|
headers,
|
||||||
).catch(() => {});
|
}).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("removes a domain via the UI", async ({ admin, page }) => {
|
test("removes a domain via the UI", async ({ admin, page }) => {
|
||||||
@@ -193,10 +193,10 @@ test.describe("Allowed Domains Settings", () => {
|
|||||||
await expect(page.locator(`.font-medium`, { hasText: testDomain })).toBeVisible();
|
await expect(page.locator(`.font-medium`, { hasText: testDomain })).toBeVisible();
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
await fetch(
|
await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(testDomain)}`, {
|
||||||
`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(testDomain)}`,
|
method: "DELETE",
|
||||||
{ method: "DELETE", headers },
|
headers,
|
||||||
).catch(() => {});
|
}).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("toggling enabled/disabled updates the domain", async ({ admin, page }) => {
|
test("toggling enabled/disabled updates the domain", async ({ admin, page }) => {
|
||||||
@@ -228,9 +228,9 @@ test.describe("Allowed Domains Settings", () => {
|
|||||||
await expect(statusMsg).toBeVisible({ timeout: 5000 });
|
await expect(statusMsg).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
await fetch(
|
await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(testDomain)}`, {
|
||||||
`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(testDomain)}`,
|
method: "DELETE",
|
||||||
{ method: "DELETE", headers },
|
headers,
|
||||||
).catch(() => {});
|
}).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -342,13 +342,10 @@ test.describe("Revisions", () => {
|
|||||||
const olderRevisionId = revisions[1].id;
|
const olderRevisionId = revisions[1].id;
|
||||||
|
|
||||||
// Restore via API
|
// Restore via API
|
||||||
const restoreRes = await fetch(
|
const restoreRes = await fetch(`${baseUrl}/_emdash/api/revisions/${olderRevisionId}/restore`, {
|
||||||
`${baseUrl}/_emdash/api/revisions/${olderRevisionId}/restore`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
expect(restoreRes.status).toBe(200);
|
expect(restoreRes.status).toBe(200);
|
||||||
|
|
||||||
// Get revision count after restore -- should have increased
|
// Get revision count after restore -- should have increased
|
||||||
|
|||||||
@@ -272,11 +272,7 @@ test.describe("Search", () => {
|
|||||||
// TODO: getSuggestions fails in dev mode -- needs investigation
|
// TODO: getSuggestions fails in dev mode -- needs investigation
|
||||||
await enableSearch(serverInfo, "posts");
|
await enableSearch(serverInfo, "posts");
|
||||||
|
|
||||||
const res = await apiRequest(
|
const res = await apiRequest(serverInfo, "GET", "/_emdash/api/search/suggest?q=Fir&limit=5");
|
||||||
serverInfo,
|
|
||||||
"GET",
|
|
||||||
"/_emdash/api/search/suggest?q=Fir&limit=5",
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|||||||
@@ -329,9 +329,7 @@ export function MarketplacePluginDetail({
|
|||||||
<h3 className="text-sm font-semibold mb-2">Version</h3>
|
<h3 className="text-sm font-semibold mb-2">Version</h3>
|
||||||
<div className="space-y-1 text-xs text-kumo-subtle">
|
<div className="space-y-1 text-xs text-kumo-subtle">
|
||||||
<div>v{latest.version}</div>
|
<div>v{latest.version}</div>
|
||||||
{latest.minEmDashVersion && (
|
{latest.minEmDashVersion && <div>Requires EmDash {latest.minEmDashVersion}</div>}
|
||||||
<div>Requires EmDash {latest.minEmDashVersion}</div>
|
|
||||||
)}
|
|
||||||
<div>Published {new Date(latest.publishedAt).toLocaleDateString()}</div>
|
<div>Published {new Date(latest.publishedAt).toLocaleDateString()}</div>
|
||||||
{latest.bundleSize > 0 && <div>{formatBytes(latest.bundleSize)}</div>}
|
{latest.bundleSize > 0 && <div>{formatBytes(latest.bundleSize)}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Button, Dialog, Input } from "@cloudflare/kumo";
|
import { Button, Dialog, Input } from "@cloudflare/kumo";
|
||||||
|
import type { Element } from "@emdash-cms/blocks";
|
||||||
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react";
|
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react";
|
||||||
import {
|
import {
|
||||||
TextB,
|
TextB,
|
||||||
@@ -41,7 +42,6 @@ import {
|
|||||||
type Icon,
|
type Icon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { X } from "@phosphor-icons/react";
|
import { X } from "@phosphor-icons/react";
|
||||||
import type { Element } from "@emdash-cms/blocks";
|
|
||||||
import { Extension, type Range } from "@tiptap/core";
|
import { Extension, type Range } from "@tiptap/core";
|
||||||
import CharacterCount from "@tiptap/extension-character-count";
|
import CharacterCount from "@tiptap/extension-character-count";
|
||||||
import Focus from "@tiptap/extension-focus";
|
import Focus from "@tiptap/extension-focus";
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
* interactions to the plugin's admin route and renders the returned blocks.
|
* interactions to the plugin's admin route and renders the returned blocks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CircleNotch, WarningCircle } from "@phosphor-icons/react";
|
|
||||||
import { BlockRenderer } from "@emdash-cms/blocks";
|
import { BlockRenderer } from "@emdash-cms/blocks";
|
||||||
import type { Block, BlockInteraction, BlockResponse } from "@emdash-cms/blocks";
|
import type { Block, BlockInteraction, BlockResponse } from "@emdash-cms/blocks";
|
||||||
|
import { CircleNotch, WarningCircle } from "@phosphor-icons/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { apiFetch, API_BASE } from "../lib/api/client.js";
|
import { apiFetch, API_BASE } from "../lib/api/client.js";
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
* interaction with page="widget:<widgetId>" to the plugin's admin route.
|
* interaction with page="widget:<widgetId>" to the plugin's admin route.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CircleNotch } from "@phosphor-icons/react";
|
|
||||||
import { BlockRenderer } from "@emdash-cms/blocks";
|
import { BlockRenderer } from "@emdash-cms/blocks";
|
||||||
import type { Block, BlockInteraction, BlockResponse } from "@emdash-cms/blocks";
|
import type { Block, BlockInteraction, BlockResponse } from "@emdash-cms/blocks";
|
||||||
|
import { CircleNotch } from "@phosphor-icons/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { apiFetch, API_BASE } from "../lib/api/client.js";
|
import { apiFetch, API_BASE } from "../lib/api/client.js";
|
||||||
|
|||||||
@@ -720,9 +720,7 @@ export function WordPressImport() {
|
|||||||
<div className="rounded-lg border bg-kumo-base p-12 text-center">
|
<div className="rounded-lg border bg-kumo-base p-12 text-center">
|
||||||
<Loader />
|
<Loader />
|
||||||
<p className="mt-4 text-kumo-subtle">Analyzing WordPress site...</p>
|
<p className="mt-4 text-kumo-subtle">Analyzing WordPress site...</p>
|
||||||
<p className="text-sm text-kumo-subtle">
|
<p className="text-sm text-kumo-subtle">Fetching content from the EmDash Exporter API.</p>
|
||||||
Fetching content from the EmDash Exporter API.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Button, Input } from "@cloudflare/kumo";
|
import { Button, Input } from "@cloudflare/kumo";
|
||||||
|
import type { Element } from "@emdash-cms/blocks";
|
||||||
import {
|
import {
|
||||||
DotsSixVertical,
|
DotsSixVertical,
|
||||||
Trash,
|
Trash,
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
Cube,
|
Cube,
|
||||||
ListBullets,
|
ListBullets,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type { Element } from "@emdash-cms/blocks";
|
|
||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
import type { NodeViewProps } from "@tiptap/react";
|
import type { NodeViewProps } from "@tiptap/react";
|
||||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ describe("sanitizeRedirectUrl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("allows paths with query strings", () => {
|
it("allows paths with query strings", () => {
|
||||||
expect(sanitizeRedirectUrl("/_emdash/admin?tab=settings")).toBe(
|
expect(sanitizeRedirectUrl("/_emdash/admin?tab=settings")).toBe("/_emdash/admin?tab=settings");
|
||||||
"/_emdash/admin?tab=settings",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows paths with hash fragments", () => {
|
it("allows paths with hash fragments", () => {
|
||||||
@@ -44,9 +42,7 @@ describe("sanitizeRedirectUrl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects data: scheme", () => {
|
it("rejects data: scheme", () => {
|
||||||
expect(sanitizeRedirectUrl("data:text/html,<script>alert(1)</script>")).toBe(
|
expect(sanitizeRedirectUrl("data:text/html,<script>alert(1)</script>")).toBe("/_emdash/admin");
|
||||||
"/_emdash/admin",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects backslash trick (/\\evil.com)", () => {
|
it("rejects backslash trick (/\\evil.com)", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Sun, Moon, Share, Check, Trash, CaretDown, Warning, Plus } from "@phosphor-icons/react";
|
|
||||||
import { BlockRenderer, validateBlocks } from "@emdash-cms/blocks";
|
import { BlockRenderer, validateBlocks } from "@emdash-cms/blocks";
|
||||||
import type { Block, BlockInteraction } from "@emdash-cms/blocks";
|
import type { Block, BlockInteraction } from "@emdash-cms/blocks";
|
||||||
|
import { Sun, Moon, Share, Check, Trash, CaretDown, Warning, Plus } from "@phosphor-icons/react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { blockCatalog } from "./block-defaults";
|
import { blockCatalog } from "./block-defaults";
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
* Do not import at config time.
|
* Do not import at config time.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
|
||||||
import type { AuthResult } from "emdash";
|
import type { AuthResult } from "emdash";
|
||||||
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for Cloudflare Access authentication
|
* Configuration for Cloudflare Access authentication
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
|
|
||||||
import type { MiddlewareHandler } from "astro";
|
import type { MiddlewareHandler } from "astro";
|
||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { Kysely } from "kysely";
|
|
||||||
import { runWithContext } from "emdash/request-context";
|
import { runWithContext } from "emdash/request-context";
|
||||||
|
import { Kysely } from "kysely";
|
||||||
import { ulid } from "ulidx";
|
import { ulid } from "ulidx";
|
||||||
|
|
||||||
import type { EmDashPreviewDB } from "./do-class.js";
|
import type { EmDashPreviewDB } from "./do-class.js";
|
||||||
|
|||||||
@@ -61,11 +61,7 @@ export class R2Storage implements Storage {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof EmDashStorageError) throw error;
|
if (error instanceof EmDashStorageError) throw error;
|
||||||
throw new EmDashStorageError(
|
throw new EmDashStorageError(`Failed to upload file: ${options.key}`, "UPLOAD_FAILED", error);
|
||||||
`Failed to upload file: ${options.key}`,
|
|
||||||
"UPLOAD_FAILED",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,11 +103,7 @@ export class R2Storage implements Storage {
|
|||||||
const object = await this.bucket.head(key);
|
const object = await this.bucket.head(key);
|
||||||
return object !== null;
|
return object !== null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new EmDashStorageError(
|
throw new EmDashStorageError(`Failed to check file existence: ${key}`, "HEAD_FAILED", error);
|
||||||
`Failed to check file existence: ${key}`,
|
|
||||||
"HEAD_FAILED",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -664,10 +664,7 @@ export async function handleTokenRevoke(
|
|||||||
|
|
||||||
if (row.token_type === "refresh") {
|
if (row.token_type === "refresh") {
|
||||||
// Revoke refresh token and all its access tokens
|
// Revoke refresh token and all its access tokens
|
||||||
await db
|
await db.deleteFrom("_emdash_oauth_tokens").where("refresh_token_hash", "=", hash).execute();
|
||||||
.deleteFrom("_emdash_oauth_tokens")
|
|
||||||
.where("refresh_token_hash", "=", hash)
|
|
||||||
.execute();
|
|
||||||
await db.deleteFrom("_emdash_oauth_tokens").where("token_hash", "=", hash).execute();
|
await db.deleteFrom("_emdash_oauth_tokens").where("token_hash", "=", hash).execute();
|
||||||
} else {
|
} else {
|
||||||
// Revoke just the access token
|
// Revoke just the access token
|
||||||
|
|||||||
@@ -236,11 +236,7 @@ export async function handleOAuthClientUpdate(
|
|||||||
updates.scopes = input.scopes ? JSON.stringify(input.scopes) : "";
|
updates.scopes = input.scopes ? JSON.stringify(input.scopes) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db.updateTable("_emdash_oauth_clients").set(updates).where("id", "=", clientId).execute();
|
||||||
.updateTable("_emdash_oauth_clients")
|
|
||||||
.set(updates)
|
|
||||||
.where("id", "=", clientId)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Fetch the updated row
|
// Fetch the updated row
|
||||||
const updated = await db
|
const updated = await db
|
||||||
|
|||||||
@@ -95,11 +95,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
|||||||
if (newStatus === "approved" && previousStatus !== "approved" && emdash.email) {
|
if (newStatus === "approved" && previousStatus !== "approved" && emdash.email) {
|
||||||
try {
|
try {
|
||||||
const adminBaseUrl = await getSiteBaseUrl(emdash.db, request);
|
const adminBaseUrl = await getSiteBaseUrl(emdash.db, request);
|
||||||
const content = await lookupContentAuthor(
|
const content = await lookupContentAuthor(emdash.db, updated.collection, updated.contentId);
|
||||||
emdash.db,
|
|
||||||
updated.collection,
|
|
||||||
updated.contentId,
|
|
||||||
);
|
|
||||||
if (content?.author) {
|
if (content?.author) {
|
||||||
await sendCommentNotification({
|
await sendCommentNotification({
|
||||||
email: emdash.email,
|
email: emdash.email,
|
||||||
|
|||||||
@@ -71,9 +71,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
// Get passkey name - prefer body.name, then check stored pending name
|
// Get passkey name - prefer body.name, then check stored pending name
|
||||||
let passKeyName: string | undefined = body.name ?? undefined;
|
let passKeyName: string | undefined = body.name ?? undefined;
|
||||||
if (!passKeyName) {
|
if (!passKeyName) {
|
||||||
const pending = await optionsRepo.get<{ name?: string }>(
|
const pending = await optionsRepo.get<{ name?: string }>(`emdash:passkey_pending:${user.id}`);
|
||||||
`emdash:passkey_pending:${user.id}`,
|
|
||||||
);
|
|
||||||
if (pending?.name) {
|
if (pending?.name) {
|
||||||
passKeyName = pending.name;
|
passKeyName = pending.name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
|
|||||||
const denied = requireOwnerPerm(user, authorId, "content:publish_own", "content:publish_any");
|
const denied = requireOwnerPerm(user, authorId, "content:publish_own", "content:publish_any");
|
||||||
if (denied) return denied;
|
if (denied) return denied;
|
||||||
|
|
||||||
const result = await emdash.handleContentSchedule(
|
const result = await emdash.handleContentSchedule(collection, resolvedId ?? id, body.scheduledAt);
|
||||||
collection,
|
|
||||||
resolvedId ?? id,
|
|
||||||
body.scheduledAt,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) return unwrapResult(result);
|
if (!result.success) return unwrapResult(result);
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import mime from "mime/lite";
|
|
||||||
import { parseWxrString, SchemaRegistry, type WxrData } from "emdash";
|
import { parseWxrString, SchemaRegistry, type WxrData } from "emdash";
|
||||||
|
import mime from "mime/lite";
|
||||||
|
|
||||||
import { requirePerm } from "#api/authorize.js";
|
import { requirePerm } from "#api/authorize.js";
|
||||||
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import mime from "mime/lite";
|
|
||||||
import { MediaRepository, computeContentHash } from "emdash";
|
import { MediaRepository, computeContentHash } from "emdash";
|
||||||
|
import mime from "mime/lite";
|
||||||
import { ulid } from "ulidx";
|
import { ulid } from "ulidx";
|
||||||
|
|
||||||
import { requirePerm } from "#api/authorize.js";
|
import { requirePerm } from "#api/authorize.js";
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ export const POST: APIRoute = async ({ params, locals }) => {
|
|||||||
const { emdash, user } = locals;
|
const { emdash, user } = locals;
|
||||||
const revisionId = params.revisionId!;
|
const revisionId = params.revisionId!;
|
||||||
|
|
||||||
if (
|
if (!emdash?.handleRevisionRestore || !emdash?.handleRevisionGet || !emdash?.handleContentGet) {
|
||||||
!emdash?.handleRevisionRestore ||
|
|
||||||
!emdash?.handleRevisionGet ||
|
|
||||||
!emdash?.handleContentGet
|
|
||||||
) {
|
|
||||||
return apiError("NOT_CONFIGURED", "EmDash not configured", 500);
|
return apiError("NOT_CONFIGURED", "EmDash not configured", 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,11 +57,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
|
|||||||
// Update sort_order for each widget
|
// Update sort_order for each widget
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
body.widgetIds.map((id, index) =>
|
body.widgetIds.map((id, index) =>
|
||||||
db
|
db.updateTable("_emdash_widgets").set({ sort_order: index }).where("id", "=", id).execute(),
|
||||||
.updateTable("_emdash_widgets")
|
|
||||||
.set({ sort_order: index })
|
|
||||||
.where("id", "=", id)
|
|
||||||
.execute(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -418,9 +418,7 @@ export const publishCommand = defineCommand({
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
consola.error(
|
consola.error("No dist/ directory found. Run `emdash plugin bundle` first or use --build.");
|
||||||
"No dist/ directory found. Run `emdash plugin bundle` first or use --build.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,9 +135,7 @@ export const seedCommand = defineCommand({
|
|||||||
const seedPath = await resolveSeedPath(cwd, args.path);
|
const seedPath = await resolveSeedPath(cwd, args.path);
|
||||||
if (!seedPath) {
|
if (!seedPath) {
|
||||||
consola.error("No seed file found");
|
consola.error("No seed file found");
|
||||||
consola.info(
|
consola.info("Provide a path, create .emdash/seed.json, or set emdash.seed in package.json");
|
||||||
"Provide a path, create .emdash/seed.json, or set emdash.seed in package.json",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// ── Device code polling tracking ─────────────────────────────────
|
// ── Device code polling tracking ─────────────────────────────────
|
||||||
await db.schema
|
await db.schema.alterTable("_emdash_device_codes").addColumn("last_polled_at", "text").execute();
|
||||||
.alterTable("_emdash_device_codes")
|
|
||||||
.addColumn("last_polled_at", "text")
|
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function down(db: Kysely<unknown>): Promise<void> {
|
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
* The handlers instance is passed per-request via authInfo on the transport.
|
* The handlers instance is passed per-request via authInfo on the transport.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
import type { Permission, RoleLevel } from "@emdash-cms/auth";
|
import type { Permission, RoleLevel } from "@emdash-cms/auth";
|
||||||
import { canActOnOwn, Role } from "@emdash-cms/auth";
|
import { canActOnOwn, Role } from "@emdash-cms/auth";
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { EmDashHandlers } from "../astro/types.js";
|
import type { EmDashHandlers } from "../astro/types.js";
|
||||||
|
|||||||
@@ -103,11 +103,7 @@ export class LocalStorage implements Storage {
|
|||||||
size: buffer.length,
|
size: buffer.length,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new EmDashStorageError(
|
throw new EmDashStorageError(`Failed to upload file: ${options.key}`, "UPLOAD_FAILED", error);
|
||||||
`Failed to upload file: ${options.key}`,
|
|
||||||
"UPLOAD_FAILED",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,11 +97,7 @@ export class S3Storage implements Storage {
|
|||||||
size: body.length,
|
size: body.length,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new EmDashStorageError(
|
throw new EmDashStorageError(`Failed to upload file: ${options.key}`, "UPLOAD_FAILED", error);
|
||||||
`Failed to upload file: ${options.key}`,
|
|
||||||
"UPLOAD_FAILED",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,11 +162,7 @@ export class S3Storage implements Storage {
|
|||||||
if (hasErrorName(error) && error.name === "NotFound") {
|
if (hasErrorName(error) && error.name === "NotFound") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
throw new EmDashStorageError(
|
throw new EmDashStorageError(`Failed to check file existence: ${key}`, "HEAD_FAILED", error);
|
||||||
`Failed to check file existence: ${key}`,
|
|
||||||
"HEAD_FAILED",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,11 +172,7 @@ describe("Device Flow: Full Lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle denied authorization", async () => {
|
it("should handle denied authorization", async () => {
|
||||||
const codeResult = await handleDeviceCodeRequest(
|
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
|
||||||
db,
|
|
||||||
{},
|
|
||||||
"https://example.com/_emdash/device",
|
|
||||||
);
|
|
||||||
expect(codeResult.success).toBe(true);
|
expect(codeResult.success).toBe(true);
|
||||||
if (!codeResult.success) return;
|
if (!codeResult.success) return;
|
||||||
|
|
||||||
@@ -199,11 +195,7 @@ describe("Device Flow: Full Lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should normalize user codes (strip hyphens, case-insensitive)", async () => {
|
it("should normalize user codes (strip hyphens, case-insensitive)", async () => {
|
||||||
const codeResult = await handleDeviceCodeRequest(
|
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
|
||||||
db,
|
|
||||||
{},
|
|
||||||
"https://example.com/_emdash/device",
|
|
||||||
);
|
|
||||||
expect(codeResult.success).toBe(true);
|
expect(codeResult.success).toBe(true);
|
||||||
if (!codeResult.success) return;
|
if (!codeResult.success) return;
|
||||||
|
|
||||||
@@ -263,11 +255,7 @@ describe("Device Token Exchange: Error Cases", () => {
|
|||||||
describe("Token Refresh", () => {
|
describe("Token Refresh", () => {
|
||||||
it("should exchange a refresh token for a new access token", async () => {
|
it("should exchange a refresh token for a new access token", async () => {
|
||||||
// Complete a device flow first to get tokens
|
// Complete a device flow first to get tokens
|
||||||
const codeResult = await handleDeviceCodeRequest(
|
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
|
||||||
db,
|
|
||||||
{},
|
|
||||||
"https://example.com/_emdash/device",
|
|
||||||
);
|
|
||||||
expect(codeResult.success).toBe(true);
|
expect(codeResult.success).toBe(true);
|
||||||
if (!codeResult.success) return;
|
if (!codeResult.success) return;
|
||||||
|
|
||||||
@@ -330,11 +318,7 @@ describe("Token Refresh", () => {
|
|||||||
describe("Token Revoke", () => {
|
describe("Token Revoke", () => {
|
||||||
it("should revoke an access token", async () => {
|
it("should revoke an access token", async () => {
|
||||||
// Get tokens via device flow
|
// Get tokens via device flow
|
||||||
const codeResult = await handleDeviceCodeRequest(
|
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
|
||||||
db,
|
|
||||||
{},
|
|
||||||
"https://example.com/_emdash/device",
|
|
||||||
);
|
|
||||||
if (!codeResult.success) return;
|
if (!codeResult.success) return;
|
||||||
|
|
||||||
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
||||||
@@ -365,11 +349,7 @@ describe("Token Revoke", () => {
|
|||||||
|
|
||||||
it("should revoke a refresh token and its access tokens", async () => {
|
it("should revoke a refresh token and its access tokens", async () => {
|
||||||
// Get tokens via device flow
|
// Get tokens via device flow
|
||||||
const codeResult = await handleDeviceCodeRequest(
|
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
|
||||||
db,
|
|
||||||
{},
|
|
||||||
"https://example.com/_emdash/device",
|
|
||||||
);
|
|
||||||
if (!codeResult.success) return;
|
if (!codeResult.success) return;
|
||||||
|
|
||||||
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
* authInfo to simulate different users and roles.
|
* authInfo to simulate different users and roles.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
||||||
import { Role } from "@emdash-cms/auth";
|
import { Role } from "@emdash-cms/auth";
|
||||||
import type { RoleLevel } from "@emdash-cms/auth";
|
import type { RoleLevel } from "@emdash-cms/auth";
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { EmDashHandlers } from "../../../src/astro/types.js";
|
import type { EmDashHandlers } from "../../../src/astro/types.js";
|
||||||
|
|||||||
@@ -77,10 +77,7 @@ describe("normalizeMediaValue", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to external for internal URL when local provider unavailable", async () => {
|
it("falls back to external for internal URL when local provider unavailable", async () => {
|
||||||
const result = await normalizeMediaValue(
|
const result = await normalizeMediaValue("/_emdash/api/media/file/01ABC.jpg", getProvider({}));
|
||||||
"/_emdash/api/media/file/01ABC.jpg",
|
|
||||||
getProvider({}),
|
|
||||||
);
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
provider: "external",
|
provider: "external",
|
||||||
id: "",
|
id: "",
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ The `"."` export has the descriptor. The `"./sandbox"` export has the implementa
|
|||||||
Each feature is optional. Add only what your plugin needs:
|
Each feature is optional. Add only what your plugin needs:
|
||||||
|
|
||||||
| Feature | Where | Standard | Native | Purpose |
|
| Feature | Where | Standard | Native | Purpose |
|
||||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
| ------------------- | ---------------------------- | -------- | ------ | ----------------------------------------------------- |
|
||||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ definePlugin({
|
|||||||
## Route URLs
|
## Route URLs
|
||||||
|
|
||||||
| Plugin ID | Route Name | URL |
|
| Plugin ID | Route Name | URL |
|
||||||
| --------- | --------------- | ------------------------------------------ |
|
| --------- | --------------- | ---------------------------------------- |
|
||||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ This skill maps WordPress concepts to their EmDash equivalents for plugin portin
|
|||||||
### Content & Data
|
### Content & Data
|
||||||
|
|
||||||
| WordPress | EmDash | Notes |
|
| WordPress | EmDash | Notes |
|
||||||
| ----------------------- | ------------------------------------------- | --------------------------------------------- |
|
| ----------------------- | ----------------------------------------- | --------------------------------------------- |
|
||||||
| `register_post_type()` | `SchemaRegistry.createCollection()` | Via Admin API or seed file |
|
| `register_post_type()` | `SchemaRegistry.createCollection()` | Via Admin API or seed file |
|
||||||
| `register_taxonomy()` | `_emdash_taxonomy_defs` table | Hierarchical or flat, attached to collections |
|
| `register_taxonomy()` | `_emdash_taxonomy_defs` table | Hierarchical or flat, attached to collections |
|
||||||
| `register_meta()` / ACF | Collection fields via SchemaRegistry | All become typed schema fields |
|
| `register_meta()` / ACF | Collection fields via SchemaRegistry | All become typed schema fields |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Implement CMS-driven features: site settings, menus, taxonomies, and widgets.
|
|||||||
Map WordPress customizer values to EmDash site settings:
|
Map WordPress customizer values to EmDash site settings:
|
||||||
|
|
||||||
| WP Customizer Setting | EmDash Site Setting |
|
| WP Customizer Setting | EmDash Site Setting |
|
||||||
| --------------------- | --------------------- |
|
| --------------------- | ------------------- |
|
||||||
| Site Title | `title` |
|
| Site Title | `title` |
|
||||||
| Tagline | `tagline` |
|
| Tagline | `tagline` |
|
||||||
| Site Icon | `favicon` |
|
| Site Icon | `favicon` |
|
||||||
|
|||||||
@@ -901,7 +901,7 @@ const { entry: page } = await getEmDashEntry("pages", "about");
|
|||||||
### Key Differences
|
### Key Differences
|
||||||
|
|
||||||
| Aspect | Astro Collections | EmDash Collections |
|
| Aspect | Astro Collections | EmDash Collections |
|
||||||
| ---------------------- | ------------------------------- | ----------------------------------------------- |
|
| ---------------------- | ------------------------------- | ------------------------------------------- |
|
||||||
| **Config file** | `src/content.config.ts` | `src/live.config.ts` |
|
| **Config file** | `src/content.config.ts` | `src/live.config.ts` |
|
||||||
| **Schema definition** | In config file with Zod | In EmDash admin UI or seed file |
|
| **Schema definition** | In config file with Zod | In EmDash admin UI or seed file |
|
||||||
| **Data source** | Files, APIs, custom loaders | SQLite database |
|
| **Data source** | Files, APIs, custom loaders | SQLite database |
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ WordPress hooks don't have direct equivalents. Most hook functionality becomes:
|
|||||||
3. **Build-time logic** - In Astro config or components
|
3. **Build-time logic** - In Astro config or components
|
||||||
|
|
||||||
| WP Hook | EmDash Approach |
|
| WP Hook | EmDash Approach |
|
||||||
| -------------------- | ------------------------------------------ |
|
| -------------------- | ---------------------------------------- |
|
||||||
| `wp_head` | Add to `<head>` in layout |
|
| `wp_head` | Add to `<head>` in layout |
|
||||||
| `wp_footer` | Add before `</body>` in layout |
|
| `wp_footer` | Add before `</body>` in layout |
|
||||||
| `the_content` filter | PortableText components |
|
| `the_content` filter | PortableText components |
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ The `"."` export has the descriptor. The `"./sandbox"` export has the implementa
|
|||||||
Each feature is optional. Add only what your plugin needs:
|
Each feature is optional. Add only what your plugin needs:
|
||||||
|
|
||||||
| Feature | Where | Standard | Native | Purpose |
|
| Feature | Where | Standard | Native | Purpose |
|
||||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
| ------------------- | ---------------------------- | -------- | ------ | ----------------------------------------------------- |
|
||||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ definePlugin({
|
|||||||
## Route URLs
|
## Route URLs
|
||||||
|
|
||||||
| Plugin ID | Route Name | URL |
|
| Plugin ID | Route Name | URL |
|
||||||
| --------- | --------------- | ------------------------------------------ |
|
| --------- | --------------- | ---------------------------------------- |
|
||||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/base",
|
"extends": "astro/tsconfigs/base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": [
|
"types": ["node"]
|
||||||
"node"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||||
"src",
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"emdash-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -262,7 +262,7 @@ The `"."` export has the descriptor. The `"./sandbox"` export has the implementa
|
|||||||
Each feature is optional. Add only what your plugin needs:
|
Each feature is optional. Add only what your plugin needs:
|
||||||
|
|
||||||
| Feature | Where | Standard | Native | Purpose |
|
| Feature | Where | Standard | Native | Purpose |
|
||||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
| ------------------- | ---------------------------- | -------- | ------ | ----------------------------------------------------- |
|
||||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ definePlugin({
|
|||||||
## Route URLs
|
## Route URLs
|
||||||
|
|
||||||
| Plugin ID | Route Name | URL |
|
| Plugin ID | Route Name | URL |
|
||||||
| --------- | --------------- | ------------------------------------------ |
|
| --------- | --------------- | ---------------------------------------- |
|
||||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/base",
|
"extends": "astro/tsconfigs/base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": [
|
"types": ["node"]
|
||||||
"node"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||||
"src",
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"emdash-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
"name": "my-emdash-site",
|
"name": "my-emdash-site",
|
||||||
"compatibility_date": "2026-02-24",
|
"compatibility_date": "2026-02-24",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
"nodejs_compat"
|
|
||||||
],
|
|
||||||
"assets": {
|
"assets": {
|
||||||
"directory": "./dist",
|
"directory": "./dist",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ The `"."` export has the descriptor. The `"./sandbox"` export has the implementa
|
|||||||
Each feature is optional. Add only what your plugin needs:
|
Each feature is optional. Add only what your plugin needs:
|
||||||
|
|
||||||
| Feature | Where | Standard | Native | Purpose |
|
| Feature | Where | Standard | Native | Purpose |
|
||||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
| ------------------- | ---------------------------- | -------- | ------ | ----------------------------------------------------- |
|
||||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ definePlugin({
|
|||||||
## Route URLs
|
## Route URLs
|
||||||
|
|
||||||
| Plugin ID | Route Name | URL |
|
| Plugin ID | Route Name | URL |
|
||||||
| --------- | --------------- | ------------------------------------------ |
|
| --------- | --------------- | ---------------------------------------- |
|
||||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/base",
|
"extends": "astro/tsconfigs/base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": [
|
"types": ["node"]
|
||||||
"node"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||||
"src",
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"emdash-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -262,7 +262,7 @@ The `"."` export has the descriptor. The `"./sandbox"` export has the implementa
|
|||||||
Each feature is optional. Add only what your plugin needs:
|
Each feature is optional. Add only what your plugin needs:
|
||||||
|
|
||||||
| Feature | Where | Standard | Native | Purpose |
|
| Feature | Where | Standard | Native | Purpose |
|
||||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
| ------------------- | ---------------------------- | -------- | ------ | ----------------------------------------------------- |
|
||||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ definePlugin({
|
|||||||
## Route URLs
|
## Route URLs
|
||||||
|
|
||||||
| Plugin ID | Route Name | URL |
|
| Plugin ID | Route Name | URL |
|
||||||
| --------- | --------------- | ------------------------------------------ |
|
| --------- | --------------- | ---------------------------------------- |
|
||||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ The `"."` export has the descriptor. The `"./sandbox"` export has the implementa
|
|||||||
Each feature is optional. Add only what your plugin needs:
|
Each feature is optional. Add only what your plugin needs:
|
||||||
|
|
||||||
| Feature | Where | Standard | Native | Purpose |
|
| Feature | Where | Standard | Native | Purpose |
|
||||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
| ------------------- | ---------------------------- | -------- | ------ | ----------------------------------------------------- |
|
||||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ definePlugin({
|
|||||||
## Route URLs
|
## Route URLs
|
||||||
|
|
||||||
| Plugin ID | Route Name | URL |
|
| Plugin ID | Route Name | URL |
|
||||||
| --------- | --------------- | ------------------------------------------ |
|
| --------- | --------------- | ---------------------------------------- |
|
||||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ The `"."` export has the descriptor. The `"./sandbox"` export has the implementa
|
|||||||
Each feature is optional. Add only what your plugin needs:
|
Each feature is optional. Add only what your plugin needs:
|
||||||
|
|
||||||
| Feature | Where | Standard | Native | Purpose |
|
| Feature | Where | Standard | Native | Purpose |
|
||||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
| ------------------- | ---------------------------- | -------- | ------ | ----------------------------------------------------- |
|
||||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ definePlugin({
|
|||||||
## Route URLs
|
## Route URLs
|
||||||
|
|
||||||
| Plugin ID | Route Name | URL |
|
| Plugin ID | Route Name | URL |
|
||||||
| --------- | --------------- | ------------------------------------------ |
|
| --------- | --------------- | ---------------------------------------- |
|
||||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ The `"."` export has the descriptor. The `"./sandbox"` export has the implementa
|
|||||||
Each feature is optional. Add only what your plugin needs:
|
Each feature is optional. Add only what your plugin needs:
|
||||||
|
|
||||||
| Feature | Where | Standard | Native | Purpose |
|
| Feature | Where | Standard | Native | Purpose |
|
||||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
| ------------------- | ---------------------------- | -------- | ------ | ----------------------------------------------------- |
|
||||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ definePlugin({
|
|||||||
## Route URLs
|
## Route URLs
|
||||||
|
|
||||||
| Plugin ID | Route Name | URL |
|
| Plugin ID | Route Name | URL |
|
||||||
| --------- | --------------- | ------------------------------------------ |
|
| --------- | --------------- | ---------------------------------------- |
|
||||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ The `"."` export has the descriptor. The `"./sandbox"` export has the implementa
|
|||||||
Each feature is optional. Add only what your plugin needs:
|
Each feature is optional. Add only what your plugin needs:
|
||||||
|
|
||||||
| Feature | Where | Standard | Native | Purpose |
|
| Feature | Where | Standard | Native | Purpose |
|
||||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
| ------------------- | ---------------------------- | -------- | ------ | ----------------------------------------------------- |
|
||||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ definePlugin({
|
|||||||
## Route URLs
|
## Route URLs
|
||||||
|
|
||||||
| Plugin ID | Route Name | URL |
|
| Plugin ID | Route Name | URL |
|
||||||
| --------- | --------------- | ------------------------------------------ |
|
| --------- | --------------- | ---------------------------------------- |
|
||||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/base",
|
"extends": "astro/tsconfigs/base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": [
|
"types": ["node"]
|
||||||
"node"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||||
"src",
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"emdash-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
"name": "my-emdash-site",
|
"name": "my-emdash-site",
|
||||||
"compatibility_date": "2026-02-24",
|
"compatibility_date": "2026-02-24",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
"nodejs_compat"
|
|
||||||
],
|
|
||||||
"assets": {
|
"assets": {
|
||||||
"directory": "./dist",
|
"directory": "./dist",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ The `"."` export has the descriptor. The `"./sandbox"` export has the implementa
|
|||||||
Each feature is optional. Add only what your plugin needs:
|
Each feature is optional. Add only what your plugin needs:
|
||||||
|
|
||||||
| Feature | Where | Standard | Native | Purpose |
|
| Feature | Where | Standard | Native | Purpose |
|
||||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
| ------------------- | ---------------------------- | -------- | ------ | ----------------------------------------------------- |
|
||||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ definePlugin({
|
|||||||
## Route URLs
|
## Route URLs
|
||||||
|
|
||||||
| Plugin ID | Route Name | URL |
|
| Plugin ID | Route Name | URL |
|
||||||
| --------- | --------------- | ------------------------------------------ |
|
| --------- | --------------- | ---------------------------------------- |
|
||||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/base",
|
"extends": "astro/tsconfigs/base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": [
|
"types": ["node"]
|
||||||
"node"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||||
"src",
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"emdash-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user