@autumnsgrove/groveengine 0.9.3 → 0.9.4

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.
Files changed (77) hide show
  1. package/dist/components/admin/GutterManager.svelte +314 -35
  2. package/dist/components/admin/MarkdownEditor.svelte +105 -40
  3. package/dist/components/admin/MarkdownEditor.svelte.d.ts +2 -0
  4. package/dist/components/custom/ContentWithGutter.svelte +12 -2
  5. package/dist/components/custom/GutterItem.svelte +122 -47
  6. package/dist/components/custom/TableOfContents.svelte +36 -3
  7. package/dist/components/quota/UpgradePrompt.svelte +12 -16
  8. package/dist/config/index.d.ts +4 -3
  9. package/dist/config/index.js +4 -3
  10. package/dist/config/tiers.d.ts +134 -0
  11. package/dist/config/tiers.js +402 -0
  12. package/dist/groveauth/types.d.ts +4 -1
  13. package/dist/groveauth/types.js +9 -13
  14. package/dist/payments/shop.d.ts +3 -3
  15. package/dist/payments/shop.js +271 -95
  16. package/dist/server/billing.d.ts +29 -0
  17. package/dist/server/billing.js +80 -0
  18. package/dist/server/env-validation.d.ts +68 -0
  19. package/dist/server/env-validation.js +95 -0
  20. package/dist/server/rate-limits/config.d.ts +24 -96
  21. package/dist/server/rate-limits/config.js +35 -59
  22. package/dist/server/rate-limits/tenant.d.ts +1 -69
  23. package/dist/server/services/database.d.ts +1 -1
  24. package/dist/server/services/database.js +69 -59
  25. package/dist/server/services/storage.d.ts +2 -1
  26. package/dist/server/services/storage.js +115 -91
  27. package/dist/server/tier-features.d.ts +56 -0
  28. package/dist/server/tier-features.js +79 -0
  29. package/dist/ui/components/chrome/Footer.svelte +16 -2
  30. package/dist/ui/components/chrome/Header.svelte +15 -19
  31. package/dist/ui/components/chrome/Header.svelte.d.ts +2 -2
  32. package/dist/ui/components/chrome/ThemeToggle.svelte +86 -27
  33. package/dist/ui/components/icons/index.d.ts +3 -3
  34. package/dist/ui/components/icons/index.js +6 -6
  35. package/dist/ui/components/icons/lucide.d.ts +15 -2
  36. package/dist/ui/components/icons/lucide.js +15 -3
  37. package/dist/ui/components/nature/GroveDivider.svelte +24 -28
  38. package/dist/ui/components/nature/GroveDivider.svelte.d.ts +5 -7
  39. package/dist/ui/components/nature/{Logo.svelte.d.ts → LogoArchive.svelte.d.ts} +3 -3
  40. package/dist/ui/components/nature/creatures/Bee.svelte +2 -2
  41. package/dist/ui/components/nature/creatures/Butterfly.svelte +3 -3
  42. package/dist/ui/components/nature/creatures/Owl.svelte +2 -2
  43. package/dist/ui/components/nature/ground/FlowerWild.svelte +3 -3
  44. package/dist/ui/components/nature/index.d.ts +12 -11
  45. package/dist/ui/components/nature/index.js +14 -12
  46. package/dist/ui/components/nature/palette.d.ts +106 -10
  47. package/dist/ui/components/nature/palette.js +211 -147
  48. package/dist/ui/components/nature/sky/Sun.svelte +2 -2
  49. package/dist/ui/components/nature/structural/LatticeWithVine.svelte +2 -2
  50. package/dist/ui/components/nature/water/LilyPad.svelte +2 -2
  51. package/dist/ui/components/ui/GlassLogo.svelte +354 -300
  52. package/dist/ui/components/ui/GlassLogo.svelte.d.ts +76 -13
  53. package/dist/ui/components/ui/GlassLogoArchive.svelte +415 -0
  54. package/dist/ui/components/ui/GlassLogoArchive.svelte.d.ts +23 -0
  55. package/dist/ui/components/ui/Logo.svelte +269 -169
  56. package/dist/ui/components/ui/Logo.svelte.d.ts +93 -14
  57. package/dist/ui/components/ui/LogoArchive.svelte +220 -0
  58. package/dist/ui/components/ui/LogoArchive.svelte.d.ts +20 -0
  59. package/dist/ui/components/ui/LogoLoader.svelte +1 -1
  60. package/dist/ui/components/ui/index.d.ts +30 -28
  61. package/dist/ui/components/ui/index.js +31 -29
  62. package/dist/ui/stores/season.d.ts +12 -2
  63. package/dist/ui/stores/season.js +101 -18
  64. package/dist/ui/types/index.d.ts +6 -0
  65. package/dist/ui/types/index.js +7 -0
  66. package/dist/ui/types/season.d.ts +36 -0
  67. package/dist/ui/types/season.js +86 -0
  68. package/dist/ui/utils/color.d.ts +69 -0
  69. package/dist/ui/utils/color.js +155 -0
  70. package/dist/ui/utils/index.d.ts +2 -1
  71. package/dist/ui/utils/index.js +5 -2
  72. package/package.json +22 -20
  73. package/static/favicon.png +0 -0
  74. package/static/favicon.svg +6 -0
  75. package/static/fonts/alagard.ttf +0 -0
  76. package/LICENSE +0 -378
  77. /package/dist/ui/components/nature/{Logo.svelte → LogoArchive.svelte} +0 -0
