@dominikcz/greg 0.9.29 → 0.9.31

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.
@@ -18,6 +18,21 @@ Relative paths are resolved from the location of the `.md` file:
18
18
  ![diagram](./images/diagram.png)
19
19
  ```
20
20
 
21
+ By default, Markdown images are rendered as thumbnails. Click an image to open
22
+ the full-size preview.
23
+
24
+ When preview is enabled, a caption is rendered under the thumbnail:
25
+ - uses `title` when present,
26
+ - otherwise falls back to `alt` text.
27
+
28
+ You can disable this behavior globally in `greg.config.js`:
29
+
30
+ ```js
31
+ export default {
32
+ markdownImagePreview: false,
33
+ }
34
+ ```
35
+
21
36
  Absolute paths are resolved from the project root's `public/` directory:
22
37
 
23
38
  ```md
@@ -62,3 +62,19 @@ Output:
62
62
  By default h2 and h3 headings are included. The right-side **Outline** panel is
63
63
  a persistent alternative. See `outline` prop in the
64
64
  [`<MarkdownDocs>` reference](/reference/markdowndocs).
65
+
66
+ ## Flex row list - `[[ ... ]]`
67
+
68
+ Use `[[ ... ]]` to render multiple markdown blocks in a horizontal wrapping row.
69
+ Each non-empty line inside the block becomes one flex item.
70
+
71
+ ```md
72
+ [[
73
+ ![Freezer A](/know-how/gastro-orlen/mroznia/freezer-a.webp)
74
+ ![Freezer B](/know-how/gastro-orlen/mroznia/freezer-b.webp)
75
+ ![Freezer C](/know-how/gastro-orlen/mroznia/freezer-c.webp)
76
+ ]]
77
+ ```
78
+
79
+ Use line breaks (Enter) to separate items. Brace syntax (`{...}`) and one-line
80
+ comma-only syntax (for example `[[One, Two]]`) are not supported.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dominikcz/greg",
3
- "version": "0.9.29",
3
+ "version": "0.9.31",
4
4
  "type": "module",
5
5
  "types": "./types/index.d.ts",
6
6
  "bin": {
@@ -164,6 +164,8 @@
164
164
  * { id, title, titleHtml, sectionTitle, sectionTitleHtml?, sectionAnchor, excerptHtml, score }
165
165
  */
166
166
  searchProvider?: (query: string, limit?: number) => Promise<any[]>;
167
+ /** Render markdown images as thumbnails with click-to-preview overlay. */
168
+ markdownImagePreview?: boolean;
167
169
  };
168
170
 
169
171
  type VersionManifestEntry = {
@@ -209,6 +211,7 @@
209
211
  (gregConfig as any).outline ?? ([2, 3] as [number, number]),
210
212
  mermaidTheme = (gregConfig as any).mermaidTheme,
211
213
  mermaidThemes,
214
+ markdownImagePreview = (gregConfig as any).markdownImagePreview ?? true,
212
215
  breadcrumb = (gregConfig as any).breadcrumb ?? false,
213
216
  backToTop = (gregConfig as any).backToTop ?? false,
214
217
  lastModified: globalLastModified = (gregConfig as any).lastModified ?? false,
@@ -1733,6 +1736,7 @@
1733
1736
  docsPrefix={withBase(currentSrcDir)}
1734
1737
  {mermaidTheme}
1735
1738
  {mermaidThemes}
1739
+ enableImagePreview={markdownImagePreview}
1736
1740
  colorTheme={theme}
1737
1741
  />
1738
1742
  {#if lastModified && activeFrontmatter?._mtime}
@@ -203,6 +203,8 @@
203
203
  * When `'dark'`, automatically selects `mermaidTheme + '-dark'` if available.
204
204
  */
205
205
  colorTheme?: "light" | "dark";
206
+ /** Enable markdown image thumbnails + click-to-preview overlay. */
207
+ enableImagePreview?: boolean;
206
208
  };
207
209
  let {
208
210
  markdown,
@@ -211,6 +213,7 @@
211
213
  mermaidTheme = DEFAULT_MERMAID_THEME,
212
214
  mermaidThemes: extraThemes = {},
213
215
  colorTheme,
216
+ enableImagePreview = true,
214
217
  }: Props = $props();
215
218
 
216
219
  /** Combined theme map: built-ins overridden/extended by user-supplied themes. */
@@ -288,6 +291,19 @@
288
291
  };
289
292
  }
290
293
 
