@tuturuuu/utils 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/biome.json +1 -1
- package/package.json +11 -9
- package/src/__tests__/api-proxy-guard.test.ts +43 -26
- package/src/__tests__/permissions.test.tsx +32 -0
- package/src/__tests__/workspace-helper.test.ts +9 -2
- package/src/api-proxy-guard.ts +1 -1
- package/src/common/metadata.test.ts +334 -0
- package/src/common/metadata.ts +275 -0
- package/src/common/nextjs.tsx +11 -81
- package/src/launchable-apps.test.ts +22 -0
- package/src/launchable-apps.ts +30 -1
- package/src/money.ts +1 -1
- package/src/next-config.test.ts +44 -0
- package/src/next-config.ts +29 -0
- package/src/permissions.tsx +10 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/task-helper/board.ts +4 -0
- package/src/workspace-helper.ts +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.0](https://github.com/tutur3u/platform/compare/utils-v0.16.0...utils-v0.17.0) (2026-07-21)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **infrastructure:** manage internal accounts ([02fd9f3](https://github.com/tutur3u/platform/commit/02fd9f3d1b4edb23c881e2dbb04bf244b36e6ed0))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **chat:** harden personal Zalo integration ([f1d12c6](https://github.com/tutur3u/platform/commit/f1d12c60fe12ab3b01a1be3f0381573d44a32226))
|
|
14
|
+
* **ci:** keep task helper collection source-only ([7355a05](https://github.com/tutur3u/platform/commit/7355a05385ccd8d1e2d1bcf6ac15ae5297e057ad))
|
|
15
|
+
* **e2e:** run tasks lifecycle in CI ([bed2cd5](https://github.com/tutur3u/platform/commit/bed2cd590572748f65e75fcb15b7e4b714b9034c))
|
|
16
|
+
* **inventory:** harden sales analytics and access ([53968d3](https://github.com/tutur3u/platform/commit/53968d38a0c5425a3c1065ab2f645e30a0145c8f))
|
|
17
|
+
* resolve code quality findings ([63f10b5](https://github.com/tutur3u/platform/commit/63f10b5ec22a4194f48f448ee2b1b088b5da8f08))
|
|
18
|
+
* **security:** remediate code scanning findings ([023db2e](https://github.com/tutur3u/platform/commit/023db2edf4b0557be108a9d772cbc7e2223af947))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Performance Improvements
|
|
22
|
+
|
|
23
|
+
* **storefront:** share customer navigation shell ([21cb78d](https://github.com/tutur3u/platform/commit/21cb78db484d1e1fdb9f2469fcac4e3a948bd176))
|
|
24
|
+
|
|
25
|
+
## [0.16.0](https://github.com/tutur3u/platform/compare/utils-v0.15.1...utils-v0.16.0) (2026-07-18)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Features
|
|
29
|
+
|
|
30
|
+
* **seo:** improve discovery metadata ([a574409](https://github.com/tutur3u/platform/commit/a57440984df45086c4989db07b02d402dd93c828))
|
|
31
|
+
* **seo:** improve discovery metadata ([8e2420d](https://github.com/tutur3u/platform/commit/8e2420d49e7b94c462d92222ea8f239ab92564f6))
|
|
32
|
+
* **seo:** improve page metadata ([0c621cb](https://github.com/tutur3u/platform/commit/0c621cb7fc1253b0a11ea21992503274052c6be0))
|
|
33
|
+
* **seo:** standardize app metadata ([6523d91](https://github.com/tutur3u/platform/commit/6523d91fedf38e19804d10ea3b82890db180bc6f))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### Bug Fixes
|
|
37
|
+
|
|
38
|
+
* **mobile:** preserve bearer auth across satellites ([f890170](https://github.com/tutur3u/platform/commit/f89017044cf3aaaa6a1b15c31c64a81d75cfdab2))
|
|
39
|
+
|
|
3
40
|
## [0.15.1](https://github.com/tutur3u/platform/compare/utils-v0.15.0...utils-v0.15.1) (2026-07-13)
|
|
4
41
|
|
|
5
42
|
|
package/biome.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.17.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -20,21 +20,21 @@
|
|
|
20
20
|
"@noble/ciphers": "^2.2.0",
|
|
21
21
|
"@noble/hashes": "^2.2.0",
|
|
22
22
|
"@tanstack/react-query": "^5.101.2",
|
|
23
|
-
"@tiptap/core": "3.
|
|
24
|
-
"@tiptap/react": "3.
|
|
23
|
+
"@tiptap/core": "3.28.0",
|
|
24
|
+
"@tiptap/react": "3.28.0",
|
|
25
25
|
"@tuturuuu/google": "0.0.3",
|
|
26
26
|
"@tuturuuu/icons": "0.0.6",
|
|
27
|
-
"@tuturuuu/internal-api": "0.
|
|
28
|
-
"@tuturuuu/supabase": "0.4.
|
|
27
|
+
"@tuturuuu/internal-api": "0.20.0",
|
|
28
|
+
"@tuturuuu/supabase": "0.4.1",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.0",
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
32
|
"date-fns": "^4.4.0",
|
|
33
33
|
"dayjs": "^1.11.20",
|
|
34
34
|
"diff": "^9.0.0",
|
|
35
|
-
"effect": "^3.
|
|
35
|
+
"effect": "^3.22.0",
|
|
36
36
|
"moment": "^2.30.1",
|
|
37
|
-
"next": "^16.3.0-preview.
|
|
37
|
+
"next": "^16.3.0-preview.6",
|
|
38
38
|
"prosemirror-model": "^1.25.11",
|
|
39
39
|
"react": "^19.2.7",
|
|
40
40
|
"react-dom": "^19.2.7",
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
"zod": "^4.4.3"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@tuturuuu/types": "0.
|
|
48
|
+
"@tuturuuu/types": "0.19.0",
|
|
49
49
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
50
50
|
"@types/diff": "^8.0.0",
|
|
51
51
|
"@types/node": "^26.1.1",
|
|
52
52
|
"@types/react": "^19.2.17",
|
|
53
53
|
"@vitejs/plugin-react": "^6.0.3",
|
|
54
54
|
"typescript": "7.0.2",
|
|
55
|
-
"vite": "^8.1.
|
|
55
|
+
"vite": "^8.1.5",
|
|
56
56
|
"vitest": "^4.1.10"
|
|
57
57
|
},
|
|
58
58
|
"exports": {
|
|
@@ -60,8 +60,10 @@
|
|
|
60
60
|
"./effect": "./src/effect.ts",
|
|
61
61
|
"./email/validation": "./src/email/validation.ts",
|
|
62
62
|
"./hooks/*": "./src/hooks/*.ts",
|
|
63
|
+
"./task-helper/personal-external-staging": "./src/task-helper/personal-external-staging.ts",
|
|
63
64
|
"./*": "./src/*.ts",
|
|
64
65
|
"./common/scan": "./src/common/scan.tsx",
|
|
66
|
+
"./common/metadata": "./src/common/metadata.ts",
|
|
65
67
|
"./common/nextjs": "./src/common/nextjs.tsx",
|
|
66
68
|
"./permissions": "./src/permissions.tsx",
|
|
67
69
|
"./editor": "./src/editor/index.ts",
|
|
@@ -1663,6 +1663,23 @@ describe('guardApiProxyRequest', () => {
|
|
|
1663
1663
|
).toBe(false);
|
|
1664
1664
|
});
|
|
1665
1665
|
|
|
1666
|
+
it('detects bearer credentials that route handlers can authenticate', async () => {
|
|
1667
|
+
const { hasAuthenticatedBearerToken } = await import(
|
|
1668
|
+
'../api-proxy-guard.js'
|
|
1669
|
+
);
|
|
1670
|
+
|
|
1671
|
+
expect(
|
|
1672
|
+
hasAuthenticatedBearerToken(
|
|
1673
|
+
new Headers({ authorization: 'Bearer header.payload.signature' })
|
|
1674
|
+
)
|
|
1675
|
+
).toBe(true);
|
|
1676
|
+
expect(
|
|
1677
|
+
hasAuthenticatedBearerToken(
|
|
1678
|
+
new Headers({ authorization: 'Bearer malformed-token' })
|
|
1679
|
+
)
|
|
1680
|
+
).toBe(false);
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1666
1683
|
it('enforces anonymous IP blocks for cookie-looking browser requests', async () => {
|
|
1667
1684
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1668
1685
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -1735,35 +1752,35 @@ describe('guardApiProxyRequest', () => {
|
|
|
1735
1752
|
);
|
|
1736
1753
|
});
|
|
1737
1754
|
|
|
1738
|
-
it.each([
|
|
1739
|
-
'
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
});
|
|
1755
|
+
it.each(['/api/v1/auth/otp/send', '/api/v1/auth/mobile/send-otp'])(
|
|
1756
|
+
'uses a classroom-friendly OTP send bucket for %s',
|
|
1757
|
+
async (path) => {
|
|
1758
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1759
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1760
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1761
|
+
mocks.redis.mockReturnValue({});
|
|
1762
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1763
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1764
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1765
|
+
success: false,
|
|
1766
|
+
limit: 30,
|
|
1767
|
+
remaining: 0,
|
|
1768
|
+
reset: Date.now() + 15_000,
|
|
1769
|
+
});
|
|
1754
1770
|
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1771
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1772
|
+
await import('../api-proxy-guard.js');
|
|
1773
|
+
clearApiProxyGuardLimiterCache();
|
|
1758
1774
|
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1775
|
+
const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
|
|
1776
|
+
prefixBase: 'proxy:test:api',
|
|
1777
|
+
});
|
|
1762
1778
|
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1779
|
+
expect(response?.status).toBe(429);
|
|
1780
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
1781
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('otp-send');
|
|
1782
|
+
}
|
|
1783
|
+
);
|
|
1767
1784
|
|
|
1768
1785
|
it('uses a classroom-friendly bucket for cross-app return handoffs', async () => {
|
|
1769
1786
|
vi.stubEnv('NODE_ENV', 'production');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { ROOT_WORKSPACE_ID } from '../constants';
|
|
4
5
|
import { permissions } from '../permissions';
|
|
5
6
|
|
|
6
7
|
describe('workspace permission catalog', () => {
|
|
@@ -30,9 +31,39 @@ describe('workspace permission catalog', () => {
|
|
|
30
31
|
expect(permissionIds).toContain('admin');
|
|
31
32
|
expect(permissionIds).not.toContain('view_infrastructure');
|
|
32
33
|
expect(permissionIds).not.toContain('manage_infrastructure_stress_tests');
|
|
34
|
+
expect(permissionIds).not.toContain('manage_internal_accounts');
|
|
33
35
|
expect(permissionIds).not.toContain('manage_workspace_secrets');
|
|
34
36
|
});
|
|
35
37
|
|
|
38
|
+
it('exposes internal account management only in the root workspace', () => {
|
|
39
|
+
const rootPermissionIds = permissions({
|
|
40
|
+
wsId: ROOT_WORKSPACE_ID,
|
|
41
|
+
user: null,
|
|
42
|
+
}).map((permission) => permission.id);
|
|
43
|
+
|
|
44
|
+
expect(rootPermissionIds).toContain('manage_internal_accounts');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('keeps internal account permission translations in shared role editors', () => {
|
|
48
|
+
for (const app of ['cms', 'infrastructure', 'inventory', 'web']) {
|
|
49
|
+
for (const locale of ['en', 'vi']) {
|
|
50
|
+
const messages = JSON.parse(
|
|
51
|
+
readFileSync(
|
|
52
|
+
resolve(repoRoot, `apps/${app}/messages/${locale}.json`),
|
|
53
|
+
'utf8'
|
|
54
|
+
)
|
|
55
|
+
) as { 'ws-roles': Record<string, string> };
|
|
56
|
+
|
|
57
|
+
expect(
|
|
58
|
+
messages['ws-roles'].manage_internal_accounts?.length
|
|
59
|
+
).toBeGreaterThan(0);
|
|
60
|
+
expect(
|
|
61
|
+
messages['ws-roles'].manage_internal_accounts_description?.length
|
|
62
|
+
).toBeGreaterThan(0);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
36
67
|
it('exposes the full shared catalog for typed defaults', () => {
|
|
37
68
|
const permissionIds = permissions({
|
|
38
69
|
catalog: 'full',
|
|
@@ -43,6 +74,7 @@ describe('workspace permission catalog', () => {
|
|
|
43
74
|
expect(permissionIds).toContain('admin');
|
|
44
75
|
expect(permissionIds).toContain('view_infrastructure');
|
|
45
76
|
expect(permissionIds).toContain('manage_infrastructure_stress_tests');
|
|
77
|
+
expect(permissionIds).toContain('manage_internal_accounts');
|
|
46
78
|
expect(permissionIds).toContain('manage_external_migrations');
|
|
47
79
|
expect(permissionIds).toContain('manage_workspace_secrets');
|
|
48
80
|
expect(new Set(permissionIds).size).toBe(permissionIds.length);
|
|
@@ -578,7 +578,7 @@ describe('getPermissions membership type gate', () => {
|
|
|
578
578
|
);
|
|
579
579
|
});
|
|
580
580
|
|
|
581
|
-
it('returns
|
|
581
|
+
it('returns a read-only permission context for a guest without defaults', async () => {
|
|
582
582
|
const membershipQuery = {
|
|
583
583
|
select: vi.fn(),
|
|
584
584
|
eq: vi.fn(),
|
|
@@ -656,7 +656,14 @@ describe('getPermissions membership type gate', () => {
|
|
|
656
656
|
wsId: '11111111-1111-4111-8111-111111111111',
|
|
657
657
|
});
|
|
658
658
|
|
|
659
|
-
expect(permissions).
|
|
659
|
+
expect(permissions).toMatchObject({
|
|
660
|
+
membershipType: 'GUEST',
|
|
661
|
+
permissions: [],
|
|
662
|
+
wsId: '11111111-1111-4111-8111-111111111111',
|
|
663
|
+
});
|
|
664
|
+
expect(permissions?.containsPermission('manage_workspace_settings')).toBe(
|
|
665
|
+
false
|
|
666
|
+
);
|
|
660
667
|
expect(defaultPermissionsQuery.eq).toHaveBeenCalledWith(
|
|
661
668
|
'member_type',
|
|
662
669
|
'GUEST'
|
package/src/api-proxy-guard.ts
CHANGED
|
@@ -763,7 +763,7 @@ function looksLikeWorkspaceApiKey(token: string): boolean {
|
|
|
763
763
|
return /^ttr_[A-Za-z0-9_-]+$/.test(token);
|
|
764
764
|
}
|
|
765
765
|
|
|
766
|
-
function hasAuthenticatedBearerToken(headers: Headers): boolean {
|
|
766
|
+
export function hasAuthenticatedBearerToken(headers: Headers): boolean {
|
|
767
767
|
const authHeader = headers.get('authorization');
|
|
768
768
|
if (!authHeader) {
|
|
769
769
|
return false;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
createCommonMetadata,
|
|
6
|
+
createPageMetadata,
|
|
7
|
+
NO_INDEX_ROBOTS,
|
|
8
|
+
} from './metadata';
|
|
9
|
+
|
|
10
|
+
const repoRoot = resolve(__dirname, '../../../..');
|
|
11
|
+
|
|
12
|
+
const config = {
|
|
13
|
+
description: {
|
|
14
|
+
en: 'English product description.',
|
|
15
|
+
vi: 'Mo ta san pham bang tieng Viet.',
|
|
16
|
+
},
|
|
17
|
+
indexable: true,
|
|
18
|
+
keywords: ['team collaboration', 'workflow automation'],
|
|
19
|
+
name: 'Tuturuuu Test',
|
|
20
|
+
url: 'https://test.tuturuuu.com',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('createCommonMetadata', () => {
|
|
24
|
+
it('builds localized, indexable product metadata', () => {
|
|
25
|
+
const metadata = createCommonMetadata({ config, locale: 'vi' });
|
|
26
|
+
|
|
27
|
+
expect(metadata.description).toBe('Mo ta san pham bang tieng Viet.');
|
|
28
|
+
expect(metadata.metadataBase?.toString()).toBe(
|
|
29
|
+
'https://test.tuturuuu.com/'
|
|
30
|
+
);
|
|
31
|
+
expect(metadata.keywords).toEqual([
|
|
32
|
+
'Tuturuuu Test',
|
|
33
|
+
'Tuturuuu',
|
|
34
|
+
'team collaboration',
|
|
35
|
+
'workflow automation',
|
|
36
|
+
]);
|
|
37
|
+
expect(metadata.robots).toMatchObject({
|
|
38
|
+
follow: true,
|
|
39
|
+
index: true,
|
|
40
|
+
});
|
|
41
|
+
expect(metadata.openGraph).toMatchObject({
|
|
42
|
+
alternateLocale: ['en_US'],
|
|
43
|
+
description: 'Mo ta san pham bang tieng Viet.',
|
|
44
|
+
locale: 'vi_VN',
|
|
45
|
+
siteName: 'Tuturuuu Test',
|
|
46
|
+
title: 'Tuturuuu Test',
|
|
47
|
+
});
|
|
48
|
+
expect(metadata.twitter).toMatchObject({
|
|
49
|
+
card: 'summary_large_image',
|
|
50
|
+
creator: '@tuturuuu',
|
|
51
|
+
site: '@tuturuuu',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('uses valid shared brand assets without advertising a missing manifest', () => {
|
|
56
|
+
const metadata = createCommonMetadata({ config, locale: 'en' });
|
|
57
|
+
|
|
58
|
+
expect(metadata.icons).toEqual({
|
|
59
|
+
apple: 'https://tuturuuu.com/apple-touch-icon.png',
|
|
60
|
+
icon: 'https://tuturuuu.com/favicon.ico',
|
|
61
|
+
shortcut: 'https://tuturuuu.com/favicon-16x16.png',
|
|
62
|
+
});
|
|
63
|
+
expect(metadata.manifest).toBeUndefined();
|
|
64
|
+
expect(metadata.openGraph).toMatchObject({
|
|
65
|
+
images: [
|
|
66
|
+
{
|
|
67
|
+
alt: 'Tuturuuu Test preview',
|
|
68
|
+
height: 630,
|
|
69
|
+
url: 'https://tuturuuu.com/media/logos/og-image.jpg',
|
|
70
|
+
width: 1200,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
locale: 'en_US',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('prevents private application surfaces from being indexed', () => {
|
|
78
|
+
const metadata = createCommonMetadata({
|
|
79
|
+
config: {
|
|
80
|
+
...config,
|
|
81
|
+
indexable: false,
|
|
82
|
+
},
|
|
83
|
+
locale: 'en',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(metadata.robots).toEqual({
|
|
87
|
+
follow: false,
|
|
88
|
+
googleBot: {
|
|
89
|
+
follow: false,
|
|
90
|
+
index: false,
|
|
91
|
+
},
|
|
92
|
+
index: false,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('includes a manifest only when the application declares one', () => {
|
|
97
|
+
const metadata = createCommonMetadata({
|
|
98
|
+
config: {
|
|
99
|
+
...config,
|
|
100
|
+
manifest: '/manifest.webmanifest',
|
|
101
|
+
},
|
|
102
|
+
locale: 'en',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(metadata.manifest).toBe('/manifest.webmanifest');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('createPageMetadata', () => {
|
|
110
|
+
it('builds canonical, hreflang, and social metadata for a localized page', () => {
|
|
111
|
+
const metadata = createPageMetadata({
|
|
112
|
+
baseUrl: 'https://test.tuturuuu.com',
|
|
113
|
+
description: 'Plan and automate team workflows.',
|
|
114
|
+
image: '/media/workflows-og.png',
|
|
115
|
+
locale: 'vi',
|
|
116
|
+
pathname: '/products/workflows',
|
|
117
|
+
title: 'Workflow Automation',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(metadata.alternates).toEqual({
|
|
121
|
+
canonical: 'https://test.tuturuuu.com/vi/products/workflows',
|
|
122
|
+
languages: {
|
|
123
|
+
'en-US': 'https://test.tuturuuu.com/products/workflows',
|
|
124
|
+
'vi-VN': 'https://test.tuturuuu.com/vi/products/workflows',
|
|
125
|
+
'x-default': 'https://test.tuturuuu.com/products/workflows',
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
expect(metadata.openGraph).toMatchObject({
|
|
129
|
+
alternateLocale: ['en_US'],
|
|
130
|
+
description: 'Plan and automate team workflows.',
|
|
131
|
+
locale: 'vi_VN',
|
|
132
|
+
siteName: 'Tuturuuu',
|
|
133
|
+
title: 'Workflow Automation',
|
|
134
|
+
url: 'https://test.tuturuuu.com/vi/products/workflows',
|
|
135
|
+
images: [
|
|
136
|
+
{
|
|
137
|
+
alt: 'Workflow Automation',
|
|
138
|
+
height: 630,
|
|
139
|
+
url: 'https://test.tuturuuu.com/media/workflows-og.png',
|
|
140
|
+
width: 1200,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
expect(metadata.twitter).toMatchObject({
|
|
145
|
+
card: 'summary_large_image',
|
|
146
|
+
creator: '@tuturuuu',
|
|
147
|
+
site: '@tuturuuu',
|
|
148
|
+
title: 'Workflow Automation',
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not override inherited robots unless page indexing is explicit', () => {
|
|
153
|
+
const baseConfig = {
|
|
154
|
+
baseUrl: 'https://test.tuturuuu.com',
|
|
155
|
+
description: 'Private workspace page.',
|
|
156
|
+
locale: 'en',
|
|
157
|
+
pathname: '/workspace',
|
|
158
|
+
title: 'Workspace',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
expect(createPageMetadata(baseConfig).robots).toBeUndefined();
|
|
162
|
+
expect(
|
|
163
|
+
createPageMetadata({ ...baseConfig, indexable: false }).robots
|
|
164
|
+
).toEqual(NO_INDEX_ROBOTS);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('uses the unprefixed English URL configured by the public router', () => {
|
|
168
|
+
const metadata = createPageMetadata({
|
|
169
|
+
baseUrl: 'https://test.tuturuuu.com',
|
|
170
|
+
description: 'The main product landing page.',
|
|
171
|
+
locale: 'en',
|
|
172
|
+
pathname: '/',
|
|
173
|
+
title: 'Tuturuuu',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(metadata.alternates).toEqual({
|
|
177
|
+
canonical: 'https://test.tuturuuu.com/',
|
|
178
|
+
languages: {
|
|
179
|
+
'en-US': 'https://test.tuturuuu.com/',
|
|
180
|
+
'vi-VN': 'https://test.tuturuuu.com/vi',
|
|
181
|
+
'x-default': 'https://test.tuturuuu.com/',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('normalizes long runs of boundary slashes in linear time', () => {
|
|
187
|
+
const boundary = '/'.repeat(20_000);
|
|
188
|
+
const metadata = createPageMetadata({
|
|
189
|
+
baseUrl: 'https://test.tuturuuu.com',
|
|
190
|
+
description: 'Stable metadata path normalization.',
|
|
191
|
+
locale: 'en',
|
|
192
|
+
pathname: `${boundary}products/workflows${boundary}`,
|
|
193
|
+
title: 'Workflow Automation',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(metadata.alternates?.canonical).toBe(
|
|
197
|
+
'https://test.tuturuuu.com/products/workflows'
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('supports apps that never expose locale prefixes', () => {
|
|
202
|
+
const metadata = createPageMetadata({
|
|
203
|
+
baseUrl: 'https://tools.tuturuuu.com',
|
|
204
|
+
description: 'Localized tools on one stable URL.',
|
|
205
|
+
locale: 'vi',
|
|
206
|
+
localePrefix: 'never',
|
|
207
|
+
pathname: '/qr',
|
|
208
|
+
title: 'QR Code Generator',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(metadata.alternates).toEqual({
|
|
212
|
+
canonical: 'https://tools.tuturuuu.com/qr',
|
|
213
|
+
});
|
|
214
|
+
expect(metadata.openGraph).toMatchObject({
|
|
215
|
+
locale: 'vi_VN',
|
|
216
|
+
url: 'https://tools.tuturuuu.com/qr',
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('supports apps that always expose locale prefixes', () => {
|
|
221
|
+
const metadata = createPageMetadata({
|
|
222
|
+
baseUrl: 'https://example.tuturuuu.com',
|
|
223
|
+
description: 'A prefixed localized page.',
|
|
224
|
+
locale: 'en',
|
|
225
|
+
localePrefix: 'always',
|
|
226
|
+
pathname: '/about',
|
|
227
|
+
title: 'About',
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(metadata.alternates).toMatchObject({
|
|
231
|
+
canonical: 'https://example.tuturuuu.com/en/about',
|
|
232
|
+
languages: {
|
|
233
|
+
'en-US': 'https://example.tuturuuu.com/en/about',
|
|
234
|
+
'vi-VN': 'https://example.tuturuuu.com/vi/about',
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('application metadata coverage', () => {
|
|
241
|
+
it('keeps every Next.js application on the shared metadata contract', () => {
|
|
242
|
+
const appsDirectory = resolve(repoRoot, 'apps');
|
|
243
|
+
const nextApps = readdirSync(appsDirectory, { withFileTypes: true })
|
|
244
|
+
.filter(
|
|
245
|
+
(entry) =>
|
|
246
|
+
entry.isDirectory() &&
|
|
247
|
+
existsSync(resolve(appsDirectory, entry.name, 'next.config.ts'))
|
|
248
|
+
)
|
|
249
|
+
.map((entry) => entry.name)
|
|
250
|
+
.sort();
|
|
251
|
+
|
|
252
|
+
expect(nextApps.length).toBeGreaterThan(0);
|
|
253
|
+
|
|
254
|
+
for (const app of nextApps) {
|
|
255
|
+
const appDirectory = resolve(appsDirectory, app, 'src/app');
|
|
256
|
+
const localizedLayout = resolve(appDirectory, '[locale]/layout.tsx');
|
|
257
|
+
const rootLayout = resolve(appDirectory, 'layout.tsx');
|
|
258
|
+
const layoutPath = existsSync(localizedLayout)
|
|
259
|
+
? localizedLayout
|
|
260
|
+
: rootLayout;
|
|
261
|
+
|
|
262
|
+
expect(existsSync(layoutPath), `${app} is missing a root layout`).toBe(
|
|
263
|
+
true
|
|
264
|
+
);
|
|
265
|
+
expect(
|
|
266
|
+
readFileSync(layoutPath, 'utf8'),
|
|
267
|
+
`${app} is not using the shared metadata contract`
|
|
268
|
+
).toMatch(/(?:create|generate)CommonMetadata/);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('covers the non-Next public application shells', () => {
|
|
273
|
+
const tanStackHead = readFileSync(
|
|
274
|
+
resolve(repoRoot, 'apps/tanstack-web/src/lib/platform/app-shell.ts'),
|
|
275
|
+
'utf8'
|
|
276
|
+
);
|
|
277
|
+
const mobileHead = readFileSync(
|
|
278
|
+
resolve(repoRoot, 'apps/mobile/web/index.html'),
|
|
279
|
+
'utf8'
|
|
280
|
+
);
|
|
281
|
+
const docsConfig = JSON.parse(
|
|
282
|
+
readFileSync(resolve(repoRoot, 'apps/docs/docs.json'), 'utf8')
|
|
283
|
+
) as { description?: string };
|
|
284
|
+
|
|
285
|
+
expect(tanStackHead).toContain('max-image-preview:large');
|
|
286
|
+
expect(tanStackHead).toContain("name: 'twitter:site'");
|
|
287
|
+
expect(mobileHead).toContain('name="robots" content="noindex, nofollow"');
|
|
288
|
+
expect(mobileHead).toContain('property="og:image"');
|
|
289
|
+
expect(docsConfig.description).toMatch(/Tuturuuu platform/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('keeps SEO-critical public routes on page-specific metadata', () => {
|
|
293
|
+
const routeFiles = [
|
|
294
|
+
'apps/apps/src/app/[locale]/page.tsx',
|
|
295
|
+
'apps/learn/src/app/[locale]/page.tsx',
|
|
296
|
+
'apps/teach/src/app/[locale]/page.tsx',
|
|
297
|
+
'apps/tools/src/app/[locale]/page.tsx',
|
|
298
|
+
'apps/tools/src/app/[locale]/qr/page.tsx',
|
|
299
|
+
'apps/tools/src/app/[locale]/random/page.tsx',
|
|
300
|
+
'apps/storefront/src/app/[locale]/[storeSlug]/(shop)/page.tsx',
|
|
301
|
+
'apps/storefront/src/app/[locale]/[storeSlug]/(shop)/products/[listingId]/page.tsx',
|
|
302
|
+
'apps/nova/src/app/[locale]/(marketing)/layout.tsx',
|
|
303
|
+
'apps/nova/src/app/[locale]/(marketing)/learn/layout.tsx',
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
for (const routeFile of routeFiles) {
|
|
307
|
+
const source = readFileSync(resolve(repoRoot, routeFile), 'utf8');
|
|
308
|
+
|
|
309
|
+
expect(
|
|
310
|
+
source,
|
|
311
|
+
`${routeFile} is missing page-specific SEO metadata`
|
|
312
|
+
).toMatch(/create(?:Page|NovaPage)Metadata/);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const marketingLayouts = readdirSync(
|
|
316
|
+
resolve(repoRoot, 'apps/web/src/app/[locale]/(marketing)'),
|
|
317
|
+
{ recursive: true, withFileTypes: true }
|
|
318
|
+
)
|
|
319
|
+
.filter(
|
|
320
|
+
(entry) =>
|
|
321
|
+
entry.isFile() &&
|
|
322
|
+
entry.name === 'layout.tsx' &&
|
|
323
|
+
readFileSync(resolve(entry.parentPath, entry.name), 'utf8').includes(
|
|
324
|
+
'createMarketingMetadata'
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
.map((entry) => resolve(entry.parentPath, entry.name));
|
|
328
|
+
|
|
329
|
+
expect(marketingLayouts).toHaveLength(34);
|
|
330
|
+
for (const layoutPath of marketingLayouts) {
|
|
331
|
+
expect(readFileSync(layoutPath, 'utf8')).toMatch(/pathname:\s*['`]/);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { DEV_MODE } from '../constants';
|
|
3
|
+
|
|
4
|
+
const TUTURUUU_URL = 'https://tuturuuu.com';
|
|
5
|
+
const DEFAULT_OG_IMAGE = `${TUTURUUU_URL}/media/logos/og-image.jpg`;
|
|
6
|
+
|
|
7
|
+
export const NO_INDEX_ROBOTS = {
|
|
8
|
+
index: false,
|
|
9
|
+
follow: false,
|
|
10
|
+
googleBot: {
|
|
11
|
+
index: false,
|
|
12
|
+
follow: false,
|
|
13
|
+
},
|
|
14
|
+
} satisfies NonNullable<Metadata['robots']>;
|
|
15
|
+
|
|
16
|
+
export interface CommonMetadataConfig {
|
|
17
|
+
name: string;
|
|
18
|
+
url: string;
|
|
19
|
+
indexable: boolean;
|
|
20
|
+
keywords: string[];
|
|
21
|
+
description: {
|
|
22
|
+
en: string;
|
|
23
|
+
vi?: string;
|
|
24
|
+
};
|
|
25
|
+
authors?: Metadata['authors'];
|
|
26
|
+
category?: string;
|
|
27
|
+
creator?: string;
|
|
28
|
+
icons?: Metadata['icons'];
|
|
29
|
+
manifest?: string;
|
|
30
|
+
ogImage?: string;
|
|
31
|
+
publisher?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PageMetadataConfig {
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
description: string;
|
|
37
|
+
image?: string;
|
|
38
|
+
imageAlt?: string;
|
|
39
|
+
indexable?: boolean;
|
|
40
|
+
keywords?: string[];
|
|
41
|
+
locale: string;
|
|
42
|
+
localePrefix?: 'always' | 'as-needed' | 'never';
|
|
43
|
+
pathname: string;
|
|
44
|
+
siteName?: string;
|
|
45
|
+
socialDescription?: string;
|
|
46
|
+
socialTitle?: string;
|
|
47
|
+
title: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CreateCommonMetadataProps {
|
|
51
|
+
config: CommonMetadataConfig;
|
|
52
|
+
locale: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface GenerateCommonMetadataProps {
|
|
56
|
+
config: CommonMetadataConfig;
|
|
57
|
+
params: Promise<{
|
|
58
|
+
locale: string;
|
|
59
|
+
}>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface GeneratePageMetadataProps {
|
|
63
|
+
config: Omit<PageMetadataConfig, 'locale'>;
|
|
64
|
+
params: Promise<{
|
|
65
|
+
locale: string;
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getOpenGraphLocale(locale: string) {
|
|
70
|
+
return locale === 'vi' ? 'vi_VN' : 'en_US';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function trimSlashes(value: string) {
|
|
74
|
+
let start = 0;
|
|
75
|
+
let end = value.length;
|
|
76
|
+
|
|
77
|
+
while (start < end && value[start] === '/') start += 1;
|
|
78
|
+
while (end > start && value[end - 1] === '/') end -= 1;
|
|
79
|
+
|
|
80
|
+
return value.slice(start, end);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getLocalizedUrl(
|
|
84
|
+
baseUrl: string,
|
|
85
|
+
locale: string,
|
|
86
|
+
pathname: string,
|
|
87
|
+
localePrefix: NonNullable<PageMetadataConfig['localePrefix']>
|
|
88
|
+
) {
|
|
89
|
+
const normalizedPathname =
|
|
90
|
+
pathname === '/' ? '' : `/${trimSlashes(pathname)}`;
|
|
91
|
+
const localizedPrefix =
|
|
92
|
+
localePrefix === 'never' ||
|
|
93
|
+
(localePrefix === 'as-needed' && locale === 'en')
|
|
94
|
+
? ''
|
|
95
|
+
: `/${locale}`;
|
|
96
|
+
const localizedPathname = `${localizedPrefix}${normalizedPathname}` || '/';
|
|
97
|
+
|
|
98
|
+
return new URL(localizedPathname, baseUrl).toString();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getRobotsMetadata(indexable: boolean): Metadata['robots'] {
|
|
102
|
+
if (!indexable) {
|
|
103
|
+
return NO_INDEX_ROBOTS;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
index: true,
|
|
108
|
+
follow: true,
|
|
109
|
+
googleBot: {
|
|
110
|
+
index: true,
|
|
111
|
+
follow: true,
|
|
112
|
+
'max-image-preview': 'large',
|
|
113
|
+
'max-snippet': -1,
|
|
114
|
+
'max-video-preview': -1,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createCommonMetadata({
|
|
120
|
+
config,
|
|
121
|
+
locale,
|
|
122
|
+
}: CreateCommonMetadataProps): Metadata {
|
|
123
|
+
const description =
|
|
124
|
+
locale === 'vi' && config.description.vi
|
|
125
|
+
? config.description.vi
|
|
126
|
+
: config.description.en;
|
|
127
|
+
const ogImage = config.ogImage ?? DEFAULT_OG_IMAGE;
|
|
128
|
+
const openGraphLocale = getOpenGraphLocale(locale);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
applicationName: config.name,
|
|
132
|
+
title: {
|
|
133
|
+
default: config.name,
|
|
134
|
+
template: `${DEV_MODE ? '[DEV] ' : ''}%s | ${config.name}`,
|
|
135
|
+
},
|
|
136
|
+
metadataBase: new URL(config.url),
|
|
137
|
+
description,
|
|
138
|
+
keywords: [...new Set([config.name, 'Tuturuuu', ...config.keywords])],
|
|
139
|
+
authors: config.authors ?? [
|
|
140
|
+
{
|
|
141
|
+
name: 'Tuturuuu',
|
|
142
|
+
url: TUTURUUU_URL,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
creator: config.creator ?? 'Tuturuuu',
|
|
146
|
+
publisher: config.publisher ?? 'Tuturuuu',
|
|
147
|
+
category: config.category,
|
|
148
|
+
referrer: 'origin-when-cross-origin',
|
|
149
|
+
robots: getRobotsMetadata(config.indexable),
|
|
150
|
+
appleWebApp: {
|
|
151
|
+
capable: true,
|
|
152
|
+
statusBarStyle: 'default',
|
|
153
|
+
title: config.name,
|
|
154
|
+
},
|
|
155
|
+
formatDetection: {
|
|
156
|
+
address: false,
|
|
157
|
+
email: false,
|
|
158
|
+
telephone: false,
|
|
159
|
+
},
|
|
160
|
+
openGraph: {
|
|
161
|
+
type: 'website',
|
|
162
|
+
locale: openGraphLocale,
|
|
163
|
+
alternateLocale: [openGraphLocale === 'vi_VN' ? 'en_US' : 'vi_VN'],
|
|
164
|
+
title: config.name,
|
|
165
|
+
description,
|
|
166
|
+
siteName: config.name,
|
|
167
|
+
images: [
|
|
168
|
+
{
|
|
169
|
+
url: ogImage,
|
|
170
|
+
width: 1200,
|
|
171
|
+
height: 630,
|
|
172
|
+
alt: `${config.name} preview`,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
twitter: {
|
|
177
|
+
card: 'summary_large_image',
|
|
178
|
+
title: config.name,
|
|
179
|
+
description,
|
|
180
|
+
images: [ogImage],
|
|
181
|
+
creator: '@tuturuuu',
|
|
182
|
+
site: '@tuturuuu',
|
|
183
|
+
},
|
|
184
|
+
icons: config.icons ?? {
|
|
185
|
+
icon: `${TUTURUUU_URL}/favicon.ico`,
|
|
186
|
+
shortcut: `${TUTURUUU_URL}/favicon-16x16.png`,
|
|
187
|
+
apple: `${TUTURUUU_URL}/apple-touch-icon.png`,
|
|
188
|
+
},
|
|
189
|
+
manifest: config.manifest,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function createPageMetadata({
|
|
194
|
+
baseUrl,
|
|
195
|
+
description,
|
|
196
|
+
image = DEFAULT_OG_IMAGE,
|
|
197
|
+
imageAlt,
|
|
198
|
+
indexable,
|
|
199
|
+
keywords,
|
|
200
|
+
locale,
|
|
201
|
+
localePrefix = 'as-needed',
|
|
202
|
+
pathname,
|
|
203
|
+
siteName = 'Tuturuuu',
|
|
204
|
+
title,
|
|
205
|
+
socialDescription = description,
|
|
206
|
+
socialTitle = title,
|
|
207
|
+
}: PageMetadataConfig): Metadata {
|
|
208
|
+
const canonical = getLocalizedUrl(baseUrl, locale, pathname, localePrefix);
|
|
209
|
+
const englishUrl = getLocalizedUrl(baseUrl, 'en', pathname, localePrefix);
|
|
210
|
+
const vietnameseUrl = getLocalizedUrl(baseUrl, 'vi', pathname, localePrefix);
|
|
211
|
+
const openGraphLocale = getOpenGraphLocale(locale);
|
|
212
|
+
const absoluteImage = new URL(image, baseUrl).toString();
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
title,
|
|
216
|
+
description,
|
|
217
|
+
...(keywords?.length ? { keywords } : {}),
|
|
218
|
+
...(typeof indexable === 'boolean'
|
|
219
|
+
? { robots: getRobotsMetadata(indexable) }
|
|
220
|
+
: {}),
|
|
221
|
+
alternates: {
|
|
222
|
+
canonical,
|
|
223
|
+
...(localePrefix === 'never'
|
|
224
|
+
? {}
|
|
225
|
+
: {
|
|
226
|
+
languages: {
|
|
227
|
+
'en-US': englishUrl,
|
|
228
|
+
'vi-VN': vietnameseUrl,
|
|
229
|
+
'x-default': englishUrl,
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
},
|
|
233
|
+
openGraph: {
|
|
234
|
+
type: 'website',
|
|
235
|
+
url: canonical,
|
|
236
|
+
locale: openGraphLocale,
|
|
237
|
+
alternateLocale: [openGraphLocale === 'vi_VN' ? 'en_US' : 'vi_VN'],
|
|
238
|
+
siteName,
|
|
239
|
+
title: socialTitle,
|
|
240
|
+
description: socialDescription,
|
|
241
|
+
images: [
|
|
242
|
+
{
|
|
243
|
+
url: absoluteImage,
|
|
244
|
+
width: 1200,
|
|
245
|
+
height: 630,
|
|
246
|
+
alt: imageAlt ?? socialTitle,
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
twitter: {
|
|
251
|
+
card: 'summary_large_image',
|
|
252
|
+
title: socialTitle,
|
|
253
|
+
description: socialDescription,
|
|
254
|
+
images: [absoluteImage],
|
|
255
|
+
creator: '@tuturuuu',
|
|
256
|
+
site: '@tuturuuu',
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function generateCommonMetadata({
|
|
262
|
+
config,
|
|
263
|
+
params,
|
|
264
|
+
}: GenerateCommonMetadataProps): Promise<Metadata> {
|
|
265
|
+
const { locale } = await params;
|
|
266
|
+
return createCommonMetadata({ config, locale });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function generatePageMetadata({
|
|
270
|
+
config,
|
|
271
|
+
params,
|
|
272
|
+
}: GeneratePageMetadataProps): Promise<Metadata> {
|
|
273
|
+
const { locale } = await params;
|
|
274
|
+
return createPageMetadata({ ...config, locale });
|
|
275
|
+
}
|
package/src/common/nextjs.tsx
CHANGED
|
@@ -1,91 +1,21 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Viewport } from 'next';
|
|
2
2
|
import { Noto_Sans } from 'next/font/google';
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
type CommonMetadataConfig,
|
|
6
|
+
createCommonMetadata,
|
|
7
|
+
createPageMetadata,
|
|
8
|
+
generateCommonMetadata,
|
|
9
|
+
generatePageMetadata,
|
|
10
|
+
NO_INDEX_ROBOTS,
|
|
11
|
+
type PageMetadataConfig,
|
|
12
|
+
} from './metadata';
|
|
4
13
|
|
|
5
14
|
export const font = Noto_Sans({
|
|
6
15
|
subsets: ['latin', 'vietnamese'],
|
|
7
16
|
display: 'block',
|
|
8
17
|
});
|
|
9
18
|
|
|
10
|
-
interface MetadataProps {
|
|
11
|
-
config: {
|
|
12
|
-
name: string;
|
|
13
|
-
url: string;
|
|
14
|
-
ogImage: string;
|
|
15
|
-
keywords?: string[];
|
|
16
|
-
description: {
|
|
17
|
-
en: string;
|
|
18
|
-
vi?: string;
|
|
19
|
-
};
|
|
20
|
-
};
|
|
21
|
-
params: Promise<{
|
|
22
|
-
locale: string;
|
|
23
|
-
}>;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export async function generateCommonMetadata({
|
|
27
|
-
config,
|
|
28
|
-
params,
|
|
29
|
-
}: MetadataProps): Promise<Metadata> {
|
|
30
|
-
const { locale } = await params;
|
|
31
|
-
const description =
|
|
32
|
-
locale === 'vi' ? config.description.vi : config.description.en;
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
applicationName: config.name,
|
|
36
|
-
title: {
|
|
37
|
-
default: config.name,
|
|
38
|
-
template: `${DEV_MODE ? '[DEV] ' : ''} %s | ${config.name}`,
|
|
39
|
-
},
|
|
40
|
-
metadataBase: new URL(config.url),
|
|
41
|
-
description,
|
|
42
|
-
keywords: config.keywords ?? [
|
|
43
|
-
'React.js',
|
|
44
|
-
'Next.js',
|
|
45
|
-
'Tailwind CSS',
|
|
46
|
-
'TypeScript',
|
|
47
|
-
'Biome',
|
|
48
|
-
],
|
|
49
|
-
appleWebApp: {
|
|
50
|
-
capable: true,
|
|
51
|
-
statusBarStyle: 'default',
|
|
52
|
-
title: config.name,
|
|
53
|
-
},
|
|
54
|
-
formatDetection: {
|
|
55
|
-
telephone: false,
|
|
56
|
-
},
|
|
57
|
-
openGraph: {
|
|
58
|
-
type: 'website',
|
|
59
|
-
locale,
|
|
60
|
-
url: config.url,
|
|
61
|
-
title: config.name,
|
|
62
|
-
description,
|
|
63
|
-
siteName: config.name,
|
|
64
|
-
images: [
|
|
65
|
-
{
|
|
66
|
-
url: config.ogImage,
|
|
67
|
-
width: 1200,
|
|
68
|
-
height: 630,
|
|
69
|
-
alt: config.name,
|
|
70
|
-
},
|
|
71
|
-
],
|
|
72
|
-
},
|
|
73
|
-
twitter: {
|
|
74
|
-
card: 'summary_large_image',
|
|
75
|
-
title: config.name,
|
|
76
|
-
description,
|
|
77
|
-
images: [config.ogImage],
|
|
78
|
-
creator: '@tuturuuu',
|
|
79
|
-
},
|
|
80
|
-
icons: {
|
|
81
|
-
icon: '/favicon.ico',
|
|
82
|
-
shortcut: '/favicon-16x16.png',
|
|
83
|
-
apple: '/apple-touch-icon.png',
|
|
84
|
-
},
|
|
85
|
-
manifest: '/manifest.webmanifest',
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
19
|
export const viewport: Viewport = {
|
|
90
20
|
width: 'device-width',
|
|
91
21
|
initialScale: 1,
|
|
@@ -101,6 +101,28 @@ describe('launchable apps', () => {
|
|
|
101
101
|
currentOrigin: 'https://calendar.tuturuuu.localhost',
|
|
102
102
|
})
|
|
103
103
|
).toBe('https://tasks.tuturuuu.localhost');
|
|
104
|
+
|
|
105
|
+
expect(
|
|
106
|
+
getLaunchableAppOrigin(app!, {
|
|
107
|
+
currentOrigin: 'https://tuturuuu.localhost:1355',
|
|
108
|
+
})
|
|
109
|
+
).toBe('https://tasks.tuturuuu.localhost:1355');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('preserves the Portless proxy port in full app URLs', () => {
|
|
113
|
+
const app = getLaunchableApp('tasks');
|
|
114
|
+
|
|
115
|
+
expect(app).not.toBeNull();
|
|
116
|
+
expect(
|
|
117
|
+
resolveLaunchableAppUrl({
|
|
118
|
+
app: app!,
|
|
119
|
+
currentOrigin: 'https://tuturuuu.localhost:1355',
|
|
120
|
+
searchParams: { source: 'sidebar-apps' },
|
|
121
|
+
workspace: teamWorkspace,
|
|
122
|
+
})
|
|
123
|
+
).toBe(
|
|
124
|
+
'https://tasks.tuturuuu.localhost:1355/team-workspace/tasks?source=sidebar-apps'
|
|
125
|
+
);
|
|
104
126
|
});
|
|
105
127
|
|
|
106
128
|
it('uses default paths when no workspace is provided', () => {
|
package/src/launchable-apps.ts
CHANGED
|
@@ -395,6 +395,32 @@ function getAutoEnvironment(currentOrigin?: string): LaunchableAppEnvironment {
|
|
|
395
395
|
return process.env.NODE_ENV === 'production' ? 'production' : 'portless';
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
function preservePortlessProxyPort(
|
|
399
|
+
targetOrigin: string,
|
|
400
|
+
currentOrigin?: string
|
|
401
|
+
): string {
|
|
402
|
+
if (!currentOrigin) return targetOrigin;
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const currentUrl = new URL(currentOrigin);
|
|
406
|
+
const targetUrl = new URL(targetOrigin);
|
|
407
|
+
const isCurrentPortlessHost =
|
|
408
|
+
currentUrl.hostname === 'tuturuuu.localhost' ||
|
|
409
|
+
currentUrl.hostname.endsWith('.tuturuuu.localhost');
|
|
410
|
+
const isTargetPortlessHost =
|
|
411
|
+
targetUrl.hostname === 'tuturuuu.localhost' ||
|
|
412
|
+
targetUrl.hostname.endsWith('.tuturuuu.localhost');
|
|
413
|
+
|
|
414
|
+
if (isCurrentPortlessHost && isTargetPortlessHost && currentUrl.port) {
|
|
415
|
+
targetUrl.port = currentUrl.port;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return targetUrl.origin;
|
|
419
|
+
} catch {
|
|
420
|
+
return targetOrigin;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
398
424
|
export function getLaunchableAppOrigin(
|
|
399
425
|
app: LaunchableApp,
|
|
400
426
|
{
|
|
@@ -413,7 +439,10 @@ export function getLaunchableAppOrigin(
|
|
|
413
439
|
return app.localhostOrigin ?? getTuturuuuPortlessAppOrigin(app.portlessApp);
|
|
414
440
|
}
|
|
415
441
|
|
|
416
|
-
return
|
|
442
|
+
return preservePortlessProxyPort(
|
|
443
|
+
getTuturuuuPortlessAppOrigin(app.portlessApp),
|
|
444
|
+
currentOrigin
|
|
445
|
+
);
|
|
417
446
|
}
|
|
418
447
|
|
|
419
448
|
function trimTrailingSlashes(value: string) {
|
package/src/money.ts
CHANGED
|
@@ -40,7 +40,7 @@ export function getCurrencyFractionDigits(currency = 'USD'): number {
|
|
|
40
40
|
const cached = fractionDigitsCache.get(code);
|
|
41
41
|
if (cached !== undefined) return cached;
|
|
42
42
|
|
|
43
|
-
let digits
|
|
43
|
+
let digits: number;
|
|
44
44
|
try {
|
|
45
45
|
digits =
|
|
46
46
|
new Intl.NumberFormat(getCurrencyLocale(code), {
|
package/src/next-config.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
createTuturuuuNextConfig,
|
|
5
5
|
isTuturuuuNextCacheComponentsEnabled,
|
|
6
6
|
isTuturuuuNextReactCompilerEnabled,
|
|
7
|
+
resolveTuturuuuInfrastructureAppUrl,
|
|
7
8
|
resolveTuturuuuWebAppUrl,
|
|
8
9
|
TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS,
|
|
9
10
|
TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS,
|
|
@@ -17,6 +18,7 @@ describe('createTuturuuuNextConfig', () => {
|
|
|
17
18
|
expect(config.allowedDevOrigins).toContain('tuturuuu.localhost');
|
|
18
19
|
expect(config.cacheComponents).toBe(true);
|
|
19
20
|
expect(config.partialPrefetching).toBe(true);
|
|
21
|
+
expect(config.experimental?.useTypeScriptCli).toBe(true);
|
|
20
22
|
expect(config.experimental?.turbopackFileSystemCacheForBuild).toBe(true);
|
|
21
23
|
expect(config.experimental?.turbopackRustReactCompiler).toBe(true);
|
|
22
24
|
expect(config.images?.remotePatterns).toEqual(
|
|
@@ -198,6 +200,7 @@ describe('createTuturuuuNextConfig', () => {
|
|
|
198
200
|
cpus: 2,
|
|
199
201
|
turbopackFileSystemCacheForBuild: false,
|
|
200
202
|
turbopackRustReactCompiler: false,
|
|
203
|
+
useTypeScriptCli: false,
|
|
201
204
|
},
|
|
202
205
|
typescript: {
|
|
203
206
|
ignoreBuildErrors: false,
|
|
@@ -211,6 +214,7 @@ describe('createTuturuuuNextConfig', () => {
|
|
|
211
214
|
expect(config.experimental?.cpus).toBe(2);
|
|
212
215
|
expect(config.experimental?.turbopackFileSystemCacheForBuild).toBe(false);
|
|
213
216
|
expect(config.experimental?.turbopackRustReactCompiler).toBe(false);
|
|
217
|
+
expect(config.experimental?.useTypeScriptCli).toBe(true);
|
|
214
218
|
expect(config.typescript?.ignoreBuildErrors).toBe(false);
|
|
215
219
|
});
|
|
216
220
|
|
|
@@ -369,6 +373,46 @@ describe('resolveTuturuuuWebAppUrl', () => {
|
|
|
369
373
|
});
|
|
370
374
|
});
|
|
371
375
|
|
|
376
|
+
describe('resolveTuturuuuInfrastructureAppUrl', () => {
|
|
377
|
+
it('trims configured infrastructure origins', () => {
|
|
378
|
+
expect(
|
|
379
|
+
resolveTuturuuuInfrastructureAppUrl({
|
|
380
|
+
env: {
|
|
381
|
+
INTERNAL_INFRASTRUCTURE_API_ORIGIN: 'https://example.com///',
|
|
382
|
+
NODE_ENV: 'development',
|
|
383
|
+
},
|
|
384
|
+
})
|
|
385
|
+
).toBe('https://example.com');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('uses the local Portless infrastructure app in development', () => {
|
|
389
|
+
expect(
|
|
390
|
+
resolveTuturuuuInfrastructureAppUrl({
|
|
391
|
+
env: { NODE_ENV: 'development' },
|
|
392
|
+
})
|
|
393
|
+
).toBe('https://infra.tuturuuu.localhost');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('uses the canonical production infrastructure app when deployed', () => {
|
|
397
|
+
expect(
|
|
398
|
+
resolveTuturuuuInfrastructureAppUrl({
|
|
399
|
+
env: { VERCEL_ENV: 'production' },
|
|
400
|
+
})
|
|
401
|
+
).toBe('https://infrastructure.tuturuuu.com');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('ignores origins registered to another Tuturuuu app', () => {
|
|
405
|
+
expect(
|
|
406
|
+
resolveTuturuuuInfrastructureAppUrl({
|
|
407
|
+
env: {
|
|
408
|
+
INTERNAL_INFRASTRUCTURE_API_ORIGIN: 'https://chat.tuturuuu.com',
|
|
409
|
+
VERCEL_ENV: 'production',
|
|
410
|
+
},
|
|
411
|
+
})
|
|
412
|
+
).toBe('https://infrastructure.tuturuuu.com');
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
372
416
|
describe('trimTrailingSlashes', () => {
|
|
373
417
|
it('removes only trailing slash characters', () => {
|
|
374
418
|
expect(trimTrailingSlashes('https://example.com/path///')).toBe(
|
package/src/next-config.ts
CHANGED
|
@@ -112,6 +112,7 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
|
|
|
112
112
|
},
|
|
113
113
|
experimental: {
|
|
114
114
|
...experimentalConfig,
|
|
115
|
+
useTypeScriptCli: true,
|
|
115
116
|
turbopackFileSystemCacheForBuild:
|
|
116
117
|
experimentalConfig.turbopackFileSystemCacheForBuild ?? true,
|
|
117
118
|
turbopackRustReactCompiler:
|
|
@@ -192,3 +193,31 @@ export function resolveTuturuuuWebAppUrl({
|
|
|
192
193
|
(isTuturuuuNextDeployedEnvironment(env) ? productionUrl : localUrl)
|
|
193
194
|
);
|
|
194
195
|
}
|
|
196
|
+
|
|
197
|
+
export function resolveTuturuuuInfrastructureAppUrl({
|
|
198
|
+
env = process.env,
|
|
199
|
+
localFallbackUrl,
|
|
200
|
+
productionUrl = 'https://infrastructure.tuturuuu.com',
|
|
201
|
+
}: {
|
|
202
|
+
env?: Environment;
|
|
203
|
+
localFallbackUrl?: string;
|
|
204
|
+
productionUrl?: string;
|
|
205
|
+
} = {}) {
|
|
206
|
+
const localUrl =
|
|
207
|
+
localFallbackUrl ??
|
|
208
|
+
getLocalInternalAppUrl('infra', 'http://localhost:7823');
|
|
209
|
+
|
|
210
|
+
return trimTrailingSlashes(
|
|
211
|
+
resolveInternalAppUrl({
|
|
212
|
+
appName: 'infra',
|
|
213
|
+
candidates: [
|
|
214
|
+
env.INTERNAL_INFRASTRUCTURE_API_ORIGIN,
|
|
215
|
+
env.INFRASTRUCTURE_APP_URL,
|
|
216
|
+
env.NEXT_PUBLIC_INFRASTRUCTURE_APP_URL,
|
|
217
|
+
],
|
|
218
|
+
fallback: isTuturuuuNextDeployedEnvironment(env)
|
|
219
|
+
? productionUrl
|
|
220
|
+
: localUrl,
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
}
|
package/src/permissions.tsx
CHANGED
|
@@ -121,6 +121,16 @@ export const permissionGroups = ({
|
|
|
121
121
|
disableOnProduction: false,
|
|
122
122
|
disabled: false,
|
|
123
123
|
},
|
|
124
|
+
{
|
|
125
|
+
id: 'manage_internal_accounts' as PermissionId,
|
|
126
|
+
icon: <UserCog />,
|
|
127
|
+
title: t('ws-roles.manage_internal_accounts'),
|
|
128
|
+
description: t(
|
|
129
|
+
'ws-roles.manage_internal_accounts_description'
|
|
130
|
+
),
|
|
131
|
+
disableOnProduction: false,
|
|
132
|
+
disabled: false,
|
|
133
|
+
},
|
|
124
134
|
{
|
|
125
135
|
id: 'manage_mobile_deployment_vault' as PermissionId,
|
|
126
136
|
icon: <FileKey2 />,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
|
|
8
8
|
describe('platform release metadata', () => {
|
|
9
9
|
it('uses the centralized shared browser app version', () => {
|
|
10
|
-
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.24.0'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('preserves the source timestamp in builtAt and derives a short hash', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.
|
|
3
|
+
export const TUTURUUU_PLATFORM_VERSION = '0.24.0'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
6
|
/** ISO timestamp of the source commit used for the build, not deployment time. */
|
package/src/task-helper/board.ts
CHANGED
|
@@ -153,9 +153,11 @@ export function useWorkspaceLabels(wsId: string | null | undefined) {
|
|
|
153
153
|
|
|
154
154
|
export interface BoardConfig {
|
|
155
155
|
id: string;
|
|
156
|
+
name?: string | null;
|
|
156
157
|
estimation_type: string | null;
|
|
157
158
|
extended_estimation: boolean;
|
|
158
159
|
allow_zero_estimates: boolean;
|
|
160
|
+
count_unestimated_issues?: boolean;
|
|
159
161
|
ws_id: string;
|
|
160
162
|
ticket_prefix: string | null;
|
|
161
163
|
}
|
|
@@ -179,9 +181,11 @@ export function useBoardConfig(
|
|
|
179
181
|
|
|
180
182
|
return {
|
|
181
183
|
id: board.id,
|
|
184
|
+
name: board.name ?? null,
|
|
182
185
|
estimation_type: board.estimation_type ?? null,
|
|
183
186
|
extended_estimation: board.extended_estimation ?? false,
|
|
184
187
|
allow_zero_estimates: board.allow_zero_estimates ?? false,
|
|
188
|
+
count_unestimated_issues: board.count_unestimated_issues ?? false,
|
|
185
189
|
ws_id: board.ws_id,
|
|
186
190
|
ticket_prefix: board.ticket_prefix ?? null,
|
|
187
191
|
} as BoardConfig;
|
package/src/workspace-helper.ts
CHANGED
|
@@ -828,22 +828,14 @@ async function getPermissionsImpl({
|
|
|
828
828
|
|
|
829
829
|
const isCreator =
|
|
830
830
|
membershipType === 'MEMBER' && workspaceData.creator_id === userId;
|
|
831
|
-
const hasPermissions =
|
|
832
|
-
permissionsData.length > 0 || defaultData.length > 0 || isCreator;
|
|
833
|
-
|
|
834
831
|
// if (DEV_MODE) {
|
|
835
832
|
// console.log('--------------------');
|
|
836
833
|
// console.log('Is creator', isCreator);
|
|
837
834
|
// console.log('Workspace permissions', permissionsData);
|
|
838
835
|
// console.log('Default permissions', defaultData);
|
|
839
|
-
// console.log('Has permissions', hasPermissions);
|
|
840
836
|
// console.log('--------------------');
|
|
841
837
|
// }
|
|
842
838
|
|
|
843
|
-
if (!isCreator && !hasPermissions) {
|
|
844
|
-
return null;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
839
|
const permissions = isCreator
|
|
848
840
|
? rolePermissions({
|
|
849
841
|
wsId: resolvedWorkspaceId,
|