@valentinkolb/cloud 0.4.0 → 0.5.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/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown highlighter for the overtype-style editor.
|
|
3
|
+
*
|
|
4
|
+
* Input: plain text (the textarea's current value).
|
|
5
|
+
* Output: an HTML string that's injected into the preview div via
|
|
6
|
+
* `innerHTML`. Whitespace is preserved 1:1 because both layers run with
|
|
7
|
+
* `white-space: pre-wrap`. Newlines in the output are real `\n`
|
|
8
|
+
* characters — the preview never adds extra wrappers around lines.
|
|
9
|
+
*
|
|
10
|
+
* The visible markdown syntax characters (`**`, `#`, `-`, etc.) are kept
|
|
11
|
+
* in the output, wrapped in a dimmed `.md-syntax` span. This is the
|
|
12
|
+
* core of the overtype trick: the textarea's char positions must match
|
|
13
|
+
* the preview's char positions exactly, so we can't elide any source
|
|
14
|
+
* characters in the rendered version.
|
|
15
|
+
*
|
|
16
|
+
* Parsing happens in three passes:
|
|
17
|
+
*
|
|
18
|
+
* 1. HTML-escape the entire text.
|
|
19
|
+
* 2. Sanctuary extraction: pull inline-code spans (`` `…` ``) and
|
|
20
|
+
* links (`[text](url)`) out into placeholder tokens BEFORE any
|
|
21
|
+
* other regex runs. This prevents `*italic*` from matching inside
|
|
22
|
+
* a URL, and prevents code-span content from being mistaken for
|
|
23
|
+
* bold/italic markdown. Lesson from overtype issue #81.
|
|
24
|
+
* 3. Block + inline pass per line, then placeholders are restored.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Private Use Area chars for sanctuary placeholders — they will never
|
|
28
|
+
// appear in user input and won't be matched by any markdown regex.
|
|
29
|
+
const PH_OPEN = String.fromCharCode(0xe000);
|
|
30
|
+
const PH_CLOSE = String.fromCharCode(0xe001);
|
|
31
|
+
|
|
32
|
+
const escapeHtml = (s: string): string =>
|
|
33
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
34
|
+
|
|
35
|
+
// NOTE: link previews are rendered as `<span>`, never `<a href>`, so
|
|
36
|
+
// the editor itself never produces a clickable target — clicks pass
|
|
37
|
+
// through the textarea overlay anyway. URL sanitisation only matters
|
|
38
|
+
// in the downstream renderer (the `.prose` HTML in read-mode), not
|
|
39
|
+
// here. Keeping link payloads as escaped text + dim-coloured syntax
|
|
40
|
+
// is enough for the preview-as-cursor-alignment-mirror use case.
|
|
41
|
+
|
|
42
|
+
type Sanctuaries = Map<string, string>;
|
|
43
|
+
|
|
44
|
+
const extractSanctuaries = (input: string): { text: string; sanctuaries: Sanctuaries } => {
|
|
45
|
+
const sanctuaries: Sanctuaries = new Map();
|
|
46
|
+
let counter = 0;
|
|
47
|
+
const issue = (): string => `${PH_OPEN}${counter++}${PH_CLOSE}`;
|
|
48
|
+
|
|
49
|
+
let text = input;
|
|
50
|
+
|
|
51
|
+
// Inline code first — match runs of N backticks paired by N backticks.
|
|
52
|
+
// Content is restricted to single-line (no `\n`) so this regex can't
|
|
53
|
+
// accidentally swallow a fenced code block like ```\n…\n``` — fences
|
|
54
|
+
// are detected later in the block-level pass. Mirrors overtype's
|
|
55
|
+
// parser logic but with stricter single-line scope.
|
|
56
|
+
text = text.replace(/(?<!`)(`+)(?!`)([^\n]+?)\1(?!`)/g, (_match, ticks: string, content: string) => {
|
|
57
|
+
const ph = issue();
|
|
58
|
+
sanctuaries.set(
|
|
59
|
+
ph,
|
|
60
|
+
`<span class="md-code"><span class="md-syntax">${ticks}</span>${content}<span class="md-syntax">${ticks}</span></span>`,
|
|
61
|
+
);
|
|
62
|
+
return ph;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Then links [text](url). HTML was escaped earlier, so brackets show
|
|
66
|
+
// as literal `[` / `]` here. The URL part is rendered as plain dim
|
|
67
|
+
// text (not as a clickable anchor target) inside the syntax span; the
|
|
68
|
+
// outer <a> wraps the whole construct so the visible label is what's
|
|
69
|
+
// hoverable. The textarea sits on top with pointer-events, so clicks
|
|
70
|
+
// don't follow the link by default — that's intentional, otherwise
|
|
71
|
+
// selecting text by clicking would navigate away mid-edit.
|
|
72
|
+
text = text.replace(/\[([^\]\n]+?)\]\(([^)\n]+?)\)/g, (_match, label: string, url: string) => {
|
|
73
|
+
const ph = issue();
|
|
74
|
+
sanctuaries.set(
|
|
75
|
+
ph,
|
|
76
|
+
`<span class="md-link"><span class="md-syntax">[</span>${label}<span class="md-syntax">](${url})</span></span>`,
|
|
77
|
+
);
|
|
78
|
+
return ph;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return { text, sanctuaries };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const restoreSanctuaries = (html: string, sanctuaries: Sanctuaries): string => {
|
|
85
|
+
// Sanctuaries are unique unicode markers; a single split/join per
|
|
86
|
+
// placeholder is cheap and avoids any regex parsing of the markers.
|
|
87
|
+
//
|
|
88
|
+
// Order matters: we restore in REVERSE insertion order so that
|
|
89
|
+
// outer wrappers (links) are restored before their inner content
|
|
90
|
+
// (code spans) — the link's stored HTML contains the code
|
|
91
|
+
// placeholder, and we need that placeholder to land in the live
|
|
92
|
+
// string BEFORE we try to substitute it with the code HTML. Without
|
|
93
|
+
// this, `[`x`](url)` would leak the raw code placeholder into the
|
|
94
|
+
// rendered link label.
|
|
95
|
+
const entries = [...sanctuaries].reverse();
|
|
96
|
+
for (const [ph, replacement] of entries) {
|
|
97
|
+
if (html.includes(ph)) html = html.split(ph).join(replacement);
|
|
98
|
+
}
|
|
99
|
+
return html;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Separate PUA range for the bold sanctuary, so its placeholders can't
|
|
103
|
+
// collide with the outer code/link sanctuaries (those use /).
|
|
104
|
+
const BOLD_PH_OPEN = String.fromCharCode(0xe002);
|
|
105
|
+
const BOLD_PH_CLOSE = String.fromCharCode(0xe003);
|
|
106
|
+
|
|
107
|
+
// Third PUA range for the completion-match sanctuary. Distinct from
|
|
108
|
+
// the code/link and bold sanctuaries so each pipeline stage can
|
|
109
|
+
// restore its own placeholders without collisions.
|
|
110
|
+
const MATCH_PH_OPEN = String.fromCharCode(0xe004);
|
|
111
|
+
const MATCH_PH_CLOSE = String.fromCharCode(0xe005);
|
|
112
|
+
|
|
113
|
+
// Escape a string for safe use as a regex literal. Used when building
|
|
114
|
+
// the alternation of known completion labels for the document scan.
|
|
115
|
+
const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
116
|
+
|
|
117
|
+
/** Build a regex that matches any of `labels` as a standalone word.
|
|
118
|
+
* The negative lookarounds use word-character classes so a label
|
|
119
|
+
* starting with `#` like `#alice` still matches in plain prose
|
|
120
|
+
* (because `#` is not a word char, the leading boundary collapses
|
|
121
|
+
* to "anything but a word char"). */
|
|
122
|
+
const buildMatchRegex = (labels: Set<string>): RegExp | null => {
|
|
123
|
+
if (labels.size === 0) return null;
|
|
124
|
+
const sorted = [...labels].sort((a, b) => b.length - a.length).map(escapeRegex);
|
|
125
|
+
return new RegExp(`(?<![\\p{L}\\p{N}_])(${sorted.join("|")})(?![\\p{L}\\p{N}_])`, "gu");
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Italic-only pass — split out so we can call it recursively on a
|
|
129
|
+
* bold span's inner content (so things like `**foo *bar* baz**`
|
|
130
|
+
* render with italic inside bold). */
|
|
131
|
+
const processItalic = (text: string): string => {
|
|
132
|
+
text = text.replace(
|
|
133
|
+
/(^|[^*\w])\*([^\s*][^*\n]*?[^\s*]|[^\s*])\*(?!\*)/g,
|
|
134
|
+
'$1<span class="md-italic"><span class="md-syntax">*</span>$2<span class="md-syntax">*</span></span>',
|
|
135
|
+
);
|
|
136
|
+
text = text.replace(
|
|
137
|
+
/(^|[^\w_])_([^\s_][^_\n]*?[^\s_]|[^\s_])_(?!\w)/g,
|
|
138
|
+
'$1<span class="md-italic"><span class="md-syntax">_</span>$2<span class="md-syntax">_</span></span>',
|
|
139
|
+
);
|
|
140
|
+
return text;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Inline pass — bold + italic with nesting support.
|
|
145
|
+
*
|
|
146
|
+
* Naïve order (bold-then-italic) breaks symmetric nesting:
|
|
147
|
+
* - `*foo **bar** baz*` — the outer italic can't span the bold span
|
|
148
|
+
* because the bold pass writes `*` into the visible syntax markers
|
|
149
|
+
* and the italic regex can't cross those literal asterisks.
|
|
150
|
+
*
|
|
151
|
+
* Fix: bold is sanctuarised first. The bold content is recursively
|
|
152
|
+
* italic-processed and stored under a placeholder (PUA chars), so the
|
|
153
|
+
* italic pass sees only plain text + opaque markers. Both directions
|
|
154
|
+
* of nesting (italic-in-bold and bold-in-italic) work.
|
|
155
|
+
*
|
|
156
|
+
* Bold's inner-content regex `(?:[^*\n]|\*[^*\n]+?\*)+?` allows either
|
|
157
|
+
* plain non-asterisk chars OR an `*…*` italic pair, so a bold can
|
|
158
|
+
* still match across embedded italic — same pattern, mirrored for `__`.
|
|
159
|
+
*/
|
|
160
|
+
const processInline = (text: string, matchRegex: RegExp | null = null): string => {
|
|
161
|
+
const sanctuaries = new Map<string, string>();
|
|
162
|
+
let counter = 0;
|
|
163
|
+
const issue = (): string => `${BOLD_PH_OPEN}${counter++}${BOLD_PH_CLOSE}`;
|
|
164
|
+
|
|
165
|
+
text = text.replace(/\*\*((?:[^*\n]|\*[^*\n]+?\*)+?)\*\*/g, (_match, inner: string) => {
|
|
166
|
+
// Recurse with the SAME match regex so labels inside bold spans
|
|
167
|
+
// are highlighted too.
|
|
168
|
+
const innerProcessed = processItalicAndMatches(inner, matchRegex);
|
|
169
|
+
const ph = issue();
|
|
170
|
+
sanctuaries.set(
|
|
171
|
+
ph,
|
|
172
|
+
`<span class="md-bold"><span class="md-syntax">**</span>${innerProcessed}<span class="md-syntax">**</span></span>`,
|
|
173
|
+
);
|
|
174
|
+
return ph;
|
|
175
|
+
});
|
|
176
|
+
text = text.replace(/__((?:[^_\n]|_[^_\n]+?_)+?)__/g, (_match, inner: string) => {
|
|
177
|
+
const innerProcessed = processItalicAndMatches(inner, matchRegex);
|
|
178
|
+
const ph = issue();
|
|
179
|
+
sanctuaries.set(
|
|
180
|
+
ph,
|
|
181
|
+
`<span class="md-bold"><span class="md-syntax">__</span>${innerProcessed}<span class="md-syntax">__</span></span>`,
|
|
182
|
+
);
|
|
183
|
+
return ph;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
text = processItalicAndMatches(text, matchRegex);
|
|
187
|
+
|
|
188
|
+
for (const [ph, html] of sanctuaries) {
|
|
189
|
+
if (text.includes(ph)) text = text.split(ph).join(html);
|
|
190
|
+
}
|
|
191
|
+
return text;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** Match-extract + italic pass. Pulled into its own helper so bold
|
|
195
|
+
* recursion shares the same logic. Matches are extracted to a third
|
|
196
|
+
* PUA sanctuary BEFORE italic so the italic regex doesn't trip on
|
|
197
|
+
* `<span>` chars inside a wrapped label. */
|
|
198
|
+
const processItalicAndMatches = (text: string, matchRegex: RegExp | null): string => {
|
|
199
|
+
if (!matchRegex) return processItalic(text);
|
|
200
|
+
const matchSanctuary = new Map<string, string>();
|
|
201
|
+
let counter = 0;
|
|
202
|
+
const issue = (): string => `${MATCH_PH_OPEN}${counter++}${MATCH_PH_CLOSE}`;
|
|
203
|
+
// Reset regex state — buildMatchRegex returns a `/g` regex which
|
|
204
|
+
// carries lastIndex between calls.
|
|
205
|
+
matchRegex.lastIndex = 0;
|
|
206
|
+
text = text.replace(matchRegex, (label: string) => {
|
|
207
|
+
const ph = issue();
|
|
208
|
+
matchSanctuary.set(ph, `<span class="md-completion-match">${label}</span>`);
|
|
209
|
+
return ph;
|
|
210
|
+
});
|
|
211
|
+
text = processItalic(text);
|
|
212
|
+
for (const [ph, html] of matchSanctuary) {
|
|
213
|
+
if (text.includes(ph)) text = text.split(ph).join(html);
|
|
214
|
+
}
|
|
215
|
+
return text;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Block-level transform for a single line (no newline inside `line`).
|
|
220
|
+
*/
|
|
221
|
+
const processLine = (line: string, matchRegex: RegExp | null = null): string => {
|
|
222
|
+
// Empty line — keep as-is so the preview's pre-wrap renders a blank
|
|
223
|
+
// line at the same position as in the textarea.
|
|
224
|
+
if (line.length === 0) return "";
|
|
225
|
+
|
|
226
|
+
// Header (1–3 hashes only — h4+ aren't typographically distinct in
|
|
227
|
+
// our font-weight-only scheme, so we don't pretend to support them).
|
|
228
|
+
const header = /^(#{1,3})(\s)(.*)$/.exec(line);
|
|
229
|
+
if (header) {
|
|
230
|
+
const [, hashes, ws, content] = header;
|
|
231
|
+
return `<span class="md-h${hashes!.length}"><span class="md-syntax">${hashes}${ws}</span>${processInline(content!, matchRegex)}</span>`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Horizontal rule — `---`, `***`, or `___` alone on a line.
|
|
235
|
+
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
236
|
+
return `<span class="md-hr">${line}</span>`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Blockquote (`>` is HTML-escaped to `>` by this point).
|
|
240
|
+
const quote = /^(>)(\s)(.*)$/.exec(line);
|
|
241
|
+
if (quote) {
|
|
242
|
+
return `<span class="md-quote"><span class="md-syntax">${quote[1]}${quote[2]}</span>${processInline(quote[3]!, matchRegex)}</span>`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Bullet list: `- `, `* `, `+ ` with optional leading indent.
|
|
246
|
+
const bullet = /^(\s*)([-*+])(\s)(.*)$/.exec(line);
|
|
247
|
+
if (bullet) {
|
|
248
|
+
const [, indent, marker, ws, content] = bullet;
|
|
249
|
+
return `${indent}<span class="md-marker">${marker}${ws}</span>${processInline(content!, matchRegex)}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Numbered list: `1. ` etc.
|
|
253
|
+
const numbered = /^(\s*)(\d+\.)(\s)(.*)$/.exec(line);
|
|
254
|
+
if (numbered) {
|
|
255
|
+
const [, indent, marker, ws, content] = numbered;
|
|
256
|
+
return `${indent}<span class="md-marker">${marker}${ws}</span>${processInline(content!, matchRegex)}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Plain paragraph line — just inline pass.
|
|
260
|
+
return processInline(line, matchRegex);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export type HighlightOptions = {
|
|
264
|
+
/** When set, every occurrence of these labels (as a standalone word
|
|
265
|
+
* outside code spans/fences) is wrapped in `.md-completion-match`
|
|
266
|
+
* for the document-wide highlight effect. Pass the set returned by
|
|
267
|
+
* `collectKnownLabels` from `completions.ts`. */
|
|
268
|
+
knownLabels?: Set<string>;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Render a markdown string as syntax-highlighted HTML for the preview
|
|
273
|
+
* layer. The output is safe to insert via `innerHTML` because the input
|
|
274
|
+
* is HTML-escaped before any markdown transforms run.
|
|
275
|
+
*/
|
|
276
|
+
export const highlightMarkdown = (text: string, options: HighlightOptions = {}): string => {
|
|
277
|
+
const matchRegex = options.knownLabels ? buildMatchRegex(options.knownLabels) : null;
|
|
278
|
+
const escaped = escapeHtml(text);
|
|
279
|
+
const { text: protectedText, sanctuaries } = extractSanctuaries(escaped);
|
|
280
|
+
|
|
281
|
+
const lines = protectedText.split("\n");
|
|
282
|
+
const out: string[] = [];
|
|
283
|
+
let inCodeFence = false;
|
|
284
|
+
|
|
285
|
+
for (const line of lines) {
|
|
286
|
+
// Fence open/close — three backticks at the start of a line. The
|
|
287
|
+
// backticks were NOT consumed by the inline-code sanctuary pass
|
|
288
|
+
// because that requires content between the ticks; a bare ``` line
|
|
289
|
+
// has none.
|
|
290
|
+
if (/^```/.test(line)) {
|
|
291
|
+
inCodeFence = !inCodeFence;
|
|
292
|
+
out.push(`<span class="md-code-block md-syntax">${line}</span>`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (inCodeFence) {
|
|
296
|
+
// Inside a fence: render verbatim with code-block background, no
|
|
297
|
+
// further markdown processing.
|
|
298
|
+
out.push(`<span class="md-code-block">${line}</span>`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
out.push(processLine(line, matchRegex));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const html = restoreSanctuaries(out.join("\n"), sanctuaries);
|
|
305
|
+
|
|
306
|
+
// A `<textarea>` reserves an empty final line for the caret when its
|
|
307
|
+
// value ends in "\n", but a `white-space: pre-wrap` block swallows
|
|
308
|
+
// that single trailing newline and renders one line shorter. The
|
|
309
|
+
// mismatch makes the preview's scrollHeight smaller than the
|
|
310
|
+
// textarea's, so the scroll-sync clamps and the visible text drifts
|
|
311
|
+
// up to one line near the bottom. Append one extra newline to mirror
|
|
312
|
+
// the textarea's phantom last line. Only the LAST trailing newline is
|
|
313
|
+
// dropped by the layout, so a single extra suffices for any run of
|
|
314
|
+
// trailing blanks.
|
|
315
|
+
return text.endsWith("\n") ? `${html}\n` : html;
|
|
316
|
+
};
|
package/src/ui/layout.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type LayoutBreadcrumb = {
|
|
2
|
+
title: string;
|
|
3
|
+
href?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type LayoutUpdate = {
|
|
7
|
+
title?: string;
|
|
8
|
+
breadcrumbs?: LayoutBreadcrumb[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const LAYOUT_UPDATE_EVENT = "cloud:layout:update";
|
|
12
|
+
|
|
13
|
+
export const layout = {
|
|
14
|
+
update(update: LayoutUpdate) {
|
|
15
|
+
if (typeof window === "undefined") return;
|
|
16
|
+
|
|
17
|
+
const title = update.title ?? update.breadcrumbs?.at(-1)?.title;
|
|
18
|
+
if (title) document.title = title;
|
|
19
|
+
|
|
20
|
+
window.dispatchEvent(new CustomEvent<LayoutUpdate>(LAYOUT_UPDATE_EVENT, { detail: update }));
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Show, type JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
export type AppOverviewProps = {
|
|
4
|
+
title: string;
|
|
5
|
+
subtitle?: string;
|
|
6
|
+
icon: string;
|
|
7
|
+
class?: string;
|
|
8
|
+
children: JSX.Element;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type AppOverviewPanelProps = {
|
|
12
|
+
title: string;
|
|
13
|
+
description?: JSX.Element;
|
|
14
|
+
toolbar?: JSX.Element;
|
|
15
|
+
class?: string;
|
|
16
|
+
children: JSX.Element;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AppOverviewEmptyStateProps = {
|
|
20
|
+
title: string;
|
|
21
|
+
description?: JSX.Element;
|
|
22
|
+
icon?: string;
|
|
23
|
+
class?: string;
|
|
24
|
+
children?: JSX.Element;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type AppOverviewComponent = ((props: AppOverviewProps) => JSX.Element) & {
|
|
28
|
+
Main: (props: AppOverviewPanelProps) => JSX.Element;
|
|
29
|
+
Aside: (props: AppOverviewPanelProps) => JSX.Element;
|
|
30
|
+
EmptyState: (props: AppOverviewEmptyStateProps) => JSX.Element;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const tablerIconClass = (icon: string | null | undefined, fallback: string): string => {
|
|
34
|
+
const value = icon?.trim() || fallback;
|
|
35
|
+
return value.startsWith("ti ") ? value : `ti ${value}`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const PanelHeader = (props: Pick<AppOverviewPanelProps, "title" | "description" | "toolbar">) => (
|
|
39
|
+
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
40
|
+
<div class="min-w-0">
|
|
41
|
+
<h2 class="text-sm font-semibold text-primary">{props.title}</h2>
|
|
42
|
+
<Show when={props.description}>
|
|
43
|
+
<p class="text-xs text-dimmed">{props.description}</p>
|
|
44
|
+
</Show>
|
|
45
|
+
</div>
|
|
46
|
+
<Show when={props.toolbar}>
|
|
47
|
+
<div class="w-full sm:w-80">{props.toolbar}</div>
|
|
48
|
+
</Show>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const AppOverviewMain = (props: AppOverviewPanelProps) => (
|
|
53
|
+
<section class={`min-w-0 flex-1 w-full ${props.class ?? ""}`}>
|
|
54
|
+
<PanelHeader title={props.title} description={props.description} toolbar={props.toolbar} />
|
|
55
|
+
{props.children}
|
|
56
|
+
</section>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const AppOverviewAside = (props: AppOverviewPanelProps) => (
|
|
60
|
+
<aside class={`min-w-0 w-full shrink-0 lg:w-96 ${props.class ?? ""}`}>
|
|
61
|
+
<PanelHeader title={props.title} description={props.description} toolbar={props.toolbar} />
|
|
62
|
+
{props.children}
|
|
63
|
+
</aside>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const AppOverviewEmptyState = (props: AppOverviewEmptyStateProps) => (
|
|
67
|
+
<div class={`paper flex min-h-56 flex-col items-center justify-center p-8 text-center ${props.class ?? ""}`}>
|
|
68
|
+
<Show when={props.icon}>
|
|
69
|
+
<div class="thumbnail mb-3 flex h-12 w-12 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
|
70
|
+
<i class={`${tablerIconClass(props.icon, "ti-inbox")} text-xl text-dimmed`} />
|
|
71
|
+
</div>
|
|
72
|
+
</Show>
|
|
73
|
+
<h3 class="mb-1 text-sm font-semibold text-primary">{props.title}</h3>
|
|
74
|
+
<Show when={props.description}>
|
|
75
|
+
<p class="max-w-sm text-xs text-dimmed">{props.description}</p>
|
|
76
|
+
</Show>
|
|
77
|
+
{props.children}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const AppOverview = ((props: AppOverviewProps) => (
|
|
82
|
+
<div class={`mx-auto max-w-6xl p-3 sm:p-4 ${props.class ?? ""}`}>
|
|
83
|
+
<header class="mb-5">
|
|
84
|
+
<div class="flex items-center gap-3">
|
|
85
|
+
<div class="thumbnail flex h-11 w-11 shrink-0 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
|
86
|
+
<i class={`${tablerIconClass(props.icon, "ti-apps")} text-xl text-zinc-600 dark:text-zinc-400`} />
|
|
87
|
+
</div>
|
|
88
|
+
<div class="min-w-0">
|
|
89
|
+
<h1 class="text-xl font-semibold text-primary">{props.title}</h1>
|
|
90
|
+
<Show when={props.subtitle}>
|
|
91
|
+
<p class="text-sm text-dimmed">{props.subtitle}</p>
|
|
92
|
+
</Show>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</header>
|
|
96
|
+
|
|
97
|
+
<div class="flex flex-col items-start gap-4 lg:flex-row">{props.children}</div>
|
|
98
|
+
</div>
|
|
99
|
+
)) as AppOverviewComponent;
|
|
100
|
+
|
|
101
|
+
AppOverview.Main = AppOverviewMain;
|
|
102
|
+
AppOverview.Aside = AppOverviewAside;
|
|
103
|
+
AppOverview.EmptyState = AppOverviewEmptyState;
|
|
104
|
+
|
|
105
|
+
export default AppOverview;
|