@stacklist-app/brandkit 1.2.2 → 1.2.4

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/README.md CHANGED
@@ -43,6 +43,8 @@ A brandkit guide isn't just a page for people — `build` (and the standalone `e
43
43
 
44
44
  `build` also makes the data **discoverable from the deployed page**: it injects `<link rel="alternate" type="application/json" href="brand.json">` and embeds the brand inline as `<script type="application/json" id="brandkit-brand">`, so an agent that fetches the URL gets structured brand data with zero extra requests.
45
45
 
46
+ And for a person who wants to hand the guide to their own agent, the sidebar shows a **"Using an AI agent?"** callout with a one-click copy-paste prompt — it carries absolute URLs to `brand.json` / `tokens.json` / `brand.md` and tells the agent to read those instead of scraping the page. Hide it with `brand.agentCallout: false`.
47
+
46
48
  A JSON Schema for `config.json` ships at [`config.schema.json`](config.schema.json) — point your editor's `$schema` at it for validation and autocomplete.
47
49
 
48
50
  ## Framework integrations
@@ -71,10 +73,26 @@ export default {
71
73
 
72
74
  **Next.js / plain HTML**: run `brandkit build brand` and serve the resulting directory as a static route.
73
75
 
76
+ ### Serving from a base path
77
+
78
+ brandkit emits page-relative paths, which resolve only when the guide is served from the root or a **trailing-slash** directory URL (`/brand/`). If you serve it at `/brand` with **no** trailing slash — e.g. a Next.js rewrite that maps `/brand` to the built `/brand/index.html` — every relative path resolves against `/` instead, so `styles.css`, `engine.js`, and `config.json` all 404 and the guide renders an empty shell.
79
+
80
+ Set `basePath` in `config.json` to fix this:
81
+
82
+ ```json
83
+ {
84
+ "basePath": "/brand",
85
+ "brand": { "name": "Acme" }
86
+ }
87
+ ```
88
+
89
+ On `brandkit build`, the generated `index.html` then points its stylesheet, engine, and `brand.json` link at `${basePath}/…`, and the engine fetches `${basePath}/config.json` — so the guide loads fully whether or not the serving URL has a trailing slash. The value is normalized (leading slash added, trailing slash stripped). Omitting `basePath` keeps the original page-relative behavior, so existing setups are unaffected.
90
+
74
91
  ## Config
75
92
 
76
93
  `config.json` is the single source of truth. Top-level keys:
77
94
 
95
+ - `basePath` — absolute path the guide is served from (e.g. `/brand`). Omit (or leave empty) when serving at the root or from a trailing-slash directory URL — output stays page-relative and unchanged. See [Serving from a base path](#serving-from-a-base-path).
78
96
  - `brand` — name, tagline, description, version, date; optional `guideLabel` (renames the "Web Style Guide" header/footer label), `headerLogo` and `sidebarLogo` (logo image paths that replace the text wordmark in the header and left menu)
79
97
  - `fonts` — display + body with Google Fonts import; each font takes an optional `fallback` web stand-in for brands whose official typeface isn't web-available (the rendered font stack becomes `'family', 'fallback', sans-serif`, while labels keep the clean family name)
80
98
  - `theme` — CSS variable map (colors, gradients, font vars)
@@ -37,6 +37,7 @@
37
37
  }
38
38
  },
39
39
  "properties": {
40
+ "basePath": { "type": "string", "description": "Absolute base path the guide is served from (e.g. \"/brand\"). Empty = served at root / relative. Set this when serving from a non-trailing-slash URL (e.g. a framework rewrite)." },
40
41
  "brand": {
41
42
  "type": "object",
42
43
  "required": ["name"],
@@ -51,7 +52,8 @@
51
52
  "date": { "type": "string" },
52
53
  "guideLabel": { "type": "string", "description": "Renames the 'Web Style Guide' label in the header and footer." },
53
54
  "headerLogo": { "type": "string", "description": "Path to a logo image shown in the header instead of the text wordmark (use a light/reversed logo)." },
54
- "sidebarLogo": { "type": "string", "description": "Path to a logo image shown at the top of the left menu instead of the brand name (use a dark logo)." }
55
+ "sidebarLogo": { "type": "string", "description": "Path to a logo image shown at the top of the left menu instead of the brand name (use a dark logo)." },
56
+ "agentCallout": { "type": "boolean", "description": "Show the 'Using an AI agent?' callout under the sidebar nav — a copy-paste prompt pointing an agent at the brand.json / tokens.json / brand.md exports. Defaults to true; set false to hide it." }
55
57
  }
56
58
  },
