@africode/core 5.0.0 → 5.0.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.
@@ -56,7 +56,7 @@ export class AfriCodeComponent extends HTMLElement {
56
56
  /**
57
57
  * Load and apply component styles
58
58
  */
59
- loadStyles(css?: string): void;
59
+ loadStyles(css?: string): Promise<void>;
60
60
 
61
61
  /**
62
62
  * Render component to shadow DOM
@@ -9,11 +9,14 @@
9
9
 
10
10
  import { html } from '../core/html.js';
11
11
 
12
+ const BaseHTMLElement = globalThis.HTMLElement ?? class {};
13
+ const hasConstructableStylesheets = typeof CSSStyleSheet !== 'undefined';
14
+
12
15
  /**
13
16
  * Base class for AfriCode components
14
17
  * All components extend this to inherit common functionality
15
18
  */
16
- export class AfriCodeComponent extends HTMLElement {
19
+ export class AfriCodeComponent extends BaseHTMLElement {
17
20
  constructor() {
18
21
  super();
19
22
  this.attachShadow({ mode: 'open' });
@@ -84,8 +87,11 @@ export class AfriCodeComponent extends HTMLElement {
84
87
  * @param {CSSStyleSheet} sheet - A Constructable Stylesheet instance
85
88
  */
86
89
  static injectGlobalSheet(sheet) {
90
+ if (!hasConstructableStylesheets) {
91
+ return;
92
+ }
93
+
87
94
  if (!(sheet instanceof CSSStyleSheet)) {
88
- console.error('[AfriCode] injectGlobalSheet requires a CSSStyleSheet instance.');
89
95
  return;
90
96
  }
91
97
  if (!AfriCodeComponent.globalSheets.includes(sheet)) {
@@ -98,37 +104,77 @@ export class AfriCodeComponent extends HTMLElement {
98
104
  * Optimized to fetch only once per session.
99
105
  * Also adopts any globally injected sheets (e.g. Tailwind utilities).
100
106
  */
101
- async loadStyles() {
107
+ async loadStyles(css = '') {
102
108
  const styleUrl = '/styles/africanity.css';
109
+ if (!this.shadowRoot) {
110
+ return;
111
+ }
112
+
113
+ const canAdoptStyleSheets = hasConstructableStylesheets && 'adoptedStyleSheets' in this.shadowRoot;
114
+ const sourceKey = typeof css === 'string' && css.length > 0 ? css : styleUrl;
115
+ const stylesheetText = typeof css === 'string' && css.length > 0 ? css : null;
103
116
 
104
117
  // 1. Check cache first
105
- if (AfriCodeComponent.styleCache.has(styleUrl)) {
106
- this.shadowRoot.adoptedStyleSheets = [
107
- AfriCodeComponent.styleCache.get(styleUrl),
108
- ...AfriCodeComponent.globalSheets
109
- ];
118
+ if (AfriCodeComponent.styleCache.has(sourceKey)) {
119
+ const cached = AfriCodeComponent.styleCache.get(sourceKey);
120
+ if (canAdoptStyleSheets) {
121
+ this.shadowRoot.adoptedStyleSheets = [
122
+ cached,
123
+ ...AfriCodeComponent.globalSheets
124
+ ];
125
+ } else {
126
+ this._applyFallbackStyle(cached?.cssText || cached?.text || cached || '');
127
+ }
110
128
  return;
111
129
  }
112
130
 
113
131
  // 2. Fetch if not cached (Single Request)
114
132
  try {
115
- const response = await fetch(styleUrl);
116
- const css = await response.text();
133
+ let resolvedCss = stylesheetText;
117
134
 
118
- const styleSheet = new CSSStyleSheet();
119
- styleSheet.replaceSync(css);
135
+ if (!resolvedCss) {
136
+ const response = await fetch(styleUrl);
137
+ if (!response.ok) {
138
+ throw new Error(`Unable to load stylesheet: ${response.status}`);
139
+ }
140
+ resolvedCss = await response.text();
141
+ }
142
+
143
+ if (canAdoptStyleSheets) {
144
+ const styleSheet = new CSSStyleSheet();
145
+ styleSheet.replaceSync(resolvedCss);
146
+
147
+ // Cache it
148
+ AfriCodeComponent.styleCache.set(sourceKey, styleSheet);
149
+
150
+ // Apply core + any global sheets
151
+ this.shadowRoot.adoptedStyleSheets = [
152
+ styleSheet,
153
+ ...AfriCodeComponent.globalSheets
154
+ ];
155
+ return;
156
+ }
157
+
158
+ this._applyFallbackStyle(resolvedCss);
159
+ AfriCodeComponent.styleCache.set(sourceKey, { text: resolvedCss });
160
+ } catch (err) {
161
+ this._applyFallbackStyle(stylesheetText || '');
162
+ }
163
+ }
120
164
 
121
- // Cache it
122
- AfriCodeComponent.styleCache.set(styleUrl, styleSheet);
165
+ _applyFallbackStyle(css) {
166
+ if (!this.shadowRoot) {
167
+ return;
168
+ }
123
169
 
124
- // Apply core + any global sheets
125
- this.shadowRoot.adoptedStyleSheets = [
126
- styleSheet,
127
- ...AfriCodeComponent.globalSheets
128
- ];
129
- } catch (err) {
130
- console.warn('AfriCode: Failed to load core styles', err);
170
+ let style = this.shadowRoot.querySelector('style[data-africode-core-styles]');
171
+ if (!style) {
172
+ style = document.createElement('style');
173
+ style.setAttribute('data-africode-core-styles', '');
174
+ this.shadowRoot.prepend(style);
131
175
  }
176
+
177
+ style.textContent = css;
132
178
  }
133
179
 
134
180
  /**
@@ -175,6 +221,10 @@ export class AfriCodeComponent extends HTMLElement {
175
221
  * @param {typeof HTMLElement} component - Component class
176
222
  */
177
223
  export function registerComponent(name, component) {
224
+ if (typeof customElements === 'undefined') {
225
+ return;
226
+ }
227
+
178
228
  if (!customElements.get(name)) {
179
229
  customElements.define(name, component);
180
230
  }
@@ -170,7 +170,7 @@ export class A2UISchemaManager {
170
170
  }));
171
171
 
172
172
  // Extract CSS custom properties
173
- const cssMatches = [...content.matchAll(/var\(['--([a-z0-9-]+)['"\)]/g)];
173
+ const cssMatches = [...content.matchAll(/var\(--([a-z0-9-]+)\)/g)];
174
174
  const cssCustomProperties = cssMatches.map(m => '--' + m[1]);
175
175
 
176
176
  // Extract JSDoc comments
@@ -217,7 +217,7 @@ export class A2UISchemaManager {
217
217
  errors.push('Forbidden: eval() detected');
218
218
  }
219
219
  } else if (pattern === 'innerHTML = userInput') {
220
- if (/innerHTML\s*=\s*(?!`[^`]*<[a-z]/i.test(html)) {
220
+ if (/innerHTML\s*=/i.test(html)) {
221
221
  warnings.push('Warning: innerHTML assignment detected — ensure content is sanitized');
222
222
  }
223
223
  }
@@ -266,6 +266,11 @@ export class A2UISchemaManager {
266
266
 
267
267
  return this.schema;
268
268
  }
269
+
270
+ /**
271
+ * Validate component props against the generated schema
272
+ */
273
+ validateComponent(tagName, props = {}) {
269
274
  const component = this.schema.components.find(c => c.tagName === tagName);
270
275
  if (!component) {
271
276
  return { valid: false, error: `Component ${tagName} not found in schema` };
@@ -327,6 +332,8 @@ export class A2UISchemaManager {
327
332
  }
328
333
  }
329
334
 
335
+ export { A2UISchemaManager as A2uiSchemaManager };
336
+
330
337
  /**
331
338
  * CLI Usage
332
339
  * node core/a2ui-schema-manager.js generate
package/core/a2ui.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * @module core/a2ui
9
9
  */
10
10
 
11
+ import { componentMap as coreComponentMap } from '../components/index.js';
12
+
11
13
  /**
12
14
  * A2UI Message Types
13
15
  */
@@ -37,6 +39,51 @@ export const A2UI_SCHEMAS = {
37
39
  }
38
40
  };
39
41
 
42
+ function escapeHtml(value) {
43
+ return String(value)
44
+ .replace(/&/g, '&amp;')
45
+ .replace(/</g, '&lt;')
46
+ .replace(/>/g, '&gt;')
47
+ .replace(/"/g, '&quot;')
48
+ .replace(/'/g, '&#039;');
49
+ }
50
+
51
+ function renderNode(node) {
52
+ if (Array.isArray(node)) {
53
+ return node.map(renderNode).join('');
54
+ }
55
+
56
+ if (node === null || node === undefined || node === false) {
57
+ return '';
58
+ }
59
+
60
+ if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean') {
61
+ return escapeHtml(node);
62
+ }
63
+
64
+ if (typeof node !== 'object') {
65
+ return '';
66
+ }
67
+
68
+ const tag = node.tag || node.tagName;
69
+ if (!tag) {
70
+ return '';
71
+ }
72
+
73
+ const props = node.props || {};
74
+ const attributes = Object.entries(props)
75
+ .filter(([, value]) => value !== null && value !== undefined && value !== false)
76
+ .map(([key, value]) => value === true ? key : `${key}="${escapeHtml(value)}"`)
77
+ .join(' ');
78
+
79
+ const children = renderNode(node.children ?? node.content ?? '');
80
+ return `<${tag}${attributes ? ` ${attributes}` : ''}>${children}</${tag}>`;
81
+ }
82
+
83
+ function isUnsafeHtml(html) {
84
+ return /<\s*script\b|on\w+\s*=|javascript:/i.test(String(html));
85
+ }
86
+
40
87
  /**
41
88
  * A2UI Renderer
42
89
  * Renders A2UI messages into actual DOM elements
@@ -51,63 +98,74 @@ export class A2UIRenderer {
51
98
  * Render an A2UI message
52
99
  * @param {Object} message - A2UI message object
53
100
  */
54
- async render(message) {
101
+ render(message) {
55
102
  if (!message || !message.type) {
56
103
  throw new Error('Invalid A2UI message');
57
104
  }
58
105
 
59
- const canRenderToDOM = this.root && typeof this.root.appendChild === 'function';
106
+ if (message.html !== undefined) {
107
+ if (isUnsafeHtml(message.html)) {
108
+ throw new Error('Unsafe HTML is not allowed in A2UI render messages');
109
+ }
110
+
111
+ throw new Error('Raw HTML payloads are not supported in A2UI render messages');
112
+ }
113
+
114
+ const output = Array.isArray(message.components)
115
+ ? message.components.map(renderNode).join('')
116
+ : renderNode(message.component || message);
60
117
 
61
- if (!canRenderToDOM) {
62
- const placeholder = {
63
- id: message.id || `a2ui-${Date.now()}`,
118
+ if (message.id) {
119
+ this.components.set(message.id, {
120
+ id: message.id,
64
121
  type: message.type,
65
122
  props: message.props || {},
66
- children: message.children || []
67
- };
123
+ children: message.children || message.components || []
124
+ });
125
+ }
68
126
 
69
- if (placeholder.id) {
70
- this.components.set(placeholder.id, placeholder);
127
+ const canRenderToDOM = this.root && typeof this.root.appendChild === 'function' && typeof document !== 'undefined';
128
+ if (canRenderToDOM && output) {
129
+ const container = document.createElement('div');
130
+ container.innerHTML = output;
131
+ while (container.firstChild) {
132
+ this.root.appendChild(container.firstChild);
71
133
  }
72
-
73
- return placeholder;
74
134
  }
75
135
 
76
- const element = await this.createElement(message);
77
- this.root.appendChild(element);
78
- return element;
136
+ return output;
79
137
  }
80
138
 
81
139
  /**
82
140
  * Create DOM element from A2UI component
83
141
  * @param {Object} component - A2UI component definition
84
142
  */
85
- async createElement(component) {
143
+ createElement(component) {
86
144
  const { type, props = {}, children = [] } = component;
87
145
 
88
146
  let element;
89
147
 
90
148
  switch (type) {
91
149
  case A2UI_TYPES.COMPONENT:
92
- element = await this.createCustomComponent(props);
150
+ element = this.createCustomComponent(props);
93
151
  break;
94
152
  case A2UI_TYPES.FORM:
95
- element = await this.createForm(props);
153
+ element = this.createForm(props);
96
154
  break;
97
155
  case A2UI_TYPES.LIST:
98
- element = await this.createList(props);
156
+ element = this.createList(props);
99
157
  break;
100
158
  case A2UI_TYPES.CARD:
101
- element = await this.createCard(props);
159
+ element = this.createCard(props);
102
160
  break;
103
161
  case A2UI_TYPES.MODAL:
104
- element = await this.createModal(props);
162
+ element = this.createModal(props);
105
163
  break;
106
164
  case A2UI_TYPES.TOAST:
107
- element = await this.createToast(props);
165
+ element = this.createToast(props);
108
166
  break;
109
167
  case A2UI_TYPES.PROGRESS:
110
- element = await this.createProgress(props);
168
+ element = this.createProgress(props);
111
169
  break;
112
170
  default:
113
171
  throw new Error(`Unknown A2UI component type: ${type}`);
@@ -116,7 +174,7 @@ export class A2UIRenderer {
116
174
  // Render children
117
175
  if (children.length > 0) {
118
176
  for (const child of children) {
119
- const childElement = await this.createElement(child);
177
+ const childElement = this.createElement(child);
120
178
  element.appendChild(childElement);
121
179
  }
122
180
  }
@@ -127,7 +185,7 @@ export class A2UIRenderer {
127
185
  /**
128
186
  * Create custom AfriCode component
129
187
  */
130
- async createCustomComponent({ tag, ...props }) {
188
+ createCustomComponent({ tag, ...props }) {
131
189
  const element = document.createElement(tag || 'div');
132
190
  Object.assign(element, props);
133
191
  return element;
@@ -136,7 +194,7 @@ export class A2UIRenderer {
136
194
  /**
137
195
  * Create form component
138
196
  */
139
- async createForm({ fields = [], onSubmit }) {
197
+ createForm({ fields = [], onSubmit }) {
140
198
  const form = document.createElement('af-form');
141
199
 
142
200
  for (const field of fields) {
@@ -157,11 +215,11 @@ export class A2UIRenderer {
157
215
  /**
158
216
  * Create list component
159
217
  */
160
- async createList({ items = [] }) {
218
+ createList({ items = [] }) {
161
219
  const list = document.createElement('af-grid');
162
220
 
163
221
  for (const item of items) {
164
- const card = await this.createCard(item);
222
+ const card = this.createCard(item);
165
223
  list.appendChild(card);
166
224
  }
167
225
 
@@ -171,7 +229,7 @@ export class A2UIRenderer {
171
229
  /**
172
230
  * Create card component
173
231
  */
174
- async createCard({ title, content, image }) {
232
+ createCard({ title, content, image }) {
175
233
  const card = document.createElement('af-card');
176
234
 
177
235
  if (title) {
@@ -199,7 +257,7 @@ export class A2UIRenderer {
199
257
  /**
200
258
  * Create modal component
201
259
  */
202
- async createModal({ title, content, actions = [] }) {
260
+ createModal({ title, content, actions = [] }) {
203
261
  const modal = document.createElement('af-modal');
204
262
  modal.setAttribute('open', '');
205
263
 
@@ -238,7 +296,7 @@ export class A2UIRenderer {
238
296
  /**
239
297
  * Create toast component
240
298
  */
241
- async createToast({ message, type = 'info', duration = 3000 }) {
299
+ createToast({ message, type = 'info', duration = 3000 }) {
242
300
  const toast = document.createElement('af-toast');
243
301
  toast.setAttribute('type', type);
244
302
  toast.textContent = message;
@@ -254,7 +312,7 @@ export class A2UIRenderer {
254
312
  /**
255
313
  * Create progress component
256
314
  */
257
- async createProgress({ value = 0, max = 100, label }) {
315
+ createProgress({ value = 0, max = 100, label }) {
258
316
  const progress = document.createElement('af-progress');
259
317
  progress.setAttribute('value', value);
260
318
  progress.setAttribute('max', max);
@@ -269,7 +327,7 @@ export class A2UIRenderer {
269
327
  /**
270
328
  * Update existing component
271
329
  */
272
- async update(id, newProps) {
330
+ update(id, newProps) {
273
331
  const element = this.components.get(id);
274
332
  if (element) {
275
333
  Object.assign(element, newProps);
@@ -314,7 +372,7 @@ export class A2UIProtocol {
314
372
  * Send a message into the A2UI system
315
373
  * @param {Object|string} message - A2UI message
316
374
  */
317
- async sendMessage(message) {
375
+ sendMessage(message) {
318
376
  return this.processMessage(message);
319
377
  }
320
378
 
@@ -322,28 +380,31 @@ export class A2UIProtocol {
322
380
  * Process incoming A2UI message
323
381
  * @param {Object|string} message - A2UI message
324
382
  */
325
- async processMessage(message) {
383
+ processMessage(message) {
326
384
  if (typeof message === 'string') {
327
385
  try {
328
386
  message = JSON.parse(message);
329
387
  } catch {
330
388
  console.warn('Invalid A2UI message format:', message);
331
- return;
389
+ return null;
332
390
  }
333
391
  }
334
392
 
335
- this.messageQueue.push(message);
336
- await this.processQueue();
393
+ if (message?.type === 'render' && this.renderer) {
394
+ return this.renderer.render(message);
395
+ }
337
396
 
338
397
  for (const handler of this.messageHandlers) {
339
398
  handler(message);
340
399
  }
400
+
401
+ return message;
341
402
  }
342
403
 
343
404
  /**
344
405
  * Process queued messages
345
406
  */
346
- async processQueue() {
407
+ processQueue() {
347
408
  if (this.isProcessing || this.messageQueue.length === 0) {
348
409
  return;
349
410
  }
@@ -354,7 +415,7 @@ export class A2UIProtocol {
354
415
  const message = this.messageQueue.shift();
355
416
 
356
417
  try {
357
- await this.renderer.render(message);
418
+ this.renderer.render(message);
358
419
  } catch (error) {
359
420
  console.error('A2UI render error:', error);
360
421
  }
@@ -388,16 +449,41 @@ export class A2UIProtocol {
388
449
 
389
450
  for (const message of messages) {
390
451
  if (message.trim()) {
391
- await this.processMessage(message.trim());
452
+ this.processMessage(message.trim());
392
453
  }
393
454
  }
394
455
  }
395
456
 
396
457
  // Process any remaining buffer
397
458
  if (buffer.trim()) {
398
- await this.processMessage(buffer.trim());
459
+ this.processMessage(buffer.trim());
399
460
  }
400
461
  }
462
+
463
+ createStream() {
464
+ const subscribers = new Set();
465
+
466
+ return {
467
+ subscribe(callback) {
468
+ if (typeof callback !== 'function') {
469
+ return () => {};
470
+ }
471
+
472
+ subscribers.add(callback);
473
+ return () => subscribers.delete(callback);
474
+ },
475
+ push(message) {
476
+ for (const callback of subscribers) {
477
+ callback(message);
478
+ }
479
+ }
480
+ };
481
+ }
482
+
483
+ validateAgainstSchema(component) {
484
+ const tag = component?.tag || component?.tagName;
485
+ return Promise.resolve(Boolean(tag && coreComponentMap[tag]));
486
+ }
401
487
  }
402
488
 
403
489
  /**
@@ -423,9 +509,11 @@ export function initA2UI(root) {
423
509
  update: (id, props) => renderer.update(id, props),
424
510
  remove: (id) => renderer.remove(id),
425
511
  sendMessage: (message) => protocol.sendMessage(message),
426
- onMessage: (callback) => protocol.onMessage(callback)
512
+ onMessage: (callback) => protocol.onMessage(callback),
513
+ createStream: () => protocol.createStream(),
514
+ validateAgainstSchema: (component) => protocol.validateAgainstSchema(component)
427
515
  };
428
516
  }
429
517
 
430
518
  return { renderer, protocol };
431
- }
519
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Session-aware action helpers for AfriCode.
3
+ *
4
+ * @module core/actions
5
+ */
6
+
7
+ import { sessionStore } from './session-store.js';
8
+
9
+ export const actions = {
10
+ incrementCounter(sessionId) {
11
+ const session = sessionStore.get(sessionId);
12
+ const current = session.counter?.value || 0;
13
+ const nextValue = current + 1;
14
+ sessionStore.set(sessionId, 'counter/value', nextValue);
15
+ return nextValue;
16
+ },
17
+
18
+ resetCounter(sessionId) {
19
+ sessionStore.set(sessionId, 'counter/value', 0);
20
+ return 0;
21
+ },
22
+
23
+ getCounter(sessionId) {
24
+ const session = sessionStore.get(sessionId);
25
+ return session.counter?.value || 0;
26
+ }
27
+ };