@@ -30,6 +30,16 @@
30
30
  * @property {string} url
31
31
  */
32
32
 
33
+ /**
34
+ * @typedef {Object} ProcessedAnchor
35
+ * @property {string} raw - Original anchor string
36
+ * @property {boolean} isHeading - Whether this is a heading anchor
37
+ * @property {number} headingLevel - Heading level (1-6) or 0 if not a heading
38
+ * @property {boolean} isAnchorTag - Whether this is a custom anchor tag
39
+ * @property {string} displayText - Human-readable display text
40
+ * @property {string} type - Anchor type for accessibility labels
41
+ */
42
+
33
43
  // Props
34
44
  let {
35
45
  gutterItems = $bindable(/** @type {GutterItem[]} */ ([])),
@@ -37,6 +47,62 @@
37
47
  availableAnchors = /** @type {string[]} */ ([]),
38
48
  } = $props();
39
49
 
50
+ /**
51
+ * Extract heading level from an anchor string (capped at 1-6)
52
+ * @param {string | undefined} anchor
53
+ * @returns {number} Heading level 1-6, or 0 if not a heading
54
+ */
55
+ function getHeadingLevel(anchor) {
56
+ if (!anchor) return 0;
57
+ // Only match valid heading levels (1-6 hash marks)
58
+ const match = anchor.match(/^#{1,6}/);
59
+ return match ? Math.min(match[0].length, 6) : 0;
60
+ }
61
+
62
+ /**
63
+ * Process a raw anchor string into structured data
64
+ * @param {string} anchor - The raw anchor string
65
+ * @returns {ProcessedAnchor}
66
+ */
67
+ function createProcessedAnchor(anchor) {
68
+ const isHeading = anchor.startsWith('#');
69
+ const headingLevel = getHeadingLevel(anchor);
70
+ const isAnchorTag = anchor.startsWith('anchor:');
71
+ const displayText = isHeading
72
+ ? anchor.replace(/^#+\s*/, '')
73
+ : anchor.replace('anchor:', '');
74
+ const type = isHeading
75
+ ? `heading level ${headingLevel}`
76
+ : isAnchorTag
77
+ ? 'anchor tag'
78
+ : 'paragraph';
79
+
80
+ return { raw: anchor, isHeading, headingLevel, isAnchorTag, displayText, type };
81
+ }
82
+
83
+ /**
84
+ * Preprocess anchors into structured data for better performance
85
+ * @type {ProcessedAnchor[]}
86
+ */
87
+ let processedAnchors = $derived(availableAnchors.map(createProcessedAnchor));
88
+
89
+ /** Default empty anchor for fallback */
90
+ const emptyAnchor = { raw: '', isHeading: false, headingLevel: 0, isAnchorTag: false, displayText: '', type: 'paragraph' };
91
+
92
+ /**
93
+ * Get processed anchor data for display (checks cache first, then computes)
94
+ * @param {string | undefined} anchor
95
+ * @returns {ProcessedAnchor}
96
+ */
97
+ function getProcessedAnchor(anchor) {
98
+ if (!anchor) return emptyAnchor;
99
+ // Try to find in preprocessed cache first
100
+ const cached = processedAnchors.find(pa => pa.raw === anchor);
101
+ if (cached) return cached;
102
+ // Fallback to computing for anchors not in availableAnchors
103
+ return createProcessedAnchor(anchor);
104
+ }
105
+
40
106
  // State
41
107
  let showAddModal = $state(false);
42
108
  /** @type {number | null} */
@@ -267,11 +333,12 @@
267
333
  <p class="hint">Add comments, images, or galleries that appear alongside your content.</p>
268
334
  </div>
269
335
  {:else}
270
- <div class="vines-list">
336
+ <div class="vines-list" role="list" aria-label="Vine items">
271
337
  {#each gutterItems as item, index (index)}
272
- <div class="vine-item">
338
+ {@const anchor = getProcessedAnchor(item.anchor)}
339
+ <div class="vine-item" role="listitem">
273
340
  <div class="item-header">
274
- <span class="item-type">
341
+ <span class="item-type" aria-hidden="true">
275
342
  {#if item.type === "comment"}
276
343
  <MessageSquare class="type-icon" />
277
344
  {:else if item.type === "photo"}
@@ -282,7 +349,21 @@
282
349
  <Pin class="type-icon" />
283
350
  {/if}
284
351
  </span>
285
- <span class="item-anchor" title={item.anchor}>{item.anchor || "No anchor"}</span>
352
+ <div class="item-anchor-display">
353
+ {#if item.anchor}
354
+ {#if anchor.isHeading}
355
+ <span class="anchor-badge heading-badge" aria-hidden="true">H{anchor.headingLevel}</span>
356
+ {:else if anchor.isAnchorTag}
357
+ <span class="anchor-badge tag-badge" aria-hidden="true">⚓</span>
358
+ {:else}
359
+ <span class="anchor-badge para-badge" aria-hidden="true">¶</span>
360
+ {/if}
361
+ <span class="item-anchor-text" title={item.anchor}>{anchor.displayText}</span>
362
+ <span class="visually-hidden">Anchored to {anchor.type}: {anchor.displayText}</span>
363
+ {:else}
364
+ <span class="no-anchor-warning" role="alert">⚠ No anchor set</span>
365
+ {/if}
366
+ </div>
286
367
  <div class="item-actions">
287
368
  <button
288
369
  class="action-btn"
@@ -364,18 +445,40 @@
364
445
  </span>
365
446
  </div>
366
447
 
367
- {#if availableAnchors.length > 0}
368
- <div class="available-anchors">
369
- <span class="anchors-label">Available:</span>
370
- {#each availableAnchors as anchor}
371
- <button
372
- type="button"
373
- class="anchor-chip"
374
- onclick={() => (itemAnchor = anchor)}
375
- >
376
- {anchor}
377
- </button>
378
- {/each}
448
+ {#if processedAnchors.length > 0}
449
+ <div class="available-anchors-section">
450
+ <span class="anchors-label" id="anchor-selection-label">Click to select anchor location:</span>
451
+ <div class="anchor-list" role="listbox" aria-labelledby="anchor-selection-label">
452
+ {#each processedAnchors as anchor}
453
+ <button
454
+ type="button"
455
+ class="anchor-option"
456
+ class:selected={itemAnchor === anchor.raw}
457
+ class:heading={anchor.isHeading}
458
+ class:anchor-tag={anchor.isAnchorTag}
459
+ role="option"
460
+ aria-selected={itemAnchor === anchor.raw}
461
+ aria-label="Select {anchor.type}: {anchor.displayText}"
462
+ onclick={() => (itemAnchor = anchor.raw)}
463
+ >
464
+ {#if anchor.isHeading}
465
+ <span class="anchor-icon heading-icon" aria-hidden="true">H{anchor.headingLevel}</span>
466
+ {:else if anchor.isAnchorTag}
467
+ <span class="anchor-icon tag-icon" aria-hidden="true">⚓</span>
468
+ {:else}
469
+ <span class="anchor-icon para-icon" aria-hidden="true">¶</span>
470
+ {/if}
471
+ <span class="anchor-text">{anchor.displayText}</span>
472
+ {#if itemAnchor === anchor.raw}
473
+ <span class="selected-check" aria-hidden="true">✓</span>
474
+ {/if}
475
+ </button>
476
+ {/each}
477
+ </div>
478
+ </div>
479
+ {:else}
480
+ <div class="no-anchors-hint">
481
+ <p>No anchors found. Add headings to your content or use "Add Anchor" to create custom anchor points.</p>
379
482
  </div>
380
483
  {/if}
381
484
 
@@ -636,20 +739,76 @@
636
739
  height: 1rem;
637
740
  }
638
741
 
639
- .item-anchor {
742
+ .item-anchor-display {
640
743
  flex: 1;
641
- font-family: monospace;
642
- font-size: 0.8rem;
744
+ display: flex;
745
+ align-items: center;
746
+ gap: 0.35rem;
747
+ min-width: 0;
748
+ }
749
+
750
+ .anchor-badge {
751
+ display: inline-flex;
752
+ align-items: center;
753
+ justify-content: center;
754
+ min-width: 20px;
755
+ height: 16px;
756
+ font-size: 0.6rem;
757
+ font-weight: 700;
758
+ border-radius: 3px;
759
+ flex-shrink: 0;
760
+ }
761
+
762
+ .heading-badge {
763
+ background: rgba(124, 77, 171, 0.15);
764
+ color: #7c4dab;
765
+ }
766
+
767
+ :global(.dark) .heading-badge {
768
+ background: rgba(201, 160, 232, 0.15);
769
+ color: #c9a0e8;
770
+ }
771
+
772
+ .tag-badge {
773
+ background: rgba(59, 130, 246, 0.15);
774
+ color: #3b82f6;
775
+ font-size: 0.65rem;
776
+ }
777
+
778
+ :global(.dark) .tag-badge {
779
+ background: rgba(96, 165, 250, 0.15);
780
+ color: #60a5fa;
781
+ }
782
+
783
+ .para-badge {
784
+ background: rgba(107, 114, 128, 0.15);
785
+ color: #6b7280;
786
+ font-size: 0.65rem;
787
+ }
788
+
789
+ .item-anchor-text {
790
+ font-family: -apple-system, system-ui, sans-serif;
791
+ font-size: 0.75rem;
643
792
  color: var(--color-text-muted);
644
793
  white-space: nowrap;
645
794
  overflow: hidden;
646
795
  text-overflow: ellipsis;
647
796
  }
648
797
 
649
- :global(.dark) .item-anchor {
798
+ :global(.dark) .item-anchor-text {
650
799
  color: var(--grove-text-strong);
651
800
  }
652
801
 
802
+ .no-anchor-warning {
803
+ font-size: 0.7rem;
804
+ color: #e07030;
805
+ font-style: italic;
806
+ }
807
+
808
+ :global(.dark) .no-anchor-warning {
809
+ color: #f0c674;
810
+ }
811
+
653
812
  .item-actions {
654
813
  display: flex;
655
814
  gap: 0.125rem;
@@ -775,40 +934,147 @@
775
934
  flex: 1;
776
935
  }
777
936
 
778
- .available-anchors {
779
- display: flex;
780
- flex-wrap: wrap;
781
- gap: 0.35rem;
782
- align-items: center;
937
+ /* Improved anchor selection UI */
938
+ .available-anchors-section {
783
939
  margin-bottom: 1rem;
940
+ background: var(--grove-overlay-5);
941
+ border: 1px solid var(--grove-border-subtle);
942
+ border-radius: 10px;
943
+ padding: 0.75rem;
784
944
  }
785
945
 
786
946
  .anchors-label {
947
+ display: block;
787
948
  font-size: 0.75rem;
788
949
  color: var(--color-text-subtle);
950
+ margin-bottom: 0.5rem;
951
+ font-weight: 500;
789
952
  }
790
953
 
791
- .anchor-chip {
792
- padding: 0.2rem 0.5rem;
793
- background: var(--grove-overlay-10);
794
- border: 1px solid var(--grove-border);
795
- border-radius: 12px;
954
+ .anchor-list {
955
+ display: flex;
956
+ flex-direction: column;
957
+ gap: 0.35rem;
958
+ max-height: 200px;
959
+ overflow-y: auto;
960
+ }
961
+
962
+ .anchor-option {
963
+ display: flex;
964
+ align-items: center;
965
+ gap: 0.5rem;
966
+ padding: 0.5rem 0.75rem;
967
+ background: var(--glass-bg-medium, rgba(255, 255, 255, 0.5));
968
+ backdrop-filter: blur(8px);
969
+ -webkit-backdrop-filter: blur(8px);
970
+ border: 1px solid var(--grove-border-subtle);
971
+ border-radius: 8px;
796
972
  color: var(--color-text-muted);
797
- font-size: 0.7rem;
798
- font-family: monospace;
973
+ font-size: 0.8rem;
799
974
  cursor: pointer;
800
975
  transition: all 0.15s ease;
976
+ text-align: left;
977
+ }
978
+
979
+ .anchor-option:hover {
980
+ background: var(--grove-overlay-15);
981
+ border-color: var(--grove-overlay-25);
982
+ transform: translateX(4px);
801
983
  }
802
984
 
803
- .anchor-chip:hover {
804
- background: var(--grove-overlay-20);
985
+ .anchor-option.selected {
986
+ background: rgba(34, 197, 94, 0.15);
987
+ border-color: rgba(34, 197, 94, 0.4);
805
988
  color: var(--color-primary);
806
989
  }
807
990
 
808
- :global(.dark) .anchor-chip:hover {
991
+ :global(.dark) .anchor-option {
992
+ background: rgba(16, 50, 37, 0.35);
993
+ border-color: rgba(74, 222, 128, 0.1);
994
+ }
995
+
996
+ :global(.dark) .anchor-option:hover {
997
+ background: rgba(16, 50, 37, 0.5);
998
+ border-color: rgba(74, 222, 128, 0.2);
999
+ }
1000
+
1001
+ :global(.dark) .anchor-option.selected {
1002
+ background: rgba(74, 222, 128, 0.15);
1003
+ border-color: rgba(74, 222, 128, 0.4);
809
1004
  color: #86efac;
810
1005
  }
811
1006
 
1007
+ .anchor-icon {
1008
+ display: inline-flex;
1009
+ align-items: center;
1010
+ justify-content: center;
1011
+ min-width: 24px;
1012
+ height: 20px;
1013
+ font-size: 0.65rem;
1014
+ font-weight: 700;
1015
+ border-radius: 4px;
1016
+ flex-shrink: 0;
1017
+ }
1018
+
1019
+ .heading-icon {
1020
+ background: rgba(124, 77, 171, 0.15);
1021
+ color: #7c4dab;
1022
+ }
1023
+
1024
+ :global(.dark) .heading-icon {
1025
+ background: rgba(201, 160, 232, 0.15);
1026
+ color: #c9a0e8;
1027
+ }
1028
+
1029
+ .tag-icon {
1030
+ background: rgba(59, 130, 246, 0.15);
1031
+ color: #3b82f6;
1032
+ font-size: 0.75rem;
1033
+ }
1034
+
1035
+ :global(.dark) .tag-icon {
1036
+ background: rgba(96, 165, 250, 0.15);
1037
+ color: #60a5fa;
1038
+ }
1039
+
1040
+ .para-icon {
1041
+ background: rgba(107, 114, 128, 0.15);
1042
+ color: #6b7280;
1043
+ font-size: 0.75rem;
1044
+ }
1045
+
1046
+ .anchor-text {
1047
+ flex: 1;
1048
+ overflow: hidden;
1049
+ text-overflow: ellipsis;
1050
+ white-space: nowrap;
1051
+ }
1052
+
1053
+ .selected-check {
1054
+ color: var(--color-primary);
1055
+ font-weight: 600;
1056
+ flex-shrink: 0;
1057
+ }
1058
+
1059
+ :global(.dark) .selected-check {
1060
+ color: #86efac;
1061
+ }
1062
+
1063
+ .no-anchors-hint {
1064
+ padding: 1rem;
1065
+ background: var(--grove-overlay-5);
1066
+ border: 1px dashed var(--grove-border);
1067
+ border-radius: 8px;
1068
+ margin-bottom: 1rem;
1069
+ }
1070
+
1071
+ .no-anchors-hint p {
1072
+ margin: 0;
1073
+ font-size: 0.8rem;
1074
+ color: var(--color-text-subtle);
1075
+ text-align: center;
1076
+ }
1077
+
812
1078
  .image-preview {
813
1079
  margin-top: 0.5rem;
814
1080
  max-height: 150px;
@@ -960,4 +1226,17 @@
960
1226
  overflow: hidden;
961
1227
  text-overflow: ellipsis;
962
1228
  }
1229
+
1230
+ /* Screen reader only utility */
1231
+ .visually-hidden {
1232
+ position: absolute;
1233
+ width: 1px;
1234
+ height: 1px;
1235
+ padding: 0;
1236
+ margin: -1px;
1237
+ overflow: hidden;
1238
+ clip: rect(0, 0, 0, 0);
1239
+ white-space: nowrap;
1240
+ border: 0;
1241
+ }
963
1242
  </style>
@@ -2,10 +2,12 @@
2
2
  import { marked } from "marked";
3
3
  import { onMount, tick } from "svelte";
4
4
  import { sanitizeMarkdown } from "../../utils/sanitize";
5
+ import { extractHeaders } from "../../utils/markdown";
5
6
  import "../../styles/content.css";
6
7
  import { Button, Input, Logo } from '../../ui';
7
8
  import Dialog from "../../ui/components/ui/Dialog.svelte";
8
9
  import FloatingToolbar from "./FloatingToolbar.svelte";
10
+ import ContentWithGutter from "../custom/ContentWithGutter.svelte";
9
11
  import { Eye, EyeOff, Maximize2, PenLine, Columns2, BookOpen, Focus, Minimize2 } from "lucide-svelte";
10
12
  import { browser } from "$app/environment";
11
13
 
@@ -23,6 +25,17 @@
23
25
  * @property {number} [wordCount]
24
26
  */
25
27
 
28
+ /**
29
+ * @typedef {Object} GutterItemProp
30
+ * @property {string} type
31
+ * @property {string} [anchor]
32
+ * @property {string} [content]
33
+ * @property {string} [url]
34
+ * @property {string} [file]
35
+ * @property {string} [caption]
36
+ * @property {Array<{url: string, alt?: string, caption?: string}>} [images]
37
+ */
38
+
26
39
  // Props
27
40
  let {
28
41
  content = $bindable(""),
@@ -34,6 +47,7 @@
34
47
  previewTitle = "",
35
48
  previewDate = "",
36
49
  previewTags = /** @type {string[]} */ ([]),
50
+ gutterItems = /** @type {GutterItemProp[]} */ ([]),
37
51
  } = $props();
38
52
 
39
53
  // Core refs and state
@@ -93,6 +107,7 @@
93
107
  let charCount = $derived(content.length);
94
108
  let lineCount = $derived(content.split("\n").length);
95
109
  let previewHtml = $derived(content ? sanitizeMarkdown(/** @type {string} */ (marked.parse(content, { async: false }))) : "");
110
+ let previewHeaders = $derived(content ? extractHeaders(content) : []);
96
111
 
97
112
  let readingTime = $derived.by(() => {
98
113
  const minutes = Math.ceil(wordCount / 200);
@@ -743,9 +758,9 @@
743
758
  {#if showFullPreview}
744
759
  <div class="full-preview-modal" role="dialog" aria-modal="true" aria-label="Full article preview" tabindex="-1" onkeydown={(e) => e.key === 'Escape' && (showFullPreview = false)}>
745
760
  <button type="button" class="full-preview-backdrop" onclick={() => (showFullPreview = false)} aria-label="Close preview"></button>
746
- <div class="full-preview-container">
761
+ <div class="full-preview-container" class:has-vines={gutterItems.length > 0}>
747
762
  <header class="full-preview-header">
748
- <h2>:: full preview</h2>
763
+ <h2>:: full preview {#if gutterItems.length > 0}<span class="vine-count">({gutterItems.length} vine{gutterItems.length !== 1 ? 's' : ''})</span>{/if}</h2>
749
764
  <div class="full-preview-actions">
750
765
  <button type="button" class="full-preview-close" onclick={() => (showFullPreview = false)}>
751
766
  [<span class="key">c</span>lose]
@@ -753,45 +768,84 @@
753
768
  </div>
754
769
  </header>
755
770
  <div class="full-preview-scroll">
756
- <article class="full-preview-article">
757
- {#if previewTitle || previewDate || previewTags.length > 0}
758
- <header class="content-header">
759
- {#if previewTitle}
760
- <h1>{previewTitle}</h1>
761
- {/if}
762
- {#if previewDate || previewTags.length > 0}
763
- <div class="post-meta">
764
- {#if previewDate}
765
- <time datetime={previewDate}>
766
- {new Date(previewDate).toLocaleDateString("en-US", {
767
- year: "numeric",
768
- month: "long",
769
- day: "numeric",
770
- })}
771
- </time>
772
- {/if}
773
- {#if previewTags.length > 0}
774
- <div class="tags">
775
- {#each previewTags as tag}
776
- <span class="tag">{tag}</span>
777
- {/each}
778
- </div>
779
- {/if}
780
- </div>
781
- {/if}
782
- </header>
783
- {/if}
784
-
785
- <div class="content-body">
786
- {#if previewHtml}
787
- {#key previewHtml}
788
- <div>{@html previewHtml}</div>
789
- {/key}
790
- {:else}
791
- <p class="preview-placeholder">Start writing to see your content here...</p>
771
+ {#if gutterItems.length > 0}
772
+ <!-- Use ContentWithGutter when we have vines -->
773
+ <ContentWithGutter
774
+ content={previewHtml}
775
+ gutterContent={gutterItems}
776
+ headers={previewHeaders}
777
+ showTableOfContents={previewHeaders.length > 0}
778
+ >
779
+ {#if previewTitle || previewDate || previewTags.length > 0}
780
+ <header class="content-header">
781
+ {#if previewTitle}
782
+ <h1 class="full-preview-title">{previewTitle}</h1>
783
+ {/if}
784
+ {#if previewDate || previewTags.length > 0}
785
+ <div class="post-meta">
786
+ {#if previewDate}
787
+ <time datetime={previewDate}>
788
+ {new Date(previewDate).toLocaleDateString("en-US", {
789
+ year: "numeric",
790
+ month: "long",
791
+ day: "numeric",
792
+ })}
793
+ </time>
794
+ {/if}
795
+ {#if previewTags.length > 0}
796
+ <div class="tags">
797
+ {#each previewTags as tag}
798
+ <span class="tag">{tag}</span>
799
+ {/each}
800
+ </div>
801
+ {/if}
802
+ </div>
803
+ {/if}
804
+ </header>
792
805
  {/if}
793
- </div>
794
- </article>
806
+ </ContentWithGutter>
807
+ {:else}
808
+ <!-- Simple preview without vines -->
809
+ <article class="full-preview-article">
810
+ {#if previewTitle || previewDate || previewTags.length > 0}
811
+ <header class="content-header">
812
+ {#if previewTitle}
813
+ <h1>{previewTitle}</h1>
814
+ {/if}
815
+ {#if previewDate || previewTags.length > 0}
816
+ <div class="post-meta">
817
+ {#if previewDate}
818
+ <time datetime={previewDate}>
819
+ {new Date(previewDate).toLocaleDateString("en-US", {
820
+ year: "numeric",
821
+ month: "long",
822
+ day: "numeric",
823
+ })}
824
+ </time>
825
+ {/if}
826
+ {#if previewTags.length > 0}
827
+ <div class="tags">
828
+ {#each previewTags as tag}
829
+ <span class="tag">{tag}</span>
830
+ {/each}
831
+ </div>
832
+ {/if}
833
+ </div>
834
+ {/if}
835
+ </header>
836
+ {/if}
837
+
838
+ <div class="content-body">
839
+ {#if previewHtml}
840
+ {#key previewHtml}
841
+ <div>{@html previewHtml}</div>
842
+ {/key}
843
+ {:else}
844
+ <p class="preview-placeholder">Start writing to see your content here...</p>
845
+ {/if}
846
+ </div>
847
+ </article>
848
+ {/if}
795
849
  </div>
796
850
  </div>
797
851
  </div>
@@ -1446,6 +1500,17 @@
1446
1500
  flex-direction: column;
1447
1501
  overflow: hidden;
1448
1502
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
1503
+ transition: max-width 0.3s ease;
1504
+ }
1505
+ /* Wider container when vines are present */
1506
+ .full-preview-container.has-vines {
1507
+ max-width: 1400px;
1508
+ }
1509
+ .vine-count {
1510
+ font-weight: 400;
1511
+ color: #7a9a7a;
1512
+ font-size: 0.75rem;
1513
+ margin-left: 0.5rem;
1449
1514
  }
1450
1515
  :global(.dark) .full-preview-container {
1451
1516
  background: var(--color-bg-dark, #0d1117);
@@ -21,6 +21,7 @@ declare const MarkdownEditor: import("svelte").Component<{
21
21
  previewTitle?: string;
22
22
  previewDate?: string;
23
23
  previewTags?: any;
24
+ gutterItems?: any;
24
25
  }, {
25
26
  getAvailableAnchors: () => string[];
26
27
  insertAnchor: (name: string) => void;
@@ -40,4 +41,5 @@ type $$ComponentProps = {
40
41
  previewTitle?: string;
41
42
  previewDate?: string;
42
43
  previewTags?: any;
44
+ gutterItems?: any;
43
45
  };
@@ -426,11 +426,21 @@
426
426
  let isPurifyReady = $state(false);
427
427
 
428
428
  // Load DOMPurify only in browser (avoids jsdom dependency for SSR)
429
+ // Content is already sanitized server-side, so we mark ready immediately
430
+ // and re-sanitize once DOMPurify loads (defensive, usually a no-op)
429
431
  onMount(async () => {
430
432
  if (browser) {
431
- const module = await import('dompurify');
432
- DOMPurify = module.default;
433
+ // Mark ready immediately so we don't block rendering
434
+ // Content was already sanitized on the server
433
435
  isPurifyReady = true;
436
+
437
+ // Load DOMPurify in the background for additional client-side sanitization
438
+ try {
439
+ const module = await import('dompurify');
440
+ DOMPurify = module.default;
441
+ } catch (err) {
442
+ console.warn('DOMPurify failed to load, using server-sanitized content:', err);
443
+ }
434
444
  }
435
445
  });
436
446