cloudflare-next-intl 0.9.7 → 0.9.10
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/bin/check_dynamic_pages.mjs +25 -0
- package/dist/src/cloudflare_email/escape_html.d.ts +1 -0
- package/dist/src/cloudflare_email/escape_html.js +7 -0
- package/dist/src/cloudflare_email/index.d.ts +3 -0
- package/dist/src/cloudflare_email/index.js +3 -0
- package/dist/src/cloudflare_email/resolve_email_binding.d.ts +11 -0
- package/dist/src/cloudflare_email/resolve_email_binding.js +8 -0
- package/dist/src/cloudflare_email/send_transactional_email.d.ts +18 -0
- package/dist/src/cloudflare_email/send_transactional_email.js +36 -0
- package/dist/src/cloudflare_fetch/fetch_text.d.ts +5 -0
- package/dist/src/cloudflare_fetch/fetch_text.js +17 -0
- package/dist/src/cloudflare_fetch/fetch_with_fallback.d.ts +2 -0
- package/dist/src/cloudflare_fetch/fetch_with_fallback.js +7 -0
- package/dist/src/cloudflare_fetch/index.d.ts +3 -0
- package/dist/src/cloudflare_fetch/index.js +3 -0
- package/dist/src/cloudflare_fetch/resolve_assets_binding.d.ts +5 -0
- package/dist/src/cloudflare_fetch/resolve_assets_binding.js +9 -0
- package/dist/src/db/connection.js +5 -5
- package/dist/src/db/context.js +2 -2
- package/dist/src/db/resolve_hyperdrive_connection_string.d.ts +5 -0
- package/dist/src/db/resolve_hyperdrive_connection_string.js +10 -0
- package/dist/src/db/resolve_mode.d.ts +2 -2
- package/dist/src/db/resolve_mode.js +7 -1
- package/dist/src/dynamic_pages_check/check_dynamic_pages.d.ts +17 -0
- package/dist/src/dynamic_pages_check/check_dynamic_pages.js +49 -0
- package/dist/src/dynamic_pages_check/detect_dynamic_usage.d.ts +5 -0
- package/dist/src/dynamic_pages_check/detect_dynamic_usage.js +16 -0
- package/dist/src/dynamic_pages_check/find_page_files.d.ts +1 -0
- package/dist/src/dynamic_pages_check/find_page_files.js +24 -0
- package/dist/src/dynamic_pages_check/index.d.ts +3 -0
- package/dist/src/dynamic_pages_check/index.js +3 -0
- package/dist/src/dynamic_pages_check/insert_dynamic_export.d.ts +1 -0
- package/dist/src/dynamic_pages_check/insert_dynamic_export.js +43 -0
- package/dist/src/errors_board/client/error_detail_view.d.ts +7 -0
- package/dist/src/errors_board/client/error_detail_view.js +35 -0
- package/dist/src/errors_board/client/error_row.d.ts +7 -0
- package/dist/src/errors_board/client/error_row.js +9 -0
- package/dist/src/errors_board/client/error_ui_client.d.ts +14 -0
- package/dist/src/errors_board/client/error_ui_client.js +26 -0
- package/dist/src/errors_board/client/errors_filter_form.d.ts +8 -0
- package/dist/src/errors_board/client/errors_filter_form.js +6 -0
- package/dist/src/errors_board/client/errors_list_client.d.ts +15 -0
- package/dist/src/errors_board/client/errors_list_client.js +92 -0
- package/dist/src/errors_board/client/errors_login_form.d.ts +5 -0
- package/dist/src/errors_board/client/errors_login_form.js +21 -0
- package/dist/src/errors_board/client/errors_stat_strip.d.ts +6 -0
- package/dist/src/errors_board/client/errors_stat_strip.js +19 -0
- package/dist/src/errors_board/server/actions_factory.d.ts +24 -0
- package/dist/src/errors_board/server/actions_factory.js +39 -0
- package/dist/src/errors_board/server/errors_repository.d.ts +83 -0
- package/dist/src/errors_board/server/errors_repository.js +199 -0
- package/dist/src/errors_board/server/gate.d.ts +19 -0
- package/dist/src/errors_board/server/gate.js +68 -0
- package/dist/src/errors_board/server/index.d.ts +3 -0
- package/dist/src/errors_board/server/index.js +3 -0
- package/dist/src/errors_board/shared/error_ui_helpers.d.ts +13 -0
- package/dist/src/errors_board/shared/error_ui_helpers.js +60 -0
- package/dist/src/types/types.d.ts +1 -0
- package/package.json +47 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Usage: cfni-check-dynamic-pages [--app-dir=src/app] [--mode=off|report|fix] [--target=next|vinext] [--skip=a/page.tsx,b/page.tsx]
|
|
3
|
+
// Env equivalents: CFNI_DYNAMIC_PAGES_APP_DIR, CFNI_DYNAMIC_PAGES_MODE, CFNI_DYNAMIC_PAGES_TARGET, CFNI_DYNAMIC_PAGES_SKIP (comma-separated).
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { checkDynamicPages } from '../dist/src/dynamic_pages_check/check_dynamic_pages.js';
|
|
6
|
+
|
|
7
|
+
function argValue(name) {
|
|
8
|
+
const prefix = `--${name}=`;
|
|
9
|
+
const arg = process.argv.slice(2).find((a) => a.startsWith(prefix));
|
|
10
|
+
return arg ? arg.slice(prefix.length) : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const appDir = resolve(argValue('app-dir') ?? process.env.CFNI_DYNAMIC_PAGES_APP_DIR ?? 'src/app');
|
|
14
|
+
const mode = argValue('mode') ?? process.env.CFNI_DYNAMIC_PAGES_MODE ?? 'report';
|
|
15
|
+
const target = argValue('target') ?? process.env.CFNI_DYNAMIC_PAGES_TARGET ?? 'next';
|
|
16
|
+
const skipRaw = argValue('skip') ?? process.env.CFNI_DYNAMIC_PAGES_SKIP ?? '';
|
|
17
|
+
const skip = skipRaw.split(',').map((s) => s.trim()).filter(Boolean).map((s) => resolve(s));
|
|
18
|
+
|
|
19
|
+
const reports = await checkDynamicPages({ appDir, mode, target, skip });
|
|
20
|
+
|
|
21
|
+
if (reports.length === 0) {
|
|
22
|
+
console.log(mode === 'off' ? 'checkDynamicPages: disabled (mode=off).' : `checkDynamicPages: no page/route files found under ${appDir}.`);
|
|
23
|
+
} else {
|
|
24
|
+
for (const { file, action } of reports) console.log(`${action.padEnd(24)} ${file}`);
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function escapeHtml(value: string): string;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { sendTransactionalEmail, type TransactionalEmailOutcome, type TransactionalEmailContent, type SendTransactionalEmailOptions } from './send_transactional_email.js';
|
|
2
|
+
export { resolveEmailBinding, type EmailBindingLike } from './resolve_email_binding.js';
|
|
3
|
+
export { escapeHtml } from './escape_html.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { GenerateRoutingConfig } from '../types/types.js';
|
|
2
|
+
export interface EmailBindingLike {
|
|
3
|
+
send(message: {
|
|
4
|
+
to: string;
|
|
5
|
+
from: string;
|
|
6
|
+
subject: string;
|
|
7
|
+
html?: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
}): Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
export declare function resolveEmailBinding(generate?: GenerateRoutingConfig, bindingName?: string): Promise<EmailBindingLike | null>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { resolveEnv } from '../server/functions/geo.js';
|
|
2
|
+
export async function resolveEmailBinding(generate, bindingName = 'EMAIL') {
|
|
3
|
+
const env = await resolveEnv(generate);
|
|
4
|
+
const candidate = env?.[bindingName];
|
|
5
|
+
if (!candidate || typeof candidate !== 'object')
|
|
6
|
+
return null;
|
|
7
|
+
return typeof candidate.send === 'function' ? candidate : null;
|
|
8
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ReportErrorConfig } from '../error_handling/report_error.js';
|
|
2
|
+
import type { GenerateRoutingConfig } from '../types/types.js';
|
|
3
|
+
export type TransactionalEmailOutcome = 'sent' | 'unavailable' | 'failed';
|
|
4
|
+
export interface TransactionalEmailContent {
|
|
5
|
+
subject: string;
|
|
6
|
+
text: string;
|
|
7
|
+
html: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SendTransactionalEmailOptions extends ReportErrorConfig {
|
|
10
|
+
generate?: GenerateRoutingConfig;
|
|
11
|
+
senderAddress: string;
|
|
12
|
+
bindingName?: string;
|
|
13
|
+
restAccountId?: string;
|
|
14
|
+
restToken?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function sendTransactionalEmail(message: {
|
|
17
|
+
to: string;
|
|
18
|
+
} & TransactionalEmailContent, options: SendTransactionalEmailOptions, reportAs: string): Promise<TransactionalEmailOutcome>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { resolveEmailBinding } from './resolve_email_binding.js';
|
|
2
|
+
import reportError from '../error_handling/report_error.js';
|
|
3
|
+
function isUsableCredential(value) {
|
|
4
|
+
return value.length > 0 && !value.includes('$(');
|
|
5
|
+
}
|
|
6
|
+
async function sendOverRest(message, options, reportAs) {
|
|
7
|
+
const accountId = (options.restAccountId ?? process.env.CLOUDFLARE_ACCOUNT_ID ?? '').trim();
|
|
8
|
+
const token = (options.restToken ?? process.env.CLOUDFLARE_EMAIL_TOKEN ?? '').trim();
|
|
9
|
+
if (!isUsableCredential(accountId) || !isUsableCredential(token))
|
|
10
|
+
return 'unavailable';
|
|
11
|
+
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/email/sending/send`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
14
|
+
body: JSON.stringify(message),
|
|
15
|
+
});
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
await reportError(options, { error: new Error(`email/sending/send responded ${response.status}`), classOrMethodName: `${reportAs}.rest` });
|
|
18
|
+
return 'failed';
|
|
19
|
+
}
|
|
20
|
+
return 'sent';
|
|
21
|
+
}
|
|
22
|
+
export async function sendTransactionalEmail(message, options, reportAs) {
|
|
23
|
+
try {
|
|
24
|
+
const binding = await resolveEmailBinding(options.generate, options.bindingName);
|
|
25
|
+
const fullMessage = { ...message, from: options.senderAddress };
|
|
26
|
+
if (binding) {
|
|
27
|
+
await binding.send(fullMessage);
|
|
28
|
+
return 'sent';
|
|
29
|
+
}
|
|
30
|
+
return await sendOverRest(fullMessage, options, reportAs);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
await reportError(options, { error, classOrMethodName: reportAs });
|
|
34
|
+
return 'failed';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ReportErrorConfig } from '../error_handling/report_error.js';
|
|
2
|
+
import type { GenerateRoutingConfig } from '../types/types.js';
|
|
3
|
+
export declare function fetchText(input: RequestInfo | URL, init: RequestInit, config: (ReportErrorConfig & {
|
|
4
|
+
generate?: GenerateRoutingConfig;
|
|
5
|
+
}) | undefined, reportAs: string): Promise<string | null>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { fetchWithCloudflareFallback } from './fetch_with_fallback.js';
|
|
2
|
+
import reportError from '../error_handling/report_error.js';
|
|
3
|
+
const MAX_ERROR_BODY_LENGTH = 500;
|
|
4
|
+
export async function fetchText(input, init, config, reportAs) {
|
|
5
|
+
try {
|
|
6
|
+
const response = await fetchWithCloudflareFallback(input, init, config?.generate);
|
|
7
|
+
if (!response.ok) {
|
|
8
|
+
const body = (await response.text()).slice(0, MAX_ERROR_BODY_LENGTH);
|
|
9
|
+
throw new Error(body || `HTTP ${response.status}`);
|
|
10
|
+
}
|
|
11
|
+
return await response.text();
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
await reportError(config, { error, classOrMethodName: reportAs, params: { input: String(input) } });
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { resolveAssetsBinding } from './resolve_assets_binding.js';
|
|
2
|
+
export async function fetchWithCloudflareFallback(input, init, generate) {
|
|
3
|
+
const binding = await resolveAssetsBinding(generate);
|
|
4
|
+
if (binding)
|
|
5
|
+
return binding.fetch(input, init);
|
|
6
|
+
return fetch(input, { ...init, cache: 'no-store' });
|
|
7
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { GenerateRoutingConfig } from '../types/types.js';
|
|
2
|
+
export interface AssetsBindingLike {
|
|
3
|
+
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
4
|
+
}
|
|
5
|
+
export declare function resolveAssetsBinding(generate?: GenerateRoutingConfig): Promise<AssetsBindingLike | null>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { resolveEnv } from '../server/functions/geo.js';
|
|
2
|
+
export async function resolveAssetsBinding(generate) {
|
|
3
|
+
const env = await resolveEnv(generate);
|
|
4
|
+
const candidate = env?.ASSETS;
|
|
5
|
+
if (!candidate || typeof candidate !== 'object')
|
|
6
|
+
return null;
|
|
7
|
+
const fetchFn = candidate.fetch;
|
|
8
|
+
return typeof fetchFn === 'function' ? candidate : null;
|
|
9
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import reportError from '../error_handling/report_error.js';
|
|
2
2
|
import requireDbConfig from './require_config.js';
|
|
3
3
|
import resolveConfigValue from './resolve_config_value.js';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveHyperdriveConnectionString } from './resolve_hyperdrive_connection_string.js';
|
|
5
5
|
const BENIGN_DISCONNECT_PATTERN = /(connection terminated|connection closed|socket closed|unexpected eof)/i;
|
|
6
6
|
let pgModule;
|
|
7
7
|
function loadPg() {
|
|
@@ -12,10 +12,10 @@ async function resolveConnectionString(db, generate) {
|
|
|
12
12
|
const configured = await resolveConfigValue(db.connectionString);
|
|
13
13
|
if (configured)
|
|
14
14
|
return configured;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
if (db.autoHyperdrive !== false) {
|
|
16
|
+
const hyperdriveConn = await resolveHyperdriveConnectionString(generate);
|
|
17
|
+
if (hyperdriveConn)
|
|
18
|
+
return hyperdriveConn;
|
|
19
19
|
}
|
|
20
20
|
throw new Error('db: could not resolve a Postgres connection string. Set `db.connectionString` ' +
|
|
21
21
|
'to a connection string, or to a function returning one (e.g. reading a ' +
|
package/dist/src/db/context.js
CHANGED
|
@@ -100,7 +100,7 @@ export async function withPublicDb(fn, dbOverride) {
|
|
|
100
100
|
const config = await resolveDbConfig(dbOverride);
|
|
101
101
|
const db = config.db;
|
|
102
102
|
requireDbConfig(db);
|
|
103
|
-
const resolved = await resolveDbMode(db);
|
|
103
|
+
const resolved = await resolveDbMode(db, config.generate);
|
|
104
104
|
if (resolved.mode === 'supabase') {
|
|
105
105
|
const { anonKey } = await resolveSupabaseEndpoint(resolved.supabase);
|
|
106
106
|
return fn(await supabaseDb(resolved.supabase, anonKey));
|
|
@@ -130,7 +130,7 @@ export async function withUserDb(fn, uid, dbOverride) {
|
|
|
130
130
|
const config = await resolveDbConfig(dbOverride);
|
|
131
131
|
const db = config.db;
|
|
132
132
|
requireDbConfig(db);
|
|
133
|
-
const resolved = await resolveDbMode(db);
|
|
133
|
+
const resolved = await resolveDbMode(db, config.generate);
|
|
134
134
|
if (resolved.mode === 'supabase') {
|
|
135
135
|
const token = await resolveAccessToken(config);
|
|
136
136
|
return fn(await supabaseDb(resolved.supabase, token));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { resolveEnv } from '../server/functions/geo.js';
|
|
2
|
+
const WRANGLER_DEV_PLACEHOLDER = 'postgresql://user:pass@localhost:5432/db';
|
|
3
|
+
export async function resolveHyperdriveConnectionString(generate) {
|
|
4
|
+
const env = await resolveEnv(generate);
|
|
5
|
+
const binding = env?.HYPERDRIVE;
|
|
6
|
+
const connectionString = binding?.connectionString;
|
|
7
|
+
if (!connectionString || connectionString === WRANGLER_DEV_PLACEHOLDER)
|
|
8
|
+
return undefined;
|
|
9
|
+
return connectionString;
|
|
10
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DbRoutingConfig, SupabaseDbConfig } from '../types/types.js';
|
|
1
|
+
import type { DbRoutingConfig, GenerateRoutingConfig, SupabaseDbConfig } from '../types/types.js';
|
|
2
2
|
export type DbMode = 'postgres' | 'supabase';
|
|
3
3
|
export type ResolvedDbMode = {
|
|
4
4
|
mode: 'postgres';
|
|
@@ -7,4 +7,4 @@ export type ResolvedDbMode = {
|
|
|
7
7
|
mode: 'supabase';
|
|
8
8
|
supabase: SupabaseDbConfig;
|
|
9
9
|
};
|
|
10
|
-
export default function resolveDbMode(db: DbRoutingConfig): Promise<ResolvedDbMode>;
|
|
10
|
+
export default function resolveDbMode(db: DbRoutingConfig, generate?: GenerateRoutingConfig): Promise<ResolvedDbMode>;
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import resolveConfigValue from './resolve_config_value.js';
|
|
2
|
-
|
|
2
|
+
import { resolveHyperdriveConnectionString } from './resolve_hyperdrive_connection_string.js';
|
|
3
|
+
export default async function resolveDbMode(db, generate) {
|
|
3
4
|
const connectionString = await resolveConfigValue(db.connectionString);
|
|
4
5
|
if (connectionString)
|
|
5
6
|
return { mode: 'postgres', connectionString };
|
|
7
|
+
if (db.autoHyperdrive !== false) {
|
|
8
|
+
const hyperdriveConnectionString = await resolveHyperdriveConnectionString(generate);
|
|
9
|
+
if (hyperdriveConnectionString)
|
|
10
|
+
return { mode: 'postgres', connectionString: hyperdriveConnectionString };
|
|
11
|
+
}
|
|
6
12
|
if (db.supabase)
|
|
7
13
|
return { mode: 'supabase', supabase: db.supabase };
|
|
8
14
|
return { mode: 'postgres', connectionString: undefined };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type DynamicPagesCheckMode = 'off' | 'report' | 'fix';
|
|
2
|
+
export interface CheckDynamicPagesOptions {
|
|
3
|
+
appDir: string;
|
|
4
|
+
mode?: DynamicPagesCheckMode;
|
|
5
|
+
target?: 'next' | 'vinext';
|
|
6
|
+
skip?: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export interface CheckDynamicPagesReport {
|
|
9
|
+
file: string;
|
|
10
|
+
action: 'added-force-dynamic' | 'would-add-force-dynamic' | 'added-force-static' | 'would-add-force-static' | 'already-declared' | 'no-dynamic-usage-detected' | 'skipped';
|
|
11
|
+
}
|
|
12
|
+
export interface CheckDynamicPagesIo {
|
|
13
|
+
findPageFiles?: (appDir: string) => string[];
|
|
14
|
+
readFile?: (file: string) => string;
|
|
15
|
+
writeFile?: (file: string, contents: string) => void;
|
|
16
|
+
}
|
|
17
|
+
export declare function checkDynamicPages(options: CheckDynamicPagesOptions, io?: CheckDynamicPagesIo): Promise<CheckDynamicPagesReport[]>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { findPageFiles as findPageFilesImpl } from './find_page_files.js';
|
|
3
|
+
import { detectDynamicUsage } from './detect_dynamic_usage.js';
|
|
4
|
+
import { insertDynamicExport } from './insert_dynamic_export.js';
|
|
5
|
+
export async function checkDynamicPages(options, io = {}) {
|
|
6
|
+
const mode = options.mode ?? 'report';
|
|
7
|
+
if (mode === 'off')
|
|
8
|
+
return [];
|
|
9
|
+
const target = options.target ?? 'next';
|
|
10
|
+
const findPageFiles = io.findPageFiles ?? findPageFilesImpl;
|
|
11
|
+
const readFile = io.readFile ?? ((file) => readFileSync(file, 'utf8'));
|
|
12
|
+
const writeFile = io.writeFile ?? ((file, contents) => writeFileSync(file, contents, 'utf8'));
|
|
13
|
+
const skipSet = new Set(options.skip ?? []);
|
|
14
|
+
const reports = [];
|
|
15
|
+
for (const file of findPageFiles(options.appDir)) {
|
|
16
|
+
if (skipSet.has(file)) {
|
|
17
|
+
reports.push({ file, action: 'skipped' });
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const source = readFile(file);
|
|
21
|
+
const detection = detectDynamicUsage(source);
|
|
22
|
+
if (detection.hasExplicitDynamicExport) {
|
|
23
|
+
reports.push({ file, action: 'already-declared' });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (detection.detectedDynamicApis.length === 0) {
|
|
27
|
+
if (target !== 'vinext') {
|
|
28
|
+
reports.push({ file, action: 'no-dynamic-usage-detected' });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (mode === 'fix') {
|
|
32
|
+
writeFile(file, insertDynamicExport(source, 'force-static'));
|
|
33
|
+
reports.push({ file, action: 'added-force-static' });
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
reports.push({ file, action: 'would-add-force-static' });
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (mode === 'fix') {
|
|
41
|
+
writeFile(file, insertDynamicExport(source, 'force-dynamic'));
|
|
42
|
+
reports.push({ file, action: 'added-force-dynamic' });
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
reports.push({ file, action: 'would-add-force-dynamic' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return reports;
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const DYNAMIC_API_CHECKS = [
|
|
2
|
+
{ name: 'cookies()', pattern: /\bcookies\s*\(/ },
|
|
3
|
+
{ name: 'headers()', pattern: /\bheaders\s*\(\s*\)/ },
|
|
4
|
+
{ name: 'searchParams', pattern: /\bsearchParams\b/ },
|
|
5
|
+
{ name: 'unstable_noStore()', pattern: /\bunstable_noStore\s*\(/ },
|
|
6
|
+
{ name: 'connection()', pattern: /\bconnection\s*\(\s*\)/ },
|
|
7
|
+
{ name: 'cache: "no-store"', pattern: /cache:\s*['"]no-store['"]/ },
|
|
8
|
+
{ name: 'next: { revalidate: 0 }', pattern: /next:\s*\{\s*revalidate:\s*0\s*[,}]/ },
|
|
9
|
+
];
|
|
10
|
+
const EXPLICIT_DYNAMIC_EXPORT = /export\s+const\s+dynamic\s*=/;
|
|
11
|
+
export function detectDynamicUsage(sourceText) {
|
|
12
|
+
return {
|
|
13
|
+
hasExplicitDynamicExport: EXPLICIT_DYNAMIC_EXPORT.test(sourceText),
|
|
14
|
+
detectedDynamicApis: DYNAMIC_API_CHECKS.filter(({ pattern }) => pattern.test(sourceText)).map(({ name }) => name),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function findPageFiles(appDir: string): string[];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const PAGE_FILE_NAMES = new Set(['page.tsx', 'page.ts', 'page.jsx', 'page.js', 'route.ts', 'route.js']);
|
|
4
|
+
export function findPageFiles(appDir) {
|
|
5
|
+
let entries;
|
|
6
|
+
try {
|
|
7
|
+
entries = readdirSync(appDir, { withFileTypes: true });
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
const files = [];
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (entry.isDirectory()) {
|
|
15
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.'))
|
|
16
|
+
continue;
|
|
17
|
+
files.push(...findPageFiles(join(appDir, entry.name)));
|
|
18
|
+
}
|
|
19
|
+
else if (PAGE_FILE_NAMES.has(entry.name)) {
|
|
20
|
+
files.push(join(appDir, entry.name));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { checkDynamicPages, type DynamicPagesCheckMode, type CheckDynamicPagesOptions, type CheckDynamicPagesReport, type CheckDynamicPagesIo } from './check_dynamic_pages.js';
|
|
2
|
+
export { findPageFiles } from './find_page_files.js';
|
|
3
|
+
export { detectDynamicUsage, type DynamicDetectionResult } from './detect_dynamic_usage.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function insertDynamicExport(sourceText: string, value: 'force-static' | 'force-dynamic'): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function findLeadingImportBlockEnd(sourceText) {
|
|
2
|
+
const lines = sourceText.split('\n');
|
|
3
|
+
let offset = 0;
|
|
4
|
+
let lastImportEnd = -1;
|
|
5
|
+
let depth = 0;
|
|
6
|
+
let inImport = false;
|
|
7
|
+
for (const line of lines) {
|
|
8
|
+
const lineEnd = offset + line.length;
|
|
9
|
+
const trimmed = line.trim();
|
|
10
|
+
if (!inImport) {
|
|
11
|
+
if (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) {
|
|
12
|
+
offset = lineEnd + 1;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (!/^import\b/.test(trimmed))
|
|
16
|
+
break;
|
|
17
|
+
inImport = true;
|
|
18
|
+
depth = 0;
|
|
19
|
+
}
|
|
20
|
+
for (const char of line) {
|
|
21
|
+
if (char === '{' || char === '(' || char === '[')
|
|
22
|
+
depth += 1;
|
|
23
|
+
else if (char === '}' || char === ')' || char === ']')
|
|
24
|
+
depth -= 1;
|
|
25
|
+
}
|
|
26
|
+
if (inImport && depth <= 0 && /;\s*$/.test(line)) {
|
|
27
|
+
inImport = false;
|
|
28
|
+
lastImportEnd = lineEnd;
|
|
29
|
+
}
|
|
30
|
+
offset = lineEnd + 1;
|
|
31
|
+
}
|
|
32
|
+
return lastImportEnd;
|
|
33
|
+
}
|
|
34
|
+
export function insertDynamicExport(sourceText, value) {
|
|
35
|
+
const block = `// Auto-inserted by cloudflare-next-intl's checkDynamicPages (mode: "fix") — remove this line, or set \`dynamic\` yourself, to override.\nexport const dynamic = "${value}";\n`;
|
|
36
|
+
const lastImportEnd = findLeadingImportBlockEnd(sourceText);
|
|
37
|
+
if (lastImportEnd === -1) {
|
|
38
|
+
return `${block}\n${sourceText}`;
|
|
39
|
+
}
|
|
40
|
+
const before = sourceText.slice(0, lastImportEnd).replace(/\n+$/, '');
|
|
41
|
+
const after = sourceText.slice(lastImportEnd).replace(/^\n+/, '');
|
|
42
|
+
return `${before}\n\n${block}\n${after}`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ErrorRow } from '../server/errors_repository.js';
|
|
2
|
+
import type { ErrorsActions } from '../server/actions_factory.js';
|
|
3
|
+
export default function ErrorDetailView({ row, actions, onDeleted, }: {
|
|
4
|
+
row: ErrorRow;
|
|
5
|
+
actions: ErrorsActions;
|
|
6
|
+
onDeleted: () => void;
|
|
7
|
+
}): Component;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { STATUS_BADGE_CLASS, STATUS_LABELS, STATUS_HINTS, formatRelativeTime, formatLocalTimestamp, parseRequestContext, } from '../shared/error_ui_helpers.js';
|
|
5
|
+
import { DetailBlock, CopyButton, LocalTime } from './error_ui_client.js';
|
|
6
|
+
export default function ErrorDetailView({ row, actions, onDeleted, }) {
|
|
7
|
+
const [isPending, setIsPending] = useState(false);
|
|
8
|
+
async function handleStatusChange(status) {
|
|
9
|
+
setIsPending(true);
|
|
10
|
+
try {
|
|
11
|
+
await actions.setErrorStatus([row.id], status);
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
setIsPending(false);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function handleDelete() {
|
|
18
|
+
if (!window.confirm("Delete this error? This can't be undone."))
|
|
19
|
+
return;
|
|
20
|
+
setIsPending(true);
|
|
21
|
+
try {
|
|
22
|
+
await actions.deleteErrors([row.id]);
|
|
23
|
+
onDeleted();
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
setIsPending(false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const requestContext = parseRequestContext(row.params);
|
|
30
|
+
return (_jsxs("div", { className: "flex flex-col gap-5", style: { opacity: isPending ? 0.5 : 1 }, children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: `rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide ${STATUS_BADGE_CLASS[row.status]}`, children: row.status }), _jsx("span", { className: "rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300", children: row.flavour }), _jsx("span", { className: "rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300", children: row.is_client === 1 ? 'Client' : 'Server' }), row.count > 1 && (_jsxs("span", { className: "rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-500 dark:bg-gray-800 dark:text-gray-400", children: ["Seen \u00D7", row.count] })), _jsx(CopyButton, { text: typeof window !== 'undefined' ? window.location.href : String(row.id), label: "Copy link", copiedLabel: "Link copied" })] }), _jsx("h1", { className: "font-mono text-lg font-semibold wrap-break-word text-gray-900 dark:text-white", children: row.caller }), _jsx("p", { className: "text-sm text-gray-600 dark:text-gray-300", children: row.message })] }), _jsxs("div", { className: "flex w-full shrink-0 flex-col gap-2 sm:w-auto sm:items-end", children: [_jsxs("div", { className: "flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:items-center", children: [_jsx("div", { className: "flex overflow-hidden rounded-lg border border-gray-300 dark:border-gray-700", children: Object.keys(STATUS_LABELS).map((status, index) => (_jsx("button", { disabled: isPending || row.status === status, onClick: () => handleStatusChange(status), className: `flex-1 px-3 py-1.5 text-xs font-medium transition-colors disabled:cursor-default sm:flex-none ${index > 0 ? 'border-l border-gray-300 dark:border-gray-700' : ''} ${row.status === status
|
|
31
|
+
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
|
32
|
+
: 'bg-white text-gray-700 hover:bg-gray-100 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800'}`, children: STATUS_LABELS[status] }, status))) }), _jsx("button", { disabled: isPending, onClick: handleDelete, className: "rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 disabled:opacity-40 dark:border-red-900 dark:text-red-400 dark:hover:bg-red-950", children: "Delete error" })] }), _jsx("p", { className: "max-w-72 text-[11px] leading-snug text-gray-400 sm:text-right dark:text-gray-500", children: STATUS_HINTS[row.status] })] })] }), _jsxs("dl", { className: "grid grid-cols-2 gap-x-4 gap-y-3 rounded-xl border border-gray-200 bg-gray-50 p-4 text-xs sm:grid-cols-3 dark:border-gray-800 dark:bg-gray-900/60", children: [_jsxs("div", { children: [_jsx("dt", { className: "text-gray-400 dark:text-gray-500", children: "First seen" }), _jsx("dd", { className: "text-gray-700 dark:text-gray-300", children: _jsx(LocalTime, { format: formatLocalTimestamp, timestampMs: row.created_at }) })] }), _jsxs("div", { children: [_jsx("dt", { className: "text-gray-400 dark:text-gray-500", children: "Last seen" }), _jsxs("dd", { className: "text-gray-700 dark:text-gray-300", children: [_jsx(LocalTime, { format: formatLocalTimestamp, timestampMs: row.updated_at }), ' ', _jsxs("span", { className: "text-gray-400 dark:text-gray-500", children: ["(", _jsx(LocalTime, { format: formatRelativeTime, timestampMs: row.updated_at }), ")"] })] })] }), _jsxs("div", { children: [_jsx("dt", { className: "text-gray-400 dark:text-gray-500", children: "User" }), _jsx("dd", { className: "text-gray-700 dark:text-gray-300", children: row.user_email ?? 'Unknown / not signed in' })] }), _jsxs("div", { children: [_jsx("dt", { className: "text-gray-400 dark:text-gray-500", children: "Regressions" }), _jsx("dd", { className: "text-gray-700 dark:text-gray-300", children: row.reopen_count > 0
|
|
33
|
+
? `Came back ${row.reopen_count} time${row.reopen_count === 1 ? '' : 's'} after being resolved`
|
|
34
|
+
: 'Never came back after a fix' })] }), row.resolved_at !== null && (_jsxs("div", { children: [_jsx("dt", { className: "text-gray-400 dark:text-gray-500", children: "Resolved" }), _jsx("dd", { className: "text-gray-700 dark:text-gray-300", children: _jsx(LocalTime, { format: formatRelativeTime, timestampMs: row.resolved_at }) })] })), requestContext?.path && (_jsxs("div", { children: [_jsx("dt", { className: "text-gray-400 dark:text-gray-500", children: "Page" }), _jsx("dd", { className: "break-all text-gray-700 dark:text-gray-300", children: requestContext.path })] })), requestContext?.referer && (_jsxs("div", { children: [_jsx("dt", { className: "text-gray-400 dark:text-gray-500", children: "Referrer" }), _jsx("dd", { className: "break-all text-gray-700 dark:text-gray-300", children: requestContext.referer })] })), requestContext?.userAgent && (_jsxs("div", { className: "col-span-2 sm:col-span-3", children: [_jsx("dt", { className: "text-gray-400 dark:text-gray-500", children: "User agent" }), _jsx("dd", { className: "break-all text-gray-700 dark:text-gray-300", children: requestContext.userAgent })] }))] }), _jsx(DetailBlock, { label: "Message", text: row.message }), row.stack && _jsx(DetailBlock, { label: "Stack trace", text: row.stack }), row.params && _jsx(DetailBlock, { label: "Params", text: row.params })] }));
|
|
35
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ErrorRow } from '../server/errors_repository.js';
|
|
2
|
+
export default function ErrorRowItem({ row, selected, onToggleSelect, hrefFor, }: {
|
|
3
|
+
row: ErrorRow;
|
|
4
|
+
selected: boolean;
|
|
5
|
+
onToggleSelect: (id: number, checked: boolean) => void;
|
|
6
|
+
hrefFor: (id: number) => string;
|
|
7
|
+
}): Component;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { STATUS_BADGE_CLASS, STATUS_DOT_CLASS, formatRelativeTime, formatLocalTimestamp } from '../shared/error_ui_helpers.js';
|
|
4
|
+
import { LocalTime, useMounted } from './error_ui_client.js';
|
|
5
|
+
export default function ErrorRowItem({ row, selected, onToggleSelect, hrefFor, }) {
|
|
6
|
+
const mounted = useMounted();
|
|
7
|
+
const absoluteTime = mounted ? formatLocalTimestamp(row.updated_at) : undefined;
|
|
8
|
+
return (_jsxs("a", { href: hrefFor(row.id), className: "flex flex-col gap-1.5 rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:hover:bg-gray-800/50", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: `size-2 shrink-0 rounded-full ${STATUS_DOT_CLASS[row.status]}`, "aria-hidden": true }), _jsx("input", { type: "checkbox", checked: selected, onClick: (event) => event.stopPropagation(), onChange: (event) => onToggleSelect(row.id, event.target.checked), className: "size-4 shrink-0 accent-blue-600" }), _jsx("span", { className: `shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide ${STATUS_BADGE_CLASS[row.status]}`, children: row.status }), _jsx("span", { className: "shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300", children: row.flavour }), row.is_client === 1 && (_jsx("span", { className: "shrink-0 rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-600 dark:bg-blue-500/10 dark:text-blue-300", children: "client" })), row.count > 1 && (_jsxs("span", { className: "shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-500 dark:bg-gray-800 dark:text-gray-400", title: `Seen ${row.count} times`, children: ["\u00D7", row.count] })), row.reopen_count > 0 && (_jsxs("span", { className: "shrink-0 rounded-full bg-orange-50 px-2 py-0.5 text-[11px] font-semibold text-orange-700 dark:bg-orange-500/10 dark:text-orange-300", title: `Came back ${row.reopen_count} time${row.reopen_count === 1 ? '' : 's'} after being resolved`, children: ["\u21A9 ", row.reopen_count] })), _jsx("span", { className: "ml-auto shrink-0 text-xs text-gray-400 dark:text-gray-500", title: absoluteTime, children: _jsx(LocalTime, { format: formatRelativeTime, timestampMs: row.updated_at }) })] }), _jsxs("div", { className: "flex flex-wrap items-baseline gap-x-2 gap-y-0.5 pl-4 sm:flex-nowrap", children: [_jsx("span", { className: "shrink-0 font-mono text-xs font-semibold text-gray-800 dark:text-gray-100", children: row.caller }), _jsx("span", { className: "min-w-0 flex-1 truncate text-sm text-gray-500 dark:text-gray-400", children: row.message }), row.user_email && (_jsx("span", { className: "shrink-0 truncate text-xs text-gray-400 sm:ml-auto sm:max-w-[40%] dark:text-gray-500", children: row.user_email }))] })] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function useMounted(): boolean;
|
|
2
|
+
export declare function LocalTime({ format, timestampMs }: {
|
|
3
|
+
format: (timestampMs: number) => string;
|
|
4
|
+
timestampMs: number;
|
|
5
|
+
}): Component;
|
|
6
|
+
export declare function CopyButton({ text, label, copiedLabel, }: {
|
|
7
|
+
text: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
copiedLabel?: string;
|
|
10
|
+
}): Component;
|
|
11
|
+
export declare function DetailBlock({ label, text }: {
|
|
12
|
+
label: string;
|
|
13
|
+
text: string;
|
|
14
|
+
}): Component;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
export function useMounted() {
|
|
5
|
+
const [mounted, setMounted] = useState(false);
|
|
6
|
+
useEffect(() => setMounted(true), []);
|
|
7
|
+
return mounted;
|
|
8
|
+
}
|
|
9
|
+
export function LocalTime({ format, timestampMs }) {
|
|
10
|
+
const mounted = useMounted();
|
|
11
|
+
return _jsx("span", { suppressHydrationWarning: true, children: mounted ? format(timestampMs) : '' });
|
|
12
|
+
}
|
|
13
|
+
export function CopyButton({ text, label = 'Copy', copiedLabel = 'Copied', }) {
|
|
14
|
+
const [copied, setCopied] = useState(false);
|
|
15
|
+
function handleCopy(event) {
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
void navigator.clipboard.writeText(text).then(() => {
|
|
18
|
+
setCopied(true);
|
|
19
|
+
setTimeout(() => setCopied(false), 1500);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return (_jsx("button", { type: "button", onClick: handleCopy, className: "rounded-md border border-gray-300 px-2 py-1 text-[11px] font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200", children: copied ? copiedLabel : label }));
|
|
23
|
+
}
|
|
24
|
+
export function DetailBlock({ label, text }) {
|
|
25
|
+
return (_jsxs("div", { className: "overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-1.5 dark:border-gray-800 dark:bg-gray-950/60", children: [_jsx("span", { className: "text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500", children: label }), _jsx(CopyButton, { text: text })] }), _jsx("pre", { className: "max-h-72 overflow-auto whitespace-pre-wrap break-words p-3 font-mono text-xs leading-relaxed text-gray-700 dark:bg-gray-950 dark:text-gray-300", children: text })] }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
const FIELD_CLASS = 'rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 transition-colors focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white';
|
|
4
|
+
export default function ErrorsFilterForm({ flavours, filters, }) {
|
|
5
|
+
return (_jsxs("form", { className: "flex flex-wrap items-end gap-3 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-900/60", children: [_jsx("input", { type: "hidden", name: "status", value: filters.status }), _jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[11px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500", children: "Flavour" }), _jsxs("select", { name: "flavour", defaultValue: filters.flavour, className: FIELD_CLASS, children: [_jsx("option", { value: "all", children: "All flavours" }), flavours.map((flavour) => (_jsx("option", { value: flavour, children: flavour }, flavour)))] }, filters.flavour)] }), _jsxs("label", { className: "flex min-w-48 flex-1 flex-col gap-1", children: [_jsx("span", { className: "text-[11px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500", children: "Search" }), _jsx("input", { type: "text", name: "q", placeholder: "Message, caller, or user email", defaultValue: filters.q, className: FIELD_CLASS }, filters.q)] }), _jsx("button", { type: "submit", className: "rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700", children: "Apply" })] }));
|
|
6
|
+
}
|