294
+ // ── Rehype plugin: markdown image component hydration ──────────────────────
295
+ // Replaces plain markdown <img> with <markdownimage> so runtime hydration can
296
+ // mount a Svelte component (thumbnail + modal + caption logic).
297
+ function rehypeMarkdownImageThumbs() {
298
+ return (tree: any) => {
299
+ if (!enableImagePreview) return;
300
+ visit(tree, "element", (node: any) => {
301
+ if (node.tagName !== "img") return;
302
+ node.tagName = "markdownimage";
303
+ });
304
+ };
305
+ }
306
+
291
307
  // ── Rehype plugin: shiki code blocks ─────────────────────────────────────────
292
308
 
293
309
  function rehypeShiki() {
@@ -421,6 +437,7 @@
421
437
  getRehypePluginEntries({
422
438
  rehypeStepsWrapper,
423
439
  rehypeMermaid,
440
+ rehypeMarkdownImageThumbs,
424
441
  rehypeShiki,
425
442
  }),
426
443
  );
@@ -482,6 +499,9 @@
482
499
  </script>
483
500
 
484
501
  <!-- svelte-ignore a11y_no_static_element_interactions -->
485
- <div class="markdown-renderer markdown-body" bind:this={containerEl}>
502
+ <div
503
+ class="markdown-renderer markdown-body"
504
+ bind:this={containerEl}
505
+ >
486
506
  {@html html}
487
507
  </div>
@@ -11,6 +11,7 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
11
11
  import rehypeCodeGroup from '../rehypeCodeGroup.js';
12
12
  import rehypeCodeTitle from '../rehypeCodeTitle.js';
13
13
  import { remarkContainers, rehypeContainers } from '../remarkContainers.js';
14
+ import { remarkFlexRow } from '../remarkFlexRow.js';
14
15
  import { rehypeTocPlaceholder } from '../rehypeToc.js';
15
16
  import { remarkCodeMeta } from '../remarkCodeMeta.js';
16
17
  import { remarkImports } from '../remarkImports.js';
