kash-shell 0.3.15__py3-none-any.whl → 0.3.17__py3-none-any.whl

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.
kash/model/items_model.py CHANGED
@@ -219,6 +219,12 @@ class Item:
219
219
  a text document, PDF or other resource, URL, etc.
220
220
  """
221
221
 
222
+ # TODO: A few cleanups:
223
+ # - Consider adding aliases and tags. See also Obsidian frontmatter format:
224
+ # https://help.obsidian.md/Editing+and+formatting/Properties#Default%20properties
225
+ # - Can eliminate context here as we now have ExectContext in a contextvar.
226
+ # - Change store_path and external_path to a StorePath and Path instead of a str.
227
+
222
228
  type: ItemType
223
229
  state: State = State.draft
224
230
  title: str | None = None
@@ -230,17 +236,15 @@ class Item:
230
236
  created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
231
237
  modified_at: datetime | None = None
232
238
 
233
- # TODO: Consider adding aliases and tags. See also Obsidian frontmatter format:
234
- # https://help.obsidian.md/Editing+and+formatting/Properties#Default%20properties
235
-
236
239
  # Content of the item.
237
240
  # Text items are in body. Large or binary items may be stored externally.
241
+ # The external_path if present should always hold the current body of the content
242
+ # (and body will not be set). This is necessary for large or binary files.
238
243
  body: str | None = None
239
244
  external_path: str | None = None
240
245
  original_filename: str | None = None
241
246
 
242
247
  # Path to the item in the store, if it has been saved.
243
- # TODO: Migrate this to StorePath.
244
248
  store_path: str | None = None
245
249
 
246
250
  # Optionally, relations to other items, including any time this item is derived from.
@@ -547,7 +551,7 @@ class Item:
547
551
  if filename_stem and not prefer_title:
548
552
  return slugify_snake(filename_stem)
549
553
  else:
550
- return slugify_snake(self.abbrev_title(max_len=max_len, add_ops_suffix=True))
554
+ return slugify_snake(self.pick_title(max_len=max_len, add_ops_suffix=True))
551
555
 
552
556
  def default_filename(self) -> str:
553
557
  """
@@ -560,7 +564,16 @@ class Item:
560
564
  full_suffix = self.get_full_suffix()
561
565
  return join_suffix(slug, full_suffix)
562
566
 
