@giancarlosgza/blog-layer 0.0.0-stage → 0.1.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/README.md +73 -2
- package/app/app.config.ts +42 -0
- package/app/assets/scss/abstracts/_tokens.scss +8 -0
- package/app/assets/scss/abstracts/_type-scale.scss +6 -0
- package/app/assets/scss/abstracts/_variables.scss +1 -0
- package/app/assets/scss/components/_icons.scss +15 -0
- package/app/assets/scss/components/_symbols.scss +20 -0
- package/app/assets/scss/main.scss +9 -0
- package/app/assets/scss/pages/_core.scss +84 -0
- package/app/assets/scss/pages/core/_article-layout.scss +260 -0
- package/app/assets/scss/pages/core/_code-layout.scss +42 -0
- package/app/assets/scss/pages/core/_layers.scss +1 -0
- package/app/assets/scss/pages/core/_post-article-layout.scss +198 -0
- package/app/assets/scss/pages/core/_post-layout.scss +286 -0
- package/app/assets/scss/pages/core/_shared-ui.scss +100 -0
- package/app/assets/scss/pages/core/_transitions-layout.scss +70 -0
- package/app/assets/scss/pages/core/_widgets.scss +17 -0
- package/app/blog-pages/archive.vue +136 -0
- package/app/blog-pages/author.vue +115 -0
- package/app/blog-pages/home.vue +178 -0
- package/app/blog-pages/post.vue +168 -0
- package/app/blog-pages/series.vue +127 -0
- package/app/blog-pages/tag.vue +96 -0
- package/app/components/OgImage/BlogPost.takumi.vue +117 -0
- package/app/components/blog/BlogCodeBlock.vue +144 -0
- package/app/components/blog/BlogPostArticle.vue +545 -0
- package/app/components/blog/BlogPostCard.vue +208 -0
- package/app/components/blog/BlogPostContent.vue +111 -0
- package/app/components/blog/BlogPostList.vue +143 -0
- package/app/components/layout/HubHeader.vue +62 -0
- package/app/composables/useBlogBrand.ts +13 -0
- package/app/composables/useBlogPath.ts +13 -0
- package/app/composables/useBlogPost.ts +12 -0
- package/app/composables/useBlogSettings.ts +15 -0
- package/app/composables/useBlogWidgets.ts +18 -0
- package/app/composables/useCachedResource.ts +72 -0
- package/app/composables/useDateValue.ts +27 -0
- package/app/composables/usePaginatedPosts.ts +57 -0
- package/app/composables/usePostFeed.ts +139 -0
- package/app/composables/useSeriesNames.ts +20 -0
- package/app/composables/useShikiHighlighter.ts +108 -0
- package/app/composables/useSiteSchemaLd.ts +44 -0
- package/app/composables/useSiteUrl.ts +4 -0
- package/app/utils/widget-props.ts +66 -0
- package/app/widgets/WidgetError.vue +13 -0
- package/blog-module.ts +71 -0
- package/nuxt.config.ts +80 -0
- package/package.json +44 -3
- package/scripts/subset-icons.mjs +167 -0
- package/server/api/blog/_sitemap-urls.ts +38 -0
- package/server/api/blog/authors/[slug].get.ts +42 -0
- package/server/api/blog/featured.get.ts +18 -0
- package/server/api/blog/posts/[slug].get.ts +116 -0
- package/server/api/blog/posts/index.get.ts +35 -0
- package/server/api/blog/series/[slug].get.ts +29 -0
- package/server/api/blog/series/index.get.ts +15 -0
- package/server/api/blog/settings.get.ts +18 -0
- package/server/api/blog/tags/[tag].get.ts +35 -0
- package/server/api/blog/tags/index.get.ts +17 -0
- package/server/handlers/rss.xml.ts +53 -0
- package/server/plugins/firebase.ts +20 -0
- package/server/utils/blog-data.ts +218 -0
- package/server/utils/blog-firestore.ts +80 -0
- package/server/utils/rss.ts +212 -0
- package/server/utils/sitemap.ts +80 -0
- package/shared/models/author.ts +19 -0
- package/shared/models/date.ts +11 -0
- package/shared/models/post.ts +114 -0
- package/shared/models/series.ts +14 -0
- package/shared/models/settings.ts +12 -0
- package/shared/models/tag.ts +15 -0
- package/shared/types/api.ts +78 -0
- package/shared/utils/blog-path.ts +23 -0
- package/shared/utils/blog.ts +45 -0
- package/shared/utils/date.ts +26 -0
- package/shared/utils/post-headings.ts +134 -0
- package/shared/utils/post-seo.ts +58 -0
- package/shared/utils/sanitize.ts +80 -0
- package/shared/widgets/types.ts +41 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { IFirestoreDateValue } from './date'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Editorial workflow state. `isPublished` stays the public-visibility flag (the blog and the
|
|
5
|
+
* rules read it): only a publish sets it true, so a live post can still be `review`/`changes`
|
|
6
|
+
* while its next revision is worked on. Legacy docs have no `status` (see resolvePostStatus).
|
|
7
|
+
*/
|
|
8
|
+
export type PostStatus = 'draft' | 'review' | 'changes' | 'scheduled' | 'published'
|
|
9
|
+
|
|
10
|
+
/** Who performed a workflow step; `{ uid: 'system', name: 'Scheduler' }` for the scheduled publisher. */
|
|
11
|
+
export interface IPostActor {
|
|
12
|
+
uid: string
|
|
13
|
+
name: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The current review cycle, written only by the server routes. */
|
|
17
|
+
export interface IPostReview {
|
|
18
|
+
/** 1 on the first submit, +1 on every resubmit after changes were requested. */
|
|
19
|
+
round: number
|
|
20
|
+
submittedAt: IFirestoreDateValue
|
|
21
|
+
submittedBy: IPostActor
|
|
22
|
+
/** Latest note in the thread: the submitter's note, then the reviewer's change request. */
|
|
23
|
+
note?: string
|
|
24
|
+
requestedAt?: IFirestoreDateValue
|
|
25
|
+
requestedBy?: IPostActor
|
|
26
|
+
reasons?: string[]
|
|
27
|
+
/** The author marked the change request as addressed. */
|
|
28
|
+
resolved?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `posts/{slug}` — post metadata. Document ID is the URL slug.
|
|
33
|
+
* `createdAt`/`updatedAt` are stored as Unix-ms numbers in the migrated data,
|
|
34
|
+
* but the type tolerates legacy Firestore Timestamp shapes too.
|
|
35
|
+
*/
|
|
36
|
+
export interface IPost {
|
|
37
|
+
id?: string
|
|
38
|
+
slug?: string
|
|
39
|
+
title: string
|
|
40
|
+
shortText?: string
|
|
41
|
+
isPublished: boolean
|
|
42
|
+
status?: PostStatus
|
|
43
|
+
/** When a `scheduled` post goes live (the scheduled Cloud Function publishes it). */
|
|
44
|
+
publishAt?: IFirestoreDateValue
|
|
45
|
+
review?: IPostReview
|
|
46
|
+
createdAt?: IFirestoreDateValue
|
|
47
|
+
updatedAt?: IFirestoreDateValue
|
|
48
|
+
authorName?: string
|
|
49
|
+
authorUuid?: string
|
|
50
|
+
/** Denormalized from the author doc so the public blog avoids a per-card author read. */
|
|
51
|
+
authorPhotoURL?: string
|
|
52
|
+
/** Denormalized author slug for linking to the author's public page. */
|
|
53
|
+
authorSlug?: string
|
|
54
|
+
/** Legacy field: author display name on older documents. */
|
|
55
|
+
author?: string
|
|
56
|
+
imageAlt?: string
|
|
57
|
+
imagePath?: string
|
|
58
|
+
imageURL?: string
|
|
59
|
+
/** Accent color for the post (used for the list icon and public theming). */
|
|
60
|
+
color?: string
|
|
61
|
+
tags?: string[]
|
|
62
|
+
/** Search/social title override; falls back to `title` (see resolvePostSeo). */
|
|
63
|
+
seoTitle?: string
|
|
64
|
+
/** Meta description override; falls back to `shortText`, then a body excerpt. */
|
|
65
|
+
seoDescription?: string
|
|
66
|
+
/** Social share image override; falls back to the cover, then the generated OG image. */
|
|
67
|
+
ogImageURL?: string
|
|
68
|
+
/** Absolute canonical URL override for republished content; defaults to the post URL. */
|
|
69
|
+
canonicalURL?: string
|
|
70
|
+
/** Hides the post from search engines and the sitemap while keeping it public. */
|
|
71
|
+
noindex?: boolean
|
|
72
|
+
/** Slug (document ID) of the `series/{slug}` this post belongs to. */
|
|
73
|
+
seriesSlug?: string
|
|
74
|
+
/** 1-based part number within the series. */
|
|
75
|
+
seriesOrder?: number
|
|
76
|
+
/** Shown in the blog home hero (published posts only). */
|
|
77
|
+
featured?: boolean
|
|
78
|
+
/** Hero order among featured posts, lower first; ties fall back to newest first. */
|
|
79
|
+
featuredOrder?: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `postContents/{slug}` (published body) and `postDrafts/{slug}` (working body).
|
|
84
|
+
* `contents` is an HTML string.
|
|
85
|
+
*/
|
|
86
|
+
export interface IPostContent {
|
|
87
|
+
id?: string
|
|
88
|
+
contents: string
|
|
89
|
+
updatedAt?: IFirestoreDateValue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type PostEventType
|
|
93
|
+
= | 'created'
|
|
94
|
+
| 'submitted'
|
|
95
|
+
| 'withdrawn'
|
|
96
|
+
| 'changes_requested'
|
|
97
|
+
| 'resolved'
|
|
98
|
+
| 'resubmitted'
|
|
99
|
+
| 'approved'
|
|
100
|
+
| 'scheduled'
|
|
101
|
+
| 'published'
|
|
102
|
+
| 'unpublished'
|
|
103
|
+
|
|
104
|
+
/** `posts/{slug}/events/{autoId}` — workflow history, written by the server only. */
|
|
105
|
+
export interface IPostEvent {
|
|
106
|
+
id?: string
|
|
107
|
+
type: PostEventType
|
|
108
|
+
from?: PostStatus
|
|
109
|
+
to?: PostStatus
|
|
110
|
+
note?: string
|
|
111
|
+
reasons?: string[]
|
|
112
|
+
actor: IPostActor
|
|
113
|
+
at: IFirestoreDateValue
|
|
114
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IFirestoreDateValue } from './date'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `series/{slug}` — an ordered set of posts. The document ID is the URL slug.
|
|
5
|
+
* Posts join a series through `posts.seriesSlug` and are ordered by `posts.seriesOrder`.
|
|
6
|
+
*/
|
|
7
|
+
export interface ISeries {
|
|
8
|
+
id?: string
|
|
9
|
+
name: string
|
|
10
|
+
slug: string
|
|
11
|
+
description?: string
|
|
12
|
+
createdAt?: IFirestoreDateValue
|
|
13
|
+
updatedAt?: IFirestoreDateValue
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `settings/general` — site-wide branding and SEO metadata.
|
|
3
|
+
*/
|
|
4
|
+
export interface ISettings {
|
|
5
|
+
blogName: string
|
|
6
|
+
blogDescription?: string
|
|
7
|
+
blogKeywords?: string
|
|
8
|
+
blogURL?: string
|
|
9
|
+
logoURL?: string
|
|
10
|
+
primaryColor?: string
|
|
11
|
+
backgroundColor?: string
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IFirestoreDateValue } from './date'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `tags/{name}` — blog tag. The document ID is the display name (e.g. "CSS").
|
|
5
|
+
* Posts reference tags by this name (`posts.tags` is a string[] of names).
|
|
6
|
+
* `count` is the number of posts using the tag.
|
|
7
|
+
*/
|
|
8
|
+
export interface ITag {
|
|
9
|
+
id?: string
|
|
10
|
+
name: string
|
|
11
|
+
slug?: string
|
|
12
|
+
count?: number
|
|
13
|
+
createdAt?: IFirestoreDateValue
|
|
14
|
+
updatedAt?: IFirestoreDateValue
|
|
15
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { IAuthor } from '../models/author'
|
|
2
|
+
import type { IPost } from '../models/post'
|
|
3
|
+
import type { ISeries } from '../models/series'
|
|
4
|
+
import type { ISettings } from '../models/settings'
|
|
5
|
+
import type { ITag } from '../models/tag'
|
|
6
|
+
import type { IPostHeading } from '../utils/post-headings'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Responses of the cached public read endpoints in `server/api`. Firestore Timestamps are
|
|
10
|
+
* serialized to Unix ms, so every payload is plain JSON.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ISettingsResponse {
|
|
14
|
+
settings: ISettings | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ITagsResponse {
|
|
18
|
+
tags: ITag[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface IPostListResponse {
|
|
22
|
+
posts: IPost[]
|
|
23
|
+
hasMore: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IFeaturedPostsResponse {
|
|
27
|
+
/** Published `featured` posts for the home hero, `featuredOrder` first then newest (max 3). */
|
|
28
|
+
posts: IPost[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IPostSeriesPart {
|
|
32
|
+
slug: string
|
|
33
|
+
title: string
|
|
34
|
+
seriesOrder: number | null
|
|
35
|
+
isCurrent: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The series a post belongs to, with its published parts in reading order. */
|
|
39
|
+
export interface IPostSeries {
|
|
40
|
+
slug: string
|
|
41
|
+
name: string
|
|
42
|
+
description?: string
|
|
43
|
+
parts: IPostSeriesPart[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IPostPageResponse {
|
|
47
|
+
post: IPost
|
|
48
|
+
/** Body already sanitized and anchored on the server; rendered as-is. */
|
|
49
|
+
contents: string
|
|
50
|
+
headings: IPostHeading[]
|
|
51
|
+
readingTime: number
|
|
52
|
+
/** Plain-text excerpt of the raw body, the SEO description fallback. */
|
|
53
|
+
excerpt: string
|
|
54
|
+
author: IAuthor | null
|
|
55
|
+
related: IPost[]
|
|
56
|
+
older: IPost | null
|
|
57
|
+
newer: IPost | null
|
|
58
|
+
series: IPostSeries | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface IAuthorPageResponse extends IPostListResponse {
|
|
62
|
+
author: IAuthor
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ITagPageResponse extends IPostListResponse {
|
|
66
|
+
tag: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Series names by slug, for the series badge on post cards. */
|
|
70
|
+
export interface ISeriesListResponse {
|
|
71
|
+
series: Array<Pick<ISeries, 'slug' | 'name'>>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ISeriesPageResponse {
|
|
75
|
+
series: ISeries
|
|
76
|
+
/** Published parts in reading order. */
|
|
77
|
+
posts: IPost[]
|
|
78
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog URL helpers. The blog mounts under `blog.basePath` (nuxt.config): `/` for a standalone blog,
|
|
3
|
+
* e.g. `/blog` when it is embedded in another site. Shared by pages, RSS, sitemap and SEO.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** `''` for the site root, otherwise `/segment(s)` with a leading and no trailing slash. */
|
|
7
|
+
export function normalizeBasePath(base?: string | null): string {
|
|
8
|
+
const trimmed = (base ?? '').trim().replace(/^\/+|\/+$/g, '')
|
|
9
|
+
return trimmed ? `/${trimmed}` : ''
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Blog-relative path → site path: (`/blog`, `/tags/css`) → `/blog/tags/css`; the root home is `/`. */
|
|
13
|
+
export function joinBlogPath(base: string | null | undefined, path = '/'): string {
|
|
14
|
+
const relative = path.replace(/^\/+/, '')
|
|
15
|
+
return `${normalizeBasePath(base)}${relative ? `/${relative}` : ''}` || '/'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Absolute blog URL. The root home is the bare origin (`https://blog.example.com`, no trailing slash). */
|
|
19
|
+
export function toBlogUrl(siteUrl: string, base: string | null | undefined, path = '/'): string {
|
|
20
|
+
const origin = siteUrl.replace(/\/+$/, '')
|
|
21
|
+
const sitePath = joinBlogPath(base, path)
|
|
22
|
+
return sitePath === '/' ? origin : `${origin}${sitePath}`
|
|
23
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { IPost } from '../models/post'
|
|
2
|
+
|
|
3
|
+
/** Convert an arbitrary string to a URL-safe slug. */
|
|
4
|
+
export function toSlug(value: string): string {
|
|
5
|
+
return value
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.trim()
|
|
8
|
+
.replace(/[^\w\s-]/g, '')
|
|
9
|
+
.replace(/[\s_-]+/g, '-')
|
|
10
|
+
.replace(/^-+|-+$/g, '')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Resolve the best available author display name for a post (prefers new fields, then legacy, then `fallback`). */
|
|
14
|
+
export function resolveAuthorName(post: Pick<IPost, 'authorName' | 'author'>, fallback = ''): string {
|
|
15
|
+
return post.authorName || post.author || fallback
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Add the `.text-link` class to every prose `<a>` (merges existing classes; idempotent). */
|
|
19
|
+
export function decorateBlogLinks(html: string): string {
|
|
20
|
+
if (!html)
|
|
21
|
+
return ''
|
|
22
|
+
|
|
23
|
+
return html.replace(/<a\b([^>]*)>/gi, (tag, attrs: string) => {
|
|
24
|
+
if (/\bclass\s*=\s*(["'])[^"']*\btext-link\b[^"']*\1/i.test(attrs))
|
|
25
|
+
return tag
|
|
26
|
+
|
|
27
|
+
if (/\bclass\s*=\s*["']/i.test(attrs))
|
|
28
|
+
return `<a${attrs.replace(/(\bclass\s*=\s*(["']))(.*?)(\2)/i, '$1$3 text-link$4')}>`
|
|
29
|
+
|
|
30
|
+
return `<a class="text-link"${attrs}>`
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Strip HTML tags and collapse whitespace to build a plain-text excerpt. */
|
|
35
|
+
export function htmlToExcerpt(html: string, maxLength = 160): string {
|
|
36
|
+
const text = (html || '')
|
|
37
|
+
.replace(/<[^>]+>/g, ' ')
|
|
38
|
+
.replace(/\s+/g, ' ')
|
|
39
|
+
.trim()
|
|
40
|
+
|
|
41
|
+
if (text.length <= maxLength)
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
return `${text.slice(0, maxLength - 1).trimEnd()}…`
|
|
45
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IFirestoreDateValue } from '../models/date'
|
|
2
|
+
|
|
3
|
+
/** Below this, a number is Unix seconds rather than milliseconds. */
|
|
4
|
+
const MIN_SECONDS_TIMESTAMP = 10_000_000_000
|
|
5
|
+
|
|
6
|
+
/** Firestore-ish date value (Date, Timestamp, `{ seconds }`, seconds/ms number, ISO string) → Unix ms, or `null`. */
|
|
7
|
+
export function toDateMs(value: IFirestoreDateValue): number | null {
|
|
8
|
+
if (value == null || value === '')
|
|
9
|
+
return null
|
|
10
|
+
|
|
11
|
+
let ms: number
|
|
12
|
+
if (value instanceof Date)
|
|
13
|
+
ms = value.getTime()
|
|
14
|
+
else if (typeof value === 'number')
|
|
15
|
+
ms = value < MIN_SECONDS_TIMESTAMP ? value * 1000 : value
|
|
16
|
+
else if (typeof value === 'string')
|
|
17
|
+
ms = Date.parse(value)
|
|
18
|
+
else if ('toDate' in value && typeof value.toDate === 'function')
|
|
19
|
+
ms = value.toDate().getTime()
|
|
20
|
+
else if ('seconds' in value && typeof value.seconds === 'number')
|
|
21
|
+
ms = value.seconds * 1000
|
|
22
|
+
else
|
|
23
|
+
return null
|
|
24
|
+
|
|
25
|
+
return Number.isFinite(ms) && ms > 0 ? ms : null
|
|
26
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { toSlug } from './blog'
|
|
2
|
+
|
|
3
|
+
export interface IPostHeading {
|
|
4
|
+
id: string
|
|
5
|
+
text: string
|
|
6
|
+
level: 2 | 3
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface IPostHeadingsResult {
|
|
10
|
+
html: string
|
|
11
|
+
headings: IPostHeading[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const WORDS_PER_MINUTE = 225
|
|
15
|
+
const FALLBACK_HEADING_ID = 'section'
|
|
16
|
+
const TAG_REGEX = /<(\/?)([a-z][\w-]*)\b([^>]*)>/gi
|
|
17
|
+
const ID_ATTR_REGEX = /(?:^|\s)id\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
|
|
18
|
+
const SKIP_ATTR_REGEX = /\bdata-blog-widget\b/i
|
|
19
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
20
|
+
amp: '&',
|
|
21
|
+
lt: '<',
|
|
22
|
+
gt: '>',
|
|
23
|
+
quot: '"',
|
|
24
|
+
apos: '\'',
|
|
25
|
+
nbsp: ' '
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function decodeEntities(value: string): string {
|
|
29
|
+
return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (entity, code: string) => {
|
|
30
|
+
if (code[0] === '#') {
|
|
31
|
+
const point = code[1]?.toLowerCase() === 'x'
|
|
32
|
+
? Number.parseInt(code.slice(2), 16)
|
|
33
|
+
: Number.parseInt(code.slice(1), 10)
|
|
34
|
+
return Number.isFinite(point) && point > 0 && point <= 0x10FFFF ? String.fromCodePoint(point) : entity
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return NAMED_ENTITIES[code.toLowerCase()] ?? entity
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function htmlToText(html: string): string {
|
|
42
|
+
return decodeEntities(html.replace(/<[^>]+>/g, ' '))
|
|
43
|
+
.replace(/\s+/g, ' ')
|
|
44
|
+
.trim()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readIdAttr(attrs: string): string | null {
|
|
48
|
+
const match = attrs.match(ID_ATTR_REGEX)
|
|
49
|
+
if (!match)
|
|
50
|
+
return null
|
|
51
|
+
|
|
52
|
+
return match[1] ?? match[2] ?? match[3] ?? null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function uniqueId(base: string, used: Set<string>): string {
|
|
56
|
+
let candidate = base
|
|
57
|
+
let suffix = 2
|
|
58
|
+
while (used.has(candidate)) {
|
|
59
|
+
candidate = `${base}-${suffix}`
|
|
60
|
+
suffix++
|
|
61
|
+
}
|
|
62
|
+
used.add(candidate)
|
|
63
|
+
return candidate
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Gives every top-level h2/h3 a stable, unique slug id and returns the TOC.
|
|
68
|
+
* String-based so SSR and client produce identical markup. Existing ids are kept,
|
|
69
|
+
* headings inside `<pre>` or widget placeholders are skipped (they are replaced on hydration).
|
|
70
|
+
*/
|
|
71
|
+
export function addHeadingAnchors(html: string): IPostHeadingsResult {
|
|
72
|
+
if (!html)
|
|
73
|
+
return { html: '', headings: [] }
|
|
74
|
+
|
|
75
|
+
const used = new Set<string>()
|
|
76
|
+
for (const match of html.matchAll(/<[a-z][\w-]*\b[^>]*>/gi)) {
|
|
77
|
+
const id = readIdAttr(match[0].slice(1))
|
|
78
|
+
if (id)
|
|
79
|
+
used.add(id)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const lowerHtml = html.toLowerCase()
|
|
83
|
+
const headings: IPostHeading[] = []
|
|
84
|
+
// Tracks the element whose subtree is skipped; depth counts nested tags of the same name
|
|
85
|
+
let skip: { tag: string, depth: number } | null = null
|
|
86
|
+
|
|
87
|
+
const output = html.replace(TAG_REGEX, (tag, closing: string, rawName: string, attrs: string, offset: number) => {
|
|
88
|
+
const name = rawName.toLowerCase()
|
|
89
|
+
const isSelfClosing = /\/\s*$/.test(attrs)
|
|
90
|
+
|
|
91
|
+
if (skip) {
|
|
92
|
+
if (name === skip.tag && !isSelfClosing) {
|
|
93
|
+
skip.depth += closing ? -1 : 1
|
|
94
|
+
if (skip.depth === 0)
|
|
95
|
+
skip = null
|
|
96
|
+
}
|
|
97
|
+
return tag
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (closing || isSelfClosing)
|
|
101
|
+
return tag
|
|
102
|
+
|
|
103
|
+
if (name === 'pre' || SKIP_ATTR_REGEX.test(attrs)) {
|
|
104
|
+
skip = { tag: name, depth: 1 }
|
|
105
|
+
return tag
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (name !== 'h2' && name !== 'h3')
|
|
109
|
+
return tag
|
|
110
|
+
|
|
111
|
+
const innerStart = offset + tag.length
|
|
112
|
+
const closeIndex = lowerHtml.indexOf(`</${name}`, innerStart)
|
|
113
|
+
const text = htmlToText(html.slice(innerStart, closeIndex === -1 ? undefined : closeIndex))
|
|
114
|
+
const level = name === 'h2' ? 2 : 3
|
|
115
|
+
const existingId = readIdAttr(attrs)
|
|
116
|
+
|
|
117
|
+
if (existingId) {
|
|
118
|
+
headings.push({ id: existingId, text, level })
|
|
119
|
+
return tag
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const id = uniqueId(toSlug(text) || FALLBACK_HEADING_ID, used)
|
|
123
|
+
headings.push({ id, text, level })
|
|
124
|
+
return `<${rawName} id="${id}"${attrs}>`
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return { html: output, headings }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Estimated reading time in minutes (225 wpm, at least 1). */
|
|
131
|
+
export function estimateReadingTime(html: string): number {
|
|
132
|
+
const words = htmlToText(html || '').split(' ').filter(Boolean).length
|
|
133
|
+
return Math.max(1, Math.round(words / WORDS_PER_MINUTE))
|
|
134
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { IPost } from '../models/post'
|
|
2
|
+
import { toBlogUrl } from './blog-path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-post SEO resolution shared by blog-admin (editor previews) and the public blog
|
|
6
|
+
* (post page meta). DUPLICATED byte-identically into blog/shared/utils/post-seo.ts so the
|
|
7
|
+
* editor preview always matches what the blog outputs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Constants */
|
|
11
|
+
export const SEO_TITLE_MAX = 60
|
|
12
|
+
export const SEO_DESCRIPTION_MAX = 160
|
|
13
|
+
|
|
14
|
+
/** Interfaces */
|
|
15
|
+
export type IPostSeoSource = Pick<IPost, 'slug' | 'id' | 'title' | 'shortText' | 'imageURL' | 'imageAlt' | 'seoTitle' | 'seoDescription' | 'ogImageURL' | 'canonicalURL' | 'noindex'>
|
|
16
|
+
|
|
17
|
+
export interface IPostSeoOptions {
|
|
18
|
+
/** Public site origin, e.g. https://blog.colorffy.com */
|
|
19
|
+
siteUrl: string
|
|
20
|
+
/** Where the blog mounts on that site (`blog.basePath`); defaults to the root. */
|
|
21
|
+
basePath?: string
|
|
22
|
+
/** Plain-text excerpt of the body, used when neither seoDescription nor shortText is set. */
|
|
23
|
+
bodyExcerpt?: string
|
|
24
|
+
/** Generated OG image URL, used when the post has no custom social image or cover. */
|
|
25
|
+
generatedImage?: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IResolvedPostSeo {
|
|
29
|
+
title: string
|
|
30
|
+
description: string
|
|
31
|
+
image: string | null
|
|
32
|
+
imageAlt: string
|
|
33
|
+
canonical: string
|
|
34
|
+
robots: 'index,follow' | 'noindex,follow'
|
|
35
|
+
noindex: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Helpers */
|
|
39
|
+
function clean(value: string | null | undefined): string {
|
|
40
|
+
return (value ?? '').replace(/\s+/g, ' ').trim()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolvePostSeo(post: IPostSeoSource, options: IPostSeoOptions): IResolvedPostSeo {
|
|
44
|
+
const slug = clean(post.slug) || clean(post.id)
|
|
45
|
+
const title = clean(post.seoTitle) || clean(post.title) || 'Article'
|
|
46
|
+
const noindex = post.noindex === true
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
title,
|
|
50
|
+
description: clean(post.seoDescription) || clean(post.shortText) || clean(options.bodyExcerpt),
|
|
51
|
+
// Custom social image beats the cover; the generated card is only a fallback.
|
|
52
|
+
image: clean(post.ogImageURL) || clean(post.imageURL) || clean(options.generatedImage) || null,
|
|
53
|
+
imageAlt: clean(post.imageAlt) || clean(post.title),
|
|
54
|
+
canonical: clean(post.canonicalURL) || toBlogUrl(options.siteUrl, options.basePath, `/${slug}`),
|
|
55
|
+
robots: noindex ? 'noindex,follow' : 'index,follow',
|
|
56
|
+
noindex
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import sanitizeHtml from 'sanitize-html'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server-side clean for post bodies shown with `v-html` (the post API only; the client
|
|
5
|
+
* never imports it). Same allowlist as blog-admin's
|
|
6
|
+
* `server/utils/sanitizePostHtml.ts` (the publish-time pass), so published markup survives untouched:
|
|
7
|
+
* code blocks keep `language-xxx`, widget placeholders keep `data-blog-widget` + `data-props`,
|
|
8
|
+
* external links get `rel="noopener nofollow"` + `target="_blank"` and images `img-fluid`.
|
|
9
|
+
* Only addition: `id` on h2/h3, so heading ids already in the stored markup are kept for the TOC.
|
|
10
|
+
*/
|
|
11
|
+
export function sanitizeBlogHtml(html: string): string {
|
|
12
|
+
if (!html)
|
|
13
|
+
return ''
|
|
14
|
+
|
|
15
|
+
return sanitizeHtml(html, {
|
|
16
|
+
allowedTags: [
|
|
17
|
+
'p',
|
|
18
|
+
'br',
|
|
19
|
+
'hr',
|
|
20
|
+
'div',
|
|
21
|
+
'span',
|
|
22
|
+
'h1',
|
|
23
|
+
'h2',
|
|
24
|
+
'h3',
|
|
25
|
+
'h4',
|
|
26
|
+
'h5',
|
|
27
|
+
'h6',
|
|
28
|
+
'strong',
|
|
29
|
+
'b',
|
|
30
|
+
'em',
|
|
31
|
+
'i',
|
|
32
|
+
's',
|
|
33
|
+
'u',
|
|
34
|
+
'code',
|
|
35
|
+
'pre',
|
|
36
|
+
'blockquote',
|
|
37
|
+
'ul',
|
|
38
|
+
'ol',
|
|
39
|
+
'li',
|
|
40
|
+
'a',
|
|
41
|
+
'img',
|
|
42
|
+
'figure',
|
|
43
|
+
'figcaption',
|
|
44
|
+
'table',
|
|
45
|
+
'thead',
|
|
46
|
+
'tbody',
|
|
47
|
+
'tr',
|
|
48
|
+
'th',
|
|
49
|
+
'td'
|
|
50
|
+
],
|
|
51
|
+
allowedAttributes: {
|
|
52
|
+
'*': ['class'],
|
|
53
|
+
'a': ['href', 'name', 'target', 'rel'],
|
|
54
|
+
'img': ['src', 'alt', 'title', 'width', 'height'],
|
|
55
|
+
'div': ['data-blog-widget', 'data-props'],
|
|
56
|
+
'h2': ['id'],
|
|
57
|
+
'h3': ['id'],
|
|
58
|
+
'td': ['colspan', 'rowspan'],
|
|
59
|
+
'th': ['colspan', 'rowspan']
|
|
60
|
+
},
|
|
61
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
62
|
+
transformTags: {
|
|
63
|
+
a: (tagName, attribs) => {
|
|
64
|
+
const href = attribs.href ?? ''
|
|
65
|
+
if (/^https?:\/\//i.test(href)) {
|
|
66
|
+
attribs.rel = 'noopener nofollow'
|
|
67
|
+
attribs.target = '_blank'
|
|
68
|
+
}
|
|
69
|
+
return { tagName, attribs }
|
|
70
|
+
},
|
|
71
|
+
img: (tagName, attribs) => {
|
|
72
|
+
const classes = (attribs.class ?? '').split(/\s+/).filter(Boolean)
|
|
73
|
+
if (!classes.includes('img-fluid'))
|
|
74
|
+
classes.push('img-fluid')
|
|
75
|
+
attribs.class = classes.join(' ')
|
|
76
|
+
return { tagName, attribs }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive blog widget contract.
|
|
3
|
+
*
|
|
4
|
+
* This file is the single source of truth for widget metadata and is DUPLICATED
|
|
5
|
+
* byte-identically into blog-admin/shared/widgets/ (two independent repos). Keep it
|
|
6
|
+
* free of Vue/runtime imports so both the public renderer and the admin editor can
|
|
7
|
+
* consume it. The public site maps each `key` to an actual Vue component separately
|
|
8
|
+
* (app/widgets/component-map.ts).
|
|
9
|
+
*/
|
|
10
|
+
export type WidgetPropType = 'string' | 'number' | 'boolean' | 'color' | 'enum'
|
|
11
|
+
|
|
12
|
+
export interface WidgetPropOption {
|
|
13
|
+
value: string
|
|
14
|
+
label: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WidgetPropSchema {
|
|
18
|
+
type: WidgetPropType
|
|
19
|
+
label: string
|
|
20
|
+
default: string | number | boolean
|
|
21
|
+
required?: boolean
|
|
22
|
+
help?: string
|
|
23
|
+
/** enum only */
|
|
24
|
+
options?: WidgetPropOption[]
|
|
25
|
+
/** number only */
|
|
26
|
+
min?: number
|
|
27
|
+
max?: number
|
|
28
|
+
step?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WidgetDescriptor {
|
|
32
|
+
/** Stored verbatim in the `data-blog-widget` attribute. */
|
|
33
|
+
key: string
|
|
34
|
+
name: string
|
|
35
|
+
description: string
|
|
36
|
+
/** Material Symbols HTML entity code point, e.g. `` (optional). */
|
|
37
|
+
icon?: string
|
|
38
|
+
/** px reserved by the placeholder before hydration, to avoid layout shift. */
|
|
39
|
+
minHeight?: number
|
|
40
|
+
props: Record<string, WidgetPropSchema>
|
|
41
|
+
}
|