57
59
  "fonts": {
package/dist/engine.js CHANGED
@@ -6,7 +6,13 @@
6
6
  /* ================================================================
7
7
  0. Load config
8
8
  ================================================================ */
9
- var res = await fetch('./config.json');
9
+ // Base path the guide is served from. The build injects
10
+ // window.__BRANDKIT_BASE__ when config.basePath is set (e.g. "/brand"), so a
11
+ // guide served from a non-trailing-slash URL still resolves its assets.
12
+ // Default '.' keeps the original page-relative behavior (dev + root serving).
13
+ var BASE = (typeof window !== 'undefined' && window.__BRANDKIT_BASE__)
14
+ ? (String(window.__BRANDKIT_BASE__).replace(/\/+$/, '') || '.') : '.';
15
+ var res = await fetch(BASE + '/config.json');
10
16
  var config = await res.json();
11
17
  init(config);
12
18
 
@@ -123,6 +129,60 @@
123
129
  }).join('');
124
130
  }
125
131
 
132
+ /* ==============================================================
133
+ 2b. Render agent callout — a paste-ready prompt that points an AI
134
+ agent at the machine-readable exports (brand.json / tokens.json /
135
+ brand.md) so it reads structured brand data instead of scraping the
136
+ rendered page. Opt out with brand.agentCallout === false.
137
+ ============================================================== */
138
+ function renderAgentCallout() {
139
+ var box = document.getElementById('agent-callout');
140
+ if (!box) return;
141
+ if (cfg.brand && cfg.brand.agentCallout === false) return;
142
+
143
+ // Resolve export URLs relative to this page exactly as a link would, so
144
+ // the pasted prompt carries absolute URLs the agent can fetch directly.
145
+ // Clear any userinfo so basic-auth creds in the page URL don't leak into
146
+ // the copied prompt (a relative path already drops the page query/hash).
147
+ function exportUrl(file) {
148
+ // When served from a base path, resolve against it so the prompt
149
+ // carries the real export URLs (page URL may have no trailing slash).
150
+ var ref = (BASE && BASE !== '.') ? BASE + '/' + file : file;
151
+ var u = new URL(ref, location.href);
152
+ u.username = ''; u.password = '';
153
+ return u.href;
154
+ }
155
+ var brandUrl, tokensUrl, mdUrl;
156
+ try {
157
+ brandUrl = exportUrl('brand.json');
158
+ tokensUrl = exportUrl('tokens.json');
159
+ mdUrl = exportUrl('brand.md');
160
+ } catch (_) {
161
+ brandUrl = 'brand.json'; tokensUrl = 'tokens.json'; mdUrl = 'brand.md';
162
+ }
163
+ // Strip control chars / newlines and collapse whitespace so a hostile
164
+ // brand name can't smuggle instructions into the paste-ready agent prompt.
165
+ var name = ((cfg.brand && (cfg.brand.displayName || cfg.brand.name)) || 'this brand')
166
+ .replace(/[\u0000-\u001F\u007F\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2069\uFEFF]/g, ' ').replace(/\s+/g, ' ').trim() || 'this brand';
167
+ var prompt =
168
+ 'This page is a brandkit brand guide for ' + name + '. It publishes ' +
169
+ 'machine-readable brand data — read these instead of scraping the page:\n\n' +
170
+ '- ' + brandUrl + ' — full structured brand: colors (roles + contrast), typography, voice, logos, spacing\n' +
171
+ '- ' + tokensUrl + ' — W3C design tokens (DTCG $type/$value), for code and design tooling\n' +
172
+ '- ' + mdUrl + ' — brand brief: how to stay on-brand\n\n' +
173
+ 'Fetch them, then apply ' + name + "'s colors, type, and voice to what you're building.";
174
+
175
+ box.innerHTML =
176
+ '<div class="sidebar-agent-title">Using an AI agent?</div>' +
177
+ '<p class="sidebar-agent-desc">Paste this so it reads the brand data, not the page.</p>' +
178
+ '<button type="button" class="sidebar-agent-btn" id="agent-callout-btn">Copy agent prompt</button>';
179
+ // Set the payload via property (not an HTML attribute) so no font/brand
180
+ // value needs escaping and newlines survive intact.
181
+ var btn = box.querySelector('#agent-callout-btn');
182
+ if (btn) btn.dataset.copy = prompt;
183
+ box.hidden = false;
184
+ }
185
+
126
186
  /* ==============================================================
127
187
  3. Render colors
128
188
  ============================================================== */
