@aureuma/svelta 0.0.1 → 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/README.md +1 -2
- package/package.json +35 -3
- package/packages/core/CHANGELOG.md +18 -0
- package/packages/core/README.md +11 -0
- package/packages/{blogkit → core}/dist/index.d.ts +1 -1
- package/packages/core/dist/server/blog.d.ts +106 -0
- package/packages/core/dist/server/blog.js +470 -0
- package/packages/core/dist/server/index.d.ts +1 -0
- package/packages/core/dist/server/index.js +1 -0
- package/packages/{blogkit → core}/dist/theme/store.js +1 -1
- package/packages/{blogkit → core}/dist/types/blog.d.ts +10 -0
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -14
- package/.changeset/publish-blogkit.md +0 -5
- package/.github/workflows/release.yml +0 -65
- package/docs/mintlify-blog-study.md +0 -697
- package/packages/blogkit/CHANGELOG.md +0 -6
- package/packages/blogkit/README.md +0 -93
- package/packages/blogkit/dist/server/blog.d.ts +0 -39
- package/packages/blogkit/dist/server/blog.js +0 -222
- package/packages/blogkit/dist/server/index.d.ts +0 -1
- package/packages/blogkit/dist/server/index.js +0 -1
- package/packages/blogkit/package.json +0 -66
- package/packages/blogkit/src/lib/components/blog/Avatar.svelte +0 -15
- package/packages/blogkit/src/lib/components/blog/BackLink.svelte +0 -23
- package/packages/blogkit/src/lib/components/blog/BlogCard.svelte +0 -37
- package/packages/blogkit/src/lib/components/blog/BlogHeroCard.svelte +0 -36
- package/packages/blogkit/src/lib/components/blog/Container.svelte +0 -8
- package/packages/blogkit/src/lib/components/blog/ImageLightbox.svelte +0 -58
- package/packages/blogkit/src/lib/components/blog/MorePosts.svelte +0 -15
- package/packages/blogkit/src/lib/components/blog/ShareButtons.svelte +0 -113
- package/packages/blogkit/src/lib/components/blog/SummaryCard.svelte +0 -11
- package/packages/blogkit/src/lib/components/blog/TagTabs.svelte +0 -32
- package/packages/blogkit/src/lib/index.ts +0 -15
- package/packages/blogkit/src/lib/server/blog.ts +0 -264
- package/packages/blogkit/src/lib/server/index.ts +0 -2
- package/packages/blogkit/src/lib/theme/ThemeSwitcher.svelte +0 -34
- package/packages/blogkit/src/lib/theme/index.ts +0 -3
- package/packages/blogkit/src/lib/theme/store.ts +0 -64
- package/packages/blogkit/src/lib/types/blog.ts +0 -36
- package/packages/blogkit/svelte.config.js +0 -8
- package/packages/blogkit/tsconfig.json +0 -5
- package/playwright.config.ts +0 -24
- package/postcss.config.cjs +0 -6
- package/src/app.css +0 -146
- package/src/app.d.ts +0 -13
- package/src/app.html +0 -26
- package/src/content/blog/ai-summary-cards-with-frontmatter.md +0 -32
- package/src/content/blog/announcing-svelta-blog.md +0 -19
- package/src/content/blog/best-practices-ship-with-checklists.md +0 -26
- package/src/content/blog/building-a-mintlify-inspired-blog.md +0 -49
- package/src/content/blog/design-tokens-that-scale.md +0 -47
- package/src/content/blog/for-founders-why-speed-matters.md +0 -23
- package/src/content/blog/infinite-scroll-with-intersection-observer.md +0 -37
- package/src/content/blog/markdown-kitchen-sink.md +0 -101
- package/src/content/blog/markdown-pipeline-mdsvex-shiki.md +0 -39
- package/src/content/blog/rss-feeds-that-actually-work.md +0 -25
- package/src/content/blog/tag-tabs-and-mobile-fade-masks.md +0 -25
- package/src/lib/assets/favicon.svg +0 -1
- package/src/lib/components/site/SiteFooter.svelte +0 -24
- package/src/lib/components/site/SiteHeader.svelte +0 -36
- package/src/lib/content/authors.ts +0 -28
- package/src/lib/index.ts +0 -1
- package/src/lib/server/blog.ts +0 -22
- package/src/lib/server/rss.ts +0 -58
- package/src/lib/server/seo.ts +0 -31
- package/src/lib/stores/theme.ts +0 -10
- package/src/lib/types/blog.ts +0 -1
- package/src/routes/+layout.svelte +0 -31
- package/src/routes/+page.svelte +0 -44
- package/src/routes/blog/+page.server.ts +0 -28
- package/src/routes/blog/+page.svelte +0 -122
- package/src/routes/blog/[slug]/+page.server.ts +0 -39
- package/src/routes/blog/[slug]/+page.svelte +0 -118
- package/src/routes/blog/posts.json/+server.ts +0 -32
- package/src/routes/feed.xml/+server.ts +0 -21
- package/static/blog/authors/alex.svg +0 -13
- package/static/blog/authors/maria.svg +0 -13
- package/static/blog/authors/shawn.svg +0 -13
- package/static/blog/covers/ai-summary.svg +0 -38
- package/static/blog/covers/design-tokens.svg +0 -37
- package/static/blog/covers/infinite-scroll.svg +0 -38
- package/static/blog/covers/kitchen-sink.svg +0 -36
- package/static/blog/covers/markdown-pipeline.svg +0 -41
- package/static/blog/covers/mintlify-style.svg +0 -35
- package/static/blog/covers/rss.svg +0 -34
- package/static/robots.txt +0 -3
- package/svelte.config.js +0 -70
- package/tailwind.config.cjs +0 -133
- package/tests/blog.spec.ts +0 -63
- package/tsconfig.json +0 -21
- package/vite.config.ts +0 -14
- /package/packages/{blogkit → core}/LICENSE +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/Avatar.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/Avatar.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BackLink.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BackLink.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BlogCard.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BlogCard.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BlogHeroCard.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BlogHeroCard.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/Container.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/Container.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/ImageLightbox.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/ImageLightbox.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/MorePosts.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/MorePosts.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/ShareButtons.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/ShareButtons.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/SummaryCard.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/SummaryCard.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/TagTabs.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/TagTabs.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/index.js +0 -0
- /package/packages/{blogkit → core}/dist/theme/ThemeSwitcher.svelte +0 -0
- /package/packages/{blogkit → core}/dist/theme/ThemeSwitcher.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/theme/index.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/theme/index.js +0 -0
- /package/packages/{blogkit → core}/dist/theme/store.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/types/blog.js +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# svelta
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A documentation-first blogging system built with SvelteKit (Svelte 5) + Markdown.
|
|
4
4
|
|
|
5
5
|
## Routes
|
|
6
6
|
|
|
@@ -32,4 +32,3 @@ npm run check
|
|
|
32
32
|
npm run build
|
|
33
33
|
npm run preview
|
|
34
34
|
```
|
|
35
|
-
|
package/package.json
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aureuma/svelta",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.1.1",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"packages/core/dist",
|
|
8
|
+
"packages/core/README.md",
|
|
9
|
+
"packages/core/CHANGELOG.md",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./packages/core/dist/index.d.ts",
|
|
16
|
+
"svelte": "./packages/core/dist/index.js",
|
|
17
|
+
"default": "./packages/core/dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./server": {
|
|
20
|
+
"types": "./packages/core/dist/server/index.d.ts",
|
|
21
|
+
"default": "./packages/core/dist/server/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./theme": {
|
|
24
|
+
"types": "./packages/core/dist/theme/index.d.ts",
|
|
25
|
+
"svelte": "./packages/core/dist/theme/index.js",
|
|
26
|
+
"default": "./packages/core/dist/theme/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"types": "./packages/core/dist/index.d.ts",
|
|
30
|
+
"main": "./packages/core/dist/index.js",
|
|
31
|
+
"svelte": "./packages/core/dist/index.js",
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"svelte": "^4.0.0 || ^5.0.0"
|
|
34
|
+
},
|
|
6
35
|
"workspaces": [
|
|
7
36
|
"packages/*"
|
|
8
37
|
],
|
|
@@ -11,14 +40,15 @@
|
|
|
11
40
|
"build": "vite build",
|
|
12
41
|
"preview": "vite preview",
|
|
13
42
|
"prepare": "svelte-kit sync || echo ''",
|
|
14
|
-
"postinstall": "npm -w @aureuma/
|
|
43
|
+
"postinstall": "npm -w @aureuma/svelta-core run build",
|
|
15
44
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
16
45
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
17
46
|
"test:e2e": "playwright test",
|
|
18
47
|
"test": "npm run check && npm run test:e2e",
|
|
19
48
|
"changeset": "changeset",
|
|
20
49
|
"version-packages": "changeset version",
|
|
21
|
-
"release": "changeset publish"
|
|
50
|
+
"release": "changeset publish",
|
|
51
|
+
"prepack": "npm -w @aureuma/svelta-core run build"
|
|
22
52
|
},
|
|
23
53
|
"devDependencies": {
|
|
24
54
|
"@changesets/changelog-github": "^0.5.2",
|
|
@@ -47,9 +77,11 @@
|
|
|
47
77
|
"vite": "^7.3.1"
|
|
48
78
|
},
|
|
49
79
|
"dependencies": {
|
|
80
|
+
"esm-env": "^1.2.2",
|
|
50
81
|
"@fontsource/geist-mono": "^5.2.7",
|
|
51
82
|
"@fontsource/inter": "^5.2.8",
|
|
52
83
|
"gray-matter": "^4.0.3",
|
|
84
|
+
"marked": "^12.0.2",
|
|
53
85
|
"reading-time": "^1.5.0",
|
|
54
86
|
"zod": "^4.3.6"
|
|
55
87
|
},
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# svelta Core Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-02-22
|
|
8
|
+
### Added
|
|
9
|
+
- Added `createRawBlog` support for raw markdown module sources.
|
|
10
|
+
- Added Viva frontmatter and author parser helpers:
|
|
11
|
+
- `parseVivaBlogFrontmatter`
|
|
12
|
+
- `parseVivaAuthorFrontmatter`
|
|
13
|
+
- `parseMarkdownAuthorMap`
|
|
14
|
+
- `parseVivaAuthorProfiles`
|
|
15
|
+
- Added exported Viva-focused types for frontmatter and author profiles.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Expanded `@aureuma/svelta/server` exports to support Viva app migration without custom blog parsing code.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# svelta Core (Internal)
|
|
2
|
+
|
|
3
|
+
This internal workspace package contains the reusable library source and build output consumed by `@aureuma/svelta`.
|
|
4
|
+
|
|
5
|
+
Public package consumers should install:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @aureuma/svelta
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The source of truth for public usage/docs is the repository root.
|
|
@@ -8,4 +8,4 @@ export { default as MorePosts } from './components/blog/MorePosts.svelte';
|
|
|
8
8
|
export { default as ShareButtons } from './components/blog/ShareButtons.svelte';
|
|
9
9
|
export { default as SummaryCard } from './components/blog/SummaryCard.svelte';
|
|
10
10
|
export { default as TagTabs } from './components/blog/TagTabs.svelte';
|
|
11
|
-
export type { BlogAuthor, BlogCategory, BlogPost, BlogPostFull } from './types/blog';
|
|
11
|
+
export type { BlogAuthor, BlogCategory, BlogPost, BlogPostFull, BlogPostWithContent, BlogTag } from './types/blog';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { BlogAuthor, BlogCategory, BlogPost, BlogPostFull, BlogPostWithContent, BlogTag } from '../types/blog';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
declare const frontmatterSchema: z.ZodObject<{
|
|
4
|
+
title: z.ZodString;
|
|
5
|
+
date: z.ZodString;
|
|
6
|
+
category: z.ZodString;
|
|
7
|
+
author: z.ZodString;
|
|
8
|
+
cover: z.ZodString;
|
|
9
|
+
excerpt: z.ZodOptional<z.ZodString>;
|
|
10
|
+
summaryAI: z.ZodOptional<z.ZodString>;
|
|
11
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
12
|
+
featured: z.ZodOptional<z.ZodBoolean>;
|
|
13
|
+
draft: z.ZodOptional<z.ZodBoolean>;
|
|
14
|
+
}, z.core.$strip>;
|
|
15
|
+
export type BlogFrontmatter = z.infer<typeof frontmatterSchema>;
|
|
16
|
+
export type BlogFrontmatterAdapter = (args: {
|
|
17
|
+
data: unknown;
|
|
18
|
+
content: string;
|
|
19
|
+
slug: string;
|
|
20
|
+
path: string;
|
|
21
|
+
}) => BlogFrontmatter;
|
|
22
|
+
type CompiledModule = {
|
|
23
|
+
default: BlogPostFull['component'];
|
|
24
|
+
};
|
|
25
|
+
export type BlogCreateConfig = {
|
|
26
|
+
compiledModules: Record<string, () => Promise<CompiledModule>>;
|
|
27
|
+
rawModules: Record<string, () => Promise<string>>;
|
|
28
|
+
getAuthor: (id: string) => BlogAuthor;
|
|
29
|
+
categoryOrder?: string[];
|
|
30
|
+
mapFrontmatter?: BlogFrontmatterAdapter;
|
|
31
|
+
};
|
|
32
|
+
export type MarkdownRenderer = (markdown: string) => string | Promise<string>;
|
|
33
|
+
export type RawBlogCreateConfig = {
|
|
34
|
+
rawModules: Record<string, () => Promise<string>>;
|
|
35
|
+
getAuthor: (id: string) => BlogAuthor;
|
|
36
|
+
categoryOrder?: string[];
|
|
37
|
+
mapFrontmatter?: BlogFrontmatterAdapter;
|
|
38
|
+
renderMarkdown?: MarkdownRenderer;
|
|
39
|
+
};
|
|
40
|
+
export type VivaImageAsset = {
|
|
41
|
+
url: string;
|
|
42
|
+
alt: string;
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
credit: string;
|
|
46
|
+
source: string;
|
|
47
|
+
};
|
|
48
|
+
export type VivaSeoFields = {
|
|
49
|
+
title: string;
|
|
50
|
+
description: string;
|
|
51
|
+
keywords: string[];
|
|
52
|
+
};
|
|
53
|
+
export type VivaBlogFrontmatter = {
|
|
54
|
+
title: string;
|
|
55
|
+
description: string;
|
|
56
|
+
slug: string;
|
|
57
|
+
publishedAt: string;
|
|
58
|
+
updatedAt: string;
|
|
59
|
+
author: string;
|
|
60
|
+
tags: string[];
|
|
61
|
+
canonical: string;
|
|
62
|
+
ogImage: VivaImageAsset;
|
|
63
|
+
draft: boolean;
|
|
64
|
+
seo: VivaSeoFields;
|
|
65
|
+
};
|
|
66
|
+
export type VivaAuthorFrontmatter = {
|
|
67
|
+
name: string;
|
|
68
|
+
slug: string;
|
|
69
|
+
role: string;
|
|
70
|
+
bio: string;
|
|
71
|
+
interests: string[];
|
|
72
|
+
canonical: string;
|
|
73
|
+
avatar: VivaImageAsset;
|
|
74
|
+
seo: VivaSeoFields;
|
|
75
|
+
};
|
|
76
|
+
export type VivaAuthorProfile = VivaAuthorFrontmatter & {
|
|
77
|
+
html: string;
|
|
78
|
+
raw: string;
|
|
79
|
+
};
|
|
80
|
+
export declare function createBlog(config: BlogCreateConfig): {
|
|
81
|
+
getAllPosts: () => Promise<BlogPost[]>;
|
|
82
|
+
getAllPostsFull: () => Promise<BlogPostFull[]>;
|
|
83
|
+
getPostBySlug: (slug: string) => Promise<BlogPostFull | null>;
|
|
84
|
+
getCategories: () => Promise<BlogCategory[]>;
|
|
85
|
+
pickHero: (posts?: BlogPost[]) => Promise<BlogPost>;
|
|
86
|
+
};
|
|
87
|
+
export declare function parseVivaBlogFrontmatter(data: unknown): VivaBlogFrontmatter;
|
|
88
|
+
export declare function parseVivaAuthorFrontmatter(data: unknown): VivaAuthorFrontmatter;
|
|
89
|
+
export declare function parseMarkdownAuthorMap(rawModules: Record<string, string>, fallbackAvatar?: string): Map<string, BlogAuthor>;
|
|
90
|
+
export declare function parseVivaAuthorProfiles(rawModules: Record<string, string>, renderMarkdown?: MarkdownRenderer): Promise<VivaAuthorProfile[]>;
|
|
91
|
+
export declare function createRawBlog(config: RawBlogCreateConfig): {
|
|
92
|
+
getAllPosts: () => Promise<BlogPost[]>;
|
|
93
|
+
getAllPostsWithContent: () => Promise<BlogPostWithContent[]>;
|
|
94
|
+
getPostBySlug: (slug: string) => Promise<BlogPostWithContent | null>;
|
|
95
|
+
getCategories: () => Promise<BlogCategory[]>;
|
|
96
|
+
pickHero: (posts?: BlogPost[]) => Promise<BlogPost>;
|
|
97
|
+
getAllTags: () => Promise<BlogTag[]>;
|
|
98
|
+
getPostsByTag: (tagSlug: string) => Promise<BlogPostWithContent[]>;
|
|
99
|
+
getPostsByAuthor: (authorId: string) => Promise<BlogPostWithContent[]>;
|
|
100
|
+
getAdjacentPosts: (slug: string) => Promise<{
|
|
101
|
+
previous: BlogPostWithContent | null;
|
|
102
|
+
next: BlogPostWithContent | null;
|
|
103
|
+
}>;
|
|
104
|
+
getRelatedPosts: (slug: string, limit?: number) => Promise<BlogPostWithContent[]>;
|
|
105
|
+
};
|
|
106
|
+
export {};
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { DEV } from 'esm-env';
|
|
2
|
+
import matter from 'gray-matter';
|
|
3
|
+
import { marked } from 'marked';
|
|
4
|
+
import readingTime from 'reading-time';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
const frontmatterSchema = z.object({
|
|
7
|
+
title: z.string(),
|
|
8
|
+
date: z.string(),
|
|
9
|
+
category: z.string(),
|
|
10
|
+
author: z.string(),
|
|
11
|
+
cover: z.string(),
|
|
12
|
+
excerpt: z.string().optional(),
|
|
13
|
+
summaryAI: z.string().optional(),
|
|
14
|
+
tags: z.array(z.string()).optional(),
|
|
15
|
+
featured: z.boolean().optional(),
|
|
16
|
+
draft: z.boolean().optional()
|
|
17
|
+
});
|
|
18
|
+
const DEFAULT_CATEGORY_ORDER = [
|
|
19
|
+
'all',
|
|
20
|
+
'ai-trends',
|
|
21
|
+
'announcements',
|
|
22
|
+
'for-founders',
|
|
23
|
+
'engineering',
|
|
24
|
+
'design',
|
|
25
|
+
'best-practices'
|
|
26
|
+
];
|
|
27
|
+
const vivaImageAssetSchema = z.object({
|
|
28
|
+
url: z.string(),
|
|
29
|
+
alt: z.string(),
|
|
30
|
+
width: z.number(),
|
|
31
|
+
height: z.number(),
|
|
32
|
+
credit: z.string(),
|
|
33
|
+
source: z.string()
|
|
34
|
+
});
|
|
35
|
+
const vivaSeoFieldsSchema = z.object({
|
|
36
|
+
title: z.string(),
|
|
37
|
+
description: z.string(),
|
|
38
|
+
keywords: z.array(z.string())
|
|
39
|
+
});
|
|
40
|
+
const vivaDateField = z
|
|
41
|
+
.union([z.string(), z.date()])
|
|
42
|
+
.transform((value) => (value instanceof Date ? value.toISOString() : value));
|
|
43
|
+
const vivaBlogFrontmatterSchema = z.object({
|
|
44
|
+
title: z.string(),
|
|
45
|
+
description: z.string(),
|
|
46
|
+
slug: z.string(),
|
|
47
|
+
publishedAt: vivaDateField,
|
|
48
|
+
updatedAt: vivaDateField,
|
|
49
|
+
author: z.string(),
|
|
50
|
+
tags: z.array(z.string()),
|
|
51
|
+
canonical: z.string(),
|
|
52
|
+
ogImage: vivaImageAssetSchema,
|
|
53
|
+
draft: z.boolean().optional().default(false),
|
|
54
|
+
seo: vivaSeoFieldsSchema
|
|
55
|
+
});
|
|
56
|
+
const vivaAuthorFrontmatterSchema = z.object({
|
|
57
|
+
name: z.string(),
|
|
58
|
+
slug: z.string(),
|
|
59
|
+
role: z.string(),
|
|
60
|
+
bio: z.string(),
|
|
61
|
+
interests: z.array(z.string()),
|
|
62
|
+
canonical: z.string(),
|
|
63
|
+
avatar: vivaImageAssetSchema,
|
|
64
|
+
seo: vivaSeoFieldsSchema
|
|
65
|
+
});
|
|
66
|
+
const frontmatterOnlySchema = /^---\s*[\r\n]+([\s\S]*?)\r?\n---\s*[\r\n]+/;
|
|
67
|
+
function slugify(input) {
|
|
68
|
+
return input
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.trim()
|
|
71
|
+
.replace(/['"]/g, '')
|
|
72
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
73
|
+
.replace(/^-+|-+$/g, '');
|
|
74
|
+
}
|
|
75
|
+
function normalizeCategory(label) {
|
|
76
|
+
const slug = slugify(label);
|
|
77
|
+
return { label, slug };
|
|
78
|
+
}
|
|
79
|
+
function parseISODate(date) {
|
|
80
|
+
// Prefer stable UTC parsing for YYYY-MM-DD.
|
|
81
|
+
if (/^\\d{4}-\\d{2}-\\d{2}$/.test(date)) {
|
|
82
|
+
const d = new Date(`${date}T00:00:00Z`);
|
|
83
|
+
if (!Number.isNaN(d.getTime()))
|
|
84
|
+
return d;
|
|
85
|
+
}
|
|
86
|
+
const d = new Date(date);
|
|
87
|
+
if (Number.isNaN(d.getTime()))
|
|
88
|
+
throw new Error(`Invalid date: ${date}`);
|
|
89
|
+
return d;
|
|
90
|
+
}
|
|
91
|
+
const fmtLong = new Intl.DateTimeFormat('en-US', {
|
|
92
|
+
month: 'long',
|
|
93
|
+
day: 'numeric',
|
|
94
|
+
year: 'numeric',
|
|
95
|
+
timeZone: 'UTC'
|
|
96
|
+
});
|
|
97
|
+
const fmtShort = new Intl.DateTimeFormat('en-US', {
|
|
98
|
+
month: 'short',
|
|
99
|
+
day: 'numeric',
|
|
100
|
+
year: 'numeric',
|
|
101
|
+
timeZone: 'UTC'
|
|
102
|
+
});
|
|
103
|
+
function stripForExcerpt(markdown) {
|
|
104
|
+
return (markdown
|
|
105
|
+
// remove fenced code blocks
|
|
106
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
107
|
+
// remove images
|
|
108
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
109
|
+
// remove links but keep text
|
|
110
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
111
|
+
// remove headings markers
|
|
112
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
113
|
+
// remove blockquote markers
|
|
114
|
+
.replace(/^>\s+/gm, '')
|
|
115
|
+
// remove emphasis markers
|
|
116
|
+
.replace(/[*_`]/g, '')
|
|
117
|
+
// collapse whitespace
|
|
118
|
+
.replace(/\s+/g, ' ')
|
|
119
|
+
.trim());
|
|
120
|
+
}
|
|
121
|
+
function excerptFromContent(content) {
|
|
122
|
+
const text = stripForExcerpt(content);
|
|
123
|
+
if (!text)
|
|
124
|
+
return '';
|
|
125
|
+
return text.length > 180 ? `${text.slice(0, 177).trimEnd()}...` : text;
|
|
126
|
+
}
|
|
127
|
+
function minutesToLabels(minutes) {
|
|
128
|
+
const m = Math.max(1, Math.round(minutes));
|
|
129
|
+
const unit = m === 1 ? 'minute' : 'minutes';
|
|
130
|
+
return {
|
|
131
|
+
minutes: m,
|
|
132
|
+
short: `${m} min read`,
|
|
133
|
+
long: `${m} ${unit} read`
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export function createBlog(config) {
|
|
137
|
+
const categoryOrder = config.categoryOrder ?? DEFAULT_CATEGORY_ORDER;
|
|
138
|
+
let cachedMetaIndex = null;
|
|
139
|
+
let cachedFullIndex = null;
|
|
140
|
+
let cachedSlugToPath = null;
|
|
141
|
+
function getSlugToPath() {
|
|
142
|
+
if (!DEV && cachedSlugToPath)
|
|
143
|
+
return cachedSlugToPath;
|
|
144
|
+
const m = new Map();
|
|
145
|
+
const paths = Object.keys(config.rawModules).sort();
|
|
146
|
+
for (const path of paths) {
|
|
147
|
+
const file = path.split('/').pop();
|
|
148
|
+
const slug = file?.replace(/\.md(?:\?.*)?$/, '');
|
|
149
|
+
if (!slug)
|
|
150
|
+
continue;
|
|
151
|
+
m.set(slug, path);
|
|
152
|
+
}
|
|
153
|
+
if (!DEV)
|
|
154
|
+
cachedSlugToPath = m;
|
|
155
|
+
return m;
|
|
156
|
+
}
|
|
157
|
+
async function buildMetaIndex() {
|
|
158
|
+
if (!DEV && cachedMetaIndex)
|
|
159
|
+
return cachedMetaIndex;
|
|
160
|
+
const posts = [];
|
|
161
|
+
const paths = Object.keys(config.rawModules).sort();
|
|
162
|
+
for (const path of paths) {
|
|
163
|
+
const file = path.split('/').pop();
|
|
164
|
+
// Glob keys can include query strings depending on bundler usage; normalize aggressively.
|
|
165
|
+
const slug = file?.replace(/\.md(?:\?.*)?$/, '');
|
|
166
|
+
if (!slug)
|
|
167
|
+
continue;
|
|
168
|
+
const rawFn = config.rawModules[path];
|
|
169
|
+
if (!rawFn)
|
|
170
|
+
continue;
|
|
171
|
+
const raw = await rawFn();
|
|
172
|
+
const { data, content } = matter(raw);
|
|
173
|
+
const metadata = config.mapFrontmatter
|
|
174
|
+
? config.mapFrontmatter({ data, content, slug, path })
|
|
175
|
+
: frontmatterSchema.parse(data);
|
|
176
|
+
if (metadata.draft)
|
|
177
|
+
continue;
|
|
178
|
+
const dateObj = parseISODate(metadata.date);
|
|
179
|
+
const rt = minutesToLabels(readingTime(content).minutes);
|
|
180
|
+
const category = normalizeCategory(metadata.category);
|
|
181
|
+
const excerpt = metadata.excerpt?.trim() || excerptFromContent(content);
|
|
182
|
+
posts.push({
|
|
183
|
+
slug,
|
|
184
|
+
title: metadata.title.trim(),
|
|
185
|
+
excerpt,
|
|
186
|
+
category,
|
|
187
|
+
tags: metadata.tags ?? [],
|
|
188
|
+
author: config.getAuthor(metadata.author),
|
|
189
|
+
date: metadata.date,
|
|
190
|
+
dateLong: fmtLong.format(dateObj),
|
|
191
|
+
dateShort: fmtShort.format(dateObj),
|
|
192
|
+
readingMinutes: rt.minutes,
|
|
193
|
+
readingTimeShort: rt.short,
|
|
194
|
+
readingTimeLong: rt.long,
|
|
195
|
+
cover: metadata.cover,
|
|
196
|
+
summaryAI: metadata.summaryAI,
|
|
197
|
+
featured: Boolean(metadata.featured)
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
posts.sort((a, b) => parseISODate(b.date).getTime() - parseISODate(a.date).getTime());
|
|
201
|
+
if (!DEV)
|
|
202
|
+
cachedMetaIndex = posts;
|
|
203
|
+
return posts;
|
|
204
|
+
}
|
|
205
|
+
async function getAllPosts() {
|
|
206
|
+
return buildMetaIndex();
|
|
207
|
+
}
|
|
208
|
+
async function getAllPostsFull() {
|
|
209
|
+
if (!DEV && cachedFullIndex)
|
|
210
|
+
return cachedFullIndex;
|
|
211
|
+
const meta = await buildMetaIndex();
|
|
212
|
+
const slugToPath = getSlugToPath();
|
|
213
|
+
const full = [];
|
|
214
|
+
for (const post of meta) {
|
|
215
|
+
const path = slugToPath.get(post.slug);
|
|
216
|
+
const compiledFn = path ? config.compiledModules[path] : undefined;
|
|
217
|
+
if (!compiledFn)
|
|
218
|
+
continue;
|
|
219
|
+
const compiled = await compiledFn();
|
|
220
|
+
full.push({ ...post, component: compiled.default });
|
|
221
|
+
}
|
|
222
|
+
if (!DEV)
|
|
223
|
+
cachedFullIndex = full;
|
|
224
|
+
return full;
|
|
225
|
+
}
|
|
226
|
+
async function getPostBySlug(slug) {
|
|
227
|
+
const meta = await buildMetaIndex();
|
|
228
|
+
const post = meta.find((p) => p.slug === slug) ?? null;
|
|
229
|
+
if (!post)
|
|
230
|
+
return null;
|
|
231
|
+
const path = getSlugToPath().get(slug);
|
|
232
|
+
const compiledFn = path ? config.compiledModules[path] : undefined;
|
|
233
|
+
if (!compiledFn)
|
|
234
|
+
return null;
|
|
235
|
+
const compiled = await compiledFn();
|
|
236
|
+
return { ...post, component: compiled.default };
|
|
237
|
+
}
|
|
238
|
+
async function getCategories() {
|
|
239
|
+
const posts = await getAllPosts();
|
|
240
|
+
const map = new Map();
|
|
241
|
+
for (const p of posts)
|
|
242
|
+
map.set(p.category.slug, p.category.label);
|
|
243
|
+
return Array.from(map.entries())
|
|
244
|
+
.map(([slug, label]) => ({ slug, label }))
|
|
245
|
+
.sort((a, b) => {
|
|
246
|
+
const ai = categoryOrder.indexOf(a.slug);
|
|
247
|
+
const bi = categoryOrder.indexOf(b.slug);
|
|
248
|
+
if (ai === -1 && bi === -1)
|
|
249
|
+
return a.label.localeCompare(b.label);
|
|
250
|
+
if (ai === -1)
|
|
251
|
+
return 1;
|
|
252
|
+
if (bi === -1)
|
|
253
|
+
return -1;
|
|
254
|
+
return ai - bi;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
async function pickHero(posts) {
|
|
258
|
+
const list = posts ?? (await getAllPosts());
|
|
259
|
+
const featured = list.filter((p) => p.featured);
|
|
260
|
+
return (featured[0] ?? list[0]);
|
|
261
|
+
}
|
|
262
|
+
return { getAllPosts, getAllPostsFull, getPostBySlug, getCategories, pickHero };
|
|
263
|
+
}
|
|
264
|
+
export function parseVivaBlogFrontmatter(data) {
|
|
265
|
+
return vivaBlogFrontmatterSchema.parse(data);
|
|
266
|
+
}
|
|
267
|
+
export function parseVivaAuthorFrontmatter(data) {
|
|
268
|
+
return vivaAuthorFrontmatterSchema.parse(data);
|
|
269
|
+
}
|
|
270
|
+
function extractFrontmatter(raw) {
|
|
271
|
+
const match = raw.match(frontmatterOnlySchema);
|
|
272
|
+
if (!match)
|
|
273
|
+
return {};
|
|
274
|
+
return matter(raw).data;
|
|
275
|
+
}
|
|
276
|
+
export function parseMarkdownAuthorMap(rawModules, fallbackAvatar = '/favicon.ico') {
|
|
277
|
+
const map = new Map();
|
|
278
|
+
for (const [path, raw] of Object.entries(rawModules)) {
|
|
279
|
+
const defaultSlug = path.split('/').pop()?.replace(/\.md(?:\?.*)?$/, '') || '';
|
|
280
|
+
const data = extractFrontmatter(raw);
|
|
281
|
+
const record = (typeof data === 'object' && data ? data : {});
|
|
282
|
+
const id = typeof record.slug === 'string' && record.slug.length > 0 ? record.slug : defaultSlug;
|
|
283
|
+
if (!id)
|
|
284
|
+
continue;
|
|
285
|
+
const name = typeof record.name === 'string' && record.name.length > 0 ? record.name : id;
|
|
286
|
+
const title = typeof record.role === 'string' && record.role.length > 0 ? record.role : 'Contributor';
|
|
287
|
+
const avatarRecord = record.avatar && typeof record.avatar === 'object'
|
|
288
|
+
? record.avatar
|
|
289
|
+
: null;
|
|
290
|
+
const avatar = avatarRecord && typeof avatarRecord.url === 'string' ? avatarRecord.url : fallbackAvatar;
|
|
291
|
+
map.set(id, { id, name, title, avatar });
|
|
292
|
+
}
|
|
293
|
+
return map;
|
|
294
|
+
}
|
|
295
|
+
export async function parseVivaAuthorProfiles(rawModules, renderMarkdown = defaultRenderMarkdown) {
|
|
296
|
+
const profiles = [];
|
|
297
|
+
for (const [path, raw] of Object.entries(rawModules)) {
|
|
298
|
+
const { data, content } = matter(raw);
|
|
299
|
+
const parsed = parseVivaAuthorFrontmatter(data);
|
|
300
|
+
const html = await renderMarkdown(content);
|
|
301
|
+
profiles.push({
|
|
302
|
+
...parsed,
|
|
303
|
+
html,
|
|
304
|
+
raw: content
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
profiles.sort((a, b) => a.name.localeCompare(b.name));
|
|
308
|
+
return profiles;
|
|
309
|
+
}
|
|
310
|
+
const defaultRenderMarkdown = (markdown) => String(marked.parse(markdown));
|
|
311
|
+
function toBlogTag(name) {
|
|
312
|
+
return {
|
|
313
|
+
name,
|
|
314
|
+
slug: slugify(name)
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
export function createRawBlog(config) {
|
|
318
|
+
const categoryOrder = config.categoryOrder ?? DEFAULT_CATEGORY_ORDER;
|
|
319
|
+
const renderMarkdown = config.renderMarkdown ?? defaultRenderMarkdown;
|
|
320
|
+
let cachedContentIndex = null;
|
|
321
|
+
async function buildContentIndex() {
|
|
322
|
+
if (!DEV && cachedContentIndex)
|
|
323
|
+
return cachedContentIndex;
|
|
324
|
+
const posts = [];
|
|
325
|
+
const paths = Object.keys(config.rawModules).sort();
|
|
326
|
+
for (const path of paths) {
|
|
327
|
+
const file = path.split('/').pop();
|
|
328
|
+
const slug = file?.replace(/\.md(?:\?.*)?$/, '');
|
|
329
|
+
if (!slug)
|
|
330
|
+
continue;
|
|
331
|
+
const rawFn = config.rawModules[path];
|
|
332
|
+
if (!rawFn)
|
|
333
|
+
continue;
|
|
334
|
+
const raw = await rawFn();
|
|
335
|
+
const { data, content } = matter(raw);
|
|
336
|
+
const metadata = config.mapFrontmatter
|
|
337
|
+
? config.mapFrontmatter({ data, content, slug, path })
|
|
338
|
+
: frontmatterSchema.parse(data);
|
|
339
|
+
if (metadata.draft)
|
|
340
|
+
continue;
|
|
341
|
+
const dateObj = parseISODate(metadata.date);
|
|
342
|
+
const rt = minutesToLabels(readingTime(content).minutes);
|
|
343
|
+
const category = normalizeCategory(metadata.category);
|
|
344
|
+
const excerpt = metadata.excerpt?.trim() || excerptFromContent(content);
|
|
345
|
+
const rendered = await renderMarkdown(content);
|
|
346
|
+
posts.push({
|
|
347
|
+
slug,
|
|
348
|
+
title: metadata.title.trim(),
|
|
349
|
+
excerpt,
|
|
350
|
+
category,
|
|
351
|
+
tags: metadata.tags ?? [],
|
|
352
|
+
author: config.getAuthor(metadata.author),
|
|
353
|
+
authorId: metadata.author,
|
|
354
|
+
date: metadata.date,
|
|
355
|
+
dateLong: fmtLong.format(dateObj),
|
|
356
|
+
dateShort: fmtShort.format(dateObj),
|
|
357
|
+
readingMinutes: rt.minutes,
|
|
358
|
+
readingTimeShort: rt.short,
|
|
359
|
+
readingTimeLong: rt.long,
|
|
360
|
+
cover: metadata.cover,
|
|
361
|
+
summaryAI: metadata.summaryAI,
|
|
362
|
+
featured: Boolean(metadata.featured),
|
|
363
|
+
html: rendered,
|
|
364
|
+
raw: content,
|
|
365
|
+
frontmatter: typeof data === 'object' && data ? data : {}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
posts.sort((a, b) => parseISODate(b.date).getTime() - parseISODate(a.date).getTime());
|
|
369
|
+
if (!DEV)
|
|
370
|
+
cachedContentIndex = posts;
|
|
371
|
+
return posts;
|
|
372
|
+
}
|
|
373
|
+
function stripContent(post) {
|
|
374
|
+
const { html: _html, raw: _raw, authorId: _authorId, frontmatter: _frontmatter, ...meta } = post;
|
|
375
|
+
return meta;
|
|
376
|
+
}
|
|
377
|
+
async function getAllPosts() {
|
|
378
|
+
const posts = await buildContentIndex();
|
|
379
|
+
return posts.map(stripContent);
|
|
380
|
+
}
|
|
381
|
+
async function getAllPostsWithContent() {
|
|
382
|
+
return buildContentIndex();
|
|
383
|
+
}
|
|
384
|
+
async function getPostBySlug(slug) {
|
|
385
|
+
const posts = await buildContentIndex();
|
|
386
|
+
return posts.find((p) => p.slug === slug) ?? null;
|
|
387
|
+
}
|
|
388
|
+
async function getCategories() {
|
|
389
|
+
const posts = await getAllPosts();
|
|
390
|
+
const map = new Map();
|
|
391
|
+
for (const p of posts)
|
|
392
|
+
map.set(p.category.slug, p.category.label);
|
|
393
|
+
return Array.from(map.entries())
|
|
394
|
+
.map(([slug, label]) => ({ slug, label }))
|
|
395
|
+
.sort((a, b) => {
|
|
396
|
+
const ai = categoryOrder.indexOf(a.slug);
|
|
397
|
+
const bi = categoryOrder.indexOf(b.slug);
|
|
398
|
+
if (ai === -1 && bi === -1)
|
|
399
|
+
return a.label.localeCompare(b.label);
|
|
400
|
+
if (ai === -1)
|
|
401
|
+
return 1;
|
|
402
|
+
if (bi === -1)
|
|
403
|
+
return -1;
|
|
404
|
+
return ai - bi;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
async function pickHero(posts) {
|
|
408
|
+
const list = posts ?? (await getAllPosts());
|
|
409
|
+
const featured = list.filter((p) => p.featured);
|
|
410
|
+
return (featured[0] ?? list[0]);
|
|
411
|
+
}
|
|
412
|
+
async function getAllTags() {
|
|
413
|
+
const posts = await buildContentIndex();
|
|
414
|
+
const map = new Map();
|
|
415
|
+
for (const post of posts) {
|
|
416
|
+
for (const tagName of post.tags) {
|
|
417
|
+
const tag = toBlogTag(tagName);
|
|
418
|
+
map.set(tag.slug, tag);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
422
|
+
}
|
|
423
|
+
async function getPostsByTag(tagSlug) {
|
|
424
|
+
const posts = await buildContentIndex();
|
|
425
|
+
return posts.filter((post) => post.tags.some((tagName) => slugify(tagName) === tagSlug));
|
|
426
|
+
}
|
|
427
|
+
async function getPostsByAuthor(authorId) {
|
|
428
|
+
const posts = await buildContentIndex();
|
|
429
|
+
return posts.filter((post) => post.authorId === authorId);
|
|
430
|
+
}
|
|
431
|
+
async function getAdjacentPosts(slug) {
|
|
432
|
+
const posts = await buildContentIndex();
|
|
433
|
+
const index = posts.findIndex((post) => post.slug === slug);
|
|
434
|
+
if (index === -1)
|
|
435
|
+
return { previous: null, next: null };
|
|
436
|
+
return {
|
|
437
|
+
previous: posts[index + 1] ?? null,
|
|
438
|
+
next: posts[index - 1] ?? null
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
async function getRelatedPosts(slug, limit = 3) {
|
|
442
|
+
const posts = await buildContentIndex();
|
|
443
|
+
const current = posts.find((post) => post.slug === slug);
|
|
444
|
+
if (!current)
|
|
445
|
+
return [];
|
|
446
|
+
const tagSet = new Set(current.tags.map((tag) => slugify(tag)));
|
|
447
|
+
return posts
|
|
448
|
+
.filter((post) => post.slug !== slug)
|
|
449
|
+
.map((post) => {
|
|
450
|
+
const overlap = post.tags.filter((tag) => tagSet.has(slugify(tag))).length;
|
|
451
|
+
return { post, overlap };
|
|
452
|
+
})
|
|
453
|
+
.filter((entry) => entry.overlap > 0)
|
|
454
|
+
.sort((a, b) => b.overlap - a.overlap)
|
|
455
|
+
.slice(0, limit)
|
|
456
|
+
.map((entry) => entry.post);
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
getAllPosts,
|
|
460
|
+
getAllPostsWithContent,
|
|
461
|
+
getPostBySlug,
|
|
462
|
+
getCategories,
|
|
463
|
+
pickHero,
|
|
464
|
+
getAllTags,
|
|
465
|
+
getPostsByTag,
|
|
466
|
+
getPostsByAuthor,
|
|
467
|
+
getAdjacentPosts,
|
|
468
|
+
getRelatedPosts
|
|
469
|
+
};
|
|
470
|
+
}
|