chore: support local debug redirection and auto version gathering during prebuild

This commit is contained in:
AI Engineer 2026-06-03 18:48:56 +08:00
parent 106d344530
commit ee74709efc
10 changed files with 322 additions and 93 deletions

1
.npmrc Normal file
View File

@ -0,0 +1 @@
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}

View File

@ -1,40 +1,52 @@
# @web/loader 模块调度中心
# @apigo.cc/loader 模块调度中心
`@web/loader` 是整个 `@web` 体系的入口调度中心。它负责根据当前环境自动生成 `importmap`,并管理各模块的版本。
`@apigo.cc/loader` 是整个 `@apigo.cc` 体系的入口调度中心。它负责根据当前环境自动生成 `importmap`,并管理各模块的版本。
---
## 一、 集成方式
### 方式一:内联引导 (推荐 - 无额外请求)
在 HTML 的 `<head>` 顶部加入以下代码。这种方式不需要额外请求 `loader.min.js` 文件。
直接引用打包好的 `loader.min.js`,它会自动根据当前的加载源适配 CDN 镜像。
### 1. 自动注入 Importmap
在 HTML 中引入 loader 并声明需要加载的模块:
```html
<!-- 引入 loader -->
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/loader@1.0.0/dist/loader.min.js"></script>
<script>
(function __load(tpl, ...pkgs) {
const DEFAULT_VERSIONS = { state: 'v1.0.11', bootstrap: 'v1.0.1', base: 'v1.0.7' };
const importMap = { imports: {} };
pkgs.forEach(pkg => {
const [name, version] = pkg.split(':');
const v = version || DEFAULT_VERSIONS[name] || 'main';
importMap.imports[`@web/${name}`] = tpl.replace(/{project}/g, name).replace(/{tag}/g, v);
});
const s = document.createElement('script');
s.type = 'importmap';
s.textContent = JSON.stringify(importMap);
document.currentScript.parentNode.insertBefore(s, document.currentScript);
})('https://apigo.cc/web/{project}/raw/tag/{tag}/dist/{project}.js', 'state', 'bootstrap', 'base');
// 定义需要使用的模块,自动生成 importmap
// 支持格式: 'name' 或 'name:version'
// 也可以使用标签模式: 'name:latest', 'name:beta' 等
Loader.load('state:1.0.11', 'bootstrap:1.0.2', 'base:1.0.7');
</script>
<!-- 随后即可使用原生 import -->
<script type="module">
import { $ } from '@apigo.cc/state';
import { Bootstrap } from '@apigo.cc/bootstrap';
console.log('Ready');
</script>
```
### 方式二:引用外部 Loader
直接引用打包好的 `loader.min.js`。它会自动根据当前的 URL 模板进行调度。
### 2. 原生朴素写法 (手动定义)
如果您不想使用 `Loader.load` 的自动注入,也可以手动定义 `importmap`
```html
<script src="https://apigo.cc/web/loader/raw/tag/v1.0.0/dist/loader.min.js"></script>
<script>
// 无需版本号,自动使用内置的默认版本
Loader.load('state', 'bootstrap', 'base');
<script type="importmap">
{
"imports": {
"@apigo.cc/state": "https://cdn.jsdelivr.net/npm/@apigo.cc/state@1.0.11/dist/state.min.js",
"@apigo.cc/bootstrap": "https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.2/dist/bootstrap.min.js"
}
}
</script>
<script type="module">
import { $ } from '@apigo.cc/state';
// ... 业务代码
</script>
```
@ -42,17 +54,48 @@
## 二、 核心特性
1. **智能模板 (Smart Templating)**:自动根据 `loader.js` 的加载路径推断其他组件的下载路径。
2. **默认版本 (Default Versioning)**:构建时内置了 `@web` 家族各成员的推荐稳定版本,解决“版本选择困难症”。
3. **按需加载 (On-demand)**:在页面头部一次性声明所需模块,避免加载无关资源。
1. **多镜像自适应**:自动识别 `loader.min.js` 的来源 URL并自动将其他模块指向相同的 CDN 镜像。支持以下镜像:
* `cdn.jsdelivr.net` (默认)
* `esm.sh`
* `unpkg.com`
* `unpkg.zhimg.com` (中国加速)
* `npm.elemecdn.com` (中国加速)
2. **默认版本管理**:内置了各组件的稳定版本号,无需手动记忆。
3. **零配置**:只需一行代码即可完成整个生态链的模块映射。
---
## 三、 支持模块清单
## 三、 本地开发与调试重定向
在本地开发时,如果需要将某些模块重定向到本地开发服务器进行调试,可以通过在浏览器控制台设置 `localStorage` 来实现,**无需修改任何 HTML 业务代码**。
### 1. 启用本地调试重定向
比如将 `bootstrap``state` 模块重定向到您本地运行的 Vite 开发服务器:
```javascript
// 重定向 bootstrap 模块
localStorage.setItem('dev:@apigo.cc/bootstrap', 'http://localhost:5173/src/index.js');
// 重定向 state 模块
localStorage.setItem('dev:@apigo.cc/state', 'http://localhost:5174/src/index.js');
```
### 2. 取消本地调试重定向
调试完成后,清除 `localStorage` 对应的键即可恢复使用 CDN 版本:
```javascript
// 恢复为 CDN 版本
localStorage.removeItem('dev:@apigo.cc/bootstrap');
localStorage.removeItem('dev:@apigo.cc/state');
```
---
## 四、 支持模块清单
所有模块均发布在 `@apigo.cc` 组织下:
* `state` (原子状态机)
* `bootstrap` (UI 引擎)
* `base` (常用控件库)
* `dataTable` (高性能表格)
* `datatable` (高性能表格)
* `kanban` (看板)
* `mindmap` (思维导图)
* `chart` (图表)
* `editor` (代码编辑器)

