@bytesbrains/weblocks 0.6.0 → 0.6.2

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/CATALOG.md CHANGED
@@ -1,4 +1,4 @@
1
- # @bytesbrains/weblocks — Block Catalog (v0.6.0)
1
+ # @bytesbrains/weblocks — Block Catalog (v0.6.2)
2
2
 
3
3
  The AI composes a `SiteManifest` (`{ meta, design, blocks[] }`) using **only** the block types below, then the engine validates + renders it to static HTML. This file is generated from the code (`npm run emit:catalog`) — do not edit by hand.
4
4
 
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ follows [semantic versioning](https://semver.org): the **block catalog** and the
5
5
  **`SiteManifest` shape** are the public contract — additive block/field changes
6
6
  are minor, breaking changes to either are major.
7
7
 
8
+ ## 0.6.2 — 2026-07-17
9
+
10
+ Bug fix: in-page nav links now scroll.
11
+
12
+ ### Fixed
13
+ - **In-page nav links now scroll (#26).** `renderSite` previously emitted sections
14
+ without ids, so `#about` / `#menu` / `#contact` resolved to nothing on every
15
+ generated site. It now emits a stable, de-duped anchor `id` on each content
16
+ section (a canonical slug — e.g. `contact-details` → `#contact`,
17
+ `services-catalogue` → `#services`, `testimonials` → `#reviews`), and `nav`
18
+ (plus its CTA) resolves links by `#hash`, label, or a common alias
19
+ (`Menu` → services, `Get in touch` → contact, …). External/relative links pass
20
+ through; unresolved links fall back to `#` (top), never a dead anchor. Chrome
21
+ and rhythm blocks get no anchor. **Render-side, so re-rendering an existing
22
+ manifest just works** — no manifest change needed.
23
+
24
+ ### Added
25
+ - `slugify` exported (`'About Us!'` → `'about-us'`, folds diacritics).
26
+
27
+ ## 0.6.1 — 2026-07-16
28
+
29
+ Docs only — no engine changes.
30
+
31
+ ### Added
32
+ - A runnable **résumé example** (`npm run example:resume` → `resume-output.html`)
33
+ demonstrating the résumé pack + print-to-PDF end to end, and a README pointer
34
+ to it.
35
+
8
36
  ## 0.6.0 — 2026-07-16
9
37
 
10
38
  A résumé/CV builder, hero image banners, and favicons. Additive and
package/README.md CHANGED
@@ -298,8 +298,9 @@ typed schema (no raw-HTML field), consumes shared tokens, renders totally
298
298
  npm install # dev deps only (typescript, @types/node)
299
299
  npm run build # tsc → lib/
300
300
  npm test # block definition-of-done + engine invariants
301
- npm run example # render a sample → example-output.html
302
- npm run emit:catalog # regenerate catalog.json + CATALOG.md from code
301
+ npm run example # render a sample landing page → example-output.html
302
+ npm run example:resume # render a live résumé/CV → resume-output.html (try its Download-PDF)
303
+ npm run emit:catalog # regenerate catalog.json + CATALOG.md from code
303
304
  ```
304
305
 
305
306
  Drive it end-to-end with a real model (dev harness — provider is env, not code):
package/catalog.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "package": "@bytesbrains/weblocks",
4
- "version": "0.6.0",
4
+ "version": "0.6.2",
5
5
  "description": "Block catalog for @bytesbrains/weblocks. Send this to the model as its API reference; it composes a SiteManifest ({ meta, design, blocks[] }) using ONLY these block types. The engine then validates and renders it.",
6
6
  "blockTypes": [
7
7
  "app-shell",
@@ -0,0 +1,11 @@
1
+ import type { Block } from './types.js';
2
+ export interface Anchors {
3
+ /** blockId → the anchor id emitted on its section (absent = skipped). */
4
+ idFor: Map<string, string>;
5
+ /** Resolve a nav/CTA link (by hash, label, or alias) to a working href. */
6
+ resolve(href: unknown, label?: string): string;
7
+ }
8
+ /** Assign a unique anchor id to each anchorable block and build a link resolver. */
9
+ export declare function buildAnchors(blocks: Block[]): Anchors;
10
+ /** Insert an anchor `id` into the first element of a block's rendered HTML. */
11
+ export declare function injectAnchorId(html: string, id: string): string;
package/lib/anchors.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * In-page anchors — makes nav links actually scroll (issue #26).
3
+ *
4
+ * `renderSite` emits sections without ids, so `#about` / `#menu` resolve to
5
+ * nothing. This module computes a stable, de-duped anchor id per content section
6
+ * (a canonical slug that can differ from the block's short CSS class, e.g.
7
+ * `contact-details` → `contact`, `services-catalogue` → `services`) and resolves
8
+ * a nav/CTA link — by its `#hash`, its label, or a common alias — to a real id.
9
+ * Chrome blocks (nav, app-shell, sidebar, announcement-bar) and pure rhythm
10
+ * blocks get no anchor. Unresolved links fall back to `#` (top), never a dead
11
+ * anchor. Render-side, so re-rendering an existing manifest just works.
12
+ */
13
+ import { slugify } from './schema.js';
14
+ const SKIP = new Set(['nav', 'app-shell', 'sidebar', 'announcement-bar', 'divider', 'spacer']);
15
+ /** Canonical anchor slug per block type (default = the type name). */
16
+ const SECTION_SLUG = {
17
+ hero: 'home', 'hero-app': 'home', 'profile-header': 'home',
18
+ about: 'about',
19
+ 'contact-details': 'contact', 'contact-form': 'contact',
20
+ 'services-catalogue': 'services',
21
+ testimonials: 'reviews',
22
+ 'blog-list': 'blog', 'blog-post': 'blog',
23
+ 'video-gallery': 'videos',
24
+ };
25
+ /** Common nav-label slugs → the canonical section slug they mean. */
26
+ const ALIAS = {
27
+ menu: 'services', catalog: 'services', catalogue: 'services', products: 'services', shop: 'services',
28
+ story: 'about', 'our-story': 'about', 'about-us': 'about', bio: 'about', 'who-we-are': 'about',
29
+ 'get-in-touch': 'contact', 'contact-us': 'contact', 'reach-us': 'contact', reach: 'contact', hire: 'contact', 'hire-me': 'contact',
30
+ testimonials: 'reviews', feedback: 'reviews',
31
+ work: 'gallery', portfolio: 'gallery', photos: 'gallery',
32
+ questions: 'faq', help: 'faq',
33
+ plans: 'pricing', prices: 'pricing',
34
+ };
35
+ function sectionSlug(type) {
36
+ if (SKIP.has(type))
37
+ return null;
38
+ return SECTION_SLUG[type] ?? type;
39
+ }
40
+ /** Assign a unique anchor id to each anchorable block and build a link resolver. */
41
+ export function buildAnchors(blocks) {
42
+ const idFor = new Map();
43
+ const ids = new Set(); // every emitted anchor id
44
+ const slugToId = new Map(); // canonical slug → first emitted id of that type
45
+ for (const b of blocks) {
46
+ const base = b && sectionSlug(b.type);
47
+ if (!base || !b.id)
48
+ continue;
49
+ let id = base;
50
+ for (let n = 2; ids.has(id); n++)
51
+ id = `${base}-${n}`; // de-dup repeats
52
+ ids.add(id);
53
+ idFor.set(b.id, id);
54
+ if (!slugToId.has(base))
55
+ slugToId.set(base, id);
56
+ }
57
+ // A slug → an existing anchor id: exact id (incl. de-duped like `features-2`),
58
+ // canonical slug, or a common alias. '' when nothing matches.
59
+ const lookup = (key) => (ids.has(key) ? key : slugToId.get(key) ?? slugToId.get(ALIAS[key] ?? '') ?? '');
60
+ const resolve = (href, label = '') => {
61
+ const raw = String(href ?? '').trim();
62
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw))
63
+ return raw; // scheme (http/mailto/tel/…) → external, untouched
64
+ if (raw && !raw.startsWith('#'))
65
+ return raw; // relative path/query → untouched
66
+ const key = slugify(raw.startsWith('#') && raw.length > 1 ? raw.slice(1) : label); // hash text, else label
67
+ if (!key || key === 'home' || key === 'top' || key === 'start')
68
+ return '#'; // Home/Top → page top
69
+ const id = lookup(key);
70
+ return id ? `#${id}` : '#'; // resolved anchor, else top (never a dead anchor)
71
+ };
72
+ return { idFor, resolve };
73
+ }
74
+ /** Insert an anchor `id` into the first element of a block's rendered HTML. */
75
+ export function injectAnchorId(html, id) {
76
+ return html.replace(/^(\s*<[a-zA-Z][\w-]*)(\s|>)/, (_m, open, next) => `${open} id="${id}"${next}`);
77
+ }
package/lib/blocks/nav.js CHANGED
@@ -19,16 +19,19 @@ const css = `
19
19
  .blk-nav .links a:hover{color:var(--text)}
20
20
  .blk-nav .cta{background:var(--primary);color:var(--on-primary);padding:.5em 1.1em;border-radius:var(--radius);text-decoration:none;font-weight:600}
21
21
  `.trim();
