hexo-bb-channel 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaaaaai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # hexo-bb-channel
2
+
3
+ English | [Chinese](./README.zh-CN.md)
4
+
5
+ > Render a public Telegram channel as a dynamic microblog timeline in Hexo.
6
+
7
+ [![Hexo](https://img.shields.io/badge/Hexo-%3E%3D6-0e83cd?style=flat-square&logo=hexo)](https://hexo.io/)
8
+ [![Telegram](https://img.shields.io/badge/Telegram-channel-26a5e4?style=flat-square&logo=telegram)](https://telegram.org/)
9
+ [![Live Demo](https://img.shields.io/badge/demo-kaaaaai.cn%2Fbb-ff7900?style=flat-square)](https://www.kaaaaai.cn/bb/)
10
+ [![License](https://img.shields.io/badge/license-MIT-black?style=flat-square)](./LICENSE)
11
+
12
+ `hexo-bb-channel` is a small Hexo plugin that creates a `/bb/` page shell and loads posts from a separate API at runtime. Your blog stays static, while Telegram updates can appear without rebuilding the whole site.
13
+
14
+ Live sample: [https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
15
+
16
+ ## Preview
17
+
18
+ Live demo: [https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
19
+
20
+ ![Hexo BB Channel desktop preview](https://raw.githubusercontent.com/Kaaaaai/kaaaaai.tools.hexo-bb-channel/main/docs/assets/preview-desktop.png)
21
+
22
+ ## Why This Design
23
+
24
+ - **Theme-agnostic**: works through Hexo's generator system, so it can be used with NexT, Wizarding, or most other themes.
25
+ - **Dynamic content on static blogs**: Telegram posts are fetched in the browser from the API backend.
26
+ - **No Telegram token in the blog**: the Hexo side only needs a public API URL.
27
+ - **Media-friendly timeline**: supports text, real Telegram hashtag entities, images, files, pagination, and image preview interactions.
28
+ - **Comments still work**: the generated page is a normal Hexo page and keeps `comments: true`.
29
+
30
+ ## Quick Start
31
+
32
+ ### 1. Deploy the API backend
33
+
34
+ Create a public Telegram channel first. Its public link should look like `https://t.me/my_notes`, and the API `TG_CHANNEL` value should be only `my_notes`, without `@`.
35
+
36
+ Deploy the companion API first:
37
+
38
+ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FKaaaaai%2Fkaaaaai.tools.channel-api&project-name=kaaaaai-tools-channel-api&repository-name=kaaaaai.tools.channel-api&stores=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%2C%22protocol%22%3A%22storage%22%2C%22allowConnectExistingProduct%22%3Atrue%7D%5D&env=TG_CHANNEL,ALLOWED_ORIGINS,REFRESH_SECRET&CACHE_TTL=300&PAGE_SIZE=20&MAX_FETCH_PAGES=2&POST_LIMIT=500)
39
+
40
+ Backend repo: [Kaaaaai/kaaaaai.tools.channel-api](https://github.com/Kaaaaai/kaaaaai.tools.channel-api)
41
+ See the API README for detailed Telegram channel creation steps.
42
+
43
+ ### 2. Install the Hexo plugin
44
+
45
+ Install the latest stable release from npm:
46
+
47
+ ```bash
48
+ npm install hexo-bb-channel
49
+ ```
50
+
51
+ To try unreleased changes from the default GitHub branch instead:
52
+
53
+ ```bash
54
+ npm install github:Kaaaaai/kaaaaai.tools.hexo-bb-channel
55
+ ```
56
+
57
+ Installing the Hexo plugin does not deploy the companion API. Complete step 1 and keep the `bb_channel` block in your site's root `_config.yml`.
58
+
59
+ ### 3. Configure Hexo
60
+
61
+ Add this to the root `_config.yml` of your Hexo site:
62
+
63
+ ```yml
64
+ bb_channel:
65
+ enable: true
66
+ mode: client
67
+ route: bb/
68
+ title: moments
69
+ description: Notes captured from a Telegram channel
70
+ api_base: https://your-channel-api.vercel.app
71
+ page_size: 20
72
+ ```
73
+
74
+ Then rebuild:
75
+
76
+ ```bash
77
+ hexo clean
78
+ hexo generate
79
+ hexo server
80
+ ```
81
+
82
+ Open:
83
+
84
+ ```text
85
+ http://localhost:4000/bb/
86
+ ```
87
+
88
+ ## Configuration
89
+
90
+ | Option | Default | Description |
91
+ | --- | --- | --- |
92
+ | `enable` | `true` | Enable or disable the generated page. |
93
+ | `mode` | `client` | Client-rendered dynamic mode. |
94
+ | `route` | `bb/` | Output route, for example `moments/`. |
95
+ | `title` | `moments` | Page title inside the content area. |
96
+ | `description` | empty | Subtitle under the title. |
97
+ | `api_base` | empty | Base URL of `kaaaaai.tools.channel-api`. |
98
+ | `page_size` | `20` | Posts requested per page. |
99
+
100
+ ## How It Works
101
+
102
+ ```text
103
+ Hexo build
104
+ └─ hexo-bb-channel generates /bb/index.html
105
+
106
+ Browser
107
+ └─ fetches api_base/api/posts?page=1&page_size=20
108
+
109
+ kaaaaai.tools.channel-api
110
+ ├─ fetches public Telegram channel pages
111
+ ├─ parses posts, tags, media, files
112
+ └─ caches normalized payload in Upstash Redis
113
+ ```
114
+
115
+ The Hexo build does **not** fetch Telegram. Only the page shell is static. Runtime content comes from the API.
116
+
117
+ ## Theme Integration
118
+
119
+ This plugin uses Hexo's generator API, so it does not require theme template changes. It has been used with:
120
+
121
+ - NexT theme
122
+ - Wizarding theme
123
+ - Any theme that renders the standard Hexo `page` layout
124
+
125
+ If your theme or source folder already has a `/bb/` page, remove it or change `bb_channel.route` to avoid duplicate output.
126
+
127
+ ## Runtime Features
128
+
129
+ - Client-side pagination
130
+ - Telegram source link per post
131
+ - Hashtags from Telegram entities instead of loose regex matching
132
+ - Image thumbnails and click-to-expand preview
133
+ - Multi-image horizontal carousel
134
+ - File attachment cards
135
+ - Image lazy hydration compatible with Hexo image lazyload plugins
136
+
137
+ ## API Contract
138
+
139
+ The plugin expects:
140
+
141
+ ```http
142
+ GET /api/posts?page=1&page_size=20
143
+ ```
144
+
145
+ ```json
146
+ {
147
+ "channel": {
148
+ "title": "Channel title",
149
+ "description": "Channel description"
150
+ },
151
+ "posts": [
152
+ {
153
+ "id": "101",
154
+ "datetime": "2026-01-01T12:00:00.000Z",
155
+ "html": "<p>Hello</p>",
156
+ "tags": ["Tools"],
157
+ "media": [{ "type": "image", "src": "https://..." }],
158
+ "attachments": [{ "title": "file.dmg", "meta": "351.5 MB", "url": "https://..." }],
159
+ "source": { "telegramUrl": "https://t.me/channel/101" }
160
+ }
161
+ ],
162
+ "pagination": {
163
+ "page": 1,
164
+ "pageSize": 20,
165
+ "total": 3,
166
+ "totalItems": 60,
167
+ "hasNext": true,
168
+ "hasPrev": false
169
+ }
170
+ }
171
+ ```
172
+
173
+ ## FAQ
174
+
175
+ ### Do I need to rebuild my blog when Telegram updates?
176
+
177
+ No. The Hexo page is static, but posts are loaded dynamically from the API.
178
+
179
+ ### Does this work with private Telegram channels?
180
+
181
+ No. This project is designed for public Telegram channels that can be accessed through `t.me/s/<channel>`.
182
+
183
+ ### Why do I need a separate API?
184
+
185
+ Static sites cannot safely store refresh secrets or Redis credentials. The API isolates fetching, caching, CORS, and refresh logic from your blog.
186
+
187
+ ### Can I use it with non-Hexo blogs?
188
+
189
+ The UI is currently packaged as a Hexo plugin. The backend API is framework-agnostic, and the same JSON can be consumed by other static site generators.
190
+
191
+ ## Development
192
+
193
+ ```bash
194
+ npm install
195
+ npm test
196
+ ```
197
+
198
+ ## Related
199
+
200
+ - API backend: [Kaaaaai/kaaaaai.tools.channel-api](https://github.com/Kaaaaai/kaaaaai.tools.channel-api)
201
+ - Live sample: [https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
202
+
203
+ ## License
204
+
205
+ MIT
@@ -0,0 +1,205 @@
1
+ # hexo-bb-channel
2
+
3
+ [English](./README.md) | 简体中文
4
+
5
+ > 将公开 Telegram Channel 渲染成 Hexo 博客里的动态碎片流页面。
6
+
7
+ [![Hexo](https://img.shields.io/badge/Hexo-%3E%3D6-0e83cd?style=flat-square&logo=hexo)](https://hexo.io/)
8
+ [![Telegram](https://img.shields.io/badge/Telegram-channel-26a5e4?style=flat-square&logo=telegram)](https://telegram.org/)
9
+ [![在线示例](https://img.shields.io/badge/demo-kaaaaai.cn%2Fbb-ff7900?style=flat-square)](https://www.kaaaaai.cn/bb/)
10
+ [![License](https://img.shields.io/badge/license-MIT-black?style=flat-square)](./LICENSE)
11
+
12
+ `hexo-bb-channel` 是一个轻量 Hexo 插件:它只生成 `/bb/` 页面壳,内容在浏览器端从独立 API 拉取。这样博客仍然是静态站,但 Telegram Channel 更新后不需要重新 `hexo generate`。
13
+
14
+ 在线示例:[https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
15
+
16
+ ## 效果预览
17
+
18
+ 在线示例:[https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
19
+
20
+ ![Hexo BB Channel 桌面端预览](https://raw.githubusercontent.com/Kaaaaai/kaaaaai.tools.hexo-bb-channel/main/docs/assets/preview-desktop.png)
21
+
22
+ ## 为什么这样设计
23
+
24
+ - **主题无关**:通过 Hexo generator 输出页面,NexT、Wizarding 以及大部分主题都可以接入。
25
+ - **静态博客里的动态内容**:页面访问时从 API 拉取 Telegram 内容。
26
+ - **博客侧不放 Telegram 凭证**:Hexo 只需要配置一个 API 地址。
27
+ - **适合碎片流展示**:支持正文、Telegram 原生 hashtag entity、图片、文件、分页、图片预览交互。
28
+ - **保留评论能力**:生成的是标准 Hexo page,默认开启 `comments: true`。
29
+
30
+ ## 快速开始
31
+
32
+ ### 1. 先部署 API
33
+
34
+ 先创建一个公开 Telegram Channel。公开链接应该类似 `https://t.me/my_notes`,API 里的 `TG_CHANNEL` 只填写 `my_notes`,不要带 `@`。
35
+
36
+ 先部署配套 API:
37
+
38
+ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FKaaaaai%2Fkaaaaai.tools.channel-api&project-name=kaaaaai-tools-channel-api&repository-name=kaaaaai.tools.channel-api&stores=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%2C%22protocol%22%3A%22storage%22%2C%22allowConnectExistingProduct%22%3Atrue%7D%5D&env=TG_CHANNEL,ALLOWED_ORIGINS,REFRESH_SECRET&CACHE_TTL=300&PAGE_SIZE=20&MAX_FETCH_PAGES=2&POST_LIMIT=500)
39
+
40
+ 后端仓库:[Kaaaaai/kaaaaai.tools.channel-api](https://github.com/Kaaaaai/kaaaaai.tools.channel-api)
41
+ 详细的 Telegram 频道创建步骤见 API 仓库 README。
42
+
43
+ ### 2. 安装 Hexo 插件
44
+
45
+ 从 npm 安装最新稳定版:
46
+
47
+ ```bash
48
+ npm install hexo-bb-channel
49
+ ```
50
+
51
+ 如需试用 GitHub 默认分支上尚未发布的改动,可改为:
52
+
53
+ ```bash
54
+ npm install github:Kaaaaai/kaaaaai.tools.hexo-bb-channel
55
+ ```
56
+
57
+ 安装 Hexo 插件不会自动部署配套 API。请先完成第 1 步,并保留站点根目录 `_config.yml` 中的 `bb_channel` 配置。
58
+
59
+ ### 3. 配置 Hexo
60
+
61
+ 在 Hexo 主目录的 `_config.yml` 增加:
62
+
63
+ ```yml
64
+ bb_channel:
65
+ enable: true
66
+ mode: client
67
+ route: bb/
68
+ title: 闲言碎语
69
+ description: 这些片段可能来自于大脑皮层短暂兴奋后的捕捉 🤏
70
+ api_base: https://your-channel-api.vercel.app
71
+ page_size: 20
72
+ ```
73
+
74
+ 然后重新生成:
75
+
76
+ ```bash
77
+ hexo clean
78
+ hexo generate
79
+ hexo server
80
+ ```
81
+
82
+ 打开:
83
+
84
+ ```text
85
+ http://localhost:4000/bb/
86
+ ```
87
+
88
+ ## 配置项
89
+
90
+ | 配置 | 默认值 | 说明 |
91
+ | --- | --- | --- |
92
+ | `enable` | `true` | 是否启用页面。 |
93
+ | `mode` | `client` | 当前支持客户端动态渲染。 |
94
+ | `route` | `bb/` | 页面路由,例如 `moments/`。 |
95
+ | `title` | `moments` | 页面内容区标题。 |
96
+ | `description` | 空 | 页面描述。 |
97
+ | `api_base` | 空 | `kaaaaai.tools.channel-api` 的 API 根地址。 |
98
+ | `page_size` | `20` | 每页拉取数量。 |
99
+
100
+ ## 工作原理
101
+
102
+ ```text
103
+ Hexo build
104
+ └─ hexo-bb-channel 生成 /bb/index.html
105
+
106
+ Browser
107
+ └─ 请求 api_base/api/posts?page=1&page_size=20
108
+
109
+ kaaaaai.tools.channel-api
110
+ ├─ 抓取公开 Telegram Channel 页面
111
+ ├─ 解析消息、标签、图片、文件
112
+ └─ 将标准化数据缓存到 Upstash Redis
113
+ ```
114
+
115
+ Hexo 构建阶段**不会**抓取 Telegram,只生成页面壳。实际内容在访问页面时由 API 返回。
116
+
117
+ ## 主题接入说明
118
+
119
+ 插件通过 Hexo generator 输出页面,理论上不需要修改主题模板。已在以下场景使用:
120
+
121
+ - NexT theme
122
+ - Wizarding theme
123
+ - 任何能渲染标准 Hexo `page` layout 的主题
124
+
125
+ 如果你的 `source/` 或主题里已经存在 `/bb/` 页面,请删除旧页面,或修改 `bb_channel.route` 避免重复输出。
126
+
127
+ ## 页面能力
128
+
129
+ - 客户端分页
130
+ - 每条消息保留 Telegram 原文链接
131
+ - 基于 Telegram 原生 entity 提取 hashtag,而不是宽松正则匹配
132
+ - 图片缩略图和点击展开预览
133
+ - 多图横向轮播
134
+ - 文件附件卡片
135
+ - 兼容 Hexo 图片懒加载插件的图片延迟注入
136
+
137
+ ## API 返回结构
138
+
139
+ 插件期望 API 返回:
140
+
141
+ ```http
142
+ GET /api/posts?page=1&page_size=20
143
+ ```
144
+
145
+ ```json
146
+ {
147
+ "channel": {
148
+ "title": "Channel title",
149
+ "description": "Channel description"
150
+ },
151
+ "posts": [
152
+ {
153
+ "id": "101",
154
+ "datetime": "2026-01-01T12:00:00.000Z",
155
+ "html": "<p>Hello</p>",
156
+ "tags": ["Tools"],
157
+ "media": [{ "type": "image", "src": "https://..." }],
158
+ "attachments": [{ "title": "file.dmg", "meta": "351.5 MB", "url": "https://..." }],
159
+ "source": { "telegramUrl": "https://t.me/channel/101" }
160
+ }
161
+ ],
162
+ "pagination": {
163
+ "page": 1,
164
+ "pageSize": 20,
165
+ "total": 3,
166
+ "totalItems": 60,
167
+ "hasNext": true,
168
+ "hasPrev": false
169
+ }
170
+ }
171
+ ```
172
+
173
+ ## FAQ
174
+
175
+ ### Telegram 更新后需要重新构建博客吗?
176
+
177
+ 不需要。页面壳是静态的,但内容由 API 动态返回。
178
+
179
+ ### 支持私有 Telegram Channel 吗?
180
+
181
+ 不支持。当前方案面向 `t.me/s/<channel>` 可访问的公开频道。
182
+
183
+ ### 为什么需要独立 API?
184
+
185
+ 静态站不适合保存刷新密钥或 Redis 凭证。独立 API 可以把抓取、缓存、CORS、刷新逻辑从博客里隔离出来。
186
+
187
+ ### 可以用于非 Hexo 博客吗?
188
+
189
+ 当前前端封装为 Hexo 插件;后端 API 是通用 JSON 服务,其他静态站也可以消费同一份数据。
190
+
191
+ ## 开发
192
+
193
+ ```bash
194
+ npm install
195
+ npm test
196
+ ```
197
+
198
+ ## 相关项目
199
+
200
+ - API 后端:[Kaaaaai/kaaaaai.tools.channel-api](https://github.com/Kaaaaai/kaaaaai.tools.channel-api)
201
+ - 在线示例:[https://www.kaaaaai.cn/bb/](https://www.kaaaaai.cn/bb/)
202
+
203
+ ## License
204
+
205
+ MIT
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ const { resolveConfig } = require('./src/config');
2
+ const { registerGenerator } = require('./src/generator');
3
+
4
+ const config = resolveConfig(hexo);
5
+ registerGenerator(hexo, config);
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "hexo-bb-channel",
3
+ "version": "0.1.1",
4
+ "description": "Render Telegram channel microblog timelines in Hexo using a dynamic API backend.",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "src",
9
+ "README.md",
10
+ "README.zh-CN.md",
11
+ "LICENSE"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test test/*.test.js",
15
+ "prepublishOnly": "npm test"
16
+ },
17
+ "keywords": [
18
+ "hexo",
19
+ "hexo-plugin",
20
+ "telegram",
21
+ "microblog"
22
+ ],
23
+ "peerDependencies": {
24
+ "hexo": ">=6"
25
+ },
26
+ "dependencies": {
27
+ "lucide-static": "^1.24.0"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/Kaaaaai/kaaaaai.tools.hexo-bb-channel.git"
32
+ },
33
+ "homepage": "https://github.com/Kaaaaai/kaaaaai.tools.hexo-bb-channel#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/Kaaaaai/kaaaaai.tools.hexo-bb-channel/issues"
36
+ },
37
+ "author": "Kaaaaai",
38
+ "license": "MIT",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
package/src/config.js ADDED
@@ -0,0 +1,25 @@
1
+ function normalizeRoute(route) {
2
+ return String(route || 'bb/').replace(/^\/+/, '').replace(/\/?$/, '/');
3
+ }
4
+
5
+ function normalizeApiBase(apiBase) {
6
+ return String(apiBase || '').replace(/\/+$/, '');
7
+ }
8
+
9
+ function resolveConfig(hexo = {}) {
10
+ const raw = (hexo.config && hexo.config.bb_channel) || {};
11
+ return {
12
+ enable: raw.enable !== false,
13
+ mode: raw.mode || 'client',
14
+ route: normalizeRoute(raw.route),
15
+ title: typeof raw.title === 'string' && raw.title.trim() ? raw.title.trim() : 'moments',
16
+ description: typeof raw.description === 'string' ? raw.description.trim() : '',
17
+ apiBase: normalizeApiBase(raw.api_base || raw.apiBase),
18
+ pageSize: Number(raw.page_size || raw.pageSize || 20),
19
+ };
20
+ }
21
+
22
+ module.exports = {
23
+ normalizeRoute,
24
+ resolveConfig,
25
+ };
@@ -0,0 +1,24 @@
1
+ const { renderClientContent } = require('./render-client');
2
+
3
+ function createBbPages(config) {
4
+ if (config.enable === false || (config.mode || 'client') !== 'client') return [];
5
+ return [{
6
+ path: `${config.route}index.html`,
7
+ layout: 'page',
8
+ data: {
9
+ title: config.title,
10
+ description: config.description,
11
+ comments: true,
12
+ content: renderClientContent(config),
13
+ },
14
+ }];
15
+ }
16
+
17
+ function registerGenerator(hexo, config) {
18
+ hexo.extend.generator.register('bb-channel', () => createBbPages(config));
19
+ }
20
+
21
+ module.exports = {
22
+ createBbPages,
23
+ registerGenerator,
24
+ };
package/src/icons.js ADDED
@@ -0,0 +1,13 @@
1
+ const fs = require('node:fs');
2
+
3
+ function loadLucideIcon(name, className) {
4
+ const svgPath = require.resolve(`lucide-static/icons/${name}.svg`);
5
+ return fs.readFileSync(svgPath, 'utf8')
6
+ .replace(/<!--[\s\S]*?-->\s*/g, '')
7
+ .replace(/class="[^"]*"/, `class="${className}"`)
8
+ .replace('<svg', '<svg aria-hidden="true" focusable="false"');
9
+ }
10
+
11
+ module.exports = {
12
+ fileIcon: loadLucideIcon('file', 'bb-channel-lucide bb-channel-lucide-file'),
13
+ };
@@ -0,0 +1,348 @@
1
+ const { fileIcon } = require('./icons');
2
+
3
+ function escapeHtml(value = '') {
4
+ return String(value)
5
+ .replace(/&/g, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;');
9
+ }
10
+
11
+ function normalizeHtml(html) {
12
+ return html.trim().replace(/^[ \t]+/gm, '');
13
+ }
14
+
15
+ function renderClientScript() {
16
+ return `
17
+ <script data-pjax>
18
+ (() => {
19
+ const bbChannelFileIcon = ${JSON.stringify(fileIcon)};
20
+ window.initBbChannel = window.initBbChannel || (() => {
21
+ const root = document.querySelector('[data-bb-channel-root]');
22
+ if (!root || root.dataset.bbChannelReady === 'true') return;
23
+ root.dataset.bbChannelReady = 'true';
24
+ const apiBase = root.dataset.apiBase;
25
+ const pageSize = root.dataset.pageSize || '20';
26
+ const feed = root.querySelector('[data-bb-channel-feed]');
27
+ const pager = root.querySelector('[data-bb-channel-pagination]');
28
+ const status = root.querySelector('[data-bb-channel-status]');
29
+
30
+ const escapeHtml = (value = '') => String(value)
31
+ .replace(/&/g, '&amp;')
32
+ .replace(/</g, '&lt;')
33
+ .replace(/>/g, '&gt;')
34
+ .replace(/"/g, '&quot;');
35
+
36
+ const formatDate = (value) => new Intl.DateTimeFormat('en-US', {
37
+ month: 'short',
38
+ day: '2-digit',
39
+ year: 'numeric',
40
+ hour: '2-digit',
41
+ minute: '2-digit',
42
+ hour12: false,
43
+ }).format(new Date(value));
44
+
45
+ const renderCard = (post) => {
46
+ const images = (post.media || []).filter((item) => item.type === 'image');
47
+ const media = images.map((item, index) =>
48
+ '<button class="bb-channel-media-thumb" type="button" data-bb-media-index="' + index + '" aria-label="View image ' + (index + 1) + '">' +
49
+ '<span class="bb-channel-image-skeleton" aria-hidden="true"></span>' +
50
+ '<img class="bb-channel-media-img" data-bb-image-url="' + escapeHtml(item.src) + '" alt="' + escapeHtml(item.alt || '') + '" loading="lazy" decoding="async">' +
51
+ '</button>'
52
+ ).join('');
53
+ const viewerImages = images.map((item) =>
54
+ '<figure class="bb-channel-image-slide" data-bb-image-slide>' +
55
+ '<span class="bb-channel-image-skeleton" aria-hidden="true"></span>' +
56
+ '<img class="bb-channel-image-large" data-bb-image-url="' + escapeHtml(item.src) + '" alt="' + escapeHtml(item.alt || '') + '" loading="lazy" decoding="async">' +
57
+ '</figure>'
58
+ ).join('');
59
+ const carousel = images.length > 1;
60
+ const tags = (post.tags || []).map((tag) => '<span>#' + escapeHtml(tag) + '</span>').join('');
61
+ const attachments = (post.attachments || []).map((item) =>
62
+ '<a class="bb-channel-attachment" href="' + escapeHtml(item.url || (post.source && post.source.telegramUrl) || '#') + '" target="_blank" rel="noopener noreferrer">' +
63
+ '<span class="bb-channel-attachment-icon">' + bbChannelFileIcon + '</span>' +
64
+ '<span class="bb-channel-attachment-main">' +
65
+ '<span class="bb-channel-attachment-title">' + escapeHtml(item.title || 'Attachment') + '</span>' +
66
+ (item.meta ? '<span class="bb-channel-attachment-meta">' + escapeHtml(item.meta) + '</span>' : '') +
67
+ '</span>' +
68
+ '</a>'
69
+ ).join('');
70
+ return '<section class="bb-channel-card" id="bb-' + escapeHtml(post.id) + '">' +
71
+ '<span class="bb-channel-dot" aria-hidden="true"></span>' +
72
+ '<span class="bb-channel-card-placeholder" aria-hidden="true"></span>' +
73
+ '<div class="bb-channel-card-surface">' +
74
+ '<header class="bb-channel-meta">' +
75
+ '<a class="bb-channel-time" href="' + escapeHtml(post.source && post.source.telegramUrl || '#') + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(formatDate(post.datetime)) + '</a>' +
76
+ '</header>' +
77
+ '<div class="bb-channel-body' + (media ? ' bb-channel-body-with-media' : '') + '">' +
78
+ '<div class="bb-channel-main">' +
79
+ (post.html || '') +
80
+ (attachments ? '<div class="bb-channel-attachments">' + attachments + '</div>' : '') +
81
+ (tags ? '<footer class="bb-channel-tags">' + tags + '</footer>' : '') +
82
+ '</div>' +
83
+ (media ? '<div class="bb-channel-media-rail" data-bb-media-rail>' + media + '</div>' : '') +
84
+ '</div>' +
85
+ (media ? '<div class="bb-channel-image-viewer" data-bb-image-viewer hidden>' +
86
+ (carousel ? '<div class="bb-channel-image-viewer-bar"><div class="bb-channel-image-count" data-bb-image-count></div></div>' : '') +
87
+ (carousel ? '<button class="bb-channel-image-nav bb-channel-image-prev" type="button" data-bb-image-prev aria-label="Previous image">‹</button>' : '') +
88
+ '<div class="bb-channel-image-track" data-bb-image-track tabindex="0" aria-label="Image carousel">' + viewerImages + '</div>' +
89
+ (carousel ? '<button class="bb-channel-image-nav bb-channel-image-next" type="button" data-bb-image-next aria-label="Next image">›</button>' : '') +
90
+ '</div>' : '') +
91
+ '</div>' +
92
+ '</section>';
93
+ };
94
+
95
+ const focusWithoutScroll = (element) => {
96
+ if (!element || typeof element.focus !== 'function') return;
97
+ try {
98
+ element.focus({ preventScroll: true });
99
+ } catch (error) {
100
+ element.focus();
101
+ }
102
+ };
103
+
104
+ const preserveCardViewportPosition = (card, update) => {
105
+ const initialTop = card.getBoundingClientRect().top;
106
+ update();
107
+ const restore = () => {
108
+ const delta = card.getBoundingClientRect().top - initialTop;
109
+ if (Math.abs(delta) > 0.5) window.scrollBy(0, delta);
110
+ };
111
+ requestAnimationFrame(() => {
112
+ restore();
113
+ requestAnimationFrame(restore);
114
+ });
115
+ };
116
+
117
+ const openImageViewer = (card, index) => {
118
+ const viewer = card.querySelector('[data-bb-image-viewer]');
119
+ const track = card.querySelector('[data-bb-image-track]');
120
+ const slides = [...card.querySelectorAll('[data-bb-image-slide]')];
121
+ const count = card.querySelector('[data-bb-image-count]');
122
+ if (!viewer || !track || !slides.length) return;
123
+ const safeIndex = (Number(index) + slides.length) % slides.length;
124
+ card.dataset.bbImageIndex = String(safeIndex);
125
+ if (count) count.textContent = slides.length > 1 ? (safeIndex + 1) + ' / ' + slides.length : '';
126
+ preserveCardViewportPosition(card, () => {
127
+ window.clearTimeout(card.bbViewerCloseTimer);
128
+ delete card.dataset.bbViewerClosing;
129
+ viewer.hidden = false;
130
+ hydrateImages(slides[safeIndex]);
131
+ requestAnimationFrame(() => {
132
+ card.dataset.bbViewerOpen = 'true';
133
+ track.scrollTo({ left: track.clientWidth * safeIndex, behavior: 'smooth' });
134
+ focusWithoutScroll(track);
135
+ });
136
+ });
137
+ };
138
+
139
+ const closeImageViewer = (card) => {
140
+ const viewer = card.querySelector('[data-bb-image-viewer]');
141
+ if (!viewer || viewer.hidden || card.dataset.bbViewerClosing === 'true') return;
142
+ preserveCardViewportPosition(card, () => {
143
+ const thumb = card.querySelector('[data-bb-media-index="' + (card.dataset.bbImageIndex || '0') + '"]');
144
+ card.dataset.bbViewerClosing = 'true';
145
+ delete card.dataset.bbViewerOpen;
146
+ focusWithoutScroll(thumb);
147
+ window.clearTimeout(card.bbViewerCloseTimer);
148
+ const finishClose = () => {
149
+ if (card.dataset.bbViewerClosing !== 'true' || card.dataset.bbViewerOpen === 'true') return;
150
+ viewer.hidden = true;
151
+ delete card.dataset.bbViewerClosing;
152
+ };
153
+ card.bbViewerCloseTimer = window.setTimeout(finishClose, 460);
154
+ });
155
+ };
156
+
157
+ const moveImageViewer = (card, delta) => {
158
+ const current = Number(card.dataset.bbImageIndex || 0);
159
+ openImageViewer(card, current + delta);
160
+ };
161
+
162
+ const syncImageViewerIndex = (track) => {
163
+ const card = track.closest('.bb-channel-card');
164
+ const slides = [...card.querySelectorAll('[data-bb-image-slide]')];
165
+ const count = card.querySelector('[data-bb-image-count]');
166
+ if (!slides.length) return;
167
+ const nextIndex = Math.max(0, Math.min(slides.length - 1, Math.round(track.scrollLeft / Math.max(1, track.clientWidth))));
168
+ card.dataset.bbImageIndex = String(nextIndex);
169
+ if (count) count.textContent = slides.length > 1 ? (nextIndex + 1) + ' / ' + slides.length : '';
170
+ };
171
+
172
+ const hydrateImages = (scope) => {
173
+ scope.querySelectorAll('img[data-bb-image-url]').forEach((img) => {
174
+ if (img.dataset.bbHydrated === 'true') return;
175
+ const holder = img.closest('.bb-channel-media-thumb,.bb-channel-image-slide');
176
+ img.dataset.bbHydrated = 'true';
177
+ img.addEventListener('load', () => {
178
+ if (holder) holder.dataset.bbLoaded = 'true';
179
+ }, { once: true });
180
+ img.addEventListener('error', () => {
181
+ if (holder) holder.dataset.bbLoaded = 'error';
182
+ }, { once: true });
183
+ img.src = img.dataset.bbImageUrl;
184
+ if (img.complete && holder) holder.dataset.bbLoaded = 'true';
185
+ });
186
+ };
187
+
188
+ const loadPage = async (page = 1) => {
189
+ if (!apiBase) {
190
+ status.textContent = 'Missing bb_channel.api_base';
191
+ return;
192
+ }
193
+ status.textContent = 'Loading...';
194
+ const url = new URL('/api/posts', apiBase);
195
+ url.searchParams.set('page', page);
196
+ url.searchParams.set('page_size', pageSize);
197
+ const response = await fetch(url.toString());
198
+ if (!response.ok) throw new Error('API responded ' + response.status);
199
+ const data = await response.json();
200
+ feed.innerHTML = (data.posts || []).map(renderCard).join('');
201
+ hydrateImages(feed);
202
+ status.textContent = '';
203
+ pager.innerHTML = '';
204
+ if (data.pagination && data.pagination.total > 1) {
205
+ const prev = data.pagination.hasPrev ? '<button data-page="' + (data.pagination.page - 1) + '">上一页</button>' : '';
206
+ const next = data.pagination.hasNext ? '<button data-page="' + (data.pagination.page + 1) + '">下一页</button>' : '';
207
+ pager.innerHTML = prev + '<span>' + data.pagination.page + ' / ' + data.pagination.total + '</span>' + next;
208
+ }
209
+ };
210
+
211
+ pager.addEventListener('click', (event) => {
212
+ const button = event.target.closest('button[data-page]');
213
+ if (button) loadPage(button.dataset.page).catch((error) => { status.textContent = error.message; });
214
+ });
215
+ feed.addEventListener('click', (event) => {
216
+ const thumb = event.target.closest('[data-bb-media-index]');
217
+ const prev = event.target.closest('[data-bb-image-prev]');
218
+ const next = event.target.closest('[data-bb-image-next]');
219
+ const large = event.target.closest('.bb-channel-image-large');
220
+ const card = event.target.closest('.bb-channel-card');
221
+ if (!card) return;
222
+ if (large) {
223
+ event.preventDefault();
224
+ closeImageViewer(card);
225
+ return;
226
+ }
227
+ if (thumb) {
228
+ openImageViewer(card, thumb.dataset.bbMediaIndex);
229
+ return;
230
+ }
231
+ if (prev) {
232
+ event.preventDefault();
233
+ moveImageViewer(card, -1);
234
+ return;
235
+ }
236
+ if (next) {
237
+ event.preventDefault();
238
+ moveImageViewer(card, 1);
239
+ }
240
+ });
241
+ feed.addEventListener('keydown', (event) => {
242
+ const card = event.target.closest('.bb-channel-card');
243
+ if (!card || !card.querySelector('[data-bb-image-viewer]:not([hidden])')) return;
244
+ if (event.key === 'ArrowLeft') {
245
+ event.preventDefault();
246
+ moveImageViewer(card, -1);
247
+ }
248
+ if (event.key === 'ArrowRight') {
249
+ event.preventDefault();
250
+ moveImageViewer(card, 1);
251
+ }
252
+ });
253
+ feed.addEventListener('scroll', (event) => {
254
+ const track = event.target.closest && event.target.closest('[data-bb-image-track]');
255
+ if (track) syncImageViewerIndex(track);
256
+ }, { passive: true, capture: true });
257
+ loadPage().catch((error) => { status.textContent = error.message; });
258
+ });
259
+ window.initBbChannel();
260
+ document.addEventListener('DOMContentLoaded', window.initBbChannel, { once: true });
261
+ document.addEventListener('pjax:success', window.initBbChannel);
262
+ })();
263
+ </script>
264
+ `;
265
+ }
266
+
267
+ function renderClientContent(config) {
268
+ return normalizeHtml(`
269
+ <style>
270
+ .post-block:has(.bb-channel-portable) .post-title,.post-block:has(.bb-channel-portable) .post-meta-container,body:has(.bb-channel-portable) .post-toc-wrap{display:none}
271
+ .bb-channel-portable{max-width:100%;margin:0 auto;color:#242424}
272
+ .bb-channel-portable *{box-sizing:border-box}
273
+ .bb-channel-portable .bb-channel-intro{position:relative;margin:0 0 1.55rem;padding:0;border-bottom:0}
274
+ .bb-channel-portable .bb-channel-title{margin:0 0 .45rem;color:#202020;font-family:inherit;font-size:1.72rem;font-weight:700;line-height:1.2;letter-spacing:-.02em}
275
+ .bb-channel-portable .bb-channel-intro-text{margin:0;color:#777;font-size:1rem;line-height:1.6;text-align:left}
276
+ .bb-channel-portable .bb-channel-status{min-height:1.2rem;margin:.4rem 0 .8rem;color:#999}
277
+ .bb-channel-portable .bb-channel-feed{position:relative;display:flex;flex-direction:column;gap:1.05rem;padding-left:2.1rem}
278
+ .bb-channel-portable .bb-channel-feed::before{content:"";position:absolute;left:.22rem;top:.25rem;bottom:.25rem;width:1px;background:linear-gradient(to bottom,rgba(226,226,226,0),#e2e2e2 1.2rem,#e2e2e2 calc(100% - 1.2rem),rgba(226,226,226,0))}
279
+ .bb-channel-portable .bb-channel-card{position:relative;border-radius:14px}
280
+ .bb-channel-portable .bb-channel-card-placeholder{pointer-events:none;position:absolute;inset:0;border:1px dashed #d9d9d9;border-radius:14px;background:rgba(255,255,255,.2);opacity:0;transition:opacity 320ms cubic-bezier(.22,1,.36,1)}
281
+ .bb-channel-portable .bb-channel-card-surface{position:relative;z-index:1;border:1px dashed #d9d9d9;border-radius:14px;background:rgba(255,255,255,.66);padding:1.45rem 1.65rem;box-shadow:none;transition:border-color 320ms cubic-bezier(.22,1,.36,1),background-color 320ms cubic-bezier(.22,1,.36,1),box-shadow 320ms cubic-bezier(.22,1,.36,1),transform 320ms cubic-bezier(.22,1,.36,1)}
282
+ .bb-channel-portable .bb-channel-card:hover .bb-channel-card-placeholder{opacity:1}
283
+ .bb-channel-portable .bb-channel-card:hover .bb-channel-card-surface{transform:translate(6px,-6px);border-style:solid;border-color:#e7b99d;background:rgba(255,255,255,.86);box-shadow:0 14px 30px -28px rgba(15,23,42,.32)}
284
+ .bb-channel-portable .bb-channel-dot{position:absolute;left:-2.16rem;top:1.7rem;z-index:2;width:.56rem;height:.56rem;border-radius:999px;background:#ff7900;box-shadow:0 0 0 .28rem rgba(255,121,0,.12)}
285
+ .bb-channel-portable .bb-channel-meta{display:flex;align-items:center;gap:.7rem;margin:0 0 1.08rem;color:#737373;font-weight:500}
286
+ .bb-channel-portable .bb-channel-time{color:inherit;text-decoration:none;border-bottom:0;font-size:.96rem;line-height:1.3}
287
+ .bb-channel-portable .bb-channel-body-with-media{display:grid;grid-template-columns:minmax(0,1fr) minmax(10rem,13.5rem);gap:1.25rem;align-items:start}
288
+ .bb-channel-portable .bb-channel-main{min-width:0}
289
+ .bb-channel-portable .bb-channel-content{font-size:1rem;line-height:1.78;overflow-wrap:anywhere}
290
+ .bb-channel-portable .bb-channel-content p{margin:0 0 .9em}
291
+ .bb-channel-portable .bb-channel-content a{color:#e46f0a;border-bottom:1px solid rgba(228,111,10,.35);text-decoration:none}
292
+ .bb-channel-portable .bb-channel-media-rail{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.32rem;justify-self:end;width:min(13.5rem,100%);max-height:24rem;opacity:1;overflow:hidden;transform:translateY(0);filter:blur(0);transition:max-height 420ms cubic-bezier(.22,1,.36,1),opacity 420ms cubic-bezier(.22,1,.36,1),transform 420ms cubic-bezier(.22,1,.36,1),filter 420ms cubic-bezier(.22,1,.36,1);will-change:max-height,opacity,transform}
293
+ .bb-channel-portable .bb-channel-media-thumb{position:relative;display:grid;min-height:0;aspect-ratio:1/1;place-items:center;border:1px solid #e7e7e7;border-radius:8px;background:#f8f8f8;padding:0;overflow:hidden;cursor:pointer}
294
+ .bb-channel-portable .bb-channel-media-thumb:focus-visible,.bb-channel-portable .bb-channel-image-nav:focus-visible,.bb-channel-portable .bb-channel-image-track:focus-visible{outline:2px solid rgba(255,121,0,.46);outline-offset:3px}
295
+ .bb-channel-portable .bb-channel-media-thumb:only-child{aspect-ratio:16/10;grid-column:1/-1}
296
+ .bb-channel-portable .bb-channel-media-img{position:relative;z-index:1;display:block!important;width:100%!important;height:100%!important;max-width:100%!important;max-height:100%!important;margin:0!important;object-fit:contain!important;opacity:0;transition:opacity 180ms ease}
297
+ .bb-channel-portable [data-bb-loaded="true"]>.bb-channel-media-img,.bb-channel-portable [data-bb-loaded="true"]>.bb-channel-image-large{opacity:1}
298
+ .bb-channel-portable .bb-channel-image-skeleton{position:absolute;inset:0;display:block;border-radius:inherit;background:linear-gradient(100deg,rgba(245,245,245,.78) 0%,rgba(233,233,233,.94) 45%,rgba(247,247,247,.8) 80%);background-size:220% 100%;animation:bb-channel-shimmer 1.15s ease-in-out infinite}
299
+ .bb-channel-portable [data-bb-loaded="true"]>.bb-channel-image-skeleton{display:none}
300
+ .bb-channel-portable [data-bb-loaded="error"]>.bb-channel-image-skeleton{background:#f4eeee}
301
+ .bb-channel-portable .bb-channel-card[data-bb-viewer-open="true"] .bb-channel-media-rail{max-height:0;opacity:0;transform:translateY(14px);filter:blur(2px);pointer-events:none}
302
+ .bb-channel-portable .bb-channel-image-viewer{position:relative;margin-top:0;max-width:100%;max-height:0;overflow:hidden;opacity:0;transform:translateY(-14px);touch-action:pan-y;transition:max-height 420ms cubic-bezier(.22,1,.36,1),margin-top 420ms cubic-bezier(.22,1,.36,1),opacity 420ms cubic-bezier(.22,1,.36,1),transform 420ms cubic-bezier(.22,1,.36,1);will-change:max-height,opacity,transform}
303
+ .bb-channel-portable .bb-channel-image-viewer[hidden]{display:none}
304
+ .bb-channel-portable .bb-channel-card[data-bb-viewer-open="true"] .bb-channel-image-viewer{margin-top:.7rem;max-height:calc(68vh + 4rem);opacity:1;transform:translateY(0)}
305
+ .bb-channel-portable .bb-channel-card[data-bb-viewer-closing="true"] .bb-channel-image-viewer{margin-top:0;max-height:0;opacity:0;transform:translateY(14px);pointer-events:none}
306
+ .bb-channel-portable .bb-channel-image-viewer-bar{display:flex;align-items:center;justify-content:flex-end;gap:.75rem;margin-bottom:.55rem}
307
+ .bb-channel-portable .bb-channel-image-track{display:flex;gap:.75rem;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scrollbar-width:thin}
308
+ .bb-channel-portable .bb-channel-image-slide{position:relative;display:flex;min-width:100%;max-width:100%;min-height:12rem;align-items:flex-start;justify-content:flex-start;margin:0;scroll-snap-align:start;scroll-snap-stop:always}
309
+ .bb-channel-portable .bb-channel-image-slide>.bb-channel-image-skeleton{position:relative;inset:auto;width:min(100%,34rem);aspect-ratio:4/3;border:1px solid #eee;border-radius:10px;flex:0 0 auto}
310
+ .bb-channel-portable .bb-channel-image-large{position:relative;z-index:1;display:block!important;width:auto!important;height:auto!important;max-width:min(100%,34rem)!important;max-height:68vh!important;margin:0!important;border:1px solid #e5e5e5;border-radius:10px;background:#fafafa;object-fit:contain!important;opacity:0;cursor:zoom-out;transition:opacity 420ms cubic-bezier(.22,1,.36,1)}
311
+ .bb-channel-portable .bb-channel-image-nav{position:absolute;top:50%;display:grid;width:2.75rem;height:2.75rem;place-items:center;transform:translateY(-50%);border:1px solid #e1e1e1;border-radius:999px;background:rgba(255,255,255,.82);color:#555;cursor:pointer}
312
+ .bb-channel-portable .bb-channel-image-prev{left:.65rem}
313
+ .bb-channel-portable .bb-channel-image-next{right:.65rem}
314
+ .bb-channel-portable .bb-channel-image-count{margin-top:.45rem;color:#888;font-size:.82rem}
315
+ .bb-channel-portable .bb-channel-attachments{display:flex;flex-direction:column;gap:.65rem;margin-top:.9rem}
316
+ .bb-channel-portable .bb-channel-attachment{display:flex;align-items:center;gap:.8rem;border:1px solid #e3ddd4;border-radius:10px;background:rgba(250,248,243,.58);padding:.82rem .95rem;color:#333;text-decoration:none;transition:border-color 220ms ease,background-color 220ms ease,box-shadow 220ms ease}
317
+ .bb-channel-portable .bb-channel-attachment:hover{border-color:#d6cbbd;background:rgba(255,252,246,.9);box-shadow:0 8px 22px -20px rgba(15,23,42,.28)}
318
+ .bb-channel-portable .bb-channel-attachment-icon{display:grid;width:2rem;height:2rem;place-items:center;flex:0 0 auto;border:1px solid rgba(214,203,189,.82);border-radius:9px;background:rgba(255,255,255,.56);color:#c96b18}
319
+ .bb-channel-portable .bb-channel-lucide{display:block;width:1.08rem;height:1.08rem;stroke-width:1.9}
320
+ .bb-channel-portable .bb-channel-attachment-main{display:flex;min-width:0;flex-direction:column;gap:.12rem}
321
+ .bb-channel-portable .bb-channel-attachment-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.95rem;font-weight:600}
322
+ .bb-channel-portable .bb-channel-attachment-meta{color:#888;font-size:.82rem}
323
+ .bb-channel-portable .bb-channel-tags{display:flex;flex-wrap:wrap;gap:.55rem;margin-top:.9rem;color:#676767;font-size:.86rem}
324
+ .bb-channel-portable .bb-channel-tags span{display:inline-flex;align-items:center;border:0;border-radius:6px;background:#f3f3f3;padding:.18rem .5rem;line-height:1.5}
325
+ .bb-channel-portable .bb-channel-pagination{display:flex;align-items:center;justify-content:center;gap:3rem;margin-top:1.8rem}
326
+ .bb-channel-portable .bb-channel-pagination button,.bb-channel-portable .bb-channel-pagination span{display:inline-flex;min-width:3.4rem;height:2.15rem;align-items:center;justify-content:center;border:1px solid #e8e8e8;border-radius:7px;color:#777;background:#fff}
327
+ .bb-channel-portable .bb-channel-pagination span{min-width:2.15rem;background:#333;color:#fff;border-color:#333}
328
+ @media(max-width:720px){.bb-channel-portable .bb-channel-body-with-media{grid-template-columns:1fr}.bb-channel-portable .bb-channel-media-rail{justify-self:start;width:min(18rem,100%)}}
329
+ @keyframes bb-channel-shimmer{0%{background-position:180% 0}100%{background-position:-80% 0}}
330
+ @media(max-width:640px){.bb-channel-portable .bb-channel-intro{margin-bottom:1.2rem}.bb-channel-portable .bb-channel-title{font-size:1.45rem}.bb-channel-portable .bb-channel-intro-text{font-size:.94rem}.bb-channel-portable .bb-channel-feed{gap:.9rem;padding-left:1.15rem}.bb-channel-portable .bb-channel-feed::before{left:.06rem}.bb-channel-portable .bb-channel-dot{left:-1.32rem;top:1.42rem}.bb-channel-portable .bb-channel-card-surface{padding:1.05rem .95rem;border-radius:12px}.bb-channel-portable .bb-channel-card-placeholder{border-radius:12px}.bb-channel-portable .bb-channel-card:hover .bb-channel-card-surface{transform:none}.bb-channel-portable .bb-channel-meta{margin-bottom:.85rem}.bb-channel-portable .bb-channel-content{font-size:.96rem;line-height:1.72}.bb-channel-portable .bb-channel-media-rail{width:100%;max-width:20rem;grid-template-columns:repeat(3,minmax(0,1fr));justify-self:center;margin-top:.15rem}.bb-channel-portable .bb-channel-media-thumb{min-height:4.1rem}.bb-channel-portable .bb-channel-image-viewer{margin-top:.95rem;max-width:100%}.bb-channel-portable .bb-channel-image-slide{min-height:10rem}.bb-channel-portable .bb-channel-image-large{max-width:100%!important;max-height:68vh!important}.bb-channel-portable .bb-channel-image-nav{width:2.55rem;height:2.55rem}.bb-channel-portable .bb-channel-image-prev{left:.45rem}.bb-channel-portable .bb-channel-image-next{right:.45rem}.bb-channel-portable .bb-channel-attachment{align-items:flex-start;padding:.78rem .82rem}.bb-channel-portable .bb-channel-attachment-title{white-space:normal;overflow-wrap:anywhere}.bb-channel-portable .bb-channel-tags{gap:.42rem}.bb-channel-portable .bb-channel-pagination{gap:1rem}}
331
+ @media(prefers-reduced-motion:reduce){.bb-channel-portable .bb-channel-card-placeholder,.bb-channel-portable .bb-channel-card-surface,.bb-channel-portable .bb-channel-media-rail,.bb-channel-portable .bb-channel-image-viewer,.bb-channel-portable .bb-channel-image-large,.bb-channel-portable .bb-channel-attachment{transition:none}.bb-channel-portable .bb-channel-card:hover .bb-channel-card-surface{transform:none}}
332
+ </style>
333
+ <div class="bb-channel-portable" data-bb-channel-root data-api-base="${escapeHtml(config.apiBase)}" data-page-size="${escapeHtml(config.pageSize)}">
334
+ <header class="bb-channel-intro">
335
+ <div class="bb-channel-title" role="heading" aria-level="1">${escapeHtml(config.title)}</div>
336
+ <p class="bb-channel-intro-text">${escapeHtml(config.description)}</p>
337
+ </header>
338
+ <div class="bb-channel-status" data-bb-channel-status aria-live="polite"></div>
339
+ <article class="bb-channel-feed" data-bb-channel-feed aria-label="Telegram microblog timeline"></article>
340
+ <nav class="bb-channel-pagination" data-bb-channel-pagination aria-label="BB pagination"></nav>
341
+ </div>
342
+ ${renderClientScript()}
343
+ `);
344
+ }
345
+
346
+ module.exports = {
347
+ renderClientContent,
348
+ };