🎉 first commit

This commit is contained in:
LIlGG
2025-09-24 13:06:25 +08:00
commit 1f4fb103e9
409 changed files with 61222 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (0) > onActionClose 1`] = `
{
"action": {
"content": "npm install",
"type": "shell",
},
"actionId": "0",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (0) > onActionOpen 1`] = `
{
"action": {
"content": "",
"type": "shell",
},
"actionId": "0",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (0) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (0) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onActionClose 1`] = `
{
"action": {
"content": "npm install",
"type": "shell",
},
"actionId": "0",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onActionClose 2`] = `
{
"action": {
"content": "some content
",
"filePath": "index.js",
"type": "file",
},
"actionId": "1",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onActionOpen 1`] = `
{
"action": {
"content": "",
"type": "shell",
},
"actionId": "0",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onActionOpen 2`] = `
{
"action": {
"content": "",
"filePath": "index.js",
"type": "file",
},
"actionId": "1",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (0) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (0) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (1) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": "bundled",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (1) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": "bundled",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (2) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (2) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (3) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (3) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (4) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (4) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (5) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (5) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (6) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (6) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;

View File

@@ -0,0 +1,237 @@
import { atom, type MapStore, map } from 'nanostores';
import type { EditorBridge } from '~/lib/bridge';
import type { ActionAlert, UPageAction } from '~/types/actions';
import { isValidContent } from '~/utils/html-parse';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
export type ActionPage = {
id: string;
name: string;
title: string;
};
const logger = createScopedLogger('ActionRunner');
export type ActionStatus = 'pending' | 'running' | 'complete' | 'aborted' | 'failed';
export type BaseActionState = UPageAction & {
status: Exclude<ActionStatus, 'failed'>;
abort: () => void;
executed: boolean;
abortSignal: AbortSignal;
};
export type FailedActionState = UPageAction &
Omit<BaseActionState, 'status'> & {
status: Extract<ActionStatus, 'failed'>;
error: string;
};
export type ActionState = BaseActionState | FailedActionState;
type BaseActionUpdate = Partial<Pick<BaseActionState, 'status' | 'abort' | 'executed'>>;
export type ActionStateUpdate =
| BaseActionUpdate
| (Omit<BaseActionUpdate, 'status'> & { status: 'failed'; error: string });
type ActionsMap = MapStore<Record<string, ActionState>>;
class ActionCommandError extends Error {
readonly _output: string;
readonly _header: string;
constructor(message: string, output: string) {
// Create a formatted message that includes both the error message and output
const formattedMessage = `Failed To Execute Shell Command: ${message}\n\nOutput:\n${output}`;
super(formattedMessage);
// Set the output separately so it can be accessed programmatically
this._header = message;
this._output = output;
// Maintain proper prototype chain
Object.setPrototypeOf(this, ActionCommandError.prototype);
// Set the name of the error for better debugging
this.name = 'ActionCommandError';
}
// Optional: Add a method to get just the terminal output
get output() {
return this._output;
}
get header() {
return this._header;
}
}
export class ActionRunner {
#editorBridge: Promise<EditorBridge>;
#currentExecutionPromise: Promise<void> = Promise.resolve();
#page: ActionPage;
runnerId = atom<string>(`${Date.now()}`);
actions: ActionsMap = map({});
onAlert?: (alert: ActionAlert) => void;
buildOutput?: { path: string; exitCode: number; output: string };
constructor(editorBridgePromise: Promise<EditorBridge>, page: ActionPage, onAlert?: (alert: ActionAlert) => void) {
this.#editorBridge = editorBridgePromise;
this.onAlert = onAlert;
this.#page = page;
}
addAction(data: ActionCallbackData) {
const { actionId } = data;
const actions = this.actions.get();
const action = actions[actionId];
if (action) {
// action already added
return;
}
const abortController = new AbortController();
this.actions.setKey(actionId, {
...data.action,
status: 'pending',
executed: false,
abort: () => {
abortController.abort();
this.#updateAction(actionId, { status: 'aborted' });
},
abortSignal: abortController.signal,
});
this.#currentExecutionPromise.then(() => {
this.#updateAction(actionId, { status: 'running' });
});
}
async runAction(data: ActionCallbackData, isRunning: boolean = false) {
const { actionId } = data;
const action = this.actions.get()[actionId];
if (!action) {
unreachable(`Action ${actionId} not found`);
}
if (action.executed) {
return;
}
this.#updateAction(actionId, { ...action, ...data.action, executed: !isRunning });
this.#currentExecutionPromise = this.#currentExecutionPromise
.then(() => {
return this.#executeAction(actionId, isRunning);
})
.catch((error) => {
console.error('Action failed:', error);
});
await this.#currentExecutionPromise;
return;
}
async #executeAction(actionId: string, isRunning: boolean = false) {
let action = this.actions.get()[actionId];
this.#updateAction(actionId, { status: 'running' });
const newAction = this.updateSectionRootDomId(actionId, action);
if (newAction) {
action = newAction;
}
try {
await this.runPageAction(action);
this.#updateAction(actionId, {
status: isRunning ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete',
});
} catch (error) {
if (action.abortSignal.aborted) {
return;
}
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
logger.error(`Action failed\n\n`, error);
if (!(error instanceof ActionCommandError)) {
return;
}
this.onAlert?.({
type: 'error',
title: 'Dev Server Failed',
description: error.header,
content: error.output,
});
// re-throw the error to be caught in the promise chain
throw error;
}
}
async #runPageSectionAction(action: ActionState) {
const editorBridge = await this.#editorBridge;
try {
await editorBridge.updateSection(action);
logger.debug(`Page Section written ${action.pageName}`);
} catch (error) {
logger.error('Failed to write page section\n\n', error);
}
}
async runPageAction(action: ActionState) {
const editorBridge = await this.#editorBridge;
try {
// 新增或更新 Pages
await editorBridge.upsertPageAction(action.pageName, this.#page.title, action.id);
logger.debug(`Page written ${action.pageName}`);
} catch (error) {
logger.error('Failed to write page\n\n', error);
}
this.#runPageSectionAction(action);
}
private updateSectionRootDomId(actionId: string, action: ActionState) {
if (action.validRootDomId) {
return;
}
if (action.action === 'remove') {
this.actions.setKey(action.id, { ...action, rootDomId: action.domId, validRootDomId: true });
return this.actions.get()[actionId];
}
const content = action.content;
const isValid = isValidContent(content);
if (!isValid) {
return;
}
const div = document.createElement('div');
div.innerHTML = content;
const rootDomId = div.firstElementChild?.id;
if (!rootDomId) {
return;
}
const oldRootDomId = action.rootDomId;
if (oldRootDomId && oldRootDomId === rootDomId) {
this.actions.setKey(actionId, { ...action, validRootDomId: true });
} else {
this.actions.setKey(actionId, { ...action, rootDomId });
}
return this.actions.get()[actionId];
}
#updateAction(id: string, newState: ActionStateUpdate) {
const actions = this.actions.get();
const actionState = actions[id];
this.actions.setKey(id, { ...actionState, ...newState });
}
}

