@solutiondesk/blog 1.0.0-1edea54 → 1.0.0-2914fd8
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 +30 -1
- package/dist/index.cjs +65 -0
- package/dist/index.d.cts +91 -2
- package/dist/index.d.ts +91 -2
- package/dist/index.js +64 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ const { post } = await blog.getPost("metadata-that-pays-off");
|
|
|
31
31
|
| --- | --- | --- |
|
|
32
32
|
| `listPosts({ lang, tag, author, limit, cursor })` | `GET /v1/blog/{workspace}/posts` | One page, newest first, without the text; `nextCursor` for the next page (`limit` 1–50, default 20). |
|
|
33
33
|
| `allPosts({ lang, tag, author })` | the same, page by page | An async iterator over every published post, e.g. for a sitemap. |
|
|
34
|
-
| `getPost(slug, { lang, redirect })` | `GET /v1/blog/{workspace}/posts/{slug}` | One post with `markdown`, `html`, `headings` and `
|
|
34
|
+
| `getPost(slug, { lang, redirect })` | `GET /v1/blog/{workspace}/posts/{slug}` | One post with `markdown`, `html`, `headings`, `readingMinutes` and the blog's own building blocks it uses (`customBlocks`, `blocks`). |
|
|
35
35
|
| `listTags({ lang })` | `GET /v1/blog/{workspace}/tags` | Every tag with its label and how many published posts carry it. |
|
|
36
36
|
| `listAuthors({ lang })` | `GET /v1/blog/{workspace}/authors` | Every author with their role. |
|
|
37
37
|
| `getPreview(token, { lang })` | `GET /v1/blog/{workspace}/preview?token=` | The current state of a post, drafts too, for your preview route. |
|
|
@@ -83,6 +83,35 @@ export default async function PostPage({ params }: { params: Promise<{ slug: str
|
|
|
83
83
|
|
|
84
84
|
Anything that runs on a server works the same way: Astro, Remix, SvelteKit, Nuxt or a plain Node script that renders your site at build time.
|
|
85
85
|
|
|
86
|
+
## Your own building blocks
|
|
87
|
+
|
|
88
|
+
In SolutionDesk, the blog can define its own building blocks under **Blog → Website**: what a block is called, whether it sits in a sentence, on its own line or around content, and which fields it has — a newsletter box, a product card, an app button. Writers add them to posts from **Block**. How a block looks is up to your website.
|
|
89
|
+
|
|
90
|
+
In `html`, every such block is a neutral element with its **text version** (set in SolutionDesk, e.g. `Subscribe to Weekly tips`), so a page without your markup still reads well:
|
|
91
|
+
|
|
92
|
+
| Kind | HTML |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| in a sentence | `<span class="sd-block" data-block="app-button" data-props="{…}">…</span>` |
|
|
95
|
+
| on its own line | `<div class="sd-block" data-block="newsletter" data-props="{…}">…</div>` |
|
|
96
|
+
| around content | `<div class="sd-block" …><p class="sd-block-text">…</p><div class="sd-block-body">…</div></div>` |
|
|
97
|
+
|
|
98
|
+
`data-props` holds the block's field values as JSON, already checked the way SolutionDesk checks them: text fields as strings, on/off fields as `true`/`false`; a value that isn't valid (an unsafe address, a picture that isn't from your blog, an option that isn't offered) is left out. `post.customBlocks` names the blocks a post uses, `post.blocks` describes them (name, kind, fields, text version template).
|
|
99
|
+
|
|
100
|
+
`renderBlogBlocks` swaps them for your own markup, by block name:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { escapeBlogHtml, renderBlogBlocks } from "@solutiondesk/blog";
|
|
104
|
+
|
|
105
|
+
const html = renderBlogBlocks(post.html, {
|
|
106
|
+
newsletter: ({ props }) =>
|
|
107
|
+
`<newsletter-signup list="${escapeBlogHtml(String(props.list ?? ""))}"></newsletter-signup>`,
|
|
108
|
+
aside: ({ props, childrenHtml }) =>
|
|
109
|
+
`<aside class="note"><h4>${escapeBlogHtml(String(props.title ?? ""))}</h4>${childrenHtml}</aside>`,
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Your renderer gets `block` (the name), `props`, `inline`, `textHtml` (the text version) and, for a block around content, `childrenHtml` (that content, with blocks inside already rendered). Return `undefined` to keep the neutral element. Values in `props` are raw text — escape them with `escapeBlogHtml` before putting them into HTML. A block without a renderer keeps its text version. With React, you can also hydrate your own components from `data-block` and `data-props` with an HTML parser of your choice.
|
|
114
|
+
|
|
86
115
|
## Moved articles
|
|
87
116
|
|
|
88
117
|
When the team changes an article's address, the old slug answers with a `301` to the new one.
|
package/dist/index.cjs
CHANGED
|
@@ -257,6 +257,69 @@ async function parseWebhook(secret, rawBody, header, options = {}) {
|
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// src/blocks.ts
|
|
261
|
+
function escapeBlogHtml(value) {
|
|
262
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
263
|
+
}
|
|
264
|
+
function unescapeAttribute(value) {
|
|
265
|
+
return value.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
266
|
+
}
|
|
267
|
+
var OPEN = /<(span|div) class="sd-block" data-block="([^"]*)" data-props="([^"]*)">/g;
|
|
268
|
+
function closingEnd(html, tag, from) {
|
|
269
|
+
const pattern = new RegExp(`<${tag}[\\s>]|</${tag}>`, "g");
|
|
270
|
+
pattern.lastIndex = from;
|
|
271
|
+
let depth = 1;
|
|
272
|
+
for (let match = pattern.exec(html); match; match = pattern.exec(html)) {
|
|
273
|
+
depth += match[0].startsWith("</") ? -1 : 1;
|
|
274
|
+
if (depth === 0) return match.index + match[0].length;
|
|
275
|
+
}
|
|
276
|
+
return html.length;
|
|
277
|
+
}
|
|
278
|
+
function parseProps(raw) {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(unescapeAttribute(raw));
|
|
281
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
282
|
+
const props = {};
|
|
283
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
284
|
+
if (typeof value === "string" || typeof value === "boolean") props[key] = value;
|
|
285
|
+
}
|
|
286
|
+
return props;
|
|
287
|
+
} catch {
|
|
288
|
+
return {};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function renderBlogBlocks(html, renderers) {
|
|
292
|
+
let out = "";
|
|
293
|
+
let pos = 0;
|
|
294
|
+
OPEN.lastIndex = 0;
|
|
295
|
+
for (let open = OPEN.exec(html); open; open = OPEN.exec(html)) {
|
|
296
|
+
const [whole, tag = "div", name = "", rawProps = ""] = open;
|
|
297
|
+
const start = open.index;
|
|
298
|
+
const contentStart = start + whole.length;
|
|
299
|
+
const end = closingEnd(html, tag, contentStart);
|
|
300
|
+
const inner = html.slice(contentStart, end - `</${tag}>`.length);
|
|
301
|
+
const body = /^(?:<p class="sd-block-text">([\s\S]*?)<\/p>)?<div class="sd-block-body">([\s\S]*)<\/div>$/.exec(
|
|
302
|
+
inner
|
|
303
|
+
);
|
|
304
|
+
const element = {
|
|
305
|
+
block: unescapeAttribute(name),
|
|
306
|
+
props: parseProps(rawProps),
|
|
307
|
+
inline: tag === "span",
|
|
308
|
+
textHtml: body ? body[1] ?? "" : inner,
|
|
309
|
+
childrenHtml: body ? renderBlogBlocks(body[2] ?? "", renderers) : null
|
|
310
|
+
};
|
|
311
|
+
const own = renderers[element.block]?.(element);
|
|
312
|
+
out += html.slice(pos, start);
|
|
313
|
+
if (own !== void 0) out += own;
|
|
314
|
+
else if (body) {
|
|
315
|
+
out += `${whole}${body[1] !== void 0 ? `<p class="sd-block-text">${body[1]}</p>` : ""}<div class="sd-block-body">${element.childrenHtml}</div></${tag}>`;
|
|
316
|
+
} else out += html.slice(start, end);
|
|
317
|
+
pos = end;
|
|
318
|
+
OPEN.lastIndex = end;
|
|
319
|
+
}
|
|
320
|
+
return out + html.slice(pos);
|
|
321
|
+
}
|
|
322
|
+
|
|
260
323
|
// src/client.ts
|
|
261
324
|
function createBlogClient(options) {
|
|
262
325
|
const workspace = required(options.workspace, "workspace");
|
|
@@ -350,7 +413,9 @@ exports.SIGNATURE_HEADER = SIGNATURE_HEADER;
|
|
|
350
413
|
exports.SolutionDeskError = SolutionDeskError;
|
|
351
414
|
exports.createBlogClient = createBlogClient;
|
|
352
415
|
exports.createMemoryEtagCache = createMemoryEtagCache;
|
|
416
|
+
exports.escapeBlogHtml = escapeBlogHtml;
|
|
353
417
|
exports.imageUrl = imageUrl;
|
|
354
418
|
exports.imageWidthFor = imageWidthFor;
|
|
355
419
|
exports.parseBlogWebhook = parseBlogWebhook;
|
|
420
|
+
exports.renderBlogBlocks = renderBlogBlocks;
|
|
356
421
|
exports.verifyWebhookSignature = verifyWebhookSignature;
|
package/dist/index.d.cts
CHANGED
|
@@ -144,6 +144,56 @@ type WebhookBody = string | Uint8Array | ArrayBuffer;
|
|
|
144
144
|
*/
|
|
145
145
|
declare function verifyWebhookSignature(secret: string, rawBody: WebhookBody, header: string | null | undefined, options?: VerifyWebhookOptions): Promise<boolean>;
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The blog's own building blocks in a post's `html` (contract in SolutionDesk's
|
|
149
|
+
* `docs/help/06-eigene-bausteine.md`): the API renders each one as a neutral
|
|
150
|
+
* element — `<span class="sd-block" data-block="…" data-props="…">` in a
|
|
151
|
+
* sentence, `<div class="sd-block" …>` on its own line, and around content
|
|
152
|
+
* `<div class="sd-block" …><p class="sd-block-text">…</p><div class="sd-block-body">…</div></div>`.
|
|
153
|
+
* The text inside is the block's text version; `data-props` its checked field
|
|
154
|
+
* values as JSON. `renderBlogBlocks` swaps them for your own markup.
|
|
155
|
+
*/
|
|
156
|
+
/** A field's checked value: `boolean` fields are true/false, every other type a string. */
|
|
157
|
+
type BlogBlockValue = string | boolean;
|
|
158
|
+
/** One block found in the HTML, handed to your renderer. */
|
|
159
|
+
interface BlogBlockElement {
|
|
160
|
+
/** The block's name (`newsletter-box`). */
|
|
161
|
+
block: string;
|
|
162
|
+
/**
|
|
163
|
+
* Its field values, already checked by SolutionDesk (an invalid value is left
|
|
164
|
+
* out, so every field may be missing). Strings are raw text — escape them
|
|
165
|
+
* (`escapeBlogHtml`) when you put them into HTML.
|
|
166
|
+
*/
|
|
167
|
+
props: Record<string, BlogBlockValue>;
|
|
168
|
+
/** `inline` (a `<span>` in a sentence) or a `<div>` (`block` or `container`). */
|
|
169
|
+
inline: boolean;
|
|
170
|
+
/** The text version as HTML (escaped), what the element shows without your markup. */
|
|
171
|
+
textHtml: string;
|
|
172
|
+
/**
|
|
173
|
+
* For a block around content: that content as HTML (own blocks inside are
|
|
174
|
+
* already rendered); otherwise null.
|
|
175
|
+
*/
|
|
176
|
+
childrenHtml: string | null;
|
|
177
|
+
}
|
|
178
|
+
/** Your markup for a block, as HTML; `undefined` keeps SolutionDesk's neutral element. */
|
|
179
|
+
type BlogBlockRenderer = (element: BlogBlockElement) => string | undefined;
|
|
180
|
+
/** `&`, `<`, `>`, `"` and `'` as character references. */
|
|
181
|
+
declare function escapeBlogHtml(value: string): string;
|
|
182
|
+
/**
|
|
183
|
+
* The post's `html` with the blog's own blocks rendered by `renderers` (by
|
|
184
|
+
* block name). A block without a renderer — or whose renderer returns
|
|
185
|
+
* `undefined` — keeps the neutral element with its text version. Blocks inside
|
|
186
|
+
* a container are rendered first and reach its renderer in `childrenHtml`.
|
|
187
|
+
*
|
|
188
|
+
* ```ts
|
|
189
|
+
* const html = renderBlogBlocks(post.html, {
|
|
190
|
+
* "newsletter-box": ({ props }) =>
|
|
191
|
+
* `<newsletter-form list="${escapeBlogHtml(String(props.list ?? ""))}"></newsletter-form>`,
|
|
192
|
+
* });
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
declare function renderBlogBlocks(html: string, renderers: Readonly<Record<string, BlogBlockRenderer>>): string;
|
|
196
|
+
|
|
147
197
|
/**
|
|
148
198
|
* The answers of the public blog API (`GET /v1/blog/{workspace}/…`). Written by
|
|
149
199
|
* hand so the package has no dependencies; a drift test in the SolutionDesk
|
|
@@ -212,13 +262,52 @@ interface PublicBlogPostSummary {
|
|
|
212
262
|
languages: string[];
|
|
213
263
|
readingMinutes: number;
|
|
214
264
|
}
|
|
265
|
+
/** What a field of one of the blog's own blocks holds. */
|
|
266
|
+
type PublicBlogBlockFieldType = "text" | "longText" | "icon" | "url" | "choice" | "image" | "video" | "boolean";
|
|
267
|
+
/**
|
|
268
|
+
* One field of a block: an attribute of its directive (`key="value"`).
|
|
269
|
+
* `boolean` fields are true/false, every other value a string. `options` lists
|
|
270
|
+
* a `choice` field's values (null for other types).
|
|
271
|
+
*/
|
|
272
|
+
interface PublicBlogBlockField {
|
|
273
|
+
key: string;
|
|
274
|
+
label: string;
|
|
275
|
+
type: PublicBlogBlockFieldType;
|
|
276
|
+
required: boolean;
|
|
277
|
+
options: string[] | null;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* A building block the blog defines itself — WHAT it is; how it looks is your
|
|
281
|
+
* website's own component. Written in Markdown as `:name[label]{…}` in running
|
|
282
|
+
* text (`inline`), `::name[label]{…}` alone on a line (`block`) or
|
|
283
|
+
* `:::name{…}` … `:::` around content (`container`). `text` is the template of
|
|
284
|
+
* its text version (`{key}` = a field's value).
|
|
285
|
+
*/
|
|
286
|
+
interface PublicBlogBlockDefinition {
|
|
287
|
+
/** The directive name (`newsletter-box`). */
|
|
288
|
+
name: string;
|
|
289
|
+
/** Display name. */
|
|
290
|
+
label: string;
|
|
291
|
+
kind: "inline" | "block" | "container";
|
|
292
|
+
fields: PublicBlogBlockField[];
|
|
293
|
+
text: string;
|
|
294
|
+
}
|
|
215
295
|
/** One post with its text. */
|
|
216
296
|
interface PublicBlogPost extends PublicBlogPostSummary {
|
|
297
|
+
/** The post's Markdown, the blog's own building blocks as written (see `blocks`). */
|
|
217
298
|
markdown: string;
|
|
218
|
-
/**
|
|
299
|
+
/**
|
|
300
|
+
* The Markdown as safe HTML (headings carry `id`s). The blog's own blocks are
|
|
301
|
+
* neutral `sd-block` elements: `data-block` the name, `data-props` the checked
|
|
302
|
+
* values as JSON, the text version inside — see `renderBlogBlocks`.
|
|
303
|
+
*/
|
|
219
304
|
html: string;
|
|
220
305
|
/** The outline, for a table of contents. */
|
|
221
306
|
headings: BlogHeading[];
|
|
307
|
+
/** The blog's own building blocks the post uses, by name (in the blog's order). */
|
|
308
|
+
customBlocks: string[];
|
|
309
|
+
/** The definitions of those blocks (empty when the post uses none). */
|
|
310
|
+
blocks: PublicBlogBlockDefinition[];
|
|
222
311
|
closing: PublicBlogClosing | null;
|
|
223
312
|
}
|
|
224
313
|
/** `GET …/posts`. */
|
|
@@ -346,4 +435,4 @@ declare function createBlogClient(options: BlogClientOptions): BlogClient;
|
|
|
346
435
|
*/
|
|
347
436
|
declare function parseBlogWebhook(secret: string, rawBody: WebhookBody, signatureHeader: string | null | undefined, options?: VerifyWebhookOptions): Promise<BlogWebhookBody | null>;
|
|
348
437
|
|
|
349
|
-
export { type BaseClientOptions, type BlogClient, type BlogClientOptions, type BlogHeading, type BlogPostMoved, type BlogPostResult, type BlogWebhookBody, type BlogWebhookEvent, type CachedResponse, DEFAULT_BASE_URL, EVENT_HEADER, type EtagCache, type FetchInitLike, type FetchLike, type FetchResponseLike, type GetPostParams, IMAGE_WIDTHS, type ImageWidth, type LangParams, type ListPostsParams, type PublicBlogAuthor, type PublicBlogAuthorsPayload, type PublicBlogClosing, type PublicBlogImage, type PublicBlogPost, type PublicBlogPostPayload, type PublicBlogPostSummary, type PublicBlogPostsPayload, type PublicBlogTag, type PublicBlogTagsPayload, type RequestOptions, SIGNATURE_HEADER, SolutionDeskError, type VerifyWebhookOptions, type WebhookBody, createBlogClient, createMemoryEtagCache, imageUrl, imageWidthFor, parseBlogWebhook, verifyWebhookSignature };
|
|
438
|
+
export { type BaseClientOptions, type BlogBlockElement, type BlogBlockRenderer, type BlogBlockValue, type BlogClient, type BlogClientOptions, type BlogHeading, type BlogPostMoved, type BlogPostResult, type BlogWebhookBody, type BlogWebhookEvent, type CachedResponse, DEFAULT_BASE_URL, EVENT_HEADER, type EtagCache, type FetchInitLike, type FetchLike, type FetchResponseLike, type GetPostParams, IMAGE_WIDTHS, type ImageWidth, type LangParams, type ListPostsParams, type PublicBlogAuthor, type PublicBlogAuthorsPayload, type PublicBlogBlockDefinition, type PublicBlogBlockField, type PublicBlogBlockFieldType, type PublicBlogClosing, type PublicBlogImage, type PublicBlogPost, type PublicBlogPostPayload, type PublicBlogPostSummary, type PublicBlogPostsPayload, type PublicBlogTag, type PublicBlogTagsPayload, type RequestOptions, SIGNATURE_HEADER, SolutionDeskError, type VerifyWebhookOptions, type WebhookBody, createBlogClient, createMemoryEtagCache, escapeBlogHtml, imageUrl, imageWidthFor, parseBlogWebhook, renderBlogBlocks, verifyWebhookSignature };
|
package/dist/index.d.ts
CHANGED
|
@@ -144,6 +144,56 @@ type WebhookBody = string | Uint8Array | ArrayBuffer;
|
|
|
144
144
|
*/
|
|
145
145
|
declare function verifyWebhookSignature(secret: string, rawBody: WebhookBody, header: string | null | undefined, options?: VerifyWebhookOptions): Promise<boolean>;
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The blog's own building blocks in a post's `html` (contract in SolutionDesk's
|
|
149
|
+
* `docs/help/06-eigene-bausteine.md`): the API renders each one as a neutral
|
|
150
|
+
* element — `<span class="sd-block" data-block="…" data-props="…">` in a
|
|
151
|
+
* sentence, `<div class="sd-block" …>` on its own line, and around content
|
|
152
|
+
* `<div class="sd-block" …><p class="sd-block-text">…</p><div class="sd-block-body">…</div></div>`.
|
|
153
|
+
* The text inside is the block's text version; `data-props` its checked field
|
|
154
|
+
* values as JSON. `renderBlogBlocks` swaps them for your own markup.
|
|
155
|
+
*/
|
|
156
|
+
/** A field's checked value: `boolean` fields are true/false, every other type a string. */
|
|
157
|
+
type BlogBlockValue = string | boolean;
|
|
158
|
+
/** One block found in the HTML, handed to your renderer. */
|
|
159
|
+
interface BlogBlockElement {
|
|
160
|
+
/** The block's name (`newsletter-box`). */
|
|
161
|
+
block: string;
|
|
162
|
+
/**
|
|
163
|
+
* Its field values, already checked by SolutionDesk (an invalid value is left
|
|
164
|
+
* out, so every field may be missing). Strings are raw text — escape them
|
|
165
|
+
* (`escapeBlogHtml`) when you put them into HTML.
|
|
166
|
+
*/
|
|
167
|
+
props: Record<string, BlogBlockValue>;
|
|
168
|
+
/** `inline` (a `<span>` in a sentence) or a `<div>` (`block` or `container`). */
|
|
169
|
+
inline: boolean;
|
|
170
|
+
/** The text version as HTML (escaped), what the element shows without your markup. */
|
|
171
|
+
textHtml: string;
|
|
172
|
+
/**
|
|
173
|
+
* For a block around content: that content as HTML (own blocks inside are
|
|
174
|
+
* already rendered); otherwise null.
|
|
175
|
+
*/
|
|
176
|
+
childrenHtml: string | null;
|
|
177
|
+
}
|
|
178
|
+
/** Your markup for a block, as HTML; `undefined` keeps SolutionDesk's neutral element. */
|
|
179
|
+
type BlogBlockRenderer = (element: BlogBlockElement) => string | undefined;
|
|
180
|
+
/** `&`, `<`, `>`, `"` and `'` as character references. */
|
|
181
|
+
declare function escapeBlogHtml(value: string): string;
|
|
182
|
+
/**
|
|
183
|
+
* The post's `html` with the blog's own blocks rendered by `renderers` (by
|
|
184
|
+
* block name). A block without a renderer — or whose renderer returns
|
|
185
|
+
* `undefined` — keeps the neutral element with its text version. Blocks inside
|
|
186
|
+
* a container are rendered first and reach its renderer in `childrenHtml`.
|
|
187
|
+
*
|
|
188
|
+
* ```ts
|
|
189
|
+
* const html = renderBlogBlocks(post.html, {
|
|
190
|
+
* "newsletter-box": ({ props }) =>
|
|
191
|
+
* `<newsletter-form list="${escapeBlogHtml(String(props.list ?? ""))}"></newsletter-form>`,
|
|
192
|
+
* });
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
declare function renderBlogBlocks(html: string, renderers: Readonly<Record<string, BlogBlockRenderer>>): string;
|
|
196
|
+
|
|
147
197
|
/**
|
|
148
198
|
* The answers of the public blog API (`GET /v1/blog/{workspace}/…`). Written by
|
|
149
199
|
* hand so the package has no dependencies; a drift test in the SolutionDesk
|
|
@@ -212,13 +262,52 @@ interface PublicBlogPostSummary {
|
|
|
212
262
|
languages: string[];
|
|
213
263
|
readingMinutes: number;
|
|
214
264
|
}
|
|
265
|
+
/** What a field of one of the blog's own blocks holds. */
|
|
266
|
+
type PublicBlogBlockFieldType = "text" | "longText" | "icon" | "url" | "choice" | "image" | "video" | "boolean";
|
|
267
|
+
/**
|
|
268
|
+
* One field of a block: an attribute of its directive (`key="value"`).
|
|
269
|
+
* `boolean` fields are true/false, every other value a string. `options` lists
|
|
270
|
+
* a `choice` field's values (null for other types).
|
|
271
|
+
*/
|
|
272
|
+
interface PublicBlogBlockField {
|
|
273
|
+
key: string;
|
|
274
|
+
label: string;
|
|
275
|
+
type: PublicBlogBlockFieldType;
|
|
276
|
+
required: boolean;
|
|
277
|
+
options: string[] | null;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* A building block the blog defines itself — WHAT it is; how it looks is your
|
|
281
|
+
* website's own component. Written in Markdown as `:name[label]{…}` in running
|
|
282
|
+
* text (`inline`), `::name[label]{…}` alone on a line (`block`) or
|
|
283
|
+
* `:::name{…}` … `:::` around content (`container`). `text` is the template of
|
|
284
|
+
* its text version (`{key}` = a field's value).
|
|
285
|
+
*/
|
|
286
|
+
interface PublicBlogBlockDefinition {
|
|
287
|
+
/** The directive name (`newsletter-box`). */
|
|
288
|
+
name: string;
|
|
289
|
+
/** Display name. */
|
|
290
|
+
label: string;
|
|
291
|
+
kind: "inline" | "block" | "container";
|
|
292
|
+
fields: PublicBlogBlockField[];
|
|
293
|
+
text: string;
|
|
294
|
+
}
|
|
215
295
|
/** One post with its text. */
|
|
216
296
|
interface PublicBlogPost extends PublicBlogPostSummary {
|
|
297
|
+
/** The post's Markdown, the blog's own building blocks as written (see `blocks`). */
|
|
217
298
|
markdown: string;
|
|
218
|
-
/**
|
|
299
|
+
/**
|
|
300
|
+
* The Markdown as safe HTML (headings carry `id`s). The blog's own blocks are
|
|
301
|
+
* neutral `sd-block` elements: `data-block` the name, `data-props` the checked
|
|
302
|
+
* values as JSON, the text version inside — see `renderBlogBlocks`.
|
|
303
|
+
*/
|
|
219
304
|
html: string;
|
|
220
305
|
/** The outline, for a table of contents. */
|
|
221
306
|
headings: BlogHeading[];
|
|
307
|
+
/** The blog's own building blocks the post uses, by name (in the blog's order). */
|
|
308
|
+
customBlocks: string[];
|
|
309
|
+
/** The definitions of those blocks (empty when the post uses none). */
|
|
310
|
+
blocks: PublicBlogBlockDefinition[];
|
|
222
311
|
closing: PublicBlogClosing | null;
|
|
223
312
|
}
|
|
224
313
|
/** `GET …/posts`. */
|
|
@@ -346,4 +435,4 @@ declare function createBlogClient(options: BlogClientOptions): BlogClient;
|
|
|
346
435
|
*/
|
|
347
436
|
declare function parseBlogWebhook(secret: string, rawBody: WebhookBody, signatureHeader: string | null | undefined, options?: VerifyWebhookOptions): Promise<BlogWebhookBody | null>;
|
|
348
437
|
|
|
349
|
-
export { type BaseClientOptions, type BlogClient, type BlogClientOptions, type BlogHeading, type BlogPostMoved, type BlogPostResult, type BlogWebhookBody, type BlogWebhookEvent, type CachedResponse, DEFAULT_BASE_URL, EVENT_HEADER, type EtagCache, type FetchInitLike, type FetchLike, type FetchResponseLike, type GetPostParams, IMAGE_WIDTHS, type ImageWidth, type LangParams, type ListPostsParams, type PublicBlogAuthor, type PublicBlogAuthorsPayload, type PublicBlogClosing, type PublicBlogImage, type PublicBlogPost, type PublicBlogPostPayload, type PublicBlogPostSummary, type PublicBlogPostsPayload, type PublicBlogTag, type PublicBlogTagsPayload, type RequestOptions, SIGNATURE_HEADER, SolutionDeskError, type VerifyWebhookOptions, type WebhookBody, createBlogClient, createMemoryEtagCache, imageUrl, imageWidthFor, parseBlogWebhook, verifyWebhookSignature };
|
|
438
|
+
export { type BaseClientOptions, type BlogBlockElement, type BlogBlockRenderer, type BlogBlockValue, type BlogClient, type BlogClientOptions, type BlogHeading, type BlogPostMoved, type BlogPostResult, type BlogWebhookBody, type BlogWebhookEvent, type CachedResponse, DEFAULT_BASE_URL, EVENT_HEADER, type EtagCache, type FetchInitLike, type FetchLike, type FetchResponseLike, type GetPostParams, IMAGE_WIDTHS, type ImageWidth, type LangParams, type ListPostsParams, type PublicBlogAuthor, type PublicBlogAuthorsPayload, type PublicBlogBlockDefinition, type PublicBlogBlockField, type PublicBlogBlockFieldType, type PublicBlogClosing, type PublicBlogImage, type PublicBlogPost, type PublicBlogPostPayload, type PublicBlogPostSummary, type PublicBlogPostsPayload, type PublicBlogTag, type PublicBlogTagsPayload, type RequestOptions, SIGNATURE_HEADER, SolutionDeskError, type VerifyWebhookOptions, type WebhookBody, createBlogClient, createMemoryEtagCache, escapeBlogHtml, imageUrl, imageWidthFor, parseBlogWebhook, renderBlogBlocks, verifyWebhookSignature };
|
package/dist/index.js
CHANGED
|
@@ -255,6 +255,69 @@ async function parseWebhook(secret, rawBody, header, options = {}) {
|
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
// src/blocks.ts
|
|
259
|
+
function escapeBlogHtml(value) {
|
|
260
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
261
|
+
}
|
|
262
|
+
function unescapeAttribute(value) {
|
|
263
|
+
return value.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
264
|
+
}
|
|
265
|
+
var OPEN = /<(span|div) class="sd-block" data-block="([^"]*)" data-props="([^"]*)">/g;
|
|
266
|
+
function closingEnd(html, tag, from) {
|
|
267
|
+
const pattern = new RegExp(`<${tag}[\\s>]|</${tag}>`, "g");
|
|
268
|
+
pattern.lastIndex = from;
|
|
269
|
+
let depth = 1;
|
|
270
|
+
for (let match = pattern.exec(html); match; match = pattern.exec(html)) {
|
|
271
|
+
depth += match[0].startsWith("</") ? -1 : 1;
|
|
272
|
+
if (depth === 0) return match.index + match[0].length;
|
|
273
|
+
}
|
|
274
|
+
return html.length;
|
|
275
|
+
}
|
|
276
|
+
function parseProps(raw) {
|
|
277
|
+
try {
|
|
278
|
+
const parsed = JSON.parse(unescapeAttribute(raw));
|
|
279
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
280
|
+
const props = {};
|
|
281
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
282
|
+
if (typeof value === "string" || typeof value === "boolean") props[key] = value;
|
|
283
|
+
}
|
|
284
|
+
return props;
|
|
285
|
+
} catch {
|
|
286
|
+
return {};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function renderBlogBlocks(html, renderers) {
|
|
290
|
+
let out = "";
|
|
291
|
+
let pos = 0;
|
|
292
|
+
OPEN.lastIndex = 0;
|
|
293
|
+
for (let open = OPEN.exec(html); open; open = OPEN.exec(html)) {
|
|
294
|
+
const [whole, tag = "div", name = "", rawProps = ""] = open;
|
|
295
|
+
const start = open.index;
|
|
296
|
+
const contentStart = start + whole.length;
|
|
297
|
+
const end = closingEnd(html, tag, contentStart);
|
|
298
|
+
const inner = html.slice(contentStart, end - `</${tag}>`.length);
|
|
299
|
+
const body = /^(?:<p class="sd-block-text">([\s\S]*?)<\/p>)?<div class="sd-block-body">([\s\S]*)<\/div>$/.exec(
|
|
300
|
+
inner
|
|
301
|
+
);
|
|
302
|
+
const element = {
|
|
303
|
+
block: unescapeAttribute(name),
|
|
304
|
+
props: parseProps(rawProps),
|
|
305
|
+
inline: tag === "span",
|
|
306
|
+
textHtml: body ? body[1] ?? "" : inner,
|
|
307
|
+
childrenHtml: body ? renderBlogBlocks(body[2] ?? "", renderers) : null
|
|
308
|
+
};
|
|
309
|
+
const own = renderers[element.block]?.(element);
|
|
310
|
+
out += html.slice(pos, start);
|
|
311
|
+
if (own !== void 0) out += own;
|
|
312
|
+
else if (body) {
|
|
313
|
+
out += `${whole}${body[1] !== void 0 ? `<p class="sd-block-text">${body[1]}</p>` : ""}<div class="sd-block-body">${element.childrenHtml}</div></${tag}>`;
|
|
314
|
+
} else out += html.slice(start, end);
|
|
315
|
+
pos = end;
|
|
316
|
+
OPEN.lastIndex = end;
|
|
317
|
+
}
|
|
318
|
+
return out + html.slice(pos);
|
|
319
|
+
}
|
|
320
|
+
|
|
258
321
|
// src/client.ts
|
|
259
322
|
function createBlogClient(options) {
|
|
260
323
|
const workspace = required(options.workspace, "workspace");
|
|
@@ -341,4 +404,4 @@ function parseBlogWebhook(secret, rawBody, signatureHeader, options) {
|
|
|
341
404
|
return parseWebhook(secret, rawBody, signatureHeader, options);
|
|
342
405
|
}
|
|
343
406
|
|
|
344
|
-
export { DEFAULT_BASE_URL, EVENT_HEADER, IMAGE_WIDTHS, SIGNATURE_HEADER, SolutionDeskError, createBlogClient, createMemoryEtagCache, imageUrl, imageWidthFor, parseBlogWebhook, verifyWebhookSignature };
|
|
407
|
+
export { DEFAULT_BASE_URL, EVENT_HEADER, IMAGE_WIDTHS, SIGNATURE_HEADER, SolutionDeskError, createBlogClient, createMemoryEtagCache, escapeBlogHtml, imageUrl, imageWidthFor, parseBlogWebhook, renderBlogBlocks, verifyWebhookSignature };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solutiondesk/blog",
|
|
3
|
-
"version": "1.0.0-
|
|
3
|
+
"version": "1.0.0-2914fd8",
|
|
4
4
|
"description": "Typed, zero-dependency client for the SolutionDesk public blog API: posts, tags, authors, previews and webhook verification.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"solutiondesk",
|