@rimelight/ui 0.0.15 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimelight/ui",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "private": false,
5
5
  "description": "Rimelight Entertainment UI Library.",
6
6
  "homepage": "https://rimelight.com/docs",
@@ -57,6 +57,7 @@
57
57
  "css-variants": "2.3.5",
58
58
  "defu": "6.1.6",
59
59
  "embla-carousel": "8.6.0",
60
+ "picomatch": "^4.0.4",
60
61
  "tailwind-merge": "^3.3.1",
61
62
  "tailwindcss": "^4.2.3",
62
63
  "turndown": "^7.2.4",
@@ -69,6 +70,7 @@
69
70
  "@astrojs/ts-plugin": "1.10.7",
70
71
  "@e18e/eslint-plugin": "0.3.0",
71
72
  "@types/node": "25.5.2",
73
+ "@types/picomatch": "^4.0.3",
72
74
  "@unocss/eslint-plugin": "66.6.8",
73
75
  "astro": "6.1.8",
74
76
  "eslint-plugin-regexp": "3.1.0",
package/src/env.d.ts CHANGED
@@ -38,5 +38,8 @@ declare module "turndown-plugin-gfm" {
38
38
  declare namespace App {
39
39
  interface Locals {
40
40
  cfContext?: Record<string, unknown>
41
+ // Extended locals used by Starlight Addons for richer type safety
42
+ starlightSidebarTopics?: any
43
+ starlightRoute?: any
41
44
  }
42
45
  }
@@ -1,2 +1 @@
1
- export * from "./sri"
2
1
  export * from "./security"
@@ -1,15 +1,92 @@
1
1
  import { defineMiddleware } from "astro:middleware"
2
+ // @ts-ignore - virtual module generated by the SRI integration
3
+ import { manifest } from "virtual:sri-manifest"
2
4
 
3
- export const security = defineMiddleware(async (_, next) => {
4
- const response = await next()
5
+ declare const HTMLRewriter: any
5
6
 
6
- const newResponse = new Response(response.body, response)
7
+ function assertManifest(obj: unknown): asserts obj is Record<string, string> {
8
+ if (!obj || typeof obj !== "object") throw new Error("Expected manifest")
9
+ }
7
10
 
8
- // Standard Security Headers
11
+ function addSecurityHeaders(response: Response): Response {
12
+ const newResponse = new Response(response.body, response)
9
13
  newResponse.headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
10
14
  newResponse.headers.set("X-Content-Type-Options", "nosniff")
11
15
  newResponse.headers.set("X-Frame-Options", "DENY")
12
16
  newResponse.headers.set("Cross-Origin-Resource-Policy", "same-origin")
13
-
17
+ newResponse.headers.set(
18
+ "Strict-Transport-Security",
19
+ "max-age=31536000; includeSubDomains; preload"
20
+ )
14
21
  return newResponse
22
+ }
23
+
24
+ /**
25
+ * Security Middleware: Injects SRI integrity attributes and security headers.
26
+ */
27
+ export const security = defineMiddleware(async (context, next) => {
28
+ // HTTP to HTTPS redirect only in production
29
+ if (import.meta.env.PROD && context.request.url.startsWith("http://")) {
30
+ const httpsUrl = context.request.url.replace(/^http:/, "https:")
31
+ return Response.redirect(httpsUrl, 301)
32
+ }
33
+
34
+ const response = await next()
35
+ const contentType = response.headers.get("content-type") || ""
36
+
37
+ if (!contentType.includes("text/html")) {
38
+ return addSecurityHeaders(response)
39
+ }
40
+
41
+ if (typeof HTMLRewriter !== "undefined") {
42
+ return new HTMLRewriter()
43
+ .on("script[src]", {
44
+ element(el: any) {
45
+ const src = el.getAttribute("src")
46
+ if (src && manifest[src]) {
47
+ el.setAttribute("integrity", manifest[src])
48
+ el.setAttribute("crossorigin", "anonymous")
49
+ }
50
+ }
51
+ })
52
+ .on('link[rel="stylesheet"][href]', {
53
+ element(el: any) {
54
+ const href = el.getAttribute("href")
55
+ if (href && manifest[href]) {
56
+ el.setAttribute("integrity", manifest[href])
57
+ el.setAttribute("crossorigin", "anonymous")
58
+ }
59
+ }
60
+ })
61
+ .transform(response)
62
+ }
63
+
64
+ let modifiedHtml = await response.text()
65
+
66
+ assertManifest(manifest)
67
+
68
+ const entries = Object.entries(manifest)
69
+ for (const [url, hash] of entries) {
70
+ if (!hash) continue
71
+
72
+ const scriptRegex = new RegExp(`(<script[^>]+src=["']${url}["'][^>]*)(/?>)`, "g")
73
+ modifiedHtml = modifiedHtml.replace(
74
+ scriptRegex,
75
+ `$1 integrity="${hash}" crossorigin="anonymous"$2`
76
+ )
77
+
78
+ const linkRegex = new RegExp(`(<link[^>]+href=["']${url}["'][^>]*)(/?>)`, "g")
79
+ modifiedHtml = modifiedHtml.replace(
80
+ linkRegex,
81
+ `$1 integrity="${hash}" crossorigin="anonymous"$2`
82
+ )
83
+ }
84
+
85
+ return addSecurityHeaders(
86
+ new Response(modifiedHtml, {
87
+ status: response.status,
88
+ statusText: response.statusText,
89
+ headers: new Headers(response.headers)
90
+ })
91
+ )
15
92
  })
@@ -0,0 +1,89 @@
1
+ ---
2
+ import { Badge, Icon } from '@astrojs/starlight/components'
3
+
4
+ const { hasSidebar } = Astro.locals.starlightRoute
5
+ const { isPageWithTopic, topics } = Astro.locals.starlightSidebarTopics
6
+ ---
7
+
8
+ {hasSidebar && isPageWithTopic && (
9
+ <ul class="starlight-sidebar-topics">
10
+ {topics.map((topic, index) => (
11
+ <li key={index}>
12
+ <a href={topic.link} class:list={{ 'starlight-sidebar-topics-current': topic.isCurrent }}>
13
+ {topic.icon && (
14
+ <div class="starlight-sidebar-topics-icon">
15
+ <Icon name={topic.icon} />
16
+ </div>
17
+ )}
18
+ <div>
19
+ {topic.label}
20
+ {topic.badge && (
21
+ <Badge class="starlight-sidebar-topics-badge" text={topic.badge.text} variant={topic.badge.variant} />
22
+ )}
23
+ </div>
24
+ </a>
25
+ </li>
26
+ ))}
27
+ </ul>
28
+ )}
29
+ <style>
30
+ ul {
31
+ list-style: none;
32
+ padding: 0;
33
+ }
34
+
35
+ ul::after {
36
+ content: '';
37
+ display: block;
38
+ margin-top: 1rem;
39
+ height: 1px;
40
+ border-top: 1px solid var(--sl-color-hairline-light);
41
+ }
42
+
43
+ li {
44
+ overflow-wrap: anywhere;
45
+ }
46
+
47
+ li + li {
48
+ margin-top: 0.25rem;
49
+ }
50
+
51
+ a {
52
+ align-items: center;
53
+ color: var(--sl-color-white);
54
+ display: flex;
55
+ font-size: var(--sl-text-base);
56
+ font-weight: 600;
57
+ gap: 0.5rem;
58
+ line-height: 1.5;
59
+ padding: 0.3em 0.5rem;
60
+ text-decoration: none;
61
+ }
62
+
63
+ a:is(.starlight-sidebar-topics-current, :hover, :focus-visible) {
64
+ color: var(--sl-color-accent-high);
65
+ }
66
+
67
+ :global([data-theme='light']) a.starlight-sidebar-topics-current {
68
+ color: var(--sl-color-accent);
69
+ }
70
+
71
+ .starlight-sidebar-topics-icon {
72
+ align-items: center;
73
+ border-radius: 0.25rem;
74
+ border: 1px solid var(--sl-color-gray-4);
75
+ display: flex;
76
+ justify-content: center;
77
+ padding: 0.25rem;
78
+ }
79
+
80
+ a:is(.starlight-sidebar-topics-current, :hover, :focus-visible) .starlight-sidebar-topics-icon {
81
+ background-color: var(--sl-color-text-accent);
82
+ border-color: var(--sl-color-text-accent);
83
+ color: var(--sl-color-text-invert);
84
+ }
85
+
86
+ .starlight-sidebar-topics-badge {
87
+ margin-inline-start: 0.25em;
88
+ }
89
+ </style>
@@ -0,0 +1,39 @@
1
+ import type { StarlightIcon } from "@astrojs/starlight/types"
2
+
3
+ import type { SidebarTopicBadge } from "./libs/config"
4
+
5
+ export interface StarlightSidebarTopicsRouteData {
6
+ /**
7
+ * Indicates if the current page is associated with a topic or not.
8
+ */
9
+ isPageWithTopic: boolean
10
+ /**
11
+ * A list of all configured topics.
12
+ */
13
+ topics: {
14
+ /**
15
+ * The optional badge associated with the topic.
16
+ */
17
+ badge?: {
18
+ text: string
19
+ variant: SidebarTopicBadge["variant"]
20
+ }
21
+ /**
22
+ * The name of an optional icon associated with the topic set to one of Starlight’s built-in
23
+ * icons.
24
+ */
25
+ icon?: StarlightIcon
26
+ /**
27
+ * Indicates if the current page is part of the topic.
28
+ */
29
+ isCurrent: boolean
30
+ /**
31
+ * The label of the topic.
32
+ */
33
+ label: string
34
+ /**
35
+ * The link to the topic’s content.
36
+ */
37
+ link: string
38
+ }[]
39
+ }
@@ -1,7 +1,25 @@
1
- import type { StarlightPlugin } from "@astrojs/starlight/types"
1
+ /// <reference path="./locals.d.ts" />
2
+ import type { StarlightPlugin, StarlightUserConfig } from "@astrojs/starlight/types"
2
3
  import virtual from "vite-plugin-virtual"
3
4
  import path from "node:path"
4
5
  import { fileURLToPath } from "node:url"
6
+ import { z } from "astro/zod"
7
+
8
+ import {
9
+ StarlightSidebarTopicsConfigSchema,
10
+ StarlightSidebarTopicsOptionsSchema,
11
+ type StarlightSidebarTopicsUserConfig,
12
+ type StarlightSidebarTopicsUserOptions
13
+ } from "./libs/config"
14
+ import { throwPluginError } from "./libs/plugin"
15
+ import { vitePluginStarlightSidebarTopics } from "./libs/vite"
16
+
17
+ export type {
18
+ StarlightSidebarTopicsConfig,
19
+ StarlightSidebarTopicsUserConfig,
20
+ StarlightSidebarTopicsOptions,
21
+ StarlightSidebarTopicsUserOptions
22
+ } from "./libs/config"
5
23
 
6
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
25
 
@@ -9,32 +27,87 @@ export interface StarlightAddonsConfig {
9
27
  copy?: boolean
10
28
  share?: boolean
11
29
  packageManagers?: string[]
30
+ // Merged config for Topics
31
+ topics?: StarlightSidebarTopicsUserConfig
32
+ topicOptions?: StarlightSidebarTopicsUserOptions
12
33
  }
13
34
 
14
35
  export default function starlightAddons(userConfig?: StarlightAddonsConfig): StarlightPlugin {
15
- const defaultConfig: StarlightAddonsConfig = {
36
+ // 1. Setup default and merged config for Addons
37
+ const defaultAddonConfig: StarlightAddonsConfig = {
16
38
  copy: true,
17
39
  share: true,
18
40
  packageManagers: ["npm"]
19
41
  }
20
42
 
21
- const config: StarlightAddonsConfig = {
22
- ...defaultConfig,
23
- ...userConfig
43
+ const addonConfig = { ...defaultAddonConfig, ...userConfig }
44
+
45
+ // 2. Validate Sidebar Topics configurations if they exist
46
+ const parsedTopics = StarlightSidebarTopicsConfigSchema.safeParse(addonConfig.topics || [])
47
+ if (!parsedTopics.success) {
48
+ throwPluginError(
49
+ `Invalid topics config: ${parsedTopics.error.issues.map((i) => i.message).join("\n")}`
50
+ )
51
+ }
52
+
53
+ const parsedOptions = StarlightSidebarTopicsOptionsSchema.safeParse(addonConfig.topicOptions)
54
+ if (!parsedOptions.success) {
55
+ throwPluginError(`Invalid topic options: ${z.prettifyError(parsedOptions.error)}`)
24
56
  }
25
57
 
58
+ const topicsData = parsedTopics.data
59
+ const optionsData = parsedOptions.data
60
+
26
61
  return {
27
62
  name: "rimelight-ui-starlight-addons",
28
63
  hooks: {
29
- "config:setup"({ addIntegration, updateConfig, logger }) {
64
+ "config:setup"({
65
+ addIntegration,
66
+ updateConfig,
67
+ logger,
68
+ addRouteMiddleware,
69
+ command,
70
+ config: starlightConfig
71
+ }) {
72
+ // --- Sidebar Topics Logic ---
73
+ if ((command === "dev" || command === "build") && topicsData.length > 0) {
74
+ if (starlightConfig.sidebar) {
75
+ throwPluginError(
76
+ "Sidebar detected. To use topics, move your sidebar items into the topics config."
77
+ )
78
+ }
79
+
80
+ const topicSidebar: StarlightUserConfig["sidebar"] = topicsData.map((topic, index) => {
81
+ return "items" in topic
82
+ ? { label: String(index), items: topic.items }
83
+ : { label: String(index), items: [] }
84
+ })
85
+
86
+ updateConfig({
87
+ sidebar: topicSidebar,
88
+ components: {
89
+ Sidebar: `starlight-sidebar-topics/overrides/Sidebar.astro`
90
+ }
91
+ })
92
+ }
93
+
94
+ // --- Addons Logic ---
30
95
  if (
31
- !config.copy &&
32
- !config.share &&
33
- (!config.packageManagers || config.packageManagers.length === 0)
96
+ !addonConfig.copy &&
97
+ !addonConfig.share &&
98
+ (!addonConfig.packageManagers || addonConfig.packageManagers.length === 0)
34
99
  ) {
35
- logger.warn("StarlightAddons: Nothing enabled.")
100
+ logger.warn("StarlightAddons: No features enabled.")
36
101
  }
37
102
 
103
+ updateConfig({
104
+ components: {
105
+ // This will merge with the Sidebar override if both exist
106
+ PageTitle: path.join(__dirname, "PageTitle.astro")
107
+ }
108
+ })
109
+
110
+ // --- Unified Integration ---
38
111
  addIntegration({
39
112
  name: "rimelight-ui-starlight-addons-integration",
40
113
  hooks: {
@@ -43,20 +116,16 @@ export default function starlightAddons(userConfig?: StarlightAddonsConfig): Sta
43
116
  vite: {
44
117
  plugins: [
45
118
  virtual({
46
- "virtual:rimelight-starlight-addons-config": `export default ${JSON.stringify(config)}`
47
- })
119
+ "virtual:rimelight-starlight-addons-config": `export default ${JSON.stringify(addonConfig)}`
120
+ }),
121
+ // Pass the validated topic data to the vite plugin
122
+ vitePluginStarlightSidebarTopics(topicsData, optionsData)
48
123
  ]
49
124
  }
50
125
  } as Record<string, unknown>)
51
126
  }
52
127
  }
53
128
  })
54
-
55
- updateConfig({
56
- components: {
57
- PageTitle: path.join(__dirname, "PageTitle.astro")
58
- }
59
- })
60
129
  }
61
130
  }
62
131
  }
@@ -0,0 +1,107 @@
1
+ import type { StarlightUserConfig } from "@astrojs/starlight/types"
2
+ import { z } from "astro/zod"
3
+
4
+ const sidebarTopicBadgeSchema = z.object({
5
+ text: z.union([z.string(), z.record(z.string(), z.string())]),
6
+ variant: z.enum(["note", "danger", "success", "caution", "tip", "default"]).default("default")
7
+ })
8
+
9
+ const sidebarTopicBaseSchema = z.object({
10
+ /**
11
+ * An optional badge to display next to the topic label.
12
+ *
13
+ * This option accepts the same configuration as the Starlight badge sidebar item configuration.
14
+ *
15
+ * @see https://starlight.astro.build/guides/sidebar/#badges
16
+ */
17
+ badge: z
18
+ .union([z.string(), sidebarTopicBadgeSchema])
19
+ .transform((badge) =>
20
+ typeof badge === "string" ? { variant: "default" as const, text: badge } : badge
21
+ )
22
+ .optional(),
23
+ /**
24
+ * The name of an optional icon to display before the topic label set to one of Starlight’s
25
+ * built-in icons.
26
+ *
27
+ * @see https://starlight.astro.build/reference/icons/#all-icons
28
+ */
29
+ icon: z.string().optional(),
30
+ /**
31
+ * The topic label visible at the top of the sidebar.
32
+ *
33
+ * The value can be a string, or for multilingual sites, an object with values for each different
34
+ * locale. When using the object form, the keys must be BCP-47 tags (e.g. en, fr, or zh-CN).
35
+ */
36
+ label: z.union([z.string(), z.record(z.string(), z.string())]),
37
+ /**
38
+ * The link to the topic’s content which an be a relative link to local files or the full URL of
39
+ * an external page.
40
+ *
41
+ * For internal links, the link can either be a page included in the items array or a different
42
+ * page acting as the topic’s landing page.
43
+ */
44
+ link: z.string()
45
+ })
46
+
47
+ const sidebarTopicLinkSchema = sidebarTopicBaseSchema
48
+
49
+ const sidebarTopicGroupSchema = sidebarTopicBaseSchema.extend({
50
+ /**
51
+ * An optional unique identifier for the topic that can be used to associate content pages that
52
+ * are not listed in any topic sidebar configuration with this topic.
53
+ */
54
+ id: z.string().optional(),
55
+ /**
56
+ * The sidebar items (links and subcategories) to display for this topic.
57
+ *
58
+ * The topic’s sidebar navigation items. This represents the sidebar displayed when the topic
59
+ * `link` page or any of the pages configured in the `items` array is the current page.
60
+ */
61
+ items: z.any().array() as z.ZodType<NonNullable<StarlightUserConfig["sidebar"]>>
62
+ })
63
+
64
+ export const StarlightSidebarTopicsConfigSchema = z
65
+ .union([sidebarTopicGroupSchema, sidebarTopicLinkSchema])
66
+ .array()
67
+
68
+ export const StarlightSidebarTopicsOptionsSchema = z
69
+ .strictObject({
70
+ /**
71
+ * Defines a list of pages or glob patterns that should be excluded from any topic.
72
+ *
73
+ * This options can be useful for custom pages that use a custom site navigation sidebar which
74
+ * do not belong to any topic. Excluded pages will use the built-in Starlight sidebar and not
75
+ * render a list of topics.
76
+ *
77
+ * @default [ ]
78
+ */
79
+ exclude: z.array(z.string()).default([]),
80
+ /**
81
+ * Defines a map of topic IDs mapped to a list of pages or glob patterns that should be
82
+ * associated with the topic.
83
+ *
84
+ * This option can be useful for custom pages generated and included in the sidebar by other
85
+ * plugins that have no knowledge of the Starlight Sidebar Topics plugin that should be
86
+ * associated with a specific topic.
87
+ *
88
+ * @default {}
89
+ */
90
+ topics: z.record(z.string(), z.array(z.string())).default({})
91
+ })
92
+ .prefault({})
93
+
94
+ export type StarlightSidebarTopicsUserConfig = z.input<typeof StarlightSidebarTopicsConfigSchema>
95
+ export type StarlightSidebarTopicsConfig = z.output<typeof StarlightSidebarTopicsConfigSchema>
96
+
97
+ export type StarlightSidebarTopicsUserOptions = z.input<typeof StarlightSidebarTopicsOptionsSchema>
98
+ export type StarlightSidebarTopicsOptions = z.output<typeof StarlightSidebarTopicsOptionsSchema>
99
+
100
+ export type StarlightSidebarTopicsSharedConfig = (
101
+ | (z.output<typeof sidebarTopicLinkSchema> & { type: "link" })
102
+ | (Omit<z.output<typeof sidebarTopicGroupSchema>, "items"> & { type: "group" })
103
+ )[]
104
+
105
+ export type StarlightSidebarTopicsSharedOptions = StarlightSidebarTopicsOptions
106
+
107
+ export type SidebarTopicBadge = z.output<typeof sidebarTopicBadgeSchema>
@@ -0,0 +1,20 @@
1
+ import type { StarlightRouteData } from "@astrojs/starlight/route-data"
2
+
3
+ export function isStarlightEntryWithTopic(entry: StarlightEntry): entry is StarlightEntryWithTopic {
4
+ // Use a runtime-friendly shape check to narrow the type safely
5
+ // without relying on potentially unsafe TS inferences for discriminated unions.
6
+ if (typeof entry !== "object" || entry === null) return false
7
+ // Shape-based runtime check without unsafe casts
8
+ if (typeof entry !== "object" || entry === null) return false
9
+ // entry must contain a data property which is an object with a string topic
10
+ // Use a minimal structural check; rely on TypeScript to narrow after this predicate
11
+ // We cannot access entry.data without a type assertion in TS without a cast; but the guard below
12
+ // ensures correctness for subsequent code that relies on this predicate.
13
+ const anyEntry = entry as any
14
+ if (!anyEntry.data) return false
15
+ const data = anyEntry.data
16
+ if (typeof data !== "object" || data === null) return false
17
+ return typeof data.topic === "string"
18
+ }
19
+ export type StarlightEntry = StarlightRouteData["entry"]
20
+ export type StarlightEntryWithTopic = StarlightEntry & { data: { topic: string } }
@@ -0,0 +1,51 @@
1
+ import type { APIContext } from "astro"
2
+ import { AstroError } from "astro/errors"
3
+ import starlightConfig from "virtual:starlight/user-config"
4
+
5
+ import { stripTrailingSlash } from "./pathname"
6
+
7
+ const defaultLang =
8
+ starlightConfig.defaultLocale.lang ?? starlightConfig.defaultLocale.locale ?? "en"
9
+
10
+ export function getLocalizedSlug(slug: string, locale: string | undefined): string {
11
+ const slugLocale = getLocaleFromSlug(slug)
12
+ if (slugLocale === locale) return slug
13
+ locale ??= ""
14
+ if (slugLocale === slug) return locale
15
+
16
+ if (slugLocale) {
17
+ return stripTrailingSlash(slug.replace(`${slugLocale}/`, locale ? `${locale}/` : ""))
18
+ }
19
+
20
+ return slug ? `${locale}/${slug}` : locale
21
+ }
22
+
23
+ export function getLocaleFromSlug(slug: string): string | undefined {
24
+ const locales = Object.keys(starlightConfig.locales ?? {})
25
+ const baseSegment = slug.split("/")[0]
26
+ return baseSegment && locales.includes(baseSegment) ? baseSegment : undefined
27
+ }
28
+
29
+ export function getTranslation(
30
+ currentLocale: APIContext["currentLocale"],
31
+ translations: Record<string, string>,
32
+ link: string,
33
+ description: string
34
+ ) {
35
+ const defaultTranslation = translations[defaultLang]
36
+
37
+ if (!defaultTranslation) {
38
+ throw new AstroError(
39
+ `The ${description} for "${link}" must have a key for the default language "${defaultLang}".`,
40
+ "Update the Starlight config to include a topic label for the default language."
41
+ )
42
+ }
43
+
44
+ let translation = defaultTranslation
45
+
46
+ if (currentLocale) {
47
+ translation = translations[currentLocale] ?? defaultTranslation
48
+ }
49
+
50
+ return translation
51
+ }
@@ -0,0 +1,12 @@
1
+ import type { StarlightSidebarTopicsSharedConfig } from "./config"
2
+
3
+ /**
4
+ * We keep track of the current topic configuration using a variable stored using `Astro.locals`
5
+ * which will be reset for each new page. The value is tracked using an untyped symbol on purpose to
6
+ * avoid users to get autocomplete for it and avoid potential clashes with user-defined variables.
7
+ */
8
+ export const StarlightSidebarTopicsLocalsSymbol = Symbol.for("starlight-sidebar-topics:locals")
9
+
10
+ export interface StarlightSidebarTopicsLocals {
11
+ config: StarlightSidebarTopicsSharedConfig[number]
12
+ }
@@ -0,0 +1,22 @@
1
+ export function arePathnamesEqual(pathnameA: string, pathnameB: string) {
2
+ return stripLeadingAndTrailingSlashes(pathnameA) === stripLeadingAndTrailingSlashes(pathnameB)
3
+ }
4
+
5
+ export function stripLeadingAndTrailingSlashes(pathname: string): string {
6
+ return stripLeadingSlash(stripTrailingSlash(pathname))
7
+ }
8
+
9
+ export function stripLeadingSlash(pathname: string) {
10
+ if (pathname.startsWith("/")) pathname = pathname.slice(1)
11
+ return pathname
12
+ }
13
+
14
+ export function stripTrailingSlash(pathname: string) {
15
+ if (pathname.endsWith("/")) pathname = pathname.slice(0, -1)
16
+ return pathname
17
+ }
18
+
19
+ export function ensureLeadingSlash(pathname: string): string {
20
+ if (pathname.startsWith("/")) return pathname
21
+ return `/${pathname}`
22
+ }
@@ -0,0 +1,9 @@
1
+ import { AstroError } from "astro/errors"
2
+
3
+ export function throwPluginError(message: string, hint?: string): never {
4
+ throw new AstroError(
5
+ message,
6
+ hint ??
7
+ `See the error report above for more informations.\n\nIf you believe this is a bug, please file an issue at https://github.com/HiDeoo/starlight-sidebar-topics/issues/new/choose`
8
+ )
9
+ }
@@ -0,0 +1,183 @@
1
+ import type { StarlightRouteData } from "@astrojs/starlight/route-data"
2
+
3
+ import type { StarlightSidebarTopicsSharedConfig } from "./config"
4
+ import { isStarlightEntryWithTopic, type StarlightEntry } from "./content"
5
+ import { getLocaleFromSlug, getLocalizedSlug } from "./i18n"
6
+ import { arePathnamesEqual, stripLeadingAndTrailingSlashes } from "./pathname"
7
+
8
+ const absoluteLinkRegex = /^https?:\/\//
9
+
10
+ export function getCurrentTopic(
11
+ config: StarlightSidebarTopicsSharedConfig,
12
+ sidebar: SidebarEntry[],
13
+ currentSlug: string,
14
+ entry: StarlightEntry
15
+ ): Topic | undefined {
16
+ // If the current page has a topic ID, use it to find the topic.
17
+ const topicId = getTopicIdFromEntry(entry)
18
+ if (topicId) return getTopicById(config, sidebar, topicId)
19
+
20
+ // Start by checking if the current page is a topic root.
21
+ const topicFromSlug = getTopicFromSlug(config, sidebar, currentSlug)
22
+ if (topicFromSlug) return topicFromSlug
23
+
24
+ // Otherwise, find the current topic by looking for the current page in the sidebar.
25
+ const currentSidebarTopic = getCurrentSidebarTopic(sidebar)
26
+ if (!currentSidebarTopic) return undefined
27
+
28
+ const currentTopicConfig = config[Number.parseInt(currentSidebarTopic.label, 10)]
29
+ if (!currentTopicConfig) return undefined
30
+
31
+ return { config: currentTopicConfig, sidebar: currentSidebarTopic.entries }
32
+ }
33
+
34
+ export function isTopicFirstPage(sidebar: SidebarEntry[], currentSlug: string): boolean {
35
+ const currentSidebarTopic = getCurrentSidebarTopic(sidebar)
36
+ if (!currentSidebarTopic) return false
37
+
38
+ const firstPage = getSidebarFirstPage(currentSidebarTopic.entries)
39
+ if (!firstPage) return false
40
+
41
+ return arePathnamesEqual(stripLeadingAndTrailingSlashes(firstPage.href), currentSlug)
42
+ }
43
+
44
+ export function isTopicLastPage(sidebar: SidebarEntry[], currentSlug: string): boolean {
45
+ const currentSidebarTopic = getCurrentSidebarTopic(sidebar)
46
+ if (!currentSidebarTopic) return false
47
+
48
+ const lastPage = getSidebarLastPage(currentSidebarTopic.entries)
49
+ if (!lastPage) return false
50
+
51
+ return arePathnamesEqual(stripLeadingAndTrailingSlashes(lastPage.href), currentSlug)
52
+ }
53
+
54
+ function getSidebarFirstPage(sidebar: SidebarEntry[]) {
55
+ const entry = sidebar[0]
56
+ if (!entry) return undefined
57
+ if (entry.type === "link") return entry
58
+
59
+ return getSidebarFirstPage(entry.entries)
60
+ }
61
+
62
+ function getSidebarLastPage(sidebar: SidebarEntry[]) {
63
+ const entry = sidebar.at(-1)
64
+ if (!entry) return undefined
65
+ if (entry.type === "link") return entry
66
+
67
+ return getSidebarLastPage(entry.entries)
68
+ }
69
+
70
+ function getTopicFromSlug(
71
+ config: StarlightSidebarTopicsSharedConfig,
72
+ sidebar: SidebarEntry[],
73
+ slug: string
74
+ ): Topic | undefined {
75
+ let topicConfig: Topic["config"] | undefined
76
+ let topicSidebar: Topic["sidebar"] | undefined
77
+
78
+ // Start by checking if the current page is a topic homepage.
79
+ let groupTopicIndex = -1
80
+ const slugLocale = getLocaleFromSlug(slug)
81
+
82
+ for (const topic of config) {
83
+ if (topic.type === "group") groupTopicIndex++
84
+
85
+ if (
86
+ !absoluteLinkRegex.test(topic.link) &&
87
+ arePathnamesEqual(
88
+ getLocalizedSlug(stripLeadingAndTrailingSlashes(topic.link), slugLocale),
89
+ slug
90
+ ) &&
91
+ groupTopicIndex !== -1
92
+ ) {
93
+ const sidebarTopic = sidebar[groupTopicIndex]
94
+
95
+ if (sidebarTopic?.type === "group") {
96
+ topicConfig = topic
97
+ topicSidebar = sidebarTopic.entries
98
+ }
99
+
100
+ break
101
+ }
102
+ }
103
+
104
+ if (!topicConfig || !topicSidebar) return undefined
105
+
106
+ return { config: topicConfig, sidebar: topicSidebar }
107
+ }
108
+
109
+ export function getTopicById(
110
+ config: StarlightSidebarTopicsSharedConfig,
111
+ sidebar: SidebarEntry[],
112
+ id: string
113
+ ): Topic | undefined {
114
+ let topicConfig: Topic["config"] | undefined
115
+ let topicSidebar: Topic["sidebar"] | undefined
116
+
117
+ let groupTopicIndex = -1
118
+
119
+ for (const topic of config) {
120
+ if (topic.type === "group") groupTopicIndex++
121
+
122
+ if (topic.type === "group" && topic.id === id) {
123
+ const sidebarTopic = sidebar[groupTopicIndex]
124
+
125
+ if (sidebarTopic?.type === "group") {
126
+ topicConfig = topic
127
+ topicSidebar = sidebarTopic.entries
128
+ }
129
+
130
+ break
131
+ }
132
+ }
133
+
134
+ if (!topicConfig || !topicSidebar) return undefined
135
+
136
+ return { config: topicConfig, sidebar: topicSidebar }
137
+ }
138
+
139
+ function getCurrentSidebarTopic(sidebar: SidebarEntry[]): SidebarTopic | undefined {
140
+ let currentSidebarTopic: SidebarTopic | undefined
141
+
142
+ for (const topic of sidebar) {
143
+ if (topic.type === "link") continue
144
+
145
+ const currentSidebarEntry = getCurrentSidebarEntry(topic.entries)
146
+
147
+ if (currentSidebarEntry) {
148
+ currentSidebarTopic = topic
149
+ break
150
+ }
151
+ }
152
+
153
+ return currentSidebarTopic
154
+ }
155
+
156
+ function getCurrentSidebarEntry(sidebar: SidebarEntry[]): SidebarEntry | undefined {
157
+ return sidebar.find((entry) => {
158
+ if (entry.type === "link") {
159
+ return entry.isCurrent
160
+ }
161
+
162
+ return getCurrentSidebarEntry(entry.entries)
163
+ })
164
+ }
165
+
166
+ function getTopicIdFromEntry(entry: StarlightEntry): string | undefined {
167
+ if (isStarlightEntryWithTopic(entry)) {
168
+ return entry.data.topic
169
+ }
170
+ return undefined
171
+ }
172
+
173
+ type SidebarEntry = StarlightRouteData["sidebar"][number]
174
+
175
+ interface SidebarTopic {
176
+ label: string
177
+ entries: SidebarEntry[]
178
+ }
179
+
180
+ export interface Topic {
181
+ config: StarlightSidebarTopicsSharedConfig[number]
182
+ sidebar: SidebarEntry[]
183
+ }
@@ -0,0 +1,46 @@
1
+ import type { ViteUserConfig } from "astro"
2
+
3
+ import type {
4
+ StarlightSidebarTopicsConfig,
5
+ StarlightSidebarTopicsOptions,
6
+ StarlightSidebarTopicsSharedConfig
7
+ } from "./config"
8
+
9
+ export function vitePluginStarlightSidebarTopics(
10
+ config: StarlightSidebarTopicsConfig,
11
+ options: StarlightSidebarTopicsOptions
12
+ ): VitePlugin {
13
+ const sharedConfig: StarlightSidebarTopicsSharedConfig = config.map((topic) => {
14
+ if (!("items" in topic)) return { ...topic, type: "link" }
15
+ // Remove the items property without creating an unused binding
16
+ const topicWithoutItems: any = { ...topic }
17
+ delete topicWithoutItems.items
18
+ return { ...topicWithoutItems, type: "group" }
19
+ })
20
+
21
+ const modules: Record<string, string> = {
22
+ "virtual:starlight-sidebar-topics/config": `export default ${JSON.stringify(sharedConfig)}`,
23
+ "virtual:starlight-sidebar-topics/options": `export default ${JSON.stringify(options)}`
24
+ }
25
+
26
+ const moduleResolutionMap = Object.fromEntries(
27
+ Object.keys(modules).map((key) => [resolveVirtualModuleId(key), key])
28
+ ) as Record<string, string>
29
+
30
+ return {
31
+ name: "vite-plugin-starlight-sidebar-topics",
32
+ load(id) {
33
+ const moduleId = moduleResolutionMap[id]
34
+ return moduleId ? modules[moduleId] : undefined
35
+ },
36
+ resolveId(id) {
37
+ return id in modules ? resolveVirtualModuleId(id) : undefined
38
+ }
39
+ }
40
+ }
41
+
42
+ function resolveVirtualModuleId<TModuleId extends string>(id: TModuleId): `\0${TModuleId}` {
43
+ return `\0${id}`
44
+ }
45
+
46
+ type VitePlugin = NonNullable<ViteUserConfig["plugins"]>[number]
@@ -0,0 +1,14 @@
1
+ import type { StarlightLocals } from "@astrojs/starlight/types"
2
+ import type { StarlightSidebarTopicsRouteData } from "./data"
3
+
4
+ declare namespace App {
5
+ interface Locals extends StarlightLocals {
6
+ /**
7
+ * Starlight Sidebar Topics data.
8
+ *
9
+ * @see https://starlight-sidebar-topics.netlify.app/docs/guides/custom-topic-list/
10
+ */
11
+ starlightSidebarTopics: StarlightSidebarTopicsRouteData
12
+ starlightRoute: any
13
+ }
14
+ }
@@ -0,0 +1,132 @@
1
+ import { defineRouteMiddleware } from "@astrojs/starlight/route-data"
2
+ import type { APIContext } from "astro"
3
+ import { getRelativeLocaleUrl } from "astro:i18n"
4
+ import picomatch from "picomatch"
5
+ import config from "virtual:starlight-sidebar-topics/config"
6
+ import options from "virtual:starlight-sidebar-topics/options"
7
+ import type { StarlightSidebarTopicsSharedConfig } from "./libs/config"
8
+ import type { StarlightSidebarTopicsSharedOptions } from "./libs/config"
9
+ import type { StarlightEntry } from "./libs/content"
10
+
11
+ import type { StarlightSidebarTopicsRouteData } from "./data"
12
+ import { getTranslation } from "./libs/i18n"
13
+ import { ensureLeadingSlash } from "./libs/pathname"
14
+ import { throwPluginError } from "./libs/plugin"
15
+ import {
16
+ getCurrentTopic,
17
+ getTopicById,
18
+ isTopicFirstPage,
19
+ isTopicLastPage,
20
+ type Topic
21
+ } from "./libs/sidebar"
22
+
23
+ type StarlightRouteLike = {
24
+ entry: StarlightEntry
25
+ hasSidebar: boolean
26
+ id: string
27
+ pagination: { prev?: unknown; next?: unknown }
28
+ sidebar: any[]
29
+ }
30
+
31
+ export const onRequest = defineRouteMiddleware((context) => {
32
+ // Locals are augmented to include starlightRoute for typing without casts
33
+ const { starlightRoute } = context.locals
34
+ const { entry, hasSidebar, id, pagination, sidebar } = starlightRoute
35
+
36
+ const topicsConfig = config as unknown as StarlightSidebarTopicsSharedConfig
37
+ const topicsOptions = options as unknown as StarlightSidebarTopicsSharedOptions
38
+
39
+ let currentTopic: Topic | undefined
40
+
41
+ if (hasSidebar) {
42
+ currentTopic = getCurrentTopic(topicsConfig, sidebar, id, entry)
43
+
44
+ if (!currentTopic) {
45
+ const normalizedId = ensureLeadingSlash(id)
46
+
47
+ for (const [topicId, patterns] of Object.entries(topicsOptions.topics)) {
48
+ if (!picomatch(patterns)(normalizedId)) continue
49
+ currentTopic = getTopicById(topicsConfig, sidebar, topicId)
50
+ break
51
+ }
52
+ }
53
+
54
+ if (!currentTopic) {
55
+ if (picomatch(options.exclude)(ensureLeadingSlash(id))) {
56
+ attachRouteData(context, currentTopic)
57
+ return
58
+ }
59
+
60
+ throwPluginError(
61
+ `Failed to find the topic for the \`${id}\` page.`,
62
+ `Either include this page in the sidebar configuration of the desired topic using the \`items\` property, associate an unlisted page with a topic using the \`topic\` frontmatter property or the \`topics\` option, or exclude this page from any topic using the \`exclude\` option.
63
+
64
+ Learn more in the following guides:
65
+
66
+ - [Unlisted pages](https://starlight-sidebar-topics.netlify.app/docs/guides/unlisted-pages/)
67
+ - [Excluded pages](https://starlight-sidebar-topics.netlify.app/docs/guides/excluded-pages/)`
68
+ )
69
+ }
70
+
71
+ starlightRoute.sidebar = currentTopic.sidebar
72
+
73
+ if (isTopicFirstPage(sidebar, id)) {
74
+ pagination.prev = undefined
75
+ }
76
+
77
+ if (isTopicLastPage(sidebar, id)) {
78
+ pagination.next = undefined
79
+ }
80
+ }
81
+
82
+ attachRouteData(context, currentTopic)
83
+ })
84
+
85
+ function attachRouteData(context: APIContext, currentTopic: Topic | undefined) {
86
+ context.locals.starlightSidebarTopics = getRouteData(context.currentLocale, currentTopic)
87
+ }
88
+
89
+ function getRouteData(
90
+ currentLocale: APIContext["currentLocale"],
91
+ currentTopic: Topic | undefined
92
+ ): StarlightSidebarTopicsRouteData {
93
+ // Build route data with strongly typed config
94
+ const topicConfigs = config as unknown as StarlightSidebarTopicsSharedConfig
95
+ return {
96
+ isPageWithTopic: currentTopic !== undefined,
97
+ topics: topicConfigs.map((topic) => {
98
+ const isLinkTopic = topic.type === "link"
99
+
100
+ const topicRouteData: StarlightSidebarTopicsRouteData["topics"][number] = {
101
+ isCurrent:
102
+ isLinkTopic || !currentTopic
103
+ ? false
104
+ : topic.label === currentTopic.config.label && topic.link === currentTopic.config.link,
105
+ label:
106
+ typeof topic.label === "string"
107
+ ? topic.label
108
+ : getTranslation(currentLocale, topic.label, topic.link, "topic label"),
109
+ link:
110
+ !isLinkTopic && currentLocale
111
+ ? getRelativeLocaleUrl(currentLocale, topic.link)
112
+ : topic.link
113
+ }
114
+
115
+ if (topic.badge) {
116
+ topicRouteData.badge = {
117
+ text:
118
+ typeof topic.badge.text === "string"
119
+ ? topic.badge.text
120
+ : getTranslation(currentLocale, topic.badge.text, topic.link, "topic badge text"),
121
+ variant: topic.badge.variant
122
+ }
123
+ }
124
+
125
+ if (topic.icon) {
126
+ topicRouteData.icon = topic.icon
127
+ }
128
+
129
+ return topicRouteData
130
+ })
131
+ }
132
+ }
@@ -0,0 +1,8 @@
1
+ ---
2
+ import Default from '@astrojs/starlight/components/Sidebar.astro'
3
+
4
+ import StarlightSidebarTopicsSidebar from './Sidebar.astro'
5
+ ---
6
+
7
+ <StarlightSidebarTopicsSidebar />
8
+ <Default><slot /></Default>
@@ -0,0 +1,13 @@
1
+ import { z } from "astro/zod"
2
+
3
+ export const topicSchema = z.object({
4
+ /**
5
+ * ID of the topic to associate with the current page if the page is not listed in any topic
6
+ * sidebar configuration.
7
+ *
8
+ * @see https://starlight-sidebar-topics.netlify.app/docs/guides/unlisted-pages/
9
+ */
10
+ topic: z.string().optional()
11
+ })
12
+
13
+ export type TopicFrontmatterSchema = z.input<typeof topicSchema>
@@ -0,0 +1,6 @@
1
+ declare module "virtual:starlight/user-config" {
2
+ import type { StarlightConfig } from "@astrojs/starlight/types"
3
+ const Config: StarlightConfig
4
+
5
+ export default Config
6
+ }
@@ -0,0 +1,13 @@
1
+ declare module "virtual:starlight-sidebar-topics/config" {
2
+ import type { StarlightSidebarTopicsSharedConfig } from "./libs/config"
3
+ const StarlightSidebarTopicsConfig: StarlightSidebarTopicsSharedConfig
4
+
5
+ export default StarlightSidebarTopicsConfig
6
+ }
7
+
8
+ declare module "virtual:starlight-sidebar-topics/options" {
9
+ import type { StarlightSidebarTopicsSharedOptions } from "./libs/config"
10
+ const StarlightSidebarTopicsOptions: StarlightSidebarTopicsSharedOptions
11
+
12
+ export default StarlightSidebarTopicsOptions
13
+ }
@@ -1,78 +0,0 @@
1
- import { defineMiddleware } from "astro:middleware"
2
- // @ts-ignore - virtual module generated by the SRI integration
3
- import { manifest } from "virtual:sri-manifest"
4
-
5
- declare const HTMLRewriter: any
6
-
7
- function assertManifest(obj: unknown): asserts obj is Record<string, string> {
8
- if (!obj || typeof obj !== "object") throw new Error("Expected manifest")
9
- }
10
-
11
- /**
12
- * SRI Middleware Injects integrity attributes into script tags based on the pre-generated manifest.
13
- * Handles both SSR streaming (via HTMLRewriter) and post-rendering fallback.
14
- */
15
- export const sri = defineMiddleware(async (_, next) => {
16
- const response = await next()
17
- const contentType = response.headers.get("content-type") || ""
18
-
19
- // Only process HTML responses
20
- if (!contentType.includes("text/html")) {
21
- return response
22
- }
23
-
24
- // Use Cloudflare's HTMLRewriter if available (SSR Production/Preview)
25
- if (typeof HTMLRewriter !== "undefined") {
26
- return new HTMLRewriter()
27
- .on("script[src]", {
28
- element(el: any) {
29
- const src = el.getAttribute("src")
30
- if (src && manifest[src]) {
31
- el.setAttribute("integrity", manifest[src])
32
- el.setAttribute("crossorigin", "anonymous")
33
- }
34
- }
35
- })
36
- .on('link[rel="stylesheet"][href]', {
37
- element(el: any) {
38
- const href = el.getAttribute("href")
39
- if (href && manifest[href]) {
40
- el.setAttribute("integrity", manifest[href])
41
- el.setAttribute("crossorigin", "anonymous")
42
- }
43
- }
44
- })
45
- .transform(response)
46
- }
47
-
48
- // Fallback for Dev
49
- let modifiedHtml = await response.text()
50
-
51
- // Simple RegEx replacement for Dev inspection
52
- assertManifest(manifest)
53
-
54
- const entries = Object.entries(manifest)
55
- for (const [url, hash] of entries) {
56
- if (!hash) continue
57
-
58
- // Look for script tags with the specific URL
59
- const scriptRegex = new RegExp(`(<script[^>]+src=["']${url}["'][^>]*)(/?>)`, "g")
60
- modifiedHtml = modifiedHtml.replace(
61
- scriptRegex,
62
- `$1 integrity="${hash}" crossorigin="anonymous"$2`
63
- )
64
-
65
- // Look for link tags with the specific URL
66
- const linkRegex = new RegExp(`(<link[^>]+href=["']${url}["'][^>]*)(/?>)`, "g")
67
- modifiedHtml = modifiedHtml.replace(
68
- linkRegex,
69
- `$1 integrity="${hash}" crossorigin="anonymous"$2`
70
- )
71
- }
72
-
73
- return new Response(modifiedHtml, {
74
- status: response.status,
75
- statusText: response.statusText,
76
- headers: response.headers
77
- })
78
- })