@stacksjs/cms 0.75.134 → 0.75.136
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/dist/blog.d.ts +124 -0
- package/dist/blog.js +3 -0
- package/package.json +9 -9
package/dist/blog.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** The posts folder: `<cwd>/content/blog`. */
|
|
2
|
+
export declare function blogContentDir(): string;
|
|
3
|
+
/** Whether a string can name a post (letters, digits, `-`, `_`; no dots or slashes). */
|
|
4
|
+
export declare function isPostSlug(slug: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* `key: value` lines between `---` fences, quotes optional. A value may
|
|
7
|
+
* contain colons; only the first one separates the key.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseFrontmatter(markdown: string): { data: Record<string, string>, body: string };
|
|
10
|
+
/**
|
|
11
|
+
* Whether frontmatter describes a published post: a title and a date, and no
|
|
12
|
+
* `draft: true`. Files without both (a STRATEGY.md, notes) are not posts.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isPublished(data: Record<string, string>): boolean;
|
|
15
|
+
/** Whole minutes to read a markdown body at 230 words a minute, at least one. Code blocks are skipped. */
|
|
16
|
+
export declare function readingMinutes(markdown: string): number;
|
|
17
|
+
/**
|
|
18
|
+
* The authors a post's frontmatter names: `authors: chris, pawel` (brackets
|
|
19
|
+
* and quotes allowed), each with its `author.<username>.*` fallbacks, or the
|
|
20
|
+
* single-author keys.
|
|
21
|
+
*/
|
|
22
|
+
export declare function authorsOf(data: Record<string, string>): AuthorRef[];
|
|
23
|
+
/** Featured first, then newest first. */
|
|
24
|
+
export declare function sortPosts<T extends Pick<PostMeta, 'date' | 'featured'>>(posts: T[]): T[];
|
|
25
|
+
/** Every published post, featured first and then newest first, without bodies. */
|
|
26
|
+
export declare function loadPosts(options?: BlogDirOptions): PostMeta[];
|
|
27
|
+
/** One published post with its body, or null for a draft, an unsafe slug or no such file. */
|
|
28
|
+
export declare function loadPost(slug: unknown, options?: BlogDirOptions): Post | null;
|
|
29
|
+
/**
|
|
30
|
+
* A post body as HTML: Bun's markdown parser, then `renderPostHtml`, which
|
|
31
|
+
* turns a lone image into a captioned figure, consecutive images into a grid
|
|
32
|
+
* and a bare video link into an embed. Pair it with `articleHead()` for the
|
|
33
|
+
* figure and grid styles.
|
|
34
|
+
*/
|
|
35
|
+
export declare function renderPostBody(markdown: string, options?: { publicDir?: string }): string;
|
|
36
|
+
/** "October 11, 2026". A post date is a calendar day, so it is read as UTC. */
|
|
37
|
+
export declare function formatPostDate(date: string, locale?: string): string;
|
|
38
|
+
/** "CB" for "Chris Breuer", first and last word. */
|
|
39
|
+
export declare function initialsOf(name: string): string;
|
|
40
|
+
/** "A", "A and B", "A, B and C". */
|
|
41
|
+
export declare function joinNames(names: string[]): string;
|
|
42
|
+
/**
|
|
43
|
+
* Resolves bylines through the app's own profiles.
|
|
44
|
+
*
|
|
45
|
+
* Each author is looked up by username; a profile's name, initials, picture
|
|
46
|
+
* and link win, and the post's own frontmatter fills whatever it does not
|
|
47
|
+
* have. A `bio` in the frontmatter wins over the profile's, so a post can say
|
|
48
|
+
* what it means to about its writer. A lookup that throws, or answers null,
|
|
49
|
+
* leaves the frontmatter standing, without a link.
|
|
50
|
+
*
|
|
51
|
+
* Create it once at module scope so its cache outlives a render:
|
|
52
|
+
*
|
|
53
|
+
* ```ts
|
|
54
|
+
* export const resolveAuthors = createAuthorResolver({
|
|
55
|
+
* lookup: async (username) => {
|
|
56
|
+
* const res = await fetch(`${api}/api/u/${username}`)
|
|
57
|
+
* const { profile } = res.ok ? await res.json() : {}
|
|
58
|
+
* return profile ? { ...profile, url: `/${profile.username}` } : null
|
|
59
|
+
* },
|
|
60
|
+
* })
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export declare function createAuthorResolver(options?: AuthorResolverOptions): AuthorResolver;
|
|
64
|
+
/**
|
|
65
|
+
* The byline as a template draws it: each author with the words before their
|
|
66
|
+
* name (`''`, `', '` or `' and '`) and an inline style that paints their
|
|
67
|
+
* picture as a background. Lay that over their initials and a picture that
|
|
68
|
+
* fails to load leaves the initials showing rather than an empty circle.
|
|
69
|
+
*/
|
|
70
|
+
export declare function bylineOf(authors: PostAuthor[]): Array<PostAuthor & { joiner: string, avatarStyle: string }>;
|
|
71
|
+
export declare interface PostMeta {
|
|
72
|
+
slug: string
|
|
73
|
+
title: string
|
|
74
|
+
date: string
|
|
75
|
+
description: string
|
|
76
|
+
author: string
|
|
77
|
+
authors: AuthorRef[]
|
|
78
|
+
image: string
|
|
79
|
+
imageAlt: string
|
|
80
|
+
featured: boolean
|
|
81
|
+
minutes: number
|
|
82
|
+
frontmatter: Record<string, string>
|
|
83
|
+
}
|
|
84
|
+
export declare interface Post extends PostMeta {
|
|
85
|
+
body: string
|
|
86
|
+
}
|
|
87
|
+
/** An author as a post's frontmatter names them. */
|
|
88
|
+
export declare interface AuthorRef {
|
|
89
|
+
username: string
|
|
90
|
+
name: string
|
|
91
|
+
avatar: string
|
|
92
|
+
bio: string
|
|
93
|
+
}
|
|
94
|
+
/** What an app knows about an author, from its `AuthorLookup`. */
|
|
95
|
+
export declare interface AuthorProfile {
|
|
96
|
+
name?: string
|
|
97
|
+
initials?: string
|
|
98
|
+
avatar?: string
|
|
99
|
+
bio?: string
|
|
100
|
+
url?: string
|
|
101
|
+
}
|
|
102
|
+
/** An author as a byline shows them. */
|
|
103
|
+
export declare interface PostAuthor {
|
|
104
|
+
name: string
|
|
105
|
+
initials: string
|
|
106
|
+
avatar: string
|
|
107
|
+
bio: string
|
|
108
|
+
url: string
|
|
109
|
+
}
|
|
110
|
+
export declare interface BlogDirOptions {
|
|
111
|
+
dir?: string
|
|
112
|
+
}
|
|
113
|
+
export declare interface AuthorResolverOptions {
|
|
114
|
+
lookup?: AuthorLookup
|
|
115
|
+
ttlMs?: number
|
|
116
|
+
fallbackName?: string
|
|
117
|
+
}
|
|
118
|
+
export declare interface AuthorResolver {
|
|
119
|
+
(post: Pick<PostMeta, 'authors'>): Promise<PostAuthor[]>
|
|
120
|
+
one: (ref: AuthorRef) => Promise<PostAuthor>
|
|
121
|
+
clear: () => void
|
|
122
|
+
}
|
|
123
|
+
/** Finds an author by username: their profile, or null when there is none to show. */
|
|
124
|
+
export type AuthorLookup = (username: string) => Promise<AuthorProfile | null | undefined> | AuthorProfile | null | undefined;
|
package/dist/blog.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import{existsSync,readdirSync,readFileSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{renderPostHtml}from"./article";export function blogContentDir(){return join(process.cwd(),"content/blog")}const SLUG=/^[A-Za-z0-9][\w-]{0,199}$/,USERNAME=/^[a-z0-9][a-z0-9_-]{0,99}$/;export function isPostSlug(slug){return SLUG.test(slug)}export function parseFrontmatter(markdown){const match=markdown.replace(/\r\n/g,`
|
|
2
|
+
`).match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);if(!match)return{data:{},body:markdown};const data={};for(const line of(match[1]??"").split(`
|
|
3
|
+
`)){const colon=line.indexOf(":");if(colon===-1)continue;const key=line.slice(0,colon).trim();let value=line.slice(colon+1).trim();if(value.startsWith('"')&&value.endsWith('"')||value.startsWith("'")&&value.endsWith("'"))value=value.slice(1,-1);if(key)data[key]=value}return{data,body:match[2]??""}}export function isPublished(data){return!!data.title&&!!data.date&&data.draft!=="true"}export function readingMinutes(markdown){const words=markdown.replace(/```[\s\S]*?```/g," ").replace(/!?\[([^\]]*)\]\([^)]*\)/g,"$1").split(/\s+/).filter((word)=>/[\p{L}\p{N}]/u.test(word)).length;return Math.max(1,Math.round(words/230))}export function authorsOf(data){const usernames=(data.authors??"").replace(/^\[|\]$/g,"").split(",").map((name)=>name.trim().replace(/^["']|["']$/g,"").toLowerCase()).filter((name)=>USERNAME.test(name));if(usernames.length>0)return[...new Set(usernames)].map((username)=>({username,name:data[`author.${username}.name`]??"",avatar:data[`author.${username}.avatar`]??"",bio:data[`author.${username}.bio`]??""}));const username=(data.authorUsername??"").toLowerCase();if(!data.author&&!username)return[];return[{username:USERNAME.test(username)?username:"",name:data.author??"",avatar:data.authorAvatar??"",bio:data.authorBio??""}]}function toPost(slug,markdown){const{data,body}=parseFrontmatter(markdown);if(!isPublished(data))return null;return{slug,title:data.title,date:data.date,description:data.description??"",author:data.author??"",authors:authorsOf(data),image:data.image??"",imageAlt:data.imageAlt??"",featured:data.featured==="true",minutes:readingMinutes(body),frontmatter:data,body}}export function sortPosts(posts){return[...posts].sort((a,b)=>{if(a.featured!==b.featured)return a.featured?-1:1;return b.date.localeCompare(a.date)})}export function loadPosts(options={}){const dir=options.dir??blogContentDir();if(!existsSync(dir))return[];const posts=[];for(const file of readdirSync(dir)){const slug=file.replace(/\.md$/,"");if(!file.endsWith(".md")||!SLUG.test(slug))continue;const post=toPost(slug,readFileSync(join(dir,file),"utf8"));if(post){const{body,...meta}=post;posts.push(meta)}}return sortPosts(posts)}export function loadPost(slug,options={}){const name=String(slug??"");if(!SLUG.test(name))return null;const file=join(options.dir??blogContentDir(),`${name}.md`);if(!existsSync(file))return null;return toPost(name,readFileSync(file,"utf8"))}export function renderPostBody(markdown,options={}){return renderPostHtml(Bun.markdown.html(markdown),{publicDir:options.publicDir??join(process.cwd(),"public")})}export function formatPostDate(date,locale="en-US"){const parsed=new Date(`${date}T00:00:00Z`);if(Number.isNaN(parsed.getTime()))return date;return parsed.toLocaleDateString(locale,{month:"long",day:"numeric",year:"numeric",timeZone:"UTC"})}export function initialsOf(name){const parts=name.trim().split(/\s+/).filter(Boolean);return((parts[0]?.[0]??"")+(parts.length>1?parts.at(-1)[0]:"")).toUpperCase()}export function joinNames(names){if(names.length<=1)return names[0]??"";return`${names.slice(0,-1).join(", ")} and ${names.at(-1)}`}export function createAuthorResolver(options={}){const ttl=options.ttlMs??600000,cache=new Map;async function profileOf(username){if(!options.lookup||!username||!USERNAME.test(username))return null;const cached=cache.get(username);if(cached&&Date.now()-cached.at<ttl)return cached.profile;let profile=null;try{profile=await options.lookup(username)??null}catch{profile=null}if(ttl>0)cache.set(username,{at:Date.now(),profile});return profile}const one=async(ref)=>{const profile=await profileOf(ref.username),name=String(profile?.name||ref.name||ref.username||options.fallbackName||process.env.APP_NAME||"Blog");return{name,initials:String(profile?.initials||initialsOf(name)),avatar:String(profile?.avatar||ref.avatar||""),bio:String(ref.bio||profile?.bio||""),url:String(profile?.url||"")}},resolve=async(post)=>{const refs=post.authors.length>0?post.authors:[{username:"",name:"",avatar:"",bio:""}];return Promise.all(refs.map(one))};resolve.one=one;resolve.clear=()=>cache.clear();return resolve}export function bylineOf(authors){return authors.map((author,index)=>({...author,joiner:index===0?"":index===authors.length-1?" and ":", ",avatarStyle:author.avatar?`background-image:url(${JSON.stringify(author.avatar)})`:""}))}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/cms",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.136",
|
|
6
6
|
"description": "Stacks cms utilities.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -58,14 +58,14 @@
|
|
|
58
58
|
"prepublishOnly": "bun run build"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@stacksjs/database": "0.75.
|
|
62
|
-
"@stacksjs/error-handling": "0.75.
|
|
63
|
-
"@stacksjs/orm": "0.75.
|
|
64
|
-
"@stacksjs/path": "0.75.
|
|
65
|
-
"@stacksjs/sites": "0.75.
|
|
66
|
-
"@stacksjs/slug": "0.75.
|
|
67
|
-
"@stacksjs/stx": "^0.2.
|
|
68
|
-
"@stacksjs/validation": "0.75.
|
|
61
|
+
"@stacksjs/database": "0.75.136",
|
|
62
|
+
"@stacksjs/error-handling": "0.75.136",
|
|
63
|
+
"@stacksjs/orm": "0.75.136",
|
|
64
|
+
"@stacksjs/path": "0.75.136",
|
|
65
|
+
"@stacksjs/sites": "0.75.136",
|
|
66
|
+
"@stacksjs/slug": "0.75.136",
|
|
67
|
+
"@stacksjs/stx": "^0.2.445",
|
|
68
|
+
"@stacksjs/validation": "0.75.136",
|
|
69
69
|
"ts-medium-editor": "^0.2.0",
|
|
70
70
|
"ts-slug": "^0.1.2",
|
|
71
71
|
"ts-spreadsheets": "^0.2.5"
|