@fuzdev/fuz_blog 0.20.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Ryan Atkinson <mail@ryanatkn.com> <https://ryanatkn.com/>
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,17 @@
1
+ # @fuzdev/fuz_blog 🖊️
2
+
3
+ [<img src="/static/logo.svg" alt="a friendly yellow spider facing you" align="right" width="192" height="192">](https://blog.fuz.dev/)
4
+
5
+ > blog software from scratch with SvelteKit
6
+
7
+ [**blog.fuz.dev**](https://blog.fuz.dev/)
8
+
9
+ [npm](https://www.npmjs.com/package/@fuzdev/fuz_blog):
10
+
11
+ ```bash
12
+ npm i @fuzdev/fuz_blog
13
+ ```
14
+
15
+ ## License [🐦](https://wikipedia.org/wiki/Free_and_open-source_software)
16
+
17
+ [MIT](LICENSE)
@@ -0,0 +1,64 @@
1
+ <script lang="ts">
2
+ import type {SvelteHTMLElements} from 'svelte/elements';
3
+ import type {Snippet} from 'svelte';
4
+ import Toot from '@fuzdev/fuz_mastodon/Toot.svelte';
5
+ import {mastodon_cache_context} from '@fuzdev/fuz_mastodon/mastodon_cache.svelte.js';
6
+
7
+ import BlogPostHeader from './BlogPostHeader.svelte';
8
+ import {blog_feed_context, type BlogPostData} from './blog.js';
9
+
10
+ interface Props {
11
+ post: BlogPostData;
12
+ attrs?: SvelteHTMLElements['article'] | undefined;
13
+ footer?: Snippet;
14
+ separator?: Snippet; // TODO currently only used before comments, maybe rename to `comments_header` or something?
15
+ children: Snippet;
16
+ }
17
+
18
+ const {post, attrs, footer, separator = default_separator, children}: Props = $props();
19
+
20
+ const feed = blog_feed_context.get();
21
+
22
+ // TODO maybe clean up the type vs `post`
23
+ const item = feed.items.find((i) => i.slug === post.slug);
24
+
25
+ const cache = mastodon_cache_context.get_maybe();
26
+ </script>
27
+
28
+ <svelte:head>
29
+ <!-- TODO title suffix like - ryanatkn.com/blog -->
30
+ <title>{post.title}</title>
31
+ </svelte:head>
32
+
33
+ <div class="blog_post width_upto_md">
34
+ {#if item}
35
+ <article {...attrs}>
36
+ <BlogPostHeader {item} />
37
+ {@render children()}
38
+ {@render footer?.()}
39
+ {#if item.comments}
40
+ {@render separator()}
41
+ <!-- TODO the storage key is weird -->
42
+ <!-- TODO use local cache in dev -->
43
+ <section>
44
+ <h2>Comments</h2>
45
+ {#if !cache || cache.data !== undefined}
46
+ <Toot
47
+ url={item.comments.url}
48
+ include_replies
49
+ initial_autoload
50
+ reply_filter={(item) => ({type: 'favourited_by', favourited_by: [item.account.acct]})}
51
+ settings_storage_key="{item.id}_comments_settings"
52
+ cache={cache?.data}
53
+ />
54
+ {/if}
55
+ </section>
56
+ {@render separator()}
57
+ {/if}
58
+ </article>
59
+ {:else}
60
+ <div>cannot find post <code>{post.slug}</code></div>
61
+ {/if}
62
+ </div>
63
+
64
+ {#snippet default_separator()}<hr />{/snippet}
@@ -0,0 +1,14 @@
1
+ import type { SvelteHTMLElements } from 'svelte/elements';
2
+ import type { Snippet } from 'svelte';
3
+ import { type BlogPostData } from './blog.js';
4
+ interface Props {
5
+ post: BlogPostData;
6
+ attrs?: SvelteHTMLElements['article'] | undefined;
7
+ footer?: Snippet;
8
+ separator?: Snippet;
9
+ children: Snippet;
10
+ }
11
+ declare const BlogPost: import("svelte").Component<Props, {}, "">;
12
+ type BlogPost = ReturnType<typeof BlogPost>;
13
+ export default BlogPost;
14
+ //# sourceMappingURL=BlogPost.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BlogPost.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/BlogPost.svelte"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,iBAAiB,CAAC;AACxD,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,QAAQ,CAAC;AAKpC,OAAO,EAAoB,KAAK,YAAY,EAAC,MAAM,WAAW,CAAC;AAG9D,UAAU,KAAK;IACd,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,CAAC,EAAE,kBAAkB,CAAC,SAAS,CAAC,GAAG,SAAS,CAAC;IAClD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;CAClB;AAsDF,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,17 @@
1
+ <script lang="ts">
2
+ import FeedItemDate from './FeedItemDate.svelte';
3
+ import type {BlogPostItem} from './blog.js';
4
+
5
+ interface Props {
6
+ item: BlogPostItem;
7
+ }
8
+
9
+ const {item}: Props = $props();
10
+ </script>
11
+
12
+ <header>
13
+ <h1 class="mt_xl5 mb_lg">{item.title}</h1>
14
+ <p class="mb_xl5">
15
+ <FeedItemDate {item} />
16
+ </p>
17
+ </header>
@@ -0,0 +1,8 @@
1
+ import type { BlogPostItem } from './blog.js';
2
+ interface Props {
3
+ item: BlogPostItem;
4
+ }
5
+ declare const BlogPostHeader: import("svelte").Component<Props, {}, "">;
6
+ type BlogPostHeader = ReturnType<typeof BlogPostHeader>;
7
+ export default BlogPostHeader;
8
+ //# sourceMappingURL=BlogPostHeader.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BlogPostHeader.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/BlogPostHeader.svelte"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,WAAW,CAAC;AAG3C,UAAU,KAAK;IACd,IAAI,EAAE,YAAY,CAAC;CACnB;AAkBF,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -0,0 +1,15 @@
1
+ <script lang="ts">
2
+ import type {FeedItem} from './feed.js';
3
+ import {format_date} from './util.js';
4
+
5
+ interface Props {
6
+ item: FeedItem;
7
+ }
8
+
9
+ const {item}: Props = $props();
10
+ </script>
11
+
12
+ {format_date(
13
+ item.date_published || item.date_modified,
14
+ )}{#if item.date_published && item.date_modified && item.date_published !== item.date_modified},
15
+ updated {format_date(item.date_modified)}{/if}
@@ -0,0 +1,8 @@
1
+ import type { FeedItem } from './feed.js';
2
+ interface Props {
3
+ item: FeedItem;
4
+ }
5
+ declare const FeedItemDate: import("svelte").Component<Props, {}, "">;
6
+ type FeedItemDate = ReturnType<typeof FeedItemDate>;
7
+ export default FeedItemDate;
8
+ //# sourceMappingURL=FeedItemDate.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FeedItemDate.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/FeedItemDate.svelte"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,WAAW,CAAC;AAIvC,UAAU,KAAK;IACd,IAAI,EAAE,QAAQ,CAAC;CACf;AAgBF,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ import type {Snippet} from 'svelte';
3
+
4
+ // TODO maybe move to Fuz
5
+
6
+ interface Props {
7
+ slug: string;
8
+ children: Snippet;
9
+ }
10
+
11
+ const {slug, children}: Props = $props();
12
+ </script>
13
+
14
+ <div class="hash_link" id={slug}>
15
+ {@render children()}
16
+ <a class="icon_button" href="#{slug}">🔗</a>
17
+ </div>
18
+
19
+ <style>
20
+ .hash_link {
21
+ display: flex;
22
+ align-items: center;
23
+ position: relative;
24
+ }
25
+ a {
26
+ --icon_size: var(--icon_size_sm);
27
+ position: absolute;
28
+ left: calc(var(--icon_size, var(--icon_size_md)) * -1 - var(--space_xl));
29
+ opacity: 0;
30
+ font-size: var(--font_size_lg);
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: center;
34
+ margin-left: var(--space_md);
35
+ }
36
+ .hash_link:hover a {
37
+ opacity: 1;
38
+ }
39
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ slug: string;
4
+ children: Snippet;
5
+ }
6
+ declare const HashLink: import("svelte").Component<Props, {}, "">;
7
+ type HashLink = ReturnType<typeof HashLink>;
8
+ export default HashLink;
9
+ //# sourceMappingURL=HashLink.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HashLink.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/HashLink.svelte"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,QAAQ,CAAC;AAKnC,UAAU,KAAK;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;CAClB;AAiBF,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
package/dist/blog.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { Component } from 'svelte';
2
+ import type { Flavored, OmitStrict } from '@fuzdev/fuz_util/types.js';
3
+ import type { Feed } from './feed.js';
4
+ export type BlogFeedData = OmitStrict<Feed, 'items'>;
5
+ export interface BlogFeed extends Feed {
6
+ items: Array<BlogPostItem>;
7
+ }
8
+ /**
9
+ * The author-defined data for each post.
10
+ */
11
+ export interface BlogPostData {
12
+ title: string;
13
+ slug: string;
14
+ date_published: string;
15
+ date_modified: string;
16
+ summary: string;
17
+ tags?: Array<string>;
18
+ comments?: BlogComments;
19
+ }
20
+ export type BlogComments = MastodonBlogComments;
21
+ export interface MastodonBlogComments {
22
+ url: string;
23
+ type: 'mastodon';
24
+ }
25
+ export interface BlogModule {
26
+ blog: BlogFeedData;
27
+ }
28
+ export interface BlogPostModule {
29
+ post: BlogPostData;
30
+ default: Component<any>;
31
+ }
32
+ export type BlogPostId = Flavored<number, 'BlogPostId'>;
33
+ export interface BlogPostItem extends BlogPostData {
34
+ /**
35
+ * Blog post path with `blog_post_id`.
36
+ */
37
+ id: string;
38
+ /**
39
+ * Blog post path with `slug`.
40
+ */
41
+ url: string;
42
+ /**
43
+ * Incrementing 1-based integer.
44
+ */
45
+ blog_post_id: BlogPostId;
46
+ tags: Array<string>;
47
+ }
48
+ export declare const blog_feed_context: {
49
+ get: (error_message?: string) => BlogFeed;
50
+ get_maybe: () => BlogFeed | undefined;
51
+ set: (value: BlogFeed) => BlogFeed;
52
+ };
53
+ //# sourceMappingURL=blog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blog.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/blog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAC,QAAQ,EAAE,UAAU,EAAC,MAAM,2BAA2B,CAAC;AAGpE,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,WAAW,CAAC;AAMpC,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AAErD,MAAM,WAAW,QAAS,SAAQ,IAAI;IACrC,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;CAC3B;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrB,QAAQ,CAAC,EAAE,YAAY,CAAC;CACxB;AAGD,MAAM,MAAM,YAAY,GAAG,oBAAoB,CAAC;AAEhD,MAAM,WAAW,oBAAoB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,UAAU,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,YAAY,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,YAAY,CAAC;IACnB,OAAO,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC;CACxB;AAED,MAAM,MAAM,UAAU,GAAG,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;AAExD,MAAM,WAAW,YAAa,SAAQ,YAAY;IACjD;;OAEG;IACH,EAAE,EAAE,MAAM,CAAC;IAEX;;OAEG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;OAEG;IACH,YAAY,EAAE,UAAU,CAAC;IAEzB,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACpB;AAED,eAAO,MAAM,iBAAiB;;;;CAA6B,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { Gen } from '@ryanatkn/gro/gen.js';
2
+ /** @nodocs */
3
+ export declare const gen: Gen;
4
+ //# sourceMappingURL=blog.gen.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blog.gen.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/blog.gen.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,GAAG,EAAC,MAAM,sBAAsB,CAAC;AAY9C,cAAc;AACd,eAAO,MAAM,GAAG,EAAE,GAkEjB,CAAC"}
@@ -0,0 +1,78 @@
1
+ import { join } from 'node:path';
2
+ import { load_package_json } from '@ryanatkn/gro/package_json.js';
3
+ import { create_atom_feed } from './feed.js';
4
+ import { collect_blog_post_ids, load_blog_post_modules, resolve_blog_post_item, } from './blog_helpers.js';
5
+ /** @nodocs */
6
+ export const gen = async ({ origin_path }) => {
7
+ // TODO @many parameterize and refactor
8
+ const package_json = await load_package_json();
9
+ const fuz_blog_import_path = package_json.name === '@fuzdev/fuz_blog' ? '$lib' : '@fuzdev/fuz_blog';
10
+ const dir = process.cwd();
11
+ const blog_dirname = 'blog';
12
+ const routes_path = 'src/routes'; // TODO read from SvelteKit config;
13
+ const blog_dir = join(dir, routes_path, blog_dirname);
14
+ const { blog } = (await import(join(blog_dir, 'blog.ts'))); // TODO zod parse
15
+ const blog_post_ids = collect_blog_post_ids(blog_dir);
16
+ const modules = await load_blog_post_modules(blog_dir, blog_post_ids);
17
+ // TODO zod schema validation including parsing the status context url (with zod?)
18
+ // for (const mod of modules) {
19
+ // validate_blog_post(mod.post)
20
+ // }
21
+ const blog_url = blog.id + blog_dirname;
22
+ const items = modules.map((mod, i) => resolve_blog_post_item(i + 1, blog_url, mod.post));
23
+ const feed = { ...blog, items };
24
+ return [
25
+ {
26
+ // TODO @many harcoded /blog/
27
+ filename: join(dir, 'static/blog/feed.xml'),
28
+ content: create_atom_feed(feed),
29
+ },
30
+ {
31
+ filename: join(blog_dir, 'feed.ts'),
32
+ content: `
33
+ // generated by ${origin_path}
34
+
35
+ import type {BlogFeed} from '${fuz_blog_import_path}/blog.js';
36
+
37
+ export const feed: BlogFeed = ${JSON.stringify(feed)}
38
+
39
+ // generated by ${origin_path}
40
+ `,
41
+ },
42
+ ...modules.map((item, i) => {
43
+ const post = item.post;
44
+ const slug = post.slug;
45
+ const blog_post_id = i + 1;
46
+ return {
47
+ filename: join(blog_dir, slug, '+page.svelte'),
48
+ content: `
49
+ <!-- generated by ${origin_path} -->
50
+
51
+ <script lang="ts">
52
+ import Blog_Post_${blog_post_id} from '../${blog_post_id}/+page.svelte';
53
+ </script>
54
+
55
+ <Blog_Post_${blog_post_id} />
56
+
57
+ <!-- generated by ${origin_path} -->
58
+ `,
59
+ };
60
+ }),
61
+ ];
62
+ };
63
+ // const to_prerender_entries = (blog: Feed): string[] => {
64
+ // const entries = [];
65
+ // for (let index = 0; index < blog.items.length; index++) {
66
+ // const item = blog.items[index];
67
+ // const {pathname} = new URL(item.url);
68
+ // entries.push(pathname);
69
+ // // replace the last segment with the index
70
+ // for (let i = pathname.length - 1; i >= 0; i--) {
71
+ // if (pathname[i] === '/') {
72
+ // entries.push(pathname.substring(0, i + 1) + (1 + index));
73
+ // break;
74
+ // }
75
+ // }
76
+ // }
77
+ // return entries;
78
+ // };
package/dist/blog.js ADDED
@@ -0,0 +1,2 @@
1
+ import { create_context } from '@fuzdev/fuz_ui/context_helpers.js';
2
+ export const blog_feed_context = create_context();
@@ -0,0 +1,11 @@
1
+ import type { BlogPostId, BlogPostData, BlogPostItem, BlogPostModule } from './blog.js';
2
+ export declare const resolve_blog_post_item: (blog_post_id: BlogPostId, blog_url: string, post: BlogPostData) => BlogPostItem;
3
+ /**
4
+ * Returns an array of all of the sequential blog post ids starting with 1.
5
+ * When it fails to find the next id, the sequence ends.
6
+ */
7
+ export declare const collect_blog_post_ids: (blog_dir: string) => Array<BlogPostId>;
8
+ export declare const load_blog_post_modules: (blog_dir: string, blog_post_ids: Array<BlogPostId>) => Promise<Array<BlogPostModule>>;
9
+ export declare const to_next_blog_post_id: (blog_post_ids: Array<BlogPostId>) => BlogPostId;
10
+ export declare const to_blog_post_path: (blog_dir: string, blog_post_id: BlogPostId) => string;
11
+ //# sourceMappingURL=blog_helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blog_helpers.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/blog_helpers.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAC,UAAU,EAAE,YAAY,EAAE,YAAY,EAAE,cAAc,EAAC,MAAM,WAAW,CAAC;AAItF,eAAO,MAAM,sBAAsB,GAClC,cAAc,UAAU,EACxB,UAAU,MAAM,EAChB,MAAM,YAAY,KAChB,YAcF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,qBAAqB,GAAI,UAAU,MAAM,KAAG,KAAK,CAAC,UAAU,CAaxE,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAClC,UAAU,MAAM,EAChB,eAAe,KAAK,CAAC,UAAU,CAAC,KAC9B,OAAO,CAAC,KAAK,CAAC,cAAc,CAAC,CACkE,CAAC;AAEnG,eAAO,MAAM,oBAAoB,GAAI,eAAe,KAAK,CAAC,UAAU,CAAC,KAAG,UAGvE,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAAI,UAAU,MAAM,EAAE,cAAc,UAAU,KAAG,MAChC,CAAC"}
@@ -0,0 +1,41 @@
1
+ import { strip_end } from '@fuzdev/fuz_util/string.js';
2
+ import { join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ // TODO maybe move non-node stuff to `blog`, maybe rename this to `blog_fs_helpers`?
5
+ export const resolve_blog_post_item = (blog_post_id, blog_url, post) => {
6
+ const final_blog_url = strip_end(blog_url, '/');
7
+ return {
8
+ id: final_blog_url + '/' + blog_post_id,
9
+ url: final_blog_url + '/' + post.slug,
10
+ blog_post_id,
11
+ title: post.title,
12
+ slug: post.slug,
13
+ date_published: post.date_published,
14
+ date_modified: post.date_modified,
15
+ summary: post.summary,
16
+ tags: post.tags ?? [],
17
+ comments: post.comments,
18
+ };
19
+ };
20
+ /**
21
+ * Returns an array of all of the sequential blog post ids starting with 1.
22
+ * When it fails to find the next id, the sequence ends.
23
+ */
24
+ export const collect_blog_post_ids = (blog_dir) => {
25
+ const blog_post_ids = [];
26
+ let blog_post_id = 1;
27
+ while (true) {
28
+ if (!existsSync(to_blog_post_path(blog_dir, blog_post_id))) {
29
+ break;
30
+ }
31
+ blog_post_ids.push(blog_post_id);
32
+ blog_post_id++;
33
+ }
34
+ return blog_post_ids;
35
+ };
36
+ export const load_blog_post_modules = (blog_dir, blog_post_ids) => Promise.all(blog_post_ids.map((item) => import(join(blog_dir, item.toString(), '+page.svelte'))));
37
+ export const to_next_blog_post_id = (blog_post_ids) => {
38
+ const last = blog_post_ids.at(-1);
39
+ return last === undefined ? 1 : last + 1;
40
+ };
41
+ export const to_blog_post_path = (blog_dir, blog_post_id) => join(blog_dir, blog_post_id + '/+page.svelte');
package/dist/feed.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * This is designed to extend JSON Feed 1.1 with namespaced data for other specs like Atom.
3
+ * It's still a work in progress, and I'll add features as I need them,
4
+ * and eventually this will be extracted to a standalone library.
5
+ * https://www.jsonfeed.org/version/1.1/
6
+ */
7
+ export interface Feed {
8
+ id: string;
9
+ title: string;
10
+ home_page_url: string;
11
+ description: string;
12
+ icon: string;
13
+ favicon: string;
14
+ author: {
15
+ name: string;
16
+ url?: string;
17
+ email?: string;
18
+ };
19
+ items: Array<FeedItem>;
20
+ atom: {
21
+ feed_url: string;
22
+ };
23
+ }
24
+ export interface FeedItem {
25
+ id: string;
26
+ title: string;
27
+ url: string;
28
+ date_published: string;
29
+ date_modified: string;
30
+ summary: string;
31
+ tags?: Array<string>;
32
+ }
33
+ export declare const create_atom_feed: (data: Feed) => string;
34
+ //# sourceMappingURL=feed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feed.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/feed.ts"],"names":[],"mappings":"AAOA;;;;;GAKG;AACH,MAAM,WAAW,IAAI;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE;QACP,IAAI,EAAE,MAAM,CAAC;QACb,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;KACf,CAAC;IAEF,KAAK,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC;IACvB,IAAI,EAAE;QACL,QAAQ,EAAE,MAAM,CAAC;KACjB,CAAC;CAUF;AAED,MAAM,WAAW,QAAQ;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAMhB,IAAI,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACrB;AAED,eAAO,MAAM,gBAAgB,GAAI,MAAM,IAAI,KAAG,MA8C7C,CAAC"}
package/dist/feed.js ADDED
@@ -0,0 +1,43 @@
1
+ // TODO publish `feed.json` not just the Atom `feed.xml`
2
+ export const create_atom_feed = (data) => {
3
+ const items = data.items
4
+ .slice()
5
+ .sort((a, b) => (new Date(a.date_published) > new Date(b.date_published) ? -1 : 1)); // TODO maybe add an option to customize this? maybe by `date_modified`?
6
+ const updated = items
7
+ .reduce((latest, item) => {
8
+ const modified = new Date(item.date_modified || item.date_published);
9
+ return modified > latest ? modified : latest;
10
+ }, new Date(0))
11
+ .toISOString();
12
+ return `<?xml version="1.0" encoding="UTF-8" ?>
13
+
14
+ <feed xmlns="http://www.w3.org/2005/Atom">
15
+
16
+ <id>${data.id}</id>
17
+ <title>${data.title}</title>
18
+ <subtitle>${data.description}</subtitle>
19
+ <link href="${data.home_page_url}" />
20
+ <link href="${data.atom.feed_url}" rel="self" type="application/atom+xml" />
21
+ <updated>${updated}</updated>
22
+ <icon>${data.icon}</icon>
23
+ <author>
24
+ <name>${data.author.name}</name>
25
+ ${data.author.email ? `<email>${data.author.email}</email>` : ''}
26
+ ${data.author.url ? `<uri>${data.author.url}</uri>` : ''}
27
+ </author>
28
+ ${items
29
+ .map((item) => `
30
+ <entry>
31
+ <id>${item.id}</id>
32
+ <title>${item.title}</title>
33
+ <link rel="alternate" href="${item.url}" />
34
+ <published>${item.date_published}</published>
35
+ <updated>${item.date_modified}</updated>
36
+ <summary>${item.summary}</summary>
37
+ ${item.tags ? item.tags.map((tag) => `<category term="${tag}" />`).join('') : ''}
38
+ </entry>`)
39
+ .join('\n')}
40
+
41
+ </feed>
42
+ `;
43
+ };
@@ -0,0 +1,11 @@
1
+ import { type Task } from '@ryanatkn/gro';
2
+ import { z } from 'zod';
3
+ /** @nodocs */
4
+ export declare const Args: z.ZodObject<{
5
+ _: z.ZodDefault<z.ZodArray<z.ZodString>>;
6
+ date: z.ZodOptional<z.ZodString>;
7
+ }, z.core.$strict>;
8
+ export type Args = z.infer<typeof Args>;
9
+ /** @nodocs */
10
+ export declare const task: Task<Args>;
11
+ //# sourceMappingURL=post.task.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"post.task.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/post.task.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,IAAI,EAAC,MAAM,eAAe,CAAC;AACnD,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAStB,cAAc;AACd,eAAO,MAAM,IAAI;;;kBAKP,CAAC;AACX,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC;AAExC,cAAc;AACd,eAAO,MAAM,IAAI,EAAE,IAAI,CAAC,IAAI,CAkE3B,CAAC"}
@@ -0,0 +1,68 @@
1
+ import { TaskError } from '@ryanatkn/gro';
2
+ import { z } from 'zod';
3
+ import { format_file } from '@ryanatkn/gro/format_file.js';
4
+ import { mkdir, writeFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+ import { load_package_json } from '@ryanatkn/gro/package_json.js';
7
+ import { slugify } from '@fuzdev/fuz_util/path.js';
8
+ import { collect_blog_post_ids, to_next_blog_post_id } from './blog_helpers.js';
9
+ /** @nodocs */
10
+ export const Args = z
11
+ .object({
12
+ _: z.array(z.string()).meta({ description: 'post title' }).max(1).default([]),
13
+ date: z.string().meta({ description: "the post's date_published" }).optional(),
14
+ })
15
+ .strict();
16
+ /** @nodocs */
17
+ export const task = {
18
+ summary: 'create a new blog post',
19
+ Args,
20
+ run: async ({ args, log, invoke_task }) => {
21
+ const { _: [raw_title], date = new Date().toISOString(), } = args;
22
+ if (!raw_title) {
23
+ throw new TaskError('post title is required, e.g. `gro post "Hello world"`');
24
+ }
25
+ const title = raw_title.trim();
26
+ const slug = slugify(title);
27
+ // TODO @many parameterize and refactor
28
+ const package_json = await load_package_json();
29
+ const fuz_blog_import_path = package_json.name === '@fuzdev/fuz_blog' ? '$lib' : '@fuzdev/fuz_blog';
30
+ const dir = process.cwd();
31
+ const blog_dirname = 'blog'; // TODO @many harcoded /blog/
32
+ const routes_path = 'src/routes'; // TODO read from SvelteKit config;
33
+ const blog_dir = join(dir, routes_path, blog_dirname);
34
+ const blog_post_ids = collect_blog_post_ids(blog_dir);
35
+ const next_blog_post_id = to_next_blog_post_id(blog_post_ids);
36
+ const next_blog_post_path = join(blog_dir, next_blog_post_id + '/+page.svelte');
37
+ const unformatted = `
38
+ <script lang="ts" module>
39
+ import type {BlogPostData} from '${fuz_blog_import_path}/blog.js';
40
+
41
+ export const post = {
42
+ title: ${JSON.stringify(title)},
43
+ slug: '${slug}',
44
+ date_published: '${date}',
45
+ date_modified: '${date}',
46
+ summary: 'todo',
47
+ tags: ['todo'],
48
+ } satisfies BlogPostData;
49
+ </script>
50
+
51
+ <script lang="ts">
52
+ import BlogPost from '${fuz_blog_import_path}/BlogPost.svelte';
53
+ </script>
54
+
55
+ <!-- This component is totally optional, you have full control over the page. -->
56
+ <BlogPost {post}>
57
+ <p>
58
+ TODO content goes here
59
+ </p>
60
+ </BlogPost>
61
+ `;
62
+ const formatted = await format_file(unformatted, { parser: 'svelte' });
63
+ await mkdir(dirname(next_blog_post_path), { recursive: true });
64
+ await writeFile(next_blog_post_path, formatted, 'utf8');
65
+ await invoke_task('gen');
66
+ log.info(`created empty blog post with index ${next_blog_post_id} at ${next_blog_post_path}`);
67
+ },
68
+ };
@@ -0,0 +1,11 @@
1
+ import { type Task } from '@ryanatkn/gro';
2
+ import { z } from 'zod';
3
+ /** @nodocs */
4
+ export declare const Args: z.ZodObject<{
5
+ _: z.ZodDefault<z.ZodArray<z.ZodString>>;
6
+ date: z.ZodOptional<z.ZodString>;
7
+ }, z.core.$strict>;
8
+ export type Args = z.infer<typeof Args>;
9
+ /** @nodocs */
10
+ export declare const task: Task<Args>;
11
+ //# sourceMappingURL=update_post.task.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update_post.task.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/update_post.task.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,IAAI,EAAC,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,cAAc;AACd,eAAO,MAAM,IAAI;;;kBAMP,CAAC;AACX,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC;AAExC,cAAc;AACd,eAAO,MAAM,IAAI,EAAE,IAAI,CAAC,IAAI,CAwB3B,CAAC"}
@@ -0,0 +1,31 @@
1
+ import { TaskError } from '@ryanatkn/gro';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { z } from 'zod';
4
+ /** @nodocs */
5
+ export const Args = z
6
+ .object({
7
+ // TODO accept `slug` as well as `id` ?
8
+ _: z.array(z.string()).meta({ description: 'id of the post to update' }).max(1).default([]),
9
+ date: z.string().meta({ description: "the post's date_modified" }).optional(),
10
+ })
11
+ .strict();
12
+ /** @nodocs */
13
+ export const task = {
14
+ summary: 'updates the `date_modified` of a blog post',
15
+ Args,
16
+ run: async ({ args, invoke_task }) => {
17
+ const { _: [id], date = new Date().toISOString(), } = args;
18
+ if (!id) {
19
+ throw new TaskError('post id is required');
20
+ }
21
+ // TODO @many harcoded /blog/
22
+ const filename = `src/routes/blog/${id}/+page.svelte`;
23
+ if (!existsSync(filename)) {
24
+ throw new TaskError(`post with id '${id}' not found at path '${filename}'`);
25
+ }
26
+ const content = readFileSync(filename, 'utf8');
27
+ const updated_content = content.replace(/date_modified: '.*'/, `date_modified: '${date}'`);
28
+ writeFileSync(filename, updated_content, 'utf8');
29
+ await invoke_task('gen');
30
+ },
31
+ };
package/dist/util.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare const format_date: (date: string | number | Date) => string;
2
+ export declare const to_pathname: (url: string, root: string) => string;
3
+ //# sourceMappingURL=util.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/util.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,GAAG,MAAM,GAAG,IAAI,KAAG,MACI,CAAC;AAEhE,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,KAAG,MACjB,CAAC"}
package/dist/util.js ADDED
@@ -0,0 +1,5 @@
1
+ import { format } from 'date-fns';
2
+ import { strip_end, strip_start } from '@fuzdev/fuz_util/string.js';
3
+ // TODO rename?
4
+ export const format_date = (date) => format(typeof date === 'string' ? new Date(date) : date, 'PP');
5
+ export const to_pathname = (url, root) => strip_start(url, strip_end(root, '/'));
package/package.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "name": "@fuzdev/fuz_blog",
3
+ "version": "0.20.0",
4
+ "description": "blog software from scratch with SvelteKit",
5
+ "glyph": "🖊️",
6
+ "logo": "logo.svg",
7
+ "logo_alt": "a friendly yellow spider facing you",
8
+ "public": true,
9
+ "homepage": "https://blog.fuz.dev/",
10
+ "repository": "https://github.com/fuzdev/fuz_blog",
11
+ "scripts": {
12
+ "start": "gro dev",
13
+ "dev": "gro dev",
14
+ "build": "gro build",
15
+ "check": "gro check",
16
+ "test": "gro test",
17
+ "preview": "vite preview",
18
+ "deploy": "gro deploy"
19
+ },
20
+ "type": "module",
21
+ "engines": {
22
+ "node": ">=22.15"
23
+ },
24
+ "peerDependencies": {
25
+ "@fuzdev/fuz_css": ">=0.40.0",
26
+ "@fuzdev/fuz_mastodon": ">=0.37.0",
27
+ "@fuzdev/fuz_ui": ">=0.169.0",
28
+ "@fuzdev/fuz_util": ">=0.42.0",
29
+ "@ryanatkn/gro": ">=0.181.0",
30
+ "@sveltejs/kit": "^2",
31
+ "date-fns": "^4",
32
+ "svelte": "^5"
33
+ },
34
+ "devDependencies": {
35
+ "@changesets/changelog-git": "^0.2.1",
36
+ "@fuzdev/fuz_code": "^0.37.0",
37
+ "@fuzdev/fuz_css": "^0.40.0",
38
+ "@fuzdev/fuz_mastodon": "^0.37.0",
39
+ "@fuzdev/fuz_ui": "^0.169.0",
40
+ "@fuzdev/fuz_util": "^0.42.0",
41
+ "@ryanatkn/belt": "^0.41.1",
42
+ "@ryanatkn/eslint-config": "^0.9.0",
43
+ "@ryanatkn/gro": "^0.181.0",
44
+ "@sveltejs/adapter-static": "^3.0.10",
45
+ "@sveltejs/kit": "^2.49.0",
46
+ "@sveltejs/package": "^2.5.7",
47
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
48
+ "@types/node": "^24.10.1",
49
+ "date-fns": "^4.1.0",
50
+ "eslint": "^9.39.1",
51
+ "eslint-plugin-svelte": "^3.13.0",
52
+ "prettier": "^3.6.2",
53
+ "prettier-plugin-svelte": "^3.4.0",
54
+ "svelte": "^5.45.2",
55
+ "svelte-check": "^4.3.4",
56
+ "tslib": "^2.8.1",
57
+ "typescript": "^5.9.3",
58
+ "typescript-eslint": "^8.48.0",
59
+ "vitest": "^4.0.14"
60
+ },
61
+ "prettier": {
62
+ "plugins": [
63
+ "prettier-plugin-svelte"
64
+ ],
65
+ "useTabs": true,
66
+ "printWidth": 100,
67
+ "singleQuote": true,
68
+ "bracketSpacing": false,
69
+ "overrides": [
70
+ {
71
+ "files": "package.json",
72
+ "options": {
73
+ "useTabs": false
74
+ }
75
+ }
76
+ ]
77
+ },
78
+ "sideEffects": [
79
+ "**/*.css"
80
+ ],
81
+ "files": [
82
+ "dist"
83
+ ],
84
+ "exports": {
85
+ "./package.json": "./package.json",
86
+ "./*.js": {
87
+ "types": "./dist/*.d.ts",
88
+ "default": "./dist/*.js"
89
+ },
90
+ "./*.ts": {
91
+ "types": "./dist/*.d.ts",
92
+ "default": "./dist/*.js"
93
+ },
94
+ "./*.svelte": {
95
+ "types": "./dist/*.svelte.d.ts",
96
+ "svelte": "./dist/*.svelte",
97
+ "default": "./dist/*.svelte"
98
+ }
99
+ }
100
+ }