diff --git a/bun.lockb b/bun.lockb index e21dc0f..46a33f1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 160b214..31268b3 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -1,6 +1,7 @@ import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; -import starlightThemeObsidian from 'starlight-theme-obsidian'; +// import starlightThemeObsidian from 'starlight-theme-obsidian'; +import starlightSiteGraph from 'starlight-site-graph'; import starlightLinksValidator from 'starlight-links-validator'; import markdocGrammar from './grammars/markdoc.tmLanguage.json'; @@ -26,7 +27,7 @@ export default defineConfig({ starlightLinksValidator({ errorOnInvalidHashes: false }), - starlightThemeObsidian({ + starlightSiteGraph({ debug: false, graphConfig: { depth: 1, diff --git a/docs/package.json b/docs/package.json index ec1031f..c6620ce 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,13 +13,13 @@ }, "dependencies": { "@astrojs/check": "0.9.4", - "@astrojs/starlight": "0.31.1", + "@astrojs/starlight": "0.32.3", "astro": "5.1.8", - "astro-og-canvas": ">=0.5.4", - "starlight-links-validator": ">=0.12.3", - "starlight-package-managers": ">=0.6.0", + "astro-og-canvas": "^0.7.0", + "starlight-links-validator": "^0.14.3", + "starlight-package-managers": "^0.10.0", "starlight-site-graph": "workspace:*", - "starlight-theme-obsidian": ">=0.1.0", - "typescript": "^5.5.4" + "starlight-theme-obsidian": "^0.1.1", + "typescript": "^5.8.2" } } \ No newline at end of file diff --git a/docs/src/content/docs/components/pagebacklinks.mdx b/docs/src/content/docs/components/pagebacklinks.mdx index 8b743e3..96d193f 100644 --- a/docs/src/content/docs/components/pagebacklinks.mdx +++ b/docs/src/content/docs/components/pagebacklinks.mdx @@ -56,7 +56,6 @@ The `` component accepts the following props: **type:** `string` (optional) Determines the slug of the current page. If not provided, the slug will be determined by the current page's URL. -When passing `{...Astro.props}` to this component, this is already set for you. ### `entry` @@ -64,7 +63,6 @@ When passing `{...Astro.props}` to this component, this is already set for you. The frontmatter properties related to the current page, for more information of what properties can be passed via this object, check out the [frontmatter config](/starlight-site-graph/configuration/frontmatter). -When passing `{...Astro.props}` to this component, this might already be set for you. ### `class` diff --git a/docs/src/content/docs/components/pagegraph.mdx b/docs/src/content/docs/components/pagegraph.mdx index 9bbaedc..683d309 100644 --- a/docs/src/content/docs/components/pagegraph.mdx +++ b/docs/src/content/docs/components/pagegraph.mdx @@ -55,7 +55,6 @@ The `` component accepts the following props: **type:** `string` (optional) Determines the slug of the current page. If not provided, the slug will be determined by the current page's URL. -When passing `{...Astro.props}` to this component, this is already set for you. ### `config` @@ -69,7 +68,6 @@ This overrides any configuration applied via the plugin settings or frontmatter. The frontmatter properties related to the current page, for more information of what properties can be passed via this object, check out the [frontmatter config](/starlight-site-graph/configuration/frontmatter). -When passing `{...Astro.props}` to this component, this might already be set for you. ### `class` **type:** `string` (optional) diff --git a/docs/src/content/docs/components/pagesidebar.mdx b/docs/src/content/docs/components/pagesidebar.mdx index ba2f751..4be379c 100644 --- a/docs/src/content/docs/components/pagesidebar.mdx +++ b/docs/src/content/docs/components/pagesidebar.mdx @@ -32,20 +32,19 @@ components, you can do so by creating your own override as follows: // src/overrides/PageSidebar.astro --- import Default from "@astrojs/starlight/components/PageSidebar.astro"; - import type { Props } from '@astrojs/starlight/props'; import { PageGraph, PageBacklinks } from "starlight-site-graph/components"; --- - +

{Astro.locals.t('starlight-site-graph.graph')}

- + - +

{Astro.locals.t('starlight-site-graph.backlinks')}

