@12-apps/storage 2.0.1 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/storage",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "Image uploads that land at YOUR OWN ORIGIN, never at the bucket (12-20). Isomorphic core (format allowlist, upload ceiling, object-key grammar, rendition set, refusal taxonomy, wire schemas), host-mounted backend surface (./server: createApiStorage, the local-disk driver, the image-pipeline port, the orphan reclaim), Hono adapter (./hono), the S3-compatible driver behind its own subpath (./s3) and the bound picker + upload hook (./react). Standardized adoption contract in ADOPTING.md.",
6
6
  "exports": {
@@ -40,7 +40,7 @@
40
40
  }
41
41
  },
42
42
  "dependencies": {
43
- "@12-apps/ui": "^6.1.0",
43
+ "@12-apps/ui": "^6.2.0",
44
44
  "zod": "^4.3.5"
45
45
  },
46
46
  "devDependencies": {
package/src/hono/index.ts CHANGED
@@ -38,8 +38,13 @@ export type ResolveStorageActor = (
38
38
 
39
39
  export interface StorageHonoConfig extends ApiStorageConfig {
40
40
  resolveActor: ResolveStorageActor;
41
- /** 401 body message. pt-BR by default, like every other refusal here. */
42
- unauthenticatedMessage?: string;
41
+ /**
42
+ * The 401 body's sentence when `resolveActor` answers null — REQUIRED, the
43
+ * host's words (pt-BR hosts: `PT_BR_STORAGE_UNAUTHENTICATED` from
44
+ * `../pt-BR`). The old default was the same silent leak every other surface
45
+ * here just paid down.
46
+ */
47
+ unauthenticatedMessage: string;
43
48
  }
44
49
 
45
50
  export interface StorageHono extends ApiStorage {
@@ -67,7 +72,7 @@ function send(c: Context, response: StorageRouteResponse): Response {
67
72
  export function storageRouter(config: StorageHonoConfig): StorageHono {
68
73
  const { resolveActor, unauthenticatedMessage, ...apiConfig } = config;
69
74
  const api = createApiStorage(apiConfig);
70
- const unauthenticated = unauthenticatedMessage ?? 'Não autenticado.';
75
+ const unauthenticated = unauthenticatedMessage;
71
76
  const router = new Hono();
72
77
 
73
78
  for (const route of api.routes) {
package/src/index.ts CHANGED
@@ -70,12 +70,12 @@ export {
70
70
  STORAGE_PROBLEM_STATUS,
71
71
  StorageNotConfiguredError,
72
72
  StorageProblemError,
73
- defaultStorageMessages,
74
73
  isStorageProblem,
75
74
  type StorageMessageContext,
76
75
  type StorageMessages,
77
76
  type StorageProblem,
78
77
  } from './problems';
78
+ export { PT_BR_STORAGE_MESSAGES, PT_BR_STORAGE_UNAUTHENTICATED } from './pt-BR';
79
79
 
80
80
  export {
81
81
  imageSources,
package/src/problems.ts CHANGED
@@ -56,29 +56,6 @@ export interface StorageMessageContext {
56
56
  limit: string;
57
57
  }
58
58
 
59
- /**
60
- * The default pt-BR copy. `limit` is interpolated rather than hard-coded so a
61
- * host that raises `maxBytes` cannot end up with a message naming the old
62
- * number — the single commonest way this kind of copy goes stale.
63
- */
64
- export function defaultStorageMessages(context: StorageMessageContext): StorageMessages {
65
- return {
66
- invalid_base64: 'A imagem enviada não está codificada corretamente em base64.',
67
- empty_file: 'A imagem enviada está vazia.',
68
- file_too_large: `A imagem enviada é maior que o limite de ${context.limit}.`,
69
- content_mismatch:
70
- 'O conteúdo da imagem não corresponde ao formato informado. Envie um PNG, JPEG, WebP ou GIF.',
71
- unsupported_content_type: 'Formato não suportado. Envie PNG, JPG, WebP ou GIF.',
72
- image_unreadable:
73
- 'Não foi possível ler a imagem enviada — o arquivo parece estar corrompido.',
74
- image_dimensions_too_large:
75
- 'A imagem enviada tem dimensões grandes demais para ser processada.',
76
- storage_not_configured: 'O armazenamento de imagens ainda não foi configurado nesta loja.',
77
- storage_unavailable:
78
- 'Não foi possível salvar a imagem agora. Tente novamente em instantes.',
79
- };
80
- }
81
-
82
59
  /**
83
60
  * A refusal, carrying the status and the finished sentence.
84
61
  *
package/src/pt-BR.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { StorageMessageContext, StorageMessages } from './problems';
2
+
3
+ /**
4
+ * The pt-BR pack — a NAMED factory a host passes by hand
5
+ * (`messages: PT_BR_STORAGE_MESSAGES`), never a default. The filename is what
6
+ * exempts this file from the copy-portability gate: Portuguese may ship, it
7
+ * may not be silent. `limit` is interpolated rather than hard-coded so a
8
+ * host that raises `maxBytes` cannot end up with a message naming the old
9
+ * number — the single commonest way this kind of copy goes stale.
10
+ */
11
+ export function PT_BR_STORAGE_MESSAGES(context: StorageMessageContext): StorageMessages {
12
+ return {
13
+ invalid_base64: 'A imagem enviada não está codificada corretamente em base64.',
14
+ empty_file: 'A imagem enviada está vazia.',
15
+ file_too_large: `A imagem enviada é maior que o limite de ${context.limit}.`,
16
+ content_mismatch:
17
+ 'O conteúdo da imagem não corresponde ao formato informado. Envie um PNG, JPEG, WebP ou GIF.',
18
+ unsupported_content_type: 'Formato não suportado. Envie PNG, JPG, WebP ou GIF.',
19
+ image_unreadable:
20
+ 'Não foi possível ler a imagem enviada — o arquivo parece estar corrompido.',
21
+ image_dimensions_too_large:
22
+ 'A imagem enviada tem dimensões grandes demais para ser processada.',
23
+ storage_not_configured: 'O armazenamento de imagens ainda não foi configurado nesta loja.',
24
+ storage_unavailable:
25
+ 'Não foi possível salvar a imagem agora. Tente novamente em instantes.',
26
+ };
27
+ }
28
+
29
+ /** The 401 the mounted router answers when the host resolves no actor. */
30
+ export const PT_BR_STORAGE_UNAUTHENTICATED = 'Não autenticado.';
@@ -4,7 +4,7 @@ import { Stack } from '@12-apps/ui/mui/Stack';
4
4
  import { Text } from '@12-apps/ui/typography/Text';
5
5
 
6
6
  import { DEFAULT_MAX_UPLOAD_BYTES, megabytes } from '../limits';
7
- import type { WebStorageMessages } from './failures';
7
+ import type { WebStorageMessageContext, WebStorageMessages } from './failures';
8
8
  import { ImageField, type ImageFieldProps } from './image-field';
9
9
  import type { ImageProfile } from './optimize-image';
10
10
  import { useUpload, type UploadState, type UseUploadConfig } from './use-upload';
@@ -33,13 +33,14 @@ export interface WebStorageConfig {
33
33
  /** How requests are made. Default: same-origin `fetch`. */
34
34
  fetchImpl?: typeof fetch;
35
35
  /**
36
- * pt-BR refusal copy overrides, mirroring the server mount's `messages`.
37
- *
38
- * Override `forbidden` to name YOUR reason: the package states only that the
39
- * account may not upload, because `mayUpload` is one host-computed boolean and a
40
- * package that named a role model would be wrong for every host but the first.
36
+ * Every sentence this surface renders — REQUIRED, the host's own factory of
37
+ * the mount's ceiling, mirroring the server mount's `messages` (pt-BR
38
+ * hosts: `PT_BR_WEB_STORAGE_MESSAGES` from `./pt-BR`). Write `forbidden` to
39
+ * name YOUR reason: the package states only that the account may not
40
+ * upload, because `mayUpload` is one host-computed boolean and a package
41
+ * that named a role model would be wrong for every host but the first.
41
42
  */
42
- messages?: Partial<WebStorageMessages>;
43
+ messages: (context: WebStorageMessageContext) => WebStorageMessages;
43
44
  }
44
45
 
45
46
  export interface WebStorage {
@@ -60,7 +61,7 @@ export function createWebStorage(config: WebStorageConfig): WebStorage {
60
61
  maxBytes,
61
62
  ...(config.profile ? { profile: config.profile } : {}),
62
63
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
63
- ...(config.messages ? { messages: config.messages } : {}),
64
+ messages: config.messages,
64
65
  };
65
66
  const useBoundUpload = (): UploadState => useUpload(uploadConfig);
66
67
 
@@ -75,17 +76,16 @@ export function createWebStorage(config: WebStorageConfig): WebStorage {
75
76
  */
76
77
  function UploadPage(): JSX.Element {
77
78
  const [imageKey, setImageKey] = useState<string | null>(null);
79
+ const messages = config.messages({ limit: megabytes(maxBytes) });
78
80
  return (
79
81
  <Stack spacing={2} data-testid="storage-upload-page">
80
- <Text as="h2">Imagens da loja</Text>
81
- <Text as="p">
82
- {`Envie um PNG, JPG, WebP ou GIF de até ${megabytes(maxBytes)}.`}
83
- </Text>
82
+ <Text as="h2">{messages.pageTitle}</Text>
83
+ <Text as="p">{messages.pageIntro}</Text>
84
84
  <BoundImageField
85
85
  value={imageKey}
86
86
  onChange={setImageKey}
87
- label="Foto do produto"
88
- helperText="A imagem é reduzida no navegador antes do envio."
87
+ label={messages.fieldLabel}
88
+ helperText={messages.fieldHelper}
89
89
  />
90
90
  </Stack>
91
91
  );
@@ -88,8 +88,25 @@ export interface WebStorageMessages {
88
88
  unsupported_content_type_upfront: (context: { contentType: string }) => string;
89
89
  /** 2xx with no key in the body — accepted, and unusable. */
90
90
  missing_key: string;
91
+ /**
92
+ * A refusal whose body carried no sentence and no code this table knows —
93
+ * the status (and the raw code, when there was one) ride along, because
94
+ * they are what turns "it failed" into something greppable.
95
+ */
96
+ upload_failed_http: (context: { status: number; detail: string }) => string;
91
97
  /** Anything thrown on the way. The last resort, and it says so. */
92
98
  upload_failed: string;
99
+
100
+ /**
101
+ * The standalone upload page's own chrome — heading, the one-line
102
+ * instruction (which carries the ceiling, same rule as the refusals), the
103
+ * field label and its helper. These rendered as literals inside
104
+ * `createWebStorage` until copy became required config.
105
+ */
106
+ pageTitle: string;
107
+ pageIntro: string;
108
+ fieldLabel: string;
109
+ fieldHelper: string;
93
110
  }
94
111
 
95
112
  /** Copy that only makes sense with the configured ceiling in it. */
@@ -98,34 +115,6 @@ export interface WebStorageMessageContext {
98
115
  limit: string;
99
116
  }
100
117
 
101
- /**
102
- * The default pt-BR copy. `limit` is interpolated rather than hard-coded for the
103
- * same reason the server does it: a host that raises the ceiling must not end up
104
- * with a message naming the old number.
105
- */
106
- export function defaultWebStorageMessages(
107
- context: WebStorageMessageContext,
108
- ): WebStorageMessages {
109
- return {
110
- forbidden: 'Sua conta não tem permissão para enviar imagens nesta loja.',
111
- unsupported_content_type: 'Formato não suportado. Envie PNG, JPG, WebP ou GIF.',
112
- file_too_large: `Imagem muito grande. O limite é ${context.limit}.`,
113
- invalid_key: 'Não foi possível localizar essa imagem.',
114
- not_found: 'Essa imagem não está mais disponível.',
115
- session_expired: 'Sua sessão expirou. Entre novamente e repita o envio.',
116
- transport: 'Não foi possível falar com o servidor. Verifique sua conexão e tente de novo.',
117
- empty_file: 'Arquivo vazio (0 bytes). Escolha a imagem novamente.',
118
- file_too_large_upfront: ({ size, limit }) =>
119
- `Imagem muito grande: ${size}. O limite é ${limit} — reduza a imagem e tente de novo.`,
120
- unknown_content_type:
121
- 'Não foi possível identificar o tipo do arquivo. Envie um PNG, JPG ou WebP.',
122
- unsupported_content_type_upfront: ({ contentType }) =>
123
- `Formato não suportado (${contentType}). Envie PNG, JPG, WebP ou GIF.`,
124
- missing_key: 'O servidor aceitou a imagem mas não devolveu a chave. Tente de novo.',
125
- upload_failed: 'Falha ao enviar a imagem. Tente de novo em instantes.',
126
- };
127
- }
128
-
129
118
  /**
130
119
  * The messages in force for a mount's ceiling.
131
120
  *
@@ -135,9 +124,9 @@ export function defaultWebStorageMessages(
135
124
  */
136
125
  export function messagesOf(
137
126
  maxBytes: number,
138
- overrides?: Partial<WebStorageMessages>,
127
+ factory: (context: WebStorageMessageContext) => WebStorageMessages,
139
128
  ): WebStorageMessages {
140
- return { ...defaultWebStorageMessages({ limit: megabytes(maxBytes) }), ...overrides };
129
+ return factory({ limit: megabytes(maxBytes) });
141
130
  }
142
131
 
143
132
  const resolve = messagesOf;
@@ -173,9 +162,9 @@ function sentenceFor(messages: WebStorageMessages, code: string): string | undef
173
162
  export function rejectFileUpfront(
174
163
  file: File,
175
164
  maxBytes: number,
176
- overrides?: Partial<WebStorageMessages>,
165
+ factory: (context: WebStorageMessageContext) => WebStorageMessages,
177
166
  ): string | null {
178
- const messages = resolve(maxBytes, overrides);
167
+ const messages = resolve(maxBytes, factory);
179
168
  if (file.size === 0) {
180
169
  return messages.empty_file;
181
170
  }
@@ -230,9 +219,9 @@ async function statedError(response: Response): Promise<string | null> {
230
219
  export async function uploadFailure(
231
220
  response: Response,
232
221
  maxBytes: number,
233
- overrides?: Partial<WebStorageMessages>,
222
+ factory: (context: WebStorageMessageContext) => WebStorageMessages,
234
223
  ): Promise<string> {
235
- const messages = resolve(maxBytes, overrides);
224
+ const messages = resolve(maxBytes, factory);
236
225
  if (response.status === 401) return messages.session_expired;
237
226
  const stated = await statedError(response);
238
227
  // A sentence rather than a code: the server already phrased it for the owner,
@@ -245,7 +234,7 @@ export async function uploadFailure(
245
234
  // what turns a support request from "it failed" into something greppable — so it
246
235
  // rides along with the status rather than being mapped away.
247
236
  const detail = stated ? ` (${stated})` : '';
248
- return `Não foi possível enviar a imagem (HTTP ${response.status}${detail}). Tente de novo em instantes.`;
237
+ return messages.upload_failed_http({ status: response.status, detail });
249
238
  }
250
239
 
251
240
  /**
@@ -254,8 +243,10 @@ export async function uploadFailure(
254
243
  * One sentence, and it can afford to be definite: the upload goes to our own
255
244
  * origin, so there is no cross-origin case to hedge about.
256
245
  */
257
- export function transportFailure(overrides?: Partial<WebStorageMessages>): string {
258
- // No ceiling to interpolate on this one, so it is read without a limit rather
259
- // than through a table built from a number that would be a lie.
260
- return overrides?.transport ?? defaultWebStorageMessages({ limit: '' }).transport;
246
+ export function transportFailure(
247
+ factory: (context: WebStorageMessageContext) => WebStorageMessages,
248
+ ): string {
249
+ // No ceiling to interpolate on this one, so the factory is read without a
250
+ // limit rather than through a table built from a number that would be a lie.
251
+ return factory({ limit: '' }).transport;
261
252
  }
@@ -35,10 +35,10 @@ export {
35
35
  } from './optimize-image';
36
36
 
37
37
  export {
38
- defaultWebStorageMessages,
39
38
  rejectFileUpfront,
40
39
  transportFailure,
41
40
  uploadFailure,
42
41
  type WebStorageMessageContext,
43
42
  type WebStorageMessages,
44
43
  } from './failures';
44
+ export { PT_BR_WEB_STORAGE_MESSAGES } from './pt-BR';
@@ -0,0 +1,38 @@
1
+ import type { WebStorageMessageContext, WebStorageMessages } from './failures';
2
+
3
+ /**
4
+ * The pt-BR pack — a NAMED factory a host passes by hand, never a default.
5
+ * `limit` is interpolated rather than hard-coded for the same reason the
6
+ * server pack does it: a host that raises the ceiling must not end up with
7
+ * copy naming the old number. The filename is what exempts this file from
8
+ * the copy-portability gate: Portuguese may ship, it may not be silent.
9
+ */
10
+ export function PT_BR_WEB_STORAGE_MESSAGES(
11
+ context: WebStorageMessageContext,
12
+ ): WebStorageMessages {
13
+ return {
14
+ forbidden: 'Sua conta não tem permissão para enviar imagens nesta loja.',
15
+ unsupported_content_type: 'Formato não suportado. Envie PNG, JPG, WebP ou GIF.',
16
+ file_too_large: `Imagem muito grande. O limite é ${context.limit}.`,
17
+ invalid_key: 'Não foi possível localizar essa imagem.',
18
+ not_found: 'Essa imagem não está mais disponível.',
19
+ session_expired: 'Sua sessão expirou. Entre novamente e repita o envio.',
20
+ transport: 'Não foi possível falar com o servidor. Verifique sua conexão e tente de novo.',
21
+ empty_file: 'Arquivo vazio (0 bytes). Escolha a imagem novamente.',
22
+ file_too_large_upfront: ({ size, limit }) =>
23
+ `Imagem muito grande: ${size}. O limite é ${limit} — reduza a imagem e tente de novo.`,
24
+ unknown_content_type:
25
+ 'Não foi possível identificar o tipo do arquivo. Envie um PNG, JPG ou WebP.',
26
+ unsupported_content_type_upfront: ({ contentType }) =>
27
+ `Formato não suportado (${contentType}). Envie PNG, JPG, WebP ou GIF.`,
28
+ missing_key: 'O servidor aceitou a imagem mas não devolveu a chave. Tente de novo.',
29
+ upload_failed_http: ({ status, detail }) =>
30
+ `Não foi possível enviar a imagem (HTTP ${status}${detail}). Tente de novo em instantes.`,
31
+ upload_failed: 'Falha ao enviar a imagem. Tente de novo em instantes.',
32
+
33
+ pageTitle: 'Imagens da loja',
34
+ pageIntro: `Envie um PNG, JPG, WebP ou GIF de até ${context.limit}.`,
35
+ fieldLabel: 'Foto do produto',
36
+ fieldHelper: 'A imagem é reduzida no navegador antes do envio.',
37
+ };
38
+ }
@@ -6,6 +6,7 @@ import {
6
6
  rejectFileUpfront,
7
7
  transportFailure,
8
8
  uploadFailure,
9
+ type WebStorageMessageContext,
9
10
  type WebStorageMessages,
10
11
  } from './failures';
11
12
  import { optimizeImage, type ImageProfile } from './optimize-image';
@@ -56,8 +57,11 @@ export interface UseUploadConfig {
56
57
  profile?: ImageProfile;
57
58
  /** How the request is made. Default: same-origin `fetch`. */
58
59
  fetchImpl?: typeof fetch;
59
- /** pt-BR refusal copy overrides, for the codes only the host can explain. */
60
- messages?: Partial<WebStorageMessages>;
60
+ /**
61
+ * The refusal copy, as the host's own factory of the mount's ceiling —
62
+ * REQUIRED (pt-BR hosts: `PT_BR_WEB_STORAGE_MESSAGES` from `./pt-BR`).
63
+ */
64
+ messages: (context: WebStorageMessageContext) => WebStorageMessages;
61
65
  }
62
66
 
63
67
  export function useUpload(config: UseUploadConfig): UploadState {
@@ -1,7 +1,7 @@
1
1
  import { ACCEPTED_CONTENT_TYPES } from '../content-types';
2
2
  import { DEFAULT_KEY_PREFIX } from '../keys';
3
3
  import { megabytes } from '../limits';
4
- import { defaultStorageMessages, type StorageMessages } from '../problems';
4
+ import type { StorageMessageContext, StorageMessages } from '../problems';
5
5
  import { CATALOG_RENDITIONS, type RenditionSpec } from '../renditions';
6
6
  import { imageSources, objectUrl, versionedObjectUrl, type ImageSources } from '../urls';
7
7
  import { inlineImageSchema, objectKeySchema, type InlineImage } from '../wire';
@@ -94,8 +94,13 @@ export interface ApiStorageConfig {
94
94
  renditions?: readonly RenditionSpec[];
95
95
  /** Top-level key segment. Default `products`. */
96
96
  keyPrefix?: string;
97
- /** pt-BR refusal copy overrides. */
98
- messages?: Partial<StorageMessages>;
97
+ /**
98
+ * The sentence each refusal is stated in — REQUIRED, the host's words. A
99
+ * factory of the mount's own context so a host that raises `maxBytes`
100
+ * cannot end up with copy naming the old ceiling. pt-BR hosts pass
101
+ * `PT_BR_STORAGE_MESSAGES` from `../pt-BR`.
102
+ */
103
+ messages: (context: StorageMessageContext) => StorageMessages;
99
104
  }
100
105
 
101
106
  /** What the mount reports about itself — the anti-drift surface. */
@@ -167,10 +172,7 @@ const SILENT: StorageLogger = { error: () => undefined };
167
172
  export function createApiStorage(config: ApiStorageConfig): ApiStorage {
168
173
  const specs = config.renditions ?? CATALOG_RENDITIONS;
169
174
  const keyPrefix = config.keyPrefix ?? DEFAULT_KEY_PREFIX;
170
- const messages: StorageMessages = {
171
- ...defaultStorageMessages({ limit: megabytes(config.maxBytes) }),
172
- ...config.messages,
173
- };
175
+ const messages: StorageMessages = config.messages({ limit: megabytes(config.maxBytes) });
174
176
  const writeDeps: StoreImageDeps = {
175
177
  driver: config.driver,
176
178
  pipeline: config.imagePipeline,