@kirchdev/duxt 0.3.4 → 0.3.5-dev-main.44-5337cc1
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/README.md +24 -9
- package/app/app.vue +17 -11
- package/app/assets/css/duxt.css +47 -6
- package/app/components/DuxtAnnouncements.vue +117 -0
- package/app/components/DuxtBrunoEntries.vue +62 -0
- package/app/components/DuxtCodeBlock.vue +18 -34
- package/app/components/DuxtCodeToolbar.vue +14 -0
- package/app/components/DuxtCopyButton.vue +33 -0
- package/app/components/DuxtCopyPage.vue +32 -30
- package/app/components/DuxtFooter.vue +39 -8
- package/app/components/DuxtHeader.vue +107 -48
- package/app/components/DuxtLandingProse.vue +1 -1
- package/app/components/DuxtLinkLabel.vue +36 -0
- package/app/components/DuxtLiveWindow.vue +8 -8
- package/app/components/DuxtLocale.vue +10 -1
- package/app/components/DuxtNavigation.vue +2 -2
- package/app/components/DuxtOpenApiClient.vue +285 -90
- package/app/components/DuxtOpenApiMedia.vue +30 -18
- package/app/components/DuxtOpenApiSchema.vue +10 -7
- package/app/components/DuxtPageFeedback.vue +7 -0
- package/app/components/DuxtPageInfo.vue +15 -12
- package/app/components/DuxtPageNav.vue +41 -27
- package/app/components/DuxtPreviewSkeleton.vue +4 -4
- package/app/components/DuxtProgress.vue +2 -14
- package/app/components/DuxtSearch.vue +230 -90
- package/app/components/DuxtSearchInput.vue +70 -0
- package/app/components/DuxtSections.vue +11 -1
- package/app/components/DuxtSegmented.vue +64 -0
- package/app/components/DuxtShortcuts.vue +16 -11
- package/app/components/DuxtShortcutsTrigger.vue +49 -0
- package/app/components/DuxtSkipLink.vue +1 -1
- package/app/components/DuxtToc.vue +16 -10
- package/app/components/DuxtTranslationBanner.vue +1 -1
- package/app/components/DuxtVersion.vue +53 -12
- package/app/components/DuxtVersionBanner.vue +5 -5
- package/app/components/content/AccordionItem.vue +2 -2
- package/app/components/content/BrunoOverview.vue +260 -0
- package/app/components/content/BrunoRequest.vue +141 -0
- package/app/components/content/BrunoRequests.vue +104 -0
- package/app/components/content/Callout.vue +5 -5
- package/app/components/content/ChangelogReleases.vue +161 -40
- package/app/components/content/CodeGroup.vue +41 -37
- package/app/components/content/Field.vue +1 -1
- package/app/components/content/FileTree.vue +1 -1
- package/app/components/content/OpenApiOperation.vue +4 -4
- package/app/components/content/OpenApiOperations.vue +1 -1
- package/app/components/content/OpenApiOverview.vue +1 -1
- package/app/components/content/PackageManagers.vue +42 -58
- package/app/components/content/PageCards.vue +7 -4
- package/app/components/content/Preview.vue +15 -39
- package/app/components/content/ProseImg.vue +596 -49
- package/app/components/content/Since.vue +1 -1
- package/app/components/content/Steps.vue +6 -4
- package/app/components/content/TabGroup.vue +13 -12
- package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
- package/app/components/ui/command/CommandShortcut.vue +1 -1
- package/app/components/ui/dialog/DialogContent.vue +2 -2
- package/app/components/ui/dialog/DialogHeader.vue +1 -1
- package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
- package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
- package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
- package/app/components/ui/select/SelectItem.vue +2 -2
- package/app/components/ui/sheet/SheetContent.vue +29 -3
- package/app/components/ui/sidebar/Sidebar.vue +28 -4
- package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarInset.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
- package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
- package/app/components/ui/sidebar/index.ts +10 -2
- package/app/components/ui/sidebar/utils.ts +0 -1
- package/app/components/ui/sonner/Sonner.vue +18 -0
- package/app/components/ui/tabs/TabsList.vue +13 -3
- package/app/components/ui/tabs/TabsTrigger.vue +24 -3
- package/app/components/ui/tooltip/TooltipContent.vue +2 -2
- package/app/composables/useActiveHeading.ts +3 -25
- package/app/composables/useDuxtAnalytics.ts +50 -0
- package/app/composables/useDuxtAnimatedHeight.ts +8 -38
- package/app/composables/useDuxtAnnouncements.ts +153 -0
- package/app/composables/useDuxtCopy.ts +51 -0
- package/app/composables/useDuxtDirection.ts +23 -0
- package/app/composables/useDuxtNavigation.ts +28 -8
- package/app/composables/useDuxtPath.ts +3 -5
- package/app/composables/useDuxtSearch.ts +87 -35
- package/app/composables/useDuxtSection.ts +15 -1
- package/app/composables/useDuxtSectionRow.ts +6 -5
- package/app/composables/useDuxtShortcuts.ts +319 -20
- package/app/composables/useDuxtVersion.ts +57 -6
- package/app/composables/useDuxtViewportMeasure.ts +37 -0
- package/app/composables/useFuzzySearch.ts +9 -4
- package/app/composables/useRecentPages.ts +14 -17
- package/app/error.vue +112 -74
- package/app/layouts/changelog.vue +9 -1
- package/app/layouts/default.vue +9 -0
- package/app/layouts/docs.vue +9 -1
- package/app/layouts/reference.vue +9 -1
- package/app/middleware/duxt-section-layout.global.ts +1 -3
- package/app/pages/[...slug].vue +146 -35
- package/app/pages/index.vue +15 -23
- package/app/types/duxt.d.ts +312 -4
- package/app/utils/analytics.ts +91 -0
- package/app/utils/announcements.ts +205 -0
- package/app/utils/changelog.ts +0 -28
- package/app/utils/code-highlight.ts +3 -1
- package/app/utils/contributor-avatar.ts +27 -0
- package/app/utils/direction.ts +43 -0
- package/app/utils/duxt-config.ts +43 -0
- package/app/utils/duxt-text.ts +10 -0
- package/app/utils/format-date.ts +28 -0
- package/app/utils/generated-sections.ts +68 -12
- package/app/utils/generated-toc.ts +14 -7
- package/app/utils/locale-path.ts +43 -0
- package/app/utils/navigation-tree.ts +51 -1
- package/app/utils/nearest-page.ts +8 -0
- package/app/utils/openapi.ts +16 -11
- package/app/utils/page-controls.ts +215 -0
- package/app/utils/pill.ts +61 -0
- package/app/utils/prose-image.ts +128 -0
- package/app/utils/search-context.ts +110 -0
- package/app/utils/search-display.ts +248 -0
- package/app/utils/section-scope.ts +65 -4
- package/app/utils/stored-json.ts +31 -0
- package/app/utils/version-choices.ts +6 -13
- package/app/utils/version-paths.ts +21 -0
- package/bin/duxt-cache-key.mjs +61 -0
- package/bin/duxt-og-cache.mjs +58 -0
- package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
- package/bruno-model.ts +169 -0
- package/bruno-parse.ts +656 -0
- package/bruno-zip.ts +195 -0
- package/cli.ts +240 -0
- package/duxt-app-config.ts +18 -1
- package/frontmatter.ts +49 -0
- package/git-contributors.ts +322 -0
- package/i18n/locales/de/duxt/announcement.json +7 -0
- package/i18n/locales/de/duxt/bruno.json +25 -0
- package/i18n/locales/de/duxt/changelog.json +1 -1
- package/i18n/locales/de/duxt/code.json +2 -1
- package/i18n/locales/de/duxt/page.json +9 -0
- package/i18n/locales/de/duxt/shortcuts.json +1 -0
- package/i18n/locales/de/duxt/theme.json +2 -1
- package/i18n/locales/en/duxt/announcement.json +7 -0
- package/i18n/locales/en/duxt/bruno.json +25 -0
- package/i18n/locales/en/duxt/changelog.json +1 -1
- package/i18n/locales/en/duxt/code.json +2 -1
- package/i18n/locales/en/duxt/page.json +9 -0
- package/i18n/locales/en/duxt/shortcuts.json +1 -0
- package/i18n/locales/en/duxt/theme.json +2 -1
- package/i18n/locales/es/duxt/announcement.json +7 -0
- package/i18n/locales/es/duxt/bruno.json +25 -0
- package/i18n/locales/es/duxt/changelog.json +1 -1
- package/i18n/locales/es/duxt/code.json +2 -1
- package/i18n/locales/es/duxt/page.json +9 -0
- package/i18n/locales/es/duxt/shortcuts.json +1 -0
- package/i18n/locales/es/duxt/theme.json +2 -1
- package/i18n/locales/fr/duxt/announcement.json +7 -0
- package/i18n/locales/fr/duxt/bruno.json +25 -0
- package/i18n/locales/fr/duxt/changelog.json +1 -1
- package/i18n/locales/fr/duxt/code.json +2 -1
- package/i18n/locales/fr/duxt/page.json +9 -0
- package/i18n/locales/fr/duxt/shortcuts.json +1 -0
- package/i18n/locales/fr/duxt/theme.json +2 -1
- package/i18n/locales/pt/duxt/announcement.json +7 -0
- package/i18n/locales/pt/duxt/bruno.json +25 -0
- package/i18n/locales/pt/duxt/changelog.json +1 -1
- package/i18n/locales/pt/duxt/code.json +2 -1
- package/i18n/locales/pt/duxt/page.json +9 -0
- package/i18n/locales/pt/duxt/shortcuts.json +1 -0
- package/i18n/locales/pt/duxt/theme.json +2 -1
- package/modules/bruno.ts +118 -0
- package/modules/config.ts +26 -0
- package/modules/devtools.ts +2 -3
- package/modules/git-meta.ts +16 -47
- package/modules/search-records.ts +132 -0
- package/nuxt.config.ts +81 -7
- package/og-image-cache.ts +742 -0
- package/package.json +42 -10
- package/public/devtools/checks.html +2 -2
- package/public/devtools/config.html +59 -4
- package/public/devtools/pages.html +8 -1
- package/public/devtools/paths.html +1 -1
- package/public/devtools/sources.html +2 -2
- package/public/devtools/versions.html +3 -0
- package/report.ts +28 -19
- package/search-records.ts +380 -0
- package/section-input.ts +142 -0
- package/section-markdown.ts +67 -0
- package/section-reports.ts +10 -4
- package/sections-bruno.ts +571 -0
- package/sections-changelog.ts +76 -43
- package/sections-openapi.ts +12 -83
- package/sections-resolve.ts +174 -8
- package/sections.ts +33 -10
- package/server/devtools/shell.ts +1 -1
- package/server/mcp/tools/list-pages.ts +77 -36
- package/server/mcp/tools/list-versions.ts +84 -0
- package/server/mcp/tools/read-page.ts +80 -26
- package/server/mcp/tools/search-docs.ts +87 -53
- package/server/middleware/raw-markdown.ts +1 -1
- package/server/routes/llms-full.txt.get.ts +6 -8
- package/server/routes/llms.txt.get.ts +1 -1
- package/server/utils/duxt-server-text.ts +0 -13
- package/server/utils/llms-pages.ts +24 -9
- package/server/utils/mcp-docs.ts +577 -0
- package/sources-cache.ts +376 -0
- package/sources-git.ts +186 -31
- package/sources-resolve.ts +59 -1
- package/sources.ts +122 -13
- package/tfplugindocs.ts +158 -0
package/app/types/duxt.d.ts
CHANGED
|
@@ -206,6 +206,10 @@ declare global {
|
|
|
206
206
|
* then share the default URL prefix of a versioned documentation source.
|
|
207
207
|
*/
|
|
208
208
|
content?: boolean;
|
|
209
|
+
/** Per-source conventions for files that are not documentation pages. */
|
|
210
|
+
exclude?: { drafts?: string; partials?: string };
|
|
211
|
+
/** A layer-owned Markdown dialect, such as tfplugindocs. */
|
|
212
|
+
flavor?: 'tfplugindocs';
|
|
209
213
|
/** `owner/name` or a full git URL. Omitted means this repository. */
|
|
210
214
|
repo?: string;
|
|
211
215
|
/** Refs to publish as versions. Omitted means the current checkout. */
|
|
@@ -238,6 +242,23 @@ declare global {
|
|
|
238
242
|
version?: string;
|
|
239
243
|
/** Shown in the version switcher and used in the URL; defaults to the ref. */
|
|
240
244
|
label?: DuxtText;
|
|
245
|
+
/**
|
|
246
|
+
* What to CALL this source where the site names it to a reader — today the
|
|
247
|
+
* search dialog's result captions.
|
|
248
|
+
*
|
|
249
|
+
* DISPLAY ONLY. It reaches no collection name, no URL prefix, no version
|
|
250
|
+
* and no ranking, which is what separates it from `label` and `slug`: those
|
|
251
|
+
* two are addresses that happen to be readable, and a translated address is
|
|
252
|
+
* not a stable URL. This is a name and nothing else, so it may be prose and
|
|
253
|
+
* it may be translated.
|
|
254
|
+
*
|
|
255
|
+
* Unset, the name falls back to the source's own segment and then to
|
|
256
|
+
* `duxt.title` — itself the localized "Documentation" label on a site that
|
|
257
|
+
* sets no title. Worth setting where the segment is an abbreviation (`tf`,
|
|
258
|
+
* `sdk`, `api`): a result captioned with one tells a reader which URL they
|
|
259
|
+
* are in rather than which project they are about to open.
|
|
260
|
+
*/
|
|
261
|
+
name?: DuxtText;
|
|
241
262
|
/**
|
|
242
263
|
* Segment used in the URL for this source; defaults to the repository name.
|
|
243
264
|
*
|
|
@@ -404,7 +425,7 @@ declare global {
|
|
|
404
425
|
* The layout its pages render in. A name bound here is public surface.
|
|
405
426
|
*
|
|
406
427
|
* A function where the declaration's own options decide it — a changelog
|
|
407
|
-
* split into a page per release draws
|
|
428
|
+
* split into a page per release draws a layout of its own, the same file
|
|
408
429
|
* rendered whole is an ordinary docs page.
|
|
409
430
|
*/
|
|
410
431
|
layout?:
|
|
@@ -434,6 +455,8 @@ declare global {
|
|
|
434
455
|
prefix: string;
|
|
435
456
|
repo?: string;
|
|
436
457
|
version?: string;
|
|
458
|
+
/** The source's display name, as written — see `DuxtSourceInput.name`. */
|
|
459
|
+
name?: DuxtText;
|
|
437
460
|
isDefault: boolean;
|
|
438
461
|
/** Where the pages came from — what "Edit this page" links back to. */
|
|
439
462
|
repository?: string;
|
|
@@ -447,6 +470,8 @@ declare global {
|
|
|
447
470
|
isDefaultLocale: boolean;
|
|
448
471
|
status: DuxtSourceStatusInput;
|
|
449
472
|
history: boolean;
|
|
473
|
+
/** The layer-owned Markdown dialect this collection is normalised from. */
|
|
474
|
+
flavor?: 'tfplugindocs';
|
|
450
475
|
/**
|
|
451
476
|
* Present when this collection is a generated section rather than a docs
|
|
452
477
|
* tree. `path` is then the artefact itself rather than a folder.
|
|
@@ -501,6 +526,77 @@ declare global {
|
|
|
501
526
|
pageIcon?: string;
|
|
502
527
|
}
|
|
503
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Where an announcement is drawn.
|
|
531
|
+
*
|
|
532
|
+
* Three, because they answer three different questions about how loud a
|
|
533
|
+
* notice is. `above-header` sits at the browser edge over everything, which
|
|
534
|
+
* is what a release or an outage wants; `below-header` keeps the navbar at
|
|
535
|
+
* the top of the window and puts the notice over the sidebars and the page;
|
|
536
|
+
* `above-content` is quietest — it sits in the document column with the
|
|
537
|
+
* version and translation banners, and scrolls with the page.
|
|
538
|
+
*/
|
|
539
|
+
type DuxtAnnouncementPlacement =
|
|
540
|
+
| 'above-header'
|
|
541
|
+
| 'below-header'
|
|
542
|
+
| 'above-content';
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* How many announcements a placement may draw at once.
|
|
546
|
+
*
|
|
547
|
+
* Three shapes for three answers: a number caps EVERY placement at that many
|
|
548
|
+
* — one release notice above the header and one outage below it, not one
|
|
549
|
+
* between them — an object caps each placement on its own, and `null` (or no
|
|
550
|
+
* value at all) means unlimited. A placement the object says nothing about is
|
|
551
|
+
* unlimited too.
|
|
552
|
+
*
|
|
553
|
+
* The cap is on what is DRAWN, not on what is due: dismissing the notice at
|
|
554
|
+
* the top of a capped placement lets the next one take its slot, so a queue
|
|
555
|
+
* of announcements is read rather than silently dropped.
|
|
556
|
+
*/
|
|
557
|
+
type DuxtAnnouncementMaxVisible =
|
|
558
|
+
| number
|
|
559
|
+
| null
|
|
560
|
+
| Partial<Record<DuxtAnnouncementPlacement, number | null>>;
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* One site-wide notice, drawn around the page rather than in it.
|
|
564
|
+
*
|
|
565
|
+
* Deliberately NOT the version and translation banners beside it: those
|
|
566
|
+
* describe the page a reader is on and are the layer's to decide. This is the
|
|
567
|
+
* site's own sentence — a release, a migration window, a maintenance
|
|
568
|
+
* notice — and duxt knows nothing about it until a consumer writes one.
|
|
569
|
+
*/
|
|
570
|
+
interface DuxtAnnouncement {
|
|
571
|
+
/**
|
|
572
|
+
* The dismissal identity, and the only reason to set one.
|
|
573
|
+
*
|
|
574
|
+
* Locale-independent, so a reader who dismissed the notice in English does
|
|
575
|
+
* not meet it again in German. Omit it and duxt derives the identity from
|
|
576
|
+
* the announcement's own content — see `duxtAnnouncementKey` — which is the
|
|
577
|
+
* right default: rewriting the sentence IS a new announcement. Set it where
|
|
578
|
+
* a typo may be corrected without the notice counting as new, and change it
|
|
579
|
+
* to make an otherwise unchanged announcement visible again.
|
|
580
|
+
*/
|
|
581
|
+
id?: string;
|
|
582
|
+
text: DuxtText;
|
|
583
|
+
/** One call to action. More than one belongs on the page it links to. */
|
|
584
|
+
link?: DuxtLink;
|
|
585
|
+
/** Where the notice is drawn. Defaults to `above-header`. */
|
|
586
|
+
placement?: DuxtAnnouncementPlacement;
|
|
587
|
+
/**
|
|
588
|
+
* When the notice starts and stops showing, as anything `Date` parses —
|
|
589
|
+
* `2026-03-01`, or a full ISO timestamp with an offset.
|
|
590
|
+
*
|
|
591
|
+
* Read in the BROWSER, against the reader's own clock, so an announcement
|
|
592
|
+
* opens and closes on time without a rebuild and without the page being
|
|
593
|
+
* reloaded. A date with no time zone is local to the reader, which is
|
|
594
|
+
* usually what a maintenance window means; write an offset where it is not.
|
|
595
|
+
*/
|
|
596
|
+
startsAt?: string;
|
|
597
|
+
endsAt?: string;
|
|
598
|
+
}
|
|
599
|
+
|
|
504
600
|
interface DuxtAction extends DuxtLink {
|
|
505
601
|
variant?:
|
|
506
602
|
| 'default'
|
|
@@ -589,8 +685,9 @@ declare global {
|
|
|
589
685
|
* A CONFIG FIELD rather than one of the layer's own strings, and the line
|
|
590
686
|
* between the two is whether a reader sees it. `featuresTitle` is shipped
|
|
591
687
|
* because it is `sr-only` — a name the document outline needs and nobody
|
|
592
|
-
* reads.
|
|
593
|
-
* to the site that wrote the list underneath
|
|
688
|
+
* reads. The heading over the supporting capabilities is prose on the page,
|
|
689
|
+
* and prose on the page belongs to the site that wrote the list underneath
|
|
690
|
+
* it.
|
|
594
691
|
*
|
|
595
692
|
* Unset, the list draws no heading and its entries are paragraphs rather
|
|
596
693
|
* than headings, so the outline gains nothing to explain.
|
|
@@ -763,7 +860,11 @@ declare global {
|
|
|
763
860
|
interface DuxtDemoFile {
|
|
764
861
|
/** The tab's label and the block's header — a file name, or a command. */
|
|
765
862
|
name?: string;
|
|
766
|
-
/**
|
|
863
|
+
/**
|
|
864
|
+
* A Shiki language id. Highlighted at runtime: the build adds every
|
|
865
|
+
* language a showcase names to the grammars it ships. A name Shiki has no
|
|
866
|
+
* grammar for (`text`) renders as plain text.
|
|
867
|
+
*/
|
|
767
868
|
language?: string;
|
|
768
869
|
code: string;
|
|
769
870
|
}
|
|
@@ -809,6 +910,132 @@ declare global {
|
|
|
809
910
|
alt?: DuxtText;
|
|
810
911
|
}
|
|
811
912
|
|
|
913
|
+
/**
|
|
914
|
+
* What every analytics event says about WHERE it happened, and nothing else.
|
|
915
|
+
*
|
|
916
|
+
* A closed shape rather than a free record, because this is the privacy
|
|
917
|
+
* surface: a reader of `duxt.analytics.track` has to be able to see, in one
|
|
918
|
+
* place, the whole of what duxt is willing to say about them. Nothing here
|
|
919
|
+
* identifies a browser or a person, and nothing is added per event — the
|
|
920
|
+
* event's own fields carry what it is, this carries where it was.
|
|
921
|
+
*
|
|
922
|
+
* Nested rather than spread into each event on purpose: `api-request` has a
|
|
923
|
+
* `path` of its own — the operation's declared template — and flattening the
|
|
924
|
+
* two would put the page and the endpoint under one name.
|
|
925
|
+
*/
|
|
926
|
+
interface DuxtAnalyticsContext {
|
|
927
|
+
/** The documentation path, without the locale prefix, query or fragment. */
|
|
928
|
+
path: string;
|
|
929
|
+
/** The locale the page was read in. */
|
|
930
|
+
locale: string;
|
|
931
|
+
/** Which source the page came out of, where the site has more than one. */
|
|
932
|
+
source?: string;
|
|
933
|
+
/** Which version, where the source has more than one. */
|
|
934
|
+
version?: string;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** The class of an HTTP status, which is all an event reports of one. */
|
|
938
|
+
type DuxtAnalyticsStatusClass = '1xx' | '2xx' | '3xx' | '4xx' | '5xx';
|
|
939
|
+
|
|
940
|
+
/** Which layer-owned code example was copied. */
|
|
941
|
+
type DuxtAnalyticsCopyKind =
|
|
942
|
+
| 'code'
|
|
943
|
+
| 'code-group'
|
|
944
|
+
| 'package-manager'
|
|
945
|
+
| 'request-sample';
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* One settled search.
|
|
949
|
+
*
|
|
950
|
+
* Emitted once per debounced, non-empty query that actually settled — a run
|
|
951
|
+
* the reader typed past never arrives, because its results were never shown.
|
|
952
|
+
* A search that found nothing is emitted like any other: "no result" is the
|
|
953
|
+
* finding a documentation site most needs.
|
|
954
|
+
*/
|
|
955
|
+
interface DuxtAnalyticsSearchEvent {
|
|
956
|
+
name: 'search';
|
|
957
|
+
/** The term as the reader typed it. */
|
|
958
|
+
query: string;
|
|
959
|
+
/** How many hits were shown. */
|
|
960
|
+
results: number;
|
|
961
|
+
/** True where full-text found nothing and these are near-misses. */
|
|
962
|
+
approximate: boolean;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** A hit the reader opened, by position rather than by content. */
|
|
966
|
+
interface DuxtAnalyticsSearchResultEvent {
|
|
967
|
+
name: 'search-result';
|
|
968
|
+
/** The term the hit was found with. */
|
|
969
|
+
query: string;
|
|
970
|
+
/** Where it sat in the ranked list, counting from one. */
|
|
971
|
+
rank: number;
|
|
972
|
+
/** The documentation path it leads to — never its rendered text. */
|
|
973
|
+
to: string;
|
|
974
|
+
/** Which collection it came out of, where a site has more than one. */
|
|
975
|
+
collection?: string;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* A copied code example.
|
|
980
|
+
*
|
|
981
|
+
* The copied TEXT is never carried. "Copy page as Markdown" is not in this
|
|
982
|
+
* family at all and emits nothing: it is the page, not an example in it.
|
|
983
|
+
*/
|
|
984
|
+
interface DuxtAnalyticsCopyEvent {
|
|
985
|
+
name: 'copy';
|
|
986
|
+
kind: DuxtAnalyticsCopyKind;
|
|
987
|
+
/** The example's language, where the block declares one. */
|
|
988
|
+
language?: string;
|
|
989
|
+
/** Which manager a command block was copied for. */
|
|
990
|
+
manager?: DuxtPackageManager;
|
|
991
|
+
/** Which request sample — its configured id, never its code. */
|
|
992
|
+
sample?: string;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* A try-it request, after it settled.
|
|
997
|
+
*
|
|
998
|
+
* Everything here is DECLARED — the operation as the document names it —
|
|
999
|
+
* never what the reader typed: not the server they chose, not the concrete
|
|
1000
|
+
* URL, not a header, not a credential, not a body. The client's whole promise
|
|
1001
|
+
* is that a token stays in the component, and an event carrying one would
|
|
1002
|
+
* break it from the other side.
|
|
1003
|
+
*/
|
|
1004
|
+
interface DuxtAnalyticsApiRequestEvent {
|
|
1005
|
+
name: 'api-request';
|
|
1006
|
+
/** The document's own `operationId`, where it declares one. */
|
|
1007
|
+
operation?: string;
|
|
1008
|
+
/** The declared method. */
|
|
1009
|
+
method: string;
|
|
1010
|
+
/** The declared path template — `/pets/{id}`, not the URL that was called. */
|
|
1011
|
+
path: string;
|
|
1012
|
+
/** Whether the request came back at all. */
|
|
1013
|
+
outcome: 'response' | 'failed';
|
|
1014
|
+
/** The class of the status, absent where nothing came back. */
|
|
1015
|
+
statusClass?: DuxtAnalyticsStatusClass;
|
|
1016
|
+
/** Milliseconds the request itself took, without the button's own floor. */
|
|
1017
|
+
duration: number;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** The answer to "Was this page helpful?". */
|
|
1021
|
+
interface DuxtAnalyticsFeedbackEvent {
|
|
1022
|
+
name: 'feedback';
|
|
1023
|
+
helpful: boolean;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** An event before `useDuxtAnalytics` stamps the context onto it. */
|
|
1027
|
+
type DuxtAnalyticsEventInput =
|
|
1028
|
+
| DuxtAnalyticsSearchEvent
|
|
1029
|
+
| DuxtAnalyticsSearchResultEvent
|
|
1030
|
+
| DuxtAnalyticsCopyEvent
|
|
1031
|
+
| DuxtAnalyticsApiRequestEvent
|
|
1032
|
+
| DuxtAnalyticsFeedbackEvent;
|
|
1033
|
+
|
|
1034
|
+
/** What `duxt.analytics.track` is handed. Discriminated by `name`. */
|
|
1035
|
+
type DuxtAnalyticsEvent = DuxtAnalyticsEventInput & {
|
|
1036
|
+
context: DuxtAnalyticsContext;
|
|
1037
|
+
};
|
|
1038
|
+
|
|
812
1039
|
interface DuxtConfig {
|
|
813
1040
|
title: DuxtText;
|
|
814
1041
|
/**
|
|
@@ -863,6 +1090,87 @@ declare global {
|
|
|
863
1090
|
pageIcon?: string;
|
|
864
1091
|
/** Shown as a badge beside the title. */
|
|
865
1092
|
version?: string;
|
|
1093
|
+
/** Model hand-off entries, in display order. */
|
|
1094
|
+
copy?: { models?: { label: DuxtText; icon: string; url: string }[] };
|
|
1095
|
+
/** URL template for contributor avatars; `{username}` is replaced. */
|
|
1096
|
+
contributors?: { avatarUrl?: string };
|
|
1097
|
+
/** Generated-outline and active-heading controls. */
|
|
1098
|
+
toc?: { depth?: number; scrollOffset?: number };
|
|
1099
|
+
/** Search behaviour that depends on a site's corpus. */
|
|
1100
|
+
search?: {
|
|
1101
|
+
fuzzy?: {
|
|
1102
|
+
threshold?: number;
|
|
1103
|
+
minMatchCharLength?: number;
|
|
1104
|
+
limit?: number;
|
|
1105
|
+
};
|
|
1106
|
+
/** `0` disables the visible recent-pages list. */
|
|
1107
|
+
recentPages?: number;
|
|
1108
|
+
};
|
|
1109
|
+
/** Separate limits for derived examples and schema-tree rendering. */
|
|
1110
|
+
openapi?: { exampleDepth?: number; schemaDepth?: number };
|
|
1111
|
+
/**
|
|
1112
|
+
* What the site allows duxt to bind globally.
|
|
1113
|
+
*
|
|
1114
|
+
* Not the bindings themselves: those are the layer's, so the sheet, the
|
|
1115
|
+
* handlers and the guards cannot come to disagree about them.
|
|
1116
|
+
*/
|
|
1117
|
+
shortcuts?: {
|
|
1118
|
+
/**
|
|
1119
|
+
* Whether a bare keystroke — `?`, `[`, `]` — is bound at all.
|
|
1120
|
+
*
|
|
1121
|
+
* `false` leaves `⌘/Ctrl+K`. Worth turning off where a site's own pages
|
|
1122
|
+
* put something under an unmodified character, or where readers arrive
|
|
1123
|
+
* through switch access, voice control or dictation — all of which emit
|
|
1124
|
+
* bare characters the reader never meant as a command.
|
|
1125
|
+
*/
|
|
1126
|
+
singleCharacter?: boolean;
|
|
1127
|
+
};
|
|
1128
|
+
/**
|
|
1129
|
+
* Site-wide notices, in the order they are drawn.
|
|
1130
|
+
*
|
|
1131
|
+
* Empty by default: an announcement is the site's own sentence, and a layer
|
|
1132
|
+
* that shipped one would put somebody else's words at the top of every page
|
|
1133
|
+
* — the same rule `sections` and `links` follow.
|
|
1134
|
+
*
|
|
1135
|
+
* A list, not a single notice, because a release and a maintenance window
|
|
1136
|
+
* are two announcements and one of them ending should not take the other
|
|
1137
|
+
* with it. `announcementOptions.maxVisible` is how a site keeps that list
|
|
1138
|
+
* from stacking up on screen.
|
|
1139
|
+
*/
|
|
1140
|
+
announcements?: DuxtAnnouncement[];
|
|
1141
|
+
/** How the list above is drawn, as opposed to what is in it. */
|
|
1142
|
+
announcementOptions?: {
|
|
1143
|
+
/** How many a placement may show at once. Unlimited unless set. */
|
|
1144
|
+
maxVisible?: DuxtAnnouncementMaxVisible;
|
|
1145
|
+
};
|
|
1146
|
+
/**
|
|
1147
|
+
* Where reader interactions go, if a site wants them anywhere.
|
|
1148
|
+
*
|
|
1149
|
+
* OFF UNTIL A SITE WRITES `track`, and off is the whole default. duxt ships
|
|
1150
|
+
* no provider, no SDK, no script and no adapter; it transmits nothing,
|
|
1151
|
+
* buffers nothing, persists nothing, sets no cookie and asks for no
|
|
1152
|
+
* consent — because a documentation layer cannot know what a consumer's
|
|
1153
|
+
* privacy policy promises, and a theme that phoned home by default is not
|
|
1154
|
+
* one to publish (ADR 0005, and the same reason `DuxtPageFeedback` has an
|
|
1155
|
+
* event and no backend).
|
|
1156
|
+
*
|
|
1157
|
+
* So this is one function and nothing else. The site's `track` is the only
|
|
1158
|
+
* thing that ever leaves the page, which puts the consent gate, the
|
|
1159
|
+
* provider and the decision to send at all on the side that owns them.
|
|
1160
|
+
*/
|
|
1161
|
+
analytics?: {
|
|
1162
|
+
/**
|
|
1163
|
+
* Called once per reader interaction duxt reports.
|
|
1164
|
+
*
|
|
1165
|
+
* Runs in the BROWSER only — a build and a prerender emit nothing, so a
|
|
1166
|
+
* static render never calls a site's analytics with a page nobody read.
|
|
1167
|
+
*
|
|
1168
|
+
* Never awaited and never retried: whatever it throws or rejects with is
|
|
1169
|
+
* swallowed, because a failed analytics call must not take the search,
|
|
1170
|
+
* the copy, the feedback or the request down with it.
|
|
1171
|
+
*/
|
|
1172
|
+
track?: (event: DuxtAnalyticsEvent) => unknown;
|
|
1173
|
+
};
|
|
866
1174
|
navigation?: DuxtLink[];
|
|
867
1175
|
/** The second navbar row: top-level parts of the documentation. */
|
|
868
1176
|
sections?: DuxtSection[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the reader was, in the four fields an event is allowed to say it with.
|
|
3
|
+
*
|
|
4
|
+
* The NORMALISATION is the point, not the assembly. A path reaches this from a
|
|
5
|
+
* router, and a router's paths carry query strings and fragments — which is
|
|
6
|
+
* where a search term, a campaign id, a session token and the heading someone
|
|
7
|
+
* scrolled to all live. None of that is "which page", so none of it survives
|
|
8
|
+
* here, once, rather than at each of the six call sites that build an event.
|
|
9
|
+
*
|
|
10
|
+
* `source` and `version` are OMITTED rather than set undefined: a site with one
|
|
11
|
+
* source has no answer, and a key present-but-undefined is a key a provider
|
|
12
|
+
* serialises as null and then charges a column for.
|
|
13
|
+
*/
|
|
14
|
+
export function duxtAnalyticsContext(
|
|
15
|
+
path: string,
|
|
16
|
+
locale: string,
|
|
17
|
+
source?: { repo?: string; version?: string }
|
|
18
|
+
): DuxtAnalyticsContext {
|
|
19
|
+
return {
|
|
20
|
+
path: path.split(/[?#]/)[0] || '/',
|
|
21
|
+
locale,
|
|
22
|
+
...(source?.repo ? { source: source.repo } : {}),
|
|
23
|
+
...(source?.version ? { version: source.version } : {})
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A status as its class, which is as far as an event is allowed to go.
|
|
29
|
+
*
|
|
30
|
+
* The exact code belongs to the reader's own request — their token, their id,
|
|
31
|
+
* their typo — and a documentation site learns nothing from 403 against 404
|
|
32
|
+
* that `4xx` does not already tell it. Undefined outside the five classes,
|
|
33
|
+
* because a request that produced no status produced no class either.
|
|
34
|
+
*/
|
|
35
|
+
export function duxtStatusClass(
|
|
36
|
+
status: number
|
|
37
|
+
): DuxtAnalyticsStatusClass | undefined {
|
|
38
|
+
const leading = Math.floor(status / 100);
|
|
39
|
+
|
|
40
|
+
return leading >= 1 && leading <= 5
|
|
41
|
+
? (`${leading}xx` as DuxtAnalyticsStatusClass)
|
|
42
|
+
: undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Does nothing, on purpose — the rejection handler that drops the reason. */
|
|
46
|
+
const ignore = () => undefined;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A value with a `then`, which is all that has to be true to reject.
|
|
50
|
+
*
|
|
51
|
+
* Duck-typed rather than `instanceof Promise`: a site may hand back its
|
|
52
|
+
* provider's own thenable, or a promise from another realm, and both reject
|
|
53
|
+
* exactly as unhandled as a native one.
|
|
54
|
+
*/
|
|
55
|
+
const isThenable = (value: unknown): value is PromiseLike<unknown> =>
|
|
56
|
+
typeof (value as PromiseLike<unknown> | undefined)?.then === 'function';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hand one event to the site's callback, and never let it back out.
|
|
60
|
+
*
|
|
61
|
+
* The whole of duxt's analytics transport. There is no queue, no batch, no
|
|
62
|
+
* retry and no network call — a site's `track` is the only thing that ever
|
|
63
|
+
* leaves the page, which is what keeps the provider, the consent gate and the
|
|
64
|
+
* decision to transmit at all on the side that owns them.
|
|
65
|
+
*
|
|
66
|
+
* Nothing it does reaches the caller: the reader pressed copy, searched, or
|
|
67
|
+
* sent a request, and a provider that is down, missing a key or throwing on a
|
|
68
|
+
* malformed property must not take that interaction with it.
|
|
69
|
+
*/
|
|
70
|
+
export function emitDuxtAnalytics(
|
|
71
|
+
track: ((event: DuxtAnalyticsEvent) => unknown) | undefined,
|
|
72
|
+
event: DuxtAnalyticsEvent
|
|
73
|
+
): void {
|
|
74
|
+
if (typeof track !== 'function') return;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const returned: unknown = track(event);
|
|
78
|
+
|
|
79
|
+
// A provider SDK almost always hands back a promise, and one that rejects
|
|
80
|
+
// with nothing attached is an unhandled rejection — logged by every
|
|
81
|
+
// browser, and fatal to a Node process configured to treat it so. Caught
|
|
82
|
+
// here rather than left to the site: `track` is called for its effect and
|
|
83
|
+
// its result is never read, so there is nowhere else a handler could go.
|
|
84
|
+
if (isThenable(returned)) {
|
|
85
|
+
returned.then(undefined, ignore);
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Deliberately silent, and silent in both halves: the reader pressed copy,
|
|
89
|
+
// not report-that-I-copied.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which announcement a reader dismissed, and which are due to be shown.
|
|
3
|
+
*
|
|
4
|
+
* Pure on purpose: the browser is what evaluates an announcement's time window
|
|
5
|
+
* and what remembers a dismissal, so every one of these answers depends on a
|
|
6
|
+
* clock and a set that only exist client-side. Keeping the arithmetic here
|
|
7
|
+
* rather than in the component is what lets it be tested at all — see
|
|
8
|
+
* `tests/announcements.test.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where an announcement that names no placement goes.
|
|
13
|
+
*
|
|
14
|
+
* The loudest of the three, because the announcements a site actually writes
|
|
15
|
+
* are releases and outages — a notice that has to be configured before it says
|
|
16
|
+
* anything may as well default to being seen.
|
|
17
|
+
*/
|
|
18
|
+
export const DUXT_ANNOUNCEMENT_PLACEMENT: DuxtAnnouncementPlacement =
|
|
19
|
+
'above-header';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A value written the same way however its object literal was typed.
|
|
23
|
+
*
|
|
24
|
+
* Keys sorted, because a locale record is an object and object key order is
|
|
25
|
+
* whatever the config file happened to use: a consumer alphabetising
|
|
26
|
+
* `{ de, en }` into `{ en, de }` must not resurrect a notice every reader has
|
|
27
|
+
* already dismissed.
|
|
28
|
+
*/
|
|
29
|
+
function canonical(value: unknown): string {
|
|
30
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
31
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
32
|
+
|
|
33
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
34
|
+
.filter(([, entry]) => entry !== undefined)
|
|
35
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
36
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`);
|
|
37
|
+
|
|
38
|
+
return `{${entries.join(',')}}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* FNV-1a, as a short base-36 string.
|
|
43
|
+
*
|
|
44
|
+
* Not a cryptographic hash and not trying to be: the worst a collision can do
|
|
45
|
+
* is leave one announcement hidden for a reader who dismissed another, and the
|
|
46
|
+
* alternative — `crypto.subtle.digest` — is asynchronous, which a computed
|
|
47
|
+
* property cannot be.
|
|
48
|
+
*/
|
|
49
|
+
function fingerprint(value: string): string {
|
|
50
|
+
let hash = 0x81_1c_9d_c5;
|
|
51
|
+
|
|
52
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
53
|
+
hash ^= value.charCodeAt(index);
|
|
54
|
+
// The FNV prime, by shifts: `hash * 16777619` overflows a double's exact
|
|
55
|
+
// integer range and starts losing the low bits it exists to mix.
|
|
56
|
+
hash = Math.imul(hash, 0x01_00_01_93);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (hash >>> 0).toString(36);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A configured bound as a timestamp, or nothing.
|
|
64
|
+
*
|
|
65
|
+
* An unparseable date is treated as ABSENT rather than as "never": a typo in
|
|
66
|
+
* `endsAt` then leaves the notice showing, which somebody notices, instead of
|
|
67
|
+
* silently swallowing the announcement it was supposed to close.
|
|
68
|
+
*/
|
|
69
|
+
function boundary(value: string | undefined): number | undefined {
|
|
70
|
+
if (!value) return undefined;
|
|
71
|
+
|
|
72
|
+
const parsed = Date.parse(value);
|
|
73
|
+
|
|
74
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Is this announcement inside its window at `now`?
|
|
79
|
+
*
|
|
80
|
+
* Half-open: it shows FROM `startsAt` and UNTIL `endsAt`, so a window ending
|
|
81
|
+
* where the next one begins never draws both for the same millisecond. No
|
|
82
|
+
* window at all means always.
|
|
83
|
+
*/
|
|
84
|
+
export function duxtAnnouncementActive(
|
|
85
|
+
announcement: DuxtAnnouncement,
|
|
86
|
+
now: number
|
|
87
|
+
): boolean {
|
|
88
|
+
const starts = boundary(announcement.startsAt);
|
|
89
|
+
const ends = boundary(announcement.endsAt);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
(starts === undefined || now >= starts) &&
|
|
93
|
+
(ends === undefined || now < ends)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The identity a dismissal is remembered under.
|
|
99
|
+
*
|
|
100
|
+
* A configured `id` wins and is the whole answer. Without one the identity is
|
|
101
|
+
* the announcement's CONTENT — every locale of it at once, so switching
|
|
102
|
+
* language keeps the dismissal, and any edit to the wording makes it a new
|
|
103
|
+
* announcement every reader is shown again.
|
|
104
|
+
*/
|
|
105
|
+
export function duxtAnnouncementKey(announcement: DuxtAnnouncement): string {
|
|
106
|
+
if (announcement.id) return announcement.id;
|
|
107
|
+
|
|
108
|
+
// The CONTENT, and nothing else: `placement` moves the same sentence and a
|
|
109
|
+
// window only says how long it runs, so neither makes a notice new.
|
|
110
|
+
return fingerprint(
|
|
111
|
+
canonical({ text: announcement.text, link: announcement.link })
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What a placement is asked for: the reader's clock, and what they dismissed. */
|
|
116
|
+
export interface DuxtVisibleAnnouncementsOptions {
|
|
117
|
+
placement: DuxtAnnouncementPlacement;
|
|
118
|
+
now: number;
|
|
119
|
+
dismissed: readonly string[];
|
|
120
|
+
maxVisible?: DuxtAnnouncementMaxVisible;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* This placement's cap, or `undefined` for no cap.
|
|
125
|
+
*
|
|
126
|
+
* `null` and an absent key mean the same thing on purpose: a consumer lifting
|
|
127
|
+
* one placement's limit should not have to remember which of the two spellings
|
|
128
|
+
* the config reader happens to honour.
|
|
129
|
+
*/
|
|
130
|
+
export function duxtAnnouncementLimit(
|
|
131
|
+
maxVisible: DuxtAnnouncementMaxVisible | undefined,
|
|
132
|
+
placement: DuxtAnnouncementPlacement
|
|
133
|
+
): number | undefined {
|
|
134
|
+
if (maxVisible === undefined || maxVisible === null) return undefined;
|
|
135
|
+
if (typeof maxVisible === 'number') return maxVisible;
|
|
136
|
+
|
|
137
|
+
return maxVisible[placement] ?? undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The announcements one placement draws, in the order they were configured.
|
|
142
|
+
*
|
|
143
|
+
* Order is the consumer's, never a ranking of ours: a list is read top to
|
|
144
|
+
* bottom, and a notice that moved because another one expired is a notice the
|
|
145
|
+
* reader has to find again.
|
|
146
|
+
*/
|
|
147
|
+
export function duxtVisibleAnnouncements(
|
|
148
|
+
announcements: readonly DuxtAnnouncement[] | undefined,
|
|
149
|
+
options: DuxtVisibleAnnouncementsOptions
|
|
150
|
+
): DuxtAnnouncement[] {
|
|
151
|
+
const dismissed = new Set(options.dismissed);
|
|
152
|
+
|
|
153
|
+
const due = (announcements ?? []).filter(
|
|
154
|
+
(announcement) =>
|
|
155
|
+
(announcement.placement ?? DUXT_ANNOUNCEMENT_PLACEMENT) ===
|
|
156
|
+
options.placement &&
|
|
157
|
+
duxtAnnouncementActive(announcement, options.now) &&
|
|
158
|
+
!dismissed.has(duxtAnnouncementKey(announcement))
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const limit = duxtAnnouncementLimit(options.maxVisible, options.placement);
|
|
162
|
+
|
|
163
|
+
// AFTER the dismissals, not before: the cap is how many a reader looks at,
|
|
164
|
+
// so closing the top one promotes the next rather than retiring it unseen.
|
|
165
|
+
return limit === undefined ? due : due.slice(0, Math.max(0, limit));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* When the configured list next changes what it would draw.
|
|
170
|
+
*
|
|
171
|
+
* A window has to open and close on the reader's clock, and the two ways to do
|
|
172
|
+
* that are to poll and to wait for the one moment that matters. This is the
|
|
173
|
+
* second: the component sleeps until this timestamp and re-reads then, so a
|
|
174
|
+
* page left open overnight is right in the morning without a timer ticking
|
|
175
|
+
* through the night.
|
|
176
|
+
*
|
|
177
|
+
* `undefined` where nothing is scheduled — which is the ordinary case, and
|
|
178
|
+
* costs no timer at all.
|
|
179
|
+
*/
|
|
180
|
+
export function duxtNextAnnouncementChange(
|
|
181
|
+
announcements: readonly DuxtAnnouncement[] | undefined,
|
|
182
|
+
now: number
|
|
183
|
+
): number | undefined {
|
|
184
|
+
let next: number | undefined;
|
|
185
|
+
|
|
186
|
+
for (const announcement of announcements ?? []) {
|
|
187
|
+
for (const bound of [
|
|
188
|
+
boundary(announcement.startsAt),
|
|
189
|
+
boundary(announcement.endsAt)
|
|
190
|
+
]) {
|
|
191
|
+
// Strictly ahead: a boundary the reader has already passed changes
|
|
192
|
+
// nothing, and one exactly at `now` has already been accounted for by
|
|
193
|
+
// `duxtAnnouncementActive`.
|
|
194
|
+
if (
|
|
195
|
+
bound !== undefined &&
|
|
196
|
+
bound > now &&
|
|
197
|
+
(next === undefined || bound < next)
|
|
198
|
+
) {
|
|
199
|
+
next = bound;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return next;
|
|
205
|
+
}
|