``` diff --git a/docs/src/overrides/Head.astro b/docs/src/overrides/Head.astro index 9bcde0f..2c252e6 100644 --- a/docs/src/overrides/Head.astro +++ b/docs/src/overrides/Head.astro @@ -1,15 +1,14 @@ --- -import type { Props } from '@astrojs/starlight/props'; import Default from '@astrojs/starlight/components/Head.astro'; // Get the URL of the generated image for the current page using its // ID and replace the file extension with `.png`. const ogImageUrl = new URL( - `${import.meta.env.BASE_URL}/og/${Astro.props.id.replace(/\.\w+$/, '.png')}`, + `${import.meta.env.BASE_URL}/og/${Astro.locals.starlightRoute.entry.id.replace(/\.\w+$/, '.png')}`, Astro.site ); --- - + diff --git a/packages/starlight-site-graph/components/backlinks/PageBacklinks.astro b/packages/starlight-site-graph/components/backlinks/PageBacklinks.astro index 1e38f58..14c1abb 100644 --- a/packages/starlight-site-graph/components/backlinks/PageBacklinks.astro +++ b/packages/starlight-site-graph/components/backlinks/PageBacklinks.astro @@ -18,16 +18,17 @@ interface Props { let backlinksData: Partial = {}; let { slug, showBacklinks, class: className } = Astro.props; +let { entry } = Astro.locals.starlightRoute; // If frontmatter is explicitly passed as 'entry' (happens in a Starlight context) -if (Astro.props.entry) { - backlinksData = Astro.props.entry['data']?.backlinks; +if (entry.id) { + backlinksData = entry.data?.backlinks; } // Infer slug from URL if not explicitly provided const slugWithBase = ensureTrailingSlash(stripSlashes((slug ? path.join(astroConfig.base, slug) : Astro.url.pathname).replaceAll("\\", "/"))); -const sitemap = config.sitemapConfig.sitemap; +const sitemap = config.sitemapConfig!.sitemap; let backlinks: string[] = []; if (sitemap) { @@ -40,14 +41,14 @@ if (showBacklinks === undefined) { if (backlinksData?.visible !== undefined) { showBacklinks = backlinksData.visible; } else { - showBacklinks = config.backlinksConfig.visibilityRules ? firstMatchingPattern(slugWithBase, config.backlinksConfig.visibilityRules, false) : true; + showBacklinks = config.backlinksConfig!.visibilityRules ? firstMatchingPattern(slugWithBase, config.backlinksConfig!.visibilityRules, false) : true; } } --- {showBacklinks && backlinks.length > 0 && -
+
diff --git a/packages/starlight-site-graph/components/graph/PageGraph.astro b/packages/starlight-site-graph/components/graph/PageGraph.astro index 9315624..faf82d4 100644 --- a/packages/starlight-site-graph/components/graph/PageGraph.astro +++ b/packages/starlight-site-graph/components/graph/PageGraph.astro @@ -21,10 +21,11 @@ interface Props { let graphData: Partial = {}; let { slug, class: className, showGraph, config: graphConfig } = Astro.props; +let { entry } = Astro.locals.starlightRoute; // If frontmatter is explicitly passed as 'entry' (happens in a Starlight context) -if (Astro.props.entry) { - graphData = Astro.props.entry['data']?.graph as PageGraphConfig; +if (entry.id) { + graphData = entry.data?.graph as PageGraphConfig; } // Infer slug from URL if not explicitly provided @@ -34,7 +35,7 @@ if (showGraph === undefined) { if (graphData?.visible !== undefined) { showGraph = graphData.visible; } else { - showGraph = config.graphConfig.visibilityRules ? firstMatchingPattern(slugWithBase, config.graphConfig.visibilityRules, false) : true; + showGraph = config.graphConfig!.visibilityRules ? firstMatchingPattern(slugWithBase, config.graphConfig!.visibilityRules, false) : true; } } --- @@ -44,13 +45,13 @@ if (showGraph === undefined) {
} diff --git a/packages/starlight-site-graph/components/graph/preprocess-sitemap.ts b/packages/starlight-site-graph/components/graph/preprocess-sitemap.ts index 5a8a3ff..d5156bc 100644 --- a/packages/starlight-site-graph/components/graph/preprocess-sitemap.ts +++ b/packages/starlight-site-graph/components/graph/preprocess-sitemap.ts @@ -257,8 +257,8 @@ function processStyle(style: Partial): NodeStyle { } if (style.cornerType === 'round' || style.cornerType === 'bevel') { - style.shapeCornerRadius = Math.min(Math.max(0, style.shapeCornerRadius ?? DEFAULT_CORNER_RADIUS), style.shapeSize!); - style.strokeCornerRadius = Math.min(Math.max(0, style.strokeCornerRadius ?? DEFAULT_CORNER_RADIUS), style.strokeWidth!); + style.shapeCornerRadius = Math.min(Math.max(0, Number(style.shapeCornerRadius ?? DEFAULT_CORNER_RADIUS)), style.shapeSize!); + style.strokeCornerRadius = Math.min(Math.max(0, Number(style.strokeCornerRadius ?? DEFAULT_CORNER_RADIUS)), style.strokeWidth!); } else { style.shapeCornerRadius = 0; style.strokeCornerRadius = 0; diff --git a/packages/starlight-site-graph/components/util.ts b/packages/starlight-site-graph/components/util.ts index 4685a6b..dfe81bf 100644 --- a/packages/starlight-site-graph/components/util.ts +++ b/packages/starlight-site-graph/components/util.ts @@ -116,11 +116,14 @@ export function isMobileDevice() { ) ) check = true; - })(navigator.userAgent || navigator.vendor || window.opera); + }) + // @ts-expect-error window.opera is not part of type definitions + (navigator.userAgent || navigator.vendor || window.opera); return check; } export function hasTouch() { + // @ts-expect-error msMaxTouchPoints is old MS API, but still used in some places return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; } diff --git a/packages/starlight-site-graph/config.ts b/packages/starlight-site-graph/config.ts deleted file mode 100644 index 26180fb..0000000 --- a/packages/starlight-site-graph/config.ts +++ /dev/null @@ -1,955 +0,0 @@ -import { AstroError } from 'astro/errors'; -import { z } from 'astro/zod'; - -export type GraphConfig = z.infer; -export type SitemapConfig = z.infer; - -const easingTypes = z.union([ - z.literal('in_quad'), - z.literal('out_quad'), - z.literal('in_out_quad'), - z.literal('linear'), -]); - -const nodeColorTypes = z.union([ - z.literal('backgroundColor'), - z.literal('nodeColor'), - z.literal('nodeColorVisited'), - z.literal('nodeColorCurrent'), - z.literal('nodeColorUnresolved'), - z.literal('nodeColorExternal'), - z.literal('nodeColorTag'), - - z.literal('nodeColor1'), - z.literal('nodeColor2'), - z.literal('nodeColor3'), - z.literal('nodeColor4'), - z.literal('nodeColor5'), - z.literal('nodeColor6'), - z.literal('nodeColor7'), - z.literal('nodeColor8'), - z.literal('nodeColor9'), - z.literal('linkColor'), -]); - -const percentageSchema = z.union([z.number().min(0, "Shape corner radius may not be negative"), z.string() - .refine(val => val.match(/^\d+\.?\d?\d?%?$/), { - message: 'Invalid percentage format, expected a string in the format "XX%"', - }) - .refine((s) => parseFloat(s) >= 0 && parseFloat(s) <= 100, { - message: 'Invalid percentage value, expected a number between 0 and 100', - })] -) - -const nodeShapeTypes = z.union([ - /** - * Circular shape - */ - z.literal('circle'), - /** - * Square shape \ - * Defined as `polygon` with `shapePoints` set to 4 - */ - z.literal('square'), - /** - * Triangle shape \ - * Defined as `polygon` with `shapePoints` set to 3 - */ - z.literal('triangle'), - /** - * Regular polygon shape \ - * Defined by `shapePoints` - */ - z.literal('polygon'), - /** - * Regular star 2n-polygon shape \ - * Defined by `shapePoints` - */ - z.literal('star'), -]); -export type NodeShapeType = z.infer; - -export const nodeStyle = z.object({ - /** - * Shape of the node in the graph - * - `circle`: Circular shape - * - `square`: Square shape - * - `triangle`: Triangle shape - * - `polygon`: Polygon shape (with `shapePoints` vertices) - * - `star`: Star shape (with `shapePoints` spikes) - * - * @default "circle" - */ - shape: nodeShapeTypes.default('circle'), - /** - * Size of the node in the graph, further scaled by `linkScale` - * - * @default 10 - */ - shapeSize: z.number().gt(0, "Shape size may not be zero or negative").default(10), - /** - * Color of the node shape in the graph, overridden if the node is visited, current, or unresolved - * If set to `'stroke'`, the color will be taken from the stroke color, if it exists, otherwise defaults to `nodeColor` - * - * @default "nodeColor" - */ - shapeColor: nodeColorTypes.or(z.literal('stroke')).default('nodeColor').optional(), - /** - * Number of points for `polygon` or `star` shapes - * - * @optional - */ - shapePoints: z.number().min(2, "The number of points for the shape may not be smaller than 2").optional(), - /** - * Rotation of the polygon or star shape in degrees. \ - * If set to `'random'`, the shape will be rotated randomly. - * - * @optional - */ - shapeRotation: z.union([z.number(), z.literal('random')]).optional(), - /** - * Radius of the shape (and, if not specified, stroke corners); does not affect circle shapes \ - * A number will be parsed as the radius of the corner in pts (clamped to `shapeWidth`). \ - * A string (e.g. `'17.648%'`) will be parsed as the radius of the corner relative to the shape size. - * - * @remarks High values of `shapeCornerRadius` will result in link connections not being rendered correctly - * @optional - */ - shapeCornerRadius: percentageSchema.optional(), - - /** - * Type of corner for the shape and stroke - * - `normal`: Normal corners - * - `round`: Rounded corners - * - `bevel`: Beveled corners - * - * @optional - */ - cornerType: z.union([z.literal('normal'), z.literal('round'), z.literal('bevel')]).optional(), - - /** - * Stroke width of the node in the graph - * - * @default 0 - */ - strokeWidth: z.number().min(0).default(0), - /** - * Stroke color of the node in the graph - * If none is specified, the stroke color will be the same as the shape color - * - * @optional - */ - strokeColor: nodeColorTypes.or(z.literal('inherit')).optional(), - /** - * Radius of the stroke corners; does not affect circle shapes \ - * A number will be parsed as the radius of the corner in pts (clamped to `shapeWidth`). \ - * A string (e.g. `'17.648%'`) will be parsed as the radius of the corner relative to the shape size. - * - * @remarks High values of `shapeCornerRadius` will result in link connections not being rendered correctly - * @optional - */ - strokeCornerRadius: percentageSchema.optional(), - - /** - * Scale of the shape collider user for collision forces - * A value higher than 1 will make the collider larger than the shape - * - * @default 1 - */ - colliderScale: z.number().min(0).default(1), - /** - * Scale factor for the node size. - * - * @default 1 - */ - nodeScale: z.number().min(0).default(1), - /** - * Scale strength of the node based on the number of neighbors (incoming and outgoing links). \ - * When set to 0, the node size will not be affected by the number of neighbors. - * - * @default 0.5 - */ - neighborScale: z.number().min(0).default(0.5), -}); - -export type NodeStyle = z.infer; - -const sitemapEntrySchema = z.object({ - /** - * Whether the page is external (i.e. not part of the website) - * @remarks Used for links to other websites - */ - external: z.boolean(), - /** - * Whether the page exists - * @remarks Used for unresolved pages - */ - exists: z.boolean(), - /** - * The title of the page - */ - title: z.string(), - /** - * The links going out from the page - * - * @optional - */ - links: z.array(z.string()).optional(), - /** - * The backlinks going into the page - * - * @optional - */ - backlinks: z.array(z.string()).optional(), - /** - * The tags associated with the page - * - * @optional - */ - tags: z.array(z.string()).optional(), - /** - * The style of the node in the graph - */ - nodeStyle: nodeStyle.partial().optional(), -}); - -export type SitemapEntry = z.infer; - -const sitemapSchema = z.record(z.string(), sitemapEntrySchema); - -export type Sitemap = z.infer; - -export const graphConfigSchema = z.object({ - /** - * The actions available within the graph component - * - * - `fullscreen`: Toggle fullscreen mode - * - `depth`: Increase the depth of the graph - * - `reset-zoom`: Reset the zoom level and center the graph on node of current page - * - `render-arrows`: Toggle the rendering of arrows - * - `render-external`: Toggle the rendering of nodes representing external pages - * - `render-unresolved`: Toggle the rendering of nodes representing unresolved pages - * - `settings`: Open the simulation settings modal - * - * @default ["fullscreen", "depth", "reset-zoom", "render-arrows", "settings"] - */ - actions: z - .array( - z.union([ - z.literal('fullscreen'), - z.literal('depth'), - z.literal('reset-zoom'), - z.literal('render-arrows'), - z.literal('render-external'), - z.literal('render-unresolved'), - z.literal('settings') - ]), - ) - .default(['fullscreen', 'depth', 'reset-zoom', 'render-arrows', 'settings']), - - /** - * Define shape, color and size, and stroke of specified tags - * - * @default { } - * @example The "index" tag is visualized a circle of size 12, with no stroke - * { "index": { shapeColor: "nodeColor1", shape: "circle", shapeSize: 12, strokeWidth: 0 } } - */ - tagStyles: z - .record( - z.string().transform(val => (!val.startsWith('#') ? '#' + val : val)), - nodeStyle.partial(), - ) - .default({}), - /** - * How tags should be rendered in the graph - * - `none`: Tags are not rendered at all - * - `node`: Tags are rendered as separate nodes (connected to all nodes that contain the tag) \ - * The style of the tag is determined by the associated tag style in `tagStyles` (or `tagDefaultStyle` if none was specified) - * - `same`: Nodes assume the style of the associated tag(s) defined in `tagStyles` \ - * If multiple tags with different styles are attached to the node, the first defined style for each tag is used - * - `both`: Tags are rendered as separate nodes and nodes assume the style of the associated tag(s) defined in `tagStyles` - * - * @default "none" - */ - tagRenderMode: z - .union([z.literal('none'), z.literal('node'), z.literal('same'), z.literal('both')]) - .default('none'), - - /** - * Whether to enable user dragging of nodes in the graph - * - * @default true - */ - enableDrag: z.boolean().default(true), - /** - * Whether to enable user zooming of the graph - * - * @default true - */ - enableZoom: z.boolean().default(true), - /** - * Whether to enable user panning of the graph (i.e. moving left/right/up/down) - */ - enablePan: z.boolean().default(true), - /** - * Whether to enable hover interactions on the graph - * This includes highlighting nodes and links on hover, and showing labels - * - * @default true - */ - enableHover: z.boolean().default(true), - /** - * The mode of interaction to trigger the page navigation - * - * - `auto`: Require double click for mobile devices with touch input, single click otherwise - * - `disable`: Disable all interactions - * - `click`: Always require a single click - * - `dblclick`: Always require a double click - * - * @default "auto" - */ - enableClick: z - .union([z.literal('auto'), z.literal('disable'), z.literal('click'), z.literal('dblclick')]) - .default('auto'), - - /** - * The depth of the graph, determines how many levels of links are shown. \ - * - `-x`: Show the entire graph (the particular value does not matter) - * - `0`: Only the current page is shown - * - `1`: The current page and its direct neighbors are shown - * - `x`: The current page and its neighbors up to `depth` levels are shown - * - * The graph will be traversed with the `depthDirection` option. - * - * @remarks For performance reasons, the depth is capped at `6`. - * @default 1 - */ - depth: z.number().default(1), - /** - * In which direction the depth of the graph should be expanded - * - `both`: Expand in both incoming and outgoing directions - * - `incoming`: Expand only in the incoming direction - * - `outgoing`: Expand only in the outgoing direction - * - * @default "both" - */ - depthDirection: z.union([z.literal('both'), z.literal('incoming'), z.literal('outgoing')]).default('both'), - - /** - * Determine what should happen when a link is followed - * - `same`: Open the link in the same tab - * - `new-tab`: Open the link in a new tab - * - `graph`: Set the link as the current node in the graph - * - * @remark This does _not_ apply to external links, which will always open in a new tab - * @default "tab" - */ - followLink: z.union([z.literal('same'), z.literal('new-tab'), z.literal('graph')]).default('same'), - - - /** - * The scale of the graph, determines the zoom level - * - * @default 1.1 - */ - scale: z.number().gt(0, "Graph scale may not be zero or negative").default(1.1), - /** - * The minimum zoom level of the graph - * - * @default 0.05 - */ - minZoom: z.number().gt(0, "Graph zoom may not be zero or negative").default(0.05), - /** - * The maximum zoom level of the graph - * - * @default 4 - */ - maxZoom: z.number().gt(0, "Graph zoom may not be zero or negative").default(4), - - /** - * Whether to render page title labels on the nodes - * - * @default true - */ - renderLabels: z.boolean().default(true), - /** - * Whether to render arrows on the links - * - * @default true - */ - renderArrows: z.boolean().default(false), - /** - * Whether to render unresolved pages in the graph - * - * @default false - */ - renderUnresolved: z.boolean().default(false), - /** - * Whether to render external pages in the graph - * - * @remarks External nodes only exist in the sitemap if `includeExternalLinks` of `sitemapConfig` is set to `true`. - * @default true - */ - renderExternal: z.boolean().default(true), - - /** - * Whether to scale the links based on the zoom level - * - * @default true - */ - scaleLinks: z.boolean().default(true), - /** - * Whether to scale the arrows based on the zoom level - * - * @default false - */ - scaleArrows: z.boolean().default(true), - /** - * Minimum zoom level at which the arrows are rendered \ - * When 0, arrows will always be rendered - * - * @default 0.8 - */ - minZoomArrows: z.number().min(0, "Minimum zoom for arrow rendering may not be negative").default(0.8), - - /** - * The scale factor for the opacity of the labels, based on the zoom level - * A higher value will make the labels more opaque at lower zoom levels - * - * @default 1.3 - */ - labelOpacityScale: z.number().min(0, "Opacity scale for labels may not be negative").default(1.3), - /** - * The opacity of unhovered labels (when hovering over a node) - * - * @default 0 - */ - labelMutedOpacity: z.number().min(0, "Opacity scale for muted labels may not be negative").default(0), - /** - * The opacity of the label when hovering over a node - * - * @default 1 - */ - labelHoverOpacity: z.number().min(0, "Opacity scale for hovered labels may not be negative").default(1), - /** - * The opacity of the label when adjacent to the hovered node. \ - * If explicitly set to undefined, the `labelMutedOpacity` will be used. - */ - labelAdjacentOpacity: z.number().min(0, "Opacity scale for hovered labels may not be negative").optional().default(1), - /** - * The font size of the labels - * - * @remarks Labels should be disabled using the `renderLabels` option - * @default 12 - */ - labelFontSize: z.number().min(0, "Label font size may not be negative").default(12), - /** - * The scale of the label when hovering over a node - * - * @default 1 - */ - labelHoverScale: z.number().min(0, "Label hover scale may not be negative").default(1), - /** - * The offset of the labels from the nodes - * - * @default 10 - */ - labelOffset: z.number().min(0, "Label offset may not be negative").default(10), - /** - * The offset of the label from the node when hovering over said node - * - * @default 14 - */ - labelHoverOffset: z.number().min(0, "Label hover offset may not be negative").default(14), - - /** - * The duration of the zoom animation in milliseconds \ - * This controls the speed of zooming and panning - * - * @default 75 - */ - zoomDuration: z.number().min(0, "Zoom duration may not be negative").default(75), - /** - * The easing function for the zoom animation - * This controls the acceleration of zooming and panning - * - * @default "out_quad" - */ - zoomEase: easingTypes.default('out_quad'), - /** - * The duration of the hover animation in milliseconds - * This controls the speed of the node/link/label highlighting transitions - * - * @default 200 - */ - hoverDuration: z.number().min(0, "Hover duration may not be negative").default(200), - /** - * The easing function for the hover animation - * This controls the acceleration of the node/link/label highlighting transitions - * - * @default "out_quad" - */ - hoverEase: easingTypes.default('out_quad'), - - /** - * The default style of a node in the graph. \ - * All other styles will overwrite these values. - * - * @default ```{ - * shape: "circle", - * shapeColor: "nodeColor", - * shapeSize: 10, - * strokeWidth: 0, - * colliderScale: 1, - * nodeScale: 1, - * neighborScale: 0.5 - * }``` - */ - nodeDefaultStyle: nodeStyle - .optional() - .transform(val => ({ - shape: 'circle', - shapeColor: 'nodeColor', - shapeSize: 10, - strokeWidth: 0, - colliderScale: 1, - nodeScale: 1, - neighborScale: 0.5, - ...val, - })), - /** - * The style of node representing a visited page in the graph. \ - * This style overwrites styles defined in `nodeDefaultStyle`. - * - * @default { shapeColor: "nodeColorVisited" } - */ - nodeVisitedStyle: nodeStyle - .partial() - .optional() - .transform(val => ({ - shapeColor: 'nodeColorVisited', - ...val, - })), - /** - * The style of node representing the current page in the graph. \ - * This style overwrites styles defined in `nodeDefaultStyle` and matching `tagStyles`. - * - * @default { shapeColor: "nodeColorCurrent" } - */ - nodeCurrentStyle: nodeStyle - .partial() - .optional() - .transform(val => ({ - shapeColor: 'nodeColorCurrent', - ...val, - })), - /** - * The style of node representing an unresolved page in the graph. \ - * This style overwrites styles defined in `nodeDefaultStyle`, matching `tagStyles`, and `nodeCurrentStyle`. - * - * @default { shapeColor: "nodeColorUnresolved" } - */ - nodeUnresolvedStyle: nodeStyle - .partial() - .optional() - .transform(val => ({ - shapeColor: 'nodeColorUnresolved', - ...val, - })), - /** - * The style of node representing an external page in the graph. \ - * This style overwrites styles defined in `nodeDefaultStyle`, matching `tagStyles`, and `nodeCurrentStyle`. - * External nodes only exist in the sitemap if `includeExternalLinks` of `sitemapConfig` is set to `true`. - * - * @default { shape: "square", shapeColor: "nodeColorExternal", strokeColor: "inherit", nodeScale: 0.8 } - */ - nodeExternalStyle: nodeStyle - .partial() - .optional() - .transform(val => ({ - shape: 'square', - shapeColor: 'nodeColorExternal', - strokeColor: 'inherit', - nodeScale: 0.6, - ...val, - })), - /** - * Default style of tag nodes in the graph - * - * @default { shape: 'circle', shapeSize: 6, shapeColor: 'backgroundColor', strokeColor: "nodeColorTag", strokeWidth: 1, colliderScale: 1, nodeScale: 1, neighborScale: 0.7 } - */ - tagDefaultStyle: nodeStyle - .partial() - .optional() - .transform(val => ({ - shape: 'circle', - shapeSize: 6, - shapeColor: 'backgroundColor', - strokeColor: 'nodeColorTag', - strokeWidth: 1, - colliderScale: 1, - nodeScale: 1, - neighborScale: 0.7, - ...val, - })), - - /** - * The width of the links in the graph - * - * @default 1 - */ - linkWidth: z.number().min(0, "Link width may not be negative").default(1), - /** - * The width of the hovered links in the graph - * - * @default 1 - */ - linkHoverWidth: z.number().min(0, "Hover link width may not be negative").default(1), - - /** - * The size of the arrows on the links - * - * @default 5 - */ - arrowSize: z.number().min(0, "Arrow size may not be negative").default(5), - /** - * The angle of the arrowhead of the links, a smaller angle will make the arrowhead pointier - * - * @default Math.PI / 6 - */ - arrowAngle: z.number().min(0, "Arrow angle may not be negative").default(Math.PI / 6), - - /** - * The strength of the force that pulls nodes towards the center of the graph. \ - * A higher value will bring nodes closer together - * - * @default 0.05 - */ - centerForce: z.number().min(0, "Center force may not be negative").default(0.05), - /** - * The collision force between nodes in the graph. \ - * A higher value will make nodes repel each other more strongly, creating an even, grid-like layout - * - * @default 20 - */ - colliderPadding: z.number().min(0, "Collider padding may not be negative").default(20), - /** - * The attraction/repulsion force between nodes in the graph. \ - * A higher value will increase the distance between nodes - * - * @default 200 - */ - repelForce: z.number().min(0, "Repel force may not be negative").default(200), - /** - * The distance between linked nodes in the graph. \ - * If set to 0, link distance are determined by the force simulation - * - * @default 0 - */ - linkDistance: z.number().min(0, "Link distance may not be negative").default(0), - /** - * The speed at which the graph stabilizes after a simulation update. \ - * A higher value will make the graph stabilize faster, but may result in a less accurate layout. \ - * If set to 0, the graph will run continuously without stabilization. - * - * @default 0.228 - */ - alphaDecay: z.number().min(0, "Alpha decay may not be negative").max(1, "Alpha decay may not be greater than 1").default(0.0228), -}); - -const globalGraphConfigSchema = graphConfigSchema.extend({ - /** - * Configure the visibility of the graph component in the sidebar with an ordered list of rules. - * The graph is hidden/shown if the page's _slug_ matches one of the rules. - * When a rule starts with `!`, the graph is _hidden_ if matched. - * Rules are evaluated in order, the first matching rule determines the visibility of the page. - * If visibility of the graph was specified in the page frontmatter, it will take precedence over these rules. - * - * @default Graph is visible for all pages - * ["**\/*"] - * @example Only show graph for pages in the "api" folder: - * ["api/**"] - * @example Show graph for all pages except those in the "secret" folder: - * ["!secret/**", "**\/*"] - * @see https://github.com/mrmlnc/fast-glob#basic-syntax - */ - visibilityRules: z.array(z.string()).default(['**/*']), - /** - * Whether to prefetch pages on hover in the sidebar graph component. - * - * @default true - */ - prefetchPages: z.boolean().default(true), -}); - -const globalBacklinksConfigSchema = z.object({ - /** - * Configure the visibility of the backlinks component in the sidebar with an ordered list of rules. - * The backlinks are hidden/shown if the page's _slug_ matches one of the rules. - * When a rule starts with `!`, the backlinks are _hidden_ if matched. - * Rules are evaluated in order, the first matching rule determines the visibility of the page. - * If visibility of the backlinks was specified in the page frontmatter, it will take precedence over these rules. - * - * @default Backlinks are visible for all pages - * ["**\/*"] - * @example Only show backlinks for pages in the "api" folder: - * ["api/**"] - * @example Show backlinks for all pages except those in the "secret" folder: - * ["!secret/**", "**\/*"] - * @see https://github.com/mrmlnc/fast-glob#basic-syntax - */ - visibilityRules: z.array(z.string()).default(['**/*']), -}); - -const globalSitemapConfigSchema = z.object({ - /** - * The root directory of the content used to generate links from for the sitemap - * - * @default "./src/content/docs" - */ - contentRoot: z.string().default('./src/content/docs'), - - /** - * Include links going to external websites in the sitemap - * - * @default false - */ - includeExternalLinks: z.boolean().default(false), - - /** - * Specify a custom sitemap to be used for the PageSidebar graph component. - * If unspecified, a sitemap will be generated from the content directory (see `contentRoot`), using the `pageInclusionRules` and `linkInclusionRules`. - * - * @default undefined - */ - sitemap: sitemapSchema.optional(), - - /** - * Title of nodes for specific nodes of the graph (including external nodes). \ - * **Overrides** the title of the page specified in the frontmatter, but - * can be **overridden** by the `sitemap.pageTitle` frontmatter field. \ - * The specified link should match the full path of the page or external link. - * - * @example The node with endpoint "BASEPATH/intro" should be called "Main" (instead of its frontmatter title "Introduction") - * { "BASEPATH/intro": "Main" } - */ - pageTitles: z.record(z.string(), z.string()).default({}), - - - /** - * Ignore links produced by Starlight which exist on every page. - * Specifically, these are: - * - `/`: The root link, which exists within the title - * - `social`: Any social link - * - `edit`: The link for editing the current page - * - `credits`: The "Starlight Attribution" link - * - Pagination links - * - * All links of the sidebar are _always_ ignored. - * These ignore rules will be added to the `pageInclusionRules` setting (inserted _before_ the last rule). - * - * @default true - */ - ignoreStarlightLinks: z.boolean().default(true), - /** - * Determine the inclusion of files in the sitemap based on provided ordered list of rules. - * The page is included/excluded if the file's _path_ matches one of the rules. - * When a rule starts with `!`, the file is _excluded_ if matched. - * Rules are evaluated in order, the first matching rule determines the inclusion of the file. - * If sitemap inclusion was specified in the page frontmatter, it will take precedence over these rules. - * - * @default Sitemap includes all files by default - * ["**\/*"] - * @example Only include files in the "api" folder: - * ["api/**", "!**\/*"] - * @example Include all files except those in the "secret" folder: - * ["!secret/**", "**\/*"] - */ - pageInclusionRules: z.array(z.string()).default(['**/*']), - - /** - * Determine which links are included in the sitemap for every page. - * The link is included/excluded if the link's target _path_ matches one of the rules. - * When a rule starts with `!`, the link is _excluded_ if matched. - * Rules are evaluated in order, the first matching rule determines the inclusion of the link. - * Link rules specified in the page frontmatter take precedence over these rules. - * - * @default Sitemap includes all links by default - * ["**\/*"] - * @example Only include links to endpoints in the "api" subdirectory: - * ["api/**"] - * @example Include all links except those to the "secret" subdirectory: - * ["!secret/**", "**\/*"] - * @example Remove external links to GitHub for "Edit page": - * ["!https://**\/edit/**", "**\/*"] - */ - linkInclusionRules: z.array(z.string()).default(['**/*']), - - /** - * Determine which pages should be associated with specific tags based on provided ordered list of rules. \ - * A tag is added to the page if the file's _path_ matches one of the rules. \ - * When a rule starts with `!`, if matched, it will _remove_ the tag from the page _(not from the file!)_, if it exists. \ - * Rules are evaluated in order, the first matching rule determines whether the tag is added. \ - * Tags generated from the rules will be combined with tags specified in the page frontmatter. - * - * @default {} - * @example Add the "api" tag to all pages in the "api" folder: - * { "api": ["api/**"] } - * @example Add the "secret" tag to all pages except those in the "public" folder, will remove existing "secret" tags in the "public" folder: - * { "secret": ["!public/**", "**\/*"] } - */ - tagRules: z.record(z.string(), z.array(z.string())).default({}), - - /** - * Specify styles to be applied to pages based on provided ordered list of rules. \ - * The style is applied to the page if the file's _path_ matches one of the rules. \ - * When a rule starts with `!`, the style will not be applied if matched. \ - * Styles generated from these rules take precedence over all styles except those specified in the page frontmatter. - * - * @remarks `tagStyles` in conjunction with `tagRules` will accomplish the same thing. - * - * @default {} - * @example Make all nodes in the "api" folder take the color of `nodeColor5` (lime) - * { ["api/**"]: { shapeColor: "nodeColor5" } } - * @example Make the shape of all nodes except those in the "public" folder doubly as large and hollow - * { ["!public/**", "**\/*"]: { nodeScale: 2, strokeWidth: "2", shapeColor: "backgroundColor" } } - */ - styleRules: z.map(z.array(z.string()), nodeStyle.partial()).default(new Map()), -}); - -export const starlightSiteGraphConfigSchema = z - .object({ - /** - * Whether debug mode is enabled - * - Adds a frametime counter to the graph - */ - debug: z.boolean().default(false), - - /** - * Whether to track pages of the website that were visited by the user. - * If disabled, the graph will not show visited pages in a different style (defined by `nodeVisitedStyle`). \ - * The tracking can be set to: - * - `disable`: Do not track visited pages - * - `session`: Track visited pages for the current session - * - `local`: Track visited pages across sessions - * - * @default "session" - */ - trackVisitedPages: z.union([z.literal('disable'), z.literal('session'), z.literal('local')]).default('session'), - - /** - * Configuration for the PageSidebar graph component. - * - * @default ```{ - * visibilityRules: ["**\/*"], - * prefetchPages: true, - * - * actions: ['fullscreen', 'depth', 'reset-zoom', 'render-arrows', 'settings'], - * - * tagStyles: {}, - * tagRenderMode: 'none', - * - * enableDrag: true, - * enableZoom: true, - * enableHover: true, - * enableClick: 'auto', - * depth: 1, - * depthDirection: 'both', - * scale: 1.1, - * minZoom: 0.05, - * maxZoom: 4, - * - * renderLabels: true, - * renderArrows: true, - * renderUnresolved: false, - * - * scaleLinks: true, - * scaleArrows: false, - * minZoomArrows: 0.5, - * - * labelOpacityScale: 1.3, - * labelMutedOpacity: 0, - * labelHoverOpacity: 1, - * labelFontSize: 12, - * labelOffset: 10, - * labelHoverOffset: 14, - * labelScaleHover: 1, - * - * zoomDuration: 75, - * zoomEase: "out_quad", - * hoverDuration: 200, - * hoverEase: "out_quad", - * - * nodeDefaultStyle: { - * shape: "circle", - * shapeColor: "nodeColor", - * shapeSize: 6, - * strokeWidth: 0, - * colliderScale: 1, - * nodeScale: 1, - * neighborScale: 0.5 - * }, - * nodeVisitedStyle: { shapeColor: "nodeColorVisited" }, - * nodeCurrentStyle: { shapeColor: "nodeColorCurrent" }, - * nodeUnresolvedStyle: { color: "nodeColorUnresolved" }, - * nodeExternalStyle: { shape: "square", shapeColor: "nodeColorExternal", strokeColor: "inherit", nodeScale: 0.8 }, - * tagDefaultStyle: { shape: 'circle', shapeSize: 6, shapeColor: 'backgroundColor', strokeColor: "nodeColorTag", strokeWidth: 1, colliderScale: 1, nodeScale: 1, neighborScale: 0.7 }, - * - * linkWidth: 1, - * linkHoverWidth: 1, - * - * arrowSize: 5, - * arrowAngle: Math.PI / 6, - * - * centerForce: 0.05, - * colliderPadding: 20, - * repelForce: 200, - * linkDistance: 0, - * alphaDecay: 0.0228 - * }``` - */ - graphConfig: globalGraphConfigSchema.default({}), - - /** - * Configuration for the sitemap generation. - * - * @default ```{ - * contentRoot: './src/content/docs', - * includeExternalLinks: false, - * pageInclusionRules: ["**\/*"], - * linkInclusionRules: ["**\/*"], - * }``` - */ - sitemapConfig: globalSitemapConfigSchema.default({}), - - /** - * Configuration for the PageSidebar backlinks component. - * - * @default ```{ - * visibilityRules: ["**\/*"], - * }``` - */ - backlinksConfig: globalBacklinksConfigSchema.default({}), - }) - .default({}); - -export type StarlightSiteGraphConfig = z.infer; - -export function validateConfig(userConfig: unknown): StarlightSiteGraphConfig { - const config = starlightSiteGraphConfigSchema.safeParse(userConfig); - - if (!config.success) { - const errors = config.error.flatten(); - throw new AstroError( - `Invalid starlight-site-graph configuration: - - ${errors.formErrors.map(formError => ` - ${formError}`).join('\n')} - ${Object.entries(errors.fieldErrors) - .map(([fieldName, fieldErrors]) => `- ${fieldName}: ${JSON.stringify(fieldErrors)}`) - .join('\n')} - `, - ); - } - - return config.data; -} diff --git a/packages/starlight-site-graph/config/backlinks.ts b/packages/starlight-site-graph/config/backlinks.ts new file mode 100644 index 0000000..45b183b --- /dev/null +++ b/packages/starlight-site-graph/config/backlinks.ts @@ -0,0 +1,26 @@ +import { z } from 'astro/zod'; + +export const globalBacklinksConfig = { + visibilityRules: ['**/*'], +} + +export const globalBacklinksConfigSchema = z.object({ + /** + * Configure the visibility of the backlinks component in the sidebar with an ordered list of rules. + * The backlinks are hidden/shown if the page's _slug_ matches one of the rules. + * When a rule starts with `!`, the backlinks are _hidden_ if matched. + * Rules are evaluated in order, the first matching rule determines the visibility of the page. + * If visibility of the backlinks was specified in the page frontmatter, it will take precedence over these rules. + * + * @default Backlinks are visible for all pages + * ["**\/*"] + * @example Only show backlinks for pages in the "api" folder: + * ["api/**"] + * @example Show backlinks for all pages except those in the "secret" folder: + * ["!secret/**", "**\/*"] + * @see https://github.com/mrmlnc/fast-glob#basic-syntax + */ + visibilityRules: z.array(z.string()).default(globalBacklinksConfig.visibilityRules), +}).partial(); + +export type BacklinksConfig = z.infer; diff --git a/packages/starlight-site-graph/config/base.ts b/packages/starlight-site-graph/config/base.ts new file mode 100644 index 0000000..5d055a8 --- /dev/null +++ b/packages/starlight-site-graph/config/base.ts @@ -0,0 +1,135 @@ +import { z } from 'astro/zod'; + +import { globalGraphConfigSchema, globalGraphConfig } from './graph'; +import { globalSitemapConfigSchema, globalSitemapConfig } from './sitemap'; +import { globalBacklinksConfigSchema, globalBacklinksConfig } from './backlinks'; + + +export const starlightSiteGraphConfig = { + debug: false, + trackVisitedPages: 'session' as 'disable' | 'session' | 'local', + graphConfig: globalGraphConfig, + sitemapConfig: globalSitemapConfig, + backlinksConfig: globalBacklinksConfig, +} + +export const starlightSiteGraphConfigSchema = z + .object({ + /** + * Whether debug mode is enabled + * - Adds a frametime counter to the graph + */ + debug: z.boolean().default(starlightSiteGraphConfig.debug), + + /** + * Whether to track pages of the website that were visited by the user. + * If disabled, the graph will not show visited pages in a different style (defined by `nodeVisitedStyle`). \ + * The tracking can be set to: + * - `disable`: Do not track visited pages + * - `session`: Track visited pages for the current session + * - `local`: Track visited pages across sessions + * + * @default "session" + */ + trackVisitedPages: z + .union([z.literal('disable'), z.literal('session'), z.literal('local')]) + .default(starlightSiteGraphConfig.trackVisitedPages as 'disable' | 'session' | 'local'), + + /** + * Configuration for the PageSidebar graph component. + * + * @default ```{ + * visibilityRules: ["**\/*"], + * prefetchPages: true, + * + * actions: ['fullscreen', 'depth', 'reset-zoom', 'render-arrows', 'settings'], + * + * tagStyles: {}, + * tagRenderMode: 'none', + * + * enableDrag: true, + * enableZoom: true, + * enableHover: true, + * enableClick: 'auto', + * depth: 1, + * depthDirection: 'both', + * scale: 1.1, + * minZoom: 0.05, + * maxZoom: 4, + * + * renderLabels: true, + * renderArrows: true, + * renderUnresolved: false, + * + * scaleLinks: true, + * scaleArrows: false, + * minZoomArrows: 0.5, + * + * labelOpacityScale: 1.3, + * labelMutedOpacity: 0, + * labelHoverOpacity: 1, + * labelFontSize: 12, + * labelOffset: 10, + * labelHoverOffset: 14, + * labelScaleHover: 1, + * + * zoomDuration: 75, + * zoomEase: "out_quad", + * hoverDuration: 200, + * hoverEase: "out_quad", + * + * nodeDefaultStyle: { + * shape: "circle", + * shapeColor: "nodeColor", + * shapeSize: 6, + * strokeWidth: 0, + * colliderScale: 1, + * nodeScale: 1, + * neighborScale: 0.5 + * }, + * nodeVisitedStyle: { shapeColor: "nodeColorVisited" }, + * nodeCurrentStyle: { shapeColor: "nodeColorCurrent" }, + * nodeUnresolvedStyle: { shapeColor: "nodeColorUnresolved" }, + * nodeExternalStyle: { shape: "square", shapeColor: "nodeColorExternal", strokeColor: "inherit", nodeScale: 0.8 }, + * tagDefaultStyle: { shape: 'circle', shapeSize: 6, shapeColor: 'backgroundColor', strokeColor: "nodeColorTag", strokeWidth: 1, colliderScale: 1, nodeScale: 1, neighborScale: 0.7 }, + * + * linkWidth: 1, + * linkHoverWidth: 1, + * + * arrowSize: 5, + * arrowAngle: Math.PI / 6, + * + * centerForce: 0.05, + * colliderPadding: 20, + * repelForce: 200, + * linkDistance: 0, + * alphaDecay: 0.0228 + * }``` + */ + graphConfig: globalGraphConfigSchema.default(starlightSiteGraphConfig.graphConfig), + + /** + * Configuration for the sitemap generation. + * + * @default ```{ + * contentRoot: './src/content/docs', + * includeExternalLinks: false, + * pageInclusionRules: ["**\/*"], + * linkInclusionRules: ["**\/*"], + * }``` + */ + sitemapConfig: globalSitemapConfigSchema.default(starlightSiteGraphConfig.sitemapConfig), + + /** + * Configuration for the PageSidebar backlinks component. + * + * @default ```{ + * visibilityRules: ["**\/*"], + * }``` + */ + backlinksConfig: globalBacklinksConfigSchema.default(starlightSiteGraphConfig.backlinksConfig), + }) + .partial() + .default({}); + +export type StarlightSiteGraphConfig = z.infer; diff --git a/packages/starlight-site-graph/config/graph.ts b/packages/starlight-site-graph/config/graph.ts new file mode 100644 index 0000000..34ac547 --- /dev/null +++ b/packages/starlight-site-graph/config/graph.ts @@ -0,0 +1,498 @@ +import { z } from 'astro/zod'; + +import { + nodeStyleSchema, nodeDefaultStyle, nodeExternalStyle, nodeCurrentStyle, + nodeUnresolvedStyle, nodeVisitedStyle, tagDefaultStyle +} from './node'; + +const easingTypes = z.union([ + z.literal('in_quad'), + z.literal('out_quad'), + z.literal('in_out_quad'), + z.literal('linear'), +]); + +const graphConfig = { + actions: ['fullscreen', 'depth', 'reset-zoom', 'render-arrows', 'settings'] as ('fullscreen' | 'depth' | 'reset-zoom' | 'render-arrows' | 'settings')[], + tagStyles: {}, + tagRenderMode: 'none' as ('none' | 'node' | 'same' | 'both'), + enableDrag: true, + enableZoom: true, + enablePan: true, + enableHover: true, + enableClick: 'auto' as ('auto' | 'disable' | 'click' | 'dblclick'), + depth: 1, + depthDirection: 'both' as ('both' | 'incoming' | 'outgoing'), + followLink: 'same' as ('same' | 'new-tab' | 'graph'), + scale: 1.1, + minZoom: 0.05, + maxZoom: 4, + renderLabels: true, + renderArrows: false, + renderUnresolved: false, + renderExternal: true, + scaleLinks: true, + scaleArrows: true, + minZoomArrows: 0.8, + labelOpacityScale: 1.3, + labelMutedOpacity: 0, + labelHoverOpacity: 1, + labelAdjacentOpacity: 1, + labelFontSize: 12, + labelHoverScale: 1, + labelOffset: 10, + labelHoverOffset: 14, + zoomDuration: 75, + zoomEase: 'out_quad' as 'in_quad' | 'out_quad' | 'in_out_quad' | 'linear', + hoverDuration: 200, + hoverEase: 'out_quad' as 'in_quad' | 'out_quad' | 'in_out_quad' | 'linear', + nodeDefaultStyle: nodeDefaultStyle, + nodeVisitedStyle: nodeVisitedStyle, + nodeCurrentStyle: nodeCurrentStyle, + nodeUnresolvedStyle: nodeUnresolvedStyle, + nodeExternalStyle: nodeExternalStyle, + tagDefaultStyle: tagDefaultStyle, + linkWidth: 1, + linkHoverWidth: 1, + arrowSize: 5, + arrowAngle: Math.PI / 6, + centerForce: 0.05, + colliderPadding: 20, + repelForce: 200, + linkDistance: 0, + alphaDecay: 0.0228, +}; +export const globalGraphConfig = { + ...graphConfig, + visibilityRules: ['**/*'], + prefetchPages: true, +} + +export const graphConfigSchema = z.object({ + /** + * The actions available within the graph component + * + * - `fullscreen`: Toggle fullscreen mode + * - `depth`: Increase the depth of the graph + * - `reset-zoom`: Reset the zoom level and center the graph on node of current page + * - `render-arrows`: Toggle the rendering of arrows + * - `render-external`: Toggle the rendering of nodes representing external pages + * - `render-unresolved`: Toggle the rendering of nodes representing unresolved pages + * - `settings`: Open the simulation settings modal + * + * @default ["fullscreen", "depth", "reset-zoom", "render-arrows", "settings"] + */ + actions: z + .array( + z.union([ + z.literal('fullscreen'), + z.literal('depth'), + z.literal('reset-zoom'), + z.literal('render-arrows'), + z.literal('render-external'), + z.literal('render-unresolved'), + z.literal('settings') + ]), + ) + .default(graphConfig.actions), + + /** + * Define shape, color and size, and stroke of specified tags + * + * @default { } + * @example The "index" tag is visualized a circle of size 12, with no stroke + * { "index": { shapeColor: "nodeColor1", shape: "circle", shapeSize: 12, strokeWidth: 0 } } + */ + tagStyles: z + .record( + z.string().transform(val => (!val.startsWith('#') ? '#' + val : val)), + nodeStyleSchema.partial(), + ) + .default(graphConfig.tagStyles), + /** + * How tags should be rendered in the graph + * - `none`: Tags are not rendered at all + * - `node`: Tags are rendered as separate nodes (connected to all nodes that contain the tag) \ + * The style of the tag is determined by the associated tag style in `tagStyles` (or `tagDefaultStyle` if none was specified) + * - `same`: Nodes assume the style of the associated tag(s) defined in `tagStyles` \ + * If multiple tags with different styles are attached to the node, the first defined style for each tag is used + * - `both`: Tags are rendered as separate nodes and nodes assume the style of the associated tag(s) defined in `tagStyles` + * + * @default "none" + */ + tagRenderMode: z + .union([z.literal('none'), z.literal('node'), z.literal('same'), z.literal('both')]) + .default(graphConfig.tagRenderMode), + + /** + * Whether to enable user dragging of nodes in the graph + * + * @default true + */ + enableDrag: z.boolean().default(graphConfig.enableDrag), + /** + * Whether to enable user zooming of the graph + * + * @default true + */ + enableZoom: z.boolean().default(graphConfig.enableZoom), + /** + * Whether to enable user panning of the graph (i.e. moving left/right/up/down) + */ + enablePan: z.boolean().default(graphConfig.enablePan), + /** + * Whether to enable hover interactions on the graph + * This includes highlighting nodes and links on hover, and showing labels + * + * @default true + */ + enableHover: z.boolean().default(graphConfig.enableHover), + /** + * The mode of interaction to trigger the page navigation + * + * - `auto`: Require double click for mobile devices with touch input, single click otherwise + * - `disable`: Disable all interactions + * - `click`: Always require a single click + * - `dblclick`: Always require a double click + * + * @default "auto" + */ + enableClick: z + .union([z.literal('auto'), z.literal('disable'), z.literal('click'), z.literal('dblclick')]) + .default(graphConfig.enableClick), + + /** + * The depth of the graph, determines how many levels of links are shown. \ + * - `-x`: Show the entire graph (the particular value does not matter) + * - `0`: Only the current page is shown + * - `1`: The current page and its direct neighbors are shown + * - `x`: The current page and its neighbors up to `depth` levels are shown + * + * The graph will be traversed with the `depthDirection` option. + * + * @remarks For performance reasons, the depth is capped at `6`. + * @default 1 + */ + depth: z.number().default(graphConfig.depth), + /** + * In which direction the depth of the graph should be expanded + * - `both`: Expand in both incoming and outgoing directions + * - `incoming`: Expand only in the incoming direction + * - `outgoing`: Expand only in the outgoing direction + * + * @default "both" + */ + depthDirection: z + .union([z.literal('both'), z.literal('incoming'), z.literal('outgoing')]) + .default(graphConfig.depthDirection), + + /** + * Determine what should happen when a link is followed + * - `same`: Open the link in the same tab + * - `new-tab`: Open the link in a new tab + * - `graph`: Set the link as the current node in the graph + * + * @remark This does _not_ apply to external links, which will always open in a new tab + * @default "tab" + */ + followLink: z + .union([z.literal('same'), z.literal('new-tab'), z.literal('graph')]) + .default(graphConfig.followLink), + + + /** + * The scale of the graph, determines the zoom level + * + * @default 1.1 + */ + scale: z.number().gt(0, "Graph scale may not be zero or negative").default(graphConfig.scale), + /** + * The minimum zoom level of the graph + * + * @default 0.05 + */ + minZoom: z.number().gt(0, "Graph zoom may not be zero or negative").default(graphConfig.minZoom), + /** + * The maximum zoom level of the graph + * + * @default 4 + */ + maxZoom: z.number().gt(0, "Graph zoom may not be zero or negative").default(graphConfig.maxZoom), + + /** + * Whether to render page title labels on the nodes + * + * @default true + */ + renderLabels: z.boolean().default(graphConfig.renderLabels), + /** + * Whether to render arrows on the links + * + * @default true + */ + renderArrows: z.boolean().default(graphConfig.renderArrows), + /** + * Whether to render unresolved pages in the graph + * + * @default false + */ + renderUnresolved: z.boolean().default(graphConfig.renderUnresolved), + /** + * Whether to render external pages in the graph + * + * @remarks External nodes only exist in the sitemap if `includeExternalLinks` of `sitemapConfig` is set to `true`. + * @default true + */ + renderExternal: z.boolean().default(graphConfig.renderExternal), + + /** + * Whether to scale the links based on the zoom level + * + * @default true + */ + scaleLinks: z.boolean().default(graphConfig.scaleLinks), + /** + * Whether to scale the arrows based on the zoom level + * + * @default false + */ + scaleArrows: z.boolean().default(graphConfig.scaleArrows), + /** + * Minimum zoom level at which the arrows are rendered \ + * When 0, arrows will always be rendered + * + * @default 0.8 + */ + minZoomArrows: z.number().min(0, "Minimum zoom for arrow rendering may not be negative").default(graphConfig.minZoomArrows), + + /** + * The scale factor for the opacity of the labels, based on the zoom level + * A higher value will make the labels more opaque at lower zoom levels + * + * @default 1.3 + */ + labelOpacityScale: z.number().min(0, "Opacity scale for labels may not be negative").default(graphConfig.labelOpacityScale), + /** + * The opacity of unhovered labels (when hovering over a node) + * + * @default 0 + */ + labelMutedOpacity: z.number().min(0, "Opacity scale for muted labels may not be negative").default(graphConfig.labelMutedOpacity), + /** + * The opacity of the label when hovering over a node + * + * @default 1 + */ + labelHoverOpacity: z.number().min(0, "Opacity scale for hovered labels may not be negative").default(graphConfig.labelHoverOpacity), + /** + * The opacity of the label when adjacent to the hovered node. \ + * If explicitly set to undefined, the `labelMutedOpacity` will be used. + */ + labelAdjacentOpacity: z.number().min(0, "Opacity scale for hovered labels may not be negative").optional().default(graphConfig.labelAdjacentOpacity), + /** + * The font size of the labels + * + * @remarks Labels should be disabled using the `renderLabels` option + * @default 12 + */ + labelFontSize: z.number().min(0, "Label font size may not be negative").default(graphConfig.labelFontSize), + /** + * The scale of the label when hovering over a node + * + * @default 1 + */ + labelHoverScale: z.number().min(0, "Label hover scale may not be negative").default(graphConfig.labelHoverScale), + /** + * The offset of the labels from the nodes + * + * @default 10 + */ + labelOffset: z.number().min(0, "Label offset may not be negative").default(graphConfig.labelOffset), + /** + * The offset of the label from the node when hovering over said node + * + * @default 14 + */ + labelHoverOffset: z.number().min(0, "Label hover offset may not be negative").default(graphConfig.labelHoverOffset), + + /** + * The duration of the zoom animation in milliseconds \ + * This controls the speed of zooming and panning + * + * @default 75 + */ + zoomDuration: z.number().min(0, "Zoom duration may not be negative").default(graphConfig.zoomDuration), + /** + * The easing function for the zoom animation + * This controls the acceleration of zooming and panning + * + * @default "out_quad" + */ + zoomEase: easingTypes.default(graphConfig.zoomEase), + /** + * The duration of the hover animation in milliseconds + * This controls the speed of the node/link/label highlighting transitions + * + * @default 200 + */ + hoverDuration: z.number().min(0, "Hover duration may not be negative").default(graphConfig.hoverDuration), + /** + * The easing function for the hover animation + * This controls the acceleration of the node/link/label highlighting transitions + * + * @default "out_quad" + */ + hoverEase: easingTypes.default(graphConfig.hoverEase), + + /** + * The default style of a node in the graph. \ + * All other styles will overwrite these values. + * + * @default ```{ + * shape: "circle", + * shapeColor: "nodeColor", + * shapeSize: 10, + * strokeWidth: 0, + * colliderScale: 1, + * nodeScale: 1, + * neighborScale: 0.5 + * }``` + */ + nodeDefaultStyle: nodeStyleSchema + .partial() + .optional(), + /** + * The style of node representing a visited page in the graph. \ + * This style overwrites styles defined in `nodeDefaultStyle`. + * + * @default { shapeColor: "nodeColorVisited" } + */ + nodeVisitedStyle: nodeStyleSchema + .partial() + .optional(), + /** + * The style of node representing the current page in the graph. \ + * This style overwrites styles defined in `nodeDefaultStyle` and matching `tagStyles`. + * + * @default { shapeColor: "nodeColorCurrent" } + */ + nodeCurrentStyle: nodeStyleSchema + .partial() + .optional(), + /** + * The style of node representing an unresolved page in the graph. \ + * This style overwrites styles defined in `nodeDefaultStyle`, matching `tagStyles`, and `nodeCurrentStyle`. + * + * @default { shapeColor: "nodeColorUnresolved" } + */ + nodeUnresolvedStyle: nodeStyleSchema + .partial() + .optional(), + /** + * The style of node representing an external page in the graph. \ + * This style overwrites styles defined in `nodeDefaultStyle`, matching `tagStyles`, and `nodeCurrentStyle`. + * External nodes only exist in the sitemap if `includeExternalLinks` of `sitemapConfig` is set to `true`. + * + * @default { shape: "square", shapeColor: "nodeColorExternal", strokeColor: "inherit", nodeScale: 0.8 } + */ + nodeExternalStyle: nodeStyleSchema + .partial() + .optional(), + /** + * Default style of tag nodes in the graph + * + * @default { shape: 'circle', shapeSize: 6, shapeColor: 'backgroundColor', strokeColor: "nodeColorTag", strokeWidth: 1, colliderScale: 1, nodeScale: 1, neighborScale: 0.7 } + */ + tagDefaultStyle: nodeStyleSchema + .partial() + .optional(), + + /** + * The width of the links in the graph + * + * @default 1 + */ + linkWidth: z.number().min(0, "Link width may not be negative").default(1), + /** + * The width of the hovered links in the graph + * + * @default 1 + */ + linkHoverWidth: z.number().min(0, "Hover link width may not be negative").default(1), + + /** + * The size of the arrows on the links + * + * @default 5 + */ + arrowSize: z.number().min(0, "Arrow size may not be negative").default(5), + /** + * The angle of the arrowhead of the links, a smaller angle will make the arrowhead pointier + * + * @default Math.PI / 6 + */ + arrowAngle: z.number().min(0, "Arrow angle may not be negative").default(Math.PI / 6), + + /** + * The strength of the force that pulls nodes towards the center of the graph. \ + * A higher value will bring nodes closer together + * + * @default 0.05 + */ + centerForce: z.number().min(0, "Center force may not be negative").default(0.05), + /** + * The collision force between nodes in the graph. \ + * A higher value will make nodes repel each other more strongly, creating an even, grid-like layout + * + * @default 20 + */ + colliderPadding: z.number().min(0, "Collider padding may not be negative").default(20), + /** + * The attraction/repulsion force between nodes in the graph. \ + * A higher value will increase the distance between nodes + * + * @default 200 + */ + repelForce: z.number().min(0, "Repel force may not be negative").default(200), + /** + * The distance between linked nodes in the graph. \ + * If set to 0, link distance are determined by the force simulation + * + * @default 0 + */ + linkDistance: z.number().min(0, "Link distance may not be negative").default(0), + /** + * The speed at which the graph stabilizes after a simulation update. \ + * A higher value will make the graph stabilize faster, but may result in a less accurate layout. \ + * If set to 0, the graph will run continuously without stabilization. + * + * @default 0.228 + */ + alphaDecay: z.number().min(0, "Alpha decay may not be negative").max(1, "Alpha decay may not be greater than 1").default(0.0228), +}).partial() +export type GraphConfig = z.infer; + +export const globalGraphConfigSchema = graphConfigSchema.extend({ + /** + * Configure the visibility of the graph component in the sidebar with an ordered list of rules. + * The graph is hidden/shown if the page's _slug_ matches one of the rules. + * When a rule starts with `!`, the graph is _hidden_ if matched. + * Rules are evaluated in order, the first matching rule determines the visibility of the page. + * If visibility of the graph was specified in the page frontmatter, it will take precedence over these rules. + * + * @default Graph is visible for all pages + * ["**\/*"] + * @example Only show graph for pages in the "api" folder: + * ["api/**"] + * @example Show graph for all pages except those in the "secret" folder: + * ["!secret/**", "**\/*"] + * @see https://github.com/mrmlnc/fast-glob#basic-syntax + */ + visibilityRules: z.array(z.string()).default(globalGraphConfig.visibilityRules), + /** + * Whether to prefetch pages on hover in the sidebar graph component. + * + * @default true + */ + prefetchPages: z.boolean().default(globalGraphConfig.prefetchPages), +}).partial(); diff --git a/packages/starlight-site-graph/config/index.ts b/packages/starlight-site-graph/config/index.ts new file mode 100644 index 0000000..1b0a5e3 --- /dev/null +++ b/packages/starlight-site-graph/config/index.ts @@ -0,0 +1,65 @@ +import { AstroError } from 'astro/errors'; +import { type StarlightSiteGraphConfig, starlightSiteGraphConfig, starlightSiteGraphConfigSchema } from './base'; + +function isObject(item: any): boolean { + return (item && typeof item === 'object' && !Array.isArray(item)); +} + +/** + * Adapted from https://stackoverflow.com/a/37164538/23278914 + */ +function deepMerge(target: any, source: any): any { + let output = Object.assign({}, target); + if (isObject(target) && isObject(source)) { + for (const [key, value] of Object.entries(source)) { + if (isObject(value)) { + if (!(key in target)) + Object.assign(output, { [key]: value }); + else + output[key] = deepMerge(target[key], value); + } else { + Object.assign(output, { [key]: value }); + } + } + } + return output; +} + +export function validateConfig(userConfig: unknown) { + const config = starlightSiteGraphConfigSchema.safeParse(userConfig); + + if (!config.success) { + const errors = config.error.flatten(); + throw new AstroError( + `Invalid starlight-site-graph configuration: + + ${errors.formErrors.map(formError => ` - ${formError}`).join('\n')} + ${Object.entries(errors.fieldErrors) + .map(([fieldName, fieldErrors]) => `- ${fieldName}: ${JSON.stringify(fieldErrors)}`) + .join('\n')} + `, + ); + } + + // TODO: Investigate how to apply Required<> to inferred type of schema, so comments still stay + // Merge with default settings + return deepMerge(starlightSiteGraphConfig, config.data) as typeof starlightSiteGraphConfig; +} + + +export type RemoveOptional = + T extends (...args: any[]) => any ? T + : T extends object + ? { [K in keyof T]-?: RemoveOptional> } + : T; + + +export type FullStarlightSiteGraphConfig = RemoveOptional; +export { starlightSiteGraphConfig, starlightSiteGraphConfigSchema, type StarlightSiteGraphConfig } from './base'; +export { globalGraphConfig, graphConfigSchema, globalGraphConfigSchema, type GraphConfig } from './graph'; +export { type SitemapEntry, type Sitemap, globalSitemapConfig, globalSitemapConfigSchema, type SitemapConfig } from './sitemap'; +export { + nodeStyleSchema, type NodeStyle, + nodeDefaultStyle, nodeVisitedStyle, nodeCurrentStyle, nodeUnresolvedStyle, nodeExternalStyle, tagDefaultStyle +} from './node'; +export { globalBacklinksConfig, globalBacklinksConfigSchema, type BacklinksConfig } from './backlinks'; diff --git a/packages/starlight-site-graph/config/node.ts b/packages/starlight-site-graph/config/node.ts new file mode 100644 index 0000000..026d8e1 --- /dev/null +++ b/packages/starlight-site-graph/config/node.ts @@ -0,0 +1,201 @@ +import { z } from 'astro/zod'; + +const nodeColorTypes = z.union([ + z.literal('inherit'), + + z.literal('backgroundColor'), + z.literal('nodeColor'), + z.literal('nodeColorVisited'), + z.literal('nodeColorCurrent'), + z.literal('nodeColorUnresolved'), + z.literal('nodeColorExternal'), + z.literal('nodeColorTag'), + + z.literal('nodeColor1'), + z.literal('nodeColor2'), + z.literal('nodeColor3'), + z.literal('nodeColor4'), + z.literal('nodeColor5'), + z.literal('nodeColor6'), + z.literal('nodeColor7'), + z.literal('nodeColor8'), + z.literal('nodeColor9'), + z.literal('linkColor'), +]); + +const percentageSchema = z.union([z.number().min(0, "Shape corner radius may not be negative"), z.string() + .refine(val => val.match(/^\d+\.?\d?\d?%?$/), { + message: 'Invalid percentage format, expected a string in the format "XX%"', + }) + .refine((s) => parseFloat(s) >= 0 && parseFloat(s) <= 100, { + message: 'Invalid percentage value, expected a number between 0 and 100', + })] +) + +const nodeShapeTypes = z.union([ + /** + * Circular shape + */ + z.literal('circle'), + /** + * Square shape \ + * Defined as `polygon` with `shapePoints` set to 4 + */ + z.literal('square'), + /** + * Triangle shape \ + * Defined as `polygon` with `shapePoints` set to 3 + */ + z.literal('triangle'), + /** + * Regular polygon shape \ + * Defined by `shapePoints` + */ + z.literal('polygon'), + /** + * Regular star 2n-polygon shape \ + * Defined by `shapePoints` + */ + z.literal('star'), +]); +export type NodeShapeType = z.infer; +type NodeColorType = z.infer; + +export const nodeStyleSchema = z.object({ + /** + * Shape of the node in the graph + * - `circle`: Circular shape + * - `square`: Square shape + * - `triangle`: Triangle shape + * - `polygon`: Polygon shape (with `shapePoints` vertices) + * - `star`: Star shape (with `shapePoints` spikes) + * + * @default "circle" + */ + shape: nodeShapeTypes.default('circle'), + /** + * Size of the node in the graph, further scaled by `linkScale` + * + * @default 10 + */ + shapeSize: z.number().gt(0, "Shape size may not be zero or negative").default(10), + /** + * Color of the node shape in the graph, overridden if the node is visited, current, or unresolved + * If set to `'stroke'`, the color will be taken from the stroke color, if it exists, otherwise defaults to `nodeColor` + * + * @default "nodeColor" + */ + shapeColor: nodeColorTypes.or(z.literal('stroke')).default('nodeColor').optional(), + /** + * Number of points for `polygon` or `star` shapes + * + * @optional + */ + shapePoints: z.number().min(2, "The number of points for the shape may not be smaller than 2").optional(), + /** + * Rotation of the polygon or star shape in degrees. \ + * If set to `'random'`, the shape will be rotated randomly. + * + * @optional + */ + shapeRotation: z.union([z.number(), z.literal('random')]).optional(), + /** + * Radius of the shape (and, if not specified, stroke corners); does not affect circle shapes \ + * A number will be parsed as the radius of the corner in pts (clamped to `shapeWidth`). \ + * A string (e.g. `'17.648%'`) will be parsed as the radius of the corner relative to the shape size. + * + * @remarks High values of `shapeCornerRadius` will result in link connections not being rendered correctly + * @optional + */ + shapeCornerRadius: percentageSchema.optional(), + + /** + * Type of corner for the shape and stroke + * - `normal`: Normal corners + * - `round`: Rounded corners + * - `bevel`: Beveled corners + * + * @optional + */ + cornerType: z.union([z.literal('normal'), z.literal('round'), z.literal('bevel')]).optional(), + + /** + * Stroke width of the node in the graph + * + * @default 0 + */ + strokeWidth: z.number().min(0).default(0), + /** + * Stroke color of the node in the graph + * If none is specified, the stroke color will be the same as the shape color + * + * @optional + */ + strokeColor: nodeColorTypes.or(z.literal('inherit')).optional(), + /** + * Radius of the stroke corners; does not affect circle shapes \ + * A number will be parsed as the radius of the corner in pts (clamped to `shapeWidth`). \ + * A string (e.g. `'17.648%'`) will be parsed as the radius of the corner relative to the shape size. + * + * @remarks High values of `shapeCornerRadius` will result in link connections not being rendered correctly + * @optional + */ + strokeCornerRadius: percentageSchema.optional(), + + /** + * Scale of the shape collider user for collision forces + * A value higher than 1 will make the collider larger than the shape + * + * @default 1 + */ + colliderScale: z.number().min(0).default(1), + /** + * Scale factor for the node size. + * + * @default 1 + */ + nodeScale: z.number().min(0).default(1), + /** + * Scale strength of the node based on the number of neighbors (incoming and outgoing links). \ + * When set to 0, the node size will not be affected by the number of neighbors. + * + * @default 0.5 + */ + neighborScale: z.number().min(0).default(0.5), +}); + +export type NodeStyle = z.infer; + +export const nodeDefaultStyle = { + shape: "circle" as NodeShapeType, + shapeColor: "nodeColor" as NodeColorType, + shapeSize: 6, + strokeWidth: 0, + colliderScale: 1, + nodeScale: 1, + neighborScale: 0.5 +} + +export const nodeVisitedStyle = { shapeColor: "nodeColorVisited" as NodeColorType }; + +export const nodeCurrentStyle = { shapeColor: "nodeColorCurrent" as NodeColorType }; + +export const nodeUnresolvedStyle = { shapeColor: "nodeColorUnresolved" as NodeColorType }; + +export const nodeExternalStyle = { + shape: "square" as NodeShapeType, + shapeColor: "nodeColorExternal" as NodeColorType, + strokeColor: "inherit" as NodeColorType, + nodeScale: 0.8 +}; + +export const tagDefaultStyle = { + shape: 'circle' as NodeShapeType, + shapeSize: 6, + shapeColor: 'backgroundColor' as NodeColorType, + strokeColor: "nodeColorTag" as NodeColorType, + strokeWidth: 1, + colliderScale: 1, + nodeScale: 1, + neighborScale: 0.7 +} diff --git a/packages/starlight-site-graph/config/sitemap.ts b/packages/starlight-site-graph/config/sitemap.ts new file mode 100644 index 0000000..1fbcbb2 --- /dev/null +++ b/packages/starlight-site-graph/config/sitemap.ts @@ -0,0 +1,177 @@ +import { z } from 'astro/zod'; +import { nodeStyleSchema } from './node'; + + +const sitemapEntrySchema = z.object({ + /** + * Whether the page is external (i.e. not part of the website) + * @remarks Used for links to other websites + */ + external: z.boolean(), + /** + * Whether the page exists + * @remarks Used for unresolved pages + */ + exists: z.boolean(), + /** + * The title of the page + */ + title: z.string(), + /** + * The links going out from the page + * + * @optional + */ + links: z.array(z.string()).optional(), + /** + * The backlinks going into the page + * + * @optional + */ + backlinks: z.array(z.string()).optional(), + /** + * The tags associated with the page + * + * @optional + */ + tags: z.array(z.string()).optional(), + /** + * The style of the node in the graph + */ + nodeStyle: nodeStyleSchema.partial().optional(), +}); + +export type SitemapEntry = z.infer; +const sitemapSchema = z.record(z.string(), sitemapEntrySchema); +export type Sitemap = z.infer; + + +export const globalSitemapConfig = { + contentRoot: './src/content/docs', + includeExternalLinks: false, + sitemap: undefined, + pageTitles: {}, + ignoreStarlightLinks: true, + pageInclusionRules: ['**/*'], + linkInclusionRules: ['**/*'], + tagRules: {}, + styleRules: new Map(), +} + +export const globalSitemapConfigSchema = z.object({ + /** + * The root directory of the content used to generate links from for the sitemap + * + * @default "./src/content/docs" + */ + contentRoot: z.string().default(globalSitemapConfig.contentRoot), + + /** + * Include links going to external websites in the sitemap + * + * @default false + */ + includeExternalLinks: z.boolean().default(globalSitemapConfig.includeExternalLinks), + + /** + * Specify a custom sitemap to be used for the PageSidebar graph component. + * If unspecified, a sitemap will be generated from the content directory (see `contentRoot`), using the `pageInclusionRules` and `linkInclusionRules`. + * + * @default undefined + */ + sitemap: sitemapSchema.optional(), + + /** + * Title of nodes for specific nodes of the graph (including external nodes). \ + * **Overrides** the title of the page specified in the frontmatter, but + * can be **overridden** by the `sitemap.pageTitle` frontmatter field. \ + * The specified link should match the full path of the page or external link. + * + * @example The node with endpoint "BASEPATH/intro" should be called "Main" (instead of its frontmatter title "Introduction") + * { "BASEPATH/intro": "Main" } + */ + pageTitles: z.record(z.string(), z.string()).default(globalSitemapConfig.pageTitles), + + + /** + * Ignore links produced by Starlight which exist on every page. + * Specifically, these are: + * - `/`: The root link, which exists within the title + * - `social`: Any social link + * - `edit`: The link for editing the current page + * - `credits`: The "Starlight Attribution" link + * - Pagination links + * + * All links of the sidebar are _always_ ignored. + * These ignore rules will be added to the `pageInclusionRules` setting (inserted _before_ the last rule). + * + * @default true + */ + ignoreStarlightLinks: z.boolean().default(globalSitemapConfig.ignoreStarlightLinks), + /** + * Determine the inclusion of files in the sitemap based on provided ordered list of rules. + * The page is included/excluded if the file's _path_ matches one of the rules. + * When a rule starts with `!`, the file is _excluded_ if matched. + * Rules are evaluated in order, the first matching rule determines the inclusion of the file. + * If sitemap inclusion was specified in the page frontmatter, it will take precedence over these rules. + * + * @default Sitemap includes all files by default + * ["**\/*"] + * @example Only include files in the "api" folder: + * ["api/**", "!**\/*"] + * @example Include all files except those in the "secret" folder: + * ["!secret/**", "**\/*"] + */ + pageInclusionRules: z.array(z.string()).default(globalSitemapConfig.pageInclusionRules), + + /** + * Determine which links are included in the sitemap for every page. + * The link is included/excluded if the link's target _path_ matches one of the rules. + * When a rule starts with `!`, the link is _excluded_ if matched. + * Rules are evaluated in order, the first matching rule determines the inclusion of the link. + * Link rules specified in the page frontmatter take precedence over these rules. + * + * @default Sitemap includes all links by default + * ["**\/*"] + * @example Only include links to endpoints in the "api" subdirectory: + * ["api/**"] + * @example Include all links except those to the "secret" subdirectory: + * ["!secret/**", "**\/*"] + * @example Remove external links to GitHub for "Edit page": + * ["!https://**\/edit/**", "**\/*"] + */ + linkInclusionRules: z.array(z.string()).default(globalSitemapConfig.linkInclusionRules), + + /** + * Determine which pages should be associated with specific tags based on provided ordered list of rules. \ + * A tag is added to the page if the file's _path_ matches one of the rules. \ + * When a rule starts with `!`, if matched, it will _remove_ the tag from the page _(not from the file!)_, if it exists. \ + * Rules are evaluated in order, the first matching rule determines whether the tag is added. \ + * Tags generated from the rules will be combined with tags specified in the page frontmatter. + * + * @default {} + * @example Add the "api" tag to all pages in the "api" folder: + * { "api": ["api/**"] } + * @example Add the "secret" tag to all pages except those in the "public" folder, will remove existing "secret" tags in the "public" folder: + * { "secret": ["!public/**", "**\/*"] } + */ + tagRules: z.record(z.string(), z.array(z.string())).default(globalSitemapConfig.tagRules), + + /** + * Specify styles to be applied to pages based on provided ordered list of rules. \ + * The style is applied to the page if the file's _path_ matches one of the rules. \ + * When a rule starts with `!`, the style will not be applied if matched. \ + * Styles generated from these rules take precedence over all styles except those specified in the page frontmatter. + * + * @remarks `tagStyles` in conjunction with `tagRules` will accomplish the same thing. + * + * @default {} + * @example Make all nodes in the "api" folder take the color of `nodeColor5` (lime) + * { ["api/**"]: { shapeColor: "nodeColor5" } } + * @example Make the shape of all nodes except those in the "public" folder doubly as large and hollow + * { ["!public/**", "**\/*"]: { nodeScale: 2, strokeWidth: "2", shapeColor: "backgroundColor" } } + */ + styleRules: z.map(z.array(z.string()), nodeStyleSchema.partial()).default(globalSitemapConfig.styleRules), +}).partial(); + +export type SitemapConfig = z.infer; diff --git a/packages/starlight-site-graph/index.ts b/packages/starlight-site-graph/index.ts index 79bb014..afcadc4 100644 --- a/packages/starlight-site-graph/index.ts +++ b/packages/starlight-site-graph/index.ts @@ -10,9 +10,11 @@ export default function plugin(userConfig?: StarlightSiteGraphConfig): Starlight return { name: 'starlight-sitemap-plugin', hooks: { - setup: async ({ addIntegration, config, command, logger, updateConfig, injectTranslations }) => { - if (command === 'preview') - return; + 'i18n:setup'({ injectTranslations }) { + injectTranslations(translations); + }, + 'config:setup': async ({ addIntegration, config, command, logger, updateConfig }) => { + if (command === 'preview') return; if (parsedConfig.sitemapConfig.ignoreStarlightLinks) { let starlightIgnoredLinks = []; @@ -36,8 +38,6 @@ export default function plugin(userConfig?: StarlightSiteGraphConfig): Starlight const componentOverrides: typeof config.components = {}; const customCss: typeof config.customCss = ['starlight-site-graph/styles/common.css']; - injectTranslations(translations); - if (config.components?.PageSidebar) { logger.warn( 'It looks like you already have a `PageSidebar` component override in your Starlight configuration.', diff --git a/packages/starlight-site-graph/integration.ts b/packages/starlight-site-graph/integration.ts index c2b7b3d..e3347c3 100644 --- a/packages/starlight-site-graph/integration.ts +++ b/packages/starlight-site-graph/integration.ts @@ -2,13 +2,14 @@ import fs from 'node:fs'; import { addVirtualImports, defineIntegration } from 'astro-integration-kit'; -import { starlightSiteGraphConfigSchema } from './config'; +import { starlightSiteGraphConfigSchema, type FullStarlightSiteGraphConfig } from './config'; import { SiteMapBuilder } from './sitemap/build'; import { processSitemap } from './sitemap/process'; import { onlyTrailingSlash } from './sitemap/util'; import { fileURLToPath } from 'node:url'; + /** * Generates a static sitemap for all md files in the docs directory inside public/sitemap.json, * consumed by graph generating code @@ -17,8 +18,8 @@ export default defineIntegration({ name: 'starlight-sitemap-integration', optionsSchema: starlightSiteGraphConfigSchema, setup({ name, options }) { - const { sitemapConfig } = options; - const builder = new SiteMapBuilder(sitemapConfig); + let settings = options as FullStarlightSiteGraphConfig; + const builder = new SiteMapBuilder(settings.sitemapConfig); let outputPath: string; return { @@ -31,24 +32,24 @@ export default defineIntegration({ outputPath = fileURLToPath(new URL(".vercel/output/static/", config.root)); } else if (config.adapter?.name === "@astrojs/cloudflare") { outputPath = fileURLToPath(new URL(config.base?.replace(/^\//, ""), config.outDir)); - } else if (config.adapter?.name === "@astrojs/node" && config.output === "hybrid") { + } else if (config.adapter?.name === "@astrojs/node" && config.output === "server") { outputPath = fileURLToPath(config.build.client!); } else { outputPath = fileURLToPath(config.outDir); } - if (!options.sitemapConfig.sitemap) { + if (!settings.sitemapConfig.sitemap) { logger.info( 'Retrieving links from Markdown content' + - (sitemapConfig.pageInclusionRules.length - ? ` (with patterns ${sitemapConfig.pageInclusionRules.join(', ')})` + (settings.sitemapConfig.pageInclusionRules.length + ? ` (with patterns ${settings.sitemapConfig.pageInclusionRules.join(', ')})` : ''), ); - if (sitemapConfig.ignoreStarlightLinks) { + if (settings.sitemapConfig.ignoreStarlightLinks) { let starlightIgnoredLinks = [`!${onlyTrailingSlash(config.base)}`]; - sitemapConfig.linkInclusionRules.splice(-1, 0, ...starlightIgnoredLinks); + settings.sitemapConfig.linkInclusionRules.splice(-1, 0, ...starlightIgnoredLinks); logger.info('Ignoring following Starlight links in sitemap: ' + starlightIgnoredLinks.join(', ')); } @@ -56,18 +57,20 @@ export default defineIntegration({ if (command === 'dev' || command === 'build') { builder.setBasePath(config.base); try { - await fs.promises.access(sitemapConfig.contentRoot); - await builder.addMDContentFolder(sitemapConfig.contentRoot, sitemapConfig.pageInclusionRules) - options.sitemapConfig.sitemap = builder.process().toSitemap(); + await fs.promises.access(settings.sitemapConfig.contentRoot); + await builder.addMDContentFolder(settings.sitemapConfig.contentRoot, settings.sitemapConfig.pageInclusionRules) + settings.sitemapConfig.sitemap = builder.process().toSitemap(); logger.info('Finished retrieving links from Markdown content'); } catch (e) { logger.error('Failed to retrieve links from Markdown content, reason: ' + e); + // TODO: Should virtual config always be added regardless of correct MD content generation? + // Failsafe? return; } } } else { logger.info('Using applied sitemap'); - options.sitemapConfig.sitemap = processSitemap(options.sitemapConfig.sitemap, options); + settings.sitemapConfig.sitemap = processSitemap(settings.sitemapConfig.sitemap, settings); } if (command === 'dev' && options.debug) { @@ -129,23 +132,23 @@ export default defineIntegration({ ); } - if (!Object.keys(options.sitemapConfig.sitemap!).length) { + if (!Object.keys(settings.sitemapConfig.sitemap!).length) { logger.info('Retrieving links from generated HTML content'); try { await fs.promises.access(outputPath); - options.sitemapConfig.sitemap = (await builder - .addHTMLContentFolder(outputPath, sitemapConfig.pageInclusionRules)) + settings.sitemapConfig.sitemap = (await builder + .addHTMLContentFolder(outputPath, settings.sitemapConfig.pageInclusionRules)) .process() .toSitemap(); logger.info('Finished generating sitemap from generated HTML content'); } catch (e) { - options.sitemapConfig.sitemap = builder.process().toSitemap(); + settings.sitemapConfig.sitemap = builder.process().toSitemap(); logger.error('Failed to retrieve links from generated HTML content, reason: ' + e); } } await fs.promises.mkdir(`${outputPath}/sitegraph`, { recursive: true }); - await fs.promises.writeFile(`${outputPath}/sitegraph/sitemap.json`, JSON.stringify(options.sitemapConfig.sitemap, null, 2)); + await fs.promises.writeFile(`${outputPath}/sitegraph/sitemap.json`, JSON.stringify(settings.sitemapConfig.sitemap, null, 2)); logger.info("`sitemap.json` created at `dist/sitegraph`"); } } diff --git a/packages/starlight-site-graph/overrides/PageSidebar.astro b/packages/starlight-site-graph/overrides/PageSidebar.astro index 088354b..3abd839 100644 --- a/packages/starlight-site-graph/overrides/PageSidebar.astro +++ b/packages/starlight-site-graph/overrides/PageSidebar.astro @@ -1,19 +1,18 @@ --- import Default from "@astrojs/starlight/components/PageSidebar.astro"; -import type { Props } from '@astrojs/starlight/props'; import { PageGraph, PageBacklinks } from "../components"; --- - +

{Astro.locals.t('starlight-site-graph.graph')}

- + - +

{Astro.locals.t('starlight-site-graph.backlinks')}

diff --git a/packages/starlight-site-graph/package.json b/packages/starlight-site-graph/package.json index f6f5072..200e5b5 100644 --- a/packages/starlight-site-graph/package.json +++ b/packages/starlight-site-graph/package.json @@ -1,7 +1,7 @@ { "name": "starlight-site-graph", "author": "Fevol", - "version": "0.2.2", + "version": "0.3.0", "license": "MIT", "description": "Starlight plugin adding a graph component to the site's right-sidebar", "type": "module", @@ -11,7 +11,7 @@ "./components": "./components/index.ts", "./styles/common.css": "./styles/common.css", "./schema": "./schema.ts", - "./config": "./config.ts", + "./config": "./config/index.ts", "./integration": "./integration.ts" }, "scripts": {}, @@ -30,19 +30,20 @@ }, "bugs": "https://github.com/fevol/starlight-site-graph/issues", "dependencies": { + "@types/chroma-js": "^3.1.1", "@types/d3": "^7.4.3", - "astro-integration-kit": "^0.16.0", + "astro-integration-kit": "^0.18.0", "chroma-js": "^3.1.2", "d3": "^7.9.0", "gray-matter": "^4.0.3", - "micromatch": "^4.0.7" + "micromatch": "^4.0.8" }, "devDependencies": { "@types/micromatch": "^4.0.9" }, "peerDependencies": { - "@astrojs/starlight": "^0.31.0", - "astro": "^5.1.5", + "@astrojs/starlight": "^0.32.3", + "astro": "^5.5.3", "pixi-stats": "^1.3.10" }, "peerDependenciesMeta": { @@ -53,4 +54,4 @@ "optional": true } } -} \ No newline at end of file +} diff --git a/packages/starlight-site-graph/schema.ts b/packages/starlight-site-graph/schema.ts index a6805f3..cb2e60f 100644 --- a/packages/starlight-site-graph/schema.ts +++ b/packages/starlight-site-graph/schema.ts @@ -1,5 +1,5 @@ import { z } from 'astro/zod'; -import { graphConfigSchema, nodeStyle } from './config'; +import { graphConfigSchema, nodeStyleSchema } from './config'; const pageGraphConfigSchema = graphConfigSchema.extend({ /** @@ -11,7 +11,7 @@ const pageGraphConfigSchema = graphConfigSchema.extend({ * Custom styles for the node defined by this page * Overrides any other styles that may be applied to this node */ - nodeStyle: nodeStyle.partial().optional(), + nodeStyle: nodeStyleSchema.partial().optional(), }); export type PageGraphConfig = z.infer; diff --git a/packages/starlight-site-graph/sitemap/build.ts b/packages/starlight-site-graph/sitemap/build.ts index 67cf115..165820d 100644 --- a/packages/starlight-site-graph/sitemap/build.ts +++ b/packages/starlight-site-graph/sitemap/build.ts @@ -1,4 +1,4 @@ -import type { NodeStyle, Sitemap, SitemapConfig } from '../config'; +import type { NodeStyle, RemoveOptional, Sitemap, SitemapConfig } from '../config'; import fs from 'node:fs'; import path from 'node:path'; import matter from 'gray-matter'; @@ -29,7 +29,7 @@ export class SiteMapBuilder { implicitNameAssociations: Map = new Map(); officialNameAssociations: Map = new Map(); - constructor(private config: SitemapConfig) { + constructor(private config: RemoveOptional) { this.map = new Map(); this.contentRoot = trimSlashes(this.config.contentRoot); this.officialNameAssociations = new Map( @@ -81,13 +81,13 @@ export class SiteMapBuilder { continue; } - let link = tag.match(/href="([^"]*)"/)?.[1] ?? ''; + let link: string | undefined = tag.match(/href="([^"]*)"/)?.[1] ?? ''; const text = extractInnerText(tag); if (link.length && !link.startsWith("#")) { - link = this.resolveLink(linkPath, link, links); - if (link && text.length) { - link = ensureTrailingSlash(link); - this.implicitNameAssociations.set(link, [...(this.implicitNameAssociations.get(link) ?? []), text]); + let resolvedLink = this.resolveLink(linkPath, link, links); + if (resolvedLink && text.length) { + resolvedLink = ensureTrailingSlash(resolvedLink); + this.implicitNameAssociations.set(resolvedLink, [...(this.implicitNameAssociations.get(resolvedLink) ?? []), text]); } } } @@ -252,7 +252,8 @@ export class SiteMapBuilder { /** * Convert the intermediate sitemap to the final sitemap */ - toSitemap(): Sitemap { + toSitemap(): RemoveOptional { + // @ts-expect-error Object has been forcefully made non-optional return Object.fromEntries( Array.from(this.map.entries()).map(([_, entry]) => [entry.linkPath, { external: entry.external, diff --git a/packages/starlight-site-graph/sitemap/process.ts b/packages/starlight-site-graph/sitemap/process.ts index fe0f832..7f45128 100644 --- a/packages/starlight-site-graph/sitemap/process.ts +++ b/packages/starlight-site-graph/sitemap/process.ts @@ -1,10 +1,10 @@ import { ensureLeadingPound, firstMatchingPattern } from './util'; -import type { NodeStyle, Sitemap, StarlightSiteGraphConfig } from '../config'; +import type { FullStarlightSiteGraphConfig, NodeStyle, RemoveOptional, Sitemap } from '../config'; /** * Ensure that the passed sitemap is valid and has all rules applied to it */ -export function processSitemap(sitemap: Sitemap, options: StarlightSiteGraphConfig) { +export function processSitemap(sitemap: RemoveOptional, options: FullStarlightSiteGraphConfig) { for (const [linkPath, entry] of Object.entries(sitemap)) { const tags = new Set(entry.tags); for (const [tag, tagRules] of Object.entries(options.sitemapConfig.tagRules)) { @@ -27,6 +27,8 @@ export function processSitemap(sitemap: Sitemap, options: StarlightSiteGraphConf }; } } + + // @ts-expect-error shapeColor cannot be correctly casted entry.nodeStyle = { ...entry.nodeStyle, ...nodeStyle,