@giancarlosgza/blog-layer 0.1.2 → 0.2.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 CHANGED
@@ -63,6 +63,9 @@ Blog pages use the site's `default` layout; set `blog.layout` to use another one
63
63
  - **`blog.layout`** (`nuxt.config`, optional): layout for the blog pages.
64
64
  - **Brand:** override `blog.*` in the site's `app/app.config.ts`. Every key is documented in this
65
65
  package's `app/app.config.ts`. `settings/general` in Firestore overrides the name and description.
66
+ - **Models and helpers:** add `@giancarlosgza/blog-core` to the site's own dependencies when site code
67
+ imports its types (e.g. `WidgetDescriptor`) or calls its auto-imported helpers; with pnpm a layer's
68
+ dependencies are not visible to the site.
66
69
  - **Widgets:** posts can embed `<div data-blog-widget="key" data-props='{...}'>`. A site registers
67
70
  widgets by providing `blogWidgets` (`{ [key]: { descriptor, component } }`) from a plugin; without
68
71
  one, placeholders render an "unavailable" fallback.
@@ -71,4 +74,5 @@ Blog pages use the site's `default` layout; set `blog.layout` to use another one
71
74
  `app/assets/css/material-symbols.generated.css` in the site.
72
75
 
73
76
  Inside the layer, files import each other through the `#blog/...` alias: `~` and `#shared` resolve
74
- to the site.
77
+ to the site. Shared models and helpers come from `@giancarlosgza/blog-core`; its helpers (`toBlogUrl`,
78
+ `toDateMs`, `resolvePostSeo`...) are auto-imported in app and server code.
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { IAuthor } from '#blog/shared/models/author'
2
+ import type { IAuthor } from '@giancarlosgza/blog-core'
3
3
  import type { IAuthorPageResponse } from '#blog/shared/types/api'
4
4
 
5
5
  /** Head */
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { IAuthor } from '#blog/shared/models/author'
3
- import type { IPost } from '#blog/shared/models/post'
2
+ import type { IAuthor, IPost } from '@giancarlosgza/blog-core'
4
3
  import type { IPostSeries } from '#blog/shared/types/api'
5
4
  import type { IPostHeading } from '#blog/shared/utils/post-headings'
6
5
  import { useClipboard, useIntersectionObserver, useShare } from '@vueuse/core'
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { IPost } from '#blog/shared/models/post'
2
+ import type { IPost } from '@giancarlosgza/blog-core'
3
3
 
4
4
  /** Interfaces */