563
- def abbrev_title(
567
+ def body_heading(self, allowed_tags: tuple[str, ...] = ("h1", "h2")) -> str | None:
568
+ """
569
+ Get the first heading (by default h1 or h2) from the body text, if present.
570
+ """
571
+ if self.format in [Format.markdown, Format.md_html]:
572
+ return first_heading(self.body_text(), allowed_tags=allowed_tags)
573
+ # TODO: Support HTML <h1> and <h2> as well.
574
+ return None
575
+
576
+ def pick_title(
564
577
  self,
565
578
  *,
566
579
  max_len: int = 100,
@@ -619,41 +632,20 @@ class Item:
619
632
 
620
633
  return final_text
621
634
 
622
- def display_title(self) -> str:
623
- """
624
- A display title for this item. Same as abbrev_title() but will fall back
625
- to the filename if it is available.
626
- """
627
- display_title = self.title
628
- if not display_title and self.store_path:
629
- display_title = Path(self.store_path).name
630
- if not display_title:
631
- display_title = self.abbrev_title(pull_body_heading=True)
632
- return display_title
633
-
634
- def abbrev_description(self, max_len: int = 1000) -> str:
635
+ def pick_description(self, max_len: int = 1000) -> str:
635
636
  """
636
637
  Get or infer description.
637
638
  """
638
639
  return abbrev_on_words(html_to_plaintext(self.description or self.body or ""), max_len)
639
640
 
640
- def body_heading(self) -> str | None:
641
- """
642
- Get the first h1 or h2 heading from the body text, if present.
643
- """
644
- if self.format in [Format.markdown, Format.md_html]:
645
- return first_heading(self.body_text(), allowed_tags=("h1", "h2"))
646
- # TODO: Support HTML <h1> and <h2> as well.
647
- return None
648
-
649
641
  def abbrev_body(self, max_len: int) -> str:
650
642
  """
651
643
  Get an abbreviated version of the body text. Must not be a binary Item.
652
644
  Abbreviates YAML bodies like {"role": "user", "content": "Hello"} to "user Hello".
653
645
  """
654
- body_text = self.body_text()[:max_len]
646
+ body_text = abbrev_str(self.body_text(), max_len)
655
647
 
656
- # Just for aesthetics especially for titles of chat files.
648
+ # Just for aesthetics, especially for titles of chat files.
657
649
  if self.type in [ItemType.chat, ItemType.config] or self.format == Format.yaml:
658
650
  try:
659
651
  yaml_obj = list(new_yaml().load_all(self.body_text()))
@@ -662,7 +654,7 @@ class Item:
662
654
  except Exception as e:
663
655
  log.info("Error parsing YAML body: %s", e)
664
656
 
665
- return body_text[:max_len]
657
+ return abbrev_str(body_text, max_len)
666
658
 
667
659
  @property
668
660
  def has_body(self) -> bool:
@@ -745,9 +737,14 @@ class Item:
745
737
  self,
746
738
  other: Item | None = None,
747
739
  update_timestamp: bool = False,
740
+ clear_fields=(
741
+ "store_path", # Will be set at save time.
742
+ "source", # Should be cleared so the ItemId of a copy is not the same as the original.
743
+ "modified_at",
744
+ ),
748
745
  **other_updates: Unpack[ItemUpdateOptions],
749
746
  ) -> dict[str, Any]:
750
- overrides: dict[str, Any] = {"store_path": None, "modified_at": None}
747
+ overrides: dict[str, Any] = {f: None for f in clear_fields}
751
748
  if update_timestamp:
752
749
  overrides["created_at"] = datetime.now()
753
750
 
@@ -767,8 +764,9 @@ class Item:
767
764
  self, update_timestamp: bool = True, **other_updates: Unpack[ItemUpdateOptions]
768
765
  ) -> Item:
769
766
  """
770
- Copy item with the given field updates. Resets `store_path` to None but preserves
771
- other fields, including the body. Updates created time if requested.
767
+ Copy item with the given field updates. Resets `store_path` and `source` to None
768
+ since those should be set explicitly later. Preserves other fields, including
769
+ the body.
772
770
  """
773
771
  new_fields = self._copy_and_update(update_timestamp=update_timestamp, **other_updates)
774
772
  return Item(**new_fields)
@@ -783,9 +781,12 @@ class Item:
783
781
 
784
782
  def derived_copy(self, **updates: Unpack[ItemUpdateOptions]) -> Item:
785
783
  """
786
- Same as `new_copy_with()`, but also makes any other updates and updates the
787
- `derived_from` relation. If we also have an action context, then use the
788
- `title_template` to derive a new title.
784
+ Copy item with the given field updates. Resets `store_path` and `source` to None
785
+ since those should be set explicitly later. Preserves other fields, including
786
+ the body.
787
+
788
+ Same as `new_copy_with` but also updates the `derived_from` relation. If we also
789
+ have an action context, then use the `title_template` to derive a new title.
789
790
  """
790
791
  if not self.store_path:
791
792
  if self.relations.derived_from:
@@ -898,7 +899,7 @@ class Item:
898
899
  elif self.external_path:
899
900
  return fmt_loc(self.external_path)
900
901
  else:
901
- return repr(self.abbrev_title())
902
+ return repr(self.pick_title())
902
903
 
903
904
  def as_chat_history(self) -> ChatHistory:
904
905
  if self.type != ItemType.chat:
@@ -270,7 +270,7 @@ def get_item_completions(
270
270
  f"{fmt_store_path(not_none(item.store_path))}",
271
271
  COMPLETION_DISPLAY_MAX_LEN,
272
272
  ),
273
- description=item.abbrev_title(),
273
+ description=item.pick_title(),
274
274
  append_space=True,
275
275
  score=score,
276
276
  )
