@stacksjs/cms 0.75.135 → 0.75.137

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 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.135",
5
+ "version": "0.75.137",
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.135",
62
- "@stacksjs/error-handling": "0.75.135",
63
- "@stacksjs/orm": "0.75.135",
64
- "@stacksjs/path": "0.75.135",
65
- "@stacksjs/sites": "0.75.135",
66
- "@stacksjs/slug": "0.75.135",
61
+ "@stacksjs/database": "0.75.137",
62
+ "@stacksjs/error-handling": "0.75.137",
63
+ "@stacksjs/orm": "0.75.137",
64
+ "@stacksjs/path": "0.75.137",
65
+ "@stacksjs/sites": "0.75.137",
66
+ "@stacksjs/slug": "0.75.137",
67
67
  "@stacksjs/stx": "^0.2.445",
68
- "@stacksjs/validation": "0.75.135",
68
+ "@stacksjs/validation": "0.75.137",
69
69
  "ts-medium-editor": "^0.2.0",
70
70
  "ts-slug": "^0.1.2",
71
71
  "ts-spreadsheets": "^0.2.5"