@@ -26,6 +27,7 @@ async function getProcessor(opts = {}) {
26
27
  if (opts.containers || opts.toc || opts.imports) {
27
28
  return unified()
28
29
  .use(remarkParse)
30
+ .use(remarkFlexRow)
29
31
  .use(remarkImports, opts.imports ?? defaultImportsOptions)
30
32
  .use(remarkCodeMeta)
31
33
  .use(remarkContainers, opts.containers ?? {})
@@ -41,6 +43,7 @@ async function getProcessor(opts = {}) {
41
43
  if (!_processor) {
42
44
  _processor = unified()
43
45
  .use(remarkParse)
46
+ .use(remarkFlexRow)
44
47
  .use(remarkImports, defaultImportsOptions)
45
48
  .use(remarkCodeMeta)
46
49
  .use(remarkContainers)
@@ -147,6 +147,57 @@ describe('rehypeTocPlaceholder — [[TOC]]', () => {
147
147
  });
148
148
  });
149
149
 
150
+ describe('remarkFlexRow — [[ line\nline ]]', () => {
151
+ it('renders top-level flex container with wrapped items', async () => {
152
+ const html = await processMarkdown('[[\nOne\nTwo\nThree\n]]');
153
+ expect(html).toContain('class="markdown-flex-row"');
154
+ expect((html.match(/class="markdown-flex-item"/g) ?? []).length).toBe(3);
155
+ expect(html).toContain('>One<');
156
+ expect(html).toContain('>Two<');
157
+ expect(html).toContain('>Three<');
158
+ });
159
+
160
+ it('supports markdown content inside line items', async () => {
161
+ const html = await processMarkdown('[[\n**Bold**\n[Link](https://example.com)\n]]');
162
+ expect(html).toContain('<strong>Bold</strong>');
163
+ expect(html).toContain('<a href="https://example.com">Link</a>');
164
+ });
165
+
166
+ it('accepts newline-delimited items without commas', async () => {
167
+ const html = await processMarkdown('[[\nOne\nTwo\n]]');
168
+ expect(html).toContain('class="markdown-flex-row"');
169
+ expect((html.match(/class="markdown-flex-item"/g) ?? []).length).toBe(2);
170
+ expect(html).toContain('>One<');
171
+ expect(html).toContain('>Two<');
172
+ });
173
+
174
+ it('works inside list items', async () => {
175
+ const html = await processMarkdown('- [[\n A\n B\n ]]');
176
+ expect(html).toContain('class="markdown-flex-row"');
177
+ expect((html.match(/class="markdown-flex-item"/g) ?? []).length).toBe(2);
178
+ });
179
+
180
+ it('supports image markdown inside items', async () => {
181
+ const html = await processMarkdown('[[\n![one](/one.png)\n![two](/two.png)\n]]');
182
+ expect(html).toContain('src="/one.png"');
183
+ expect(html).toContain('src="/two.png"');
184
+ });
185
+
186
+ it('supports multiline syntax with optional trailing commas', async () => {
187
+ const html = await processMarkdown('[[\n![one](/one.png),\n![two](/two.png)\n]]');
188
+ expect(html).toContain('class="markdown-flex-row"');
189
+ expect((html.match(/class="markdown-flex-item"/g) ?? []).length).toBe(2);
190
+ expect(html).toContain('src="/one.png"');
191
+ expect(html).toContain('src="/two.png"');
192
+ });
193
+
194
+ it('does not parse one-line comma-separated syntax', async () => {
195
+ const html = await processMarkdown('[[One, Two]]');
196
+ expect(html).not.toContain('class="markdown-flex-row"');
197
+ expect(html).toContain('[[One, Two]]');
198
+ });
199
+ });
200
+
150
201
  // ─── Custom Containers ─────────────────────────────────────────────────────────
151
202
 
152
203
  describe('remarkContainers — ::: info', () => {
@@ -12,11 +12,13 @@ import rehypeCodeTitle from "./rehypeCodeTitle.js";
12
12
  import { remarkCodeMeta } from "./remarkCodeMeta.js";
13
13
  import { remarkCustomAnchors } from "./remarkCustomAnchors.js";
14
14
  import { remarkInlineAttrs } from "./remarkInlineAttrs.js";
15
+ import { remarkFlexRow } from "./remarkFlexRow.js";
15
16
  import { remarkImportsBrowser } from "./remarkImportsBrowser.js";
16
17
 
17
18
  import Badge from "../components/Badge.svelte";
18
19
  import Button from "../components/Button.svelte";
19
20
  import Image from "../components/Image.svelte";
21
+ import MarkdownImagePreview from "../components/MarkdownImagePreview.svelte";
20
22
  import Link from "../components/Link.svelte";
21
23
  import CodeGroup from "../components/CodeGroup.svelte";
22
24
  import Hero from "../components/Hero.svelte";
@@ -170,6 +172,10 @@ export const COMPONENT_REGISTRY: Record<string, ComponentHydrationEntry> = {
170
172
  component: Image,
171
173
  buildProps: buildPropsFromAttributes,
172
174
  },
175
+ markdownimage: {
176
+ component: MarkdownImagePreview,
177
+ buildProps: buildPropsFromAttributes,
178
+ },
173
179
  link: {
174
180
  component: Link,
175
181
  buildProps: buildPropsFromAttributes,
@@ -252,6 +258,7 @@ export function getRemarkPluginEntries(
252
258
  return [
253
259
  { name: "remark-parse", plugin: remarkParse },
254
260
  { name: "remark-gfm", plugin: remarkGfm },
261
+ { name: "remark-flex-row", plugin: remarkFlexRow },
255
262
  { name: "remark-inline-attrs", plugin: remarkInlineAttrs },
256
263
  {
257
264
  name: "remark-imports-browser",
@@ -278,6 +285,7 @@ export function getRemarkPluginEntries(
278
285
  export function getRehypePluginEntries(deps: {
279
286
  rehypeStepsWrapper: any;
280
287
  rehypeMermaid: any;
288
+ rehypeMarkdownImageThumbs: any;
281
289
  rehypeShiki: any;
282
290
  }): PluginEntry[] {
283
291
  return [
@@ -289,6 +297,10 @@ export function getRehypePluginEntries(deps: {
289
297
  },
290
298
  { name: "rehype-steps-wrapper", plugin: deps.rehypeStepsWrapper },
291
299
  { name: "rehype-mermaid", plugin: deps.rehypeMermaid },
300
+ {
301
+ name: "rehype-markdown-image-thumbs",
302
+ plugin: deps.rehypeMarkdownImageThumbs,
303
+ },
292
304
  { name: "rehype-shiki", plugin: deps.rehypeShiki },
293
305
  { name: "rehype-containers", plugin: rehypeContainers },
294
306
  { name: "rehype-code-group", plugin: rehypeCodeGroup },
@@ -0,0 +1,86 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { visit } from 'unist-util-visit';
5
+
6
+ function parseFlexPattern(value) {
7
+ const raw = String(value ?? '').trim();
8
+ if (!raw.startsWith('[[') || !raw.endsWith(']]')) return null;
9
+
10
+ const inner = raw.slice(2, -2).trim();
11
+ if (!inner) return [];
12
+ if (/^toc$/i.test(inner)) return null;
13
+
14
+ return parseLineItems(inner);
15
+ }
16
+
17
+ function parseLineItems(inner) {
18
+ if (!inner.includes('\n')) return null;
19
+
20
+ const parts = inner
21
+ .split('\n')
22
+ .map((line) => line.trim())
23
+ .filter(Boolean)
24
+ .map((line) => line.replace(/,$/, '').trim())
25
+ .filter(Boolean);
26
+
27
+ return parts.length ? parts : null;
28
+ }
29
+
30
+ function parseMarkdownFragment(fragment, parser) {
31
+ const tree = parser.parse(fragment);
32
+ return Array.isArray(tree?.children) ? tree.children : [];
33
+ }
34
+
35
+ function createFlexItemNode(children) {
36
+ return {
37
+ type: 'flexRowItem',
38
+ data: {
39
+ hName: 'div',
40
+ hProperties: { className: ['markdown-flex-item'] },
41
+ },
42
+ children,
43
+ };
44
+ }
45
+
46
+ function createFlexRowNode(items, parser) {
47
+ return {
48
+ type: 'flexRowBlock',
49
+ data: {
50
+ hName: 'div',
51
+ hProperties: { className: ['markdown-flex-row'] },
52
+ },
53
+ children: items.map((item) =>
54
+ createFlexItemNode(parseMarkdownFragment(item, parser)),
55
+ ),
56
+ };
57
+ }
58
+
59
+ export function remarkFlexRow() {
60
+ const parser = unified().use(remarkParse).use(remarkGfm);
61
+
62
+ return (tree, file) => {
63
+ const source = typeof file?.value === 'string' ? file.value : '';
64
+
65
+ visit(tree, 'paragraph', (node, index, parent) => {
66
+ if (!parent || index == null) return;
67
+
68
+ let raw = '';
69
+ const start = node?.position?.start?.offset;
70
+ const end = node?.position?.end?.offset;
71
+
72
+ if (typeof start === 'number' && typeof end === 'number' && end >= start) {
73
+ raw = source.slice(start, end);
74
+ } else {
75
+ raw = (node.children ?? [])
76
+ .map((child) => child?.value ?? '')
77
+ .join('');
78
+ }
79
+
80
+ const parsedItems = parseFlexPattern(raw);
81
+ if (!parsedItems || parsedItems.length === 0) return;
82
+
83
+ parent.children[index] = createFlexRowNode(parsedItems, parser);
84
+ });
85
+ };
86
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * vitePluginCopyDocs
3
3
  *
4
- * Copies docs/**\/*.md (and optional extra static dirs) to the build output
4
+ * Copies docs/** files (and optional extra static dirs) to the build output
5
5
  * directory as-is, so they can be fetched at runtime by the browser without
6
6
  * being compiled by mdsvex/rollup.
7
7
  *
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * The `staticDirs` option (default: ['snippets']) lists additional project-root
12
12
  * directories whose files should also be served/copied verbatim. This is
13
- * needed for `<<< @”‹/snippets/file.js` style snippet includes.
13
+ * needed for snippet include syntax that references files from static dirs.
14
14
  */
15
15
 
16
16
  import fs from 'node:fs';
@@ -25,11 +25,21 @@ export function vitePluginCopyDocs({ docsDir = 'docs', srcDir = '/docs', staticD
25
25
  let outDir = 'dist';
26
26
  let viteBase = '/';
27
27
 
28
+ function isTraversableDirectory(entry, fullPath) {
29
+ if (entry.isDirectory()) return true;
30
+ if (!entry.isSymbolicLink()) return false;
31
+ try {
32
+ return fs.statSync(fullPath).isDirectory();
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
28
38
  function* walkAll(dir) {
29
39
  if (!fs.existsSync(dir)) return;
30
40
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
31
41
  const full = path.join(dir, entry.name);
32
- if (entry.isDirectory()) {
42
+ if (isTraversableDirectory(entry, full)) {
33
43
  yield* walkAll(full);
34
44
  } else if (entry.isFile()) {
35
45
  yield full;
@@ -37,16 +47,29 @@ export function vitePluginCopyDocs({ docsDir = 'docs', srcDir = '/docs', staticD
37
47
  }
38
48
  }
39
49
 
40
- function* walkMd(dir) {
41
- for (const full of walkAll(dir)) {
42
- if (full.endsWith('.md')) yield full;
43
- }
44
- }
45
-
46
50
  function toPosix(value) {
47
51
  return String(value).replace(/\\/g, '/');
48
52
  }
49
53
 
54
+ function getContentType(filePath) {
55
+ const ext = path.extname(filePath).toLowerCase();
56
+ switch (ext) {
57
+ case '.md': return 'text/plain; charset=utf-8';
58
+ case '.txt': return 'text/plain; charset=utf-8';
59
+ case '.json': return 'application/json; charset=utf-8';
60
+ case '.js': return 'text/javascript; charset=utf-8';
61
+ case '.css': return 'text/css; charset=utf-8';
62
+ case '.svg': return 'image/svg+xml';
63
+ case '.png': return 'image/png';
64
+ case '.jpg':
65
+ case '.jpeg': return 'image/jpeg';
66
+ case '.webp': return 'image/webp';
67
+ case '.gif': return 'image/gif';
68
+ case '.pdf': return 'application/pdf';
69
+ default: return 'application/octet-stream';
70
+ }
71
+ }
72
+
50
73
  function resolveRootPrefix() {
51
74
  const cleaned = trimSlashes(srcDir);
52
75
  return cleaned ? '/' + cleaned : '';
@@ -62,7 +85,7 @@ export function vitePluginCopyDocs({ docsDir = 'docs', srcDir = '/docs', staticD
62
85
  },
63
86
 
64
87
  /**
65
- * In dev mode: serve .md files and staticDirs files as plain text.
88
+ * In dev mode: serve docs files and staticDirs files.
66
89
  * Vite doesn't auto-serve project files outside public/ as raw assets.
67
90
  */
68
91
  configureServer(server) {
@@ -86,17 +109,17 @@ export function vitePluginCopyDocs({ docsDir = 'docs', srcDir = '/docs', staticD
86
109
  ? '/' + rawUrl.slice(base.length).replace(/^\/+/, '')
87
110
  : rawUrl;
88
111
 
89
- // Docs markdown files
90
- const isDocsMarkdown = rootPrefix
91
- ? (url === rootPrefix || url.startsWith(rootPrefix + '/')) && url.endsWith('.md')
92
- : url.startsWith('/') && url.endsWith('.md');
93
- if (isDocsMarkdown) {
112
+ // Docs files (markdown + local assets like images/pdf)
113
+ const isDocsPath = rootPrefix
114
+ ? (url === rootPrefix || url.startsWith(rootPrefix + '/'))
115
+ : url.startsWith('/');
116
+ if (isDocsPath) {
94
117
  const rel = url.slice(rootPrefix.length).replace(/^\//, '');
95
118
  for (const dir of (Array.isArray(docsDir) ? docsDir : [docsDir])) {
96
119
  const filePath = path.resolve(root, dir, rel);
97
- if (fs.existsSync(filePath)) {
98
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
99
- res.end(fs.readFileSync(filePath, 'utf8'));
120
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
121
+ res.setHeader('Content-Type', getContentType(filePath));
122
+ res.end(fs.readFileSync(filePath));
100
123
  return;
101
124
  }
102
125
  }
@@ -118,15 +141,15 @@ export function vitePluginCopyDocs({ docsDir = 'docs', srcDir = '/docs', staticD
118
141
  });
119
142
  },
120
143
 
121
- /** After bundle is written, copy .md files and staticDirs verbatim. */
144
+ /** After bundle is written, copy docs files and staticDirs verbatim. */
122
145
  writeBundle() {
123
146
  let count = 0;
124
147
  const rootPrefix = trimSlashes(resolveRootPrefix());
125
148
 
126
- // Copy markdown docs from all source dirs
149
+ // Copy docs files from all source dirs
127
150
  for (const dir of (Array.isArray(docsDir) ? docsDir : [docsDir])) {
128
151
  const docsRoot = path.resolve(root, dir);
129
- for (const srcFile of walkMd(docsRoot)) {
152
+ for (const srcFile of walkAll(docsRoot)) {
130
153
  const rel = toPosix(path.relative(docsRoot, srcFile));
131
154
  const destFile = path.join(outDir, rootPrefix, rel);
132
155
  fs.mkdirSync(path.dirname(destFile), { recursive: true });
@@ -44,6 +44,16 @@ export function vitePluginFrontmatter({ docsDir = 'docs', srcDir = '/docs' } = {
44
44
  return cleaned ? `/${cleaned}` : '/';
45
45
  }
46
46
 
47
+ function isTraversableDirectory(entry, fullPath) {
48
+ if (entry.isDirectory()) return true;
49
+ if (!entry.isSymbolicLink()) return false;
50
+ try {
51
+ return fs.statSync(fullPath).isDirectory();
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
47
57
  /** Collect all .md paths and return the virtual module source. */
48
58
  function buildModule() {
49
59
  const absDirs = (Array.isArray(docsDir) ? docsDir : [docsDir]).map(d => path.resolve(root, d));
@@ -56,7 +66,7 @@ export function vitePluginFrontmatter({ docsDir = 'docs', srcDir = '/docs' } = {
56
66
  catch { return; }
57
67
  for (const item of items) {
58
68
  const full = path.join(dir, item.name);
59
- if (item.isDirectory()) {
69
+ if (isTraversableDirectory(item, full)) {
60
70
  walk(full, baseDir);
61
71
  } else if (item.isFile() && item.name.endsWith('.md') && !item.name.startsWith('__')) {
62
72
  const rel = path.relative(baseDir, full).replace(/\\/g, '/');
@@ -0,0 +1,345 @@
1
+ <script lang="ts">
2
+ type Props = {
3
+ src?: string;
4
+ alt?: string;
5
+ title?: string;
6
+ width?: number | string;
7
+ height?: number | string;
8
+ };
9
+
10
+ let { src = "", alt = "", title = "", width, height }: Props = $props();
11
+
12
+ let isOpen = $state(false);
13
+ let zoom = $state(1);
14
+ let isDragging = $state(false);
15
+ let dragStartX = $state(0);
16
+ let dragStartY = $state(0);
17
+ let dragStartScrollLeft = $state(0);
18
+ let dragStartScrollTop = $state(0);
19
+ let viewportEl = $state<HTMLDivElement | null>(null);
20
+
21
+ const MIN_ZOOM = 1;
22
+ const MAX_ZOOM = 6;
23
+ const ZOOM_STEP = 0.25;
24
+
25
+ const caption = $derived((String(title || "").trim() || String(alt || "").trim()));
26
+ const zoomLabel = $derived(`${Math.round(zoom * 100)}%`);
27
+ const isPannable = $derived(zoom > 1);
28
+
29
+ const previewImageStyle = $derived.by(() => {
30
+ if (zoom <= 1) return undefined;
31
+ return `width: ${Math.round(zoom * 100)}%; max-width: none; max-height: none;`;
32
+ });
33
+
34
+ function clampZoom(value: number) {
35
+ return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value));
36
+ }
37
+
38
+ function setZoom(value: number) {
39
+ zoom = clampZoom(value);
40
+ }
41
+
42
+ function zoomIn() {
43
+ setZoom(zoom + ZOOM_STEP);
44
+ }
45
+
46
+ function zoomOut() {
47
+ setZoom(zoom - ZOOM_STEP);
48
+ }
49
+
50
+ function resetZoom() {
51
+ setZoom(1);
52
+ isDragging = false;
53
+ }
54
+
55
+ function beginPan(event: MouseEvent) {
56
+ if (!isPannable || !viewportEl) return;
57
+ isDragging = true;
58
+ dragStartX = event.clientX;
59
+ dragStartY = event.clientY;
60
+ dragStartScrollLeft = viewportEl.scrollLeft;
61
+ dragStartScrollTop = viewportEl.scrollTop;
62
+ event.preventDefault();
63
+ }
64
+
65
+ function continuePan(event: MouseEvent) {
66
+ if (!isDragging || !viewportEl) return;
67
+ const deltaX = event.clientX - dragStartX;
68
+ const deltaY = event.clientY - dragStartY;
69
+ viewportEl.scrollLeft = dragStartScrollLeft - deltaX;
70
+ viewportEl.scrollTop = dragStartScrollTop - deltaY;
71
+ event.preventDefault();
72
+ }
73
+
74
+ function endPan() {
75
+ isDragging = false;
76
+ }
77
+
78
+ function openPreview() {
79
+ if (!src) return;
80
+ isOpen = true;
81
+ resetZoom();
82
+ }
83
+
84
+ function closePreview() {
85
+ isOpen = false;
86
+ resetZoom();
87
+ }
88
+
89
+ function handleKeydown(event: KeyboardEvent) {
90
+ if (event.key === "Escape" && isOpen) {
91
+ closePreview();
92
+ }
93
+ }
94
+
95
+ function handleWheel(event: WheelEvent) {
96
+ if (!isOpen) return;
97
+ event.preventDefault();
98
+ if (event.deltaY < 0) {
99
+ zoomIn();
100
+ return;
101
+ }
102
+ zoomOut();
103
+ }
104
+
105
+ $effect(() => {
106
+ if (typeof window === "undefined" || !isOpen) return;
107
+ window.addEventListener("keydown", handleKeydown);
108
+ return () => {
109
+ window.removeEventListener("keydown", handleKeydown);
110
+ };
111
+ });
112
+
113
+ $effect(() => {
114
+ if (typeof window === "undefined" || !isDragging) return;
115
+ window.addEventListener("mousemove", continuePan);
116
+ window.addEventListener("mouseup", endPan);
117
+ return () => {
118
+ window.removeEventListener("mousemove", continuePan);
119
+ window.removeEventListener("mouseup", endPan);
120
+ };
121
+ });
122
+ </script>
123
+
124
+ {#if src}
125
+ <figure class="markdown-image-figure">
126
+ <button
127
+ type="button"
128
+ class="markdown-image-thumb"
129
+ onclick={openPreview}
130
+ aria-label={caption ? `Open image preview: ${caption}` : "Open image preview"}
131
+ >
132
+ <img src={src} alt={alt} title={title || undefined} width={width} height={height} />
133
+ </button>
134
+ {#if caption}
135
+ <figcaption class="markdown-image-caption">{caption}</figcaption>
136
+ {/if}
137
+ </figure>
138
+ {/if}
139
+
140
+ {#if isOpen}
141
+ <div class="markdown-image-preview" role="dialog" aria-modal="true" aria-label="Image preview">
142
+ <button
143
+ type="button"
144
+ class="markdown-image-preview__backdrop"
145
+ aria-label="Close image preview"
146
+ onclick={closePreview}
147
+ ></button>
148
+ <div class="markdown-image-preview__content">
149
+ <div class="markdown-image-preview__toolbar" role="group" aria-label="Image zoom controls">
150
+ <button
151
+ type="button"
152
+ class="markdown-image-preview__zoom-btn"
153
+ aria-label="Zoom out"
154
+ onclick={zoomOut}
155
+ disabled={zoom <= MIN_ZOOM}
156
+ >
157
+
158
+ </button>
159
+ <button
160
+ type="button"
161
+ class="markdown-image-preview__zoom-btn markdown-image-preview__zoom-label"
162
+ aria-label="Reset zoom"
163
+ onclick={resetZoom}
164
+ >
165
+ {zoomLabel}
166
+ </button>
167
+ <button
168
+ type="button"
169
+ class="markdown-image-preview__zoom-btn"
170
+ aria-label="Zoom in"
171
+ onclick={zoomIn}
172
+ disabled={zoom >= MAX_ZOOM}
173
+ >
174
+ +
175
+ </button>
176
+ </div>
177
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
178
+ <div
179
+ class="markdown-image-preview__viewport"
180
+ class:is-pannable={isPannable}
181
+ class:is-dragging={isDragging}
182
+ bind:this={viewportEl}
183
+ onwheel={handleWheel}
184
+ onmousedown={beginPan}
185
+ >
186
+ <img
187
+ src={src}
188
+ alt={alt || caption || "Preview image"}
189
+ class="markdown-image-preview__image"
190
+ style={previewImageStyle}
191
+ />
192
+ </div>
193
+ <button
194
+ type="button"
195
+ class="markdown-image-preview__close"
196
+ aria-label="Close image preview"
197
+ onclick={closePreview}
198
+ >
199
+ ×
200
+ </button>
201
+ {#if caption}
202
+ <p class="markdown-image-preview__caption">{caption}</p>
203
+ {/if}
204
+ </div>
205
+ </div>
206
+ {/if}
207
+
208
+ <style>
209
+ .markdown-image-figure {
210
+ margin: 1rem 0;
211
+ max-width: 100%;
212
+ }
213
+
214
+ .markdown-image-thumb {
215
+ display: block;
216
+ padding: 0;
217
+ border: 0;
218
+ background: transparent;
219
+ cursor: zoom-in;
220
+ max-width: 100%;
221
+ }
222
+
223
+ .markdown-image-thumb img {
224
+ display: block;
225
+ max-width: 100%;
226
+ max-height: 220px;
227
+ width: auto;
228
+ border-radius: 8px;
229
+ border: 1px solid var(--greg-border-color);
230
+ transition: transform 0.14s ease, box-shadow 0.14s ease;
231
+ }
232
+
233
+ .markdown-image-thumb:hover img {
234
+ transform: translateY(-1px);
235
+ box-shadow: 0 8px 18px color-mix(in srgb, var(--greg-color) 16%, transparent);
236
+ }
237
+
238
+ .markdown-image-caption {
239
+ margin-top: 0.45rem;
240
+ font-size: 0.9rem;
241
+ color: var(--fgColor-muted);
242
+ }
243
+
244
+ .markdown-image-preview {
245
+ position: fixed;
246
+ inset: 0;
247
+ z-index: 1200;
248
+ display: grid;
249
+ place-items: center;
250
+ padding: 1.25rem;
251
+ }
252
+
253
+ .markdown-image-preview__backdrop {
254
+ position: absolute;
255
+ inset: 0;
256
+ border: none;
257
+ background: color-mix(in srgb, #000 74%, transparent);
258
+ cursor: zoom-out;
259
+ }
260
+
261
+ .markdown-image-preview__content {
262
+ position: relative;
263
+ max-width: min(92vw, 1500px);
264
+ max-height: 92vh;
265
+ z-index: 1;
266
+ display: grid;
267
+ gap: 0.5rem;
268
+ }
269
+
270
+ .markdown-image-preview__toolbar {
271
+ display: inline-flex;
272
+ gap: 0.4rem;
273
+ justify-self: center;
274
+ }
275
+
276
+ .markdown-image-preview__zoom-btn {
277
+ border: 1px solid rgb(255 255 255 / 0.4);
278
+ border-radius: 999px;
279
+ background: rgb(0 0 0 / 0.6);
280
+ color: #fff;
281
+ min-width: 2rem;
282
+ height: 2rem;
283
+ cursor: pointer;
284
+ font-size: 0.95rem;
285
+ }
286
+
287
+ .markdown-image-preview__zoom-btn:disabled {
288
+ opacity: 0.45;
289
+ cursor: not-allowed;
290
+ }
291
+
292
+ .markdown-image-preview__zoom-label {
293
+ padding: 0 0.7rem;
294
+ width: auto;
295
+ min-width: 4.25rem;
296
+ }
297
+
298
+ .markdown-image-preview__viewport {
299
+ overflow: auto;
300
+ max-width: min(92vw, 1500px);
301
+ max-height: 84vh;
302
+ padding: 0.15rem;
303
+ cursor: default;
304
+ }
305
+
306
+ .markdown-image-preview__viewport.is-pannable {
307
+ cursor: grab;
308
+ }
309
+
310
+ .markdown-image-preview__viewport.is-dragging {
311
+ cursor: grabbing;
312
+ user-select: none;
313
+ }
314
+
315
+ .markdown-image-preview__image {
316
+ display: block;
317
+ margin: 0 auto;
318
+ max-width: 100%;
319
+ max-height: 82vh;
320
+ border-radius: 0.625rem;
321
+ box-shadow: 0 18px 40px rgb(0 0 0 / 0.45);
322
+ }
323
+
324
+ .markdown-image-preview__close {
325
+ position: absolute;
326
+ top: -0.75rem;
327
+ right: -0.75rem;
328
+ width: 2rem;
329
+ height: 2rem;
330
+ border: 1px solid rgb(255 255 255 / 0.4);
331
+ border-radius: 999px;
332
+ background: rgb(0 0 0 / 0.6);
333
+ color: #fff;
334
+ line-height: 1;
335
+ cursor: pointer;
336
+ }
337
+
338
+ .markdown-image-preview__caption {
339
+ margin-top: 0.65rem;
340
+ margin-bottom: 0;
341
+ color: #fff;
342
+ text-align: center;
343
+ text-shadow: 0 1px 2px rgb(0 0 0 / 0.7);
344
+ }
345
+ </style>
@@ -73,6 +73,27 @@
73
73
  margin: 1rem 0 0.4rem 0;
74
74
  }
75
75
 
76
+ .markdown-flex-row {
77
+ display: flex;
78
+ flex-wrap: wrap;
79
+ gap: 0.75rem;
80
+ align-items: flex-start;
81
+ margin: 1rem 0;
82
+ }
83
+
84
+ .markdown-flex-item {
85
+ min-width: 0;
86
+ flex: 0 1 auto;
87
+ }
88
+
89
+ .markdown-flex-item > :first-child {
90
+ margin-top: 0;
91
+ }
92
+
93
+ .markdown-flex-item > :last-child {
94
+ margin-bottom: 0;
95
+ }
96
+
76
97
  img {
77
98
  max-width: 100%;
78
99
  border-style: none;
package/svelte.config.js CHANGED
@@ -11,6 +11,7 @@ import { remarkImports } from './src/lib/MarkdownDocs/remarkImports.js';
11
11
  import { remarkGlobalComponents } from './src/lib/MarkdownDocs/remarkGlobalComponents.js';
12
12
  import { remarkCustomAnchors } from './src/lib/MarkdownDocs/remarkCustomAnchors.js';
13
13
  import { remarkInlineAttrs } from './src/lib/MarkdownDocs/remarkInlineAttrs.js';
14
+ import { remarkFlexRow } from './src/lib/MarkdownDocs/remarkFlexRow.js';
14
15
  import { remarkEscapeSvelte } from './src/lib/MarkdownDocs/remarkEscapeSvelte.js';
15
16
  import { remarkMathToHtml } from './src/lib/MarkdownDocs/remarkMathToHtml.js';
16
17
  import { parseCodeDirectives, decorateHighlightedCodeHtml } from './src/lib/MarkdownDocs/codeDirectives.js';
@@ -103,6 +104,7 @@ const mdsvexOptions = {
103
104
  },
104
105
  },
105
106
  remarkPlugins: [
107
+ remarkFlexRow,
106
108
  remarkInlineAttrs,
107
109
  remarkGlobalComponents,
108
110
  remarkCodeMeta,
package/types/index.d.ts CHANGED
@@ -342,6 +342,8 @@ export type GregConfig = {
342
342
  version?: string;
343
343
  /** Site title shown in the header. */
344
344
  mainTitle?: string;
345
+ /** Render markdown images as thumbnails with click-to-preview overlay. Default: true. */
346
+ markdownImagePreview?: boolean;
345
347
  /**
346
348
  * VitePress-compatible outline setting.
347
349
  * false – disable outline