22
- function render(config) {
22
+ function render(config, _tokens, ctx) {
23
23
  const brand = config.brand;
24
24
  const sticky = config.sticky;
25
25
  const links = config.links ?? [];
26
26
  const cta = config.cta;
27
- const linkHtml = links.map((l) => `<a href="${escapeAttr(sanitizeUrl(l.href))}">${escapeHtml(l.label)}</a>`).join('');
27
+ // Resolve in-page links to real section anchors (falls back to plain sanitize
28
+ // when rendered without a page context, e.g. a single block in isolation).
29
+ const href = (l) => escapeAttr(sanitizeUrl(ctx?.resolveLink ? ctx.resolveLink(l.href, l.label) : l.href));
30
+ const linkHtml = links.map((l) => `<a href="${href(l)}">${escapeHtml(l.label)}</a>`).join('');
28
31
  return `<nav class="blk-nav" data-sticky="${sticky ? 'true' : 'false'}" aria-label="Primary">
29
32
  <div class="wrap">
30
33
  <a class="brand" href="#">${escapeHtml(brand)}</a>
31
- <div class="links">${linkHtml}${cta.label ? `<a class="cta" href="${escapeAttr(sanitizeUrl(cta.href))}">${escapeHtml(cta.label)}</a>` : ''}</div>
34
+ <div class="links">${linkHtml}${cta.label ? `<a class="cta" href="${href(cta)}">${escapeHtml(cta.label)}</a>` : ''}</div>
32
35
  </div>
33
36
  </nav>`;
34
37
  }
package/lib/index.d.ts CHANGED
@@ -13,7 +13,7 @@ export { renderSite, type RenderOptions } from './render.js';
13
13
  export { NOOP_RUNTIME, pathRuntime, runtimeNeeds, type RuntimeAdapter, type RuntimeAction, type BlockRuntimeNeeds, } from './runtime.js';
14
14
  export { buildWebManifest, buildWebManifestJson, buildServiceWorker, emitPwa, type WebAppManifest, } from './pwa.js';
15
15
  export { validateBlock, validateManifest, type Validation } from './validate.js';
16
- export { parse, escapeHtml, escapeAttr, sanitizeUrl, type Field, type Schema, type ParseResult } from './schema.js';
16
+ export { parse, escapeHtml, escapeAttr, sanitizeUrl, slugify, type Field, type Schema, type ParseResult } from './schema.js';
17
17
  export { renderMarkdown } from './markdown.js';
18
18
  export { catalog, catalogPrompt, type BlockCatalogEntry, type JsonSchema } from './catalog.js';
19
19
  export { applyOp, applyOps, type EditOp, type OpResult, type BatchResult } from './ops.js';
package/lib/index.js CHANGED
@@ -5,7 +5,7 @@ export { renderSite } from './render.js';
5
5
  export { NOOP_RUNTIME, pathRuntime, runtimeNeeds, } from './runtime.js';
6
6
  export { buildWebManifest, buildWebManifestJson, buildServiceWorker, emitPwa, } from './pwa.js';
7
7
  export { validateBlock, validateManifest } from './validate.js';
8
- export { parse, escapeHtml, escapeAttr, sanitizeUrl } from './schema.js';
8
+ export { parse, escapeHtml, escapeAttr, sanitizeUrl, slugify } from './schema.js';
9
9
  export { renderMarkdown } from './markdown.js';
10
10
  export { catalog, catalogPrompt } from './catalog.js';
11
11
  export { applyOp, applyOps } from './ops.js';
package/lib/registry.d.ts CHANGED
@@ -16,6 +16,12 @@ export interface RenderContext {
16
16
  id: string;
17
17
  /** Host-provided runtime; `resolve` returns null when unwired (inert render). */
18
18
  runtime: RuntimeAdapter;
19
+ /**
20
+ * Resolve an in-page nav/CTA link (by `#hash`, label, or alias) to a working
21
+ * href against the page's section anchor ids. External/relative links pass
22
+ * through; unresolved links fall back to `#`. Used by navigation bricks.
23
+ */
24
+ resolveLink?: (href: unknown, label?: string) => string;
19
25
  }
20
26
  export interface BlockSpec {
21
27
  type: string;
package/lib/render.js CHANGED
@@ -13,6 +13,7 @@ import { escapeAttr, escapeHtml, parse, sanitizeUrl } from './schema.js';
13
13
  import { getSpec, needsIsland, REGISTRY } from './registry.js';
14
14
  import { normalizeTokens, sectionOverrideCss, tokensToCss } from './tokens.js';
15
15
  import { NOOP_RUNTIME } from './runtime.js';
16
+ import { buildAnchors, injectAnchorId } from './anchors.js';
16
17
  const RESET_CSS = `
17
18
  *,*::before,*::after{box-sizing:border-box}
18
19
  body{margin:0;font-family:var(--font);font-size:var(--fs-base);color:var(--text);background:var(--bg);line-height:1.5;-webkit-font-smoothing:antialiased}
@@ -25,14 +26,18 @@ img{max-width:100%}
25
26
  // un-stick fixed chrome, and avoid splitting entries across pages.
26
27
  const PRINT_CSS = `@media print{*{-webkit-print-color-adjust:exact;print-color-adjust:exact}[data-wl-noprint]{display:none!important}.blk-nav,.blk-app-shell,.blk-announcement-bar,.blk-sidebar{position:static!important}.blk-profile-header,.blk-experience .entry,.blk-skills .group,.blk-timeline li{break-inside:avoid}a[href]{text-decoration:none}@page{margin:1.4cm}}`;
27
28
  /** Render one block: normalize its config, then hand markup to the brick. */
28
- function renderBlock(block, manifest, runtime) {
29
+ function renderBlock(block, manifest, runtime, anchors) {
29
30
  const spec = getSpec(block.type);
30
31
  if (!spec)
31
32
  return ''; // unknown type → skip (closed vocabulary; total)
32
33
  const { value } = parse(spec.schema, block.config ?? {});
33
34
  const tokens = normalizeTokens(manifest.design);
34
- const ctx = { id: block.id, runtime };
35
+ const ctx = { id: block.id, runtime, resolveLink: anchors.resolve };
35
36
  let html = spec.render(value, tokens, ctx);
37
+ // In-page anchor: emit a stable id on the section so nav links can scroll to it.
38
+ const anchorId = anchors.idFor.get(block.id);
39
+ if (anchorId)
40
+ html = injectAnchorId(html, anchorId);
36
41
  // Opt-in per-section overrides: scope palette/radius/spacing as inherited CSS
37
42
  // vars on a wrapper (escaped for the style attribute).
38
43
  const style = sectionOverrideCss(block.overrides);
@@ -52,7 +57,8 @@ export function renderSite(manifest, options = {}) {
52
57
  .filter((s) => usedTypes.has(s.type) && s.css)
53
58
  .map((s) => s.css)
54
59
  .join('\n');
55
- const body = blocks.map((b) => renderBlock(b, manifest, runtime)).filter(Boolean).join('\n');
60
+ const anchors = buildAnchors(blocks);
61
+ const body = blocks.map((b) => renderBlock(b, manifest, runtime, anchors)).filter(Boolean).join('\n');
56
62
  // Islands to hydrate. Static-only pages emit none.
57
63
  const islands = new Set();
58
64
  for (const b of blocks) {
package/lib/schema.d.ts CHANGED
@@ -65,3 +65,9 @@ export declare function escapeAttr(s: unknown): string;
65
65
  * attribute context.
66
66
  */
67
67
  export declare function sanitizeUrl(url: unknown): string;
68
+ /**
69
+ * A URL-fragment slug from arbitrary text: `'About Us!'` → `'about-us'`.
70
+ * Diacritics are folded (`résumé` → `resume`). Used for section anchor ids and
71
+ * nav in-page link resolution.
72
+ */
73
+ export declare function slugify(s: unknown): string;
package/lib/schema.js CHANGED
@@ -148,3 +148,18 @@ export function sanitizeUrl(url) {
148
148
  return '#';
149
149
  return s;
150
150
  }
151
+ /**
152
+ * A URL-fragment slug from arbitrary text: `'About Us!'` → `'about-us'`.
153
+ * Diacritics are folded (`résumé` → `resume`). Used for section anchor ids and
154
+ * nav in-page link resolution.
155
+ */
156
+ export function slugify(s) {
157
+ return String(s ?? '')
158
+ .normalize('NFKD').replace(/[̀-ͯ]/g, '') // fold accents
159
+ .toLowerCase()
160
+ .replace(/[^a-z0-9\s_-]/g, '') // drop other punctuation
161
+ .trim()
162
+ .replace(/[\s_]+/g, '-') // spaces/underscores → hyphen
163
+ .replace(/-+/g, '-') // collapse
164
+ .replace(/^-+|-+$/g, ''); // trim hyphens
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytesbrains/weblocks",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Block engine for AI-composable web apps — the AI composes a SiteManifest from a fixed block catalog; the engine validates and renders it to static HTML. Snap-together \"Lego\" web building blocks, shareable across repos.",
5
5
  "keywords": [
6
6
  "ai",
@@ -37,7 +37,7 @@
37
37
  "lib",
38
38
  "!lib/**/*.test.js",
39
39
  "!lib/**/*.test.d.ts",
40
- "!lib/example.*",
40
+ "!lib/example*",
41
41
  "catalog.json",
42
42
  "CATALOG.md",
43
43
  "AGENT.md",
@@ -61,6 +61,7 @@
61
61
  "emit:catalog": "node scripts/emit-catalog.mjs",
62
62
  "test": "npm run build && node --test lib/*.test.js test/*.mjs",
63
63
  "example": "tsc -p tsconfig.json && node lib/example.js",
64
+ "example:resume": "tsc -p tsconfig.json && node lib/example-resume.js",
64
65
  "ai": "tsc -p tsconfig.json && node scripts/ai-run.mjs",
65
66
  "prepare": "npm run build",
66
67
  "prepublishOnly": "npm run build && npm run emit:catalog && npm test"