@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.
Files changed (79) hide show
  1. package/README.md +73 -2
  2. package/app/app.config.ts +42 -0
  3. package/app/assets/scss/abstracts/_tokens.scss +8 -0
  4. package/app/assets/scss/abstracts/_type-scale.scss +6 -0
  5. package/app/assets/scss/abstracts/_variables.scss +1 -0
  6. package/app/assets/scss/components/_icons.scss +15 -0
  7. package/app/assets/scss/components/_symbols.scss +20 -0
  8. package/app/assets/scss/main.scss +9 -0
  9. package/app/assets/scss/pages/_core.scss +84 -0
  10. package/app/assets/scss/pages/core/_article-layout.scss +260 -0
  11. package/app/assets/scss/pages/core/_code-layout.scss +42 -0
  12. package/app/assets/scss/pages/core/_layers.scss +1 -0
  13. package/app/assets/scss/pages/core/_post-article-layout.scss +198 -0
  14. package/app/assets/scss/pages/core/_post-layout.scss +286 -0
  15. package/app/assets/scss/pages/core/_shared-ui.scss +100 -0
  16. package/app/assets/scss/pages/core/_transitions-layout.scss +70 -0
  17. package/app/assets/scss/pages/core/_widgets.scss +17 -0
  18. package/app/blog-pages/archive.vue +136 -0
  19. package/app/blog-pages/author.vue +115 -0
  20. package/app/blog-pages/home.vue +178 -0
  21. package/app/blog-pages/post.vue +168 -0
  22. package/app/blog-pages/series.vue +127 -0
  23. package/app/blog-pages/tag.vue +96 -0
  24. package/app/components/OgImage/BlogPost.takumi.vue +117 -0
  25. package/app/components/blog/BlogCodeBlock.vue +144 -0
  26. package/app/components/blog/BlogPostArticle.vue +545 -0
  27. package/app/components/blog/BlogPostCard.vue +208 -0
  28. package/app/components/blog/BlogPostContent.vue +111 -0
  29. package/app/components/blog/BlogPostList.vue +143 -0
  30. package/app/components/layout/HubHeader.vue +62 -0
  31. package/app/composables/useBlogBrand.ts +13 -0
  32. package/app/composables/useBlogPath.ts +13 -0
  33. package/app/composables/useBlogPost.ts +12 -0
  34. package/app/composables/useBlogSettings.ts +15 -0
  35. package/app/composables/useBlogWidgets.ts +18 -0
  36. package/app/composables/useCachedResource.ts +72 -0
  37. package/app/composables/useDateValue.ts +27 -0
  38. package/app/composables/usePaginatedPosts.ts +57 -0
  39. package/app/composables/usePostFeed.ts +139 -0
  40. package/app/composables/useSeriesNames.ts +20 -0
  41. package/app/composables/useShikiHighlighter.ts +108 -0
  42. package/app/composables/useSiteSchemaLd.ts +44 -0
  43. package/app/composables/useSiteUrl.ts +4 -0
  44. package/app/utils/widget-props.ts +66 -0
  45. package/app/widgets/WidgetError.vue +13 -0
  46. package/blog-module.ts +71 -0
  47. package/nuxt.config.ts +80 -0
  48. package/package.json +44 -3
  49. package/scripts/subset-icons.mjs +167 -0
  50. package/server/api/blog/_sitemap-urls.ts +38 -0
  51. package/server/api/blog/authors/[slug].get.ts +42 -0
  52. package/server/api/blog/featured.get.ts +18 -0
  53. package/server/api/blog/posts/[slug].get.ts +116 -0
  54. package/server/api/blog/posts/index.get.ts +35 -0
  55. package/server/api/blog/series/[slug].get.ts +29 -0
  56. package/server/api/blog/series/index.get.ts +15 -0
  57. package/server/api/blog/settings.get.ts +18 -0
  58. package/server/api/blog/tags/[tag].get.ts +35 -0
  59. package/server/api/blog/tags/index.get.ts +17 -0
  60. package/server/handlers/rss.xml.ts +53 -0
  61. package/server/plugins/firebase.ts +20 -0
  62. package/server/utils/blog-data.ts +218 -0
  63. package/server/utils/blog-firestore.ts +80 -0
  64. package/server/utils/rss.ts +212 -0
  65. package/server/utils/sitemap.ts +80 -0
  66. package/shared/models/author.ts +19 -0
  67. package/shared/models/date.ts +11 -0
  68. package/shared/models/post.ts +114 -0
  69. package/shared/models/series.ts +14 -0
  70. package/shared/models/settings.ts +12 -0
  71. package/shared/models/tag.ts +15 -0
  72. package/shared/types/api.ts +78 -0
  73. package/shared/utils/blog-path.ts +23 -0
  74. package/shared/utils/blog.ts +45 -0
  75. package/shared/utils/date.ts +26 -0
  76. package/shared/utils/post-headings.ts +134 -0
  77. package/shared/utils/post-seo.ts +58 -0
  78. package/shared/utils/sanitize.ts +80 -0
  79. 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. `&#xe3e9;` (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
+ }