hazo_blog 0.3.0 → 0.4.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/CHANGE_LOG.md +58 -0
- package/README.md +31 -4
- package/SETUP_CHECKLIST.md +1 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +1 -0
- package/dist/lib/text.d.ts +27 -7
- package/dist/lib/text.d.ts.map +1 -1
- package/dist/lib/text.js +38 -8
- package/dist/lib/validate.d.ts +48 -0
- package/dist/lib/validate.d.ts.map +1 -0
- package/dist/lib/validate.js +68 -0
- package/package.json +19 -11
package/CHANGE_LOG.md
CHANGED
|
@@ -3,6 +3,64 @@
|
|
|
3
3
|
All notable changes are documented here. This project follows
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## 0.4.0 — 2026-07-14
|
|
7
|
+
|
|
8
|
+
**`validatePost()` — pre-publish content-quality gate (new, `hazo_blog/lib`)**
|
|
9
|
+
|
|
10
|
+
Encodes the SEO lessons a stale Bing report surfaced late on a consumer site, so they're caught
|
|
11
|
+
before a post goes live rather than months after. `validatePost(input, opts?)` returns
|
|
12
|
+
`{ ok, errors, warnings, issues, wordCount }`:
|
|
13
|
+
|
|
14
|
+
- **Blocking errors:** thin content (`< minWords`, default 300), missing meta description, and a
|
|
15
|
+
second page heading in the body — an `# ` markdown H1 or a `<h1>` (the post title is already the
|
|
16
|
+
page H1). Code samples in fenced blocks are ignored so a `#` comment doesn't false-positive.
|
|
17
|
+
- **Advisory warnings:** meta-description length outside 150–160, composed SEO title over
|
|
18
|
+
`titleLengthLimit` (default 60, *including* the `titleSuffix` — same suffix-aware fix shipped in
|
|
19
|
+
hazo_seo 0.5.0), missing excerpt, missing FAQ, and no subheadings.
|
|
20
|
+
|
|
21
|
+
Consumers wire this into their create/update/publish path (e.g. gotimer's `PATCH /api/blog/manage`)
|
|
22
|
+
and reject on `!result.ok`. Pure + server-safe; reuses the existing `mdxToPlainText`/`countWords`/
|
|
23
|
+
`extractToc` helpers, no new dependencies. Reachable from the main `hazo_blog` entry and the
|
|
24
|
+
`hazo_blog/lib` subpath.
|
|
25
|
+
|
|
26
|
+
**`hazo_blog/lib` subpath export (new)** — the `./lib` subpath (advertised in the README for the
|
|
27
|
+
text utilities but previously absent from `package.json` `exports`) is now a real entry, so
|
|
28
|
+
`sanitizeMdx`/`mdxToPlainText`/`slugify`/`extractToc`/`validatePost` etc. import directly from
|
|
29
|
+
`hazo_blog/lib` as documented (they were only reachable via the main `hazo_blog` entry before).
|
|
30
|
+
|
|
31
|
+
**Internal (test tooling)**
|
|
32
|
+
- Fixed `repository.integration.test.ts` failing to run: the CJS-transformed `hazo_connect` adapter
|
|
33
|
+
graph does `require()` of ESM-only hazo packages (`hazo_core`, and transitively
|
|
34
|
+
`hazo_secure/crypto`), which jest's vm-modules loader can't `require()` on Node < 24.9 ("Must use
|
|
35
|
+
import to load ES Module"). Fix: plain-**CJS** mocks — `__mocks__/hazo_core.cjs` (functional error
|
|
36
|
+
classes + `safeJsonParse` etc.) and `__mocks__/hazo_secure_crypto.cjs` (passthrough field-crypto
|
|
37
|
+
stub) — mapped via `moduleNameMapper`, plus a `transformIgnorePatterns` exception for
|
|
38
|
+
`hazo_connect`/`hazo_core`. `.cjs` isn't matched by the `tsx` transform, so it loads raw as
|
|
39
|
+
CommonJS and the real `hazo_connect` adapter resolves against it. The integration test now runs
|
|
40
|
+
against real `hazo_connect` + real SQLite on the Node 22 the workspace pins for better-sqlite3.
|
|
41
|
+
(An earlier `.ts` ESM mock silently did **not** fix this on Node 22 — a CJS `require()` still can't
|
|
42
|
+
load an ESM `.ts`.)
|
|
43
|
+
|
|
44
|
+
## 0.3.3 — 2026-07-06
|
|
45
|
+
|
|
46
|
+
**`sanitizeMdx` hardened against raw WordPress HTML**
|
|
47
|
+
|
|
48
|
+
Extends the sanitizer beyond HTML comments to cover three more parser-breaking patterns commonly
|
|
49
|
+
found in imported WordPress post bodies. All run on the code-masked string, so fenced/inline code
|
|
50
|
+
spans stay byte-for-byte untouched, and the transform remains idempotent.
|
|
51
|
+
|
|
52
|
+
- **`<script>` / `<style>` blocks** — dropped entirely. Their bodies (JSON-LD, inline CSS) contain
|
|
53
|
+
raw `{ }` that MDX parses as a JS expression, throwing "Could not parse expression with acorn".
|
|
54
|
+
They carry no visible prose, so removing them never changes rendered output.
|
|
55
|
+
- **HTML void elements** (`<img>`, `<br>`, `<hr>`, `<input>`, …) — normalized to self-closing
|
|
56
|
+
(`<img src="x" />`). Un-self-closed voids throw "Expected a closing tag" in MDX/JSX.
|
|
57
|
+
- **String-valued `style="..."` / `style='...'` attributes** — dropped. Valid HTML but invalid JSX
|
|
58
|
+
(React requires the object form `style={{...}}`), else "The style prop expects a mapping … not a
|
|
59
|
+
string" at render. The JSX object form is deliberately left untouched.
|
|
60
|
+
|
|
61
|
+
No API change — same `sanitizeMdx(mdx: string): string` signature. **Consumer note:** `^0.3.0`
|
|
62
|
+
resolves to `0.3.3` (caret allows patch/minor within the same pre-1.0 minor).
|
|
63
|
+
|
|
6
64
|
## 0.3.0 — 2026-06-12
|
|
7
65
|
|
|
8
66
|
**Build resilience: sanitizer + async try/catch guard against malformed MDX**
|
package/README.md
CHANGED
|
@@ -208,9 +208,14 @@ const nextConfig = {
|
|
|
208
208
|
|
|
209
209
|
`BlogContent` (≥ 0.3.0) is an async server component that catches MDX compilation errors with
|
|
210
210
|
`try/catch`, so a single malformed post renders a graceful fallback instead of failing the whole
|
|
211
|
-
`next build`. It also pre-sanitizes content with `sanitizeMdx`
|
|
212
|
-
|
|
213
|
-
|
|
211
|
+
`next build`. It also pre-sanitizes content with `sanitizeMdx` before passing it to the compiler.
|
|
212
|
+
The sanitizer fixes the common ways raw WordPress HTML trips the MDX/acorn parser: converts HTML
|
|
213
|
+
comments → MDX comments (and escapes stray `<!`), strips `<script>`/`<style>` blocks whose bodies
|
|
214
|
+
break parsing, self-closes HTML void elements (`<img>`, `<br>`, …), and drops string-valued
|
|
215
|
+
`style="..."` attributes (invalid JSX; the object form `style={{...}}` is left untouched). Fenced
|
|
216
|
+
and inline code spans are left byte-for-byte untouched, and the transform is idempotent. The
|
|
217
|
+
sanitizer is also available as a standalone export from `hazo_blog/lib` for custom rendering
|
|
218
|
+
pipelines.
|
|
214
219
|
|
|
215
220
|
## Exports
|
|
216
221
|
|
|
@@ -219,7 +224,7 @@ as a standalone export from `hazo_blog/lib` for custom rendering pipelines.
|
|
|
219
224
|
| `hazo_blog` | server: `createBlogService`, `createBlogRepository`, SEO builders, **route-handler factories** (React-free), types |
|
|
220
225
|
| `hazo_blog/client` | client components: `PostCard`, `PostHero`, `AuthorBio`, `FaqSection`, `TableOfContents`, `RelatedPosts`, `BlogSearch`, `PostForm`, MDX components, `trackBlogEvent` |
|
|
221
226
|
| `hazo_blog/next` | sealed page factories: public (`createBlogIndexPage`, `createBlogPostPage`, `createBlogTagPage`) + admin (`createBlogAdminListPage`, `createBlogAdminNewPage`, `createBlogAdminEditPage`) + `BlogContent` + `MdxErrorBoundary` |
|
|
222
|
-
| `hazo_blog/lib` | pure text utilities: `sanitizeMdx`, `mdxToPlainText`, `buildExcerpt`, `slugify`, `calculateReadingTime`, `extractToc` |
|
|
227
|
+
| `hazo_blog/lib` | pure text utilities: `sanitizeMdx`, `mdxToPlainText`, `buildExcerpt`, `slugify`, `calculateReadingTime`, `extractToc` + `validatePost` (pre-publish content-quality gate) |
|
|
223
228
|
| `hazo_blog/seo` | `buildBlogPostingJsonLd`, `buildBreadcrumbJsonLd`, `buildFaqJsonLd`, `getBlogSitemapEntries`, `getBlogRobotsRules`, `getBlogRssXml` |
|
|
224
229
|
| `hazo_blog/config` | `BlogConfig` types + `resolveConfig` |
|
|
225
230
|
|
|
@@ -239,6 +244,28 @@ searchApiPath: "/api/blog/search", // default
|
|
|
239
244
|
See `SETUP_CHECKLIST.md` for a step-by-step integration list. A runnable demo
|
|
240
245
|
lives in `test-app/`.
|
|
241
246
|
|
|
247
|
+
## Pre-publish validation
|
|
248
|
+
|
|
249
|
+
`validatePost` is a pure, server-safe content-quality gate. Wire it into your
|
|
250
|
+
create/update/publish path and reject on `!result.ok`:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import { validatePost } from "hazo_blog"; // also on the `hazo_blog/lib` subpath
|
|
254
|
+
|
|
255
|
+
const result = validatePost(
|
|
256
|
+
{ title, content, meta_description, excerpt, faq },
|
|
257
|
+
{ titleSuffix: " | GoTimer" }, // options are optional
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (!result.ok) {
|
|
261
|
+
return fail(result.errors); // blocking: thin content, missing meta description, a second <h1>
|
|
262
|
+
}
|
|
263
|
+
logWarnings(result.warnings); // advisory: meta length, over-long title, missing excerpt/FAQ, no subheadings
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Options: `minWords` (default 300), `titleLengthLimit` (default 60, suffix-aware),
|
|
267
|
+
`titleSuffix`, `descriptionRange` (default `[150, 160]`).
|
|
268
|
+
|
|
242
269
|
## License
|
|
243
270
|
|
|
244
271
|
MIT
|
package/SETUP_CHECKLIST.md
CHANGED
|
@@ -36,6 +36,7 @@ for full code samples.
|
|
|
36
36
|
- [ ] `app/api/blog/feed/route.ts` → `createBlogFeedRoute`
|
|
37
37
|
- [ ] `app/api/admin/blog/route.ts` + `[id]/route.ts` → `createBlogAdminRoutes`
|
|
38
38
|
- [ ] (optional) `app/api/blog/manage/route.ts` → `createBlogManageRoutes`
|
|
39
|
+
- [ ] (optional) call `validatePost(input, opts)` in your create/update/publish path and reject on `!result.ok` (pre-publish content-quality gate)
|
|
39
40
|
- [ ] Merge `getBlogSitemapEntries()` into root `app/sitemap.ts` and `getBlogRobotsRules()` into `app/robots.ts`
|
|
40
41
|
|
|
41
42
|
### Admin UI pages (sealed, recommended)
|
package/dist/lib/index.d.ts
CHANGED
package/dist/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,WAAW,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC"}
|
package/dist/lib/index.js
CHANGED
package/dist/lib/text.d.ts
CHANGED
|
@@ -23,13 +23,33 @@ export interface TocHeading {
|
|
|
23
23
|
}
|
|
24
24
|
export declare function extractToc(mdx: string): TocHeading[];
|
|
25
25
|
/**
|
|
26
|
-
* Make raw MDX safe to compile.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
26
|
+
* Make raw MDX safe to compile. Raw WordPress HTML stored as post content
|
|
27
|
+
* trips the MDX/acorn parser in a handful of unambiguously-fixable ways:
|
|
28
|
+
*
|
|
29
|
+
* 1. HTML comments — MDX has none, so a bare `<!--` makes the parser expect
|
|
30
|
+
* a JSX tag name after `<` and throw "Unexpected character `!` before
|
|
31
|
+
* name". Converted to MDX comments ({/*…*\/}, equally non-rendering).
|
|
32
|
+
* 2. `<script>`/`<style>` blocks — their bodies (JSON-LD, inline CSS)
|
|
33
|
+
* contain raw `{ }` that MDX parses as a JS expression, throwing "Could
|
|
34
|
+
* not parse expression with acorn". They carry no visible prose (SEO
|
|
35
|
+
* metadata / global CSS handle those concerns elsewhere), so dropping the
|
|
36
|
+
* whole block is safe and never changes rendered output.
|
|
37
|
+
* 3. HTML void elements (`<img>`, `<br>`, …) written un-self-closed — MDX/JSX
|
|
38
|
+
* requires them self-closed; `<img src="x">` alone throws "Expected a
|
|
39
|
+
* closing tag". Normalized to `<img src="x" />` regardless of whether a
|
|
40
|
+
* stray slash was already present.
|
|
41
|
+
* 4. String-valued `style="..."` / `style='...'` attributes — valid HTML but
|
|
42
|
+
* invalid JSX/MDX (React requires `style={{...}}`, an object, never a
|
|
43
|
+
* string); left alone, they compile fine but throw "The style prop
|
|
44
|
+
* expects a mapping ... not a string" at render time. Always broken, so
|
|
45
|
+
* the attribute is dropped outright. Only the quoted-string form is
|
|
46
|
+
* matched (`style="`/`style='`) — the legitimate JSX object form
|
|
47
|
+
* (`style={{...}}`) never matches and is left untouched.
|
|
48
|
+
*
|
|
49
|
+
* All four run on the code-MASKED string, so fenced/inline code spans are
|
|
50
|
+
* left byte-for-byte untouched (they are literal in MDX, never error, and
|
|
51
|
+
* may legitimately show a comment, a `<script>` sample, or a bare `<img>` in
|
|
52
|
+
* a code sample). Idempotent: running this twice yields the same output.
|
|
33
53
|
*/
|
|
34
54
|
export declare function sanitizeMdx(mdx: string): string;
|
|
35
55
|
//# sourceMappingURL=text.d.ts.map
|
package/dist/lib/text.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/lib/text.ts"],"names":[],"mappings":"AAGA,6CAA6C;AAC7C,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAS7C;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAalD;AAED,0CAA0C;AAC1C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,SAAM,GAAG,MAAM,CAG9E;AAED,qFAAqF;AACrF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,SAAM,GAAG,MAAM,CAMhE;AAED,qFAAqF;AACrF,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,EAAE,CAYpD;AAED
|
|
1
|
+
{"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/lib/text.ts"],"names":[],"mappings":"AAGA,6CAA6C;AAC7C,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAS7C;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAalD;AAED,0CAA0C;AAC1C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,SAAM,GAAG,MAAM,CAG9E;AAED,qFAAqF;AACrF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,SAAM,GAAG,MAAM,CAMhE;AAED,qFAAqF;AACrF,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,EAAE,CAYpD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA+B/C"}
|
package/dist/lib/text.js
CHANGED
|
@@ -68,13 +68,33 @@ export function extractToc(mdx) {
|
|
|
68
68
|
return headings;
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
71
|
-
* Make raw MDX safe to compile.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
71
|
+
* Make raw MDX safe to compile. Raw WordPress HTML stored as post content
|
|
72
|
+
* trips the MDX/acorn parser in a handful of unambiguously-fixable ways:
|
|
73
|
+
*
|
|
74
|
+
* 1. HTML comments — MDX has none, so a bare `<!--` makes the parser expect
|
|
75
|
+
* a JSX tag name after `<` and throw "Unexpected character `!` before
|
|
76
|
+
* name". Converted to MDX comments ({/*…*\/}, equally non-rendering).
|
|
77
|
+
* 2. `<script>`/`<style>` blocks — their bodies (JSON-LD, inline CSS)
|
|
78
|
+
* contain raw `{ }` that MDX parses as a JS expression, throwing "Could
|
|
79
|
+
* not parse expression with acorn". They carry no visible prose (SEO
|
|
80
|
+
* metadata / global CSS handle those concerns elsewhere), so dropping the
|
|
81
|
+
* whole block is safe and never changes rendered output.
|
|
82
|
+
* 3. HTML void elements (`<img>`, `<br>`, …) written un-self-closed — MDX/JSX
|
|
83
|
+
* requires them self-closed; `<img src="x">` alone throws "Expected a
|
|
84
|
+
* closing tag". Normalized to `<img src="x" />` regardless of whether a
|
|
85
|
+
* stray slash was already present.
|
|
86
|
+
* 4. String-valued `style="..."` / `style='...'` attributes — valid HTML but
|
|
87
|
+
* invalid JSX/MDX (React requires `style={{...}}`, an object, never a
|
|
88
|
+
* string); left alone, they compile fine but throw "The style prop
|
|
89
|
+
* expects a mapping ... not a string" at render time. Always broken, so
|
|
90
|
+
* the attribute is dropped outright. Only the quoted-string form is
|
|
91
|
+
* matched (`style="`/`style='`) — the legitimate JSX object form
|
|
92
|
+
* (`style={{...}}`) never matches and is left untouched.
|
|
93
|
+
*
|
|
94
|
+
* All four run on the code-MASKED string, so fenced/inline code spans are
|
|
95
|
+
* left byte-for-byte untouched (they are literal in MDX, never error, and
|
|
96
|
+
* may legitimately show a comment, a `<script>` sample, or a bare `<img>` in
|
|
97
|
+
* a code sample). Idempotent: running this twice yields the same output.
|
|
78
98
|
*/
|
|
79
99
|
export function sanitizeMdx(mdx) {
|
|
80
100
|
const stash = [];
|
|
@@ -90,8 +110,18 @@ export function sanitizeMdx(mdx) {
|
|
|
90
110
|
.replace(/~~~[\s\S]*?~~~/g, keep)
|
|
91
111
|
.replace(/`[^`\n]*`/g, keep);
|
|
92
112
|
masked = masked
|
|
113
|
+
// Strip <script>/<style> blocks entirely — see rationale above.
|
|
114
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
115
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
93
116
|
// Comment body: neutralize any `*/` so it can't close the MDX comment early.
|
|
94
117
|
.replace(/<!--([\s\S]*?)-->/g, (_, body) => `{/*${body.replace(/\*\//g, "* /")}*/}`)
|
|
95
|
-
.replace(/<!(?!--)/g, "<!")
|
|
118
|
+
.replace(/<!(?!--)/g, "<!")
|
|
119
|
+
// Strip string-valued `style="..."` attributes — see rationale above.
|
|
120
|
+
// Deliberately does NOT match `style={` (the valid JSX object form).
|
|
121
|
+
.replace(/\sstyle=("[^"]*"|'[^']*')/gi, "")
|
|
122
|
+
// Self-close HTML void elements — see rationale above. Emits `/>`
|
|
123
|
+
// whether or not a slash was already present, so a second pass is a
|
|
124
|
+
// no-op (idempotent).
|
|
125
|
+
.replace(/<(img|br|hr|input|source|meta|link|col|area|base|embed|param|track|wbr)\b([^>]*?)\s*\/?>/gi, "<$1$2 />");
|
|
96
126
|
return masked.replace(/(\d+)/g, (_, i) => stash[Number(i)]);
|
|
97
127
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { FaqItem, PostStatus } from '../types/index.js';
|
|
2
|
+
export interface ValidatePostInput {
|
|
3
|
+
title: string;
|
|
4
|
+
/** Raw MDX body. */
|
|
5
|
+
content: string;
|
|
6
|
+
meta_title?: string;
|
|
7
|
+
meta_description?: string;
|
|
8
|
+
excerpt?: string | null;
|
|
9
|
+
faq?: FaqItem[];
|
|
10
|
+
status?: PostStatus;
|
|
11
|
+
}
|
|
12
|
+
export interface PostValidationOptions {
|
|
13
|
+
/** Minimum body word count before content is flagged as thin. Default 300. */
|
|
14
|
+
minWords?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Max length of the *composed* SEO title (title + `titleSuffix`) before a
|
|
17
|
+
* warning. Default 60 (Google's practical display limit).
|
|
18
|
+
*/
|
|
19
|
+
titleLengthLimit?: number;
|
|
20
|
+
/** Brand/site suffix appended to titles at render time, e.g. " | GoTimer". Default "". */
|
|
21
|
+
titleSuffix?: string;
|
|
22
|
+
/** [min, max] ideal meta-description length. Default [150, 160]. */
|
|
23
|
+
descriptionRange?: [number, number];
|
|
24
|
+
}
|
|
25
|
+
export type ValidationSeverity = 'error' | 'warning';
|
|
26
|
+
export interface ValidationIssue {
|
|
27
|
+
code: string;
|
|
28
|
+
severity: ValidationSeverity;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
export interface PostValidationResult {
|
|
32
|
+
/** True when there are no `error`-severity issues. */
|
|
33
|
+
ok: boolean;
|
|
34
|
+
errors: ValidationIssue[];
|
|
35
|
+
warnings: ValidationIssue[];
|
|
36
|
+
/** All issues (errors + warnings), in the order produced. */
|
|
37
|
+
issues: ValidationIssue[];
|
|
38
|
+
/** Body word count (plain text), exposed for logging/telemetry. */
|
|
39
|
+
wordCount: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate a post before publishing. Blocking issues land in `errors`
|
|
43
|
+
* (thin content, missing meta description, a second page <h1> in the body);
|
|
44
|
+
* advisory issues land in `warnings` (meta-description length, over-long title,
|
|
45
|
+
* missing excerpt/FAQ, no subheadings). `ok` is true iff there are no errors.
|
|
46
|
+
*/
|
|
47
|
+
export declare function validatePost(input: ValidatePostInput, opts?: PostValidationOptions): PostValidationResult;
|
|
48
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/lib/validate.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAG7D,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,oBAAoB;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,GAAG,CAAC,EAAE,OAAO,EAAE,CAAC;IAChB,MAAM,CAAC,EAAE,UAAU,CAAC;CACrB;AAED,MAAM,WAAW,qBAAqB;IACpC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,0FAA0F;IAC1F,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC;AAED,MAAM,MAAM,kBAAkB,GAAG,OAAO,GAAG,SAAS,CAAC;AAErD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,sDAAsD;IACtD,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,6DAA6D;IAC7D,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;CACnB;AAOD;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,iBAAiB,EACxB,IAAI,GAAE,qBAA0B,GAC/B,oBAAoB,CAyDtB"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// hazo_blog/lib — validatePost(): a pre-publish content-quality gate.
|
|
2
|
+
//
|
|
3
|
+
// Encodes the SEO lessons that a stale Bing report surfaced late on a consumer
|
|
4
|
+
// site: thin content, missing/oversized meta, and multiple <h1> per page. A
|
|
5
|
+
// post's <h1> is its *title* — an `# ` (or <h1>) inside the body creates a
|
|
6
|
+
// second page heading. Consumers call this before persisting/publishing and
|
|
7
|
+
// reject on `errors` (blocking) while surfacing `warnings` (advisory).
|
|
8
|
+
//
|
|
9
|
+
// Pure + server-safe; reuses the existing text helpers, no new deps.
|
|
10
|
+
import { mdxToPlainText, countWords, extractToc } from './text.js';
|
|
11
|
+
/** Strip fenced code so a `#` in a code sample isn't mistaken for a heading. */
|
|
12
|
+
function stripFencedCode(mdx) {
|
|
13
|
+
return mdx.replace(/```[\s\S]*?```/g, ' ');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Validate a post before publishing. Blocking issues land in `errors`
|
|
17
|
+
* (thin content, missing meta description, a second page <h1> in the body);
|
|
18
|
+
* advisory issues land in `warnings` (meta-description length, over-long title,
|
|
19
|
+
* missing excerpt/FAQ, no subheadings). `ok` is true iff there are no errors.
|
|
20
|
+
*/
|
|
21
|
+
export function validatePost(input, opts = {}) {
|
|
22
|
+
const minWords = opts.minWords ?? 300;
|
|
23
|
+
const titleLimit = opts.titleLengthLimit ?? 60;
|
|
24
|
+
const titleSuffix = opts.titleSuffix ?? '';
|
|
25
|
+
const [descMin, descMax] = opts.descriptionRange ?? [150, 160];
|
|
26
|
+
const errors = [];
|
|
27
|
+
const warnings = [];
|
|
28
|
+
const add = (severity, code, message) => (severity === 'error' ? errors : warnings).push({ code, severity, message });
|
|
29
|
+
// --- Thin content ---
|
|
30
|
+
const wordCount = countWords(mdxToPlainText(input.content));
|
|
31
|
+
if (wordCount < minWords) {
|
|
32
|
+
add('error', 'THIN_CONTENT', `Body is ${wordCount} words (<${minWords}). Expand before publishing.`);
|
|
33
|
+
}
|
|
34
|
+
// --- Second page <h1> in the body (the post title is the page <h1>) ---
|
|
35
|
+
const body = stripFencedCode(input.content);
|
|
36
|
+
const hasMarkdownH1 = /(^|\n)#\s+\S/.test(body);
|
|
37
|
+
const hasJsxH1 = /<h1[\s>]/i.test(body);
|
|
38
|
+
if (hasMarkdownH1 || hasJsxH1) {
|
|
39
|
+
add('error', 'BODY_H1', 'Body contains an H1 ("# " or <h1>). The post title is already the page H1 — demote body headings to ## or lower.');
|
|
40
|
+
}
|
|
41
|
+
// --- Meta description ---
|
|
42
|
+
const desc = input.meta_description?.trim();
|
|
43
|
+
if (!desc) {
|
|
44
|
+
add('error', 'MISSING_META_DESCRIPTION', 'Missing meta description.');
|
|
45
|
+
}
|
|
46
|
+
else if (desc.length < descMin) {
|
|
47
|
+
add('warning', 'META_DESCRIPTION_SHORT', `Meta description is ${desc.length} chars (<${descMin}). The ${descMin}–${descMax} band maximises snippet size.`);
|
|
48
|
+
}
|
|
49
|
+
else if (desc.length > descMax) {
|
|
50
|
+
add('warning', 'META_DESCRIPTION_LONG', `Meta description is ${desc.length} chars (>${descMax}) and will be truncated in SERPs.`);
|
|
51
|
+
}
|
|
52
|
+
// --- Composed title length ---
|
|
53
|
+
const seoTitle = (input.meta_title?.trim() || input.title.trim()) + titleSuffix;
|
|
54
|
+
if (seoTitle.length > titleLimit) {
|
|
55
|
+
add('warning', 'TITLE_TOO_LONG', `Rendered title "${seoTitle}" is ${seoTitle.length} chars (>${titleLimit}, incl. suffix). Google truncates ~60 and Bing flags >70.`);
|
|
56
|
+
}
|
|
57
|
+
// --- Softer structural advisories ---
|
|
58
|
+
if (!input.excerpt?.trim()) {
|
|
59
|
+
add('warning', 'MISSING_EXCERPT', 'No excerpt set — one will be auto-generated, but a hand-written excerpt reads better in listings.');
|
|
60
|
+
}
|
|
61
|
+
if (!input.faq || input.faq.length === 0) {
|
|
62
|
+
add('warning', 'NO_FAQ', 'No FAQ items. An FAQ section adds long-tail coverage (and is used across the site).');
|
|
63
|
+
}
|
|
64
|
+
if (extractToc(input.content).length === 0 && wordCount >= minWords) {
|
|
65
|
+
add('warning', 'NO_SUBHEADINGS', 'No subheadings (##) found. Long posts need structure for readers and for heading-based SERP features.');
|
|
66
|
+
}
|
|
67
|
+
return { ok: errors.length === 0, errors, warnings, issues: [...errors, ...warnings], wordCount };
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hazo_blog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "SEO-optimized blogging package: posts, categories, tags, MDX content, and GA4/GSC/Bing-ready SEO.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"types": "./dist/index.client.d.ts",
|
|
15
15
|
"import": "./dist/index.client.js"
|
|
16
16
|
},
|
|
17
|
+
"./lib": {
|
|
18
|
+
"types": "./dist/lib/index.d.ts",
|
|
19
|
+
"import": "./dist/lib/index.js"
|
|
20
|
+
},
|
|
17
21
|
"./seo": {
|
|
18
22
|
"types": "./dist/seo/index.d.ts",
|
|
19
23
|
"import": "./dist/seo/index.js"
|
|
@@ -44,14 +48,15 @@
|
|
|
44
48
|
"build:test-app": "npm run build && cd test-app && npm run build"
|
|
45
49
|
},
|
|
46
50
|
"peerDependencies": {
|
|
47
|
-
"hazo_core": "^1.2.
|
|
48
|
-
"hazo_connect": "^3.9.
|
|
49
|
-
"hazo_api": "^2.
|
|
50
|
-
"hazo_files": "^3.1.
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
51
|
+
"hazo_core": "^1.2.1",
|
|
52
|
+
"hazo_connect": "^3.9.2",
|
|
53
|
+
"hazo_api": "^2.5.1",
|
|
54
|
+
"hazo_files": "^3.1.1",
|
|
55
|
+
"hazo_theme": "^1.0.1",
|
|
56
|
+
"hazo_ui": "^6.0.0",
|
|
57
|
+
"hazo_images": "^1.8.1",
|
|
58
|
+
"hazo_jobs": "^0.15.0",
|
|
59
|
+
"hazo_auth": "^10.8.2",
|
|
55
60
|
"react": "^18.0.0 || ^19.0.0",
|
|
56
61
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
57
62
|
"next": "^14.0.0 || ^16.0.0"
|
|
@@ -65,6 +70,9 @@
|
|
|
65
70
|
},
|
|
66
71
|
"hazo_auth": {
|
|
67
72
|
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"hazo_theme": {
|
|
75
|
+
"optional": true
|
|
68
76
|
}
|
|
69
77
|
},
|
|
70
78
|
"dependencies": {
|
|
@@ -84,10 +92,10 @@
|
|
|
84
92
|
"react-dom": "^19.0.0",
|
|
85
93
|
"next": "^16.0.10",
|
|
86
94
|
"hazo_core": "^1.2.1",
|
|
87
|
-
"hazo_connect": "^3.9.
|
|
95
|
+
"hazo_connect": "^3.9.2",
|
|
88
96
|
"hazo_api": "^2.5.1",
|
|
89
97
|
"hazo_files": "^3.1.1",
|
|
90
|
-
"hazo_ui": "^
|
|
98
|
+
"hazo_ui": "^6.0.0",
|
|
91
99
|
"tailwindcss": "^4.2.4",
|
|
92
100
|
"@tailwindcss/postcss": "^4.2.4",
|
|
93
101
|
"postcss": "^8.4.49"
|