View File

@@ -0,0 +1,212 @@
import { describe, expect, it, vi } from 'vitest';
import { type ActionCallback, type ArtifactCallback, StreamingMessageParser } from './message-parser';
interface ExpectedResult {
output: string;
callbacks?: {
onArtifactOpen?: number;
onArtifactClose?: number;
onActionOpen?: number;
onActionClose?: number;
};
}
describe('StreamingMessageParser', () => {
it('should pass through normal text', () => {
const parser = new StreamingMessageParser();
expect(parser.parse('test_id', 'Hello, world!')).toBe('Hello, world!');
});
it('should allow normal HTML tags', () => {
const parser = new StreamingMessageParser();
expect(parser.parse('test_id', 'Hello <strong>world</strong>!')).toBe('Hello <strong>world</strong>!');
});
describe('no artifacts', () => {
it.each<[string | string[], ExpectedResult | string]>([
['Foo bar', 'Foo bar'],
['Foo bar <', 'Foo bar '],
['Foo bar <p', 'Foo bar <p'],
[['Foo bar <', 's', 'p', 'an>some text</span>'], 'Foo bar <span>some text</span>'],
])('should correctly parse chunks and strip out upage artifacts (%#)', (input, expected) => {
runTest(input, expected);
});
});
describe('invalid or incomplete artifacts', () => {
it.each<[string | string[], ExpectedResult | string]>([
['Foo bar <u', 'Foo bar '],
['Foo bar <uP', 'Foo bar <uP'],
['Foo bar <uPa', 'Foo bar '],
['Foo bar <uPag', 'Foo bar '],
['Foo bar <uPage', 'Foo bar <uPage'],
['Foo bar <uPageA', 'Foo bar '],
['Foo bar <uPageArtifacs></uPageArtifact>', 'Foo bar <uPageArtifacs></uPageArtifact>'],
['Before <PageArtfiact>foo</uPageArtifact> After', 'Before <PageArtfiact>foo</uPageArtifact> After'],
['Before <uPageArtifactt>foo</uPageArtifact> After', 'Before <uPageArtifactt>foo</uPageArtifact> After'],
])('should correctly parse chunks and strip out upage artifacts (%#)', (input, expected) => {
runTest(input, expected);
});
});
describe('valid artifacts without actions', () => {
it.each<[string | string[], ExpectedResult | string]>([
[
'Some text before <uPageArtifact id="artifact_1" name="index" title="Some title">foo bar</uPageArtifact> Some more text',
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
[
'Some text before <uPageArti',
'fact',
' name="index" title="Some title" id="artifact_1">foo</uPageArtifact> Some more text',
],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
[
'Some text before <uPageArti',
'fac',
't title="Some title" id="artifact_1" name="index"',
' ',
'>',
'foo</uPageArtifact> Some more text',
],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
[
'Some text before <uPageArti',
'fact',
' title="Some title" id="artifact_1" name="index"',
' >fo',
'o</uPageArtifact> Some more text',
],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
[
'Some text before <uPageArti',
'fact tit',
'le="Some ',
'title" id="artifact_1" name="index">fo',
'o',
'<',
'/uPageArtifact> Some more text',
],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
[
'Some text before <uPageArti',
'fact title="Some title" id="artif',
' name="index"',
'act_1">fo',
'o<',
'/uPageArtifact> Some more text',
],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
'Before <uPageArtifact title="Some title" id="artifact_1" name="index">foo</uPageArtifact> After',
{
output: 'Before After',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
])('should correctly parse chunks and strip out uPage artifacts (%#)', (input, expected) => {
runTest(input, expected);
});
});
describe('valid artifacts with actions', () => {
it.each<[string | string[], ExpectedResult | string]>([
[
'Before <uPageArtifact title="Some title" id="artifact_1" name="index"><uPageAction type="shell">npm install</uPageAction></uPageArtifact> After',
{
output: 'Before After',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 1, onActionClose: 1 },
},
],
[
'Before <uPageArtifact title="Some title" id="artifact_1"><uPageAction type="shell">npm install</uPageAction><uPageAction type="file" filePath="index.js">some content</uPageAction></uPageArtifact> After',
{
output: 'Before After',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 2, onActionClose: 2 },
},
],
])('should correctly parse chunks and strip out uPage artifacts (%#)', (input, expected) => {
runTest(input, expected);
});
});
});
function runTest(input: string | string[], outputOrExpectedResult: string | ExpectedResult) {
let expected: ExpectedResult;
if (typeof outputOrExpectedResult === 'string') {
expected = { output: outputOrExpectedResult };
} else {
expected = outputOrExpectedResult;
}
const callbacks = {
onArtifactOpen: vi.fn<ArtifactCallback>((data) => {
expect(data).toMatchSnapshot('onArtifactOpen');
}),
onArtifactClose: vi.fn<ArtifactCallback>((data) => {
expect(data).toMatchSnapshot('onArtifactClose');
}),
onActionOpen: vi.fn<ActionCallback>((data) => {
expect(data).toMatchSnapshot('onActionOpen');
}),
onActionClose: vi.fn<ActionCallback>((data) => {
expect(data).toMatchSnapshot('onActionClose');
}),
};
const parser = new StreamingMessageParser({
artifactElement: () => '',
callbacks,
});
let message = '';
let result = '';
const chunks = Array.isArray(input) ? input : input.split('');
for (const chunk of chunks) {
message += chunk;
result += parser.parse('message_1', message);
}
for (const name in expected.callbacks) {
const callbackName = name;
expect(callbacks[callbackName as keyof typeof callbacks]).toHaveBeenCalledTimes(
expected.callbacks[callbackName as keyof typeof expected.callbacks] ?? 0,
);
}
expect(result).toEqual(expected.output);
}