5
5
  interface IProps {
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { IPost } from '#blog/shared/models/post'
2
+ import type { IPost } from '@giancarlosgza/blog-core'
3
3
  import { NuxtLink } from '#components'
4
4
 
5
5
  /** Interfaces */
@@ -1,4 +1,4 @@
1
- import type { ISettings } from '#blog/shared/models/settings'
1
+ import type { ISettings } from '@giancarlosgza/blog-core'
2
2
  import type { ISettingsResponse } from '#blog/shared/types/api'
3
3
 
4
4
  /**
@@ -1,5 +1,5 @@
1
+ import type { WidgetDescriptor } from '@giancarlosgza/blog-core'
1
2
  import type { Component } from 'vue'
2
- import type { WidgetDescriptor } from '#blog/shared/widgets/types'
3
3
 
4
4
  /** An embeddable post widget: its schema (for props coercion) and the component that renders it. */
5
5
  export interface IBlogWidget {
@@ -1,5 +1,5 @@
1
- import type { IFirestoreDateValue } from '#blog/shared/models/date'
2
- import { toDateMs } from '#blog/shared/utils/date'
1
+ import type { IFirestoreDateValue } from '@giancarlosgza/blog-core'
2
+ import { toDateMs } from '@giancarlosgza/blog-core'
3
3
 
4
4
  /** Normalize Date, Firestore Timestamp, serialized timestamps, and strings to milliseconds. */
5
5
  export function useDateValue() {
@@ -1,4 +1,4 @@
1
- import type { IPost } from '#blog/shared/models/post'
1
+ import type { IPost } from '@giancarlosgza/blog-core'
2
2
  import type { IPostListResponse } from '#blog/shared/types/api'
3
3
 
4
4
  /** Interfaces */
@@ -1,4 +1,4 @@
1
- import type { IPost } from '#blog/shared/models/post'
1
+ import type { IPost } from '@giancarlosgza/blog-core'
2
2
  import type { IFeaturedPostsResponse, IPostListResponse, ITagsResponse } from '#blog/shared/types/api'
3
3
 
4
4
  /** Interfaces */
@@ -1,4 +1,4 @@
1
- import type { WidgetDescriptor } from '#blog/shared/widgets/types'
1
+ import type { WidgetDescriptor } from '@giancarlosgza/blog-core'
2
2
 
3
3
  /** Safely parse a widget's `data-props` JSON into a plain object. Never throws. */
4
4
  export function safeParseProps(raw: string | null | undefined): Record<string, unknown> {
package/blog-module.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { NuxtPage } from 'nuxt/schema'
2
+ import { joinBlogPath, normalizeBasePath } from '@giancarlosgza/blog-core'
2
3
  import { addServerHandler, createResolver, defineNuxtModule, extendPages } from 'nuxt/kit'
3
- import { joinBlogPath, normalizeBasePath } from './shared/utils/blog-path'
4
4
 
5
5
  export interface BlogModuleOptions {
6
6
  /** Where the blog mounts: `/` for a standalone blog, e.g. `/blog` inside another site. Build time. */
package/nuxt.config.ts CHANGED
@@ -4,6 +4,12 @@ import blogModule from './blog-module'
4
4
 
5
5
  const layerDir = dirname(fileURLToPath(import.meta.url))
6
6
 
7
+ // blog-core helpers, auto-imported like the layer's own utils
8
+ const coreImports = [{
9
+ from: '@giancarlosgza/blog-core',
10
+ imports: ['htmlToExcerpt', 'joinBlogPath', 'normalizeBasePath', 'resolveAuthorName', 'resolvePostSeo', 'SEO_DESCRIPTION_MAX', 'SEO_TITLE_MAX', 'toBlogUrl', 'toDateMs', 'toSlug']
11
+ }]
12
+
7
13
  /** Blog Layer */
8
14
  export default defineNuxtConfig({
9
15
  compatibilityDate: '2026-01-01',
@@ -16,7 +22,14 @@ export default defineNuxtConfig({
16
22
  basePath: '/'
17
23
  },
18
24
 
25
+ imports: {
26
+ presets: coreImports
27
+ },
28
+
19
29
  nitro: {
30
+ imports: {
31
+ presets: [...coreImports, { from: '@giancarlosgza/blog-core/sanitize', imports: ['sanitizeBlogHtml'] }]
32
+ },
20
33
  devStorage: {
21
34
  cache: { driver: 'memory' }
22
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giancarlosgza/blog-layer",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Nuxt layer that adds a Firestore-backed blog to a site under a configurable base path: pages, cached API, RSS, sitemap and SEO",
5
5
  "author": "Giancarlos Garza <giancarlosgza@gmail.com> (https://github.com/giancarlosgza)",
6
6
  "type": "module",
@@ -32,16 +32,15 @@
32
32
  "@shikijs/langs": "4.5.0",
33
33
  "@shikijs/themes": "4.5.0",
34
34
  "@takumi-rs/core": "2.14.0",
35
- "@types/sanitize-html": "2.16.2",
36
35
  "@vueuse/components": "14.4.0",
37
36
  "@vueuse/core": "14.4.0",
38
37
  "@vueuse/nuxt": "14.4.0",
39
38
  "firebase-admin": "13.10.0",
40
39
  "floating-vue": "5.4.0",
41
40
  "nuxt-og-image": "6.10.3",
42
- "sanitize-html": "2.18.0",
43
41
  "sass": "1.105.1",
44
42
  "shiki": "4.5.0",
45
- "subset-font": "2.9.0"
43
+ "subset-font": "2.9.0",
44
+ "@giancarlosgza/blog-core": "^0.2.0"
46
45
  }
47
46
  }
@@ -1,5 +1,4 @@
1
- import type { IAuthor } from '#blog/shared/models/author'
2
- import type { IPost } from '#blog/shared/models/post'
1
+ import type { IAuthor, IPost } from '@giancarlosgza/blog-core'
3
2
  import type { IPostPageResponse, IPostSeries } from '#blog/shared/types/api'
4
3
  import { getFirestore } from 'firebase-admin/firestore'
5
4
 
@@ -1,8 +1,4 @@
1
- import type { IAuthor } from '#blog/shared/models/author'
2
- import type { IPost } from '#blog/shared/models/post'
3
- import type { ISeries } from '#blog/shared/models/series'
4
- import type { ISettings } from '#blog/shared/models/settings'
5
- import type { ITag } from '#blog/shared/models/tag'
1
+ import type { IAuthor, IPost, ISeries, ISettings, ITag } from '@giancarlosgza/blog-core'
6
2
  import type { IPostSeries, IPostSeriesPart } from '#blog/shared/types/api'
7
3
  import { Buffer } from 'node:buffer'
8
4
 
@@ -1,6 +1,5 @@
1
+ import type { IPost, ISeries } from '@giancarlosgza/blog-core'
1
2
  import type { Query } from 'firebase-admin/firestore'
2
- import type { IPost } from '#blog/shared/models/post'
3
- import type { ISeries } from '#blog/shared/models/series'
4
3
  import { getFirestore } from 'firebase-admin/firestore'
5
4
 
6
5
  /**
@@ -1,9 +1,6 @@
1
- import type { IFirestoreDateValue } from '#blog/shared/models/date'
2
- import type { IPost } from '#blog/shared/models/post'
3
- import { htmlToExcerpt, resolveAuthorName } from '#blog/shared/utils/blog'
4
- import { toBlogUrl } from '#blog/shared/utils/blog-path'
5
- import { toDateMs } from '#blog/shared/utils/date'
6
- import { sanitizeBlogHtml } from '#blog/shared/utils/sanitize'
1
+ import type { IFirestoreDateValue, IPost } from '@giancarlosgza/blog-core'
2
+ import { htmlToExcerpt, resolveAuthorName, toBlogUrl, toDateMs } from '@giancarlosgza/blog-core'
3
+ import { sanitizeBlogHtml } from '@giancarlosgza/blog-core/sanitize'
7
4
 
8
5
  /**
9
6
  * Pure RSS 2.0 builders for `server/handlers/rss.xml.ts`: XML escaping, CDATA, RFC 822 dates,
@@ -1,8 +1,5 @@
1
- import type { IAuthor } from '#blog/shared/models/author'
2
- import type { IPost } from '#blog/shared/models/post'
3
- import type { ISeries } from '#blog/shared/models/series'
4
- import type { ITag } from '#blog/shared/models/tag'
5
- import { joinBlogPath } from '#blog/shared/utils/blog-path'
1
+ import type { IAuthor, IPost, ISeries, ITag } from '@giancarlosgza/blog-core'
2
+ import { joinBlogPath } from '@giancarlosgza/blog-core'
6
3
 
7
4
  /**
8
5
  * Pure sitemap URL builder for `/api/blog/_sitemap-urls` (Firestore reads live in the route).
@@ -1,8 +1,4 @@
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'
1
+ import type { IAuthor, IPost, ISeries, ISettings, ITag } from '@giancarlosgza/blog-core'
6
2
  import type { IPostHeading } from '../utils/post-headings'
7
3
 
8
4
  /**
@@ -1,20 +1,3 @@
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
1
  /** Add the `.text-link` class to every prose `<a>` (merges existing classes; idempotent). */
19
2
  export function decorateBlogLinks(html: string): string {
20
3
  if (!html)
@@ -30,16 +13,3 @@ export function decorateBlogLinks(html: string): string {
30
13
  return `<a class="text-link"${attrs}>`
31
14
  })
32
15
  }
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
- }
@@ -1,4 +1,4 @@
1
- import { toSlug } from './blog'
1
+ import { toSlug } from '@giancarlosgza/blog-core'
2
2
 
3
3
  export interface IPostHeading {
4
4
  id: string
@@ -1,19 +0,0 @@
1
- import type { IFirestoreDateValue } from './date'
2
-
3
- /**
4
- * `authors/{uid}` — author profile. Document ID is the Firebase Auth UID.
5
- */
6
- export interface IAuthor {
7
- id?: string
8
- uid?: string
9
- name: string
10
- /** URL slug for the author's public page (e.g. used by the blog's sitemap/author routes). */
11
- slug?: string
12
- description?: string
13
- photoURL?: string
14
- profileLink?: string
15
- /** Legacy handle shown on the author page; not managed by blog-admin. */
16
- username?: string
17
- createdAt?: IFirestoreDateValue
18
- updatedAt?: IFirestoreDateValue
19
- }
@@ -1,11 +0,0 @@
1
- export interface ISerializedTimestamp {
2
- seconds: number
3
- nanoseconds?: number
4
- }
5
-
6
- export interface IDateWithToDate {
7
- toDate: () => Date
8
- }
9
-
10
- /** Firestore `Timestamp`s (client or Admin SDK) match `IDateWithToDate` / `ISerializedTimestamp` structurally. */
11
- export type IFirestoreDateValue = ISerializedTimestamp | IDateWithToDate | number | Date | string | null | undefined
@@ -1,114 +0,0 @@
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
- }
@@ -1,14 +0,0 @@
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
- }
@@ -1,12 +0,0 @@
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
- }
@@ -1,15 +0,0 @@
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
- }
@@ -1,23 +0,0 @@
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
- }
@@ -1,26 +0,0 @@
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
- }
@@ -1,58 +0,0 @@
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
- }
@@ -1,80 +0,0 @@
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
- }
@@ -1,41 +0,0 @@
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
- }