Using Obsidian to manage content for websites cover image

Using Obsidian to manage content for websites

I had this idea that because my blog uses markdown and obsidian uses markdown that I could somehow have my blog posts as notes in obsidian and everytime I change or add new posts they would magically appear on my website. I thought it should be possible because jigsaw has a compile step that I can hook into. So if I could get jigsaw to pull the repo where I keep my notes, then it could use a directory of my choosing to copy blog post markdown files and compile them for the website. I started digging in the documentation and discovered that jigsaw supports remote collections in a very simple and extendible way. Just provide anything, however you want, as an array in the config.php to the collections.posts.items parameter. Jigsaw will take this and convert it to .blade.md files and then process those in the normal manner.

One benefit of doing this is that we can skip some of the yaml settings, and instead just put provide the data we want directly in the item array. I'll get back to this later.

For now I just want to clone or pull the obsidian repo if it already exists.

1<?php
2 
3$files = (new UpdateFilesAction)(
4 'git@github.com:forsvunnet/Obsidian.md.git',
5 __DIR__.'/obsidian_repo'
6);

I made an action class that does the git stuff.

1<?php
2 
3namespace Obsidian\Actions;
4 
5use Illuminate\Filesystem\Filesystem;
6use Symfony\Component\Finder\SplFileInfo;
7 
8class UpdateFilesAction
9{
10 public function __invoke(string $repoUrl, string $target): array
11 {
12 $cwd = getcwd();
13 if (!is_dir($target)) {
14 `git clone $repoUrl $target`;
15 }
16 chdir($target);
17 `git pull`;
18 $fs = new Filesystem();
19 chdir($cwd);
20 return collect($fs->allFiles($target))->filter(
21 fn(SplFileInfo $file) => str_starts_with($file->getRelativePath(), 'Obsidian.md/eivin.me')
22 )->toArray();
23 }
24}

Now that we have all the file, they need a bit of post processing before we can pass them over to Jigsaw. Here I'm using a collection to filter only the .md files in the "Posts" directory and a ParseObsidianFileAction to read the files and convert them to an array.

1<?php
2 
3$posts = collect($files)->filter(
4 fn(SplFileInfo $file) => str_starts_with(
5 $file->getRelativePath(),
6 'Obsidian.md/eivin.me/Posts'
7 ) && $file->getExtension() === 'md'
8)->map(
9 fn(SplFileInfo $file) => (new ParseObsidianFileAction)($file)
10)->toArray();

Then it's a matter of getting the content of the file and extract the various data that I need. I made a few decisions about how each post must be structured to make this easier. 1. Each post starts with a yaml definition at the top 2. I use obsidians categories yaml, but the website expects tags so I just change the key 3. Each post starts with an image and a \<h1> 4. I always have the date in the filename. eg. this posts filename starts with "2022-03-22 Using..."

1<?php
2 
3namespace Obsidian\Actions;
4 
5use Illuminate\Support\Carbon;
6use Obsidian\UnsplashImage;
7use Symfony\Component\Finder\SplFileInfo;
8use Symfony\Component\Yaml\Parser;
9 
10class ParseObsidianFileAction
11{
12 public function __invoke(SplFileInfo $file): array
13 {
14 $content = $file->getContents();
15 $content = strtr(
16 $content,
17 [
18 '<?p' => '&lt;?',
19 ]
20 );
21 $extra = [];
22 if (preg_match('/^---\n(.*)\n---\n/s', $content, $matches)) {
23 $parser = new Parser();
24 $content = substr($content, strlen($matches[0]));
25 $extra = $parser->parse($matches[1]);
26 if (! is_array($extra)) {
27 $extra = [];
28 }
29 if (isset($extra['tags'])) {
30 $extra['categories'] = $extra['tags'];
31 unset($extra['tags']);
32 }
33 }
34 
35 // Hack to make it easier to match things at the beginning of the content.
36 $content = "\n\n\n\n\n\n{$content}";
37 
38 $date = $this->getDate($file);
39 
40 return [
41 ...$this->getImageInfo($content),
42 'title' => $this->getTitle($content),
43 'extends' => '_layouts.post',
44 'section' => 'content',
45 'featured' => true,
46 'content' => trim($content),
47 'date' => $date,
48 'updated' => Carbon::createFromTimestamp($file->getMTime()),
49 ...$extra
50 ];
51 }
52 
53 private function getDate(SplFileInfo $file): Carbon
54 {
55 if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $file->getFilename(), $matches)) {
56 return Carbon::createFromDate(
57 $matches[1],
58 $matches[2],
59 $matches[3],
60 );
61 }
62 return Carbon::createFromTimestamp($file->getCTime());
63 }
64 
65 private function getTitle(string &$content): string
66 {
67 $title = 'N/A';
68 if (preg_match('/\n# [^\n]+\n/s', $content, $matches)) {
69 $content = str_replace($matches[0], '', $content);
70 return substr($matches[0], 2);
71 }
72 return $title;
73 }
74 
75 private function getImageInfo(string &$content): array
76 {
77 if (preg_match('/\n!\[([^\n]*)]\(([^\n]*)\)\n/s', $content, $matches)) {
78 $content = str_replace($matches[0], '', $content);
79 return $this->urlToImageInfo($matches[2]);
80 }
81 return [];
82 }
83 
84 private function urlToImageInfo(string $url):array
85 {
86 if (str_starts_with($url, 'https://source.unsplash.com/')) {
87 return (new UnsplashImage($url))->toArray();
88 }
89 return [
90 'cover_image' => $url,
91 'caption' => null,
92 ];
93 }
94 
95}

The UnsplashImage class just looks at the url and if it's an unsplash image it returns an array with the url for the image and a caption with the authors name and a link to the picture on unsplash.

Some of the data I just hardcoded because it stays the same for all posts.

1[
2 ...
3 'extends' => '_layouts.post',
4 'section' => 'content',
5 'featured' => true,
6 ...
7];

To automatically publish changes I just use a github webhook in the repo to notify the forge deployment url and forge does the rest for me.

I've still got some room for improvements. Especially how the unsplash images are displayed, and I don't yet have support for local images and no handling of Obsidians magic internal linking. The main thing I want to get working is srcset for the images. I'll probably write another post about this down the line. Stay tuned.

Written by Eivin Landa • March 22, 2022 • Updated March 26, 2022