cmskite-mcp 0.1.0 → 0.1.1

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.
@@ -0,0 +1,1059 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from './register.js';
3
+ /**
4
+ * How to wire CMSKite into a project that already exists.
5
+ *
6
+ * An assistant with the other tools could create a project, write posts and
7
+ * mint a key, and would then write the integration by hand -- a `fetch`
8
+ * wrapper, a URL built from a template string, a `catch` that re-threw a
9
+ * `TypeError`, and no view tracking at all, because nothing told it there was
10
+ * any. Every assistant wrote a slightly different one, and none of them counted
11
+ * a reader.
12
+ *
13
+ * This tool answers with the official SDK instead. It is not a code generator:
14
+ * it returns the two or three files that project shape needs, and says which
15
+ * key goes where, because that is the part that is easy to get wrong in a way
16
+ * nobody notices until a secret is in a bundle.
17
+ *
18
+ * Deliberately not clever about detection. The caller says what it found --
19
+ * an assistant has already read the package.json it is standing in -- and this
20
+ * answers for that. Guessing from a directory listing would be a brittle
21
+ * assumption dressed as intelligence.
22
+ */
23
+ const framework = z
24
+ .enum([
25
+ 'nextjs-app',
26
+ 'nextjs-pages',
27
+ 'react',
28
+ 'node',
29
+ 'html',
30
+ 'php',
31
+ 'wordpress',
32
+ 'laravel',
33
+ 'python',
34
+ 'other',
35
+ ])
36
+ .describe('What the project is. Look at the files rather than guessing. ' +
37
+ 'JavaScript: `next` in package.json with an app/ directory is `nextjs-app`, `next` with ' +
38
+ 'pages/ is `nextjs-pages`, `react` without `next` is `react`, a package.json with no ' +
39
+ 'framework is `node`. ' +
40
+ 'Not JavaScript: .html files and no build step is `html`; wp-config.php or a wp-content/ ' +
41
+ 'directory is `wordpress`; artisan and composer.json with laravel/framework is `laravel`; ' +
42
+ 'any other .php is `php`; requirements.txt, pyproject.toml or .py is `python`. ' +
43
+ 'Use `other` for anything else and the answer is the raw HTTP calls, which work in every ' +
44
+ 'language.');
45
+ export const integrationTools = [
46
+ defineTool({
47
+ name: 'get_integration_guide',
48
+ title: 'How to integrate CMSKite into this project',
49
+ description: 'The official way to connect a project to CMSKite. For JavaScript and TypeScript that is ' +
50
+ 'the cmskite package; for PHP, WordPress, Laravel, Python or a plain HTML site it is the ' +
51
+ 'raw HTTP calls, which need no dependency at all. Returns what to install if anything, ' +
52
+ 'the files to write, and which key belongs on the server versus in the browser. ' +
53
+ 'Call this BEFORE writing any integration code by hand: hand-rolled fetch wrappers miss ' +
54
+ 'view tracking entirely, so the customer gets a working site with no analytics and no ' +
55
+ 'indication that anything is missing. ' +
56
+ 'Finish by calling check_integration, which is the only thing that can tell a working ' +
57
+ 'integration from one that silently reports nothing. ' +
58
+ 'Does not read or change anything in CMSKite.',
59
+ input: {
60
+ framework,
61
+ projectId: z
62
+ .string()
63
+ .optional()
64
+ .describe('The CMSKite project id, as `prj_...`, if one already exists.'),
65
+ includeAnalytics: z
66
+ .boolean()
67
+ .optional()
68
+ .describe('Include view and click tracking. Defaults to true; there is rarely a reason not to.'),
69
+ },
70
+ readOnly: true,
71
+ run: (_client, args) => Promise.resolve(guide(args)),
72
+ }),
73
+ ];
74
+ /** Everything that is not JavaScript, and therefore uses the raw HTTP calls. */
75
+ const RAW = new Set(['html', 'php', 'wordpress', 'laravel', 'python', 'other']);
76
+ /** A site whose content is fetched by the browser, not by a server. */
77
+ const BROWSER_FETCHED = new Set(['html', 'react']);
78
+ function guide(args) {
79
+ const analytics = args.includeAnalytics !== false;
80
+ const raw = RAW.has(args.framework);
81
+ const notes = [
82
+ 'Fetch the content on the server, report the view from the browser. That split IS the ' +
83
+ 'integration, and it is the part that gets missed: a site that only fetches content ' +
84
+ 'works perfectly and counts nobody.',
85
+ 'A CMSKite key is read-only and returns published content only. There is no secret ' +
86
+ 'credential here — but use two keys anyway, so the one in the page can be revoked ' +
87
+ 'without taking the site down.',
88
+ 'A view is reported by the page that renders the post, never inferred from an API ' +
89
+ 'request. A build fetching every post is two hundred requests and no readers.',
90
+ 'The tracker needs the post’s `id` (`post_...`), not its slug. Render the id into the ' +
91
+ 'page; a slug is rejected and the view is silently dropped.',
92
+ ];
93
+ if (BROWSER_FETCHED.has(args.framework)) {
94
+ notes.push('This site reads content from the browser, so allowed origins are NOT optional: add ' +
95
+ 'every domain the site is served from under Project → Settings → Allowed origins. ' +
96
+ 'Without them the browser refuses the response and the page renders empty.');
97
+ }
98
+ else {
99
+ notes.push('Set allowed origins under Project → Settings → Allowed origins. Until that is set, any ' +
100
+ 'page anywhere can read this project’s published content.');
101
+ }
102
+ if (args.projectId) {
103
+ notes.push(`Create the keys for project ${args.projectId} with the create_api_key tool.`);
104
+ }
105
+ const keys = browserOnly(args.framework)
106
+ ? [
107
+ {
108
+ name: 'the project key, pasted directly into the page',
109
+ where: 'In the HTML. There is no server to hide it behind, and nothing to hide.',
110
+ why: 'Reads published content and reports views. Restrict its origins in project ' +
111
+ 'settings — that is the control that matters here, not secrecy.',
112
+ },
113
+ ]
114
+ : [
115
+ {
116
+ name: 'CMSKITE_API_KEY',
117
+ where: serverKeyHome(args.framework),
118
+ why: 'Fetches content while the page is being rendered or built.',
119
+ },
120
+ ];
121
+ if (analytics && !browserOnly(args.framework)) {
122
+ keys.push({
123
+ name: publicKeyName(args.framework),
124
+ where: 'In the page the reader loads, deliberately.',
125
+ why: 'Reports views. Restrict its origins in project settings.',
126
+ });
127
+ }
128
+ return {
129
+ install: raw ? 'Nothing to install. This is plain HTTP.' : 'npm install cmskite',
130
+ keys,
131
+ files: filesFor(args.framework, analytics),
132
+ notes,
133
+ verify: 'When the files are in place, load one blog post in a browser, then call ' +
134
+ 'check_integration for this project. It reports whether the key has been used and ' +
135
+ 'whether views are arriving. Do not report the integration as finished before it passes.',
136
+ };
137
+ }
138
+ /** No server at all, so there is nowhere to put a key that the page cannot see. */
139
+ function browserOnly(kind) {
140
+ return kind === 'html';
141
+ }
142
+ function serverKeyHome(kind) {
143
+ if (kind === 'wordpress')
144
+ return 'wp-config.php, as a define(). Not in the theme.';
145
+ if (kind === 'laravel')
146
+ return '.env, read through config(). Never committed.';
147
+ if (kind === 'php')
148
+ return 'An environment variable, or a config file outside the web root.';
149
+ if (kind === 'python')
150
+ return 'An environment variable.';
151
+ return 'Server environment only. Never prefixed with NEXT_PUBLIC_ or VITE_.';
152
+ }
153
+ function publicKeyName(kind) {
154
+ if (kind.startsWith('nextjs'))
155
+ return 'NEXT_PUBLIC_CMSKITE_KEY';
156
+ if (kind === 'react')
157
+ return 'VITE_CMSKITE_KEY or equivalent';
158
+ return 'the project key, printed into the page by the template';
159
+ }
160
+ /**
161
+ * The tracking snippet for a page that has no build step.
162
+ *
163
+ * This is the piece that a hand-rolled integration leaves out, so it is written
164
+ * here once, correctly, rather than reinvented per site. Three things in it are
165
+ * not obvious and are each the difference between a counted view and a silent
166
+ * nothing:
167
+ *
168
+ * `text/plain`. The body is JSON, but `application/json` is not on the CORS
169
+ * safelist, so the browser preflights the request -- and a preflight carries
170
+ * no key, so the API cannot tell which project's allowlist to answer from and
171
+ * refuses it. The beacon is then dropped with no error visible anywhere.
172
+ *
173
+ * `sendBeacon`. The last view of a reading session is reported as the page is
174
+ * closing, and it is the only thing a browser promises to finish afterwards.
175
+ *
176
+ * The post id, not the slug. An id the API does not recognise is dropped
177
+ * rather than refused, deliberately -- so a slug here produces a page that
178
+ * looks entirely healthy and counts nothing.
179
+ */
180
+ const RAW_TRACKER = `<!--
181
+ CMSKite view tracking.
182
+ Put this at the end of the page that shows ONE post, and give it that post's id.
183
+ -->
184
+ <script>
185
+ (function () {
186
+ var POST_ID = 'PASTE_THE_POST_ID' // the post's id, like post_01h... NOT the slug
187
+ var KEY = 'PASTE_THE_PROJECT_KEY' // the project's read-only key
188
+ if (!POST_ID || !KEY || POST_ID.indexOf('PASTE') === 0) return
189
+
190
+ // One view per post per tab. A refresh is not a second reader.
191
+ try {
192
+ var seen = 'cmskite:v:' + POST_ID
193
+ if (sessionStorage.getItem(seen)) return
194
+ sessionStorage.setItem(seen, '1')
195
+ } catch (e) {
196
+ // Private mode, or blocked site data. Count the view rather than lose it.
197
+ }
198
+
199
+ var url = 'https://api.cmskite.com/v1/blog/events?key=' + encodeURIComponent(KEY)
200
+ var body = JSON.stringify({
201
+ events: [{ type: 'view', postId: POST_ID, path: location.pathname }]
202
+ })
203
+
204
+ // text/plain is deliberate. The body is JSON, but this content type is
205
+ // CORS-safelisted so the browser sends it with no preflight. Using
206
+ // application/json here means the view is silently never delivered.
207
+ var type = 'text/plain;charset=UTF-8'
208
+ try {
209
+ if (navigator.sendBeacon && navigator.sendBeacon(url, new Blob([body], { type: type }))) return
210
+ } catch (e) {}
211
+ try {
212
+ fetch(url, { method: 'POST', headers: { 'content-type': type }, body: body, keepalive: true })
213
+ .catch(function () {})
214
+ } catch (e) {
215
+ // Analytics must never break the page it is measuring.
216
+ }
217
+ })()
218
+ </script>
219
+ `;
220
+ function filesFor(kind, analytics) {
221
+ const rawFiles = rawFilesFor(kind, analytics);
222
+ if (rawFiles)
223
+ return rawFiles;
224
+ const client = `import { createCMSKite } from 'cmskite'
225
+
226
+ // Created once and reused. It holds no connection and no mutable state.
227
+ export const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })
228
+ `;
229
+ const tracker = `'use client'
230
+
231
+ import { useTrackView } from 'cmskite/react'
232
+
233
+ /**
234
+ * Reports one view for this post, once.
235
+ *
236
+ * Safe to render on every navigation: the hook reports the first mount only,
237
+ * and the tracker remembers the post for the tab besides. Nothing here can
238
+ * throw, and nothing blocks the page.
239
+ */
240
+ export function TrackView({ postId }: { postId: string }) {
241
+ useTrackView(postId, { apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })
242
+ return null
243
+ }
244
+ `;
245
+ if (kind === 'nextjs-app') {
246
+ return [
247
+ { path: 'lib/cmskite.ts', contents: client },
248
+ ...(analytics ? [{ path: 'components/track-view.tsx', contents: tracker }] : []),
249
+ {
250
+ path: 'app/blog/[slug]/page.tsx',
251
+ contents: `import { notFound } from 'next/navigation'
252
+ import { cms } from '@/lib/cmskite'
253
+ ${analytics ? "import { TrackView } from '@/components/track-view'\n" : ''}
254
+ export async function generateStaticParams() {
255
+ const params = []
256
+ // An async iterator, so a site with four thousand posts does not hold four
257
+ // thousand posts in memory to build a route list.
258
+ for await (const post of cms.posts.all({ status: 'published' })) {
259
+ params.push({ slug: post.slug })
260
+ }
261
+ return params
262
+ }
263
+
264
+ export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
265
+ const { slug } = await params
266
+ // findBySlug answers null rather than throwing, so the page renders its own
267
+ // not-found state.
268
+ const post = await cms.posts.findBySlug(slug)
269
+ if (!post) notFound()
270
+
271
+ return (
272
+ <article>
273
+ <h1>{post.title}</h1>
274
+ <div dangerouslySetInnerHTML={{ __html: post.body }} />
275
+ ${analytics ? ' <TrackView postId={post.id} />\n' : ''} </article>
276
+ )
277
+ }
278
+ `,
279
+ },
280
+ {
281
+ path: 'app/blog/page.tsx',
282
+ contents: `import Link from 'next/link'
283
+ import { cms } from '@/lib/cmskite'
284
+
285
+ export default async function BlogIndex() {
286
+ const { items } = await cms.posts.list({ limit: 20, status: 'published' })
287
+
288
+ return (
289
+ <ul>
290
+ {items.map((post) => (
291
+ <li key={post.id}>
292
+ <Link href={\`/blog/\${post.slug}\`}>{post.title}</Link>
293
+ </li>
294
+ ))}
295
+ </ul>
296
+ )
297
+ }
298
+ `,
299
+ },
300
+ ];
301
+ }
302
+ if (kind === 'nextjs-pages') {
303
+ return [
304
+ { path: 'lib/cmskite.ts', contents: client },
305
+ {
306
+ path: 'pages/blog/[slug].tsx',
307
+ contents: `import type { GetStaticPaths, GetStaticProps } from 'next'
308
+ ${analytics ? "import { useTrackView } from 'cmskite/react'\n" : ''}import type { Post } from 'cmskite'
309
+ import { cms } from '../../lib/cmskite'
310
+
311
+ export default function BlogPost({ post }: { post: Post }) {
312
+ ${analytics ? " useTrackView(post.id, { apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })\n\n" : ''} return (
313
+ <article>
314
+ <h1>{post.title}</h1>
315
+ <div dangerouslySetInnerHTML={{ __html: post.body }} />
316
+ </article>
317
+ )
318
+ }
319
+
320
+ export const getStaticPaths: GetStaticPaths = async () => {
321
+ const { items } = await cms.posts.list({ limit: 100, status: 'published' })
322
+ return { paths: items.map((p) => ({ params: { slug: p.slug } })), fallback: 'blocking' }
323
+ }
324
+
325
+ export const getStaticProps: GetStaticProps = async ({ params }) => {
326
+ const post = await cms.posts.findBySlug(String(params?.slug))
327
+ if (!post) return { notFound: true }
328
+ return { props: { post }, revalidate: 300 }
329
+ }
330
+ `,
331
+ },
332
+ ];
333
+ }
334
+ if (kind === 'react') {
335
+ return [
336
+ {
337
+ path: 'src/cmskite.ts',
338
+ contents: `import { createCMSKite } from 'cmskite'
339
+
340
+ /**
341
+ * A browser-only app has no server to hide a key behind, so this is the
342
+ * read-only project key and its origins MUST be restricted in project
343
+ * settings. If this app has a backend, fetch there instead and let the browser
344
+ * do only the tracking.
345
+ */
346
+ export const cms = createCMSKite({ apiKey: import.meta.env.VITE_CMSKITE_KEY })
347
+ `,
348
+ },
349
+ {
350
+ path: 'src/BlogPost.tsx',
351
+ contents: `import { useEffect, useState } from 'react'
352
+ ${analytics ? "import { useTrackView } from 'cmskite/react'\n" : ''}import { CMSKiteError, type Post } from 'cmskite'
353
+ import { cms } from './cmskite'
354
+
355
+ export function BlogPost({ slug }: { slug: string }) {
356
+ const [post, setPost] = useState<Post | null>(null)
357
+ const [error, setError] = useState<string | null>(null)
358
+
359
+ ${analytics ? " useTrackView(post?.id, { apiKey: import.meta.env.VITE_CMSKITE_KEY })\n\n" : ''} useEffect(() => {
360
+ // Cancelled on unmount, and when a newer slug supersedes this one.
361
+ const controller = new AbortController()
362
+ cms.posts
363
+ .findBySlug(slug, { signal: controller.signal })
364
+ .then(setPost)
365
+ .catch((err: unknown) => {
366
+ if (err instanceof CMSKiteError) setError(err.message)
367
+ })
368
+ return () => controller.abort()
369
+ }, [slug])
370
+
371
+ if (error) return <p>{error}</p>
372
+ if (!post) return <p>Loading…</p>
373
+ return (
374
+ <article>
375
+ <h1>{post.title}</h1>
376
+ <div dangerouslySetInnerHTML={{ __html: post.body }} />
377
+ </article>
378
+ )
379
+ }
380
+ `,
381
+ },
382
+ ];
383
+ }
384
+ return [
385
+ {
386
+ path: 'cmskite.ts',
387
+ contents: `${client}
388
+ // Reading content:
389
+ // const { items } = await cms.posts.list({ limit: 10 })
390
+ // const post = await cms.posts.getBySlug('my-post')
391
+ //
392
+ // Every page at once, without a cursor loop:
393
+ // for await (const post of cms.posts.all()) { … }
394
+ //
395
+ // Errors are one type, with a status, a stable code and a request id:
396
+ // catch (error) { if (error instanceof CMSKiteError) … }
397
+ ${analytics
398
+ ? `
399
+ // View tracking runs in a browser, not here. A server fetching a post is not
400
+ // a reader, which is the whole reason it is not counted as one. Send the
401
+ // rendered page the project's public key and call trackView there.`
402
+ : ''}`,
403
+ },
404
+ ];
405
+ }
406
+ /**
407
+ * The guides for everything that is not JavaScript.
408
+ *
409
+ * Returns null for the frameworks the SDK covers, so `filesFor` keeps its own
410
+ * shape and there is exactly one place that decides which world a project is
411
+ * in.
412
+ *
413
+ * These are raw HTTP calls on purpose. A PHP or WordPress site cannot install
414
+ * an npm package, and telling somebody to build one is how a five-minute
415
+ * integration becomes a project. Every one of them is two requests: read the
416
+ * posts with a key, and post the view from the page.
417
+ */
418
+ function rawFilesFor(kind, analytics) {
419
+ const tracker = analytics ? [{ path: 'the-post-page (tracking snippet)', contents: RAW_TRACKER }] : [];
420
+ if (kind === 'html') {
421
+ return [
422
+ {
423
+ path: 'blog.html',
424
+ contents: `<!doctype html>
425
+ <html lang="en">
426
+ <head><meta charset="utf-8"><title>Blog</title></head>
427
+ <body>
428
+ <ul id="posts"></ul>
429
+
430
+ <script>
431
+ // A plain HTML site has no server, so the key is in the page. That is fine --
432
+ // the key is read-only and returns published posts only. What is NOT optional
433
+ // is the origin allowlist: add this site's domain under
434
+ // Project -> Settings -> Allowed origins, or the browser will refuse every
435
+ // response and this list will render empty with a CORS error in the console.
436
+ var KEY = 'PASTE_THE_PROJECT_KEY'
437
+
438
+ fetch('https://api.cmskite.com/v1/blog/posts?limit=20', {
439
+ headers: { authorization: 'Bearer ' + KEY }
440
+ })
441
+ .then(function (res) {
442
+ if (!res.ok) throw new Error('CMSKite ' + res.status)
443
+ return res.json()
444
+ })
445
+ .then(function (payload) {
446
+ document.getElementById('posts').innerHTML = payload.data
447
+ .map(function (post) {
448
+ // textContent-safe: titles are author-controlled text, not markup.
449
+ var a = document.createElement('a')
450
+ a.href = 'post.html?slug=' + encodeURIComponent(post.slug)
451
+ a.textContent = post.title
452
+ return '<li>' + a.outerHTML + '</li>'
453
+ })
454
+ .join('')
455
+ })
456
+ .catch(function (err) {
457
+ document.getElementById('posts').textContent = 'Could not load posts.'
458
+ console.error(err)
459
+ })
460
+ </script>
461
+ </body>
462
+ </html>
463
+ `,
464
+ },
465
+ {
466
+ path: 'post.html',
467
+ contents: `<!doctype html>
468
+ <html lang="en">
469
+ <head><meta charset="utf-8"><title>Post</title></head>
470
+ <body>
471
+ <article><h1 id="title"></h1><div id="body"></div></article>
472
+
473
+ <script>
474
+ var KEY = 'PASTE_THE_PROJECT_KEY'
475
+ var slug = new URLSearchParams(location.search).get('slug')
476
+
477
+ fetch('https://api.cmskite.com/v1/blog/posts/slug/' + encodeURIComponent(slug), {
478
+ headers: { authorization: 'Bearer ' + KEY }
479
+ })
480
+ .then(function (res) {
481
+ if (res.status === 404) throw new Error('No such post')
482
+ if (!res.ok) throw new Error('CMSKite ' + res.status)
483
+ return res.json()
484
+ })
485
+ .then(function (payload) {
486
+ var post = payload.data
487
+ document.getElementById('title').textContent = post.title
488
+ document.getElementById('body').innerHTML = post.body
489
+
490
+ // The view is reported here, with the id the API just gave us -- which is
491
+ // why this snippet lives inside the fetch and not beside it. Reporting a
492
+ // slug reports nothing at all.
493
+ trackView(post.id)
494
+ })
495
+ .catch(function (err) {
496
+ document.getElementById('title').textContent = 'Post not found'
497
+ console.error(err)
498
+ })
499
+
500
+ ${analytics ? rawTrackerFunction() : '// Tracking was left out. Every post will read zero views.\nfunction trackView() {}'}
501
+ </script>
502
+ </body>
503
+ </html>
504
+ `,
505
+ },
506
+ ];
507
+ }
508
+ if (kind === 'wordpress') {
509
+ return [
510
+ {
511
+ path: 'wp-config.php (add near the other defines)',
512
+ contents: `<?php
513
+ // The server key. Never echoed into a template.
514
+ define('CMSKITE_API_KEY', 'PASTE_THE_SERVER_KEY');
515
+ // The key the reader's browser uses to report views. Safe in the page.
516
+ define('CMSKITE_PUBLIC_KEY', 'PASTE_THE_BROWSER_KEY');
517
+ `,
518
+ },
519
+ {
520
+ path: 'wp-content/themes/<your-theme>/cmskite.php',
521
+ contents: `<?php
522
+ /**
523
+ * CMSKite, for WordPress.
524
+ *
525
+ * Two functions and no dependency. Include this from functions.php:
526
+ *
527
+ * require_once get_stylesheet_directory() . '/cmskite.php';
528
+ */
529
+
530
+ function cmskite_get($path, $query = []) {
531
+ $url = 'https://api.cmskite.com/v1' . $path;
532
+ if ($query) {
533
+ $url .= '?' . http_build_query($query);
534
+ }
535
+
536
+ // WordPress ships an HTTP client. Using it means proxies, timeouts and
537
+ // filters behave the way the rest of the site does.
538
+ $response = wp_remote_get($url, [
539
+ 'timeout' => 10,
540
+ 'headers' => ['authorization' => 'Bearer ' . CMSKITE_API_KEY],
541
+ ]);
542
+
543
+ if (is_wp_error($response)) {
544
+ error_log('CMSKite: ' . $response->get_error_message());
545
+ return null;
546
+ }
547
+ if (wp_remote_retrieve_response_code($response) !== 200) {
548
+ error_log('CMSKite: HTTP ' . wp_remote_retrieve_response_code($response));
549
+ return null;
550
+ }
551
+
552
+ $payload = json_decode(wp_remote_retrieve_body($response), true);
553
+ return $payload['data'] ?? null;
554
+ }
555
+
556
+ /** The list. Cached, because a blog index should not make an API call per visitor. */
557
+ function cmskite_posts($limit = 20) {
558
+ $cached = get_transient('cmskite_posts_' . $limit);
559
+ if ($cached !== false) {
560
+ return $cached;
561
+ }
562
+ $posts = cmskite_get('/blog/posts', ['limit' => $limit]) ?: [];
563
+ set_transient('cmskite_posts_' . $limit, $posts, 5 * MINUTE_IN_SECONDS);
564
+ return $posts;
565
+ }
566
+
567
+ /** One post, by its slug. Null when there is no such post. */
568
+ function cmskite_post($slug) {
569
+ return cmskite_get('/blog/posts/slug/' . rawurlencode($slug));
570
+ }
571
+
572
+ ${analytics ? phpTrackerHelper() : '// Tracking was left out. Every post will read zero views.'}
573
+ `,
574
+ },
575
+ {
576
+ path: 'wp-content/themes/<your-theme>/page-blog.php',
577
+ contents: `<?php
578
+ /* Template Name: CMSKite Blog */
579
+ get_header();
580
+
581
+ $posts = cmskite_posts(20);
582
+ ?>
583
+ <ul>
584
+ <?php foreach ($posts as $post): ?>
585
+ <li>
586
+ <a href="<?php echo esc_url(home_url('/blog/' . $post['slug'])); ?>">
587
+ <?php echo esc_html($post['title']); ?>
588
+ </a>
589
+ </li>
590
+ <?php endforeach; ?>
591
+ </ul>
592
+ <?php get_footer(); ?>
593
+ `,
594
+ },
595
+ {
596
+ path: 'wp-content/themes/<your-theme>/single-cmskite.php',
597
+ contents: `<?php
598
+ /* One post. Route your /blog/{slug} URLs here. */
599
+ get_header();
600
+
601
+ $slug = get_query_var('cmskite_slug');
602
+ $post = cmskite_post($slug);
603
+
604
+ if (!$post) {
605
+ status_header(404);
606
+ echo '<p>Post not found.</p>';
607
+ get_footer();
608
+ return;
609
+ }
610
+ ?>
611
+ <article>
612
+ <h1><?php echo esc_html($post['title']); ?></h1>
613
+ <?php
614
+ // The body is HTML the author wrote in CMSKite, so it is printed as markup
615
+ // rather than escaped. wp_kses_post strips anything a post has no business
616
+ // containing.
617
+ echo wp_kses_post($post['body']);
618
+ ?>
619
+ </article>
620
+ <?php
621
+ ${analytics ? "// Reports the view. Takes the post's id -- a slug here counts nothing.\ncmskite_track_view($post['id']);" : '// No tracking: this post will always read zero views.'}
622
+ get_footer();
623
+ ?>
624
+ `,
625
+ },
626
+ ];
627
+ }
628
+ if (kind === 'laravel') {
629
+ return [
630
+ {
631
+ path: '.env',
632
+ contents: `CMSKITE_API_KEY=PASTE_THE_SERVER_KEY
633
+ CMSKITE_PUBLIC_KEY=PASTE_THE_BROWSER_KEY
634
+ `,
635
+ },
636
+ {
637
+ path: 'config/services.php (add to the returned array)',
638
+ contents: `'cmskite' => [
639
+ 'key' => env('CMSKITE_API_KEY'),
640
+ 'public_key' => env('CMSKITE_PUBLIC_KEY'),
641
+ 'url' => 'https://api.cmskite.com/v1',
642
+ ],
643
+ `,
644
+ },
645
+ {
646
+ path: 'app/Services/CMSKite.php',
647
+ contents: `<?php
648
+
649
+ namespace App\\Services;
650
+
651
+ use Illuminate\\Support\\Facades\\Cache;
652
+ use Illuminate\\Support\\Facades\\Http;
653
+ use Illuminate\\Support\\Facades\\Log;
654
+
655
+ /**
656
+ * CMSKite, through Laravel's own HTTP client.
657
+ *
658
+ * No package: this is two GETs. The client is here rather than in the
659
+ * controllers so the key is attached in exactly one place and the failure
660
+ * behaviour is the same everywhere -- a blog that cannot reach the API renders
661
+ * an empty list, it does not 500.
662
+ */
663
+ class CMSKite
664
+ {
665
+ /** @return array<int, array<string, mixed>> */
666
+ public function posts(int $limit = 20): array
667
+ {
668
+ return Cache::remember("cmskite.posts.{$limit}", now()->addMinutes(5), function () use ($limit) {
669
+ return $this->get('/blog/posts', ['limit' => $limit]) ?? [];
670
+ });
671
+ }
672
+
673
+ /** @return array<string, mixed>|null */
674
+ public function post(string $slug): ?array
675
+ {
676
+ return $this->get('/blog/posts/slug/' . rawurlencode($slug));
677
+ }
678
+
679
+ private function get(string $path, array $query = []): mixed
680
+ {
681
+ try {
682
+ $response = Http::withToken(config('services.cmskite.key'))
683
+ ->timeout(10)
684
+ ->get(config('services.cmskite.url') . $path, $query);
685
+
686
+ if ($response->status() === 404) {
687
+ return null;
688
+ }
689
+ if ($response->failed()) {
690
+ Log::warning('CMSKite responded ' . $response->status(), [
691
+ // Every CMSKite response carries one, and support can find
692
+ // the request from it.
693
+ 'requestId' => $response->json('requestId'),
694
+ ]);
695
+ return null;
696
+ }
697
+
698
+ return $response->json('data');
699
+ } catch (\\Throwable $e) {
700
+ Log::warning('CMSKite unreachable: ' . $e->getMessage());
701
+ return null;
702
+ }
703
+ }
704
+ }
705
+ `,
706
+ },
707
+ {
708
+ path: 'resources/views/blog/show.blade.php',
709
+ contents: `<article>
710
+ <h1>{{ $post['title'] }}</h1>
711
+ {{-- The body is HTML the author wrote in CMSKite, so it is not escaped. --}}
712
+ {!! $post['body'] !!}
713
+ </article>
714
+
715
+ ${analytics
716
+ ? `{{-- Reports the view. Uses the post's id: a slug here counts nothing. --}}
717
+ @include('blog.track', ['postId' => $post['id']])`
718
+ : '{{-- No tracking: this post will always read zero views. --}}'}
719
+ `,
720
+ },
721
+ ...(analytics
722
+ ? [
723
+ {
724
+ path: 'resources/views/blog/track.blade.php',
725
+ contents: bladeTracker(),
726
+ },
727
+ ]
728
+ : []),
729
+ ];
730
+ }
731
+ if (kind === 'php') {
732
+ return [
733
+ {
734
+ path: 'cmskite.php',
735
+ contents: `<?php
736
+ /**
737
+ * CMSKite, in plain PHP. No dependency -- this is two GETs.
738
+ *
739
+ * The key comes from the environment rather than from this file, so it is not
740
+ * in version control and not served if the web server ever stops executing
741
+ * .php files.
742
+ */
743
+
744
+ function cmskite_get(string $path, array $query = []): mixed
745
+ {
746
+ $url = 'https://api.cmskite.com/v1' . $path;
747
+ if ($query) {
748
+ $url .= '?' . http_build_query($query);
749
+ }
750
+
751
+ $ch = curl_init($url);
752
+ curl_setopt_array($ch, [
753
+ CURLOPT_RETURNTRANSFER => true,
754
+ CURLOPT_TIMEOUT => 10,
755
+ CURLOPT_HTTPHEADER => ['authorization: Bearer ' . getenv('CMSKITE_API_KEY')],
756
+ ]);
757
+
758
+ $body = curl_exec($ch);
759
+ $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
760
+ // No curl_close(). It has done nothing since PHP 8.0 and since 8.5 it
761
+ // prints a deprecation notice -- into the page, above the <!doctype>.
762
+
763
+ // A blog that cannot reach the API renders empty. It does not fatal.
764
+ if ($body === false || $status !== 200) {
765
+ error_log("CMSKite: HTTP {$status} for {$path}");
766
+ return null;
767
+ }
768
+
769
+ $payload = json_decode($body, true);
770
+ return $payload['data'] ?? null;
771
+ }
772
+
773
+ function cmskite_posts(int $limit = 20): array
774
+ {
775
+ return cmskite_get('/blog/posts', ['limit' => $limit]) ?? [];
776
+ }
777
+
778
+ function cmskite_post(string $slug): ?array
779
+ {
780
+ return cmskite_get('/blog/posts/slug/' . rawurlencode($slug));
781
+ }
782
+
783
+ ${analytics ? phpTrackerHelper() : '// Tracking was left out. Every post will read zero views.'}
784
+ `,
785
+ },
786
+ {
787
+ path: 'blog.php',
788
+ contents: `<?php require __DIR__ . '/cmskite.php'; ?>
789
+ <!doctype html>
790
+ <html lang="en">
791
+ <head><meta charset="utf-8"><title>Blog</title></head>
792
+ <body>
793
+ <ul>
794
+ <?php foreach (cmskite_posts(20) as $post): ?>
795
+ <li>
796
+ <a href="/post.php?slug=<?= urlencode($post['slug']) ?>">
797
+ <?= htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8') ?>
798
+ </a>
799
+ </li>
800
+ <?php endforeach; ?>
801
+ </ul>
802
+ </body>
803
+ </html>
804
+ `,
805
+ },
806
+ {
807
+ path: 'post.php',
808
+ contents: `<?php
809
+ require __DIR__ . '/cmskite.php';
810
+
811
+ $post = cmskite_post($_GET['slug'] ?? '');
812
+ if (!$post) {
813
+ http_response_code(404);
814
+ echo 'Post not found';
815
+ exit;
816
+ }
817
+ ?>
818
+ <!doctype html>
819
+ <html lang="en">
820
+ <head>
821
+ <meta charset="utf-8">
822
+ <title><?= htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8') ?></title>
823
+ </head>
824
+ <body>
825
+ <article>
826
+ <h1><?= htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8') ?></h1>
827
+ <?php
828
+ // The body is HTML the author wrote in CMSKite, so it is printed as markup.
829
+ echo $post['body'];
830
+ ?>
831
+ </article>
832
+ <?php
833
+ ${analytics ? "// Reports the view. Uses the post's id -- a slug here counts nothing.\ncmskite_track_view($post['id']);" : '// No tracking: this post will always read zero views.'}
834
+ ?>
835
+ </body>
836
+ </html>
837
+ `,
838
+ },
839
+ ];
840
+ }
841
+ if (kind === 'python') {
842
+ return [
843
+ {
844
+ path: 'cmskite.py',
845
+ contents: `"""CMSKite, in plain Python. No package -- this is two GETs.
846
+
847
+ The key comes from the environment. It is read-only and returns published
848
+ content only, but it still does not belong in the repository.
849
+ """
850
+
851
+ import os
852
+ import httpx
853
+
854
+ BASE_URL = "https://api.cmskite.com/v1"
855
+
856
+ _client = httpx.Client(
857
+ base_url=BASE_URL,
858
+ timeout=10.0,
859
+ headers={"authorization": f"Bearer {os.environ['CMSKITE_API_KEY']}"},
860
+ )
861
+
862
+
863
+ def posts(limit: int = 20) -> list[dict]:
864
+ """The published posts, newest first. Empty when the API cannot be reached."""
865
+ try:
866
+ response = _client.get("/blog/posts", params={"limit": limit})
867
+ response.raise_for_status()
868
+ except httpx.HTTPError as error:
869
+ # A blog that cannot reach the API renders empty rather than erroring.
870
+ print(f"CMSKite: {error}")
871
+ return []
872
+ return response.json()["data"]
873
+
874
+
875
+ def post(slug: str) -> dict | None:
876
+ """One post, or None when there is no such post."""
877
+ try:
878
+ response = _client.get(f"/blog/posts/slug/{slug}")
879
+ if response.status_code == 404:
880
+ return None
881
+ response.raise_for_status()
882
+ except httpx.HTTPError as error:
883
+ print(f"CMSKite: {error}")
884
+ return None
885
+ return response.json()["data"]
886
+ `,
887
+ },
888
+ {
889
+ path: 'templates/post.html (Jinja, Django or anything else)',
890
+ contents: `<article>
891
+ <h1>{{ post.title }}</h1>
892
+ {# The body is HTML the author wrote in CMSKite, so it is not escaped. #}
893
+ {{ post.body | safe }}
894
+ </article>
895
+
896
+ ${analytics
897
+ ? `{# Reports the view. Uses the post's id -- a slug here counts nothing. #}
898
+ ${RAW_TRACKER.replace("var POST_ID = 'PASTE_THE_POST_ID' // the post's id, like post_01h... NOT the slug", "var POST_ID = '{{ post.id }}'").replace("var KEY = 'PASTE_THE_PROJECT_KEY' // the project's read-only key", "var KEY = '{{ cmskite_public_key }}'")}`
899
+ : '{# No tracking: this post will always read zero views. #}'}
900
+ `,
901
+ },
902
+ ];
903
+ }
904
+ if (kind === 'other') {
905
+ return [
906
+ {
907
+ path: 'README-cmskite.md',
908
+ contents: `# CMSKite, in any language
909
+
910
+ Two requests. There is no SDK to wait for.
911
+
912
+ ## 1. Read the content (on your server)
913
+
914
+ GET https://api.cmskite.com/v1/blog/posts?limit=20
915
+ Authorization: Bearer <your project key>
916
+
917
+ GET https://api.cmskite.com/v1/blog/posts/slug/<slug>
918
+ Authorization: Bearer <your project key>
919
+
920
+ Every response is \`{ "success": true, "data": ..., "requestId": "req_..." }\`.
921
+ A list also carries \`pagination\`. Quote the \`requestId\` to support.
922
+
923
+ The key already knows which project it belongs to, so there is no workspace or
924
+ project header to send — one supplied by the client is refused, not honoured.
925
+
926
+ ## 2. Report the view (from the reader's browser)
927
+
928
+ This is the half that gets skipped, and skipping it is invisible: the site
929
+ works, and every post reads zero views forever.
930
+
931
+ POST https://api.cmskite.com/v1/blog/events?key=<your project key>
932
+ Content-Type: text/plain;charset=UTF-8
933
+
934
+ {"events":[{"type":"view","postId":"post_01h...","path":"/blog/hello"}]}
935
+
936
+ Three things that are not obvious:
937
+
938
+ - **\`postId\`, not the slug.** An id the API does not recognise is dropped
939
+ rather than refused, so a slug produces a page that looks healthy and counts
940
+ nothing.
941
+ - **\`text/plain\`.** The body is JSON, but \`application/json\` is not
942
+ CORS-safelisted, so a browser preflights it — and the preflight carries no
943
+ key, so it is refused and the view is silently never sent.
944
+ - **It must run in the reader's browser.** A server fetching a post is not a
945
+ reader, which is exactly why fetching is not counted as one.
946
+
947
+ The snippet is below. Give it the post's id.
948
+
949
+ ${RAW_TRACKER}
950
+
951
+ ## 3. Check it worked
952
+
953
+ Load one post in a browser, then ask for this project's integration health. It
954
+ reports whether the key has been used and whether views are arriving. Do not
955
+ call the integration finished before it passes.
956
+ `,
957
+ },
958
+ ...tracker,
959
+ ];
960
+ }
961
+ return null;
962
+ }
963
+ /**
964
+ * The tracker as a callable function, for a page that only learns the post id
965
+ * after its fetch has resolved.
966
+ */
967
+ function rawTrackerFunction() {
968
+ return `// Reports one view. See the notes: text/plain and the post id, not the slug.
969
+ function trackView(postId) {
970
+ if (!postId || !KEY) return
971
+ try {
972
+ var seen = 'cmskite:v:' + postId
973
+ if (sessionStorage.getItem(seen)) return
974
+ sessionStorage.setItem(seen, '1')
975
+ } catch (e) {
976
+ // Private mode. Count the view rather than lose it.
977
+ }
978
+
979
+ var url = 'https://api.cmskite.com/v1/blog/events?key=' + encodeURIComponent(KEY)
980
+ var body = JSON.stringify({
981
+ events: [{ type: 'view', postId: postId, path: location.pathname }]
982
+ })
983
+ // text/plain is CORS-safelisted, so this is not preflighted. Using
984
+ // application/json means the view is silently never delivered.
985
+ var type = 'text/plain;charset=UTF-8'
986
+ try {
987
+ if (navigator.sendBeacon && navigator.sendBeacon(url, new Blob([body], { type: type }))) return
988
+ } catch (e) {}
989
+ try {
990
+ fetch(url, { method: 'POST', headers: { 'content-type': type }, body: body, keepalive: true })
991
+ .catch(function () {})
992
+ } catch (e) {}
993
+ }`;
994
+ }
995
+ /** The same snippet, printed by PHP with the ids already filled in. */
996
+ function phpTrackerHelper() {
997
+ return `/**
998
+ * Prints the view-tracking snippet for one post.
999
+ *
1000
+ * Call it from the template that renders a single post, with that post's id.
1001
+ * The id is required: a slug is dropped by the API rather than refused, so
1002
+ * passing one gives a page that looks fine and counts nothing.
1003
+ */
1004
+ function cmskite_track_view(string $postId): void
1005
+ {
1006
+ $post = json_encode($postId);
1007
+ $key = json_encode(getenv('CMSKITE_PUBLIC_KEY') ?: (defined('CMSKITE_PUBLIC_KEY') ? CMSKITE_PUBLIC_KEY : ''));
1008
+ echo <<<HTML
1009
+ <script>
1010
+ (function () {
1011
+ var POST_ID = {$post}
1012
+ var KEY = {$key}
1013
+ if (!POST_ID || !KEY) return
1014
+ try {
1015
+ var seen = 'cmskite:v:' + POST_ID
1016
+ if (sessionStorage.getItem(seen)) return
1017
+ sessionStorage.setItem(seen, '1')
1018
+ } catch (e) {}
1019
+ var url = 'https://api.cmskite.com/v1/blog/events?key=' + encodeURIComponent(KEY)
1020
+ var body = JSON.stringify({ events: [{ type: 'view', postId: POST_ID, path: location.pathname }] })
1021
+ // text/plain is CORS-safelisted, so this is not preflighted. application/json
1022
+ // would be, and the view would be silently dropped.
1023
+ var type = 'text/plain;charset=UTF-8'
1024
+ try { if (navigator.sendBeacon && navigator.sendBeacon(url, new Blob([body], { type: type }))) return } catch (e) {}
1025
+ try { fetch(url, { method: 'POST', headers: { 'content-type': type }, body: body, keepalive: true }).catch(function () {}) } catch (e) {}
1026
+ })()
1027
+ </script>
1028
+ HTML;
1029
+ }`;
1030
+ }
1031
+ /** The Blade partial, which receives the post id from the view that includes it. */
1032
+ function bladeTracker() {
1033
+ return `{{--
1034
+ Reports one view for this post.
1035
+ Included with: @include('blog.track', ['postId' => $post['id']])
1036
+ The id is required -- a slug is dropped by the API rather than refused.
1037
+ --}}
1038
+ <script>
1039
+ (function () {
1040
+ var POST_ID = @json($postId)
1041
+ var KEY = @json(config('services.cmskite.public_key'))
1042
+ if (!POST_ID || !KEY) return
1043
+ try {
1044
+ var seen = 'cmskite:v:' + POST_ID
1045
+ if (sessionStorage.getItem(seen)) return
1046
+ sessionStorage.setItem(seen, '1')
1047
+ } catch (e) {}
1048
+ var url = 'https://api.cmskite.com/v1/blog/events?key=' + encodeURIComponent(KEY)
1049
+ var body = JSON.stringify({ events: [{ type: 'view', postId: POST_ID, path: location.pathname }] })
1050
+ // text/plain is CORS-safelisted, so this is not preflighted. application/json
1051
+ // would be, and the view would be silently dropped.
1052
+ var type = 'text/plain;charset=UTF-8'
1053
+ try { if (navigator.sendBeacon && navigator.sendBeacon(url, new Blob([body], { type: type }))) return } catch (e) {}
1054
+ try { fetch(url, { method: 'POST', headers: { 'content-type': type }, body: body, keepalive: true }).catch(function () {}) } catch (e) {}
1055
+ })()
1056
+ </script>
1057
+ `;
1058
+ }
1059
+ //# sourceMappingURL=integrate.js.map