Skip to content

Commit 3de8aaa

Browse files
authored
refactor: server ts (#179)
* refactor: server ts * refactor: add more tests
1 parent 3fb910c commit 3de8aaa

File tree

12 files changed

+1354
-280
lines changed

12 files changed

+1354
-280
lines changed

.eslintignore.web

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ vite-env.d.ts
88
coverage/
99
src/proto/**/*
1010
cypress/e2e/
11-
cypress/support/
11+
cypress/support/
12+
deploy/*.test.ts
13+
deploy/*.integration.test.ts

deploy/api.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// @ts-expect-error no bun
2+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
3+
import { fetch } from 'bun';
4+
5+
import { baseURL } from './config';
6+
import { logger } from './logger';
7+
8+
export const fetchPublishMetadata = async (namespace: string, publishName?: string) => {
9+
const encodedNamespace = encodeURIComponent(namespace);
10+
let url = `${baseURL}/api/workspace/published/${encodedNamespace}`;
11+
12+
if (publishName) {
13+
url = `${baseURL}/api/workspace/v1/published/${encodedNamespace}/${encodeURIComponent(publishName)}`;
14+
}
15+
16+
logger.debug(`Fetching meta data from ${url}`);
17+
18+
const response = await fetch(url, {
19+
verbose: false,
20+
});
21+
22+
if (!response.ok) {
23+
throw new Error(`HTTP error! Status: ${response.status}`);
24+
}
25+
26+
const data = await response.json();
27+
28+
logger.debug(`Fetched meta data from ${url}: ${JSON.stringify(data)}`);
29+
30+
return data;
31+
};

deploy/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import path from 'path';
2+
3+
export const distDir = path.join(__dirname, 'dist');
4+
export const indexPath = path.join(distDir, 'index.html');
5+
export const baseURL = process.env.APPFLOWY_BASE_URL as string;
6+
// Used when a namespace is requested without /publishName; users get redirected to the
7+
// public marketing site if the namespace segment is empty (see redirect in publish route).
8+
export const defaultSite = 'https://appflowy.com';

deploy/html.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import * as fs from 'fs';
2+
import { type CheerioAPI, load } from 'cheerio';
3+
4+
import { indexPath } from './config';
5+
import { logger } from './logger';
6+
import { type PublishErrorPayload } from './publish-error';
7+
8+
const DEFAULT_DESCRIPTION = 'Write, share, and publish docs quickly on AppFlowy.\nGet started for free.';
9+
const DEFAULT_IMAGE = '/og-image.png';
10+
const DEFAULT_FAVICON = '/appflowy.ico';
11+
12+
const MARKETING_META: Record<
13+
string,
14+
{
15+
title?: string;
16+
description?: string;
17+
}
18+
> = {
19+
'/after-payment': {
20+
title: 'Payment Success | AppFlowy',
21+
description: 'Payment success on AppFlowy',
22+
},
23+
'/login': {
24+
title: 'Login | AppFlowy',
25+
description: 'Login to AppFlowy',
26+
},
27+
};
28+
29+
export const renderMarketingPage = (pathname: string) => {
30+
const htmlData = fs.readFileSync(indexPath, 'utf8');
31+
const $ = load(htmlData);
32+
const meta = MARKETING_META[pathname];
33+
34+
if (meta?.title) {
35+
$('title').text(meta.title);
36+
}
37+
38+
if (meta?.description) {
39+
setOrUpdateMetaTag($, 'meta[name="description"]', 'name', meta.description);
40+
}
41+
42+
return $.html();
43+
};
44+
45+
type PublishViewMeta = {
46+
name?: string;
47+
icon?: {
48+
ty: number;
49+
value: string;
50+
};
51+
extra?: string;
52+
};
53+
54+
export type RenderPublishPageOptions = {
55+
hostname: string | null;
56+
pathname: string;
57+
metaData?: {
58+
view?: PublishViewMeta;
59+
};
60+
publishError?: PublishErrorPayload | null;
61+
};
62+
63+
export const renderPublishPage = ({ hostname, pathname, metaData, publishError }: RenderPublishPageOptions) => {
64+
const htmlData = fs.readFileSync(indexPath, 'utf8');
65+
const $ = load(htmlData);
66+
67+
const description = DEFAULT_DESCRIPTION;
68+
let title = 'AppFlowy';
69+
const url = `https://${hostname ?? ''}${pathname}`;
70+
let image = DEFAULT_IMAGE;
71+
let favicon = DEFAULT_FAVICON;
72+
73+
try {
74+
if (metaData && metaData.view) {
75+
const view = metaData.view;
76+
const emoji = view.icon?.ty === 0 && view.icon?.value;
77+
const icon = view.icon?.ty === 2 && view.icon?.value;
78+
const titleList: string[] = [];
79+
80+
if (emoji) {
81+
const emojiCode = emoji.codePointAt(0)?.toString(16);
82+
const baseUrl = 'https://raw.githubusercontent.com/googlefonts/noto-emoji/main/svg/emoji_u';
83+
84+
if (emojiCode) {
85+
favicon = `${baseUrl}${emojiCode}.svg`;
86+
}
87+
} else if (icon) {
88+
try {
89+
const { iconContent, color } = JSON.parse(icon);
90+
91+
favicon = getIconBase64(iconContent, color);
92+
$('link[rel="icon"]').attr('type', 'image/svg+xml');
93+
} catch (_) {
94+
// ignore icon parsing errors
95+
}
96+
}
97+
98+
if (view.name) {
99+
titleList.push(view.name);
100+
titleList.push('|');
101+
}
102+
103+
titleList.push('AppFlowy');
104+
title = titleList.join(' ');
105+
106+
try {
107+
const cover = view.extra ? JSON.parse(view.extra)?.cover : null;
108+
109+
if (cover) {
110+
if (['unsplash', 'custom'].includes(cover.type)) {
111+
image = cover.value;
112+
} else if (cover.type === 'built_in') {
113+
image = `/covers/m_cover_image_${cover.value}.png`;
114+
}
115+
}
116+
} catch (_) {
117+
// ignore cover parsing errors
118+
}
119+
}
120+
} catch (error) {
121+
logger.error(`Error injecting meta data: ${error}`);
122+
}
123+
124+
$('title').text(title);
125+
$('link[rel="icon"]').attr('href', favicon);
126+
$('link[rel="canonical"]').attr('href', url);
127+
setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description);
128+
setOrUpdateMetaTag($, 'meta[property="og:title"]', 'property', title);
129+
setOrUpdateMetaTag($, 'meta[property="og:description"]', 'property', description);
130+
setOrUpdateMetaTag($, 'meta[property="og:image"]', 'property', image);
131+
setOrUpdateMetaTag($, 'meta[property="og:url"]', 'property', url);
132+
setOrUpdateMetaTag($, 'meta[property="og:site_name"]', 'property', 'AppFlowy');
133+
setOrUpdateMetaTag($, 'meta[property="og:type"]', 'property', 'website');
134+
setOrUpdateMetaTag($, 'meta[name="twitter:card"]', 'name', 'summary_large_image');
135+
setOrUpdateMetaTag($, 'meta[name="twitter:title"]', 'name', title);
136+
setOrUpdateMetaTag($, 'meta[name="twitter:description"]', 'name', description);
137+
setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image);
138+
setOrUpdateMetaTag($, 'meta[name="twitter:site"]', 'name', '@appflowy');
139+
140+
if (publishError) {
141+
appendPublishErrorScript($, publishError);
142+
}
143+
144+
return $.html();
145+
};
146+
147+
const appendPublishErrorScript = ($: CheerioAPI, error: PublishErrorPayload) => {
148+
const serialized = JSON.stringify(error)
149+
.replace(/</g, '\\u003c')
150+
.replace(/>/g, '\\u003e');
151+
152+
$('head').append(
153+
`<script id="appflowy-publish-error">window.__APPFLOWY_PUBLISH_ERROR__ = ${serialized};</script>`
154+
);
155+
};
156+
157+
const setOrUpdateMetaTag = ($: CheerioAPI, selector: string, attribute: string, content: string) => {
158+
if ($(selector).length === 0) {
159+
const valueMatch = selector.match(/\[.*?="([^"]+)"\]/);
160+
const value = valueMatch?.[1] ?? '';
161+
162+
$('head').append(`<meta ${attribute}="${value}" content="${content}">`);
163+
} else {
164+
$(selector).attr('content', content);
165+
}
166+
};
167+
168+
const getIconBase64 = (svgText: string, color: string) => {
169+
let newSvgText = svgText.replace(/fill="[^"]*"/g, ``);
170+
171+
newSvgText = newSvgText.replace('<svg', `<svg fill="${argbToRgba(color)}"`);
172+
173+
const base64String = btoa(newSvgText);
174+
175+
return `data:image/svg+xml;base64,${base64String}`;
176+
};
177+
178+
const argbToRgba = (color: string): string => {
179+
const hex = color.replace(/^#|0x/, '');
180+
const hasAlpha = hex.length === 8;
181+
182+
if (!hasAlpha) {
183+
return color.replace('0x', '#');
184+
}
185+
186+
const r = parseInt(hex.slice(2, 4), 16);
187+
const g = parseInt(hex.slice(4, 6), 16);
188+
const b = parseInt(hex.slice(6, 8), 16);
189+
const a = hasAlpha ? parseInt(hex.slice(0, 2), 16) / 255 : 1;
190+
191+
return `rgba(${r}, ${g}, ${b}, ${a})`;
192+
};

deploy/logger.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import pino from 'pino';
2+
3+
const prettyTransport = {
4+
target: 'pino-pretty',
5+
options: {
6+
colorize: true,
7+
translateTime: 'SYS:standard',
8+
},
9+
};
10+
11+
export const logger = pino({
12+
transport: process.env.NODE_ENV === 'production' ? undefined : prettyTransport,
13+
level: process.env.LOG_LEVEL || 'info',
14+
});
15+
16+
// Request timing logic removed – we only keep the shared logger here.

deploy/publish-error.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export type PublishErrorCode =
2+
| 'NO_DEFAULT_PAGE'
3+
| 'PUBLISH_VIEW_LOOKUP_FAILED'
4+
| 'FETCH_ERROR'
5+
| 'UNKNOWN_FALLBACK';
6+
7+
export type PublishErrorPayload = {
8+
code: PublishErrorCode;
9+
message: string;
10+
namespace?: string;
11+
publishName?: string;
12+
response?: unknown;
13+
detail?: string;
14+
};

0 commit comments

Comments
 (0)