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