@@ -106,7 +106,9 @@ class Format(Enum):
106
106
  @property
107
107
  def is_simple_text(self) -> bool:
108
108
  """
109
- Is this plaintext or close to it, like Markdown?
109
+ Is this plaintext or close to it, like Markdown or Markdown with limited HTML?
110
+ "Simple text" should be a format that converts canonically to clean HTML.
111
+ Does not include full-page general HTML.
110
112
  """
111
113
  return self in [self.plaintext, self.markdown, self.md_html]
112
114
 
@@ -174,8 +176,8 @@ class Format(Enum):
174
176
  """
175
177
  Is this format compatible with frontmatter format metadata?
176
178
  PDF and docx unfortunately won't work with frontmatter.
177
- CSV does to some degree, depending on the tool, and this is useful so we support it.
178
- Perhaps we could include JSON here (assuming it's JSON5), but currently we do not.
179
+ CSV does to some degree, depending on the tool, and this can be useful so we support it.
180
+ Including JSON here (assuming it's JSON5) for similar reasons.
179
181
  """
180
182
  return self in [
181
183
  self.url,
@@ -189,7 +191,7 @@ class Format(Enum):
189
191
  self.python,
190
192
  self.shellscript,
191
193
  self.xonsh,
192
- self.csv,
194
+ self.csv, # Often but not always supported.
193
195
  self.log,
194
196
  ]
195
197
 
@@ -7,6 +7,7 @@ def simple_webpage_render(
7
7
  item: Item,
8
8
  page_template: str = "simple_webpage.html.jinja",
9
9
  add_title_h1: bool = True,
10
+ show_theme_toggle: bool = False,
10
11
  ) -> str:
11
12
  """
12
13
  Generate a simple web page from a single item.
@@ -15,10 +16,12 @@ def simple_webpage_render(
15
16
  return render_web_template(
16
17
  template_filename=page_template,
17
18
  data={
18
- "title": item.abbrev_title(),
19
+ "title": item.pick_title(),
19
20
  "add_title_h1": add_title_h1,
20
21
  "content_html": item.body_as_html(),
21
22
  "thumbnail_url": item.thumbnail_url,
23
+ "enable_themes": show_theme_toggle,
24
+ "show_theme_toggle": show_theme_toggle,
22
25
  },
23
26
  )
24
27
 
@@ -68,14 +68,14 @@ def tabbed_webpage_config(
68
68
 
69
69
  tabs = [
70
70
  TabInfo(
71
- label=clean_label(item.abbrev_title()),
71
+ label=clean_label(item.pick_title()),
72
72
  store_path=item.store_path,
73
73
  thumbnail_url=get_thumbnail_url(item),
74
74
  )
75
75
  for item in items
76
76
  ]
77
77
  _fill_in_ids(tabs)
78
- title = summary_heading([item.abbrev_title() for item in items])
78
+ title = summary_heading([item.pick_title() for item in items])
79
79
  config = TabbedWebpage(
80
80
  title=title, tabs=tabs, show_tabs=len(tabs) > 1, add_title_h1=add_title_h1
81
81
  )
@@ -100,7 +100,10 @@ def _load_tab_content(config: TabbedWebpage):
100
100
 
101
101
 
102
102
  def tabbed_webpage_generate(
103
- config_item: Item, page_template: str = "base_webpage.html.jinja", add_title_h1: bool = True
103
+ config_item: Item,
104
+ page_template: str = "base_webpage.html.jinja",
105
+ add_title_h1: bool = True,
106
+ show_theme_toggle: bool = False,
104
107
  ) -> str:
105
108
  """
106
109
  Generate a web page using the supplied config.
@@ -121,6 +124,8 @@ def tabbed_webpage_generate(
121
124
  "title": tabbed_webpage.title,
122
125
  "add_title_h1": add_title_h1,
123
126
  "content": content,
127
+ "enable_themes": show_theme_toggle,
128
+ "show_theme_toggle": show_theme_toggle,
124
129
  },
125
130
  )
126
131
 
@@ -176,11 +176,10 @@ ol > li {
176
176
  }
