@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 +5 -1
- package/app/blog-pages/author.vue +1 -1
- package/app/components/blog/BlogPostArticle.vue +1 -2
- package/app/components/blog/BlogPostCard.vue +1 -1
- package/app/components/blog/BlogPostList.vue +1 -1
- package/app/composables/useBlogSettings.ts +1 -1
- package/app/composables/useBlogWidgets.ts +1 -1
- package/app/composables/useDateValue.ts +2 -2
- package/app/composables/usePaginatedPosts.ts +1 -1
- package/app/composables/usePostFeed.ts +1 -1
- package/app/utils/widget-props.ts +1 -1
- package/blog-module.ts +1 -1
- package/nuxt.config.ts +13 -0
- package/package.json +3 -4
- package/server/api/blog/posts/[slug].get.ts +1 -2
- package/server/utils/blog-data.ts +1 -5
- package/server/utils/blog-firestore.ts +1 -2
- package/server/utils/rss.ts +3 -6
- package/server/utils/sitemap.ts +2 -5
- package/shared/types/api.ts +1 -5
- package/shared/utils/blog.ts +0 -30
- package/shared/utils/post-headings.ts +1 -1
- package/shared/models/author.ts +0 -19
- package/shared/models/date.ts +0 -11
- package/shared/models/post.ts +0 -114
- package/shared/models/series.ts +0 -14
- package/shared/models/settings.ts +0 -12
- package/shared/models/tag.ts +0 -15
- package/shared/utils/blog-path.ts +0 -23
- package/shared/utils/date.ts +0 -26
- package/shared/utils/post-seo.ts +0 -58
- package/shared/utils/sanitize.ts +0 -80
- package/shared/widgets/types.ts +0 -41
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,6 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { IAuthor } from '
|
|
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
|
+
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 '
|
|
2
|
-
import { toDateMs } from '
|
|
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 { WidgetDescriptor } from '
|
|
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.
|
|
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 '
|
|
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 '
|
|
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
|
/**
|
package/server/utils/rss.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import type { IFirestoreDateValue } from '
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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,
|
package/server/utils/sitemap.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import type { IAuthor } from '
|
|
2
|
-
import
|
|
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).
|
package/shared/types/api.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import type { IAuthor } from '
|
|
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
|
/**
|
package/shared/utils/blog.ts
CHANGED
|
@@ -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
|
-
}
|
package/shared/models/author.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/models/date.ts
DELETED
|
@@ -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
|
package/shared/models/post.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/models/series.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/models/tag.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/utils/date.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/utils/post-seo.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/utils/sanitize.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/widgets/types.ts
DELETED
|
@@ -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. `` (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
|
-
}
|