59
dist/loader.js vendored
View File

@ -1,41 +1,52 @@
(function(exports) {
"use strict";
const DEFAULT_VERSIONS = {
"state": "v1.0.11",
"bootstrap": "v1.0.1",
"base": "v1.0.7",
"dataTable": "v1.0.6",
"kanban": "v1.0.0",
"mindmap": "v1.0.0",
"chart": "v1.0.0"
"state": "1.0.11",
"bootstrap": "1.0.2",
"base": "1.0.7",
"datatable": "1.0.6",
"kanban": "1.0.0",
"mindmap": "1.0.0",
"chart": "1.0.0",
"editor": "1.0.0",
"loader": "1.0.0"
};
const Loader = {
/**
* 加载指定模块并注入 importmap
* @param {...string} pkgs 模块名格式: 'name' 'name:version'
*/
load: (...pkgs) => {
if (typeof document === "undefined") return;
const currentScript = document.currentScript;
const currentUrl = currentScript ? currentScript.src : "";
let tpl = "https://apigo.cc/web/{project}/raw/tag/{tag}/dist/{project}.js";
if (currentUrl.includes("/loader/")) {
const match = currentUrl.match(/(.*\/web\/)loader\/raw\/tag\/([^\/]+)\//);
if (match) {
const baseUrl = match[1];
const loaderTag = match[2];
tpl = `${baseUrl}{project}/raw/tag/{tag}/dist/{project}.js`;
Object.keys(DEFAULT_VERSIONS).forEach((k) => {
if (DEFAULT_VERSIONS[k] === "v1.0.0") DEFAULT_VERSIONS[k] = loaderTag;
});
let tpl = "https://cdn.jsdelivr.net/npm/@apigo.cc/{project}@{tag}/dist/{project}.min.js";
if (currentUrl) {
if (currentUrl.includes("esm.sh")) {
tpl = "https://esm.sh/@apigo.cc/{project}@{tag}/dist/{project}.min.js";
} else if (currentUrl.includes("unpkg.com")) {
tpl = "https://unpkg.com/@apigo.cc/{project}@{tag}/dist/{project}.min.js";
} else if (currentUrl.includes("unpkg.zhimg.com")) {
tpl = "https://unpkg.zhimg.com/@apigo.cc/{project}@{tag}/dist/{project}.min.js";
} else if (currentUrl.includes("npm.elemecdn.com")) {
tpl = "https://npm.elemecdn.com/@apigo.cc/{project}@{tag}/dist/{project}.min.js";
} else if (currentUrl.includes("jsdelivr.net")) {
tpl = "https://cdn.jsdelivr.net/npm/@apigo.cc/{project}@{tag}/dist/{project}.min.js";
}
}
const importMap = { imports: {} };
pkgs.forEach((pkg) => {
let [name, version] = pkg.split(":");
version = version || DEFAULT_VERSIONS[name] || "main";
const url = tpl.replace(/{project}/g, name).replace(/{tag}/g, version);
importMap.imports[`@web/${name}`] = url;
const key = name.toLowerCase();
const fullKey = `@apigo.cc/${key}`;
let url = "";
try {
if (typeof localStorage !== "undefined") {
url = localStorage.getItem(`dev:${fullKey}`) || "";
}
} catch (e) {
}
if (!url) {
version = version || DEFAULT_VERSIONS[key] || "latest";
url = tpl.replace(/{project}/g, key).replace(/{tag}/g, version);
}
importMap.imports[fullKey] = url;
});
const script = document.createElement("script");
script.type = "importmap";

2
dist/loader.min.js vendored
View File

@ -1 +1 @@
!function(t){"use strict";const e={state:"v1.0.11",bootstrap:"v1.0.1",base:"v1.0.7",dataTable:"v1.0.6",kanban:"v1.0.0",mindmap:"v1.0.0",chart:"v1.0.0"},a={load:(...t)=>{if("undefined"==typeof document)return;const a=document.currentScript,o=a?a.src:"";let r="https://apigo.cc/web/{project}/raw/tag/{tag}/dist/{project}.js";if(o.includes("/loader/")){const t=o.match(/(.*\/web\/)loader\/raw\/tag\/([^\/]+)\//);if(t){const a=t[1],o=t[2];r=`${a}{project}/raw/tag/{tag}/dist/{project}.js`,Object.keys(e).forEach(t=>{"v1.0.0"===e[t]&&(e[t]=o)})}}const c={imports:{}};t.forEach(t=>{let[a,o]=t.split(":");o=o||e[a]||"main";const n=r.replace(/{project}/g,a).replace(/{tag}/g,o);c.imports[`@web/${a}`]=n});const n=document.createElement("script");n.type="importmap",n.textContent=JSON.stringify(c),a?a.parentNode.insertBefore(n,a):document.head.appendChild(n)}};globalThis.Loader=a,t.Loader=a,t.default=a,Object.defineProperties(t,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})}(this.Loader=this.Loader||{});
!function(t){"use strict";const e={state:"1.0.11",bootstrap:"1.0.2",base:"1.0.7",datatable:"1.0.6",kanban:"1.0.0",mindmap:"1.0.0",chart:"1.0.0",editor:"1.0.0",loader:"1.0.0"},c={load:(...t)=>{if("undefined"==typeof document)return;const c=document.currentScript,o=c?c.src:"";let n="https://cdn.jsdelivr.net/npm/@apigo.cc/{project}@{tag}/dist/{project}.min.js";o&&(o.includes("esm.sh")?n="https://esm.sh/@apigo.cc/{project}@{tag}/dist/{project}.min.js":o.includes("unpkg.com")?n="https://unpkg.com/@apigo.cc/{project}@{tag}/dist/{project}.min.js":o.includes("unpkg.zhimg.com")?n="https://unpkg.zhimg.com/@apigo.cc/{project}@{tag}/dist/{project}.min.js":o.includes("npm.elemecdn.com")?n="https://npm.elemecdn.com/@apigo.cc/{project}@{tag}/dist/{project}.min.js":o.includes("jsdelivr.net")&&(n="https://cdn.jsdelivr.net/npm/@apigo.cc/{project}@{tag}/dist/{project}.min.js"));const s={imports:{}};t.forEach(t=>{let[c,o]=t.split(":");const i=c.toLowerCase(),p=`@apigo.cc/${i}`;let a="";try{"undefined"!=typeof localStorage&&(a=localStorage.getItem(`dev:${p}`)||"")}catch(t){}a||(o=o||e[i]||"latest",a=n.replace(/{project}/g,i).replace(/{tag}/g,o)),s.imports[p]=a});const i=document.createElement("script");i.type="importmap",i.textContent=JSON.stringify(s),c?c.parentNode.insertBefore(i,c):document.head.appendChild(i)}};globalThis.Loader=c,t.Loader=c,t.default=c,Object.defineProperties(t,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})}(this.Loader=this.Loader||{});

2
node_modules/.package-lock.json generated vendored
View File

@ -1,5 +1,5 @@
{
"name": "loader",
"name": "@apigo.cc/loader",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,

4
package-lock.json generated
View File

@ -1,11 +1,11 @@
{
"name": "loader",
"name": "@apigo.cc/loader",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loader",
"name": "@apigo.cc/loader",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {

View File

@ -1,10 +1,17 @@
{
"name": "loader",
"name": "@apigo.cc/loader",
"version": "1.0.0",
"type": "module",
"main": "dist/loader.js",
"module": "dist/loader.js",
"files": [
"dist"
],
"scripts": {
"dev": "vite",
"build": "vite build"
"prebuild": "node scripts/update-versions.js",
"build": "vite build",
"pub": "node scripts/publish.js"
},
"keywords": [],
"author": "",

48
scripts/publish.js Normal file
View File

@ -0,0 +1,48 @@
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
try {
// 1. 获取最新 tag
let tag;
try {
tag = execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
} catch (err) {
throw new Error('Failed to find git tags. Please make sure the repository has tags (e.g., v1.0.0) before publishing.');
}
// 去掉 v 前缀
const version = tag.startsWith('v') ? tag.slice(1) : tag;
console.log(`Latest git tag: ${tag}, Version to publish: ${version}`);
// 2. 读取并更新 package.json
const pkgPath = path.join(__dirname, '../package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
// 保持原有名称(如果已经带有 @apigo.cc/ 前缀)或替换前缀
if (!pkg.name.startsWith('@apigo.cc/')) {
const baseName = pkg.name.includes('/') ? pkg.name.split('/')[1] : pkg.name;
pkg.name = `@apigo.cc/${baseName}`;
}
pkg.version = version;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`Updated package.json: name=${pkg.name}, version=${pkg.version}`);
// 3. 构建
console.log('Running build...');
execSync('npm run build', { stdio: 'inherit', cwd: path.join(__dirname, '..') });
// 4. 发布
console.log('Publishing to npm...');
const args = process.argv.slice(2).join(' ');
execSync(`npm publish --access public ${args}`, { stdio: 'inherit', cwd: path.join(__dirname, '..') });
console.log('Publish successful!');
} catch (error) {
console.error('Publish failed:', error.message);
process.exit(1);
}

115
scripts/update-versions.js Normal file
View File

@ -0,0 +1,115 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const parentDir = path.join(__dirname, '../..');
// 默认版本,如果没扫到就用这个
const versions = {
'state': '1.0.11',
'bootstrap': '1.0.0',
'base': '1.0.7',
'datatable': '1.0.6',
'kanban': '1.0.0',
'mindmap': '1.0.0',
'chart': '1.0.0',
'editor': '1.0.0'
};
// 1. 扫描同级目录获取最新版本号
try {
const dirs = fs.readdirSync(parentDir);
dirs.forEach(dir => {
const pkgPath = path.join(parentDir, dir, 'package.json');
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (pkg.name && pkg.name.startsWith('@apigo.cc/')) {
const key = pkg.name.replace('@apigo.cc/', '').toLowerCase();
if (pkg.version) {
versions[key] = pkg.version;
}
}
} catch (e) {
console.warn(`Failed to parse ${pkgPath}:`, e.message);
}
}
});
} catch (err) {
console.error('Failed to read parent directory:', err.message);
}
console.log('Gathered versions:', versions);
// 2. 获取 loader 自己的版本号
const loaderPkgPath = path.join(__dirname, '../package.json');
let loaderVersion = '1.0.0';
try {
const loaderPkg = JSON.parse(fs.readFileSync(loaderPkgPath, 'utf8'));
loaderVersion = loaderPkg.version || '1.0.0';
} catch (e) {
console.warn('Failed to read loader package.json:', e.message);
}
// 3. 更新 loader/src/index.js
const indexPath = path.join(__dirname, '../src/index.js');
if (fs.existsSync(indexPath)) {
let indexContent = fs.readFileSync(indexPath, 'utf8');
const versionsString = Object.entries(versions)
.map(([k, v]) => ` '${k}': '${v}'`)
.join(',\n');
const newDefaultVersions = `const DEFAULT_VERSIONS = {\n${versionsString}\n};`;
indexContent = indexContent.replace(/const DEFAULT_VERSIONS = \{[\s\S]*?\};/, newDefaultVersions);
fs.writeFileSync(indexPath, indexContent, 'utf8');
console.log('Updated loader/src/index.js DEFAULT_VERSIONS.');
}
// 4. 更新 loader/README.md
const readmePath = path.join(__dirname, '../README.md');
if (fs.existsSync(readmePath)) {
let readmeContent = fs.readFileSync(readmePath, 'utf8');
// 替换 Loader.load('state', 'bootstrap', 'base') 里的版本号
// 支持形如 Loader.load('state:x.y.z', 'bootstrap:x.y.z', 'base:x.y.z')
// 或者最原始的 Loader.load('state', 'bootstrap', 'base')
readmeContent = readmeContent.replace(
/Loader\.load\(([^)]*)\)/g,
(match, p1) => {
// 提取被单引号/双引号包裹的各个 package 项
const items = p1.split(',').map(item => item.trim().replace(/['"]/g, ''));
const newItems = items.map(item => {
const [name] = item.split(':');
const key = name.toLowerCase();
if (versions[key]) {
return `'${key}:${versions[key]}'`;
}
return `'${item}'`;
});
return `Loader.load(${newItems.join(', ')})`;
}
);
// 替换 cdn 链接里的 loader 版本
// 如 <script src="https://cdn.jsdelivr.net/npm/@apigo.cc/loader@1.0.0/dist/loader.min.js"></script>
readmeContent = readmeContent.replace(
/(\/\/cdn\.jsdelivr\.net\/npm\/@apigo\.cc\/loader@)[0-9.]+(\/dist\/loader\.min\.js)/g,
`$1${loaderVersion}$2`
);
// 替换 manual import 里的依赖版本,如:
// "@apigo.cc/state": "https://cdn.jsdelivr.net/npm/@apigo.cc/state@1.0.11/dist/state.min.js",
readmeContent = readmeContent.replace(
/(\/@apigo\.cc\/([a-zA-Z0-9_-]+)@)[0-9.]+(\/dist\/)/g,
(match, prefix, name, suffix) => {
const key = name.toLowerCase();
const ver = key === 'loader' ? loaderVersion : (versions[key] || 'latest');
return `${prefix}${ver}${suffix}`;
}
);
fs.writeFileSync(readmePath, readmeContent, 'utf8');
console.log('Updated loader/README.md versions.');
}

View File

@ -4,42 +4,39 @@
*/
const DEFAULT_VERSIONS = {
'state': 'v1.0.11',
'bootstrap': 'v1.0.1',
'base': 'v1.0.7',
'dataTable': 'v1.0.6',
'kanban': 'v1.0.0',
'mindmap': 'v1.0.0',
'chart': 'v1.0.0'
'state': '1.0.11',
'bootstrap': '1.0.2',
'base': '1.0.7',
'datatable': '1.0.6',
'kanban': '1.0.0',
'mindmap': '1.0.0',
'chart': '1.0.0',
'editor': '1.0.0',
'loader': '1.0.0'
};
const Loader = {
/**
* 加载指定模块并注入 importmap
* @param {...string} pkgs 模块名格式: 'name' 'name:version'
*/
load: (...pkgs) => {
if (typeof document === 'undefined') return;
const currentScript = document.currentScript;
const currentUrl = currentScript ? currentScript.src : '';
// 解析 URL 模板
// 假设当前 URL 是 https://apigo.cc/web/loader/raw/tag/v1.0.0/dist/loader.min.js
// 目标模板 https://apigo.cc/web/{project}/raw/tag/{tag}/dist/{project}.js
let tpl = 'https://apigo.cc/web/{project}/raw/tag/{tag}/dist/{project}.js';
if (currentUrl.includes('/loader/')) {
// 自动推断基础路径和 tag
const match = currentUrl.match(/(.*\/web\/)loader\/raw\/tag\/([^\/]+)\//);
if (match) {
const baseUrl = match[1];
const loaderTag = match[2];
tpl = `${baseUrl}{project}/raw/tag/{tag}/dist/{project}.js`;
// 默认情况下其他库也使用和 loader 相同的 tag除非另有定义
Object.keys(DEFAULT_VERSIONS).forEach(k => {
if (DEFAULT_VERSIONS[k] === 'v1.0.0') DEFAULT_VERSIONS[k] = loaderTag;
});
// 1. 默认模板 (jsDelivr)
let tpl = 'https://cdn.jsdelivr.net/npm/@apigo.cc/{project}@{tag}/dist/{project}.min.js';
// 2. 根据当前 loader.js 来源自动适配镜像
if (currentUrl) {
if (currentUrl.includes('esm.sh')) {
tpl = 'https://esm.sh/@apigo.cc/{project}@{tag}/dist/{project}.min.js';
} else if (currentUrl.includes('unpkg.com')) {
tpl = 'https://unpkg.com/@apigo.cc/{project}@{tag}/dist/{project}.min.js';
} else if (currentUrl.includes('unpkg.zhimg.com')) {
tpl = 'https://unpkg.zhimg.com/@apigo.cc/{project}@{tag}/dist/{project}.min.js';
} else if (currentUrl.includes('npm.elemecdn.com')) {
tpl = 'https://npm.elemecdn.com/@apigo.cc/{project}@{tag}/dist/{project}.min.js';
} else if (currentUrl.includes('jsdelivr.net')) {
tpl = 'https://cdn.jsdelivr.net/npm/@apigo.cc/{project}@{tag}/dist/{project}.min.js';
}
}
@ -47,15 +44,22 @@ const Loader = {
pkgs.forEach(pkg => {
let [name, version] = pkg.split(':');
version = version || DEFAULT_VERSIONS[name] || 'main';
const url = tpl.replace(/{project}/g, name).replace(/{tag}/g, version);
importMap.imports[`@web/${name}`] = url;
// 特殊处理:如果是 bootstrap可能需要额外处理或依赖
if (name === 'bootstrap') {
// bootstrap 内部现在是自包含的,直接映射即可
const key = name.toLowerCase();
const fullKey = `@apigo.cc/${key}`;
let url = '';
// 优先检查本地 localStorage 重定向配置,方便开发调试
try {
if (typeof localStorage !== 'undefined') {
url = localStorage.getItem(`dev:${fullKey}`) || '';
}
} catch (e) {}
if (!url) {
version = version || DEFAULT_VERSIONS[key] || 'latest';
url = tpl.replace(/{project}/g, key).replace(/{tag}/g, version);
}
importMap.imports[fullKey] = url;
});
const script = document.createElement('script');