@calmify/next 0.0.0-stage → 1.0.2

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 CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # @calmify/next
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Receive articles from Calmify in a Next.js (App Router) site.
4
+
5
+ 1. `npm install @calmify/next`
6
+ 2. Create `app/api/calmify/route.ts`:
7
+ ```ts
8
+ import { createCalmifyHandler } from "@calmify/next";
9
+ export const runtime = "nodejs";
10
+ export const { GET, POST } = createCalmifyHandler({
11
+ findByKey: async (key) => /* look up a post saved with this key */ null,
12
+ save: async (post) => { /* save to your DB/CMS, idempotent on post.idempotencyKey */ return { id: 1, url: `https://www.yoursite.com/blog/${post.slug}` }; },
13
+ });
14
+ ```
15
+ 3. `npx calmify-pair` once to generate `CALMIFY_API_KEY`; add it to your hosting env and deploy.
16
+ 4. In Calmify: Websites → Connection → Connect, then `npx calmify-pair CALM-XXXX-XXXX --endpoint https://www.yoursite.com/api/calmify`.
17
+
18
+ Full guide: https://calmifye.vercel.app/docs/connect
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // npx calmify-pair CALM-XXXX-XXXX [--endpoint https://www.yoursite.com/api/calmify]
3
+ // Needs CALMIFY_API_KEY in the environment (or .env.local). On success it
4
+ // prints CALMIFY_ASSET_HOST and appends it to .env.local.
5
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
6
+ import { randomBytes } from "node:crypto";
7
+
8
+ const args = process.argv.slice(2);
9
+ const code = args.find((a) => !a.startsWith("--"));
10
+ const opt = (n) => { const i = args.indexOf("--" + n); return i >= 0 ? args[i + 1] : undefined; };
11
+ const envFile = ".env.local";
12
+ const fileEnv = existsSync(envFile) ? Object.fromEntries(readFileSync(envFile, "utf8").split("\n").map((l) => l.match(/^([A-Z_]+)=(.*)$/)).filter(Boolean).map((m) => [m[1], m[2]])) : {};
13
+ let key = process.env.CALMIFY_API_KEY || fileEnv.CALMIFY_API_KEY;
14
+ if (!key) {
15
+ key = randomBytes(32).toString("hex");
16
+ appendFileSync(envFile, `\nCALMIFY_API_KEY=${key}\n`);
17
+ console.log("Generated CALMIFY_API_KEY in .env.local. Add it to your hosting environment too, redeploy, then run this again.");
18
+ process.exit(0);
19
+ }
20
+ let endpoint = opt("endpoint") || process.env.CALMIFY_ENDPOINT || fileEnv.CALMIFY_ENDPOINT;
21
+ const calmify = (opt("calmify") || "https://calmifye.vercel.app").replace(/\/+$/, "");
22
+ if (!code || !endpoint) {
23
+ console.log("Usage: npx calmify-pair CALM-XXXX-XXXX --endpoint https://www.yoursite.com/api/calmify");
24
+ process.exit(1);
25
+ }
26
+ for (let attempt = 0; attempt < 2; attempt++) {
27
+ console.log("Pairing " + endpoint + " …");
28
+ const res = await fetch(calmify + "/api/connect/pair", {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
31
+ body: JSON.stringify({ code, endpoint, key, receiver: "nextjs" }),
32
+ });
33
+ const body = await res.json().catch(() => ({}));
34
+ if (res.ok) {
35
+ appendFileSync(envFile, `\nCALMIFY_ASSET_HOST=${body.data.assetHost}\n`);
36
+ console.log(`Connected to Calmify website "${body.data.website}".`);
37
+ console.log(`Set CALMIFY_ASSET_HOST=${body.data.assetHost} in your hosting environment too (added to .env.local).`);
38
+ process.exit(0);
39
+ }
40
+ const suggested = body?.error?.fields?.suggestedEndpoint;
41
+ if (attempt === 0 && suggested) {
42
+ console.log("The site answers at " + suggested + ". Trying that address.");
43
+ endpoint = suggested;
44
+ continue;
45
+ }
46
+ console.error(body?.error?.message ?? `Pairing failed (HTTP ${res.status}).`);
47
+ process.exit(1);
48
+ }
package/index.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ export type CalmifyPost = {
2
+ idempotencyKey: string;
3
+ title: string;
4
+ slug: string;
5
+ /** Sanitized HTML. */
6
+ contentHtml: string;
7
+ category: string;
8
+ metaDescription: string;
9
+ metaKeywords: string;
10
+ /** Cover image bytes (PNG/JPEG/WebP, checked) or null. */
11
+ image: { bytes: Uint8Array; ext: "png" | "jpg" | "webp"; alt: string } | null;
12
+ };
13
+ export type SaveResult = { id: string | number; url: string };
14
+ export function createCalmifyHandler(options: {
15
+ /** Return the saved post's id and public URL. Must be idempotent on idempotencyKey. */
16
+ save: (post: CalmifyPost) => Promise<SaveResult>;
17
+ /** Return the existing post for a key, or null. */
18
+ findByKey: (idempotencyKey: string) => Promise<SaveResult | null>;
19
+ }): { GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> };
package/index.js ADDED
@@ -0,0 +1,89 @@
1
+ // @calmify/next: Calmify receiver for the App Router.
2
+ //
3
+ // // app/api/calmify/route.ts
4
+ // import { createCalmifyHandler } from "@calmify/next";
5
+ // export const runtime = "nodejs";
6
+ // export const { GET, POST } = createCalmifyHandler({ save, findByKey });
7
+ //
8
+ // Env: CALMIFY_API_KEY (any 64-char random hex), CALMIFY_ASSET_HOST (set by
9
+ // `npx calmify-pair`). GET answers Calmify's connection check; POST
10
+ // receives a post. You store it (your DB/CMS) in `save`.
11
+ import { createHash, timingSafeEqual } from "node:crypto";
12
+ import sanitizeHtml from "sanitize-html";
13
+
14
+ const MAX_BODY = 600 * 1024;
15
+ const MAX_IMAGE = 5 * 1024 * 1024;
16
+ const json = (status, body) => Response.json(body, { status });
17
+ const same = (a, b) => timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
18
+ const authorized = (req) => {
19
+ const key = process.env.CALMIFY_API_KEY ?? "";
20
+ return key.length >= 32 && same(key, req.headers.get("x-calmify-key") ?? "");
21
+ };
22
+
23
+ async function cover(url) {
24
+ let u;
25
+ try {
26
+ u = new URL(url);
27
+ } catch {
28
+ return null;
29
+ }
30
+ if (u.protocol !== "https:" || u.hostname !== process.env.CALMIFY_ASSET_HOST || u.username) return null;
31
+ const res = await fetch(u, { redirect: "manual", signal: AbortSignal.timeout(20_000) });
32
+ if (res.status !== 200 || !res.body) return null;
33
+ const chunks = [];
34
+ let size = 0;
35
+ for await (const c of res.body) {
36
+ size += c.byteLength;
37
+ if (size > MAX_IMAGE) return null;
38
+ chunks.push(c);
39
+ }
40
+ const b = Buffer.concat(chunks);
41
+ const ext =
42
+ b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) ? "png"
43
+ : b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? "jpg"
44
+ : b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP" ? "webp"
45
+ : null;
46
+ return ext ? { bytes: new Uint8Array(b), ext } : null;
47
+ }
48
+
49
+ export function createCalmifyHandler({ save, findByKey }) {
50
+ return {
51
+ async GET(req) {
52
+ if (!authorized(req)) return json(401, { error: "unauthorized" });
53
+ return json(200, { ok: true, receiver: "nextjs" });
54
+ },
55
+ async POST(req) {
56
+ if (!authorized(req)) return json(401, { error: "unauthorized" });
57
+ const raw = await req.text();
58
+ if (raw.length > MAX_BODY) return json(413, { error: "too_large" });
59
+ const key = req.headers.get("idempotency-key") ?? "";
60
+ let b;
61
+ try {
62
+ b = JSON.parse(raw);
63
+ } catch {
64
+ return json(400, { error: "invalid_json" });
65
+ }
66
+ const s = (k, max, required = false) => {
67
+ const v = b[k] ?? (required ? undefined : "");
68
+ return typeof v === "string" && v.length <= max && (!required || v) ? v : null;
69
+ };
70
+ const p = { title: s("title", 300, true), slug: s("slug", 120, true), content: s("content", 500_000, true), category: s("category", 100), description: s("meta_description", 320), keywords: s("meta_keywords", 1000), alt: s("image_alt", 300) };
71
+ if (!key || key.length > 200 || Object.values(p).includes(null) || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(p.slug)) return json(422, { error: "invalid" });
72
+ const existing = await findByKey(key);
73
+ if (existing) return json(200, existing);
74
+ let image = null;
75
+ if (typeof b.image_url === "string" && b.image_url) {
76
+ const got = await cover(b.image_url);
77
+ if (!got) return json(422, { error: "image_rejected" });
78
+ image = { ...got, alt: p.alt };
79
+ }
80
+ try {
81
+ const saved = await save({ idempotencyKey: key, title: p.title, slug: p.slug, contentHtml: sanitizeHtml(p.content), category: p.category, metaDescription: p.description, metaKeywords: p.keywords, image });
82
+ return json(201, saved);
83
+ } catch {
84
+ const again = await findByKey(key);
85
+ return again ? json(200, again) : json(409, { error: "slug_taken" });
86
+ }
87
+ },
88
+ };
89
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@calmify/next",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.2",
4
+ "description": "Receive articles from Calmify in a Next.js app: a route handler and a one-time pairing command.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "types": "index.d.ts",
9
+ "bin": {
10
+ "calmify-pair": "bin/calmify-pair.js"
11
+ },
12
+ "peerDependencies": {
13
+ "next": ">=14"
14
+ },
15
+ "dependencies": {
16
+ "sanitize-html": "^2.13.0"
17
+ },
18
+ "engines": {
19
+ "node": ">=18.17"
20
+ },
21
+ "files": [
22
+ "index.js",
23
+ "index.d.ts",
24
+ "bin",
25
+ "README.md"
26
+ ],
27
+ "keywords": [
28
+ "calmify",
29
+ "nextjs",
30
+ "blog",
31
+ "cms",
32
+ "receiver"
33
+ ],
34
+ "homepage": "https://calmifye.vercel.app/docs/connect",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./index.d.ts",
38
+ "import": "./index.js"
39
+ }
40
+ }
41
+ }