Files
upage-git/app/lib/stores/web-builder.ts

597 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 type { Page } from '~/types/actions';
import { base64ToBinary, getContentType, getExtensionFromMimeType, getFileName } from '~/utils/file-utils';
import { createScopedLogger } from '~/utils/logger';
import { formatFile } from '~/utils/prettier';
import { ChatStore } from './chat';
import { EditorStore } from './editor';
import { type PageMap, 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);
});
}
});
}
setPages(pages: PageMap) {
this.editorStore.setDocuments(pages, true);
for (const [pageName, page] of Object.entries(pages)) {
if (page) {
this.pagesStore.setPage(pageName, page);
}
}
if (this.pagesStore.pagesCount > 0 && this.editorStore.currentDocument.get() === undefined) {
// 找到第一个页面并选中
for (const [pageName] of Object.entries(pages)) {
this.setSelectedPage(pageName);
break;
}
}
}
setSelectedPage(pageName: string | undefined) {
this.pagesStore.setActivePage(pageName);
if (pageName) {
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) {
// 更新内容,但不会触发保存,不会保存至 pages 中。
this.editorStore.updateDocumentContent(pageName, _html ?? '');
}
/**
* 执行保存,将 editorStore 中的当前页面内容同步保存至 PagesStore 中。
* @returns
*/
async saveCurrentDocument() {
const currentPage = this.editorStore.currentDocument.get();
if (!currentPage) {
return;
}
this.saveDocument(currentPage.name as string);
}
async saveDocument(pageName: string) {
const documents = this.editorStore.editorDocuments.get();
const pageProperties = documents[pageName];
if (pageProperties === undefined) {
return;
}
// 触发 page 的保存
this.pagesStore.savePage(pageName, pageProperties.content as string).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() {
for (const pageName of this.editorStore.unsavedDocuments.get()) {
await this.saveDocument(pageName);
}
}
/**
* 创建一个页面,通常由用户手动创建。
* @param pageName 页面名称
* @param pageTitle 页面标题
* @returns 是否成功
*/
async createPage(pageName: string, pageTitle = '未命名页面') {
try {
// 只需更新 pagesStoreDocument 将会监听 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();