@protobase/ui 0.19.8 → 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.
@@ -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
- return row.labels[name] ?? displayValue(field, row.record[name], view?.fields[name]);
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: shownValue(subtitle)
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",
@@ -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,
@@ -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: stored.record[name],
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
  });
@@ -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,5 @@
1
+ //#region src/markdown/markdown-hint.ts
2
+ /** Whether the view shows a field as markdown (`.markdown()`), which only a text field takes. */
3
+ const isMarkdown = (field, hints) => field.type === "text" && Boolean(hints?.markdown);
4
+ //#endregion
5
+ export { isMarkdown };
@@ -0,0 +1,59 @@
1
+ import { Marked } from "marked";
2
+ //#region src/markdown/markdown-html.ts
3
+ const escapes = {
4
+ "&": "&amp;",
5
+ "<": "&lt;",
6
+ ">": "&gt;",
7
+ "\"": "&quot;",
8
+ "'": "&#39;"
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
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.19.8";
2
+ var version = "0.19.9";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };