@bytesbrains/weblocks 0.6.1 → 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 +1 -1
- package/CHANGELOG.md +19 -0
- package/catalog.json +1 -1
- package/lib/anchors.d.ts +11 -0
- package/lib/anchors.js +77 -0
- package/lib/blocks/nav.js +6 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/registry.d.ts +6 -0
- package/lib/render.js +9 -3
- package/lib/schema.d.ts +6 -0
- package/lib/schema.js +15 -0
- package/package.json +1 -1
package/CATALOG.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @bytesbrains/weblocks — Block Catalog (v0.6.
|
|
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,25 @@ 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
|
+
|
|
8
27
|
## 0.6.1 — 2026-07-16
|
|
9
28
|
|
|
10
29
|
Docs only — no engine changes.
|
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.
|
|
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",
|
package/lib/anchors.d.ts
ADDED
|
@@ -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
|
-
|
|
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="${
|
|
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
|
|
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.
|
|
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",
|