@softure-ai/blog 0.1.6 → 0.1.8
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/CHANGELOG.md +30 -0
- package/README.md +211 -26
- package/dist/cli/report.d.ts +17 -0
- package/dist/cli/report.d.ts.map +1 -0
- package/dist/cli/report.js +149 -0
- package/dist/cli/report.js.map +1 -0
- package/dist/cli/run.d.ts +12 -5
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +122 -88
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/skill.d.ts.map +1 -1
- package/dist/cli/skill.js +2 -1
- package/dist/cli/skill.js.map +1 -1
- package/dist/contract.d.ts +4 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/db/articles.d.ts +10 -1
- package/dist/db/articles.d.ts.map +1 -1
- package/dist/db/articles.js +41 -5
- package/dist/db/articles.js.map +1 -1
- package/dist/db/history.d.ts +36 -0
- package/dist/db/history.d.ts.map +1 -0
- package/dist/db/history.js +70 -0
- package/dist/db/history.js.map +1 -0
- package/dist/db/publish-run.d.ts +11 -0
- package/dist/db/publish-run.d.ts.map +1 -1
- package/dist/db/publish-run.js +14 -4
- package/dist/db/publish-run.js.map +1 -1
- package/dist/index.d.ts +20 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +2 -0
- package/dist/next/index.js.map +1 -1
- package/dist/next/json-ld.d.ts +13 -0
- package/dist/next/json-ld.d.ts.map +1 -0
- package/dist/next/json-ld.js +42 -0
- package/dist/next/json-ld.js.map +1 -0
- package/dist/next/metadata.d.ts +18 -0
- package/dist/next/metadata.d.ts.map +1 -0
- package/dist/next/metadata.js +84 -0
- package/dist/next/metadata.js.map +1 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +16 -77
- package/dist/next/pages.js.map +1 -1
- package/dist/options.d.ts +23 -7
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +26 -1
- package/dist/options.js.map +1 -1
- package/dist/pages/accept.d.ts +7 -0
- package/dist/pages/accept.d.ts.map +1 -0
- package/dist/pages/accept.js +34 -0
- package/dist/pages/accept.js.map +1 -0
- package/dist/pages/body.d.ts +1 -1
- package/dist/pages/body.d.ts.map +1 -1
- package/dist/pages/body.js +1 -0
- package/dist/pages/body.js.map +1 -1
- package/dist/pages/index.d.ts +1 -1
- package/dist/pages/index.d.ts.map +1 -1
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/redirects.d.ts +22 -6
- package/dist/pages/redirects.d.ts.map +1 -1
- package/dist/pages/redirects.js +5 -2
- package/dist/pages/redirects.js.map +1 -1
- package/dist/proxy/index.d.ts +15 -0
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +60 -3
- package/dist/proxy/index.js.map +1 -1
- package/dist/quality/catalog.d.ts.map +1 -1
- package/dist/quality/catalog.js +4 -1
- package/dist/quality/catalog.js.map +1 -1
- package/dist/quality/check-article.d.ts.map +1 -1
- package/dist/quality/check-article.js +2 -1
- package/dist/quality/check-article.js.map +1 -1
- package/dist/quality/index.d.ts +1 -1
- package/dist/quality/index.d.ts.map +1 -1
- package/dist/quality/index.js.map +1 -1
- package/dist/quality/link-targets.d.ts.map +1 -1
- package/dist/quality/link-targets.js +6 -5
- package/dist/quality/link-targets.js.map +1 -1
- package/dist/quality/options.d.ts +3 -3
- package/dist/quality/options.d.ts.map +1 -1
- package/dist/quality/options.js +9 -4
- package/dist/quality/options.js.map +1 -1
- package/dist/quality/rules/blocks.d.ts +8 -1
- package/dist/quality/rules/blocks.d.ts.map +1 -1
- package/dist/quality/rules/blocks.js +26 -0
- package/dist/quality/rules/blocks.js.map +1 -1
- package/dist/quality/settings.d.ts +11 -0
- package/dist/quality/settings.d.ts.map +1 -1
- package/dist/quality/settings.js +7 -1
- package/dist/quality/settings.js.map +1 -1
- package/dist/render/article-markdown.d.ts +12 -0
- package/dist/render/article-markdown.d.ts.map +1 -0
- package/dist/render/article-markdown.js +21 -0
- package/dist/render/article-markdown.js.map +1 -0
- package/dist/render/index.d.ts +2 -1
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +2 -1
- package/dist/render/index.js.map +1 -1
- package/dist/render/render-article.d.ts +48 -4
- package/dist/render/render-article.d.ts.map +1 -1
- package/dist/render/render-article.js +146 -17
- package/dist/render/render-article.js.map +1 -1
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/options.js +1 -1
- package/dist/server/options.js.map +1 -1
- package/module.json +1 -1
- package/package.json +1 -1
- package/skill/references/rules.md +2 -1
- package/src/cli/report.ts +182 -0
- package/src/cli/run.ts +122 -92
- package/src/cli/skill.ts +2 -1
- package/src/contract.ts +9 -1
- package/src/db/articles.ts +48 -8
- package/src/db/history.ts +90 -0
- package/src/db/publish-run.ts +24 -4
- package/src/index.ts +1 -1
- package/src/next/index.ts +9 -0
- package/src/next/json-ld.ts +47 -0
- package/src/next/metadata.ts +103 -0
- package/src/next/pages.tsx +16 -82
- package/src/options.ts +32 -3
- package/src/pages/accept.ts +39 -0
- package/src/pages/body.ts +2 -1
- package/src/pages/index.ts +3 -0
- package/src/pages/redirects.ts +27 -3
- package/src/proxy/index.ts +69 -3
- package/src/quality/catalog.ts +4 -1
- package/src/quality/check-article.ts +2 -1
- package/src/quality/index.ts +1 -1
- package/src/quality/link-targets.ts +6 -5
- package/src/quality/options.ts +12 -5
- package/src/quality/rules/blocks.ts +26 -1
- package/src/quality/settings.ts +17 -1
- package/src/render/article-markdown.ts +34 -0
- package/src/render/index.ts +8 -0
- package/src/render/render-article.ts +192 -23
- package/src/server/index.ts +2 -0
- package/src/server/options.ts +1 -1
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
// ## Links
|
|
18
18
|
//
|
|
19
19
|
// A link that leaves the site (`siteHosts`, subdomains included) gets `rel="noopener noreferrer"`,
|
|
20
|
-
// opens in a new tab and carries a visible marker plus a visually hidden "opens in a new tab"
|
|
20
|
+
// opens in a new tab and carries a visible marker plus a visually hidden "opens in a new tab"
|
|
21
|
+
// (`externalMarker`: both, the hidden words only, or neither; the `blog-external` class stays either way).
|
|
21
22
|
//
|
|
22
23
|
// ## Headings and the table of contents
|
|
23
24
|
//
|
|
@@ -36,6 +37,10 @@
|
|
|
36
37
|
// A fenced block whose type an app registers (```` ```chart ````) is rendered by the app's plugin, as
|
|
37
38
|
// HTML or as a node (e.g. a React server component). Plugin output is the app's own code and is
|
|
38
39
|
// trusted as is. Only top-level fences are plugin blocks; a fence inside a list or a quote stays code.
|
|
40
|
+
//
|
|
41
|
+
// A plugin with `syntax: "directive"` renders a leaf directive instead: a line of its own,
|
|
42
|
+
// `::chart{type="wealth" scenario="…"}` (braces optional), with double-quoted values and each key
|
|
43
|
+
// once. Like fences, only top-level lines count; a directive in a list, a quote or code stays text.
|
|
39
44
|
import MarkdownIt, { type MarkdownIt as Markdown, type StateCore, type Token } from "markdown-it";
|
|
40
45
|
import footnote from "markdown-it-footnote";
|
|
41
46
|
import type { BlogFields } from "../contract.js";
|
|
@@ -61,12 +66,24 @@ export interface BlockArticle {
|
|
|
61
66
|
readonly fields?: BlogFields;
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
/** How a block is written: a fence (```` ```chart ````) or a leaf directive (`::chart{…}`). */
|
|
70
|
+
export type BlockSyntax = "fence" | "directive";
|
|
71
|
+
|
|
72
|
+
/** A directive's attributes, or `null` when its braces cannot be read. Always `{}` for a fence. */
|
|
73
|
+
export type BlockAttributes = Readonly<Record<string, string>> | null;
|
|
74
|
+
|
|
64
75
|
export interface ArticleBlock {
|
|
65
|
-
/** The block type
|
|
76
|
+
/** The block type: the first word of the fence's info string, or the directive's name. */
|
|
66
77
|
readonly type: string;
|
|
67
|
-
|
|
78
|
+
readonly syntax: BlockSyntax;
|
|
79
|
+
/**
|
|
80
|
+
* Fence: the rest of the info string, trimmed (```` ```chart wealth ```` → `"wealth"`). Directive:
|
|
81
|
+
* the text inside the braces, trimmed.
|
|
82
|
+
*/
|
|
68
83
|
readonly info: string;
|
|
69
|
-
/**
|
|
84
|
+
/** `key="value"` pairs of a directive; `{}` for a fence. */
|
|
85
|
+
readonly attributes: BlockAttributes;
|
|
86
|
+
/** Fence: its body, as written. Directive: the whole line, trimmed. */
|
|
70
87
|
readonly content: string;
|
|
71
88
|
readonly article: BlockArticle;
|
|
72
89
|
}
|
|
@@ -78,6 +95,8 @@ export type BlockOutput<TNode = unknown> =
|
|
|
78
95
|
export interface BlockPlugin<TNode = unknown> {
|
|
79
96
|
/** Lower-case kebab-case, e.g. `chart`. */
|
|
80
97
|
readonly type: string;
|
|
98
|
+
/** Which blocks the plugin renders: fences (the default) or leaf directives. */
|
|
99
|
+
readonly syntax?: BlockSyntax;
|
|
81
100
|
/**
|
|
82
101
|
* The frontmatter keys the block reads (`current_as_of` or keys of the app's `fields`), so the
|
|
83
102
|
* quality gate can report a block whose article lacks them.
|
|
@@ -85,12 +104,22 @@ export interface BlockPlugin<TNode = unknown> {
|
|
|
85
104
|
readonly requires?: readonly string[];
|
|
86
105
|
/** Throws only on a bug; a block that cannot render returns its own error markup. */
|
|
87
106
|
readonly render: (block: ArticleBlock) => BlockOutput<TNode>;
|
|
107
|
+
/**
|
|
108
|
+
* The block as Markdown for agents (`toArticleMarkdown`, `Accept: text/markdown`): a table or a
|
|
109
|
+
* sentence. Without it the block's source stays in the Markdown.
|
|
110
|
+
*/
|
|
111
|
+
readonly markdown?: (block: ArticleBlock) => string;
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
export type ArticleSegment<TNode = unknown> =
|
|
91
115
|
| { readonly kind: "html"; readonly html: string }
|
|
92
116
|
| { readonly kind: "node"; readonly type: string; readonly node: TNode };
|
|
93
117
|
|
|
118
|
+
/** What the renderer appends to an external link (`RenderArticleOptions.externalMarker`). */
|
|
119
|
+
export const EXTERNAL_LINK_MARKERS = ["icon-and-text", "text", "none"] as const;
|
|
120
|
+
|
|
121
|
+
export type ExternalLinkMarker = (typeof EXTERNAL_LINK_MARKERS)[number];
|
|
122
|
+
|
|
94
123
|
export interface RenderArticleOptions<TNode = unknown> {
|
|
95
124
|
/** Terms for automatic links; without it no text is linked. */
|
|
96
125
|
readonly glossary?: readonly GlossaryTerm[];
|
|
@@ -107,6 +136,11 @@ export interface RenderArticleOptions<TNode = unknown> {
|
|
|
107
136
|
readonly article?: BlockArticle;
|
|
108
137
|
/** Render a table of contents of `h2` down to `maxLevel` (3 by default). */
|
|
109
138
|
readonly toc?: boolean | { readonly maxLevel: number };
|
|
139
|
+
/**
|
|
140
|
+
* What follows an external link: the visible arrow and the visually hidden "opens in a new tab"
|
|
141
|
+
* (`"icon-and-text"`, the default), the hidden words only (`"text"`), or nothing (`"none"`).
|
|
142
|
+
*/
|
|
143
|
+
readonly externalMarker?: ExternalLinkMarker;
|
|
110
144
|
/** Copy for footnotes, external links and the table of contents; English by default. */
|
|
111
145
|
readonly messages?: BlogRenderMessages;
|
|
112
146
|
readonly wordsPerMinute?: number;
|
|
@@ -127,8 +161,10 @@ export interface RenderedArticle<TNode = unknown> {
|
|
|
127
161
|
|
|
128
162
|
export interface FoundBlock {
|
|
129
163
|
readonly type: string;
|
|
164
|
+
readonly syntax: BlockSyntax;
|
|
130
165
|
readonly info: string;
|
|
131
|
-
|
|
166
|
+
readonly attributes: BlockAttributes;
|
|
167
|
+
/** 1-based line of the opening fence or of the directive. */
|
|
132
168
|
readonly line: number;
|
|
133
169
|
readonly requires: readonly string[];
|
|
134
170
|
}
|
|
@@ -141,6 +177,61 @@ const FOOTNOTES_HEADING_ID = "footnotes";
|
|
|
141
177
|
// Ids the renderer gives footnotes; a heading whose slug equals one of them gets a suffix.
|
|
142
178
|
const FOOTNOTE_ID = /^(?:footnotes|fn(?:ref)?-\d+(?:-\d+)?)$/;
|
|
143
179
|
const BLOCK_TOKEN = "blog_block";
|
|
180
|
+
const DIRECTIVE_LINE = /^::([a-z][a-z0-9]*(?:-[a-z0-9]+)*)(?:\{(.*)\})?$/;
|
|
181
|
+
const DIRECTIVE_START = /^::[a-z]/;
|
|
182
|
+
const ATTRIBUTE_KEY_START = /[a-z]/;
|
|
183
|
+
const ATTRIBUTE_KEY_CHAR = /[a-z0-9_-]/;
|
|
184
|
+
const WHITESPACE = /\s/;
|
|
185
|
+
|
|
186
|
+
interface BlockMeta {
|
|
187
|
+
readonly type: string;
|
|
188
|
+
readonly syntax: BlockSyntax;
|
|
189
|
+
readonly info: string;
|
|
190
|
+
readonly attributes: BlockAttributes;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The `key="value"` pairs inside a directive's braces; `null` when anything else is there or a key repeats. */
|
|
194
|
+
export function parseDirectiveAttributes(text: string): BlockAttributes {
|
|
195
|
+
// A hand scanner, linear in the text: a regex here runs on author input and backtracks on long runs.
|
|
196
|
+
const attributes: Record<string, string> = {};
|
|
197
|
+
let index = 0;
|
|
198
|
+
const skipWhitespace = (): void => {
|
|
199
|
+
while (index < text.length && WHITESPACE.test(text.charAt(index))) index += 1;
|
|
200
|
+
};
|
|
201
|
+
skipWhitespace();
|
|
202
|
+
while (index < text.length) {
|
|
203
|
+
if (!ATTRIBUTE_KEY_START.test(text.charAt(index))) return null;
|
|
204
|
+
const keyStart = index;
|
|
205
|
+
while (index < text.length && ATTRIBUTE_KEY_CHAR.test(text.charAt(index))) index += 1;
|
|
206
|
+
const key = text.slice(keyStart, index);
|
|
207
|
+
if (text.charAt(index) !== "=" || text.charAt(index + 1) !== '"') return null;
|
|
208
|
+
const valueEnd = text.indexOf('"', index + 2);
|
|
209
|
+
if (valueEnd === -1) return null;
|
|
210
|
+
if (Object.hasOwn(attributes, key)) return null;
|
|
211
|
+
attributes[key] = text.slice(index + 2, valueEnd);
|
|
212
|
+
index = valueEnd + 1;
|
|
213
|
+
skipWhitespace();
|
|
214
|
+
}
|
|
215
|
+
return attributes;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A line read as a leaf directive: its name, the text inside the braces and the attributes. `null`
|
|
220
|
+
* for a line that is not one (a name must follow `::`); a directive whose braces do not close keeps
|
|
221
|
+
* its name and gets `null` attributes.
|
|
222
|
+
*/
|
|
223
|
+
export function parseDirectiveLine(line: string): { readonly name: string; readonly info: string; readonly attributes: BlockAttributes } | null {
|
|
224
|
+
const trimmed = line.trim();
|
|
225
|
+
if (!DIRECTIVE_START.test(trimmed)) return null;
|
|
226
|
+
const match = DIRECTIVE_LINE.exec(trimmed);
|
|
227
|
+
if (match === null) {
|
|
228
|
+
const name = /^::([a-z][a-z0-9-]*)/.exec(trimmed)?.[1] ?? "";
|
|
229
|
+
const rest = trimmed.slice(2 + name.length).trim();
|
|
230
|
+
return { name, info: rest.replace(/^\{/, "").trim(), attributes: null };
|
|
231
|
+
}
|
|
232
|
+
const info = (match[2] ?? "").trim();
|
|
233
|
+
return { name: match[1] ?? "", info, attributes: parseDirectiveAttributes(info) };
|
|
234
|
+
}
|
|
144
235
|
|
|
145
236
|
function getDefaultTermHref(slug: string): string {
|
|
146
237
|
return `/blog/glossary/${slug}`;
|
|
@@ -217,7 +308,14 @@ function addFootnoteMarkup(md: Markdown, messages: BlogRenderMessages): void {
|
|
|
217
308
|
};
|
|
218
309
|
}
|
|
219
310
|
|
|
220
|
-
function
|
|
311
|
+
function getExternalMarkerHtml(md: Markdown, marker: ExternalLinkMarker, messages: BlogRenderMessages): string {
|
|
312
|
+
if (marker === "none") return "";
|
|
313
|
+
const hidden = `<span class="blog-visually-hidden"> ${md.utils.escapeHtml(messages.opensInNewTab)}</span>`;
|
|
314
|
+
return marker === "text" ? hidden : `<span class="blog-external-marker" aria-hidden="true">↗</span>${hidden}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function addExternalLinks(md: Markdown, siteHosts: readonly string[], messages: BlogRenderMessages, marker: ExternalLinkMarker): void {
|
|
318
|
+
const markerHtml = getExternalMarkerHtml(md, marker, messages);
|
|
221
319
|
// Markdown links do not nest, but a stack keeps open and close paired whatever the token stream.
|
|
222
320
|
const externalStack: boolean[] = [];
|
|
223
321
|
md.renderer.rules.link_open = (tokens, index, options, _env, self) => {
|
|
@@ -232,10 +330,7 @@ function addExternalLinks(md: Markdown, siteHosts: readonly string[], messages:
|
|
|
232
330
|
return self.renderToken(tokens, index, options);
|
|
233
331
|
};
|
|
234
332
|
md.renderer.rules.link_close = (tokens, index, options, _env, self) => {
|
|
235
|
-
|
|
236
|
-
? `<span class="blog-external-marker" aria-hidden="true">↗</span><span class="blog-visually-hidden"> ${md.utils.escapeHtml(messages.opensInNewTab)}</span>`
|
|
237
|
-
: "";
|
|
238
|
-
return marker + self.renderToken(tokens, index, options);
|
|
333
|
+
return (externalStack.pop() === true ? markerHtml : "") + self.renderToken(tokens, index, options);
|
|
239
334
|
};
|
|
240
335
|
}
|
|
241
336
|
|
|
@@ -354,26 +449,77 @@ function addBlockTokens(md: Markdown, types: ReadonlySet<string>): void {
|
|
|
354
449
|
const [type = "", ...rest] = token.info.trim().split(/\s+/);
|
|
355
450
|
if (!types.has(type)) continue;
|
|
356
451
|
token.type = BLOCK_TOKEN;
|
|
357
|
-
|
|
452
|
+
const meta: BlockMeta = { type, syntax: "fence", info: rest.join(" "), attributes: {} };
|
|
453
|
+
token.meta = { block: meta };
|
|
358
454
|
}
|
|
359
455
|
});
|
|
360
456
|
}
|
|
361
457
|
|
|
362
|
-
|
|
363
|
-
|
|
458
|
+
/**
|
|
459
|
+
* A block rule for top-level lines `::name{…}` of registered directive names. Before `paragraph`
|
|
460
|
+
* and allowed to end one, so a directive right under a paragraph line is still a block.
|
|
461
|
+
*/
|
|
462
|
+
function addDirectiveRule(md: Markdown, names: ReadonlySet<string>): void {
|
|
463
|
+
if (names.size === 0) return;
|
|
464
|
+
md.block.ruler.before(
|
|
465
|
+
"paragraph",
|
|
466
|
+
"blog_directive",
|
|
467
|
+
(state, startLine, _endLine, silent) => {
|
|
468
|
+
// Four spaces are an indented code block; a nested block (list, quote) is not top level.
|
|
469
|
+
if (state.level !== 0 || state.blkIndent !== 0 || (state.sCount[startLine] ?? 0) - state.blkIndent >= 4) return false;
|
|
470
|
+
const line = state.src.slice((state.bMarks[startLine] ?? 0) + (state.tShift[startLine] ?? 0), state.eMarks[startLine]);
|
|
471
|
+
const directive = parseDirectiveLine(line);
|
|
472
|
+
if (directive === null || !names.has(directive.name)) return false;
|
|
473
|
+
if (!silent) {
|
|
474
|
+
const token = state.push(BLOCK_TOKEN, "", 0);
|
|
475
|
+
token.block = true;
|
|
476
|
+
token.content = line.trim();
|
|
477
|
+
token.map = [startLine, startLine + 1];
|
|
478
|
+
const meta: BlockMeta = { type: directive.name, syntax: "directive", info: directive.info, attributes: directive.attributes };
|
|
479
|
+
token.meta = { block: meta };
|
|
480
|
+
}
|
|
481
|
+
state.line = startLine + 1;
|
|
482
|
+
return true;
|
|
483
|
+
},
|
|
484
|
+
{ alt: ["paragraph"] },
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** The block a `blog_block` token stands for; set by `addBlockTokens` and `addDirectiveRule`. */
|
|
489
|
+
function readBlockMeta(token: Token): BlockMeta {
|
|
490
|
+
return (token.meta as { block: BlockMeta }).block;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Plugins by `syntax:type`. */
|
|
494
|
+
type PluginRegistry<TNode> = Map<string, BlockPlugin<TNode>>;
|
|
495
|
+
|
|
496
|
+
function getPluginKey(syntax: BlockSyntax, type: string): string {
|
|
497
|
+
return `${syntax}:${type}`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function getPluginsByType<TNode>(plugins: readonly BlockPlugin<TNode>[]): PluginRegistry<TNode> {
|
|
501
|
+
const byType: PluginRegistry<TNode> = new Map();
|
|
364
502
|
for (const plugin of plugins) {
|
|
365
503
|
if (!BLOCK_TYPE.test(plugin.type)) {
|
|
366
504
|
throw new Error(`Block plugin type "${plugin.type}" must be lower-case kebab-case, e.g. "chart".`);
|
|
367
505
|
}
|
|
368
|
-
|
|
369
|
-
|
|
506
|
+
const syntax = plugin.syntax ?? "fence";
|
|
507
|
+
const key = getPluginKey(syntax, plugin.type);
|
|
508
|
+
if (byType.has(key)) {
|
|
509
|
+
throw new Error(syntax === "fence" ? `Block plugin type "${plugin.type}" is registered twice.` : `Block plugin type "${plugin.type}" (directive) is registered twice.`);
|
|
510
|
+
}
|
|
511
|
+
byType.set(key, plugin);
|
|
370
512
|
}
|
|
371
513
|
return byType;
|
|
372
514
|
}
|
|
373
515
|
|
|
516
|
+
function getTypes<TNode>(plugins: PluginRegistry<TNode>, syntax: BlockSyntax): Set<string> {
|
|
517
|
+
return new Set([...plugins.values()].filter((plugin) => (plugin.syntax ?? "fence") === syntax).map((plugin) => plugin.type));
|
|
518
|
+
}
|
|
519
|
+
|
|
374
520
|
function createMarkdown<TNode>(
|
|
375
521
|
options: RenderArticleOptions<TNode>,
|
|
376
|
-
plugins:
|
|
522
|
+
plugins: PluginRegistry<TNode>,
|
|
377
523
|
state: RenderState,
|
|
378
524
|
): Markdown {
|
|
379
525
|
const messages = options.messages ?? en.render;
|
|
@@ -382,8 +528,9 @@ function createMarkdown<TNode>(
|
|
|
382
528
|
md.validateLink = isSafeLink;
|
|
383
529
|
addImages(md, options.images);
|
|
384
530
|
addFootnoteMarkup(md, messages);
|
|
385
|
-
addExternalLinks(md, options.siteHosts ?? [], messages);
|
|
386
|
-
addBlockTokens(md,
|
|
531
|
+
addExternalLinks(md, options.siteHosts ?? [], messages, options.externalMarker ?? "icon-and-text");
|
|
532
|
+
addBlockTokens(md, getTypes(plugins, "fence"));
|
|
533
|
+
addDirectiveRule(md, getTypes(plugins, "directive"));
|
|
387
534
|
addHeadingIds(md, state);
|
|
388
535
|
addGlossaryLinks(
|
|
389
536
|
md,
|
|
@@ -442,10 +589,10 @@ export function renderArticle<TNode = unknown>(markdown: string, options: Render
|
|
|
442
589
|
if (token.type !== BLOCK_TOKEN) return;
|
|
443
590
|
addHtmlSegment(segments, md.renderer.render(tokens.slice(start, index), md.options, env));
|
|
444
591
|
start = index + 1;
|
|
445
|
-
const meta = token
|
|
446
|
-
const plugin = plugins.get(meta.type);
|
|
592
|
+
const meta = readBlockMeta(token);
|
|
593
|
+
const plugin = plugins.get(getPluginKey(meta.syntax, meta.type));
|
|
447
594
|
if (plugin === undefined) return;
|
|
448
|
-
const output = plugin.render({
|
|
595
|
+
const output = plugin.render({ ...meta, content: token.content, article: options.article ?? {} });
|
|
449
596
|
if (output.kind === "html") {
|
|
450
597
|
addHtmlSegment(segments, output.html);
|
|
451
598
|
} else {
|
|
@@ -481,7 +628,29 @@ export function findArticleBlocks(markdown: string, plugins: readonly BlockPlugi
|
|
|
481
628
|
const md = createMarkdown({ blocks: plugins }, byType, { headings: [], linkedTerms: [] });
|
|
482
629
|
return md.parse(markdown, {}).flatMap((token) => {
|
|
483
630
|
if (token.type !== BLOCK_TOKEN) return [];
|
|
484
|
-
const meta = token
|
|
485
|
-
|
|
631
|
+
const meta = readBlockMeta(token);
|
|
632
|
+
const requires = byType.get(getPluginKey(meta.syntax, meta.type))?.requires ?? [];
|
|
633
|
+
return [{ type: meta.type, syntax: meta.syntax, info: meta.info, attributes: meta.attributes, line: (token.map?.[0] ?? 0) + 1, requires }];
|
|
486
634
|
});
|
|
487
635
|
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The text with each plugin block that has a Markdown form (`BlockPlugin.markdown`) replaced by it;
|
|
639
|
+
* other blocks keep their source. For agents that read the article as Markdown.
|
|
640
|
+
*/
|
|
641
|
+
export function replaceArticleBlocks(markdown: string, plugins: readonly BlockPlugin[], article: BlockArticle = {}): string {
|
|
642
|
+
if (!plugins.some((plugin) => plugin.markdown !== undefined)) return markdown;
|
|
643
|
+
const byType = getPluginsByType(plugins);
|
|
644
|
+
const md = createMarkdown({ blocks: plugins }, byType, { headings: [], linkedTerms: [] });
|
|
645
|
+
const lines = markdown.split("\n");
|
|
646
|
+
const blocks = md.parse(markdown, {}).filter((token) => token.type === BLOCK_TOKEN && token.map !== null);
|
|
647
|
+
for (const token of blocks.reverse()) {
|
|
648
|
+
const meta = readBlockMeta(token);
|
|
649
|
+
const plugin = byType.get(getPluginKey(meta.syntax, meta.type));
|
|
650
|
+
const [start = 0, end = start] = token.map ?? [];
|
|
651
|
+
if (plugin?.markdown === undefined) continue;
|
|
652
|
+
const replacement = plugin.markdown({ ...meta, content: token.content, article });
|
|
653
|
+
lines.splice(start, end - start, ...replacement.split("\n"));
|
|
654
|
+
}
|
|
655
|
+
return lines.join("\n");
|
|
656
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -10,7 +10,9 @@ export {
|
|
|
10
10
|
publishArticle,
|
|
11
11
|
type BlogContext,
|
|
12
12
|
type ListArticlesFilter,
|
|
13
|
+
type PublishArticleOptions,
|
|
13
14
|
} from "../db/articles.js";
|
|
15
|
+
export { articleHistorySchema, parseArticleHistory, type ArticleHistory, type ArticleHistoryMap } from "../db/history.js";
|
|
14
16
|
export {
|
|
15
17
|
runBlogPublish,
|
|
16
18
|
type ArticleFile,
|
package/src/server/options.ts
CHANGED
|
@@ -57,6 +57,6 @@ export function getBlogReservedSlugs(config: SoftureConfig): string[] {
|
|
|
57
57
|
export function getQualitySettings(config: SoftureConfig): QualitySettings | null {
|
|
58
58
|
const { quality, images } = getBlogOptions(config);
|
|
59
59
|
if (quality === false) return null;
|
|
60
|
-
const site = { appOrigin: config.appOrigin, siteOrigin: getSiteUrls(config).origin, timezone: config.timezone };
|
|
60
|
+
const site = { appOrigin: config.appOrigin, siteOrigin: getSiteUrls(config).origin, timezone: config.timezone, routes: getBlogRoutes(config) };
|
|
61
61
|
return resolveQualitySettings(quality, site, images ?? null);
|
|
62
62
|
}
|