@pokoblog/next 1.0.0
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/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/client.d.ts +94 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +219 -0
- package/dist/client.js.map +1 -0
- package/dist/components.d.ts +84 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +60 -0
- package/dist/components.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/metadata.d.ts +51 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +78 -0
- package/dist/metadata.js.map +1 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Disrex V.O.F.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# PokoBlog for Next.js
|
|
2
|
+
|
|
3
|
+
Server components and metadata helpers for rendering a PokoBlog blog in the App
|
|
4
|
+
Router. Built and tested against **Next.js 16.3**.
|
|
5
|
+
|
|
6
|
+
## Installing
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @pokoblog/next
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Peer dependencies: `next@^16`, `react@^19`.
|
|
13
|
+
|
|
14
|
+
## Everything here runs on the server
|
|
15
|
+
|
|
16
|
+
That is not a default this package picked; it is the product. These articles
|
|
17
|
+
exist to be found by search engines and by AI crawlers, and GPTBot, ClaudeBot
|
|
18
|
+
and PerplexityBot fetch HTML and read what comes back — they do not run
|
|
19
|
+
JavaScript. A blog fetched in the browser after hydration is an empty div to all
|
|
20
|
+
of them.
|
|
21
|
+
|
|
22
|
+
So there is no `"use client"` anywhere in this package, no hook and no
|
|
23
|
+
`useEffect`, and `./components` imports `server-only`: put these components in a
|
|
24
|
+
client component and the **build fails** rather than shipping a blog nothing can
|
|
25
|
+
read.
|
|
26
|
+
|
|
27
|
+
## Setup
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// lib/pokoblog.ts
|
|
31
|
+
import { createPokoBlog } from "@pokoblog/next";
|
|
32
|
+
|
|
33
|
+
export const poko = createPokoBlog({
|
|
34
|
+
url: process.env.POKOBLOG_URL!,
|
|
35
|
+
token: process.env.POKOBLOG_TOKEN!, // Connections → Embed
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`fetch` in Next 16 does not cache unless asked, so this asks: `revalidate`
|
|
40
|
+
defaults to 300 seconds, matching the `max-age` PokoBlog sends. Pass
|
|
41
|
+
`revalidate: false` with `tags` if you would rather cache indefinitely and drop
|
|
42
|
+
it from a webhook route handler with `revalidateTag()`.
|
|
43
|
+
|
|
44
|
+
## The blog index
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
// app/blog/page.tsx
|
|
48
|
+
import { ArticleList, blogMetadata } from "@pokoblog/next";
|
|
49
|
+
|
|
50
|
+
import { poko } from "@/lib/pokoblog";
|
|
51
|
+
|
|
52
|
+
import type { Metadata } from "next";
|
|
53
|
+
|
|
54
|
+
export const metadata: Metadata = blogMetadata({
|
|
55
|
+
title: "Blog",
|
|
56
|
+
description: "Wat we schrijven over Magento.",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export default function BlogIndex() {
|
|
60
|
+
return <ArticleList client={poko} limit={20} />;
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
One request. A card needs a title, an excerpt, a date and a picture, and the
|
|
65
|
+
list carries all four — there is no call per article here and there must not be
|
|
66
|
+
one.
|
|
67
|
+
|
|
68
|
+
`ArticleList` renders semantic HTML with no styling and no class names of ours,
|
|
69
|
+
because a blog index has to look like the site it is in. Pass `className`, or
|
|
70
|
+
`renderItem` to replace the card entirely; the `<li>` and the paging stay ours.
|
|
71
|
+
|
|
72
|
+
## One article, with its metadata
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
// app/blog/[slug]/page.tsx
|
|
76
|
+
import { notFound } from "next/navigation";
|
|
77
|
+
|
|
78
|
+
import {
|
|
79
|
+
ArticleView,
|
|
80
|
+
articleMetadata,
|
|
81
|
+
PokoBlogNotFoundError,
|
|
82
|
+
} from "@pokoblog/next";
|
|
83
|
+
|
|
84
|
+
import { poko } from "@/lib/pokoblog";
|
|
85
|
+
|
|
86
|
+
import type { Metadata } from "next";
|
|
87
|
+
|
|
88
|
+
// `params` is a Promise in Next 16. Awaiting it is not optional.
|
|
89
|
+
type Props = { params: Promise<{ slug: string }> };
|
|
90
|
+
|
|
91
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
92
|
+
const { slug } = await params;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
return articleMetadata({
|
|
96
|
+
article: await poko.article(slug),
|
|
97
|
+
url: `https://example.com/blog/${slug}`,
|
|
98
|
+
siteName: "Example",
|
|
99
|
+
locale: "nl_NL",
|
|
100
|
+
});
|
|
101
|
+
} catch (failure) {
|
|
102
|
+
if (failure instanceof PokoBlogNotFoundError) return {};
|
|
103
|
+
|
|
104
|
+
throw failure;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default async function ArticlePage({ params }: Props) {
|
|
109
|
+
const { slug } = await params;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
return <ArticleView article={await poko.article(slug)} />;
|
|
113
|
+
} catch (failure) {
|
|
114
|
+
if (failure instanceof PokoBlogNotFoundError) notFound();
|
|
115
|
+
|
|
116
|
+
throw failure;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Fetching the article twice costs one request.** `fetch` GETs with the same URL
|
|
122
|
+
and options are memoised across `generateMetadata`, layouts and the page within
|
|
123
|
+
one render pass. That is why this package hands you a function of an article
|
|
124
|
+
rather than asking you to thread one from the metadata into the page.
|
|
125
|
+
|
|
126
|
+
`articleMetadata` sets the title, the description, `og:type: article` with
|
|
127
|
+
`article:published_time` and `article:modified_time`, the picture with the alt
|
|
128
|
+
text the author wrote, and a Twitter card sized to whether there is a picture at
|
|
129
|
+
all. It falls back from the meta description to the excerpt — they are different
|
|
130
|
+
fields on purpose, but a page with no description at all gets whatever sentence
|
|
131
|
+
a search engine picks out of the body.
|
|
132
|
+
|
|
133
|
+
## Static generation
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
export async function generateStaticParams() {
|
|
137
|
+
return (await poko.slugs()).map((slug) => ({ slug }));
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`slugs()` walks every page. The walk is a consistent snapshot: an article
|
|
142
|
+
published while it runs lands in front of the walk and arrives on the next
|
|
143
|
+
build.
|
|
144
|
+
|
|
145
|
+
## Dropping the cache when PokoBlog publishes
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// app/api/pokoblog/route.ts
|
|
149
|
+
import { revalidateTag } from "next/cache";
|
|
150
|
+
|
|
151
|
+
export async function POST(request: Request) {
|
|
152
|
+
const body = await request.text(); // raw bytes: the signature is over these
|
|
153
|
+
|
|
154
|
+
// Verify `Poko-Signature` before trusting this. `t=<unix>,v1=<hex>` is
|
|
155
|
+
// HMAC-SHA256 over `<t>.<body>`; compare with `crypto.timingSafeEqual`,
|
|
156
|
+
// never `===`, and refuse anything more than 300 seconds old.
|
|
157
|
+
|
|
158
|
+
revalidateTag("pokoblog", "max");
|
|
159
|
+
|
|
160
|
+
return new Response(null, { status: 204 });
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Create the client with `tags: ["pokoblog"]` for this to reach it.
|
|
165
|
+
|
|
166
|
+
**The second argument is not optional in Next 16.** `revalidateTag(tag)` on its
|
|
167
|
+
own is deprecated and does not type-check; `"max"` gives stale-while-revalidate,
|
|
168
|
+
which is what a blog wants. `updateTag(tag)` expires the entry immediately
|
|
169
|
+
instead, at the cost of making the next visitor wait for the refetch.
|
|
170
|
+
|
|
171
|
+
## The body
|
|
172
|
+
|
|
173
|
+
`ArticleView` renders `article.html` through `dangerouslySetInnerHTML`. That is
|
|
174
|
+
correct here for a specific reason: `html` is the output of PokoBlog's allowlist
|
|
175
|
+
renderer — a closed set of tags, every scrap of text escaped on the way in — and
|
|
176
|
+
is the identical string PokoBlog writes into a WordPress post.
|
|
177
|
+
|
|
178
|
+
**`markdown` is not interchangeable.** It is the unsanitized source and accepts
|
|
179
|
+
raw HTML on purpose, because the renderer escapes it on the way out. Putting it
|
|
180
|
+
through this prop, or through a markdown renderer with raw HTML enabled (which
|
|
181
|
+
is most of them by default), undoes the sanitizing that has already happened.
|
|
182
|
+
|
|
183
|
+
## Pictures
|
|
184
|
+
|
|
185
|
+
The components use a plain `<img>`, not `next/image`, because `next/image`
|
|
186
|
+
requires the article CDN's hostname in `images.remotePatterns` and a component
|
|
187
|
+
that silently needed a config change would fail in your build with an error
|
|
188
|
+
about a hostname rather than about this package. Once that host is configured,
|
|
189
|
+
swap it in through `renderItem`.
|
|
190
|
+
|
|
191
|
+
`imageAlt` is `null` when nobody wrote alt text, which is **not** the same as
|
|
192
|
+
`alt=""`, and this package never substitutes the title — it describes the
|
|
193
|
+
article, not the picture. The rendered `<img>` uses `alt=""` for the null case,
|
|
194
|
+
which is the honest reading beside a heading carrying the same meaning; the
|
|
195
|
+
metadata omits the attribute entirely.
|
|
196
|
+
|
|
197
|
+
## The token
|
|
198
|
+
|
|
199
|
+
The token is the **embed** connector's. Rotating it invalidates every URL built
|
|
200
|
+
from the old one, and **disconnecting the embed connector switches this API off
|
|
201
|
+
too** — the widget and the JSON API are the same connector row. Both arrive as a
|
|
202
|
+
`PokoBlogNotFoundError`, which is deliberately indistinguishable from a slug
|
|
203
|
+
that does not exist.
|
|
204
|
+
|
|
205
|
+
## Tests
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
cd clients/nextjs && npx vitest run
|
|
209
|
+
npx tsc --noEmit
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The suite runs under the `react-server` resolve condition, which is how Next
|
|
213
|
+
resolves a server component and the only way to load a module that imports
|
|
214
|
+
`server-only` at all.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Article, ArticleBody, ArticlePage } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reading a PokoBlog blog from a Next.js server component.
|
|
4
|
+
*
|
|
5
|
+
* ## Everything here runs on the server, and that is the product
|
|
6
|
+
*
|
|
7
|
+
* These articles exist to be found by search engines and by AI crawlers.
|
|
8
|
+
* GPTBot, ClaudeBot and PerplexityBot fetch HTML and read what comes back; they
|
|
9
|
+
* do not run JavaScript. A blog fetched in the browser after hydration is, to
|
|
10
|
+
* every one of them, an empty div -- which is the exact failure the embed
|
|
11
|
+
* widget has and the reason this package exists beside it.
|
|
12
|
+
*
|
|
13
|
+
* So there is no `"use client"` anywhere in this package, no hook, and no
|
|
14
|
+
* `useEffect`. The functions here are `async` and are meant to be awaited
|
|
15
|
+
* inside a server component, which is the App Router default: a component
|
|
16
|
+
* without `"use client"` renders on the server and its output is in the HTML.
|
|
17
|
+
* Nothing has to be configured to get that; it has to be *avoided* to lose it.
|
|
18
|
+
*
|
|
19
|
+
* The one way to lose it is to call these from a component that has
|
|
20
|
+
* `"use client"` at the top. `./components` imports `server-only` so that this
|
|
21
|
+
* fails the build instead of shipping a blog nothing can read.
|
|
22
|
+
*
|
|
23
|
+
* ## Caching
|
|
24
|
+
*
|
|
25
|
+
* `fetch` in Next 16 does not cache unless asked, so this asks: `revalidate`
|
|
26
|
+
* defaults to 300 seconds, matching the `max-age` PokoBlog itself sends. Two
|
|
27
|
+
* consequences worth knowing:
|
|
28
|
+
*
|
|
29
|
+
* - Two calls for the same URL in one render pass are **memoised** into one
|
|
30
|
+
* request. That is what makes calling `article()` in both `generateMetadata`
|
|
31
|
+
* and the page component free, and it is why this package does not ask you to
|
|
32
|
+
* thread the article down from one to the other.
|
|
33
|
+
* - `tags` lets you drop the cache on demand with `revalidateTag()`, which is
|
|
34
|
+
* what to call from a route handler receiving PokoBlog's publish webhook.
|
|
35
|
+
*/
|
|
36
|
+
/** The API's own default page size, and its ceiling. Outside 1..100 is a 422. */
|
|
37
|
+
export declare const PAGE = 50;
|
|
38
|
+
export declare const MAX_PAGE = 100;
|
|
39
|
+
export interface PokoBlogOptions {
|
|
40
|
+
/** The origin PokoBlog is served from, e.g. `https://app.example.com`. */
|
|
41
|
+
readonly url: string;
|
|
42
|
+
/** The **embed** connector's token, from Connections → Embed. */
|
|
43
|
+
readonly token: string;
|
|
44
|
+
/**
|
|
45
|
+
* Seconds before a cached answer is refetched. `false` caches indefinitely,
|
|
46
|
+
* which is what a build that revalidates by tag wants; `0` disables caching.
|
|
47
|
+
*/
|
|
48
|
+
readonly revalidate?: number | false;
|
|
49
|
+
/** Cache tags, for `revalidateTag()` from a webhook route handler. */
|
|
50
|
+
readonly tags?: readonly string[];
|
|
51
|
+
/** Swappable for tests. Defaults to the global `fetch`. */
|
|
52
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
53
|
+
}
|
|
54
|
+
/** Something the API said no to. `code` is the stable part; branch on it. */
|
|
55
|
+
export declare class PokoBlogError extends Error {
|
|
56
|
+
readonly status: number;
|
|
57
|
+
readonly code: string | null;
|
|
58
|
+
constructor(message: string, status: number, code: string | null);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The address reaches nothing.
|
|
62
|
+
*
|
|
63
|
+
* Deliberately ambiguous at the server and the ambiguity travels: a rotated
|
|
64
|
+
* token, a disconnected embed connector, a slug that is still a draft and a
|
|
65
|
+
* slug that never existed all answer this. That is what stops a stranger
|
|
66
|
+
* confirming a draft's address by asking for it.
|
|
67
|
+
*
|
|
68
|
+
* In a page, this is the one to turn into `notFound()`.
|
|
69
|
+
*/
|
|
70
|
+
export declare class PokoBlogNotFoundError extends PokoBlogError {
|
|
71
|
+
constructor(message: string, code: string | null);
|
|
72
|
+
}
|
|
73
|
+
export interface PokoBlogClient {
|
|
74
|
+
/** One page of articles, newest first. */
|
|
75
|
+
readonly page: (options?: {
|
|
76
|
+
readonly limit?: number;
|
|
77
|
+
readonly cursor?: string;
|
|
78
|
+
}) => Promise<ArticlePage>;
|
|
79
|
+
/** Every article, paging handled. Lazy: stop reading and it stops fetching. */
|
|
80
|
+
readonly articles: (options?: {
|
|
81
|
+
readonly perPage?: number;
|
|
82
|
+
}) => AsyncGenerator<Article, void, undefined>;
|
|
83
|
+
/** One article, body and all. */
|
|
84
|
+
readonly article: (slug: string) => Promise<ArticleBody>;
|
|
85
|
+
/** Every slug, for `generateStaticParams`. */
|
|
86
|
+
readonly slugs: () => Promise<string[]>;
|
|
87
|
+
readonly listUrl: (options?: {
|
|
88
|
+
readonly limit?: number;
|
|
89
|
+
readonly cursor?: string;
|
|
90
|
+
}) => string;
|
|
91
|
+
readonly articleUrl: (slug: string) => string;
|
|
92
|
+
}
|
|
93
|
+
export declare const createPokoBlog: ({ url, token, revalidate, tags, fetch: fetcher, }: PokoBlogOptions) => PokoBlogClient;
|
|
94
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAY,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,iFAAiF;AACjF,eAAO,MAAM,IAAI,KAAK,CAAC;AACvB,eAAO,MAAM,QAAQ,MAAM,CAAC;AAE5B,MAAM,WAAW,eAAe;IAC9B,0EAA0E;IAC1E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACrC,sEAAsE;IACtE,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CAC1C;AAED,6EAA6E;AAC7E,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAE7B,YAAY,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,EAK/D;CACF;AAED;;;;;;;;;GASG;AACH,qBAAa,qBAAsB,SAAQ,aAAa;IACtD,YAAY,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,EAG/C;CACF;AAED,MAAM,WAAW,cAAc;IAC7B,0CAA0C;IAC1C,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,EAAE;QACxB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;KAC1B,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC3B,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,EAAE;QAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;KAC3B,KAAK,cAAc,CAAC,OAAO,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC;IAC/C,iCAAiC;IACjC,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IACzD,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACxC,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,EAAE;QAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;KAC1B,KAAK,MAAM,CAAC;IACb,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CAC/C;AAED,eAAO,MAAM,cAAc,sDAMxB,eAAe,KAAG,cAoHpB,CAAC"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a PokoBlog blog from a Next.js server component.
|
|
3
|
+
*
|
|
4
|
+
* ## Everything here runs on the server, and that is the product
|
|
5
|
+
*
|
|
6
|
+
* These articles exist to be found by search engines and by AI crawlers.
|
|
7
|
+
* GPTBot, ClaudeBot and PerplexityBot fetch HTML and read what comes back; they
|
|
8
|
+
* do not run JavaScript. A blog fetched in the browser after hydration is, to
|
|
9
|
+
* every one of them, an empty div -- which is the exact failure the embed
|
|
10
|
+
* widget has and the reason this package exists beside it.
|
|
11
|
+
*
|
|
12
|
+
* So there is no `"use client"` anywhere in this package, no hook, and no
|
|
13
|
+
* `useEffect`. The functions here are `async` and are meant to be awaited
|
|
14
|
+
* inside a server component, which is the App Router default: a component
|
|
15
|
+
* without `"use client"` renders on the server and its output is in the HTML.
|
|
16
|
+
* Nothing has to be configured to get that; it has to be *avoided* to lose it.
|
|
17
|
+
*
|
|
18
|
+
* The one way to lose it is to call these from a component that has
|
|
19
|
+
* `"use client"` at the top. `./components` imports `server-only` so that this
|
|
20
|
+
* fails the build instead of shipping a blog nothing can read.
|
|
21
|
+
*
|
|
22
|
+
* ## Caching
|
|
23
|
+
*
|
|
24
|
+
* `fetch` in Next 16 does not cache unless asked, so this asks: `revalidate`
|
|
25
|
+
* defaults to 300 seconds, matching the `max-age` PokoBlog itself sends. Two
|
|
26
|
+
* consequences worth knowing:
|
|
27
|
+
*
|
|
28
|
+
* - Two calls for the same URL in one render pass are **memoised** into one
|
|
29
|
+
* request. That is what makes calling `article()` in both `generateMetadata`
|
|
30
|
+
* and the page component free, and it is why this package does not ask you to
|
|
31
|
+
* thread the article down from one to the other.
|
|
32
|
+
* - `tags` lets you drop the cache on demand with `revalidateTag()`, which is
|
|
33
|
+
* what to call from a route handler receiving PokoBlog's publish webhook.
|
|
34
|
+
*/
|
|
35
|
+
/** The API's own default page size, and its ceiling. Outside 1..100 is a 422. */
|
|
36
|
+
export const PAGE = 50;
|
|
37
|
+
export const MAX_PAGE = 100;
|
|
38
|
+
/** Something the API said no to. `code` is the stable part; branch on it. */
|
|
39
|
+
export class PokoBlogError extends Error {
|
|
40
|
+
status;
|
|
41
|
+
code;
|
|
42
|
+
constructor(message, status, code) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "PokoBlogError";
|
|
45
|
+
this.status = status;
|
|
46
|
+
this.code = code;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The address reaches nothing.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately ambiguous at the server and the ambiguity travels: a rotated
|
|
53
|
+
* token, a disconnected embed connector, a slug that is still a draft and a
|
|
54
|
+
* slug that never existed all answer this. That is what stops a stranger
|
|
55
|
+
* confirming a draft's address by asking for it.
|
|
56
|
+
*
|
|
57
|
+
* In a page, this is the one to turn into `notFound()`.
|
|
58
|
+
*/
|
|
59
|
+
export class PokoBlogNotFoundError extends PokoBlogError {
|
|
60
|
+
constructor(message, code) {
|
|
61
|
+
super(message, 404, code);
|
|
62
|
+
this.name = "PokoBlogNotFoundError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export const createPokoBlog = ({ url, token, revalidate = 300, tags, fetch: fetcher = globalThis.fetch, }) => {
|
|
66
|
+
const base = url.replace(/\/+$/, "");
|
|
67
|
+
if (!/^https?:\/\//i.test(base)) {
|
|
68
|
+
throw new TypeError("PokoBlog: `url` must be an absolute http(s) address");
|
|
69
|
+
}
|
|
70
|
+
if (token.trim() === "") {
|
|
71
|
+
throw new TypeError("PokoBlog: `token` is empty");
|
|
72
|
+
}
|
|
73
|
+
const endpoint = (path) => `${base}/api/connectors/${encodeURIComponent(token)}/${path}`;
|
|
74
|
+
const listUrl = ({ limit = PAGE, cursor, } = {}) => {
|
|
75
|
+
const query = new URLSearchParams({ limit: String(limit) });
|
|
76
|
+
if (cursor !== undefined)
|
|
77
|
+
query.set("cursor", cursor);
|
|
78
|
+
return `${endpoint("articles")}?${query.toString()}`;
|
|
79
|
+
};
|
|
80
|
+
const articleUrl = (slug) => endpoint(`articles/${encodeURIComponent(slug)}`);
|
|
81
|
+
const read = async (target) => {
|
|
82
|
+
const response = await fetcher(target, {
|
|
83
|
+
headers: { accept: "application/json" },
|
|
84
|
+
/*
|
|
85
|
+
* `next` rather than `cache`, because the two conflict: Next ignores both
|
|
86
|
+
* and warns in development when a request sets `revalidate` beside
|
|
87
|
+
* `cache: "no-store"`. One knob, and `revalidate: 0` is how you turn
|
|
88
|
+
* caching off.
|
|
89
|
+
*/
|
|
90
|
+
next: { revalidate, ...(tags ? { tags: [...tags] } : {}) },
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok)
|
|
93
|
+
throw await failure(response);
|
|
94
|
+
/*
|
|
95
|
+
* A 200 whose body is not JSON is not a parse error, it is the wrong
|
|
96
|
+
* address answering: a captive portal, a login wall, a load balancer error
|
|
97
|
+
* page, a base URL with a typo in it. Left alone, the caller gets
|
|
98
|
+
* `Unexpected token '<'` from deep inside `Response.json`, which names
|
|
99
|
+
* neither the cause nor the URL -- and the URL is the answer almost every
|
|
100
|
+
* time.
|
|
101
|
+
*/
|
|
102
|
+
try {
|
|
103
|
+
return (await response.json());
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw malformed("JSON");
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const page = async (options) => asPage(await read(listUrl(options)));
|
|
110
|
+
async function* articles({ perPage = PAGE, } = {}) {
|
|
111
|
+
let cursor;
|
|
112
|
+
for (;;) {
|
|
113
|
+
const current = await page({
|
|
114
|
+
limit: perPage,
|
|
115
|
+
...(cursor ? { cursor } : {}),
|
|
116
|
+
});
|
|
117
|
+
yield* current.articles;
|
|
118
|
+
if (current.nextCursor === null)
|
|
119
|
+
return;
|
|
120
|
+
/*
|
|
121
|
+
* A cursor that does not move is an infinite loop, and an infinite loop
|
|
122
|
+
* inside `generateStaticParams` is a build that never finishes. The API
|
|
123
|
+
* cannot produce one -- the cursor is built from the last row of the page
|
|
124
|
+
* -- which is exactly why it is checked rather than trusted: what sits
|
|
125
|
+
* between this and the API is a customer's CDN or a proxy.
|
|
126
|
+
*/
|
|
127
|
+
if (current.nextCursor === cursor) {
|
|
128
|
+
throw new PokoBlogError("PokoBlog: the cursor did not advance; refusing to page forever", 200, null);
|
|
129
|
+
}
|
|
130
|
+
cursor = current.nextCursor;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
page,
|
|
135
|
+
articles,
|
|
136
|
+
article: async (slug) => asArticleBody(await read(articleUrl(slug))),
|
|
137
|
+
slugs: async () => {
|
|
138
|
+
const found = [];
|
|
139
|
+
for await (const article of articles({ perPage: MAX_PAGE })) {
|
|
140
|
+
found.push(article.slug);
|
|
141
|
+
}
|
|
142
|
+
return found;
|
|
143
|
+
},
|
|
144
|
+
listUrl,
|
|
145
|
+
articleUrl,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
const failure = async (response) => {
|
|
149
|
+
/*
|
|
150
|
+
* Read defensively. A 502 usually comes from something in front of the API
|
|
151
|
+
* and its body is usually HTML, so a `json()` that throws must not replace
|
|
152
|
+
* the status the caller needs to see.
|
|
153
|
+
*/
|
|
154
|
+
let body = {};
|
|
155
|
+
try {
|
|
156
|
+
body = (await response.json());
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
body = {};
|
|
160
|
+
}
|
|
161
|
+
const code = typeof body.code === "string" ? body.code : null;
|
|
162
|
+
const described = `PokoBlog answered ${response.status}${code ? ` (${code})` : ""}`;
|
|
163
|
+
return response.status === 404
|
|
164
|
+
? new PokoBlogNotFoundError(described, code)
|
|
165
|
+
: new PokoBlogError(described, response.status, code);
|
|
166
|
+
};
|
|
167
|
+
/*
|
|
168
|
+
* Narrowing rather than casting.
|
|
169
|
+
*
|
|
170
|
+
* `as ArticlePage` would compile and would be a lie the moment anything but the
|
|
171
|
+
* API answers on that address -- a captive portal, a proxy error page, a base
|
|
172
|
+
* URL with a typo. The first symptom of the cast version is `articles.map is
|
|
173
|
+
* not a function` inside a render, which names neither the cause nor the URL.
|
|
174
|
+
*/
|
|
175
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
176
|
+
const malformed = (what) => new PokoBlogError(`PokoBlog: the response is not ${what}; check the base URL`, 200, null);
|
|
177
|
+
const asArticle = (value) => {
|
|
178
|
+
if (!isRecord(value))
|
|
179
|
+
throw malformed("an article");
|
|
180
|
+
const { title, slug, excerpt, description, image, imageAlt, published, modified, } = value;
|
|
181
|
+
if (typeof title !== "string" ||
|
|
182
|
+
typeof slug !== "string" ||
|
|
183
|
+
typeof modified !== "string") {
|
|
184
|
+
throw malformed("an article");
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
title,
|
|
188
|
+
slug,
|
|
189
|
+
excerpt: nullableString(excerpt),
|
|
190
|
+
description: nullableString(description),
|
|
191
|
+
image: nullableString(image),
|
|
192
|
+
imageAlt: nullableString(imageAlt),
|
|
193
|
+
published: nullableString(published),
|
|
194
|
+
modified,
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
const asArticleBody = (value) => {
|
|
198
|
+
if (!isRecord(value) || typeof value.html !== "string") {
|
|
199
|
+
throw malformed("an article with a body");
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
...asArticle(value),
|
|
203
|
+
html: value.html,
|
|
204
|
+
markdown: nullableString(value.markdown),
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
const asPage = (value) => {
|
|
208
|
+
if (!isRecord(value) || !Array.isArray(value.articles))
|
|
209
|
+
throw malformed("a listing");
|
|
210
|
+
const { nextCursor } = value;
|
|
211
|
+
if (nextCursor !== null && typeof nextCursor !== "string")
|
|
212
|
+
throw malformed("a listing");
|
|
213
|
+
return {
|
|
214
|
+
articles: value.articles.map(asArticle),
|
|
215
|
+
nextCursor,
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
const nullableString = (value) => typeof value === "string" ? value : null;
|
|
219
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { PokoBlogClient } from "./client.js";
|
|
3
|
+
import type { Article, ArticleBody } from "./types.js";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
/**
|
|
6
|
+
* Server components that put a blog in the HTML.
|
|
7
|
+
*
|
|
8
|
+
* ## `import "server-only"` is the whole point of this file
|
|
9
|
+
*
|
|
10
|
+
* Everything below could be written as a client component that fetches after
|
|
11
|
+
* hydration, and it would look identical in a browser and be worthless. AI
|
|
12
|
+
* crawlers -- GPTBot, ClaudeBot, PerplexityBot -- fetch HTML and read what
|
|
13
|
+
* comes back; they do not run JavaScript. A blog assembled after load is an
|
|
14
|
+
* empty div to every one of them, which defeats the reason these articles are
|
|
15
|
+
* written.
|
|
16
|
+
*
|
|
17
|
+
* A comment asking people not to do that would be a comment. `server-only`
|
|
18
|
+
* makes it a build error: put `"use client"` at the top of a file that imports
|
|
19
|
+
* this one and the build fails with React's own message instead of shipping a
|
|
20
|
+
* blog nothing can read.
|
|
21
|
+
*
|
|
22
|
+
* ## The markup is deliberately plain
|
|
23
|
+
*
|
|
24
|
+
* Semantic HTML, no styling, no class names of ours. A blog index is the part
|
|
25
|
+
* of a customer's site that has to look like their site, and a component with
|
|
26
|
+
* opinions about that is a component people copy out of the package and edit --
|
|
27
|
+
* at which point they own the paging, the dates and the alt text too.
|
|
28
|
+
*
|
|
29
|
+
* The parts that are *not* left to the caller are the ones with a correct
|
|
30
|
+
* answer: `<time dateTime>` in a machine-readable format, alt text that is
|
|
31
|
+
* absent rather than invented, and `html` rather than `markdown` in the body.
|
|
32
|
+
* `renderItem` is there for when the rest is not enough.
|
|
33
|
+
*/
|
|
34
|
+
export interface ArticleListProps {
|
|
35
|
+
readonly client: PokoBlogClient;
|
|
36
|
+
/** How many to show. Defaults to the API's page size of 50. */
|
|
37
|
+
readonly limit?: number;
|
|
38
|
+
/** Where an article lives on your site. Defaults to `/blog/<slug>`. */
|
|
39
|
+
readonly href?: (article: Article) => string;
|
|
40
|
+
/** Replace the whole card. The `<li>` is still ours. */
|
|
41
|
+
readonly renderItem?: (article: Article) => ReactNode;
|
|
42
|
+
/** Rendered instead of an empty `<ul>` when there are no articles. */
|
|
43
|
+
readonly empty?: ReactNode;
|
|
44
|
+
readonly className?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The blog index, in the HTML of the response.
|
|
48
|
+
*
|
|
49
|
+
* One request: a card needs a title, an excerpt, a date and a picture, and the
|
|
50
|
+
* list carries all four. There is no per-article call here and there must not
|
|
51
|
+
* be one -- twenty bodies is a megabyte nobody asked for.
|
|
52
|
+
*/
|
|
53
|
+
export declare function ArticleList({ client, limit, href, renderItem, empty, className, }: ArticleListProps): Promise<import("react").JSX.Element>;
|
|
54
|
+
export interface ArticleViewProps {
|
|
55
|
+
readonly article: ArticleBody;
|
|
56
|
+
readonly className?: string;
|
|
57
|
+
/** Rendered above the body, after the heading. */
|
|
58
|
+
readonly children?: ReactNode;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* One article, body and all, in the HTML of the response.
|
|
62
|
+
*
|
|
63
|
+
* Takes the article rather than fetching it, because the page around it needs
|
|
64
|
+
* the same article for `generateMetadata` and passing it in makes that obvious.
|
|
65
|
+
* (Fetching it here twice would in fact cost one request -- Next memoises `GET`
|
|
66
|
+
* fetches within a render pass -- but a component that quietly relied on that
|
|
67
|
+
* would break the moment somebody wrapped it in a cache with a different key.)
|
|
68
|
+
*
|
|
69
|
+
* ## `dangerouslySetInnerHTML`
|
|
70
|
+
*
|
|
71
|
+
* Named alarmingly and correct here, and the reason is specific rather than
|
|
72
|
+
* general. `html` is the output of PokoBlog's allowlist renderer: a closed set
|
|
73
|
+
* of tags with every scrap of text escaped on the way in, and it is the
|
|
74
|
+
* identical string PokoBlog writes into a WordPress `wp_posts` row. Rendering
|
|
75
|
+
* it is the intended use.
|
|
76
|
+
*
|
|
77
|
+
* `markdown` is **not** interchangeable here. It is the unsanitized source and
|
|
78
|
+
* accepts raw HTML on purpose, because the renderer escapes it on the way out.
|
|
79
|
+
* Putting `markdown` through this prop -- or through a markdown renderer with
|
|
80
|
+
* raw HTML enabled, which is most of them by default -- undoes the sanitizing
|
|
81
|
+
* this field exists to have already done.
|
|
82
|
+
*/
|
|
83
|
+
export declare function ArticleView({ article, className, children, }: ArticleViewProps): import("react").JSX.Element;
|
|
84
|
+
//# sourceMappingURL=components.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../src/components.tsx"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,+DAA+D;IAC/D,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,uEAAuE;IACvE,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC;IAC7C,wDAAwD;IACxD,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,SAAS,CAAC;IACtD,sEAAsE;IACtE,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAAC,EAChC,MAAM,EACN,KAAK,EACL,IAA2C,EAC3C,UAAU,EACV,KAAK,EACL,SAAS,GACV,EAAE,gBAAgB,wCA0ClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,kDAAkD;IAClD,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;CAC/B;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,SAAS,EACT,QAAQ,GACT,EAAE,gBAAgB,+BAalB"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import "server-only";
|
|
3
|
+
/**
|
|
4
|
+
* The blog index, in the HTML of the response.
|
|
5
|
+
*
|
|
6
|
+
* One request: a card needs a title, an excerpt, a date and a picture, and the
|
|
7
|
+
* list carries all four. There is no per-article call here and there must not
|
|
8
|
+
* be one -- twenty bodies is a megabyte nobody asked for.
|
|
9
|
+
*/
|
|
10
|
+
export async function ArticleList({ client, limit, href = (article) => `/blog/${article.slug}`, renderItem, empty, className, }) {
|
|
11
|
+
const { articles } = await client.page(limit === undefined ? {} : { limit });
|
|
12
|
+
if (articles.length === 0 && empty !== undefined)
|
|
13
|
+
return _jsx(_Fragment, { children: empty });
|
|
14
|
+
return (_jsx("ul", { className: className, children: articles.map((article) => (_jsx("li", { children: renderItem ? (renderItem(article)) : (_jsxs("article", { children: [article.image ? (_jsx("img", { src: article.image, alt: article.imageAlt ?? "" })) : null, _jsx("h2", { children: _jsx("a", { href: href(article), children: article.title }) }), _jsx(PublishedAt, { article: article }), article.excerpt ? _jsx("p", { children: article.excerpt }) : null] })) }, article.slug))) }));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* One article, body and all, in the HTML of the response.
|
|
18
|
+
*
|
|
19
|
+
* Takes the article rather than fetching it, because the page around it needs
|
|
20
|
+
* the same article for `generateMetadata` and passing it in makes that obvious.
|
|
21
|
+
* (Fetching it here twice would in fact cost one request -- Next memoises `GET`
|
|
22
|
+
* fetches within a render pass -- but a component that quietly relied on that
|
|
23
|
+
* would break the moment somebody wrapped it in a cache with a different key.)
|
|
24
|
+
*
|
|
25
|
+
* ## `dangerouslySetInnerHTML`
|
|
26
|
+
*
|
|
27
|
+
* Named alarmingly and correct here, and the reason is specific rather than
|
|
28
|
+
* general. `html` is the output of PokoBlog's allowlist renderer: a closed set
|
|
29
|
+
* of tags with every scrap of text escaped on the way in, and it is the
|
|
30
|
+
* identical string PokoBlog writes into a WordPress `wp_posts` row. Rendering
|
|
31
|
+
* it is the intended use.
|
|
32
|
+
*
|
|
33
|
+
* `markdown` is **not** interchangeable here. It is the unsanitized source and
|
|
34
|
+
* accepts raw HTML on purpose, because the renderer escapes it on the way out.
|
|
35
|
+
* Putting `markdown` through this prop -- or through a markdown renderer with
|
|
36
|
+
* raw HTML enabled, which is most of them by default -- undoes the sanitizing
|
|
37
|
+
* this field exists to have already done.
|
|
38
|
+
*/
|
|
39
|
+
export function ArticleView({ article, className, children, }) {
|
|
40
|
+
return (_jsxs("article", { className: className, children: [_jsx("h1", { children: article.title }), _jsx(PublishedAt, { article: article }), article.image ? (_jsx("img", { src: article.image, alt: article.imageAlt ?? "" })) : null, children, _jsx("div", { dangerouslySetInnerHTML: { __html: article.html } })] }));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `<time dateTime="2026-08-25">25 augustus 2026</time>`, or nothing.
|
|
44
|
+
*
|
|
45
|
+
* The attribute is the machine-readable half and is what a crawler reads; the
|
|
46
|
+
* text is for a person. Both, or the date is only half published -- and the
|
|
47
|
+
* attribute is the ten-character date rather than the full instant, because
|
|
48
|
+
* that is the form `<time>` is defined for and the one search engines parse
|
|
49
|
+
* most reliably.
|
|
50
|
+
*
|
|
51
|
+
* `published` is null for an article with no publish date, and a `<time>` with
|
|
52
|
+
* an empty `dateTime` is invalid markup, so there is nothing to render.
|
|
53
|
+
*/
|
|
54
|
+
function PublishedAt({ article }) {
|
|
55
|
+
if (article.published === null)
|
|
56
|
+
return null;
|
|
57
|
+
const date = article.published.slice(0, 10);
|
|
58
|
+
return _jsx("time", { dateTime: date, children: date });
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=components.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"components.js","sourceRoot":"","sources":["../src/components.tsx"],"names":[],"mappings":";AAAA,OAAO,aAAa,CAAC;AAgDrB;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,EAChC,MAAM,EACN,KAAK,EACL,IAAI,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,SAAS,OAAO,CAAC,IAAI,EAAE,EAC3C,UAAU,EACV,KAAK,EACL,SAAS,GACQ;IACjB,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IAE7E,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,4BAAG,KAAK,GAAI,CAAC;IAEtE,OAAO,CACL,aAAI,SAAS,EAAE,SAAS,YACrB,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CACzB,uBACG,UAAU,CAAC,CAAC,CAAC,CACZ,UAAU,CAAC,OAAO,CAAC,CACpB,CAAC,CAAC,CAAC,CACF,8BACG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAgBf,cAAK,GAAG,EAAE,OAAO,CAAC,KAAK,EAAE,GAAG,EAAE,OAAO,CAAC,QAAQ,IAAI,EAAE,GAAI,CACzD,CAAC,CAAC,CAAC,IAAI,EACR,uBACE,YAAG,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,YAAG,OAAO,CAAC,KAAK,GAAK,GACxC,EACL,KAAC,WAAW,IAAC,OAAO,EAAE,OAAO,GAAI,EAChC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,sBAAI,OAAO,CAAC,OAAO,GAAK,CAAC,CAAC,CAAC,IAAI,IAC1C,CACX,IA7BM,OAAO,CAAC,IAAI,CA8BhB,CACN,CAAC,GACC,CACN,CAAC;AACJ,CAAC;AASD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,WAAW,CAAC,EAC1B,OAAO,EACP,SAAS,EACT,QAAQ,GACS;IACjB,OAAO,CACL,mBAAS,SAAS,EAAE,SAAS,aAC3B,uBAAK,OAAO,CAAC,KAAK,GAAM,EACxB,KAAC,WAAW,IAAC,OAAO,EAAE,OAAO,GAAI,EAChC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAEf,cAAK,GAAG,EAAE,OAAO,CAAC,KAAK,EAAE,GAAG,EAAE,OAAO,CAAC,QAAQ,IAAI,EAAE,GAAI,CACzD,CAAC,CAAC,CAAC,IAAI,EACP,QAAQ,EACT,cAAK,uBAAuB,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,IAAI,EAAE,GAAI,IAClD,CACX,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAS,WAAW,CAAC,EAAE,OAAO,EAAiC;IAC7D,IAAI,OAAO,CAAC,SAAS,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAE5C,MAAM,IAAI,GAAG,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAE5C,OAAO,eAAM,QAAQ,EAAE,IAAI,YAAG,IAAI,GAAQ,CAAC;AAC7C,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createPokoBlog, PokoBlogError, PokoBlogNotFoundError, MAX_PAGE, PAGE, } from "./client.js";
|
|
2
|
+
export { ArticleList, ArticleView } from "./components.js";
|
|
3
|
+
export { articleMetadata, blogMetadata } from "./metadata.js";
|
|
4
|
+
export type { PokoBlogClient, PokoBlogOptions } from "./client.js";
|
|
5
|
+
export type { ArticleListProps, ArticleViewProps } from "./components.js";
|
|
6
|
+
export type { ArticleMetadataOptions, BlogMetadataOptions, } from "./metadata.js";
|
|
7
|
+
export type { ApiError, Article, ArticleBody, ArticlePage } from "./types.js";
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,aAAa,EACb,qBAAqB,EACrB,QAAQ,EACR,IAAI,GACL,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE9D,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAC1E,YAAY,EACV,sBAAsB,EACtB,mBAAmB,GACpB,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,aAAa,EACb,qBAAqB,EACrB,QAAQ,EACR,IAAI,GACL,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Article, ArticleBody } from "./types.js";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
/**
|
|
4
|
+
* The `<head>` for one article, built from the article.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this is not a component
|
|
7
|
+
*
|
|
8
|
+
* Metadata in the App Router is a returned object, not markup. Next resolves
|
|
9
|
+
* `generateMetadata` as part of rendering the page and puts the tags in the
|
|
10
|
+
* **initial HTML** -- which is the only version a crawler that does not run
|
|
11
|
+
* JavaScript will ever see, and those are the crawlers this product is for. A
|
|
12
|
+
* component rendering `<meta>` tags into the body would not be in the head, and
|
|
13
|
+
* a client component setting `document.title` would not be in the response at
|
|
14
|
+
* all.
|
|
15
|
+
*
|
|
16
|
+
* ## Calling it costs nothing extra
|
|
17
|
+
*
|
|
18
|
+
* `generateMetadata` and the page component both need the article, and both
|
|
19
|
+
* fetching it is the obvious worry. It is not one: `fetch` GETs with the same
|
|
20
|
+
* URL and options are memoised across `generateMetadata`, layouts and the page
|
|
21
|
+
* within a single render pass, so the second call is the first call's result.
|
|
22
|
+
* That is why this package hands you a function of an article rather than
|
|
23
|
+
* asking you to thread one down from the metadata into the page.
|
|
24
|
+
*/
|
|
25
|
+
export interface ArticleMetadataOptions {
|
|
26
|
+
/** The article. Either shape -- the body is not used. */
|
|
27
|
+
readonly article: Article | ArticleBody;
|
|
28
|
+
/**
|
|
29
|
+
* The canonical address of this page on *your* site.
|
|
30
|
+
*
|
|
31
|
+
* Absolute, or relative to the `metadataBase` in your root layout. Worth
|
|
32
|
+
* passing: it is what stops the same article being indexed separately under
|
|
33
|
+
* every query string that reaches it.
|
|
34
|
+
*/
|
|
35
|
+
readonly url?: string;
|
|
36
|
+
/** Your site's name, for `og:site_name`. */
|
|
37
|
+
readonly siteName?: string;
|
|
38
|
+
/** e.g. `"nl_NL"`. */
|
|
39
|
+
readonly locale?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare const articleMetadata: ({ article, url, siteName, locale, }: ArticleMetadataOptions) => Metadata;
|
|
42
|
+
export interface BlogMetadataOptions {
|
|
43
|
+
readonly title: string;
|
|
44
|
+
readonly description?: string;
|
|
45
|
+
readonly url?: string;
|
|
46
|
+
readonly siteName?: string;
|
|
47
|
+
readonly locale?: string;
|
|
48
|
+
}
|
|
49
|
+
/** The `<head>` for the index page. `website`, not `article`. */
|
|
50
|
+
export declare const blogMetadata: ({ title, description, url, siteName, locale, }: BlogMetadataOptions) => Metadata;
|
|
51
|
+
//# sourceMappingURL=metadata.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata.d.ts","sourceRoot":"","sources":["../src/metadata.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,MAAM,CAAC;AAErC;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,WAAW,sBAAsB;IACrC,yDAAyD;IACzD,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,WAAW,CAAC;IACxC;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,sBAAsB;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,eAAO,MAAM,eAAe,wCAKzB,sBAAsB,KAAG,QAgE3B,CAAC;AAEF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,iEAAiE;AACjE,eAAO,MAAM,YAAY,mDAMtB,mBAAmB,KAAG,QAYvB,CAAC"}
|
package/dist/metadata.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const articleMetadata = ({ article, url, siteName, locale, }) => {
|
|
2
|
+
/*
|
|
3
|
+
* The meta description if there is one, the excerpt if there is not.
|
|
4
|
+
*
|
|
5
|
+
* They are different fields on purpose -- the description is written for a
|
|
6
|
+
* search result and the excerpt is the line under a title in a list -- so
|
|
7
|
+
* this is a fallback and not an equivalence. It exists because the failure it
|
|
8
|
+
* prevents is worse than the imprecision it accepts: a page with no
|
|
9
|
+
* description at all gets whatever sentence the search engine picks out of
|
|
10
|
+
* the body, and for an article whose first line is a heading that is usually
|
|
11
|
+
* the heading.
|
|
12
|
+
*/
|
|
13
|
+
const description = article.description ?? article.excerpt ?? undefined;
|
|
14
|
+
/*
|
|
15
|
+
* The alt travels with the image and is not invented when it is missing.
|
|
16
|
+
*
|
|
17
|
+
* `imageAlt: null` means nobody has written alt text, which is not the same
|
|
18
|
+
* as `alt=""`. Substituting the title here would describe the article rather
|
|
19
|
+
* than the picture, in the one place a screen reader is most likely to read
|
|
20
|
+
* it out.
|
|
21
|
+
*/
|
|
22
|
+
const images = article.image
|
|
23
|
+
? [
|
|
24
|
+
{
|
|
25
|
+
url: article.image,
|
|
26
|
+
...(article.imageAlt === null ? {} : { alt: article.imageAlt }),
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
: undefined;
|
|
30
|
+
return {
|
|
31
|
+
title: article.title,
|
|
32
|
+
...(description ? { description } : {}),
|
|
33
|
+
...(url ? { alternates: { canonical: url } } : {}),
|
|
34
|
+
openGraph: {
|
|
35
|
+
/*
|
|
36
|
+
* `article`, not `website`. It is what carries `article:published_time`
|
|
37
|
+
* and `article:modified_time`, and those are how a reader -- or a model
|
|
38
|
+
* summarising the page -- knows whether they are looking at something
|
|
39
|
+
* from this week or from 2019.
|
|
40
|
+
*/
|
|
41
|
+
type: "article",
|
|
42
|
+
title: article.title,
|
|
43
|
+
...(description ? { description } : {}),
|
|
44
|
+
...(url ? { url } : {}),
|
|
45
|
+
...(siteName ? { siteName } : {}),
|
|
46
|
+
...(locale ? { locale } : {}),
|
|
47
|
+
...(article.published ? { publishedTime: article.published } : {}),
|
|
48
|
+
modifiedTime: article.modified,
|
|
49
|
+
...(images ? { images } : {}),
|
|
50
|
+
},
|
|
51
|
+
twitter: {
|
|
52
|
+
/*
|
|
53
|
+
* `summary_large_image` when there is a picture and `summary` when there
|
|
54
|
+
* is not. Claiming the large card without an image gets a card with a
|
|
55
|
+
* blank rectangle where the picture should be.
|
|
56
|
+
*/
|
|
57
|
+
card: article.image ? "summary_large_image" : "summary",
|
|
58
|
+
title: article.title,
|
|
59
|
+
...(description ? { description } : {}),
|
|
60
|
+
...(images ? { images } : {}),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
/** The `<head>` for the index page. `website`, not `article`. */
|
|
65
|
+
export const blogMetadata = ({ title, description, url, siteName, locale, }) => ({
|
|
66
|
+
title,
|
|
67
|
+
...(description ? { description } : {}),
|
|
68
|
+
...(url ? { alternates: { canonical: url } } : {}),
|
|
69
|
+
openGraph: {
|
|
70
|
+
type: "website",
|
|
71
|
+
title,
|
|
72
|
+
...(description ? { description } : {}),
|
|
73
|
+
...(url ? { url } : {}),
|
|
74
|
+
...(siteName ? { siteName } : {}),
|
|
75
|
+
...(locale ? { locale } : {}),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
//# sourceMappingURL=metadata.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata.js","sourceRoot":"","sources":["../src/metadata.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes `GET /api/connectors/:token/articles` actually answers with.
|
|
3
|
+
*
|
|
4
|
+
* Written from responses that were recorded off the running API rather than
|
|
5
|
+
* from the server's own types: a type copied across agrees with the server it
|
|
6
|
+
* was copied from and with nothing else, and the day the two repositories drift
|
|
7
|
+
* is the day the copy stops being a check on anything.
|
|
8
|
+
*
|
|
9
|
+
* Every field is `readonly`. These objects come out of a network response and
|
|
10
|
+
* nothing downstream has any business editing one in place -- a component that
|
|
11
|
+
* did would be editing an object another component is also rendering, since a
|
|
12
|
+
* `fetch` inside one render pass is memoised and hands the same value to
|
|
13
|
+
* everybody who asked.
|
|
14
|
+
*/
|
|
15
|
+
/** One published article, as the list answers. No body -- see {@link ArticleBody}. */
|
|
16
|
+
export interface Article {
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly slug: string;
|
|
19
|
+
/** The line under the title in a list. Not the meta description. */
|
|
20
|
+
readonly excerpt: string | null;
|
|
21
|
+
/** The sentence written for a search result. Not the excerpt. */
|
|
22
|
+
readonly description: string | null;
|
|
23
|
+
readonly image: string | null;
|
|
24
|
+
/**
|
|
25
|
+
* The alt text, and `null` when there is none.
|
|
26
|
+
*
|
|
27
|
+
* `null` is not `alt=""`. The empty string tells a screen reader the picture
|
|
28
|
+
* is decoration, which is a claim the article's author did not make. Decide
|
|
29
|
+
* once, in your own component, what to do when it is null.
|
|
30
|
+
*/
|
|
31
|
+
readonly imageAlt: string | null;
|
|
32
|
+
/** ISO 8601, UTC. */
|
|
33
|
+
readonly published: string | null;
|
|
34
|
+
/** ISO 8601, UTC. The row's last write, which a publish also moves. */
|
|
35
|
+
readonly modified: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* One article with its body.
|
|
39
|
+
*
|
|
40
|
+
* **`html` is the field to render.** It is the output of PokoBlog's allowlist
|
|
41
|
+
* renderer and is the identical string PokoBlog writes into a WordPress post.
|
|
42
|
+
*
|
|
43
|
+
* **`markdown` is the source and it is not sanitized.** It is here for
|
|
44
|
+
* consumers that genuinely re-render -- an MDX pipeline, a native app, a search
|
|
45
|
+
* index -- and any renderer pointed at it must have raw HTML disabled. It is
|
|
46
|
+
* `null` for an article written before the field existed.
|
|
47
|
+
*/
|
|
48
|
+
export interface ArticleBody extends Article {
|
|
49
|
+
readonly html: string;
|
|
50
|
+
readonly markdown: string | null;
|
|
51
|
+
}
|
|
52
|
+
/** One page of the list, and where the next one starts. */
|
|
53
|
+
export interface ArticlePage {
|
|
54
|
+
readonly articles: readonly Article[];
|
|
55
|
+
/** `null` on the last page, and never absent. */
|
|
56
|
+
readonly nextCursor: string | null;
|
|
57
|
+
}
|
|
58
|
+
/** The error body every non-2xx on this API carries. */
|
|
59
|
+
export interface ApiError {
|
|
60
|
+
readonly code: string;
|
|
61
|
+
readonly message: string;
|
|
62
|
+
readonly status: number;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,sFAAsF;AACtF,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,oEAAoE;IACpE,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,iEAAiE;IACjE,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,qBAAqB;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,uEAAuE;IACvE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAY,SAAQ,OAAO;IAC1C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,2DAA2D;AAC3D,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,iDAAiD;IACjD,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,wDAAwD;AACxD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes `GET /api/connectors/:token/articles` actually answers with.
|
|
3
|
+
*
|
|
4
|
+
* Written from responses that were recorded off the running API rather than
|
|
5
|
+
* from the server's own types: a type copied across agrees with the server it
|
|
6
|
+
* was copied from and with nothing else, and the day the two repositories drift
|
|
7
|
+
* is the day the copy stops being a check on anything.
|
|
8
|
+
*
|
|
9
|
+
* Every field is `readonly`. These objects come out of a network response and
|
|
10
|
+
* nothing downstream has any business editing one in place -- a component that
|
|
11
|
+
* did would be editing an object another component is also rendering, since a
|
|
12
|
+
* `fetch` inside one render pass is memoised and hands the same value to
|
|
13
|
+
* everybody who asked.
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pokoblog/next",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Server components and metadata helpers for rendering a PokoBlog blog in Next.js.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./client": {
|
|
19
|
+
"types": "./dist/client.d.ts",
|
|
20
|
+
"default": "./dist/client.js"
|
|
21
|
+
},
|
|
22
|
+
"./metadata": {
|
|
23
|
+
"types": "./dist/metadata.d.ts",
|
|
24
|
+
"default": "./dist/metadata.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.build.json",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"prepublishOnly": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"server-only": "0.0.1"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"next": "^16.0.0",
|
|
38
|
+
"react": "^19.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^24.0.0",
|
|
42
|
+
"@types/react": "19.2.18",
|
|
43
|
+
"next": "16.3.1",
|
|
44
|
+
"react": "19.2.8",
|
|
45
|
+
"react-dom": "19.2.8",
|
|
46
|
+
"typescript": "7.0.2",
|
|
47
|
+
"vitest": "4.1.10"
|
|
48
|
+
}
|
|
49
|
+
}
|