starlight-obsidian 0.13.0 → 0.14.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # starlight-obsidian
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#77](https://github.com/HiDeoo/starlight-obsidian/pull/77) [`5818330`](https://github.com/HiDeoo/starlight-obsidian/commit/5818330456dd11d03f9787f4d9b6a4e68359d02d) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Adds support for Astro v7, drops support for Astro v6.
8
+
9
+ ⚠️ **BREAKING CHANGE:** The minimum supported version of Starlight is now `0.41.0`.
10
+
11
+ Please follow the [upgrade guide](https://github.com/withastro/starlight/releases/tag/%40astrojs%2Fstarlight%400.41.0) to update your project.
12
+
13
+ - [#77](https://github.com/HiDeoo/starlight-obsidian/pull/77) [`5818330`](https://github.com/HiDeoo/starlight-obsidian/commit/5818330456dd11d03f9787f4d9b6a4e68359d02d) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Adds support for the Sätteri Markdown processor.
14
+
3
15
  ## 0.13.0
4
16
 
5
17
  ### Minor Changes
package/index.ts CHANGED
@@ -4,10 +4,10 @@ import type { StarlightPlugin, StarlightUserConfig } from '@astrojs/starlight/ty
4
4
  import type { AstroIntegrationLogger } from 'astro'
5
5
  import { z } from 'astro/zod'
6
6
 
7
+ import { throwPluginError } from './libs/error'
7
8
  import { starlightObsidianIntegration } from './libs/integration'
8
9
  import { getObsidianPaths, getVault } from './libs/obsidian'
9
10
  import { stripLeadingAndTrailingSlashes } from './libs/path'
10
- import { throwUserError } from './libs/plugin'
11
11
  import {
12
12
  addObsidianFiles,
13
13
  getSidebarEntriesPlaceholder,
@@ -158,7 +158,7 @@ function makeStarlightObsidianPlugin(
158
158
  })
159
159
 
160
160
  if (isUsingRemovedSidebarGroupConfig) {
161
- throwUserError(
161
+ throwPluginError(
162
162
  'The `sidebar.label` and `sidebar.collapsed` options have been removed.',
163
163
  'Create a group in your Starlight sidebar configuration, set the `label` and `collapsed` options on that group, and add `obsidianSidebarEntries` to its `items` array.\nFor more information see https://starlight.astro.build/guides/sidebar/#groups',
164
164
  )
@@ -169,13 +169,13 @@ function makeStarlightObsidianPlugin(
169
169
  )
170
170
 
171
171
  if (isUsingDeprecatedCopyStarlightFrontmatter) {
172
- throwUserError(
172
+ throwPluginError(
173
173
  'The `copyStarlightFrontmatter` option has been deprecated in favor of the `copyFrontmatter` option.',
174
174
  'For more information see https://starlight-obsidian.vercel.app/configuration/#copyfrontmatter',
175
175
  )
176
176
  }
177
177
 
178
- throwUserError(
178
+ throwPluginError(
179
179
  `Invalid starlight-obsidian configuration:
180
180
 
181
181
  ${z.prettifyError(parsedConfig.error)}
@@ -231,7 +231,7 @@ ${z.prettifyError(parsedConfig.error)}
231
231
  } catch (error) {
232
232
  logger.error(error instanceof Error ? error.message : String(error))
233
233
 
234
- throwUserError(`Failed to generate Starlight pages from Obsidian vault at '${config.vault}'.`)
234
+ throwPluginError(`Failed to generate Starlight pages from Obsidian vault at '${config.vault}'.`)
235
235
  }
236
236
  }
237
237
 
@@ -1,6 +1,6 @@
1
1
  import { AstroError } from 'astro/errors'
2
2
 
3
- export function throwUserError(message: string, hint?: string): never {
3
+ export function throwPluginError(message: string, hint?: string): never {
4
4
  throw new AstroError(
5
5
  message,
6
6
  hint ??
@@ -1,23 +1,18 @@
1
1
  import type { AstroIntegration } from 'astro'
2
- import rehypeKatex from 'rehype-katex'
3
- import remarkMath from 'remark-math'
4
2
 
5
3
  import type { StarlightObsidianConfig } from '..'
6
4
 
7
- import { rehypeStarlightObsidian } from './rehype'
5
+ import { applyMarkdownPlugin } from './processor'
8
6
  import { vitePluginStarlightObsidianConfig } from './vite'
9
7
 
10
8
  export function starlightObsidianIntegration(config: StarlightObsidianConfig): AstroIntegration {
11
9
  return {
12
10
  name: 'starlight-obsidian-integration',
13
11
  hooks: {
14
- 'astro:config:setup': ({ updateConfig }) => {
15
- updateConfig({
16
- markdown: {
17
- rehypePlugins: [rehypeStarlightObsidian, rehypeKatex],
12
+ 'astro:config:setup': ({ config: astroConfig, updateConfig }) => {
13
+ applyMarkdownPlugin(astroConfig.markdown.processor, config)
18
14
 
19
- remarkPlugins: [[remarkMath, { singleDollarTextMath: config.math.singleDollarTextMath }]],
20
- },
15
+ updateConfig({
21
16
  vite: {
22
17
  plugins: [vitePluginStarlightObsidianConfig(config)],
23
18
  },
package/libs/markdown.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ElementContent } from 'hast'
2
+ import type { Literal } from 'mdast'
1
3
  import { fromMarkdown } from 'mdast-util-from-markdown'
2
4
  import { remark } from 'remark'
3
5
  import remarkFrontmatter from 'remark-frontmatter'
@@ -7,6 +9,8 @@ import { VFile } from 'vfile'
7
9
 
8
10
  import { remarkStarlightObsidian, type TransformContext } from './remark'
9
11
 
12
+ const blockIdentifierRegex = /(?<identifier> *\^(?<name>[\w-]+))$/
13
+
10
14
  let processor: ReturnType<typeof remark> | undefined
11
15
 
12
16
  export async function transformMarkdownToString(
@@ -49,9 +53,34 @@ function getVFile(filePath: string, markdown: string, context: TransformContext)
49
53
  })
50
54
  }
51
55
 
56
+ export function isNodeWithValue(node: ElementContent | undefined): node is NodeWithValue {
57
+ return node !== undefined && 'value' in node
58
+ }
59
+
60
+ export function getBlockIdentifier(node: NodeWithValue): { length: number; name: string } | undefined {
61
+ const match = blockIdentifierRegex.exec(node.value)
62
+ const identifier = match?.groups?.['identifier']
63
+ const name = match?.groups?.['name']
64
+
65
+ if (!identifier || !name) {
66
+ return undefined
67
+ }
68
+
69
+ return { length: identifier.length, name }
70
+ }
71
+
72
+ export function getLastContentChild(children: ElementContent[]) {
73
+ const index = children.findLastIndex((child) => child.type !== 'text' || child.value.trim().length > 0)
74
+ if (index === -1) return undefined
75
+
76
+ return { child: children[index], index }
77
+ }
78
+
52
79
  interface TransformResult {
53
80
  aliases: string[] | undefined
54
81
  content: string
55
82
  skip: boolean
56
83
  type: 'markdown' | 'mdx'
57
84
  }
85
+
86
+ export type NodeWithValue = ElementContent & Literal
package/libs/obsidian.ts CHANGED
@@ -9,9 +9,9 @@ import yaml from 'yaml'
9
9
 
10
10
  import type { StarlightObsidianConfig } from '..'
11
11
 
12
+ import { throwPluginError } from './error'
12
13
  import { isDirectory, isFile } from './fs'
13
14
  import { getExtension, isAnchor, slashify, slugifyPath, stripExtension } from './path'
14
- import { throwUserError } from './plugin'
15
15
  import { isAssetFile } from './starlight'
16
16
 
17
17
  const obsidianAppConfigSchema = z.object({
@@ -48,11 +48,11 @@ export async function getVault(config: StarlightObsidianConfig): Promise<Vault>
48
48
  const vaultPath = path.resolve(config.vault)
49
49
 
50
50
  if (!(await isDirectory(vaultPath))) {
51
- throwUserError(`The provided vault path is not a directory.\n> Provided path: ${vaultPath}`)
51
+ throwPluginError(`The provided vault path is not a directory.\n> Provided path: ${vaultPath}`)
52
52
  }
53
53
 
54
54
  if (!(await isVaultDirectory(config, vaultPath))) {
55
- throwUserError(
55
+ throwPluginError(
56
56
  `The provided vault path is not a valid Obsidian vault directory and does not include an '.obsidian/app.json' file.\n> Provided path: ${vaultPath}`,
57
57
  )
58
58
  }
@@ -0,0 +1,68 @@
1
+ import { katex as satteriKatex } from '@nullpinter/satteri-katex'
2
+ import type { AstroConfig } from 'astro'
3
+ import rehypeKatex from 'rehype-katex'
4
+ import remarkMath from 'remark-math'
5
+
6
+ import type { StarlightObsidianConfig } from '..'
7
+
8
+ import { throwPluginError } from './error'
9
+ import { rehypeStarlightObsidian } from './rehype'
10
+ import { satteriStarlightObsidian } from './satteri'
11
+
12
+ export function applyMarkdownPlugin(processor: MarkdownProcessor, config: StarlightObsidianConfig) {
13
+ if (isSatteriProcessor(processor)) {
14
+ processor.options.features ??= {}
15
+ processor.options.features.math = {
16
+ ...(typeof processor.options.features.math === 'object' ? processor.options.features.math : {}),
17
+ singleDollarTextMath: config.math.singleDollarTextMath,
18
+ }
19
+
20
+ processor.options.mdastPlugins.push(satteriKatex())
21
+ processor.options.hastPlugins.push(satteriStarlightObsidian)
22
+ } else if (isUnifiedProcessor(processor)) {
23
+ processor.options.remarkPlugins.push([remarkMath, { singleDollarTextMath: config.math.singleDollarTextMath }])
24
+ processor.options.rehypePlugins.push([rehypeStarlightObsidian], [rehypeKatex])
25
+ } else {
26
+ throwPluginError("The configured 'markdown.processor' is not supported by the starlight-obsidian plugin.")
27
+ }
28
+ }
29
+
30
+ function isSatteriProcessor(processor: unknown): processor is SatteriMarkdownProcessor {
31
+ if (typeof processor !== 'object' || processor === null) return false
32
+ const candidate = processor as { name?: unknown; options?: { hastPlugins?: unknown; mdastPlugins?: unknown[] } }
33
+ return (
34
+ candidate.name === 'satteri' &&
35
+ Array.isArray(candidate.options?.hastPlugins) &&
36
+ Array.isArray(candidate.options.mdastPlugins)
37
+ )
38
+ }
39
+
40
+ function isUnifiedProcessor(processor: unknown): processor is UnifiedMarkdownProcessor {
41
+ if (typeof processor !== 'object' || processor === null) return false
42
+ const candidate = processor as { name?: unknown; options?: { rehypePlugins?: unknown; remarkPlugins?: unknown[] } }
43
+ return (
44
+ candidate.name === 'unified' &&
45
+ Array.isArray(candidate.options?.rehypePlugins) &&
46
+ Array.isArray(candidate.options.remarkPlugins)
47
+ )
48
+ }
49
+
50
+ type MarkdownProcessor = NonNullable<AstroConfig['markdown']['processor']>
51
+
52
+ interface SatteriMarkdownProcessor {
53
+ name: string
54
+ options: { features?: SatteriFeatures; hastPlugins: unknown[]; mdastPlugins: unknown[] }
55
+ }
56
+
57
+ interface SatteriFeatures {
58
+ math?:
59
+ | boolean
60
+ | {
61
+ singleDollarTextMath?: boolean
62
+ }
63
+ }
64
+
65
+ interface UnifiedMarkdownProcessor {
66
+ name: string
67
+ options: { rehypePlugins: unknown[]; remarkPlugins: unknown[] }
68
+ }
package/libs/rehype.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import type { Element, ElementContent, Root } from 'hast'
2
- import type { Literal } from 'mdast'
3
2
  import { CONTINUE, SKIP, visit } from 'unist-util-visit'
4
3
 
5
- const blockIdentifierRegex = /(?<identifier> *\^(?<name>[\w-]+))$/
4
+ import { getBlockIdentifier, getLastContentChild, isNodeWithValue } from './markdown'
6
5
 
7
6
  export function rehypeStarlightObsidian() {
8
7
  return function transformer(tree: Root) {
@@ -12,7 +11,7 @@ export function rehypeStarlightObsidian() {
12
11
  // Handle blockqoutes first as they are block which can contain paragraphs or list items and we want to hoist
13
12
  // the IDs to the blockquote element.
14
13
  if (node.tagName === 'blockquote') {
15
- const lastChild = node.children.at(-1)
14
+ const lastChild = getLastContentChild(node.children)?.child
16
15
 
17
16
  if (
18
17
  lastChild?.type !== 'element' ||
@@ -21,15 +20,15 @@ export function rehypeStarlightObsidian() {
21
20
  return CONTINUE
22
21
  }
23
22
 
24
- const lastGrandChild = lastChild.children.at(-1)
23
+ const lastGrandChild = getLastContentChild(lastChild.children)?.child
25
24
 
26
25
  if (lastChild.tagName === 'p') {
27
26
  return transformBlockIdentifier(node, lastGrandChild)
28
27
  } else if (lastGrandChild?.type === 'element' && lastGrandChild.tagName === 'li') {
29
- return transformBlockIdentifier(node, lastGrandChild.children.at(-1))
28
+ return transformBlockIdentifier(node, getLastContentChild(lastGrandChild.children)?.child)
30
29
  }
31
30
  } else if (node.tagName === 'p' || node.tagName === 'li') {
32
- return transformBlockIdentifier(node, node.children.at(-1))
31
+ return transformBlockIdentifier(node, getLastContentChild(node.children)?.child)
33
32
  }
34
33
 
35
34
  return CONTINUE
@@ -42,7 +41,7 @@ function transformBlockIdentifier(reference: Element, node: ElementContent | und
42
41
  return CONTINUE
43
42
  }
44
43
 
45
- const identifier = getBlockIdentifer(node)
44
+ const identifier = getBlockIdentifier(node)
46
45
 
47
46
  if (!identifier) {
48
47
  return CONTINUE
@@ -53,21 +52,3 @@ function transformBlockIdentifier(reference: Element, node: ElementContent | und
53
52
 
54
53
  return SKIP
55
54
  }
56
-
57
- function isNodeWithValue(node: ElementContent | undefined): node is NodeWithValue {
58
- return node !== undefined && 'value' in node
59
- }
60
-
61
- function getBlockIdentifer(node: NodeWithValue): { length: number; name: string } | undefined {
62
- const match = blockIdentifierRegex.exec(node.value)
63
- const identifier = match?.groups?.['identifier']
64
- const name = match?.groups?.['name']
65
-
66
- if (!identifier || !name) {
67
- return undefined
68
- }
69
-
70
- return { length: identifier.length, name }
71
- }
72
-
73
- type NodeWithValue = ElementContent & Literal
@@ -0,0 +1,86 @@
1
+ import type { Element, ElementContent, Parents } from 'hast'
2
+ import { defineHastPlugin, type HastPluginDefinition, type HastVisitorContext } from 'satteri'
3
+
4
+ import { getBlockIdentifier, getLastContentChild, isNodeWithValue } from './markdown'
5
+
6
+ export function satteriStarlightObsidian(): HastPluginDefinition {
7
+ return defineHastPlugin({
8
+ name: 'starlight-obsidian',
9
+ element: {
10
+ filter: ['blockquote', 'p', 'li'],
11
+ visit(node, ctx) {
12
+ if (node.tagName === 'blockquote') {
13
+ const lastChild = getLastContentChild(node.children)?.child
14
+
15
+ if (
16
+ lastChild?.type !== 'element' ||
17
+ !(lastChild.tagName === 'p' || lastChild.tagName === 'ul' || lastChild.tagName === 'ol')
18
+ ) {
19
+ return
20
+ }
21
+
22
+ const lastGrandChild = getLastContentChild(lastChild.children)?.child
23
+
24
+ if (lastChild.tagName === 'p') {
25
+ transformBlockIdentifier(node, lastGrandChild, ctx)
26
+ } else if (lastGrandChild?.type === 'element' && lastGrandChild.tagName === 'li') {
27
+ transformBlockIdentifier(node, getLastContentChild(lastGrandChild.children)?.child, ctx)
28
+ }
29
+ } else if (node.tagName === 'p' || node.tagName === 'li') {
30
+ if (isInsideBlockquoteWithIdentifier(node, ctx)) return
31
+
32
+ transformBlockIdentifier(node, getLastContentChild(node.children)?.child, ctx)
33
+ }
34
+ },
35
+ },
36
+ })
37
+ }
38
+
39
+ function transformBlockIdentifier(reference: Element, node: ElementContent | undefined, ctx: HastVisitorContext) {
40
+ if (!isNodeWithValue(node)) return
41
+
42
+ const identifier = getBlockIdentifier(node)
43
+ if (!identifier) return
44
+
45
+ ctx.setProperty(node, 'value', node.value.slice(0, identifier.length * -1))
46
+ ctx.setProperty(reference, 'id', `block-${identifier.name}`)
47
+ }
48
+
49
+ function isInsideBlockquoteWithIdentifier(node: Element, ctx: HastVisitorContext) {
50
+ let parent: Parents | undefined = ctx.parent(node)
51
+
52
+ while (parent !== undefined) {
53
+ if (parent.type === 'element' && parent.tagName === 'blockquote') {
54
+ return blockquoteHasIdentifier(parent)
55
+ }
56
+
57
+ parent = ctx.parent(parent)
58
+ }
59
+
60
+ return false
61
+ }
62
+
63
+ function blockquoteHasIdentifier(blockquote: Element) {
64
+ const lastChild = getLastContentChild(blockquote.children)?.child
65
+
66
+ if (
67
+ lastChild?.type !== 'element' ||
68
+ !(lastChild.tagName === 'p' || lastChild.tagName === 'ul' || lastChild.tagName === 'ol')
69
+ ) {
70
+ return false
71
+ }
72
+
73
+ const lastGrandChild = getLastContentChild(lastChild.children)?.child
74
+
75
+ if (lastChild.tagName === 'p') {
76
+ return isNodeWithValue(lastGrandChild) && getBlockIdentifier(lastGrandChild) !== undefined
77
+ }
78
+
79
+ if (lastGrandChild?.type !== 'element' || lastGrandChild.tagName !== 'li') {
80
+ return false
81
+ }
82
+
83
+ const lastListItemChild = getLastContentChild(lastGrandChild.children)?.child
84
+
85
+ return isNodeWithValue(lastListItemChild) && getBlockIdentifier(lastListItemChild) !== undefined
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starlight-obsidian",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "description": "Starlight plugin to publish Obsidian vaults.",
6
6
  "author": "HiDeoo <github@hideoo.dev> (https://hideoo.dev)",
@@ -19,6 +19,7 @@
19
19
  "dependencies": {
20
20
  "@astro-community/astro-embed-twitter": "^0.5.11",
21
21
  "@astro-community/astro-embed-youtube": "^0.5.10",
22
+ "@nullpinter/satteri-katex": "^0.1.2",
22
23
  "decode-uri-component": "^0.4.1",
23
24
  "github-slugger": "^2.0.0",
24
25
  "globby": "^16.1.1",
@@ -35,6 +36,7 @@
35
36
  "remark-frontmatter": "^5.0.0",
36
37
  "remark-gfm": "^4.0.1",
37
38
  "remark-math": "^6.0.0",
39
+ "satteri": "^0.9.3",
38
40
  "unist-util-visit": "^5.1.0",
39
41
  "vfile": "^6.0.3",
40
42
  "yaml": "^2.8.2"
@@ -48,7 +50,7 @@
48
50
  "vitest": "^4.0.18"
49
51
  },
50
52
  "peerDependencies": {
51
- "@astrojs/starlight": ">=0.39.0"
53
+ "@astrojs/starlight": ">=0.41.0"
52
54
  },
53
55
  "engines": {
54
56
  "node": ">=22.12.0"