@solutiondesk/blog 1.0.0-e730b93 → 1.0.0-e8cc591

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 `readingMinutes`. |
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
263
+ }
264
+ function unescapeAttribute(value) {
265
+ return value.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/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
- /** The Markdown as safe HTML (headings carry `id`s). */
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
- /** The Markdown as safe HTML (headings carry `id`s). */
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
261
+ }
262
+ function unescapeAttribute(value) {
263
+ return value.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/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-e730b93",
3
+ "version": "1.0.0-e8cc591",
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",