* pref: optimize the diff to make it more accurately reflect changes * pref: optimize the diff page selection
594 lines
18 KiB
TypeScript
594 lines
18 KiB
TypeScript
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
|
||
import Cookies from 'js-cookie';
|
||
import JSZip from 'jszip';
|
||
import { atom, type WritableAtom } from 'nanostores';
|
||
import { toast } from 'sonner';
|
||
import { formatFile } from '~/.client/utils/prettier';
|
||
import type { ChangeSource, Page } from '~/types/actions';
|
||
import type { PageMap } from '~/types/pages';
|
||
import { base64ToBinary, getContentType, getExtensionFromMimeType, getFileName } from '~/utils/file-utils';
|
||
import { createScopedLogger } from '~/utils/logger';
|
||
import { ChatStore } from './chat';
|
||
import { EditorStore } from './editor';
|
||
import { PagesStore } from './pages';
|
||
import { PreviewsStore } from './previews';
|
||
|
||
const logger = createScopedLogger('WebBuilderStore');
|
||
|
||
export type WebBuilderViewType = 'code' | 'diff' | 'preview';
|
||
|
||
export type GetProjectFilesOptions = {
|
||
inline?: boolean;
|
||
pathMode?: 'relative' | 'absolute';
|
||
};
|
||
|
||
export type ExportEditorFile = {
|
||
filename: string;
|
||
content: string;
|
||
mimeType: string;
|
||
};
|
||
|
||
/**
|
||
* WebBuilderStore 是整个 builder 的 store,负责管理、统筹所有与构建器有关的状态。
|
||
*/
|
||
export class WebBuilderStore {
|
||
readonly chatStore: ChatStore;
|
||
readonly previewsStore: PreviewsStore;
|
||
readonly pagesStore: PagesStore;
|
||
readonly editorStore: EditorStore;
|
||
|
||
// 是否显示 webBuilder
|
||
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data?.showWorkbench ?? atom(false);
|
||
// 当前 webBuilder 所在的视图
|
||
currentView: WritableAtom<WebBuilderViewType> = import.meta.hot?.data?.currentView ?? atom('code');
|
||
|
||
constructor() {
|
||
this.previewsStore = new PreviewsStore();
|
||
this.pagesStore = new PagesStore();
|
||
this.chatStore = new ChatStore(this, this.pagesStore);
|
||
this.editorStore = new EditorStore(this.pagesStore);
|
||
|
||
if (import.meta.hot && import.meta.hot.data) {
|
||
import.meta.hot.data.showWorkbench = this.showWorkbench;
|
||
import.meta.hot.data.currentView = this.currentView;
|
||
}
|
||
|
||
this.setupCoordination();
|
||
}
|
||
|
||
private setupCoordination() {
|
||
this.currentView.listen((view) => {
|
||
if (view === 'preview') {
|
||
this.getProjectFiles({ pathMode: 'absolute' }).then((files) => {
|
||
this.setPreviews(files);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 手动设置页面数据,通常用于初始化页面数据。
|
||
* @param pages 页面数据
|
||
*/
|
||
setPages(pages: PageMap) {
|
||
this.editorStore.setDocuments(pages, true);
|
||
for (const [pageName, page] of Object.entries(pages)) {
|
||
if (page) {
|
||
this.pagesStore.setPage(pageName, page);
|
||
this.pagesStore.savePageHistory(pageName, page.content as string, 'initial');
|
||
}
|
||
}
|
||
|
||
if (this.pagesStore.pagesCount > 0 && this.editorStore.currentDocument.get() === undefined) {
|
||
// 找到第一个页面并选中
|
||
for (const [pageName] of Object.entries(pages)) {
|
||
this.setSelectedPage(pageName);
|
||
this.setActiveSectionByPageName(pageName);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
setSelectedPage(pageName: string | undefined) {
|
||
this.pagesStore.setActivePage(pageName);
|
||
}
|
||
|
||
setActiveSectionByPageName(pageName: string) {
|
||
const page = this.pagesStore.getPage(pageName);
|
||
if (page) {
|
||
this.setActiveSection(page.actionIds[page.actionIds.length - 1]);
|
||
}
|
||
}
|
||
|
||
setActiveSection(sectionId: string | undefined) {
|
||
this.pagesStore.setActiveSection(sectionId);
|
||
}
|
||
|
||
setPreviews(files: ExportEditorFile[]) {
|
||
this.previewsStore.setPreviews(files);
|
||
|
||
const currentDocument = this.editorStore.currentDocument.get();
|
||
const pageName = currentDocument?.name;
|
||
if (pageName) {
|
||
this.previewsStore.setCurrentPreview(`${pageName}.html`);
|
||
}
|
||
}
|
||
|
||
setShowWorkbench(show: boolean) {
|
||
this.showWorkbench.set(show);
|
||
}
|
||
|
||
async setDocumentContent(pageName: string, _html: string) {
|
||
if (_html.trim() === '') {
|
||
return;
|
||
}
|
||
// 更新内容,但不会触发保存,不会保存至 pages 中。
|
||
this.editorStore.updateDocumentContent(pageName, _html);
|
||
}
|
||
|
||
async saveDocument(pageName: string, changeSource: ChangeSource) {
|
||
const documents = this.editorStore.editorDocuments.get();
|
||
const pageProperties = documents[pageName];
|
||
if (pageProperties === undefined) {
|
||
return;
|
||
}
|
||
// 触发 page 的保存
|
||
this.pagesStore.savePage(pageName, pageProperties.content as string, changeSource).then(() => {
|
||
this.editorStore.removeUnsavedDocument(pageName, true);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 将当前页面重置为上次保存的状态。
|
||
* @returns
|
||
*/
|
||
resetCurrentPage() {
|
||
const currentPage = this.editorStore.currentDocument.get();
|
||
if (currentPage === undefined) {
|
||
return;
|
||
}
|
||
|
||
const { name: pageName } = currentPage;
|
||
const page = this.pagesStore.getPage(pageName as string);
|
||
if (!page) {
|
||
return;
|
||
}
|
||
this.editorStore.updateDocumentContent(pageName as string, page.content as string);
|
||
}
|
||
|
||
async saveAllPages(changeSource: ChangeSource) {
|
||
for (const pageName of this.editorStore.unsavedDocuments.get()) {
|
||
await this.saveDocument(pageName, changeSource);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建一个页面,通常由用户手动创建。
|
||
* @param pageName 页面名称
|
||
* @param pageTitle 页面标题
|
||
* @returns 是否成功
|
||
*/
|
||
async createPage(pageName: string, pageTitle = '未命名页面') {
|
||
try {
|
||
// 只需更新 pagesStore,Document 将会监听 pagesStore 的变化,并更新自身。
|
||
return await this.pagesStore.createPage(pageName, pageTitle);
|
||
} catch (error) {
|
||
console.error('Failed to create page:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除一个页面,
|
||
* @param pageName 页面名称
|
||
* @returns 是否成功
|
||
*/
|
||
async deletePage(pageName: string) {
|
||
try {
|
||
const currentDocument = this.editorStore.currentDocument.get();
|
||
const isInCurrentPage = currentDocument?.name === pageName;
|
||
const success = await this.pagesStore.deletePage(pageName);
|
||
if (success) {
|
||
if (isInCurrentPage) {
|
||
const pages = this.pagesStore.pages.get();
|
||
let nextPage: string | undefined = undefined;
|
||
|
||
for (const [path] of Object.entries(pages)) {
|
||
nextPage = path;
|
||
break;
|
||
}
|
||
|
||
this.setSelectedPage(nextPage);
|
||
}
|
||
}
|
||
|
||
return success;
|
||
} catch (error) {
|
||
console.error('Failed to delete page:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async pushToGitHub(repoName: string, commitMessage?: string, githubUsername?: string, ghToken?: string) {
|
||
try {
|
||
// Use cookies if username and token are not provided
|
||
const githubToken = ghToken || Cookies.get('githubToken');
|
||
const owner = githubUsername || Cookies.get('githubUsername');
|
||
|
||
if (!githubToken || !owner) {
|
||
throw new Error('GitHub token or username is not set in cookies or provided.');
|
||
}
|
||
|
||
// Initialize Octokit with the auth token
|
||
const octokit = new Octokit({ auth: githubToken });
|
||
|
||
// Check if the repository already exists before creating it
|
||
let repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
|
||
|
||
try {
|
||
const resp = await octokit.repos.get({ owner, repo: repoName });
|
||
repo = resp.data;
|
||
} catch (error) {
|
||
if (error instanceof Error && 'status' in error && error.status === 404) {
|
||
// Repository doesn't exist, so create a new one
|
||
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
|
||
name: repoName,
|
||
private: false,
|
||
auto_init: true,
|
||
});
|
||
repo = newRepo;
|
||
} else {
|
||
console.log('cannot create repo!');
|
||
throw error; // Some other error occurred
|
||
}
|
||
}
|
||
|
||
// Get all pages
|
||
const pages = await this.getProjectFilesAsMap({
|
||
inline: false,
|
||
});
|
||
if (!pages || Object.keys(pages).length === 0) {
|
||
throw new Error('No pages found to push');
|
||
}
|
||
|
||
// Create blobs for each file
|
||
const blobs = await Promise.all(
|
||
Object.entries(pages).map(async ([path, content]) => {
|
||
if (path && content) {
|
||
const formatContent = await formatFile(path, content);
|
||
const { data: blob } = await octokit.git.createBlob({
|
||
owner: repo.owner.login,
|
||
repo: repo.name,
|
||
content: Buffer.from(formatContent).toString('base64'),
|
||
encoding: 'base64',
|
||
});
|
||
return { path, sha: blob.sha };
|
||
}
|
||
|
||
return null;
|
||
}),
|
||
);
|
||
|
||
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
|
||
|
||
if (validBlobs.length === 0) {
|
||
throw new Error('No valid files to push');
|
||
}
|
||
|
||
// Get the latest commit SHA (assuming main branch, update dynamically if needed)
|
||
const { data: ref } = await octokit.git.getRef({
|
||
owner: repo.owner.login,
|
||
repo: repo.name,
|
||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||
});
|
||
const latestCommitSha = ref.object.sha;
|
||
|
||
// Create a new tree
|
||
const { data: newTree } = await octokit.git.createTree({
|
||
owner: repo.owner.login,
|
||
repo: repo.name,
|
||
base_tree: latestCommitSha,
|
||
tree: validBlobs.map((blob) => ({
|
||
path: blob!.path,
|
||
mode: '100644',
|
||
type: 'blob',
|
||
sha: blob!.sha,
|
||
})),
|
||
});
|
||
|
||
// Create a new commit
|
||
const { data: newCommit } = await octokit.git.createCommit({
|
||
owner: repo.owner.login,
|
||
repo: repo.name,
|
||
message: commitMessage || 'Initial commit from your app',
|
||
tree: newTree.sha,
|
||
parents: [latestCommitSha],
|
||
});
|
||
|
||
// Update the reference
|
||
await octokit.git.updateRef({
|
||
owner: repo.owner.login,
|
||
repo: repo.name,
|
||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||
sha: newCommit.sha,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error pushing to GitHub:', error);
|
||
throw error; // Rethrow the error for further handling
|
||
}
|
||
}
|
||
|
||
async exportToZip(prefix: string = 'upage_export') {
|
||
try {
|
||
const projectFiles = await this.getProjectFiles({ inline: false });
|
||
if (projectFiles.length === 0) {
|
||
toast.error('没有可导出的文件');
|
||
return;
|
||
}
|
||
|
||
const zip = new JSZip();
|
||
projectFiles.forEach((file: ExportEditorFile) => {
|
||
if (file.mimeType?.startsWith('text/') || file.filename.endsWith('.js')) {
|
||
zip.file(file.filename, file.content);
|
||
} else {
|
||
zip.file(file.filename, file.content, { binary: true });
|
||
}
|
||
});
|
||
|
||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||
|
||
const url = URL.createObjectURL(zipBlob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${prefix}_${Date.now()}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} catch (error) {
|
||
logger.error('导出 HTML 文件失败', error);
|
||
toast.error('导出 HTML 文件失败');
|
||
}
|
||
}
|
||
|
||
async getProjectFilesAsMap(options: GetProjectFilesOptions = {}): Promise<Record<string, string>> {
|
||
const files = await this.getProjectFiles(options);
|
||
return files.reduce(
|
||
(acc, file) => {
|
||
acc[file.filename] = file.content;
|
||
return acc;
|
||
},
|
||
{} as Record<string, string>,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 获取当前编辑器中的文件。
|
||
* @param inline 是否内联样式及所有图片。如果为 false,则将提取所有本地资源为单独文件。默认为 true。
|
||
* @param pathMode HTML 内的文件路径模式,默认为相对路径。
|
||
* @returns 文件列表。
|
||
*/
|
||
async getProjectFiles({
|
||
inline = true,
|
||
pathMode = 'relative',
|
||
}: GetProjectFilesOptions = {}): Promise<ExportEditorFile[]> {
|
||
const getFiles = async () => {
|
||
const files: ExportEditorFile[] = [];
|
||
for (const page of Object.values(this.pagesStore.pages.get())) {
|
||
if (!page) {
|
||
continue;
|
||
}
|
||
const doc = this.createProjectHead(page, pathMode);
|
||
const pageElement = document.createElement('div');
|
||
pageElement.id = page.name;
|
||
pageElement.innerHTML = page.content || '';
|
||
doc.body.innerHTML = pageElement.innerHTML;
|
||
|
||
const file = {
|
||
filename: `${page.name}.html`,
|
||
content: '',
|
||
mimeType: 'text/html',
|
||
};
|
||
|
||
if (inline) {
|
||
file.content = '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
|
||
files.push(file);
|
||
} else {
|
||
const extractedFiles = await this.extractResources(pageElement);
|
||
doc.body.innerHTML = pageElement.innerHTML;
|
||
file.content = '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
|
||
files.push(file);
|
||
files.push(...extractedFiles);
|
||
}
|
||
}
|
||
|
||
return files;
|
||
};
|
||
|
||
const files: ExportEditorFile[] = await getFiles();
|
||
|
||
if (!inline) {
|
||
const tailwindContent = await fetch('/tailwindcss.js').then((resp) => resp.text());
|
||
files.push({
|
||
filename: 'tailwindcss.js',
|
||
content: tailwindContent,
|
||
mimeType: 'application/javascript',
|
||
});
|
||
const iconifyContent = await fetch('/iconify-icon.min.js').then((resp) => resp.text());
|
||
files.push({
|
||
filename: 'iconify-icon.min.js',
|
||
content: iconifyContent,
|
||
mimeType: 'application/javascript',
|
||
});
|
||
}
|
||
|
||
return files;
|
||
}
|
||
|
||
private createProjectHead(page: Page, pathMode: 'relative' | 'absolute' = 'relative'): Document {
|
||
const basePath = pathMode === 'relative' ? './' : '/';
|
||
const doc = document.implementation.createHTMLDocument('');
|
||
const head = doc.head;
|
||
|
||
const meta = doc.createElement('meta');
|
||
meta.setAttribute('charset', 'UTF-8');
|
||
head.appendChild(meta);
|
||
|
||
const viewport = doc.createElement('meta');
|
||
viewport.setAttribute('name', 'viewport');
|
||
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0');
|
||
head.appendChild(viewport);
|
||
|
||
const title = doc.createElement('title');
|
||
title.textContent = page.title || 'UPage Generated Page';
|
||
head.appendChild(title);
|
||
|
||
const tailwindScript = doc.createElement('script');
|
||
tailwindScript.setAttribute('src', `${basePath}tailwindcss.js`);
|
||
head.appendChild(tailwindScript);
|
||
|
||
const iconifyScript = doc.createElement('script');
|
||
iconifyScript.setAttribute('src', `${basePath}iconify-icon.min.js`);
|
||
head.appendChild(iconifyScript);
|
||
|
||
return doc;
|
||
}
|
||
|
||
/**
|
||
* 提取资源为单独文件
|
||
* @param doc HTML 文档
|
||
* @returns 提取的文件列表
|
||
*/
|
||
private async extractResources(doc: Element, assetsFolder: string = 'assets'): Promise<ExportEditorFile[]> {
|
||
const files: ExportEditorFile[] = [];
|
||
|
||
const resources: {
|
||
element: Element;
|
||
attribute: string;
|
||
value: string;
|
||
}[] = [];
|
||
doc.querySelectorAll('*').forEach((element) => {
|
||
const src = element.getAttribute('src');
|
||
if (src) {
|
||
resources.push({
|
||
element,
|
||
attribute: 'src',
|
||
value: src,
|
||
});
|
||
}
|
||
const href = element.getAttribute('href');
|
||
if (href && element.tagName === 'LINK') {
|
||
resources.push({
|
||
element,
|
||
attribute: 'href',
|
||
value: href,
|
||
});
|
||
}
|
||
const background = element.getAttribute('background');
|
||
if (background) {
|
||
resources.push({
|
||
element,
|
||
attribute: 'background',
|
||
value: background,
|
||
});
|
||
}
|
||
const backgroundImage = element.getAttribute('background-image');
|
||
if (backgroundImage) {
|
||
resources.push({
|
||
element,
|
||
attribute: 'background-image',
|
||
value: backgroundImage,
|
||
});
|
||
}
|
||
});
|
||
|
||
for (const resource of resources) {
|
||
if (this.isRemoteUrl(resource.value) || this.isAnchor(resource.value)) {
|
||
continue;
|
||
}
|
||
|
||
if (resource.value.startsWith('data:')) {
|
||
const mimeType = resource.value.split(';')[0].split(':')[1];
|
||
const base64Content = resource.value.split(',')[1];
|
||
const filename = `${assetsFolder}/${Date.now()}${getExtensionFromMimeType(mimeType)}`;
|
||
resource.element.setAttribute(resource.attribute, `./${filename}`);
|
||
|
||
// 将 base64 转换为二进制字符串
|
||
const binaryString = base64ToBinary(base64Content);
|
||
files.push({
|
||
filename,
|
||
content: binaryString,
|
||
mimeType,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(resource.value, {
|
||
headers: {
|
||
'Content-Type': getContentType(resource.value),
|
||
},
|
||
});
|
||
if (!response.ok) {
|
||
logger.error(`Failed to fetch resource: ${resource.value} (status: ${response.status})`);
|
||
continue;
|
||
}
|
||
const filename = `${assetsFolder}/${getFileName(resource.value)}`;
|
||
resource.element.setAttribute(resource.attribute, `./${filename}`);
|
||
|
||
const mimeType = getContentType(resource.value);
|
||
let content: string;
|
||
|
||
if (this.isTextMimeType(mimeType)) {
|
||
content = await response.text();
|
||
} else {
|
||
const buffer = await response.arrayBuffer();
|
||
const array = new Uint8Array(buffer);
|
||
content = Array.from(array)
|
||
.map((byte) => String.fromCharCode(byte))
|
||
.join('');
|
||
}
|
||
|
||
files.push({
|
||
filename,
|
||
content,
|
||
mimeType,
|
||
});
|
||
} catch (error) {
|
||
logger.error(`Error fetching resource: ${resource.value}`, error);
|
||
continue;
|
||
}
|
||
}
|
||
return files;
|
||
}
|
||
|
||
/**
|
||
* 检查 URL 是否为远程 URL(以 http:// 或 https:// 开头)
|
||
* @param url 要检查的 URL
|
||
* @returns 是否为远程 URL
|
||
*/
|
||
private isRemoteUrl(url: string): boolean {
|
||
return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//');
|
||
}
|
||
|
||
private isAnchor(url: string): boolean {
|
||
return url.startsWith('#');
|
||
}
|
||
|
||
/**
|
||
* 判断是否为文本类型的 MIME 类型
|
||
* @param mimeType MIME 类型
|
||
* @returns 是否为文本类型
|
||
*/
|
||
private isTextMimeType(mimeType: string): boolean {
|
||
return (
|
||
mimeType.startsWith('text/') ||
|
||
mimeType === 'application/javascript' ||
|
||
mimeType === 'application/json' ||
|
||
mimeType === 'application/xml'
|
||
);
|
||
}
|
||
}
|
||
|
||
export const webBuilderStore = new WebBuilderStore();
|