177
177
 
178
178
  blockquote {
179
- border-left: 4px solid var(--color-primary);
179
+ border-left: 2px solid var(--color-primary);
180
180
  padding-left: 1rem;
181
181
  margin-left: 0;
182
182
  margin-right: 0;
183
- color: var(--color-secondary);
184
183
  }
185
184
 
186
185
  /* Inline code styling */
@@ -188,6 +187,20 @@ code {
188
187
  font-family: var(--font-mono);
189
188
  font-size: var(--font-size-mono);
190
189
  letter-spacing: -0.025em;
190
+
191
+ transition: color 0.4s ease-in-out;
192
+ }
193
+ /* For code inside pre we style the pre tag */
194
+ code:not(pre code) {
195
+ background-color: var(--color-bg-alt);
196
+ border-radius: 3px;
197
+ border: 1px solid var(--color-hint-gentle);
198
+ padding: 0.25em 0.2em 0.1em 0.2em;
199
+ }
200
+
201
+ /* Code block wrapper for positioning copy button */
202
+ .code-block-wrapper {
203
+ position: relative;
191
204
  }
192
205
 
193
206
  /* Code blocks (pre + code) */
@@ -195,12 +208,14 @@ pre {
195
208
  font-family: var(--font-mono);
196
209
  font-size: var(--font-size-mono);
197
210
  letter-spacing: -0.025em;
211
+
198
212
  background-color: var(--color-bg-alt);
199
- border-radius: 4px;
200
- border: 1px dotted var(--color-border-hint);
201
- padding: 0.2rem 0.2rem 0.1rem 0.2rem;
213
+ border-radius: 3px;
214
+ border: 1px solid var(--color-hint-gentle);
215
+ padding: 0.25rem 0.2rem 0.1rem 0.2rem;
202
216
  overflow-x: auto; /* Enable horizontal scrolling */
203
- position: relative; /* Create new stacking context */
217
+ margin: 0;
218
+ transition: background-color 0.4s ease-in-out, border-color 0.4s ease-in-out;
204
219
  }
205
220
 
206
221
  /* Reset code styling when inside pre blocks */
@@ -209,6 +224,44 @@ pre > code {
209
224
  line-height: 1.5; /* Improve readability */
210
225
  }
211
226
 
