@jant/core 0.7.0 → 0.7.1
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/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +689 -216
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +18 -60
- package/bin/commands/site/snapshot/import.js +22 -23
- package/bin/lib/d1-query.js +87 -2
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +10 -1
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +9 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
- package/dist/client/.vite/manifest.json +20 -20
- package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
- package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
- package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
- package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
- package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
- package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
- package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
- package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
- package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
- package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
- package/dist/index.js +2 -2
- package/dist/node.js +9 -5
- package/package.json +13 -3
- package/src/__tests__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +130 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +611 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +15 -6
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
- package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +4 -7
- package/src/client/components/jant-settings-general.ts +17 -12
- package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/i18n/locales/settings/en.po +8 -8
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +8 -8
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +8 -8
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/resolve-config.test.ts +67 -0
- package/src/lib/__tests__/schemas.test.ts +27 -1
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/discover.ts +3 -1
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/hugo-markdown.ts +46 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/resolve-config.ts +58 -1
- package/src/lib/schemas.ts +50 -5
- package/src/lib/thread-fold.ts +3 -3
- package/src/lib/timeline.ts +1 -1
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/url.ts +20 -0
- package/src/lib/view.ts +2 -2
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +38 -0
- package/src/node/index.ts +2 -0
- package/src/node/request-handler.ts +3 -1
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/export.ts +3 -3
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/posts.ts +2 -0
- package/src/routes/api/public/posts.ts +21 -1
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/compose.tsx +4 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/settings.tsx +212 -70
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +17 -11
- package/src/routes/pages/featured.tsx +11 -5
- package/src/routes/pages/page.tsx +53 -31
- package/src/routes/pages/search.tsx +4 -2
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/node.ts +49 -0
- package/src/runtime/readiness.ts +15 -0
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/path.test.ts +56 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +74 -0
- package/src/services/bootstrap.ts +263 -44
- package/src/services/custom-url.ts +2 -2
- package/src/services/export-theme/layouts/_default/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +27 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +0 -1
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +620 -71
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/mcp.ts +19 -1
- package/src/services/media.ts +8 -1
- package/src/services/path.ts +34 -1
- package/src/services/post.ts +214 -39
- package/src/services/site-admin.ts +3 -7
- package/src/services/site.ts +83 -31
- package/src/styles/ui.css +7 -1
- package/src/types/app-context.ts +16 -0
- package/src/types/bindings.ts +5 -0
- package/src/types/operations.ts +10 -0
- package/src/ui/dash/settings/GeneralContent.tsx +10 -8
- package/src/client/tiptap/exitable-marks.ts +0 -73
package/src/lib/image.ts
CHANGED
|
@@ -34,7 +34,11 @@ export interface ImageOptions {
|
|
|
34
34
|
* - Cloudinary
|
|
35
35
|
* - Any service with similar URL-based transformation API
|
|
36
36
|
*
|
|
37
|
-
*
|
|
37
|
+
* A root-relative source is written without its leading slash, the form the
|
|
38
|
+
* transformation service resolves against its own host. Kept, it would produce
|
|
39
|
+
* `…/width=200//media/abc123`, which Cloudflare cannot fetch (`err=9404`).
|
|
40
|
+
*
|
|
41
|
+
* @param originalUrl - The original image URL, absolute or root-relative
|
|
38
42
|
* @param transformUrl - The base URL for transformations (e.g., `https://example.com/cdn-cgi/image`)
|
|
39
43
|
* @param options - Transformation options (width, height, quality, format, fit)
|
|
40
44
|
* @returns The transformed URL or original URL if transformations are not configured
|
|
@@ -45,9 +49,13 @@ export interface ImageOptions {
|
|
|
45
49
|
* getImageUrl("/media/abc123", undefined, { width: 200 });
|
|
46
50
|
* // Returns: "/media/abc123"
|
|
47
51
|
*
|
|
48
|
-
* // With transform URL -
|
|
52
|
+
* // With transform URL and a root-relative source
|
|
49
53
|
* getImageUrl("/media/abc123", "https://example.com/cdn-cgi/image", { width: 200, quality: 80 });
|
|
50
|
-
* // Returns: "https://example.com/cdn-cgi/image/width=200,quality=80/
|
|
54
|
+
* // Returns: "https://example.com/cdn-cgi/image/width=200,quality=80/media/abc123"
|
|
55
|
+
*
|
|
56
|
+
* // With transform URL and an absolute source
|
|
57
|
+
* getImageUrl("https://cdn.example.com/media/abc123", "https://example.com/cdn-cgi/image", { width: 200 });
|
|
58
|
+
* // Returns: "https://example.com/cdn-cgi/image/width=200/https://cdn.example.com/media/abc123"
|
|
51
59
|
* ```
|
|
52
60
|
*/
|
|
53
61
|
export function getImageUrl(
|
|
@@ -70,7 +78,12 @@ export function getImageUrl(
|
|
|
70
78
|
return originalUrl;
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
|
|
81
|
+
const base = transformUrl.replace(/\/+$/, "");
|
|
82
|
+
const source =
|
|
83
|
+
originalUrl.startsWith("/") && !originalUrl.startsWith("//")
|
|
84
|
+
? originalUrl.slice(1)
|
|
85
|
+
: originalUrl;
|
|
86
|
+
return `${base}/${params.join(",")}/${source}`;
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
/**
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Extension,
|
|
3
3
|
Node,
|
|
4
|
+
getSchema,
|
|
4
5
|
type AnyExtension,
|
|
5
6
|
type Extensions,
|
|
6
7
|
type JSONContent,
|
|
7
8
|
} from "@tiptap/core";
|
|
9
|
+
import { Fragment, type Schema } from "@tiptap/pm/model";
|
|
8
10
|
import { MarkdownManager } from "@tiptap/markdown";
|
|
9
11
|
import CodeBlock from "@tiptap/extension-code-block";
|
|
12
|
+
import { OrderedList } from "@tiptap/extension-list";
|
|
13
|
+
import HardBreak from "@tiptap/extension-hard-break";
|
|
14
|
+
import Paragraph from "@tiptap/extension-paragraph";
|
|
15
|
+
import Bold from "@tiptap/extension-bold";
|
|
16
|
+
import Italic from "@tiptap/extension-italic";
|
|
17
|
+
import Strike from "@tiptap/extension-strike";
|
|
10
18
|
import Link from "@tiptap/extension-link";
|
|
11
19
|
import StarterKit from "@tiptap/starter-kit";
|
|
12
20
|
import {
|
|
@@ -224,6 +232,333 @@ const SemanticLink = Link.extend({
|
|
|
224
232
|
clearable: false,
|
|
225
233
|
});
|
|
226
234
|
|
|
235
|
+
const LINE_START_BLOCK_SYNTAX: ReadonlyArray<[RegExp, string]> = [
|
|
236
|
+
// `1986. A good year`, `2) Second`: an ordered-list marker
|
|
237
|
+
[/^([ \t]{0,3})(\d{1,9})([.)])(?=[ \t]|$)/, "$1$2\\$3"],
|
|
238
|
+
// `- note`, `+ note`: a bullet-list marker (`*` is escaped inline already)
|
|
239
|
+
[/^([ \t]{0,3})([-+])(?=[ \t]|$)/, "$1\\$2"],
|
|
240
|
+
// `# note`: an ATX heading
|
|
241
|
+
[/^([ \t]{0,3})(#{1,6})(?=[ \t]|$)/, "$1\\$2"],
|
|
242
|
+
// `---`, `===`: a thematic break or a setext underline for the line above
|
|
243
|
+
[/^([ \t]{0,3})([-=])(?=[-= \t]*$)/, "$1\\$2"],
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Backslash-escape block syntax that a paragraph line happens to open with.
|
|
248
|
+
*
|
|
249
|
+
* Text nodes only get inline escaping, so a paragraph reading `1. Pony`, or a
|
|
250
|
+
* hard break followed by `- note`, came back from the Markdown as a list.
|
|
251
|
+
*
|
|
252
|
+
* @param markdown - A paragraph's rendered Markdown
|
|
253
|
+
* @returns The same Markdown with each line's leading block syntax escaped
|
|
254
|
+
* @example
|
|
255
|
+
* escapeLineStartBlockSyntax("Update: \n1. Pony"); // "Update: \n1\\. Pony"
|
|
256
|
+
*/
|
|
257
|
+
function escapeLineStartBlockSyntax(markdown: string): string {
|
|
258
|
+
return markdown
|
|
259
|
+
.split("\n")
|
|
260
|
+
.map((line) =>
|
|
261
|
+
LINE_START_BLOCK_SYNTAX.reduce(
|
|
262
|
+
(escaped, [pattern, replacement]) =>
|
|
263
|
+
escaped.replace(pattern, replacement),
|
|
264
|
+
line,
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
.join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The HTML tag each emphasis mark falls back to. */
|
|
271
|
+
const HTML_EMPHASIS_TAGS = {
|
|
272
|
+
bold: "strong",
|
|
273
|
+
italic: "em",
|
|
274
|
+
strike: "s",
|
|
275
|
+
} as const;
|
|
276
|
+
|
|
277
|
+
type EmphasisMarkType = keyof typeof HTML_EMPHASIS_TAGS;
|
|
278
|
+
|
|
279
|
+
const EMPHASIS_MARK_TYPES = Object.keys(
|
|
280
|
+
HTML_EMPHASIS_TAGS,
|
|
281
|
+
) as EmphasisMarkType[];
|
|
282
|
+
|
|
283
|
+
/** Marks that write their own delimiter characters around the text. */
|
|
284
|
+
const DELIMITED_MARK_TYPES = new Set<string>([
|
|
285
|
+
...EMPHASIS_MARK_TYPES,
|
|
286
|
+
"code",
|
|
287
|
+
"link",
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Serialization-only mark attribute: write this run with HTML tags. It is
|
|
292
|
+
* set by `markEmphasisDelimiters` on a copy of the document and never stored.
|
|
293
|
+
*/
|
|
294
|
+
const HTML_EMPHASIS_ATTR = "markdownAsHtml";
|
|
295
|
+
|
|
296
|
+
type FlankingClass = "space" | "punctuation" | "other";
|
|
297
|
+
|
|
298
|
+
function classifyFlankingChar(char: string | undefined): FlankingClass {
|
|
299
|
+
if (char === undefined || /\s/u.test(char)) return "space";
|
|
300
|
+
return /[\p{P}\p{S}]/u.test(char) ? "punctuation" : "other";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function hasMark(node: JSONContent | undefined, type: string): boolean {
|
|
304
|
+
return node?.marks?.some((mark) => mark.type === type) ?? false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Whether a `**`, `*`, or `~~` at one end of a run can open or close it.
|
|
309
|
+
*
|
|
310
|
+
* CommonMark's flanking rule, which GFM strikethrough shares: the character
|
|
311
|
+
* on the text side must not be whitespace, and when it is punctuation, the
|
|
312
|
+
* character on the outside must be whitespace or punctuation too. The rule is
|
|
313
|
+
* symmetric, so one check covers the opening and the closing delimiter.
|
|
314
|
+
*
|
|
315
|
+
* @param nodes - The inline nodes of one block
|
|
316
|
+
* @param index - The run's first node (`side: "start"`) or last (`"end"`)
|
|
317
|
+
* @param side - Which end of the run
|
|
318
|
+
* @param markType - The run's mark
|
|
319
|
+
* @returns True when Markdown delimiters work at this end
|
|
320
|
+
*/
|
|
321
|
+
function delimiterFlanks(
|
|
322
|
+
nodes: JSONContent[],
|
|
323
|
+
index: number,
|
|
324
|
+
side: "start" | "end",
|
|
325
|
+
markType: string,
|
|
326
|
+
): boolean {
|
|
327
|
+
const node = nodes[index];
|
|
328
|
+
const neighbor = nodes[side === "start" ? index - 1 : index + 1];
|
|
329
|
+
const chars = [...(node?.text ?? "")];
|
|
330
|
+
const edge = side === "start" ? chars : chars.slice().reverse();
|
|
331
|
+
const innerChar = edge.find((char) => !/\s/u.test(char));
|
|
332
|
+
|
|
333
|
+
// Another mark opening or closing at the same edge writes its delimiter
|
|
334
|
+
// between ours and the text; it counts as punctuation.
|
|
335
|
+
const sharesEdge =
|
|
336
|
+
node?.marks?.some(
|
|
337
|
+
(mark) =>
|
|
338
|
+
mark.type !== markType &&
|
|
339
|
+
DELIMITED_MARK_TYPES.has(mark.type) &&
|
|
340
|
+
!hasMark(neighbor, mark.type),
|
|
341
|
+
) ?? false;
|
|
342
|
+
const inner = sharesEdge ? "punctuation" : classifyFlankingChar(innerChar);
|
|
343
|
+
|
|
344
|
+
// The serializer moves whitespace at a run's edge outside the delimiter.
|
|
345
|
+
let outer: FlankingClass;
|
|
346
|
+
if (edge[0] !== undefined && /\s/u.test(edge[0])) outer = "space";
|
|
347
|
+
else if (!neighbor || neighbor.type === "hardBreak") outer = "space";
|
|
348
|
+
else if (neighbor.type === "text") {
|
|
349
|
+
const neighborChars = [...(neighbor.text ?? "")];
|
|
350
|
+
outer = classifyFlankingChar(
|
|
351
|
+
side === "start" ? neighborChars.at(-1) : neighborChars[0],
|
|
352
|
+
);
|
|
353
|
+
} else outer = "punctuation"; // ``, `[^1]`
|
|
354
|
+
|
|
355
|
+
return inner !== "space" && (inner !== "punctuation" || outer !== "other");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function markEmphasisRuns(nodes: JSONContent[]): JSONContent[] {
|
|
359
|
+
const result = nodes.map((node) => ({ ...node }));
|
|
360
|
+
|
|
361
|
+
for (const markType of EMPHASIS_MARK_TYPES) {
|
|
362
|
+
let start = 0;
|
|
363
|
+
while (start < result.length) {
|
|
364
|
+
if (result[start]?.type !== "text" || !hasMark(result[start], markType)) {
|
|
365
|
+
start += 1;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The serializer closes marks around any non-text node, so a run is
|
|
370
|
+
// consecutive text nodes.
|
|
371
|
+
let end = start;
|
|
372
|
+
while (
|
|
373
|
+
result[end + 1]?.type === "text" &&
|
|
374
|
+
hasMark(result[end + 1], markType)
|
|
375
|
+
) {
|
|
376
|
+
end += 1;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (
|
|
380
|
+
!delimiterFlanks(result, start, "start", markType) ||
|
|
381
|
+
!delimiterFlanks(result, end, "end", markType)
|
|
382
|
+
) {
|
|
383
|
+
for (let index = start; index <= end; index += 1) {
|
|
384
|
+
const node = result[index] as JSONContent;
|
|
385
|
+
node.marks = node.marks?.map((mark) =>
|
|
386
|
+
mark.type === markType
|
|
387
|
+
? {
|
|
388
|
+
...mark,
|
|
389
|
+
attrs: { ...mark.attrs, [HTML_EMPHASIS_ATTR]: true },
|
|
390
|
+
}
|
|
391
|
+
: mark,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
start = end + 1;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Flag the emphasis runs whose Markdown delimiters a CommonMark parser would
|
|
405
|
+
* leave as literal characters, so they serialize as HTML tags instead.
|
|
406
|
+
*
|
|
407
|
+
* Chinese and Japanese put no space around punctuation, so `**说话。**来的人`
|
|
408
|
+
* is common, and neither Hugo (goldmark) nor Jant's own parser reads the
|
|
409
|
+
* closing `**` after `。` followed by `来`. `<strong>…</strong>` reads the
|
|
410
|
+
* same in both, and the rest of the Markdown stays as it was.
|
|
411
|
+
*
|
|
412
|
+
* @param node - A TipTap document or descendant
|
|
413
|
+
* @returns A copy with unflankable runs flagged
|
|
414
|
+
* @example
|
|
415
|
+
* markEmphasisDelimiters(doc); // bold "说话。" before "来" gets the flag
|
|
416
|
+
*/
|
|
417
|
+
function markEmphasisDelimiters(node: JSONContent): JSONContent {
|
|
418
|
+
if (!node.content || node.type === "codeBlock") return node;
|
|
419
|
+
|
|
420
|
+
const content = node.content.map(markEmphasisDelimiters);
|
|
421
|
+
const hasInline = content.some(
|
|
422
|
+
(child) => child.type === "text" || child.type === "hardBreak",
|
|
423
|
+
);
|
|
424
|
+
return { ...node, content: hasInline ? markEmphasisRuns(content) : content };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function renderEmphasis(
|
|
428
|
+
node: JSONContent,
|
|
429
|
+
content: string,
|
|
430
|
+
markType: EmphasisMarkType,
|
|
431
|
+
delimiter: string,
|
|
432
|
+
): string {
|
|
433
|
+
if (!node.attrs?.[HTML_EMPHASIS_ATTR]) {
|
|
434
|
+
return `${delimiter}${content}${delimiter}`;
|
|
435
|
+
}
|
|
436
|
+
const tag = HTML_EMPHASIS_TAGS[markType];
|
|
437
|
+
return `<${tag}>${content}</${tag}>`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const MarkdownBold = Bold.extend({
|
|
441
|
+
renderMarkdown(node, helpers) {
|
|
442
|
+
return renderEmphasis(node, helpers.renderChildren(node), "bold", "**");
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const MarkdownItalic = Italic.extend({
|
|
447
|
+
renderMarkdown(node, helpers) {
|
|
448
|
+
return renderEmphasis(node, helpers.renderChildren(node), "italic", "*");
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const MarkdownStrike = Strike.extend({
|
|
453
|
+
renderMarkdown(node, helpers) {
|
|
454
|
+
return renderEmphasis(node, helpers.renderChildren(node), "strike", "~~");
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
/** Marked token for each HTML emphasis tag the parser accepts. */
|
|
459
|
+
const HTML_EMPHASIS_TOKEN_TYPES: Record<string, "strong" | "em" | "del"> = {
|
|
460
|
+
strong: "strong",
|
|
461
|
+
b: "strong",
|
|
462
|
+
em: "em",
|
|
463
|
+
i: "em",
|
|
464
|
+
s: "del",
|
|
465
|
+
del: "del",
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const HTML_EMPHASIS_OPEN_PATTERN = /<(?:strong|b|em|i|s|del)>/i;
|
|
469
|
+
const HTML_EMPHASIS_PATTERN = /^<(strong|b|em|i|s|del)>([\s\S]*?)<\/\1>/i;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Reads the HTML tags `markEmphasisDelimiters` writes back as marks.
|
|
473
|
+
*
|
|
474
|
+
* Only bare tags: `<strong onclick=…>` and every other tag stay text, as
|
|
475
|
+
* inline HTML always has. The content between the tags is Markdown.
|
|
476
|
+
*/
|
|
477
|
+
const MarkdownHtmlEmphasis = Extension.create({
|
|
478
|
+
name: "markdownHtmlEmphasis",
|
|
479
|
+
|
|
480
|
+
markdownTokenizer: {
|
|
481
|
+
name: "htmlEmphasis",
|
|
482
|
+
level: "inline",
|
|
483
|
+
start(src: string) {
|
|
484
|
+
return src.search(HTML_EMPHASIS_OPEN_PATTERN);
|
|
485
|
+
},
|
|
486
|
+
tokenize(src: string, _tokens, helpers) {
|
|
487
|
+
const match = HTML_EMPHASIS_PATTERN.exec(src);
|
|
488
|
+
if (!match) return undefined;
|
|
489
|
+
|
|
490
|
+
const [raw, tag = "", text = ""] = match;
|
|
491
|
+
const type = HTML_EMPHASIS_TOKEN_TYPES[tag.toLowerCase()];
|
|
492
|
+
if (!type) return undefined;
|
|
493
|
+
return { type, raw, text, tokens: helpers.inlineTokens(text) };
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Hard breaks as two trailing spaces, except where that line would be blank.
|
|
500
|
+
*
|
|
501
|
+
* A break at the start of a paragraph, or right after another break, puts
|
|
502
|
+
* the spaces on a line of their own. A line of spaces is blank in Markdown:
|
|
503
|
+
* it ended the paragraph, and `- ` left a list item empty with the text
|
|
504
|
+
* after it outside the list. The backslash form keeps something on the line.
|
|
505
|
+
*/
|
|
506
|
+
const MarkdownHardBreak = HardBreak.extend({
|
|
507
|
+
renderMarkdown(_node, _helpers, context) {
|
|
508
|
+
const previous = context?.previousNode;
|
|
509
|
+
return !previous || previous.type === "hardBreak" ? "\\\n" : " \n";
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const renderParagraphMarkdown = Paragraph.config.renderMarkdown;
|
|
514
|
+
|
|
515
|
+
const MarkdownParagraph = Paragraph.extend({
|
|
516
|
+
renderMarkdown(node, helpers, context) {
|
|
517
|
+
const rendered =
|
|
518
|
+
renderParagraphMarkdown?.call(this, node, helpers, context) ?? "";
|
|
519
|
+
return escapeLineStartBlockSyntax(rendered);
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Ordered lists with CommonMark markers: digits only.
|
|
525
|
+
*
|
|
526
|
+
* Tiptap's ordered list also reads letters and roman numerals as markers
|
|
527
|
+
* (`a.`, `IV.`, anything of one or two letters), so a line such as
|
|
528
|
+
* `PS. 补充一句` or `Mr. Smith went` became a list item and lost its first
|
|
529
|
+
* word. Its tokenizer also measured a marker's width without the `.`, which
|
|
530
|
+
* left one stray space on every line of a code block inside a list item.
|
|
531
|
+
*
|
|
532
|
+
* Jant's Markdown is CommonMark plus GFM (docs/internal/markdown-contract.md),
|
|
533
|
+
* the same dialect Hugo reads in an export, so list tokenizing goes back to
|
|
534
|
+
* marked: a tokenizer that never matches leaves the built-in one in charge,
|
|
535
|
+
* and marked's list items go to `listItem` the way bullet lists' do. Tiptap's
|
|
536
|
+
* plain-text paste plugin goes too; it applied the same markers, and the
|
|
537
|
+
* editors already parse pasted plain text as Markdown (`MarkdownClipboard`).
|
|
538
|
+
*/
|
|
539
|
+
const CommonMarkOrderedList = OrderedList.extend({
|
|
540
|
+
markdownTokenizer: {
|
|
541
|
+
name: "orderedList",
|
|
542
|
+
level: "block",
|
|
543
|
+
start: () => -1,
|
|
544
|
+
tokenize: () => undefined,
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
parseMarkdown: (token, helpers) => {
|
|
548
|
+
if (token.type !== "list" || !token.ordered) return [];
|
|
549
|
+
const start = typeof token.start === "number" ? token.start : 1;
|
|
550
|
+
return {
|
|
551
|
+
type: "orderedList",
|
|
552
|
+
...(start === 1 ? {} : { attrs: { start } }),
|
|
553
|
+
content: token.items ? helpers.parseChildren(token.items) : [],
|
|
554
|
+
};
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
addProseMirrorPlugins() {
|
|
558
|
+
return [];
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
|
|
227
562
|
const MarkdownFigureImageSupport = Extension.create({
|
|
228
563
|
name: "markdownFigureImageSupport",
|
|
229
564
|
|
|
@@ -824,8 +1159,21 @@ export function createMarkdownContentExtensions(
|
|
|
824
1159
|
heading: { levels: [1, 2, 3] },
|
|
825
1160
|
link: false,
|
|
826
1161
|
codeBlock: false,
|
|
1162
|
+
orderedList: false,
|
|
1163
|
+
paragraph: false,
|
|
1164
|
+
hardBreak: false,
|
|
1165
|
+
bold: false,
|
|
1166
|
+
italic: false,
|
|
1167
|
+
strike: false,
|
|
827
1168
|
trailingNode: { notAfter: ["footnoteDefinition"] },
|
|
828
1169
|
}),
|
|
1170
|
+
MarkdownParagraph,
|
|
1171
|
+
MarkdownHardBreak,
|
|
1172
|
+
MarkdownBold,
|
|
1173
|
+
MarkdownItalic,
|
|
1174
|
+
MarkdownStrike,
|
|
1175
|
+
MarkdownHtmlEmphasis,
|
|
1176
|
+
CommonMarkOrderedList,
|
|
829
1177
|
SemanticLink.configure({
|
|
830
1178
|
openOnClick: false,
|
|
831
1179
|
autolink: false,
|
|
@@ -1050,12 +1398,54 @@ export function getMarkdownManager(): MarkdownManager {
|
|
|
1050
1398
|
return sharedMarkdownManager;
|
|
1051
1399
|
}
|
|
1052
1400
|
|
|
1401
|
+
let sharedContentSchema: Schema | null = null;
|
|
1402
|
+
|
|
1403
|
+
function getContentSchema(): Schema {
|
|
1404
|
+
sharedContentSchema ??= getSchema(createMarkdownContentExtensions());
|
|
1405
|
+
return sharedContentSchema;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* Fill in the children a node's schema requires but the document left out.
|
|
1410
|
+
*
|
|
1411
|
+
* A list item must hold a paragraph, a blockquote a block, a doc a block.
|
|
1412
|
+
* Documents that skipped one (an empty `1. ` item from an older Markdown
|
|
1413
|
+
* parser, or JSON posted through the API) made the Markdown serializer throw.
|
|
1414
|
+
* Each empty node that cannot be empty gets what `createAndFill` would give it.
|
|
1415
|
+
*
|
|
1416
|
+
* @param node - A TipTap document or descendant
|
|
1417
|
+
* @returns A copy whose empty required containers are filled
|
|
1418
|
+
* @example
|
|
1419
|
+
* fillRequiredContent({ type: "listItem", content: [] });
|
|
1420
|
+
* // { type: "listItem", content: [{ type: "paragraph" }] }
|
|
1421
|
+
*/
|
|
1422
|
+
export function fillRequiredContent(node: JSONContent): JSONContent {
|
|
1423
|
+
const nodeType = node.type ? getContentSchema().nodes[node.type] : undefined;
|
|
1424
|
+
if (!nodeType || nodeType.isLeaf) return node;
|
|
1425
|
+
|
|
1426
|
+
const content = node.content?.map(fillRequiredContent);
|
|
1427
|
+
if ((content?.length ?? 0) === 0 && !nodeType.contentMatch.validEnd) {
|
|
1428
|
+
const filled = nodeType.contentMatch.fillBefore(Fragment.empty, true);
|
|
1429
|
+
if (filled) {
|
|
1430
|
+
return { ...node, content: filled.toJSON() as JSONContent[] };
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
return content ? { ...node, content } : node;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1053
1437
|
export function parseMarkdownDocument(markdown: string): JSONContent {
|
|
1054
|
-
return
|
|
1438
|
+
return fillRequiredContent(
|
|
1439
|
+
normalizeMarkdownDocument(getMarkdownManager().parse(markdown)),
|
|
1440
|
+
);
|
|
1055
1441
|
}
|
|
1056
1442
|
|
|
1057
1443
|
export function serializeMarkdownDocument(doc: JSONContent): string {
|
|
1058
1444
|
return expandCodeBlockFences(
|
|
1059
|
-
getMarkdownManager().serialize(
|
|
1445
|
+
getMarkdownManager().serialize(
|
|
1446
|
+
normalizeFootnoteArtifacts(
|
|
1447
|
+
markEmphasisDelimiters(fillRequiredContent(doc)),
|
|
1448
|
+
),
|
|
1449
|
+
),
|
|
1060
1450
|
);
|
|
1061
1451
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { JSONContent } from "@tiptap/core";
|
|
10
10
|
import { upgradeLegacyFootnotes } from "./footnotes.js";
|
|
11
|
+
import { fillRequiredContent } from "./markdown-manager.js";
|
|
11
12
|
import {
|
|
12
13
|
renderTiptapDocument,
|
|
13
14
|
type TiptapRenderResult,
|
|
@@ -50,8 +51,9 @@ export function renderPostBodyHtml(postId: string, body: string): string {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
|
-
* Normalize recognized historical footnotes
|
|
54
|
-
*
|
|
54
|
+
* Normalize recognized historical footnotes, fill in children the schema
|
|
55
|
+
* requires (an empty list item gets its paragraph), and render the current
|
|
56
|
+
* HTML projection in one parse pass.
|
|
55
57
|
*
|
|
56
58
|
* @param postId - Immutable post TypeID
|
|
57
59
|
* @param body - Canonical TipTap JSON
|
|
@@ -73,10 +75,15 @@ export function tryPreparePostBodyHtml(
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
const upgraded = upgradeLegacyFootnotes(parsed);
|
|
78
|
+
const doc = fillRequiredContent(upgraded.doc);
|
|
79
|
+
const canonical = JSON.stringify(doc);
|
|
76
80
|
return {
|
|
77
81
|
ok: true,
|
|
78
|
-
body:
|
|
79
|
-
|
|
82
|
+
body:
|
|
83
|
+
upgraded.upgraded || canonical !== JSON.stringify(upgraded.doc)
|
|
84
|
+
? canonical
|
|
85
|
+
: body,
|
|
86
|
+
html: renderTiptapDocument(doc, { namespace: postId }),
|
|
80
87
|
upgradedLegacyFootnotes: upgraded.upgraded,
|
|
81
88
|
};
|
|
82
89
|
} catch (error) {
|
|
@@ -27,9 +27,63 @@ import { getPublicUrlForProvider, getMediaUrl, getImageUrl } from "./image.js";
|
|
|
27
27
|
import { normalizeTimeZone } from "./timezones.js";
|
|
28
28
|
import { getSiteOrigin, getSitePathPrefix, normalizeSiteUrl } from "./url.js";
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Whether an empty stored value means "never configured" for this field.
|
|
32
|
+
*
|
|
33
|
+
* The test is whether the editor could have produced the empty value, and it
|
|
34
|
+
* mirrors what `normalizeConfigEditorDefinitionValue` accepts. A boolean takes
|
|
35
|
+
* only `true` or `false`; a number has to parse; an enum has to name one of its
|
|
36
|
+
* options. None of those can be stored empty, so an empty row for one of them
|
|
37
|
+
* is not a choice anybody made — it comes from a path that skips validation,
|
|
38
|
+
* and a snapshot's `db.sql`, replayed as raw SQL, is exactly that. Read as a
|
|
39
|
+
* configured value it silences the environment variable the operator did set,
|
|
40
|
+
* for as long as the row exists.
|
|
41
|
+
*
|
|
42
|
+
* Text is the opposite: clearing a description or a footer is an ordinary edit,
|
|
43
|
+
* so an empty value there is a decision and keeps its precedence. So is an enum
|
|
44
|
+
* that lists `""` among its options, the way `DASHBOARD_LANGUAGE` does, and one
|
|
45
|
+
* whose options come from a runtime source, where nothing here can rule it
|
|
46
|
+
* out.
|
|
47
|
+
*
|
|
48
|
+
* @param field - Config registry entry for the key being resolved
|
|
49
|
+
* @returns True when an empty stored value should fall through to env/default
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* isUnsetWhenEmpty(CONFIG_FIELDS.NOINDEX); // true (boolean)
|
|
54
|
+
* isUnsetWhenEmpty(CONFIG_FIELDS.SITE_FOOTER); // false (string)
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
function isUnsetWhenEmpty(
|
|
58
|
+
field: (typeof CONFIG_FIELDS)[keyof typeof CONFIG_FIELDS],
|
|
59
|
+
): boolean {
|
|
60
|
+
if (!("editor" in field)) return false;
|
|
61
|
+
const editor = field.editor;
|
|
62
|
+
switch (editor.type) {
|
|
63
|
+
case "boolean":
|
|
64
|
+
case "number":
|
|
65
|
+
return true;
|
|
66
|
+
case "enum":
|
|
67
|
+
// Only a fixed option list proves the value is impossible. `""` is a real
|
|
68
|
+
// choice for `DASHBOARD_LANGUAGE`, which offers it, and an enum drawing
|
|
69
|
+
// its options from a runtime source cannot rule it out either.
|
|
70
|
+
return (
|
|
71
|
+
"options" in editor &&
|
|
72
|
+
!(editor.options as readonly string[]).includes("")
|
|
73
|
+
);
|
|
74
|
+
default:
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
30
79
|
/**
|
|
31
80
|
* Resolve a single config value following priority rules.
|
|
32
81
|
*
|
|
82
|
+
* Settings saved in the dashboard outrank the environment, which outranks the
|
|
83
|
+
* default. The exception is an empty boolean or numeric value — see
|
|
84
|
+
* {@link isUnsetWhenEmpty}, which explains why that is absence rather than
|
|
85
|
+
* choice.
|
|
86
|
+
*
|
|
33
87
|
* @param key - CONFIG_FIELDS key
|
|
34
88
|
* @param allSettings - DB settings map
|
|
35
89
|
* @param env - Worker bindings
|
|
@@ -46,7 +100,10 @@ function resolve(
|
|
|
46
100
|
|
|
47
101
|
// User-configurable: DB > ENV > Default
|
|
48
102
|
if (!field.envOnly && Object.hasOwn(allSettings, key)) {
|
|
49
|
-
|
|
103
|
+
const stored = allSettings[key] ?? "";
|
|
104
|
+
if (stored !== "" || !isUnsetWhenEmpty(field)) {
|
|
105
|
+
return stored;
|
|
106
|
+
}
|
|
50
107
|
}
|
|
51
108
|
|
|
52
109
|
// ENV > Default
|
package/src/lib/schemas.ts
CHANGED
|
@@ -195,10 +195,14 @@ export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
|
|
|
195
195
|
export const SystemNavKeySchema = z.enum(SYSTEM_NAV_KEY_VALUES);
|
|
196
196
|
|
|
197
197
|
/**
|
|
198
|
-
* Redirect type
|
|
199
|
-
*
|
|
198
|
+
* Redirect type, as forms send it ("301") or as the API answers it (301).
|
|
199
|
+
* Parses to the string form; stored as a number.
|
|
200
200
|
*/
|
|
201
|
-
export const RedirectTypeSchema = z.
|
|
201
|
+
export const RedirectTypeSchema = z.union([
|
|
202
|
+
z.enum(["301", "302"]),
|
|
203
|
+
z.literal(301).transform(() => "301" as const),
|
|
204
|
+
z.literal(302).transform(() => "302" as const),
|
|
205
|
+
]);
|
|
202
206
|
|
|
203
207
|
/**
|
|
204
208
|
* Custom URL target type enum schema.
|
|
@@ -547,15 +551,32 @@ function refineSlugPathExclusivity<T extends { slug?: string; path?: string }>(
|
|
|
547
551
|
});
|
|
548
552
|
}
|
|
549
553
|
|
|
554
|
+
/**
|
|
555
|
+
* Create only: a post's own record timestamps, in Unix seconds. An import or
|
|
556
|
+
* a migration restores them, so "last edited" (feed `<updated>`, sitemap
|
|
557
|
+
* `lastmod`) and Thread order (the root, then replies by creation time, then
|
|
558
|
+
* ID) survive the move.
|
|
559
|
+
* Omitted, both are the time of the request; `updatedAt` alone defaults to
|
|
560
|
+
* `createdAt`.
|
|
561
|
+
*/
|
|
562
|
+
const RestoredTimestampFields = {
|
|
563
|
+
createdAt: z.number().int().positive().optional(),
|
|
564
|
+
updatedAt: z.number().int().positive().optional(),
|
|
565
|
+
};
|
|
566
|
+
|
|
550
567
|
/**
|
|
551
568
|
* API request body schema for creating a post
|
|
552
569
|
*/
|
|
553
570
|
export const CreatePostSchema = refineSlugPathExclusivity(
|
|
554
|
-
refineCreatePostFormatShape(
|
|
571
|
+
refineCreatePostFormatShape(
|
|
572
|
+
refineBodyExclusivity(PostFieldsSchema.extend(RestoredTimestampFields)),
|
|
573
|
+
),
|
|
555
574
|
);
|
|
556
575
|
|
|
557
576
|
export const CreatePostApiSchema = refineSlugPathExclusivity(
|
|
558
|
-
refineCreatePostFormatShape(
|
|
577
|
+
refineCreatePostFormatShape(
|
|
578
|
+
refineBodyExclusivity(ApiPostFieldsSchema.extend(RestoredTimestampFields)),
|
|
579
|
+
),
|
|
559
580
|
);
|
|
560
581
|
|
|
561
582
|
/**
|
|
@@ -1054,6 +1075,30 @@ export const SetupSiteSchema = SetupLanguageSchema.extend({
|
|
|
1054
1075
|
/** The answers the last setup screen collects from either install kind. */
|
|
1055
1076
|
export type SetupLanguageAnswers = z.infer<typeof SetupLanguageSchema>;
|
|
1056
1077
|
|
|
1078
|
+
/**
|
|
1079
|
+
* Both setup screens' answers at once, for `jant setup`: a self-hosted install
|
|
1080
|
+
* set up by its deployment rather than in a browser.
|
|
1081
|
+
*
|
|
1082
|
+
* Only the credentials are required. Everything the second screen asks has
|
|
1083
|
+
* the default an empty answer gets there, and a browser's guesses — its
|
|
1084
|
+
* language, its time zone — have no counterpart here.
|
|
1085
|
+
*/
|
|
1086
|
+
export const InstanceSetupSchema = SetupAccountSchema.extend({
|
|
1087
|
+
siteId: createTypeIdSchema(ID_PREFIX.site).optional(),
|
|
1088
|
+
siteName: z
|
|
1089
|
+
.string()
|
|
1090
|
+
.trim()
|
|
1091
|
+
.min(1, "Site name cannot be empty")
|
|
1092
|
+
.max(MAX_SITE_NAME_LENGTH)
|
|
1093
|
+
.optional(),
|
|
1094
|
+
siteLanguage: ContentLanguageSchema.optional(),
|
|
1095
|
+
timeZone: z
|
|
1096
|
+
.string()
|
|
1097
|
+
.refine(isSupportedTimeZone, "Choose a valid time zone.")
|
|
1098
|
+
.transform(normalizeTimeZone)
|
|
1099
|
+
.optional(),
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1057
1102
|
/**
|
|
1058
1103
|
* Sign-in form validation schema
|
|
1059
1104
|
*/
|
package/src/lib/thread-fold.ts
CHANGED
|
@@ -106,9 +106,9 @@ export interface ThreadFold<T> {
|
|
|
106
106
|
*
|
|
107
107
|
* Takes the same slices the SQL path ranks for: the first
|
|
108
108
|
* `THREAD_LEADING_REPLIES`, and the last `THREAD_TRAILING_REPLIES` with the
|
|
109
|
-
* newest as the hero. `getPublishedThreads`
|
|
110
|
-
*
|
|
111
|
-
*
|
|
109
|
+
* newest as the hero. `getPublishedThreads` returns each Thread in Thread
|
|
110
|
+
* order, the same `threadOrder` the window functions rank by, so once the root
|
|
111
|
+
* is set aside position in this array *is* the rank — no approximation.
|
|
112
112
|
*
|
|
113
113
|
* @param replies - Every published reply, oldest first
|
|
114
114
|
* @returns The fold, or null when the thread has no replies
|