Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
466 changes: 466 additions & 0 deletions docs/PluginPages.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ Repository = "https://github.com/langbot-app/langbot-plugin-sdk"
Issues = "https://github.com/langbot-app/langbot-plugin-sdk/issues"

[tool.setuptools]
package-data = { "langbot_plugin" = ["assets/templates/*"] }
package-data = { "langbot_plugin" = ["assets/templates/*", "assets/*.js"] }
23 changes: 23 additions & 0 deletions src/langbot_plugin/api/definition/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,29 @@ def __init__(self):
async def initialize(self) -> None:
pass

async def handle_page_api(
self,
page_id: str,
endpoint: str,
method: str,
body: typing.Any = None,
) -> typing.Any:
"""Handle API calls from plugin pages.

Override this method to implement a backend for your plugin pages.
All plugin components can call this method.

Args:
page_id: The page identifier from manifest.yaml
endpoint: The API endpoint path requested by the page
method: HTTP method (GET, POST, PUT, DELETE)
body: Request body (JSON)

Returns:
Response data (will be JSON-serialized)
"""
return None

def __del__(self) -> None:
pass

Expand Down
271 changes: 271 additions & 0 deletions src/langbot_plugin/assets/langbot-page-sdk.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
/**
* LangBot Plugin Page SDK
*
* This script provides a bridge between plugin pages (running in iframes)
* and the LangBot host application.
*
* Features:
* - Automatic dark/light theme synchronization
* - Language/locale information
* - Automatic i18n loading from i18n/{locale}.json files
* - API calls to the plugin backend (handle_page_api)
*
* Usage in your HTML page:
* <script src="/api/v1/plugins/_sdk/page-sdk.js"></script>
* <script>
* langbot.onReady(async (ctx) => {
* console.log('Theme:', ctx.theme); // 'light' or 'dark'
* console.log('Language:', ctx.language); // 'zh-Hans', 'en-US', etc.
* console.log(langbot.t('title')); // Translated string
*
* // Call your plugin's handle_page_api
* const result = await langbot.api('/my-endpoint', { key: 'value' });
* });
* </script>
*
* i18n: Place JSON files in an i18n/ directory next to your HTML:
* pages/dashboard/
* index.html
* i18n/
* en_US.json (fallback)
* zh_Hans.json
*
* Elements with data-i18n="key" will be auto-translated.
*/
(function () {
'use strict';

var _theme = 'light';
var _language = 'en-US';
var _ready = false;
var _readyCallbacks = [];
var _themeCallbacks = [];
var _pendingRequests = {};
var _requestIdCounter = 0;
var _translations = {};
var _i18nLoaded = false;
var _languageCallbacks = [];
var _i18nPromise = null;

// Apply theme to document
function applyTheme(theme) {
_theme = theme;
var root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
root.setAttribute('data-theme', theme);

// Set CSS custom properties for common dark/light patterns
if (theme === 'dark') {
root.style.setProperty('--langbot-bg', '#0a0a0a');
root.style.setProperty('--langbot-bg-card', '#171717');
root.style.setProperty('--langbot-text', '#fafafa');
root.style.setProperty('--langbot-text-muted', '#a1a1aa');
root.style.setProperty('--langbot-border', '#27272a');
root.style.setProperty('--langbot-accent', '#3b82f6');
} else {
root.style.setProperty('--langbot-bg', '#ffffff');
root.style.setProperty('--langbot-bg-card', '#f8fafc');
root.style.setProperty('--langbot-text', '#0a0a0a');
root.style.setProperty('--langbot-text-muted', '#71717a');
root.style.setProperty('--langbot-border', '#e4e4e7');
root.style.setProperty('--langbot-accent', '#2563eb');
}

for (var i = 0; i < _themeCallbacks.length; i++) {
try { _themeCallbacks[i](theme); } catch (e) { console.error(e); }
}
}

// Convert language code for i18n filenames: "zh-Hans" → "zh_Hans"
function langToFileName(lang) {
return lang.replace(/-/g, '_');
}

// Load i18n JSON from ./i18n/{locale}.json relative to the page
function loadI18n(lang) {
var fileName = langToFileName(lang);
return fetch('./i18n/' + fileName + '.json')
.then(function (resp) {
if (resp.ok) return resp.json();
// Fallback to en_US
if (fileName !== 'en_US') {
return fetch('./i18n/en_US.json').then(function (r) {
return r.ok ? r.json() : {};
});
}
return {};
})
.then(function (data) {
_translations = data || {};
_i18nLoaded = true;
applyTranslations();
return _translations;
})
.catch(function () {
_translations = {};
_i18nLoaded = true;
});
}

// Allowed attributes for data-i18n-attr (whitelist to prevent XSS)
var SAFE_I18N_ATTRS = ['placeholder', 'title', 'alt', 'aria-label', 'aria-description'];

// Apply translations to elements with data-i18n attribute
function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var key = el.getAttribute('data-i18n');
if (_translations[key] != null) {
// Support data-i18n-attr for safe attribute translation (e.g. placeholder)
var attr = el.getAttribute('data-i18n-attr');
if (attr && SAFE_I18N_ATTRS.indexOf(attr) !== -1) {
el.setAttribute(attr, _translations[key]);
} else {
el.textContent = _translations[key];
}
}
});
}

