starlight-obsidian 0.1.1
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/LICENSE +21 -0
- package/README.md +36 -0
- package/components/Tags.astro +36 -0
- package/components/Twitter.astro +11 -0
- package/components/Youtube.astro +11 -0
- package/index.ts +125 -0
- package/libs/fs.ts +37 -0
- package/libs/html.ts +18 -0
- package/libs/integration.ts +21 -0
- package/libs/markdown.ts +43 -0
- package/libs/obsidian.ts +225 -0
- package/libs/path.ts +39 -0
- package/libs/plugin.ts +8 -0
- package/libs/rehype.ts +74 -0
- package/libs/remark.ts +670 -0
- package/libs/starlight.ts +263 -0
- package/overrides/PageTitle.astro +14 -0
- package/package.json +77 -0
- package/schema.ts +8 -0
- package/styles.css +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present, HiDeoo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>starlight-obsidian 📔</h1>
|
|
3
|
+
<p>Starlight plugin to publish Obsidian vaults.</p>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div align="center">
|
|
7
|
+
<a href="https://github.com/HiDeoo/starlight-obsidian/actions/workflows/integration.yml">
|
|
8
|
+
<img alt="Integration Status" src="https://github.com/HiDeoo/starlight-obsidian/actions/workflows/integration.yml/badge.svg" />
|
|
9
|
+
</a>
|
|
10
|
+
<a href="https://github.com/HiDeoo/starlight-obsidian/blob/main/LICENSE">
|
|
11
|
+
<img alt="License" src="https://badgen.net/github/license/HiDeoo/starlight-obsidian" />
|
|
12
|
+
</a>
|
|
13
|
+
<br />
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
## Getting Started
|
|
17
|
+
|
|
18
|
+
Want to get started immediately? Check out the [getting started guide](https://starlight-obsidian.vercel.app/getting-started/).
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
A [Starlight](https://starlight.astro.build) plugin to publish [Obsidian](https://obsidian.md) vaults to a Starlight website.
|
|
23
|
+
|
|
24
|
+
Check out the [demo](https://starlight-obsidian.vercel.app/demo/hello/) for a preview of the generated pages.
|
|
25
|
+
|
|
26
|
+
> **Warning**
|
|
27
|
+
>
|
|
28
|
+
> The Starlight Obsidian plugin is in early development. If you find something that's not working, [open an issue](https://github.com/HiDeoo/starlight-obsidian/issues/new/choose) on GitHub.
|
|
29
|
+
>
|
|
30
|
+
> You should also always check that the rendered pages are correct and only include the content you want to publish before publishing your website.
|
|
31
|
+
|
|
32
|
+
## License
|
|
33
|
+
|
|
34
|
+
Licensed under the MIT License, Copyright © HiDeoo.
|
|
35
|
+
|
|
36
|
+
See [LICENSE](https://github.com/HiDeoo/starlight-obsidian/blob/main/LICENSE) for more information.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
tags: string[]
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { tags } = Astro.props
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<div>
|
|
10
|
+
<ul>
|
|
11
|
+
{
|
|
12
|
+
tags.map((tag) => (
|
|
13
|
+
<li>
|
|
14
|
+
<span class="sl-obs-tag">{tag}</span>
|
|
15
|
+
</li>
|
|
16
|
+
))
|
|
17
|
+
}
|
|
18
|
+
</ul>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<style>
|
|
22
|
+
ul {
|
|
23
|
+
display: inline;
|
|
24
|
+
padding: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
li {
|
|
28
|
+
display: inline-block;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
li .sl-obs-tag {
|
|
32
|
+
margin-inline-end: 0.375rem;
|
|
33
|
+
margin-bottom: 0.25rem;
|
|
34
|
+
overflow-wrap: anywhere;
|
|
35
|
+
}
|
|
36
|
+
</style>
|
package/index.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { StarlightPlugin, StarlightUserConfig } from '@astrojs/starlight/types'
|
|
2
|
+
import { z } from 'astro/zod'
|
|
3
|
+
|
|
4
|
+
import { starlightObsidianIntegration } from './libs/integration'
|
|
5
|
+
import { getObsidianPaths, getVault } from './libs/obsidian'
|
|
6
|
+
import { throwUserError } from './libs/plugin'
|
|
7
|
+
import { addObsidianFiles, getSidebarFromConfig, getSidebarGroupPlaceholder } from './libs/starlight'
|
|
8
|
+
|
|
9
|
+
const starlightObsidianConfigSchema = z.object({
|
|
10
|
+
/**
|
|
11
|
+
* The name of the Obsidian vault configuration folder if different from the default one.
|
|
12
|
+
*
|
|
13
|
+
* @default '.obsidian'
|
|
14
|
+
* @see https://help.obsidian.md/Files+and+folders/Configuration+folder
|
|
15
|
+
*/
|
|
16
|
+
configFolder: z.string().startsWith('.').default('.obsidian'),
|
|
17
|
+
/**
|
|
18
|
+
* A list of glob patterns to ignore when generating the Obsidian vault pages.
|
|
19
|
+
* This option can be used to ignore files or folders.
|
|
20
|
+
*
|
|
21
|
+
* @default []
|
|
22
|
+
* @see https://github.com/mrmlnc/fast-glob#basic-syntax
|
|
23
|
+
* @see https://help.obsidian.md/Files+and+folders/Accepted+file+formats
|
|
24
|
+
*/
|
|
25
|
+
ignore: z.array(z.string()).default([]),
|
|
26
|
+
/**
|
|
27
|
+
* The name of the output directory containing the generated Obsidian vault pages relative to the `src/content/docs/`
|
|
28
|
+
* directory.
|
|
29
|
+
*
|
|
30
|
+
* @default 'notes'
|
|
31
|
+
*/
|
|
32
|
+
output: z.string().default('notes'),
|
|
33
|
+
/**
|
|
34
|
+
* The generated vault pages sidebar group configuration.
|
|
35
|
+
*/
|
|
36
|
+
sidebar: z
|
|
37
|
+
.object({
|
|
38
|
+
/**
|
|
39
|
+
* Whether the generated vault pages root sidebar group should be collapsed by default.
|
|
40
|
+
*
|
|
41
|
+
* @default false
|
|
42
|
+
*/
|
|
43
|
+
collapsed: z.boolean().default(false),
|
|
44
|
+
/**
|
|
45
|
+
* Whether the sidebar groups of your vault nested folders should be collapsed by default.
|
|
46
|
+
*
|
|
47
|
+
* Defaults to the value of the `collapsed` option.
|
|
48
|
+
*/
|
|
49
|
+
collapsedFolders: z.boolean().optional(),
|
|
50
|
+
/**
|
|
51
|
+
* The generated vault pages sidebar group label.
|
|
52
|
+
*
|
|
53
|
+
* @default 'Notes'
|
|
54
|
+
*/
|
|
55
|
+
label: z.string().default('Notes'),
|
|
56
|
+
})
|
|
57
|
+
.default({}),
|
|
58
|
+
/**
|
|
59
|
+
* The absolute or relative path to the Obsidian vault to publish.
|
|
60
|
+
*/
|
|
61
|
+
vault: z.string(),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
export const obsidianSidebarGroup = getSidebarGroupPlaceholder()
|
|
65
|
+
|
|
66
|
+
export default function starlightObsidianPlugin(userConfig: StarlightObsidianUserConfig): StarlightPlugin {
|
|
67
|
+
const parsedConfig = starlightObsidianConfigSchema.safeParse(userConfig)
|
|
68
|
+
|
|
69
|
+
if (!parsedConfig.success) {
|
|
70
|
+
throwUserError(
|
|
71
|
+
`The provided plugin configuration is invalid.\n${parsedConfig.error.issues.map((issue) => issue.message).join('\n')}`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const config = parsedConfig.data
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
name: 'starlight-obsidian-plugin',
|
|
79
|
+
hooks: {
|
|
80
|
+
async setup({ addIntegration, command, config: starlightConfig, logger, updateConfig }) {
|
|
81
|
+
if (command !== 'build' && command !== 'dev') {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const updatedStarlightConfig: Partial<StarlightUserConfig> = {
|
|
86
|
+
customCss: [...(starlightConfig.customCss ?? []), 'starlight-obsidian/styles'],
|
|
87
|
+
sidebar: getSidebarFromConfig(config, starlightConfig.sidebar),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (starlightConfig.components?.PageTitle) {
|
|
91
|
+
logger.warn(
|
|
92
|
+
'It looks like you already have a `PageTitle` component override in your Starlight configuration.',
|
|
93
|
+
)
|
|
94
|
+
logger.warn('To use `starlight-obsidian`, remove the override for the `PageTitle` component.\n')
|
|
95
|
+
} else {
|
|
96
|
+
updatedStarlightConfig.components = {
|
|
97
|
+
PageTitle: 'starlight-obsidian/overrides/PageTitle.astro',
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const start = performance.now()
|
|
103
|
+
logger.info('Generating Starlight pages from Obsidian vault…')
|
|
104
|
+
|
|
105
|
+
const vault = await getVault(config)
|
|
106
|
+
const obsidianPaths = await getObsidianPaths(vault, config.ignore)
|
|
107
|
+
await addObsidianFiles(config, vault, obsidianPaths, logger)
|
|
108
|
+
|
|
109
|
+
const duration = Math.round(performance.now() - start)
|
|
110
|
+
logger.info(`Starlight pages generated from Obsidian vault in ${duration}ms.`)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
logger.error(error instanceof Error ? error.message : String(error))
|
|
113
|
+
|
|
114
|
+
throwUserError('Failed to generate Starlight pages from Obsidian vault.')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
addIntegration(starlightObsidianIntegration())
|
|
118
|
+
updateConfig(updatedStarlightConfig)
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type StarlightObsidianUserConfig = z.input<typeof starlightObsidianConfigSchema>
|
|
125
|
+
export type StarlightObsidianConfig = z.output<typeof starlightObsidianConfigSchema>
|
package/libs/fs.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export async function isDirectory(path: string) {
|
|
5
|
+
try {
|
|
6
|
+
const stats = await fs.stat(path)
|
|
7
|
+
|
|
8
|
+
return stats.isDirectory()
|
|
9
|
+
} catch {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function isFile(path: string) {
|
|
15
|
+
try {
|
|
16
|
+
const stats = await fs.stat(path)
|
|
17
|
+
|
|
18
|
+
return stats.isFile()
|
|
19
|
+
} catch {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ensureDirectory(path: string) {
|
|
25
|
+
return fs.mkdir(path, { recursive: true })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function removeDirectory(path: string) {
|
|
29
|
+
return fs.rm(path, { force: true, recursive: true })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function copyFile(sourcePath: string, destinationPath: string) {
|
|
33
|
+
const dirPath = path.dirname(destinationPath)
|
|
34
|
+
|
|
35
|
+
await ensureDirectory(dirPath)
|
|
36
|
+
return fs.copyFile(sourcePath, destinationPath)
|
|
37
|
+
}
|
package/libs/html.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { rehype } from 'rehype'
|
|
2
|
+
import rehypeMermaid from 'rehype-mermaid'
|
|
3
|
+
|
|
4
|
+
const processor = rehype()
|
|
5
|
+
.data('settings', {
|
|
6
|
+
fragment: true,
|
|
7
|
+
closeSelfClosing: true,
|
|
8
|
+
})
|
|
9
|
+
.use(rehypeMermaid, {
|
|
10
|
+
dark: true,
|
|
11
|
+
strategy: 'img-svg',
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export async function transformHtmlToString(html: string) {
|
|
15
|
+
const file = await processor.process(html)
|
|
16
|
+
|
|
17
|
+
return String(file)
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AstroIntegration } from 'astro'
|
|
2
|
+
import rehypeKatex from 'rehype-katex'
|
|
3
|
+
import remarkMath from 'remark-math'
|
|
4
|
+
|
|
5
|
+
import { rehypeStarlightObsidian } from './rehype'
|
|
6
|
+
|
|
7
|
+
export function starlightObsidianIntegration(): AstroIntegration {
|
|
8
|
+
return {
|
|
9
|
+
name: 'starlight-obsidian-integration',
|
|
10
|
+
hooks: {
|
|
11
|
+
'astro:config:setup': ({ updateConfig }) => {
|
|
12
|
+
updateConfig({
|
|
13
|
+
markdown: {
|
|
14
|
+
rehypePlugins: [rehypeStarlightObsidian, rehypeKatex],
|
|
15
|
+
remarkPlugins: [remarkMath],
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
package/libs/markdown.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { remark } from 'remark'
|
|
2
|
+
import remarkFrontmatter from 'remark-frontmatter'
|
|
3
|
+
import remarkGfm from 'remark-gfm'
|
|
4
|
+
import remarkMath from 'remark-math'
|
|
5
|
+
import { VFile } from 'vfile'
|
|
6
|
+
|
|
7
|
+
import { remarkStarlightObsidian, type TransformContext } from './remark'
|
|
8
|
+
|
|
9
|
+
const processor = remark().use(remarkGfm).use(remarkMath).use(remarkFrontmatter).use(remarkStarlightObsidian)
|
|
10
|
+
|
|
11
|
+
export async function transformMarkdownToString(
|
|
12
|
+
filePath: string,
|
|
13
|
+
markdown: string,
|
|
14
|
+
context: TransformContext,
|
|
15
|
+
): Promise<TransformResult> {
|
|
16
|
+
const file = await processor.process(getVFile(filePath, markdown, context))
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
aliases: file.data.aliases,
|
|
20
|
+
content: String(file),
|
|
21
|
+
skip: file.data.skip === true,
|
|
22
|
+
type: file.data.isMdx === true ? 'mdx' : 'markdown',
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function transformMarkdownToAST(filePath: string, markdown: string, context: TransformContext) {
|
|
27
|
+
return processor.parse(getVFile(filePath, markdown, context))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getVFile(filePath: string, markdown: string, context: TransformContext) {
|
|
31
|
+
return new VFile({
|
|
32
|
+
data: { ...context },
|
|
33
|
+
path: filePath,
|
|
34
|
+
value: markdown,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TransformResult {
|
|
39
|
+
aliases: string[] | undefined
|
|
40
|
+
content: string
|
|
41
|
+
skip: boolean
|
|
42
|
+
type: 'markdown' | 'mdx'
|
|
43
|
+
}
|
package/libs/obsidian.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { z } from 'astro/zod'
|
|
5
|
+
import { slug } from 'github-slugger'
|
|
6
|
+
import { globby } from 'globby'
|
|
7
|
+
import yaml from 'yaml'
|
|
8
|
+
|
|
9
|
+
import type { StarlightObsidianConfig } from '..'
|
|
10
|
+
|
|
11
|
+
import { isDirectory, isFile } from './fs'
|
|
12
|
+
import { getExtension, isAnchor, slugifyPath, stripExtension } from './path'
|
|
13
|
+
import { throwUserError } from './plugin'
|
|
14
|
+
import { isAssetFile } from './starlight'
|
|
15
|
+
|
|
16
|
+
const obsidianAppConfigSchema = z.object({
|
|
17
|
+
newLinkFormat: z.union([z.literal('absolute'), z.literal('relative'), z.literal('shortest')]).default('shortest'),
|
|
18
|
+
useMarkdownLinks: z.boolean().default(false),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const obsidianFrontmatterSchema = z.object({
|
|
22
|
+
aliases: z
|
|
23
|
+
.array(z.string())
|
|
24
|
+
.optional()
|
|
25
|
+
.transform((aliases) => aliases?.map((alias) => slug(alias))),
|
|
26
|
+
cover: z.string().optional(),
|
|
27
|
+
description: z.string().optional(),
|
|
28
|
+
image: z.string().optional(),
|
|
29
|
+
permalink: z.string().optional(),
|
|
30
|
+
publish: z
|
|
31
|
+
.union([z.boolean(), z.literal('true'), z.literal('false')])
|
|
32
|
+
.optional()
|
|
33
|
+
.transform((publish) => publish === undefined || publish === 'true' || publish === true),
|
|
34
|
+
tags: z.array(z.string()).optional(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const imageFileFormats = new Set(['.avif', '.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'])
|
|
38
|
+
const audioFileFormats = new Set(['.flac', '.m4a', '.mp3', '.wav', '.ogg', '.wav', '.3gp'])
|
|
39
|
+
const videoFileFormats = new Set(['.mkv', '.mov', '.mp4', '.ogv', '.webm'])
|
|
40
|
+
const otherFileFormats = new Set(['.pdf'])
|
|
41
|
+
|
|
42
|
+
const fileFormats = new Set([...imageFileFormats, ...audioFileFormats, ...videoFileFormats, ...otherFileFormats])
|
|
43
|
+
|
|
44
|
+
export async function getVault(config: StarlightObsidianConfig): Promise<Vault> {
|
|
45
|
+
const vaultPath = path.resolve(config.vault)
|
|
46
|
+
|
|
47
|
+
if (!(await isDirectory(vaultPath))) {
|
|
48
|
+
throwUserError('The provided vault path is not a directory.')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!(await isVaultDirectory(config, vaultPath))) {
|
|
52
|
+
throwUserError('The provided vault path is not a valid Obsidian vault directory.')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const options = await getVaultOptions(config, vaultPath)
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
options,
|
|
59
|
+
path: vaultPath,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getObsidianPaths(vault: Vault, ignore: StarlightObsidianConfig['ignore'] = []) {
|
|
64
|
+
return globby(['**/*.md', ...[...fileFormats].map((fileFormat) => `**/*${fileFormat}`)], {
|
|
65
|
+
absolute: true,
|
|
66
|
+
cwd: vault.path,
|
|
67
|
+
ignore,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getObsidianVaultFiles(vault: Vault, obsidianPaths: string[]): VaultFile[] {
|
|
72
|
+
const allFileNames = obsidianPaths.map((obsidianPath) => path.basename(obsidianPath))
|
|
73
|
+
|
|
74
|
+
return obsidianPaths.map((obsidianPath, index) => {
|
|
75
|
+
const baseFileName = allFileNames[index] as string
|
|
76
|
+
let fileName = baseFileName
|
|
77
|
+
|
|
78
|
+
const type = isAssetFile(fileName) ? 'asset' : isObsidianFile(fileName) ? 'file' : 'content'
|
|
79
|
+
|
|
80
|
+
if (type === 'asset') {
|
|
81
|
+
fileName = slugifyPath(fileName)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const filePath = getObsidianRelativePath(vault, obsidianPath)
|
|
85
|
+
const slug = slugifyObsidianPath(filePath)
|
|
86
|
+
|
|
87
|
+
return createVaultFile({
|
|
88
|
+
fileName,
|
|
89
|
+
fsPath: obsidianPath,
|
|
90
|
+
path: type === 'asset' ? slug : filePath,
|
|
91
|
+
slug,
|
|
92
|
+
stem: stripExtension(fileName),
|
|
93
|
+
type,
|
|
94
|
+
uniqueFileName: allFileNames.filter((currentFileName) => currentFileName === baseFileName).length === 1,
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getObsidianRelativePath(vault: Vault, obsidianPath: string) {
|
|
100
|
+
return obsidianPath.replace(vault.path, '')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function slugifyObsidianPath(obsidianPath: string) {
|
|
104
|
+
const segments = obsidianPath.split('/')
|
|
105
|
+
|
|
106
|
+
return segments
|
|
107
|
+
.map((segment, index) => {
|
|
108
|
+
const isLastSegment = index === segments.length - 1
|
|
109
|
+
|
|
110
|
+
if (!isLastSegment) {
|
|
111
|
+
return slug(decodeURIComponent(segment))
|
|
112
|
+
} else if (isObsidianFile(segment) && !isAssetFile(segment)) {
|
|
113
|
+
return decodeURIComponent(segment)
|
|
114
|
+
} else if (isAssetFile(segment)) {
|
|
115
|
+
return `${slug(decodeURIComponent(stripExtension(segment)))}${getExtension(segment)}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return slug(decodeURIComponent(stripExtension(segment)))
|
|
119
|
+
})
|
|
120
|
+
.join('/')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function slugifyObsidianAnchor(obsidianAnchor: string) {
|
|
124
|
+
if (obsidianAnchor.length === 0) {
|
|
125
|
+
return ''
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let anchor = isAnchor(obsidianAnchor) ? obsidianAnchor.slice(1) : obsidianAnchor
|
|
129
|
+
|
|
130
|
+
if (isObsidianBlockAnchor(anchor)) {
|
|
131
|
+
anchor = anchor.replace('^', 'block-')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return `#${slug(decodeURIComponent(anchor))}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function isObsidianBlockAnchor(anchor: string) {
|
|
138
|
+
return anchor.startsWith('#^') || anchor.startsWith('^')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function isObsidianFile(filePath: string, type?: 'image' | 'audio' | 'video' | 'other') {
|
|
142
|
+
const formats: Set<string> =
|
|
143
|
+
type === undefined
|
|
144
|
+
? fileFormats
|
|
145
|
+
: type === 'image'
|
|
146
|
+
? imageFileFormats
|
|
147
|
+
: type === 'audio'
|
|
148
|
+
? audioFileFormats
|
|
149
|
+
: type === 'video'
|
|
150
|
+
? videoFileFormats
|
|
151
|
+
: otherFileFormats
|
|
152
|
+
|
|
153
|
+
return formats.has(getExtension(filePath))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function parseObsidianFrontmatter(content: string): ObsidianFrontmatter | undefined {
|
|
157
|
+
try {
|
|
158
|
+
return obsidianFrontmatterSchema.parse(yaml.parse(content))
|
|
159
|
+
} catch {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createVaultFile(baseVaultFile: BaseVaultFile) {
|
|
165
|
+
return {
|
|
166
|
+
...baseVaultFile,
|
|
167
|
+
isEqualFileName(otherFileName: string) {
|
|
168
|
+
return (isAssetFile(otherFileName) ? slugifyPath(otherFileName) : otherFileName) === this.fileName
|
|
169
|
+
},
|
|
170
|
+
isEqualStem(otherStem: string) {
|
|
171
|
+
return (isAssetFile(otherStem) ? slugifyPath(otherStem) : otherStem) === this.stem
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function isVaultDirectory(config: StarlightObsidianConfig, vaultPath: string) {
|
|
177
|
+
const configPath = path.join(vaultPath, config.configFolder)
|
|
178
|
+
|
|
179
|
+
return (await isDirectory(configPath)) && (await isFile(path.join(configPath, 'app.json')))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function getVaultOptions(config: StarlightObsidianConfig, vaultPath: string): Promise<VaultOptions> {
|
|
183
|
+
const appConfigPath = path.join(vaultPath, config.configFolder, 'app.json')
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const appConfigData = await fs.readFile(appConfigPath, 'utf8')
|
|
187
|
+
const appConfig = obsidianAppConfigSchema.parse(JSON.parse(appConfigData))
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
linkFormat: appConfig.newLinkFormat,
|
|
191
|
+
linkSyntax: appConfig.useMarkdownLinks ? 'markdown' : 'wikilink',
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new Error('Failed to read Obsidian vault app configuration.', { cause: error })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface Vault {
|
|
199
|
+
options: VaultOptions
|
|
200
|
+
path: string
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface VaultOptions {
|
|
204
|
+
linkFormat: 'absolute' | 'relative' | 'shortest'
|
|
205
|
+
linkSyntax: 'markdown' | 'wikilink'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface BaseVaultFile {
|
|
209
|
+
fileName: string
|
|
210
|
+
fsPath: string
|
|
211
|
+
// The path is relative to the vault root.
|
|
212
|
+
path: string
|
|
213
|
+
slug: string
|
|
214
|
+
// This represent the file name without the extension.
|
|
215
|
+
stem: string
|
|
216
|
+
type: 'asset' | 'content' | 'file'
|
|
217
|
+
uniqueFileName: boolean
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface VaultFile extends BaseVaultFile {
|
|
221
|
+
isEqualFileName(otherFileName: string): boolean
|
|
222
|
+
isEqualStem(otherStem: string): boolean
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export type ObsidianFrontmatter = z.output<typeof obsidianFrontmatterSchema>
|
package/libs/path.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { slug } from 'github-slugger'
|
|
4
|
+
|
|
5
|
+
export function getExtension(filePath: string) {
|
|
6
|
+
return path.parse(filePath).ext
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function stripExtension(filePath: string) {
|
|
10
|
+
return path.parse(filePath).name
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function extractPathAndAnchor(filePathAndAnchor: string): [string, string | undefined] {
|
|
14
|
+
const [filePath, fileAnchor] = filePathAndAnchor.split('#', 2)
|
|
15
|
+
|
|
16
|
+
return [filePath as string, fileAnchor]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isAnchor(filePath: string): filePath is `#${string}` {
|
|
20
|
+
return filePath.startsWith('#')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function slugifyPath(filePath: string) {
|
|
24
|
+
const segments = filePath.split('/')
|
|
25
|
+
|
|
26
|
+
return segments
|
|
27
|
+
.map((segment, index) => {
|
|
28
|
+
const isLastSegment = index === segments.length - 1
|
|
29
|
+
|
|
30
|
+
if (!isLastSegment) {
|
|
31
|
+
return slug(segment)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parsedPath = path.parse(segment)
|
|
35
|
+
|
|
36
|
+
return `${slug(parsedPath.name)}${parsedPath.ext}`
|
|
37
|
+
})
|
|
38
|
+
.join('/')
|
|
39
|
+
}
|
package/libs/plugin.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AstroError } from 'astro/errors'
|
|
2
|
+
|
|
3
|
+
export function throwUserError(message: string): never {
|
|
4
|
+
throw new AstroError(
|
|
5
|
+
message,
|
|
6
|
+
`See the error report above for more informations.\n\nIf you believe this is a bug, please file an issue at https://github.com/HiDeoo/starlight-obsidian/issues/new/choose`,
|
|
7
|
+
)
|
|
8
|
+
}
|