View File

@@ -0,0 +1,325 @@
import type { UPageAction, UPageActionData } from '~/types/actions';
import type { UPageArtifactData } from '~/types/artifact';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
const ARTIFACT_TAG_OPEN = '<uPageArtifact';
const ARTIFACT_TAG_CLOSE = '</uPageArtifact>';
const ARTIFACT_ACTION_TAG_OPEN = '<uPageAction';
const ARTIFACT_ACTION_TAG_CLOSE = '</uPageAction>';
const logger = createScopedLogger('MessageParser');
export interface ArtifactCallbackData extends UPageArtifactData {
messageId: string;
}
export interface ActionCallbackData {
artifactId: string;
messageId: string;
actionId: string;
action: UPageAction;
}
export type ArtifactCallback = (data: ArtifactCallbackData) => void;
export type ActionCallback = (data: ActionCallbackData) => void;
export interface ParserCallbacks {
onArtifactOpen?: ArtifactCallback;
onArtifactClose?: ArtifactCallback;
onActionOpen?: ActionCallback;
onActionStream?: ActionCallback;
onActionClose?: ActionCallback;
}
interface ElementFactoryProps {
messageId: string;
}
type ElementFactory = (props: ElementFactoryProps) => string;
export interface StreamingMessageParserOptions {
callbacks?: ParserCallbacks;
artifactElement?: ElementFactory;
}
interface MessageState {
position: number;
insideArtifact: boolean;
insideAction: boolean;
currentArtifact?: UPageArtifactData;
currentAction: UPageActionData;
actionId: number;
}
export class StreamingMessageParser {
#messages = new Map<string, MessageState>();
constructor(private _options: StreamingMessageParserOptions = {}) {}
parse(messageId: string, input: string) {
let state = this.#messages.get(messageId);
if (!state) {
state = {
position: 0,
insideAction: false,
insideArtifact: false,
currentAction: { content: '' },
actionId: 0,
};
this.#messages.set(messageId, state);
}
let output = '';
let i = state.position;
let earlyBreak = false;
while (i < input.length) {
if (state.insideArtifact) {
const currentArtifact = state.currentArtifact;
if (currentArtifact === undefined) {
unreachable('Artifact not initialized');
}
if (state.insideAction) {
const closeIndex = input.indexOf(ARTIFACT_ACTION_TAG_CLOSE, i);
const currentAction = state.currentAction;
if (closeIndex !== -1) {
currentAction.content += input.slice(i, closeIndex);
currentAction.content = currentAction.content.trim();
this._options.callbacks?.onActionClose?.({
artifactId: currentArtifact.id,
messageId,
/**
* We decrement the id because it's been incremented already
* when `onActionOpen` was emitted to make sure the ids are
* the same.
*/
actionId: String(state.actionId - 1),
action: currentAction as UPageAction,
});
state.insideAction = false;
state.currentAction = { content: '' };
i = closeIndex + ARTIFACT_ACTION_TAG_CLOSE.length;
} else {
const content = input.slice(i);
this._options.callbacks?.onActionStream?.({
artifactId: currentArtifact.id,
messageId,
actionId: String(state.actionId - 1),
action: {
...(currentAction as UPageAction),
content,
},
});
break;
}
} else {
const actionOpenIndex = input.indexOf(ARTIFACT_ACTION_TAG_OPEN, i);
const artifactCloseIndex = input.indexOf(ARTIFACT_TAG_CLOSE, i);
if (actionOpenIndex !== -1 && (artifactCloseIndex === -1 || actionOpenIndex < artifactCloseIndex)) {
const actionEndIndex = input.indexOf('>', actionOpenIndex);
if (actionEndIndex !== -1) {
state.insideAction = true;
state.currentAction = this.#parseActionTag(input, actionOpenIndex, actionEndIndex);
this._options.callbacks?.onActionOpen?.({
artifactId: currentArtifact.id,
messageId,
actionId: String(state.actionId++),
action: state.currentAction as UPageAction,
});
i = actionEndIndex + 1;
} else {
break;
}
} else if (artifactCloseIndex !== -1) {
this._options.callbacks?.onArtifactClose?.({ messageId, ...currentArtifact });
state.insideArtifact = false;
state.currentArtifact = undefined;
i = artifactCloseIndex + ARTIFACT_TAG_CLOSE.length;
} else {
break;
}
}
} else if (input[i] === '<' && input[i + 1] !== '/') {
let j = i;
let potentialTag = '';
while (j < input.length && potentialTag.length < ARTIFACT_TAG_OPEN.length) {
potentialTag += input[j];
if (potentialTag === ARTIFACT_TAG_OPEN) {
const nextChar = input[j + 1];
if (nextChar && nextChar !== '>' && nextChar !== ' ') {
output += input.slice(i, j + 1);
i = j + 1;
break;
}
const openTagEnd = input.indexOf('>', j);
if (openTagEnd !== -1) {
const artifactTag = input.slice(i, openTagEnd + 1);
const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
const artifactName = this.#extractAttribute(artifactTag, 'name') as string;
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
if (!artifactId || !artifactName) {
logger.warn('Artifact id or name missing');
}
if (!artifactTitle) {
logger.warn('Artifact title missing');
}
state.insideArtifact = true;
const currentArtifact = {
id: artifactId,
name: artifactName,
title: artifactTitle,
} satisfies UPageArtifactData;
state.currentArtifact = currentArtifact;
this._options.callbacks?.onArtifactOpen?.({ messageId, ...currentArtifact });
const artifactFactory = this._options.artifactElement ?? createArtifactElement;
output += artifactFactory({ messageId });
i = openTagEnd + 1;
} else {
earlyBreak = true;
}
break;
} else if (!ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
output += input.slice(i, j + 1);
i = j + 1;
break;
}
j++;
}
if (j === input.length && ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
break;
}
} else {
output += input[i];
i++;
}
if (earlyBreak) {
break;
}
}
state.position = i;
return output;
}
reset() {
this.#messages.clear();
}
#parseActionTag(input: string, actionOpenIndex: number, actionEndIndex: number) {
const actionTag = input.slice(actionOpenIndex, actionEndIndex + 1);
const actionAttributes: UPageAction = {
id: '',
pageName: '',
action: 'add',
domId: '',
content: '',
rootDomId: '',
validRootDomId: false,
};
const id = this.#extractAttribute(actionTag, 'id') as string;
if (!id) {
logger.warn('Page id not specified');
throw new Error('Page id not specified');
}
const pageName = this.#extractAttribute(actionTag, 'pageName') as string;
if (!pageName) {
logger.warn('Page Name not specified');
}
const action = this.#extractAttribute(actionTag, 'action') as UPageAction['action'];
if (!action) {
logger.warn('Action not specified');
}
if (!['add', 'remove', 'update'].includes(action)) {
logger.warn(`Invalid action '${action}'`);
throw new Error(`Invalid action: ${action}`);
}
const domId = this.#extractAttribute(actionTag, 'domId') as string;
if (!domId) {
logger.warn('domId not specified');
}
const rootDomId = this.#extractAttribute(actionTag, 'rootDomId') as string;
if (!rootDomId) {
logger.warn('rootDomId not specified');
} else {
actionAttributes.validRootDomId = true;
}
const sort = this.#extractAttribute(actionTag, 'sort');
actionAttributes.id = id;
actionAttributes.pageName = pageName;
actionAttributes.action = action;
actionAttributes.domId = domId;
actionAttributes.rootDomId = rootDomId;
actionAttributes.sort = sort ? parseInt(sort) : undefined;
return actionAttributes;
}
#extractAttribute(tag: string, attributeName: string): string | undefined {
const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i'));
return match ? match[1] : undefined;
}
}
const createArtifactElement: ElementFactory = (props) => {
const elementProps = [
'class="__uPageArtifact__"',
...Object.entries(props).map(([key, value]) => {
return `data-${camelToDashCase(key)}=${JSON.stringify(value)}`;
}),
];
return `<div ${elementProps.join(' ')}></div>`;
};
function camelToDashCase(input: string) {
return input.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}