@protobase/ui 0.19.7 → 0.19.9
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/dist/app/create/create-page.js +1 -1
- package/dist/app/displays/record-card.js +17 -3
- package/dist/app/field-editor.js +16 -0
- package/dist/app/pages/field-value.js +3 -0
- package/dist/app/record-page.js +8 -4
- package/dist/live/columns-from-model.js +3 -0
- package/dist/live/model-helpers.js +7 -3
- package/dist/markdown/markdown-content.js +18 -0
- package/dist/markdown/markdown-editor.js +46 -0
- package/dist/markdown/markdown-excerpt.js +36 -0
- package/dist/markdown/markdown-hint.js +5 -0
- package/dist/markdown/markdown-html.js +59 -0
- package/dist/markdown/markdown.js +15 -0
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-CB1ojooV.js +69 -0
- package/dist/shell/assets/index-DLVcUwwG.css +2 -0
- package/dist/shell/assets/{index-CafsxSWx.js → index-d-04ZYJt.js} +1 -1
- package/dist/shell/assets/{maplibre-Cb7ks8SG.js → maplibre-BACAMuVa.js} +1 -1
- package/dist/shell/assets/markdown-content-CHU2BTHn.js +67 -0
- package/dist/shell/assets/{record-map-dXyPrmGg.js → record-map-B6USTMW8.js} +1 -1
- package/dist/shell/assets/{records-map-bGH18FRn.js → records-map-DVwLP4Ey.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-e-G14mxb.js → shared-protobase-ui-BEvX9eTO.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +6 -4
- package/dist/shell/assets/app-BRVJlz_E.js +0 -68
- package/dist/shell/assets/index-Cuulz26c.css +0 -2
|
@@ -117,7 +117,7 @@ const LoadedCreate = ({ resource, values }) => {
|
|
|
117
117
|
help: view?.fields[name]?.help,
|
|
118
118
|
required: isRequired(field),
|
|
119
119
|
error: errors[name],
|
|
120
|
-
wide: Boolean(view?.fields[name]?.multiline),
|
|
120
|
+
wide: Boolean(view?.fields[name]?.multiline || view?.fields[name]?.markdown),
|
|
121
121
|
editor: /* @__PURE__ */ jsx(FieldEditor, {
|
|
122
122
|
field,
|
|
123
123
|
hints: view?.fields[name],
|
|
@@ -8,6 +8,9 @@ import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displa
|
|
|
8
8
|
import { AppLink } from "../use-app-address.js";
|
|
9
9
|
import { shownWidget } from "../field-types/shown-widget.js";
|
|
10
10
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
11
|
+
import { Markdown } from "../../markdown/markdown.js";
|
|
12
|
+
import { isMarkdown } from "../../markdown/markdown-hint.js";
|
|
13
|
+
import { markdownExcerpt } from "../../markdown/markdown-excerpt.js";
|
|
11
14
|
import { namedIcon } from "../named-icon.js";
|
|
12
15
|
import { FavoriteButton } from "../saved/favorite-button.js";
|
|
13
16
|
import { ValueBadge } from "../value-badge.js";
|
|
@@ -38,7 +41,9 @@ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
|
|
|
38
41
|
const text = (name) => {
|
|
39
42
|
const field = model.fields[name];
|
|
40
43
|
if (!field) return "";
|
|
41
|
-
|
|
44
|
+
const value = row.record[name];
|
|
45
|
+
if (row.labels[name] === void 0 && isMarkdown(field, view?.fields[name]) && typeof value === "string" && value !== "") return markdownExcerpt(value);
|
|
46
|
+
return row.labels[name] ?? displayValue(field, value, view?.fields[name]);
|
|
42
47
|
};
|
|
43
48
|
const { fields: fieldTypes, components } = useProjectUi();
|
|
44
49
|
const Card = display.card === void 0 ? void 0 : components?.[display.card];
|
|
@@ -53,6 +58,15 @@ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
|
|
|
53
58
|
fallback: text(name)
|
|
54
59
|
}) : text(name);
|
|
55
60
|
};
|
|
61
|
+
const subtitleValue = (name) => {
|
|
62
|
+
const field = model.fields[name];
|
|
63
|
+
const value = row.record[name];
|
|
64
|
+
if (!field || !isMarkdown(field, view?.fields[name]) || shownWidget(fieldTypes, field, view?.fields[name]) || typeof value !== "string" || value === "") return shownValue(name);
|
|
65
|
+
return /* @__PURE__ */ jsx(Markdown, {
|
|
66
|
+
source: value,
|
|
67
|
+
inLink: true
|
|
68
|
+
});
|
|
69
|
+
};
|
|
56
70
|
const metaValue = (name) => {
|
|
57
71
|
const field = model.fields[name];
|
|
58
72
|
const hints = view?.fields[name];
|
|
@@ -99,8 +113,8 @@ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
|
|
|
99
113
|
})]
|
|
100
114
|
}),
|
|
101
115
|
subtitle !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
102
|
-
className: view?.fields[subtitle]?.multiline ? "line-clamp-3 whitespace-pre-line break-words text-muted-foreground" : "truncate text-muted-foreground",
|
|
103
|
-
children:
|
|
116
|
+
className: view?.fields[subtitle]?.markdown ? "line-clamp-3 break-words text-muted-foreground" : view?.fields[subtitle]?.multiline ? "line-clamp-3 whitespace-pre-line break-words text-muted-foreground" : "truncate text-muted-foreground",
|
|
117
|
+
children: subtitleValue(subtitle)
|
|
104
118
|
}),
|
|
105
119
|
shownAmount !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
106
120
|
className: "truncate tabular-nums text-muted-foreground",
|
package/dist/app/field-editor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
import { Input } from "../primitives/input.js";
|
|
3
|
+
import { humanize } from "../live/naming.js";
|
|
3
4
|
import { Switch } from "../primitives/switch.js";
|
|
4
5
|
import { Textarea } from "../primitives/textarea.js";
|
|
5
6
|
import { displayValue } from "../live/display-value.js";
|
|
@@ -10,6 +11,9 @@ import { SensitiveField } from "./sensitive-field.js";
|
|
|
10
11
|
import { FileField } from "./file-field.js";
|
|
11
12
|
import { useShownWidget } from "./field-types/shown-widget.js";
|
|
12
13
|
import { WidgetPart } from "./field-types/widget-part.js";
|
|
14
|
+
import { Markdown } from "../markdown/markdown.js";
|
|
15
|
+
import { MarkdownEditor } from "../markdown/markdown-editor.js";
|
|
16
|
+
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
13
17
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
18
|
import { enumLabel } from "@protobase/schema";
|
|
15
19
|
//#region src/app/field-editor.tsx
|
|
@@ -70,6 +74,11 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
|
|
|
70
74
|
});
|
|
71
75
|
}
|
|
72
76
|
const multiline = Boolean(hints?.multiline) && field.type === "text";
|
|
77
|
+
const markdown = isMarkdown(field, hints);
|
|
78
|
+
if ((!isEditable(field) || locked) && markdown && typeof stored === "string" && stored !== "") return /* @__PURE__ */ jsx(Markdown, {
|
|
79
|
+
source: stored,
|
|
80
|
+
className: "py-1.5"
|
|
81
|
+
});
|
|
73
82
|
if (!isEditable(field) || locked) return /* @__PURE__ */ jsx("div", {
|
|
74
83
|
className: cn("py-1.5 tabular-nums", field.type === "decimal" || field.type === "integer" ? "font-medium" : "text-muted-foreground", multiline && "whitespace-pre-line break-words"),
|
|
75
84
|
children: displayValue(field, stored, hints)
|
|
@@ -86,6 +95,13 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
|
|
|
86
95
|
children: enumLabel(option, hints?.valueLabels)
|
|
87
96
|
}, option))]
|
|
88
97
|
});
|
|
98
|
+
if (markdown) return /* @__PURE__ */ jsx(MarkdownEditor, {
|
|
99
|
+
label: hints?.label ?? humanize(field.name),
|
|
100
|
+
invalid,
|
|
101
|
+
placeholder,
|
|
102
|
+
value: String(value),
|
|
103
|
+
onChange
|
|
104
|
+
});
|
|
89
105
|
if (multiline) return /* @__PURE__ */ jsx(Textarea, {
|
|
90
106
|
invalid,
|
|
91
107
|
placeholder,
|
|
@@ -9,6 +9,8 @@ import { AppLink } from "../use-app-address.js";
|
|
|
9
9
|
import { FileImage } from "../../live/file-image.js";
|
|
10
10
|
import { useShownWidget } from "../field-types/shown-widget.js";
|
|
11
11
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
12
|
+
import { Markdown } from "../../markdown/markdown.js";
|
|
13
|
+
import { isMarkdown } from "../../markdown/markdown-hint.js";
|
|
12
14
|
import { ValueBadge } from "../value-badge.js";
|
|
13
15
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
16
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
@@ -70,6 +72,7 @@ const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
|
|
|
70
72
|
})]
|
|
71
73
|
});
|
|
72
74
|
}
|
|
75
|
+
if (isMarkdown(field, hints)) return /* @__PURE__ */ jsx(Markdown, { source: String(value) });
|
|
73
76
|
const text = displayValue(field, value, hints);
|
|
74
77
|
if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(ValueBadge, {
|
|
75
78
|
value,
|
package/dist/app/record-page.js
CHANGED
|
@@ -16,6 +16,8 @@ import { AppLink, useAppAddress } from "./use-app-address.js";
|
|
|
16
16
|
import { ErrorBanner } from "./error-banner.js";
|
|
17
17
|
import { shownWidget } from "./field-types/shown-widget.js";
|
|
18
18
|
import { WidgetPart } from "./field-types/widget-part.js";
|
|
19
|
+
import { Markdown } from "../markdown/markdown.js";
|
|
20
|
+
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
19
21
|
import { FieldEditor } from "./field-editor.js";
|
|
20
22
|
import { can, fieldAccess } from "./permissions.js";
|
|
21
23
|
import { Field } from "../record-view/field.js";
|
|
@@ -164,7 +166,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
164
166
|
label: hint(name)?.label ?? humanize(name),
|
|
165
167
|
help: fieldHelp(hint(name), view, editing),
|
|
166
168
|
error: editor.errors[name],
|
|
167
|
-
wide: Boolean(hint(name)?.multiline),
|
|
169
|
+
wide: Boolean(hint(name)?.multiline || hint(name)?.markdown),
|
|
168
170
|
conflict: conflict === void 0 ? void 0 : {
|
|
169
171
|
user: {
|
|
170
172
|
name: "Someone else",
|
|
@@ -211,7 +213,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
211
213
|
return {
|
|
212
214
|
label: hint(name)?.label ?? humanize(name),
|
|
213
215
|
help: fieldHelp(hint(name), view, editing),
|
|
214
|
-
wide: Boolean(hint(name)?.multiline),
|
|
216
|
+
wide: Boolean(hint(name)?.multiline || hint(name)?.markdown),
|
|
215
217
|
value
|
|
216
218
|
};
|
|
217
219
|
};
|
|
@@ -222,11 +224,13 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
222
224
|
const field = model.fields[name];
|
|
223
225
|
if (!field || !visible(name)) return [];
|
|
224
226
|
const text = displayValue(field, stored.record[name], hint(name));
|
|
227
|
+
const value = stored.record[name];
|
|
228
|
+
const markdown = isMarkdown(field, hint(name)) && typeof value === "string" && value !== "";
|
|
225
229
|
const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(ValueBadge, {
|
|
226
|
-
value
|
|
230
|
+
value,
|
|
227
231
|
hints: hint(name),
|
|
228
232
|
children: text
|
|
229
|
-
}) : /* @__PURE__ */ jsx("strong", {
|
|
233
|
+
}) : markdown ? /* @__PURE__ */ jsx(Markdown, { source: value }) : /* @__PURE__ */ jsx("strong", {
|
|
230
234
|
className: hint(name)?.multiline ? "whitespace-pre-line break-words" : void 0,
|
|
231
235
|
children: text
|
|
232
236
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { humanize } from "./naming.js";
|
|
2
|
+
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
3
|
+
import { markdownExcerpt } from "../markdown/markdown-excerpt.js";
|
|
2
4
|
import { enumLabel } from "@protobase/schema";
|
|
3
5
|
//#region src/live/columns-from-model.ts
|
|
4
6
|
const tones = {
|
|
@@ -94,6 +96,7 @@ const columnsFromModel = (resource, view, highlight = [], names = listColumnName
|
|
|
94
96
|
value: (row) => row[name] == null ? null : currencyCents(row[name])
|
|
95
97
|
},
|
|
96
98
|
...(kind === "datetime" || kind === "date") && { value: (row) => row[name] == null ? null : Date.parse(String(row[name])) },
|
|
99
|
+
...isMarkdown(field, viewField) && { value: (row) => typeof row[name] === "string" ? markdownExcerpt(row[name]) : row[name] },
|
|
97
100
|
...(kind === "percent" || kind === "number" && field.type === "decimal") && { value: (row) => row[name] == null ? null : Number(row[name]) }
|
|
98
101
|
}];
|
|
99
102
|
});
|
|
@@ -20,10 +20,14 @@ const recordTitle = (model, view, record, fallback) => {
|
|
|
20
20
|
if (value === null || value === void 0) return fallback;
|
|
21
21
|
return model.fields[name]?.type === "enum" ? enumLabel(String(value), view?.fields[name]?.valueLabels) : String(value);
|
|
22
22
|
};
|
|
23
|
-
const
|
|
24
|
-
|
|
23
|
+
const keyPart = (model, record, name) => {
|
|
24
|
+
const value = record[name];
|
|
25
|
+
return model.fields[name]?.type === "relation" && typeof value === "object" && value !== null && "id" in value ? value.id : value;
|
|
26
|
+
};
|
|
27
|
+
const recordId = (model, record) => model.primaryKey.map((name) => String(keyPart(model, record, name))).join(",");
|
|
28
|
+
/** The key as the URL carries it, for `get` and `update`, from a stored record or a list row. */
|
|
25
29
|
const recordKey = (model, record) => {
|
|
26
|
-
const parts = model.primaryKey.map((name) => record
|
|
30
|
+
const parts = model.primaryKey.map((name) => keyPart(model, record, name));
|
|
27
31
|
return parts.length === 1 ? parts[0] : parts;
|
|
28
32
|
};
|
|
29
33
|
//#endregion
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { markdownHtml } from "./markdown-html.js";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
import DOMPurify from "dompurify";
|
|
6
|
+
//#region src/markdown/markdown-content.tsx
|
|
7
|
+
const page = cn("break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "[&_p]:my-2 [&_ul]:my-2 [&_ol]:my-2 [&_pre]:my-2 [&_blockquote]:my-2 [&_table]:my-2 [&_hr]:my-3", "[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-lg [&_h1]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-base [&_h2]:font-semibold", "[&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:font-semibold [&_h4]:mt-3 [&_h4]:mb-1 [&_h4]:font-semibold [&_h5]:font-semibold [&_h6]:font-semibold", "[&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li:has(>input)]:list-none [&_li>input]:mr-1.5 [&_li>input]:-ml-5 [&_li>input]:align-middle", "[&_a]:text-primary-text [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-primary-hover", "[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em]", "[&_pre]:whitespace-pre-wrap [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0", "[&_blockquote]:border-l-2 [&_blockquote]:border-border-strong [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground", "[&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-border [&_th]:bg-surface [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold", "[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_td]:[overflow-wrap:anywhere]", "[&_hr]:border-border [&_img]:inline [&_img]:max-w-full [&_img]:rounded [&_del]:text-muted-foreground");
|
|
8
|
+
const inLink = cn("[&_*]:my-0 [&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-inside [&_ol]:list-inside [&_pre]:whitespace-pre-wrap [&_code]:font-mono [&_code]:text-[0.9em]");
|
|
9
|
+
/** Markdown rendered, sanitised before it reaches the page: raw HTML shows as text, links open in a new tab. */
|
|
10
|
+
const MarkdownContent = ({ source, inLink: insideLink = false, className }) => {
|
|
11
|
+
const html = useMemo(() => DOMPurify.sanitize(markdownHtml(source, { inLink: insideLink }), { ADD_ATTR: ["target"] }), [source, insideLink]);
|
|
12
|
+
return /* @__PURE__ */ jsx("div", {
|
|
13
|
+
className: cn(insideLink ? inLink : page, className),
|
|
14
|
+
dangerouslySetInnerHTML: { __html: html }
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { MarkdownContent };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Segmented } from "../primitives/segmented.js";
|
|
2
|
+
import { Textarea } from "../primitives/textarea.js";
|
|
3
|
+
import { Markdown } from "./markdown.js";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/markdown/markdown-editor.tsx
|
|
7
|
+
const modes = [{
|
|
8
|
+
value: "write",
|
|
9
|
+
label: "Write"
|
|
10
|
+
}, {
|
|
11
|
+
value: "preview",
|
|
12
|
+
label: "Preview"
|
|
13
|
+
}];
|
|
14
|
+
/**
|
|
15
|
+
* Markdown source in a box that grows with it, as GitHub edits a comment, and a switch to its rendered preview: the
|
|
16
|
+
* text is stored exactly as typed.
|
|
17
|
+
*/
|
|
18
|
+
const MarkdownEditor = ({ value, onChange, label, invalid, placeholder }) => {
|
|
19
|
+
const [mode, setMode] = useState("write");
|
|
20
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
21
|
+
className: "flex flex-col gap-1.5",
|
|
22
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
23
|
+
className: "self-start",
|
|
24
|
+
children: /* @__PURE__ */ jsx(Segmented, {
|
|
25
|
+
value: mode,
|
|
26
|
+
options: modes,
|
|
27
|
+
onChange: setMode,
|
|
28
|
+
label
|
|
29
|
+
})
|
|
30
|
+
}), mode === "write" ? /* @__PURE__ */ jsx(Textarea, {
|
|
31
|
+
rows: 5,
|
|
32
|
+
invalid,
|
|
33
|
+
placeholder,
|
|
34
|
+
value,
|
|
35
|
+
onChange: (event) => onChange(event.target.value)
|
|
36
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
37
|
+
className: "min-h-[7.5rem] rounded-md border border-border px-2.5 py-1.5",
|
|
38
|
+
children: value.trim() === "" ? /* @__PURE__ */ jsx("span", {
|
|
39
|
+
className: "text-faint-foreground",
|
|
40
|
+
children: "Nothing to preview"
|
|
41
|
+
}) : /* @__PURE__ */ jsx(Markdown, { source: value })
|
|
42
|
+
})]
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
export { MarkdownEditor };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/markdown/markdown-excerpt.ts
|
|
2
|
+
const blockMarks = [
|
|
3
|
+
[/^\s*(```|~~~).*$/, ""],
|
|
4
|
+
[/^\s*\[[^\]]+\]:\s+\S.*$/, ""],
|
|
5
|
+
[/^\s*([-*_]\s*){3,}$/, ""],
|
|
6
|
+
[/^\s*\|?(\s*:?-+:?\s*\|)+\s*:?-*:?\s*$/, ""],
|
|
7
|
+
[/^\s*#{1,6}\s+/, ""],
|
|
8
|
+
[/^\s*(>\s?)+/, ""],
|
|
9
|
+
[/^\s*([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?/, ""]
|
|
10
|
+
];
|
|
11
|
+
const inlineMarks = [
|
|
12
|
+
[/!\[([^\]]*)\]\([^)]*\)/g, "$1"],
|
|
13
|
+
[/\[([^\]]*)\]\([^)]*\)/g, "$1"],
|
|
14
|
+
[/\[([^\]]+)\]\[[^\]]*\]/g, "$1"],
|
|
15
|
+
[/<((?:https?|mailto):[^>\s]+)>/gi, "$1"],
|
|
16
|
+
[/<\/?[a-z][^>]*>/gi, ""],
|
|
17
|
+
[/`([^`]+)`/g, "$1"],
|
|
18
|
+
[/(?<!\\)(\*\*|__)(?=\S)(.+?)(?<=\S)\1/g, "$2"],
|
|
19
|
+
[/(?<![\\\w*])\*(?=\S)(.+?)(?<=\S)\*/g, "$1"],
|
|
20
|
+
[/(?<![\\\w])_(?=\S)(.+?)(?<=\S)_(?!\w)/g, "$1"],
|
|
21
|
+
[/(?<!\\)~~(.+?)~~/g, "$1"],
|
|
22
|
+
[/\s*\|\s*/g, " "],
|
|
23
|
+
[/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1"]
|
|
24
|
+
];
|
|
25
|
+
const longest = 200;
|
|
26
|
+
/**
|
|
27
|
+
* Markdown as one line of plain text, as a list's cell shows it: the text of its headings, lists, links and emphasis
|
|
28
|
+
* without their marks, at most 200 characters.
|
|
29
|
+
*/
|
|
30
|
+
const markdownExcerpt = (source) => {
|
|
31
|
+
const lines = source.split(/\r?\n/).map((line) => blockMarks.reduce((text, [mark, kept]) => text.replace(mark, kept), line));
|
|
32
|
+
const text = inlineMarks.reduce((joined, [mark, kept]) => joined.replace(mark, kept), lines.join("\n")).replace(/\s+/g, " ").trim();
|
|
33
|
+
return text.length > longest ? `${text.slice(0, 199).trimEnd()}…` : text;
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { markdownExcerpt };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Marked } from "marked";
|
|
2
|
+
//#region src/markdown/markdown-html.ts
|
|
3
|
+
const escapes = {
|
|
4
|
+
"&": "&",
|
|
5
|
+
"<": "<",
|
|
6
|
+
">": ">",
|
|
7
|
+
"\"": """,
|
|
8
|
+
"'": "'"
|
|
9
|
+
};
|
|
10
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, (char) => escapes[char]);
|
|
11
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i;
|
|
12
|
+
const linkSchemes = /* @__PURE__ */ new Set([
|
|
13
|
+
"http",
|
|
14
|
+
"https",
|
|
15
|
+
"mailto",
|
|
16
|
+
"tel"
|
|
17
|
+
]);
|
|
18
|
+
const imageSchemes = /* @__PURE__ */ new Set(["http", "https"]);
|
|
19
|
+
const ignoredByBrowsers = /[\u0000-\u0020\u007f]/g;
|
|
20
|
+
const safeAddress = (href, schemes) => {
|
|
21
|
+
const named = scheme.exec(href.replace(ignoredByBrowsers, ""))?.[1]?.toLowerCase();
|
|
22
|
+
if (named !== void 0 && !schemes.has(named)) return null;
|
|
23
|
+
try {
|
|
24
|
+
return encodeURI(href).replace(/%25/g, "%");
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof URIError) return null;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const titled = (title) => title ? ` title="${escapeHtml(title)}"` : "";
|
|
31
|
+
const parser = ({ inLink = false }) => new Marked({
|
|
32
|
+
gfm: true,
|
|
33
|
+
breaks: true,
|
|
34
|
+
renderer: {
|
|
35
|
+
html: ({ text, block }) => block ? `<p>${escapeHtml(text)}</p>` : escapeHtml(text),
|
|
36
|
+
link({ href, title, tokens }) {
|
|
37
|
+
const text = this.parser.parseInline(tokens);
|
|
38
|
+
const address = inLink ? null : safeAddress(href, linkSchemes);
|
|
39
|
+
if (address === null) return text;
|
|
40
|
+
return `<a href="${escapeHtml(address)}"${titled(title)} target="_blank" rel="noopener noreferrer nofollow">${text}</a>`;
|
|
41
|
+
},
|
|
42
|
+
image({ href, title, text }) {
|
|
43
|
+
const address = inLink ? null : safeAddress(href, imageSchemes);
|
|
44
|
+
if (address === null) return escapeHtml(text);
|
|
45
|
+
return `<img src="${escapeHtml(address)}" alt="${escapeHtml(text)}"${titled(title)} loading="lazy">`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const parsers = {
|
|
50
|
+
inLink: parser({ inLink: true }),
|
|
51
|
+
page: parser({})
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Markdown as HTML (GitHub's flavour, line breaks kept): raw HTML escaped, links and images only to http(s), mailto and
|
|
55
|
+
* tel addresses or relative ones, and links opening in a new tab. The page sanitises the result again before showing it.
|
|
56
|
+
*/
|
|
57
|
+
const markdownHtml = (source, options = {}) => (options.inLink ? parsers.inLink : parsers.page).parse(source, { async: false });
|
|
58
|
+
//#endregion
|
|
59
|
+
export { markdownHtml };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { Suspense, lazy } from "react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/markdown/markdown.tsx
|
|
5
|
+
const MarkdownContent = lazy(() => import("./markdown-content.js").then((module) => ({ default: module.MarkdownContent })));
|
|
6
|
+
/** Markdown rendered and sanitised; its source, line breaks kept, until the renderer has loaded. */
|
|
7
|
+
const Markdown = (props) => /* @__PURE__ */ jsx(Suspense, {
|
|
8
|
+
fallback: /* @__PURE__ */ jsx("div", {
|
|
9
|
+
className: cn("whitespace-pre-line break-words", props.className),
|
|
10
|
+
children: props.source
|
|
11
|
+
}),
|
|
12
|
+
children: /* @__PURE__ */ jsx(MarkdownContent, { ...props })
|
|
13
|
+
});
|
|
14
|
+
//#endregion
|
|
15
|
+
export { Markdown };
|
package/dist/package.js
CHANGED