hazo_blog 0.3.3 → 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,44 @@
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
+
6
44
  ## 0.3.3 — 2026-07-06
7
45
 
8
46
  **`sanitizeMdx` hardened against raw WordPress HTML**
package/README.md CHANGED
@@ -224,7 +224,7 @@ pipelines.
224
224
  | `hazo_blog` | server: `createBlogService`, `createBlogRepository`, SEO builders, **route-handler factories** (React-free), types |
225
225
  | `hazo_blog/client` | client components: `PostCard`, `PostHero`, `AuthorBio`, `FaqSection`, `TableOfContents`, `RelatedPosts`, `BlogSearch`, `PostForm`, MDX components, `trackBlogEvent` |
226
226
  | `hazo_blog/next` | sealed page factories: public (`createBlogIndexPage`, `createBlogPostPage`, `createBlogTagPage`) + admin (`createBlogAdminListPage`, `createBlogAdminNewPage`, `createBlogAdminEditPage`) + `BlogContent` + `MdxErrorBoundary` |
227
- | `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) |
228
228
  | `hazo_blog/seo` | `buildBlogPostingJsonLd`, `buildBreadcrumbJsonLd`, `buildFaqJsonLd`, `getBlogSitemapEntries`, `getBlogRobotsRules`, `getBlogRssXml` |
229
229
  | `hazo_blog/config` | `BlogConfig` types + `resolveConfig` |
230
230
 
@@ -244,6 +244,28 @@ searchApiPath: "/api/blog/search", // default
244
244
  See `SETUP_CHECKLIST.md` for a step-by-step integration list. A runnable demo
245
245
  lives in `test-app/`.
246
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
+
247
269
  ## License
248
270
 
249
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";
@@ -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.3",
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"
@@ -45,13 +49,14 @@
45
49
  },
46
50
  "peerDependencies": {
47
51
  "hazo_core": "^1.2.1",
48
- "hazo_connect": "^3.9.0",
52
+ "hazo_connect": "^3.9.2",
49
53
  "hazo_api": "^2.5.1",
50
54
  "hazo_files": "^3.1.1",
51
- "hazo_ui": "^4.8.0",
52
- "hazo_images": "^1.7.0",
53
- "hazo_jobs": "^0.14.0",
54
- "hazo_auth": "^10.5.0",
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.8.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"