@@ -686,6 +746,13 @@
686
746
  copyText(codeLine.dataset.copy);
687
747
  return;
688
748
  }
749
+
750
+ // Agent callout prompt
751
+ var agentBtn = e.target.closest('#agent-callout-btn');
752
+ if (agentBtn) {
753
+ copyText(agentBtn.dataset.copy);
754
+ return;
755
+ }
689
756
  });
690
757
  }
691
758
 
@@ -886,6 +953,7 @@
886
953
  bootstrap();
887
954
  renderShell();
888
955
  renderNav();
956
+ renderAgentCallout();
889
957
  renderSectionIntros();
890
958
  renderColors();
891
959
  renderGradients();
package/dist/index.html CHANGED
@@ -14,6 +14,7 @@
14
14
  <nav class="sidebar">
15
15
  <div class="sidebar-brand">brandkit</div>
16
16
  <ul class="sidebar-nav" id="nav"></ul>
17
+ <div class="sidebar-agent" id="agent-callout" hidden></div>
17
18
  </nav>
18
19
 
19
20
  <!-- Main Content -->
package/dist/styles.css CHANGED
@@ -129,11 +129,49 @@ body {
129
129
  .sidebar-nav a:hover { color: var(--ink); background: var(--cloud); }
130
130
  .sidebar-nav a.active { color: var(--accent-text); border-left-color: var(--accent-text); background: rgba(var(--accent-rgb), 0.04); font-weight: 500; }
131
131
 
132
+ /* Agent callout — paste-ready prompt that points an AI agent at the exports */
133
+ .sidebar-agent {
134
+ margin: 28px 16px 8px;
135
+ padding: 14px;
136
+ border: 1px solid var(--mist);
137
+ border-radius: 12px;
138
+ background: rgba(var(--accent-rgb), 0.04);
139
+ }
140
+ .sidebar-agent-title {
141
+ font-size: 12px;
142
+ font-weight: 600;
143
+ color: var(--ink);
144
+ margin-bottom: 4px;
145
+ }
146
+ .sidebar-agent-desc {
147
+ font-size: 11px;
148
+ line-height: 1.4;
149
+ color: var(--slate);
150
+ margin: 0 0 10px;
151
+ }
152
+ .sidebar-agent-btn {
153
+ display: block;
154
+ width: 100%;
155
+ padding: 7px 10px;
156
+ font-size: 12px;
157
+ font-weight: 500;
158
+ font-family: inherit;
159
+ color: var(--primary-foreground);
160
+ background: var(--primary);
161
+ border: none;
162
+ border-radius: 8px;
163
+ cursor: pointer;
164
+ transition: filter 150ms ease;
165
+ }
166
+ .sidebar-agent-btn:hover { filter: brightness(0.9); }
167
+ .sidebar-agent-btn:active { transform: translateY(1px); }
168
+ .sidebar-agent-btn:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
169
+
132
170
  /* ── Sidebar Grouped Navigation ── */
133
171
  .nav-group {
134
172
  padding: 20px 24px 6px;
135
173
  font-family: var(--font-body), sans-serif;
136
- font-size: 10px;
174
+ font-size: 12px;
137
175
  font-weight: 700;
138
176
  letter-spacing: 0.10em;
139
177
  text-transform: uppercase;
package/lib/template.js CHANGED
@@ -42,6 +42,32 @@ function generateRootCSS(config) {
42
42
  return lines.join('\n');
43
43
  }
44
44
 
45
+ /**
46
+ * Escape a string for safe interpolation inside a double-quoted HTML attribute.
47
+ * A legitimate URL path never contains these chars, so real basePaths pass
48
+ * through unchanged; a hostile value can't break out of the attribute.
49
+ */
50
+ function escapeAttr(s) {
51
+ return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
52
+ .replace(/</g, '&lt;').replace(/>/g, '&gt;');
53
+ }
54
+
55
+ /**
56
+ * Normalize config.basePath into an absolute, no-trailing-slash prefix.
57
+ * "" / undefined / "/" → "" (served at root, page-relative — unchanged).
58
+ * "brand" or "/brand/" → "/brand".
59
+ */
60
+ function normalizeBasePath(bp) {
61
+ if (!bp || typeof bp !== 'string') return '';
62
+ var s = bp.trim();
63
+ if (!s) return '';
64
+ // Exactly one leading slash: collapse "//brand" (a protocol-relative URL —
65
+ // browsers read it as a cross-origin host) and "brand" alike to "/brand".
66
+ s = '/' + s.replace(/^\/+/, '');
67
+ s = s.replace(/\/+$/, '');
68
+ return s;
69
+ }
70
+
45
71
  /**
46
72
  * Generate a Google Fonts <link> tag from config.fonts.
47
73
  */
@@ -64,6 +90,11 @@ function generateFontsLink(config) {
64
90
  * Writes index.html and styles.css into the target directory.
65
91
  */
66
92
  function build(distDir, targetDir, config) {
93
+ // Absolute base path the guide is served from (e.g. "/brand"). Empty = root /
94
+ // page-relative, in which case nothing below is rewritten (byte-identical to
95
+ // the pre-basePath output). The "/" + file form (no trailing slash on basePath)
96
+ // is what lets a framework rewrite like /brand → /brand/index.html resolve.
97
+ var basePath = normalizeBasePath(config.basePath);
67
98
  // Build styles.css: append the config :root AFTER the stylesheet's default
68
99
  // tokens so brand overrides win the cascade (styles.css ships a baseline
69
100
  // :root; an earlier block would be overridden by it).
@@ -81,10 +112,34 @@ function build(distDir, targetDir, config) {
81
112
  var fontsLink = generateFontsLink(config);
82
113
  var brandName = (config.brand && config.brand.displayName) || 'Brand';
83
114
 
84
- // Inject fonts link + a discovery <link> for the structured brand data
85
- var headInject = (fontsLink ? fontsLink + '\n' : '') +
86
- '<link rel="alternate" type="application/json" href="brand.json" title="Brand data">\n';
87
- htmlTemplate = htmlTemplate.replace('</head>', headInject + '</head>');
115
+ // Inject fonts link + a discovery <link> for the structured brand data.
116
+ // When a basePath is set, prefix the brandkit-generated refs (stylesheet,
117
+ // engine, brand.json) so they resolve from a non-trailing-slash URL; also
118
+ // expose the base to the runtime engine via window.__BRANDKIT_BASE__.
119
+ // The Google Fonts link and author-supplied logo paths are left untouched.
120
+ // basePath is author-controlled config, so it's hardened before it lands in
121
+ // markup, each context with its own escaping:
122
+ // - inline <script>: JSON.stringify + "<" → <, so a "</script>" in the
123
+ // value can't break out of the script element (JS-string context).
124
+ // - href/src attributes: HTML-attribute-escaped (basePathAttr), so a stray
125
+ // quote can't break out of the attribute and inject a handler.
126
+ // - all .replace() calls below use function replacers, so a "$" in the path
127
+ // isn't interpreted as a $-replacement pattern.
128
+ var basePathLiteral = JSON.stringify(basePath).replace(/</g, '\\u003C');
129
+ var basePathAttr = escapeAttr(basePath);
130
+ var brandJsonHref = basePath ? basePathAttr + '/brand.json' : 'brand.json';
131
+ var headInject = (basePath ? '<script>window.__BRANDKIT_BASE__ = ' + basePathLiteral + ';</script>\n' : '') +
132
+ (fontsLink ? fontsLink + '\n' : '') +
133
+ '<link rel="alternate" type="application/json" href="' + brandJsonHref + '" title="Brand data">\n';
134
+ htmlTemplate = htmlTemplate.replace('</head>', function () { return headInject + '</head>'; });
135
+
136
+ if (basePath) {
137
+ // Tolerant of quote style / whitespace around "=" so a future reformat of
138
+ // the template doesn't silently no-op and ship broken (root-relative) refs.
139
+ htmlTemplate = htmlTemplate
140
+ .replace(/href\s*=\s*["']styles\.css["']/, function () { return 'href="' + basePathAttr + '/styles.css"'; })
141
+ .replace(/src\s*=\s*["']engine\.js["']/, function () { return 'src="' + basePathAttr + '/engine.js"'; });
142
+ }
88
143
 
89
144
  // Set title
90
145
  htmlTemplate = htmlTemplate.replace(
@@ -116,5 +171,6 @@ function build(distDir, targetDir, config) {
116
171
  module.exports = {
117
172
  generateRootCSS: generateRootCSS,
118
173
  generateFontsLink: generateFontsLink,
174
+ normalizeBasePath: normalizeBasePath,
119
175
  build: build
120
176
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stacklist-app/brandkit",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "Config-driven brand guide that bolts onto any website. Zero dependencies.",
5
5
  "main": "lib/resolve.js",
6
6
  "bin": {