// Listen for messages from the parent LangBot page
window.addEventListener('message', function (event) {
var data = event.data;
if (!data || typeof data !== 'object') return;

if (data.type === 'langbot:context') {
var oldTheme = _theme;
var oldLang = _language;
_language = data.language || _language;

applyTheme(data.theme || 'light');

if (!_ready) {
// Load i18n before marking ready and firing callbacks
_i18nPromise = loadI18n(_language).then(function () {
_ready = true;
var ctx = { theme: _theme, language: _language };
for (var i = 0; i < _readyCallbacks.length; i++) {
try { _readyCallbacks[i](ctx); } catch (e) { console.error(e); }
}
});
} else {
// Language changed — reload translations
if (oldLang !== _language) {
loadI18n(_language).then(function () {
for (var i = 0; i < _languageCallbacks.length; i++) {
try { _languageCallbacks[i](_language); } catch (e) { console.error(e); }
}
});
}
}
}

if (data.type === 'langbot:api:response') {
var requestId = data.requestId;
if (_pendingRequests[requestId]) {
if (data.error) {
_pendingRequests[requestId].reject(new Error(data.error));
} else {
_pendingRequests[requestId].resolve(data.data);
}
delete _pendingRequests[requestId];
}
}
});

// Public API
window.langbot = {
/** Current theme: 'light' or 'dark' */
get theme() { return _theme; },

/** Current language: e.g. 'zh-Hans', 'en-US' */
get language() { return _language; },

/** Whether the SDK has received initial context and i18n is loaded */
get ready() { return _ready && _i18nLoaded; },

/**
* Register a callback for when context is first received.
* If already ready, fires immediately. If context received but i18n
* still loading, waits for i18n to complete.
* @param {function({theme: string, language: string}): void} callback
*/
onReady: function (callback) {
if (_ready && _i18nLoaded) {
try { callback({ theme: _theme, language: _language }); } catch (e) { console.error(e); }
} else if (_i18nPromise) {
_i18nPromise.then(function () {
try { callback({ theme: _theme, language: _language }); } catch (e) { console.error(e); }
});
} else {
_readyCallbacks.push(callback);
}
},

/**
* Register a callback for theme changes.
* @param {function(string): void} callback
*/
onThemeChange: function (callback) {
_themeCallbacks.push(callback);
},

/**
* Register a callback for language changes (after initial load).
* Translations are already reloaded when this fires.
* @param {function(string): void} callback
*/
onLanguageChange: function (callback) {
_languageCallbacks.push(callback);
},

/**
* Get a translated string by key.
* Falls back to the provided fallback or the key itself.
* @param {string} key - The translation key
* @param {string} [fallback] - Fallback if key not found
* @returns {string} Translated string
*/
t: function (key, fallback) {
return _translations[key] != null ? _translations[key] : (fallback || key);
},

/**
* Manually re-apply translations to all data-i18n elements.
* Useful after dynamically adding new elements to the DOM.
*/
applyI18n: function () {
applyTranslations();
},

/**
* Call the plugin's handle_page_api method.
* @param {string} endpoint - The API endpoint
* @param {*} [body] - Request body
* @param {string} [method='POST'] - HTTP method
* @returns {Promise<*>} Response data
*/
api: function (endpoint, body, method) {
return new Promise(function (resolve, reject) {
var requestId = 'req_' + (++_requestIdCounter) + '_' + Date.now();
_pendingRequests[requestId] = { resolve: resolve, reject: reject };

window.parent.postMessage({
type: 'langbot:api',
requestId: requestId,
endpoint: endpoint,
method: method || 'POST',
body: body,
}, '*');

// Timeout after 30s
setTimeout(function () {
if (_pendingRequests[requestId]) {
_pendingRequests[requestId].reject(new Error('Request timeout'));
delete _pendingRequests[requestId];
}
}, 30000);
});
},
};
})();
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_label }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--langbot-bg, #ffffff);
color: var(--langbot-text, #0a0a0a);
padding: 24px;
transition: background 0.2s, color 0.2s;
}
h1 { font-size: 24px; font-weight: 700; margin-bottom: 16px; }
</style>
</head>
<body>
<h1 data-i18n="title">{{ page_label }}</h1>
<p data-i18n="description">This is the {{ page_label }} page.</p>

<!-- Load the LangBot Page SDK -->
<script src="/api/v1/plugins/_sdk/page-sdk.js"></script>
<script>
LangBotPageSDK.ready().then(sdk => {
console.log('Page SDK ready');
});
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Page
metadata:
name: {{ page_name }}
label:
en_US: {{ page_label }}
zh_Hans: {{ page_label }}
th_TH: {{ page_label }}
vi_VN: {{ page_label }}
es_ES: {{ page_label }}
spec:
path: {{ page_name }}.html
16 changes: 9 additions & 7 deletions src/langbot_plugin/cli/commands/gencomponent.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,19 @@ def generate_component_process(component_type: str) -> None:

if not os.path.exists("components"):
os.makedirs("components")
with open("components/__init__.py", "w", encoding="utf-8") as f:
f.write("")
if component_type_obj.type_name != "Page":
with open("components/__init__.py", "w", encoding="utf-8") as f:
f.write("")

if not os.path.exists(component_type_obj.target_dir):
os.makedirs(component_type_obj.target_dir)

if not os.path.exists(f"{component_type_obj.target_dir}/__init__.py"):
with open(
f"{component_type_obj.target_dir}/__init__.py", "w", encoding="utf-8"
) as f:
f.write("")
if component_type_obj.type_name != "Page":
if not os.path.exists(f"{component_type_obj.target_dir}/__init__.py"):
with open(
f"{component_type_obj.target_dir}/__init__.py", "w", encoding="utf-8"
) as f:
f.write("")

# render templates
for file in component_type_obj.template_files:
Expand Down
Loading