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 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` (converts HTML comments MDX
212
- comments, escapes stray `<!`) before passing it to the compiler. The sanitizer is also available
213
- as a standalone export from `hazo_blog/lib` for custom rendering pipelines.
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
@@ -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)
@@ -1,2 +1,3 @@
1
1
  export * from "./text.js";
2
+ export * from "./validate.js";
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -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
@@ -1,2 +1,3 @@
1
1
  // hazo_blog/src/lib/index.ts — core library exports (server-safe).
2
2
  export * from "./text.js";
3
+ export * from "./validate.js";
@@ -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. MDX has no HTML comments a `<!--` in prose
27
- * makes the parser expect a JSX tag name after `<` and throw "Unexpected
28
- * character `!` before name", crashing the whole Next build at prerender.
29
- * Convert HTML comments to MDX comments ({/*…*\/}, equally non-rendering) and
30
- * escape any stray `<!`, while leaving fenced/inline code untouched (it is
31
- * literal in MDX, never errors, and may legitimately show an HTML comment in a
32
- * sample). Idempotent: after one pass no literal `<!` survives in prose.
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
@@ -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;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAkB/C"}
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. MDX has no HTML comments a `<!--` in prose
72
- * makes the parser expect a JSX tag name after `<` and throw "Unexpected
73
- * character `!` before name", crashing the whole Next build at prerender.
74
- * Convert HTML comments to MDX comments ({/*…*\/}, equally non-rendering) and
75
- * escape any stray `<!`, while leaving fenced/inline code untouched (it is
76
- * literal in MDX, never errors, and may legitimately show an HTML comment in a
77
- * sample). Idempotent: after one pass no literal `<!` survives in prose.
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, "&lt;!");
118
+ .replace(/<!(?!--)/g, "&lt;!")
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.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.0",
48
- "hazo_connect": "^3.9.0",
49
- "hazo_api": "^2.4.0",
50
- "hazo_files": "^3.1.0",
51
- "hazo_ui": "^4.6.2",
52
- "hazo_images": "^1.2.1",
53
- "hazo_jobs": "^0.12.0",
54
- "hazo_auth": "^10.0.0",
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.0",
95
+ "hazo_connect": "^3.9.2",
88
96
  "hazo_api": "^2.5.1",
89
97
  "hazo_files": "^3.1.1",
90
- "hazo_ui": "^4.7.0",
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"