227
+ /* Copy button for code blocks */
228
+ .code-copy-button {
229
+ position: absolute;
230
+ top: 0;
231
+ right: 0;
232
+ margin: 1px;
233
+ background: var(--color-bg-alt-solid);
234
+ color: var(--color-hint);
235
+ border: none;
236
+ border-radius: 0.25rem;
237
+ padding: 0;
238
+ cursor: pointer;
239
+ font-size: 0.75rem;
240
+ z-index: 10;
241
+ transition: all 0.2s ease-in-out;
242
+ display: flex;
243
+ align-items: center;
244
+ justify-content: center;
245
+ width: 1.5rem;
246
+ height: 1.5rem;
247
+ opacity: 0.9;
248
+ }
249
+
250
+ .code-copy-button:hover {
251
+ background: var(--color-hover-bg);
252
+ color: var(--color-primary);
253
+ opacity: 1;
254
+ }
255
+
256
+ .code-copy-button.copied {
257
+ color: var(--color-success);
258
+ }
259
+
260
+ .code-copy-button svg {
261
+ width: 0.875rem;
262
+ height: 0.875rem;
263
+ }
264
+
212
265
  hr {
213
266
  border: none;
214
267
  height: 1.5rem;
@@ -298,7 +351,7 @@ hr:before {
298
351
  font-weight: var(--font-weight-sans-bold);
299
352
  text-transform: uppercase;
300
353
  letter-spacing: 0.03em;
301
- margin-top: 1.1rem;
354
+ margin-top: 1rem;
302
355
  margin-bottom: 0.8rem;
303
356
  }
304
357
  {% endblock long_text_styles %}
@@ -339,13 +392,21 @@ tbody tr:nth-child(even) {
339
392
 
340
393
  /* Container for wide tables to allow tables to break out of parent width. */
341
394
  .table-container {
342
- {# max-width: calc(100vw - 6rem); #}
343
395
  position: relative;
344
- left: 50%;
345
- transform: translateX(-50%);
346
396
  box-sizing: border-box;
347
397
  margin: 2rem 0;
348
398
  background-color: var(--color-bg-solid);
399
+ /* Default: center tables within their container */
400
+ left: 50%;
401
+ transform: translateX(-50%);
402
+ }
403
+
404
+ /* When TOC is present, simplify table container positioning */
405
+ .content-with-toc.has-toc .table-container {
406
+ /* Within grid layout, position relative to the grid column */
407
+ left: 50%;
408
+ transform: translateX(-50%);
409
+ /* Let the table width be controlled by the responsive styles */
349
410
  }
350
411
  {% endblock table_styles %}
351
412
 
@@ -399,16 +460,50 @@ sup {
399
460
 
400
461
  /* Apply shadow to long-text containers on larger screens */
401
462
  .long-text {
463
+ border: 1px solid var(--color-hint-gentle);
402
464
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 -2px 6px -1px rgba(0, 0, 0, 0.07);
403
465
  }
404
466
  /* But remove shadow when wrapped in no-shadow class */
405
467
  .no-shadow .long-text {
406
- box-shadow: none !important;
468
+ box-shadow: none;
469
+ border: none;
470
+ }
471
+ }
472
+
473
+ /* Handle TOC layouts specially - tables should bleed within their grid column */
474
+ @media (min-width: 1200px) {
475
+ /* When TOC is present, tables should bleed within the content grid column */
476
+ .content-with-toc.has-toc table {
477
+ /* Calculate available width: full viewport minus TOC column width minus margins */
478
+ width: calc(100vw - var(--toc-width, 16rem) - 8rem);
479
+ max-width: none;
480
+ }
481
+ .content-with-toc.has-toc .table-container {
482
+ width: calc(100vw - var(--toc-width, 16rem) - 8rem);
483
+ max-width: none;
484
+ }
485
+
486
+ /* But ensure tables don't exceed their grid column space */
487
+ .content-with-toc.has-toc .long-text {
488
+ /* The content area needs to allow tables to bleed beyond the text width */
489
+ overflow: visible;
490
+ }
491
+ }
492
+
493
+ /* Medium screens (768px - TOC breakpoint) - no TOC, tables should bleed but not as wide */
494
+ @media (min-width: 768px) and (max-width: 1199px) {
495
+ table {
496
+ width: calc(100vw - 6rem);
497
+ max-width: calc(100vw - 6rem);
498
+ }
499
+ .table-container {
500
+ width: calc(100vw - 6rem);
501
+ max-width: calc(100vw - 6rem);
407
502
  }
408
503
  }
409
504
 
410
505
  /* Make narrower screens more usable for lists and tables. */
411
- @media (max-width: 768px) {
506
+ @media (max-width: 767px) {
412
507
  /* Prevent horizontal scrolling on the body */
413
508
  body {
414
509
  overflow-x: hidden;
@@ -422,7 +517,7 @@ sup {
422
517
 
423
518
  /* Make table containers scrollable without affecting page layout */
424
519
  .table-container {
425
- max-width: 100%;
520
+ max-width: calc(100vw - 3rem);
426
521
  overflow-x: auto;
427
522
  transform: none;
428
523
  left: 0;
@@ -433,9 +528,10 @@ sup {
433
528
 
434
529
  table {
435
530
  font-size: var(--font-size-smaller);
436
- /* Tables can be wider than container */
531
+ /* Tables can be wider than container on mobile */
437
532
  width: auto;
438
533
  min-width: 100%;
534
+ max-width: none;
439
535
  }
440
536
 
441
537
  ul, ol {
@@ -5,10 +5,32 @@
5
5
  {% block meta %}
6
6
  <meta charset="UTF-8" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <meta name="color-scheme" content="light dark">
8
9
  {% endblock meta %}
9
10
 
10
11
  {% block title %}<title>{{ title }}</title>{% endblock title %}
11
12
 
13
+ {% block dark_mode_script %}
14
+ <script>
15
+ // Set theme before body renders to prevent flash of unstyled content
16
+ function applyTheme(theme) {
17
+ document.documentElement.dataset.theme = theme;
18
+ localStorage.setItem('theme', theme);
19
+ }
20
+
21
+ // If theme toggle is enabled, respect stored preference or system preference.
22
+ // Otherwise default to light mode.
23
+ {% if enable_themes %}
24
+ const storedTheme = localStorage.getItem('theme');
25
+ const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
26
+ const initialTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
27
+ {% else %}
28
+ const initialTheme = 'light';
29
+ {% endif %}
30
+ applyTheme(initialTheme);
31
+ </script>
32
+ {% endblock dark_mode_script %}
33
+
12
34
  {% block head_basic %}
13
35
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
36
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -35,6 +57,67 @@
35
57
  {% block head_extra %}{% endblock head_extra %}
36
58
 
37
59
  <style>
60
+
61
+ body {
62
+ background: var(--color-bg);
63
+ color: var(--color-text);
64
+ transition: background 0.4s ease-in-out, color 0.4s ease-in-out;
65
+ }
66
+
67
+ .button {
68
+ color: var(--color-hint);
69
+ background: var(--color-bg);
70
+ border: none;
71
+ padding: 0;
72
+ border-radius: 0.3rem;
73
+ cursor: pointer;
74
+ font-size: 1rem;
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ width: 2.5rem;
79
+ height: 2.5rem;
80
+
81
+ /* Separate transitions for theme vs interaction */
82
+ transition: background-color 0.4s ease-in-out,
83
+ color 0.4s ease-in-out,
84
+ transform 0.2s ease-in-out,
85
+ box-shadow 0.2s ease-in-out;
86
+ }
87
+
88
+ .button:hover {
89
+ background: var(--color-hover-bg);
90
+ color: var(--color-primary);
91
+ }
92
+
93
+ .button svg {
94
+ width: 1rem;
95
+ height: 1rem;
96
+ transition: inherit; /* Inherit the color transition */
97
+ }
98
+
99
+ /* Dark mode styles for buttons */
100
+ [data-theme="dark"] .button {
101
+ color: var(--color-primary-light);
102
+ }
103
+
104
+ [data-theme="dark"] .button:hover {
105
+ background: var(--color-hover-bg);
106
+ color: var(--color-bright);
107
+ }
108
+
109
+ /* Positioning class for fixed buttons */
110
+ .fixed-button {
111
+ position: fixed;
112
+ top: 1rem;
113
+ }
114
+
115
+ /* Specific positioning and z-index for theme toggle */
116
+ .theme-toggle {
117
+ right: 1rem;
118
+ z-index: 100;
119
+ }
120
+
38
121
  {% block font_faces %}
39
122
  /* https://fontsource.org/fonts/pt-serif/cdn */
40
123
  /* pt-serif-latin-400-normal */
@@ -108,6 +191,14 @@
108
191
  </head>
109
192
 
110
193
  <body>
194
+ {% block theme_toggle %}
195
+ {% if show_theme_toggle %}
196
+ <button class="button fixed-button theme-toggle" aria-label="toggle dark mode">
197
+ <i data-feather="moon"></i>
198
+ </button>
199
+ {% endif %}
200
+ {% endblock theme_toggle %}
201
+
111
202
  {% block body_header %}{% endblock body_header %}
112
203
 
113
204
  {% block main_content %}
@@ -119,6 +210,63 @@
119
210
  {% block scripts %}
120
211
  <script>
121
212
  document.addEventListener('DOMContentLoaded', () => {
213
+ // Add copy buttons to code blocks
214
+ document.querySelectorAll('pre').forEach(pre => {
215
+ // Skip if already has a copy button (check parent for wrapper)
216
+ if (pre.parentElement.classList.contains('code-block-wrapper')) {
217
+ return;
218
+ }
219
+
220
+ // Create wrapper div
221
+ const wrapper = document.createElement('div');
222
+ wrapper.className = 'code-block-wrapper';
223
+
224
+ // Insert wrapper before pre and move pre inside it
225
+ pre.parentNode.insertBefore(wrapper, pre);
226
+ wrapper.appendChild(pre);
227
+
228
+ // Create copy button
229
+ const copyButton = document.createElement('button');
230
+ copyButton.className = 'code-copy-button';
231
+ copyButton.setAttribute('aria-label', 'Copy code');
232
+
233
+ const copyIcon = typeof feather !== 'undefined' ? feather.icons.copy.toSvg() : '<i data-feather="copy"></i>';
234
+ const checkIcon = typeof feather !== 'undefined' ? feather.icons.check.toSvg() : '<i data-feather="check"></i>';
235
+
236
+ copyButton.innerHTML = copyIcon;
237
+ copyButton.addEventListener('click', async () => {
238
+ const codeElement = pre.querySelector('code') || pre;
239
+ const textToCopy = (codeElement.textContent || codeElement.innerText).trim();
240
+
241
+ // Works on modern browsers.
242
+ navigator.clipboard.writeText(textToCopy).then(() => {
243
+ copyButton.innerHTML = checkIcon;
244
+ copyButton.classList.add('copied');
245
+
246
+ // Reset after 2 seconds
247
+ setTimeout(() => {
248
+ copyButton.innerHTML = copyIcon;
249
+ copyButton.classList.remove('copied');
250
+ }, 2000);
251
+ }).catch(err => {
252
+ console.error('Failed to copy text: ', err);
253
+ });
254
+ });
255
+
256
+ // Add button to wrapper, not to pre
257
+ wrapper.appendChild(copyButton);
258
+ });
259
+
260
+ // Theme toggle (if present on page)
261
+ const themeToggleButton = document.querySelector('.theme-toggle');
262
+ if (themeToggleButton) {
263
+ themeToggleButton.addEventListener('click', () => {
264
+ const currentTheme = document.documentElement.dataset.theme;
265
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
266
+ applyTheme(newTheme);
267
+ });
268
+ }
269
+
122
270
  // Send messages to the parent window, in case we are in a viewport where that matters
123
271
  // (e.g. an iframe tooltip).
124
272
  // Request a resize of the parent viewport. This iframe size message format isn't
@@ -159,6 +307,11 @@
159
307
  }
160
308
  });
161
309
  });
310
+
311
+ // Initialize Feather icons once at the end, after all DOM manipulation.
312
+ if (typeof feather !== 'undefined') {
313
+ feather.replace();
314
+ }
162
315
  });
163
316
 
164
317
  // Double-click to expand (e.g. expand tooltip to popover).
@@ -179,8 +332,9 @@
179
332
  }
180
333
  });
181
334
 
182
- {% block scripts_extra %}{% endblock scripts_extra %}
335
+
183
336
  </script>
337
+ {% block scripts_extra %}{% endblock scripts_extra %}
184
338
  {% endblock scripts %}
185
339
 
186
340
  {% block analytics %}{% endblock analytics %}
@@ -10,7 +10,7 @@
10
10
  }
11
11
 
12
12
  .item-header {
13
- padding: 0.5rem 1rem 0rem 1rem;
13
+ padding: 0 1rem 0rem 1rem;
14
14
  border-bottom: 1px solid var(--color-border-hint);
15
15
  }
16
16
 
@@ -109,7 +109,7 @@ h1.item-title {
109
109
 
110
110
  .item-file-info {
111
111
  {# font-family: var(--font-sans); #}
112
- padding: 0.5rem 0;
112
+ padding: 0;
113
113
  {# font-size: var(--font-size-mono-small); #}
114
114
  word-break: break-word;
115
115
  }
@@ -118,6 +118,8 @@ h1.item-title {
118
118
  font-family: var(--font-sans);
119
119
  font-size: var(--font-size-small);
120
120
  letter-spacing: 0;
121
+ border: none;
122
+ background-color: transparent;
121
123
  }
122
124
 
123
125
  .item-url {