ampless 1.0.0-alpha.17 → 1.0.0-alpha.18

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/dist/index.d.ts CHANGED
@@ -314,13 +314,27 @@ type PluginCapability = 'publicHead' | 'publicBody' | 'metadata' | 'eventHooks'
314
314
  type ScriptStrategy = 'afterInteractive' | 'lazyOnload';
315
315
  /**
316
316
  * Context passed to `publicHead` / `publicBodyEnd`. Phase 1 carries
317
- * only the site-wide config block; per-route and per-post context lands
318
- * in Phase 4 (`plugin-per-post-rfp.md`). Plugin settings are read from
319
- * the factory closure today Phase 2 (`plugin-settings-rfp.md`) adds
320
- * an admin-managed accessor here.
317
+ * only the site-wide config block. Phase 2 adds the `setting()`
318
+ * accessor for admin-managed `settings.public` values; per-route /
319
+ * per-post context lands in Phase 4 (`plugin-per-post-rfp.md`).
321
320
  */
322
321
  interface PluginPublicRenderContext {
323
322
  site: Config['site'];
323
+ /**
324
+ * Resolve a public setting value for the active plugin instance.
325
+ * Returns `stored ?? manifest.default ?? undefined`. Stored values
326
+ * are read at request time from the DDB → S3 site-settings cache
327
+ * pipeline. Bound by the runtime when the plugin declares
328
+ * `settings.public` — plugins without a manifest can still call
329
+ * this, in which case it always returns `undefined`.
330
+ *
331
+ * The generic type is a convenience cast: the runtime does not
332
+ * coerce values, so callers should pick `T` to match the field's
333
+ * declared type (`text` → `string`, `number` → `number`, etc.).
334
+ * Validation at admin save time guarantees stored values match the
335
+ * field's declared shape, so the cast is safe in practice.
336
+ */
337
+ setting<T = unknown>(key: string): T | undefined;
324
338
  }
325
339
  /**
326
340
  * Descriptor returned by `publicHead()`. The runtime validates each
@@ -474,6 +488,95 @@ interface OgImageConfig {
474
488
  */
475
489
  render(ctx: OgImageRenderContext): Promise<unknown> | unknown;
476
490
  }
491
+ /**
492
+ * Shared shape for every `PluginSettingField` variant. `T` is the
493
+ * decoded value type (string for text-like fields, number for number,
494
+ * boolean for boolean, etc.). All variants share `key` / `label` /
495
+ * `default` / `required`; type-specific constraints (e.g. `pattern`,
496
+ * `min`, `options`) live on the discriminated branches.
497
+ */
498
+ interface PluginFieldBase<T> {
499
+ /**
500
+ * Storage key. Stored as `plugins.<instanceId>.<key>`. Must match
501
+ * `PLUGIN_KEY_PATTERN` (`/^[a-zA-Z0-9_-]+$/`) so the `pk.sk` dotted
502
+ * separator survives. Violations are skipped + warned by the
503
+ * runtime/admin normalization pass.
504
+ */
505
+ key: string;
506
+ label: LocalizedString;
507
+ description?: LocalizedString;
508
+ /** Used by the runtime when no admin-stored value is present. */
509
+ default?: T;
510
+ /**
511
+ * When true, an empty / undefined value is rejected at save time
512
+ * and the resolver returns `undefined`. When false (default),
513
+ * string-like fields accept empty string as a valid "disabled"
514
+ * value while non-string fields still reject empty strings.
515
+ */
516
+ required?: boolean;
517
+ /** Optional UI grouping (mirrors `ThemeField.group`). */
518
+ group?: LocalizedString;
519
+ }
520
+ interface PluginTextField extends PluginFieldBase<string> {
521
+ type: 'text';
522
+ maxLength?: number;
523
+ /** RegExp source (e.g. `'^G-[A-Z0-9]+$'`). Validated against
524
+ * non-empty values; empty string skips the check. */
525
+ pattern?: string;
526
+ /** Placeholder for the admin input. */
527
+ placeholder?: string;
528
+ }
529
+ interface PluginTextareaField extends PluginFieldBase<string> {
530
+ type: 'textarea';
531
+ maxLength?: number;
532
+ placeholder?: string;
533
+ /** Rendered rows hint for the admin textarea. */
534
+ rows?: number;
535
+ }
536
+ interface PluginBooleanField extends PluginFieldBase<boolean> {
537
+ type: 'boolean';
538
+ }
539
+ interface PluginNumberField extends PluginFieldBase<number> {
540
+ type: 'number';
541
+ min?: number;
542
+ max?: number;
543
+ step?: number;
544
+ }
545
+ interface PluginSelectField extends PluginFieldBase<string> {
546
+ type: 'select';
547
+ options: ReadonlyArray<{
548
+ value: string;
549
+ label: LocalizedString;
550
+ }>;
551
+ }
552
+ interface PluginUrlField extends PluginFieldBase<string> {
553
+ type: 'url';
554
+ placeholder?: string;
555
+ /** When true, relative paths (`/foo`, `./foo`) pass validation; default true. */
556
+ allowRelative?: boolean;
557
+ }
558
+ interface PluginCodeField extends PluginFieldBase<string> {
559
+ type: 'code';
560
+ /** Display-only language label (e.g. `'js'`, `'css'`, `'html'`). */
561
+ language?: string;
562
+ maxLength?: number;
563
+ placeholder?: string;
564
+ rows?: number;
565
+ }
566
+ interface PluginJsonField extends PluginFieldBase<unknown> {
567
+ type: 'json';
568
+ placeholder?: string;
569
+ rows?: number;
570
+ }
571
+ type PluginSettingField = PluginTextField | PluginTextareaField | PluginBooleanField | PluginNumberField | PluginSelectField | PluginUrlField | PluginCodeField | PluginJsonField;
572
+ /**
573
+ * Per-plugin settings declaration. Phase 2 implements `public`;
574
+ * `secret` is reserved for Phase 6a (admin-only storage; never reaches
575
+ * the public runtime).
576
+ */
577
+ interface PluginSettingsManifest {
578
+ public?: readonly PluginSettingField[];
579
+ }
477
580
  interface AmplessPlugin {
478
581
  name: string;
479
582
  /** Plugin API version. Currently 1; future versions will be additive. */
@@ -501,6 +604,15 @@ interface AmplessPlugin {
501
604
  * continue to work unchanged.
502
605
  */
503
606
  capabilities?: readonly PluginCapability[];
607
+ /**
608
+ * Public, admin-editable settings (Phase 2). Each declared field is
609
+ * stored under `pk='siteconfig', sk='plugins.<instanceId>.<key>'`,
610
+ * mirrored to S3 via the trusted processor, and surfaced to
611
+ * `publicHead` / `publicBodyEnd` via `ctx.setting<T>(key)`. Plugins
612
+ * without an admin UI continue to work — they just don't have a
613
+ * `settings` block.
614
+ */
615
+ settings?: PluginSettingsManifest;
504
616
  /** Async event hooks. Run in trust_level-matched Lambda. */
505
617
  hooks?: {
506
618
  [K in EventType]?: PluginEventHandler<K>;
@@ -914,6 +1026,53 @@ declare function decodeAwsJson(value: unknown): unknown;
914
1026
  */
915
1027
  declare function formatPublicAssetUrl(bucket: string, region: string, key: string): string;
916
1028
 
1029
+ declare const PLUGIN_KEY_PATTERN: RegExp;
1030
+ declare function isValidPluginKey(key: string): boolean;
1031
+ /**
1032
+ * Validate one raw value against a field definition. Returns the
1033
+ * (possibly-coerced) value on success, or `null` to signal rejection.
1034
+ *
1035
+ * Semantics:
1036
+ * - **string-like fields** (text / textarea / url / code) accept
1037
+ * `''` as valid when `required` is falsy. This is the "disable"
1038
+ * sentinel — e.g. `pattern: '^$|^G-...'` lets a GA4 plugin save
1039
+ * an empty measurementId to suppress the loader without dropping
1040
+ * the row.
1041
+ * - **non-string-like fields** (number / boolean / json / select)
1042
+ * always reject `''`. Allowing it would force callers to handle a
1043
+ * `string` reading where `number | undefined` is declared. To
1044
+ * "unset" these fields, delete the row (admin form's "Reset to
1045
+ * default" button).
1046
+ * - `undefined` is treated as "not set" by `resolvePluginSettings`
1047
+ * — `validatePluginSettingValue` itself returns `null` for it.
1048
+ * - Constraints (`pattern`, `min`, `max`, `maxLength`, `options`) run
1049
+ * only when the value is present and non-empty.
1050
+ *
1051
+ * The returned shape preserves the declared type — `text` stores a
1052
+ * sanitized string, `number` stores a `number`, `boolean` stores a
1053
+ * `boolean`, `json` stores the decoded value (object / array /
1054
+ * primitive — never the source string), etc.
1055
+ */
1056
+ declare function validatePluginSettingValue(field: PluginSettingField, raw: unknown): unknown | null;
1057
+ /**
1058
+ * Merge stored values on top of manifest defaults for one plugin
1059
+ * instance. Both sides are validated through `validatePluginSettingValue`
1060
+ * — stored values that fail validation (manual DDB tampering, schema
1061
+ * drift) fall back to the validated default; defaults that themselves
1062
+ * fail validation surface as `undefined`.
1063
+ *
1064
+ * `stored` is a flat key → value map keyed by the field's `key` (not
1065
+ * the full DDB SK). The runtime extracts it from the site-settings
1066
+ * snapshot using the `plugins.<instanceId>.` prefix.
1067
+ *
1068
+ * Validation runs on the default too because the default can come
1069
+ * from a plugin's constructor argument (e.g. GA4 takes a
1070
+ * `measurementId` option and passes it through as the field's
1071
+ * `default`). If the operator supplies a bogus value at install time
1072
+ * we'd rather surface `undefined` than render garbage.
1073
+ */
1074
+ declare function resolvePluginSettings(manifest: PluginSettingsManifest | undefined, stored: Record<string, unknown>): Record<string, unknown>;
1075
+
917
1076
  /**
918
1077
  * Extract the first image URL from a post body. Used by plugins (e.g.
919
1078
  * plugin-og-image) that want to derive an image from the post content
@@ -1019,4 +1178,4 @@ declare function pickDefaultEntrypoint(files: readonly {
1019
1178
 
1020
1179
  declare const VERSION = "0.0.1";
1021
1180
 
1022
- export { type AmplessEvent, type AmplessPlugin, type AuthContext, type BundleExtractResult, type CacheConfig, type CacheStrategy, type Config, type ContentEventPayload, type ContentEventType, type ContentFormat, type ContentTypeDefinition, type CreatePostInput, DEFAULT_ENTRYPOINT, type DateFormat, type EventPayloadOf, type EventType, type ExtractedFile, type FieldDefinition, type FieldType, type ImageDisplay, type KvItem, type KvStore, type LinkListItem, type ListOptions, type LocalizedString, MAX_BUNDLE_BYTES, type Media, type MediaEventPayload, type MediaEventType, type MediaMetadata, type MediaProcessingDefaults, type OgImageConfig, type OgImageFont, type OgImageRenderContext, type Page, type PluginCapability, type PluginEventHandler, type PluginMetadata, type PluginPublicRenderContext, type PluginRuntimeContext, type Post, type PostIndexEventPayload, type PostIndexEventType, type PostMetadata, type PostStatus, type PostsProvider, type PublicBodyDescriptor, type PublicHeadDescriptor, type Role, SITE_CONFIG_PK, type ScriptStrategy, type SiteSettingsEventPayload, type SiteSettingsEventType, type StaticPostBody, type StaticPostFileMeta, type StreamEventName, TEXT_EXTENSIONS, type ThemeColorField, type ThemeField, type ThemeFieldType, type ThemeFontFamilyField, type ThemeImageField, type ThemeLengthField, type ThemeLinkListField, type ThemeManifest, type ThemeModule, type ThemeRouteContext, type ThemeSelectField, type ThemeTextField, type TrustLevel, VERSION, type ValidationIssue, bundlePrefix, createPost, decodeAwsJson, defineConfig, definePlugin, defineSchema, defineTheme, defineThemeModule, deletePost, deleteSiteSetting, detectContentEvents, encodeAwsJson, escapeXml, extractFirstImageUrl, findAbsolutePathRefs, flattenSettings, formatColorPair, formatDate, formatPublicAssetUrl, getKvStore, getPost, getPostById, getSiteSetting, hasKvStore, hasPostsProvider, isTagListUrl, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validateThemeValue };
1181
+ export { type AmplessEvent, type AmplessPlugin, type AuthContext, type BundleExtractResult, type CacheConfig, type CacheStrategy, type Config, type ContentEventPayload, type ContentEventType, type ContentFormat, type ContentTypeDefinition, type CreatePostInput, DEFAULT_ENTRYPOINT, type DateFormat, type EventPayloadOf, type EventType, type ExtractedFile, type FieldDefinition, type FieldType, type ImageDisplay, type KvItem, type KvStore, type LinkListItem, type ListOptions, type LocalizedString, MAX_BUNDLE_BYTES, type Media, type MediaEventPayload, type MediaEventType, type MediaMetadata, type MediaProcessingDefaults, type OgImageConfig, type OgImageFont, type OgImageRenderContext, PLUGIN_KEY_PATTERN, type Page, type PluginBooleanField, type PluginCapability, type PluginCodeField, type PluginEventHandler, type PluginJsonField, type PluginMetadata, type PluginNumberField, type PluginPublicRenderContext, type PluginRuntimeContext, type PluginSelectField, type PluginSettingField, type PluginSettingsManifest, type PluginTextField, type PluginTextareaField, type PluginUrlField, type Post, type PostIndexEventPayload, type PostIndexEventType, type PostMetadata, type PostStatus, type PostsProvider, type PublicBodyDescriptor, type PublicHeadDescriptor, type Role, SITE_CONFIG_PK, type ScriptStrategy, type SiteSettingsEventPayload, type SiteSettingsEventType, type StaticPostBody, type StaticPostFileMeta, type StreamEventName, TEXT_EXTENSIONS, type ThemeColorField, type ThemeField, type ThemeFieldType, type ThemeFontFamilyField, type ThemeImageField, type ThemeLengthField, type ThemeLinkListField, type ThemeManifest, type ThemeModule, type ThemeRouteContext, type ThemeSelectField, type ThemeTextField, type TrustLevel, VERSION, type ValidationIssue, bundlePrefix, createPost, decodeAwsJson, defineConfig, definePlugin, defineSchema, defineTheme, defineThemeModule, deletePost, deleteSiteSetting, detectContentEvents, encodeAwsJson, escapeXml, extractFirstImageUrl, findAbsolutePathRefs, flattenSettings, formatColorPair, formatDate, formatPublicAssetUrl, getKvStore, getPost, getPostById, getSiteSetting, hasKvStore, hasPostsProvider, isTagListUrl, isValidPluginKey, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolvePluginSettings, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validatePluginSettingValue, validateThemeValue };
package/dist/index.js CHANGED
@@ -215,6 +215,116 @@ function definePlugin(p) {
215
215
  return p;
216
216
  }
217
217
 
218
+ // src/plugin-settings.ts
219
+ var PLUGIN_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/;
220
+ function isValidPluginKey(key) {
221
+ return typeof key === "string" && PLUGIN_KEY_PATTERN.test(key);
222
+ }
223
+ function validatePluginSettingValue(field, raw) {
224
+ if (raw === void 0) return null;
225
+ switch (field.type) {
226
+ case "text":
227
+ case "textarea":
228
+ case "code": {
229
+ if (typeof raw !== "string") return null;
230
+ const sanitized = raw.replace(/[\x00-\x1f<>]/g, "");
231
+ if (sanitized === "") {
232
+ return field.required ? null : "";
233
+ }
234
+ const maxLength = field.type === "text" && field.maxLength || field.type === "textarea" && field.maxLength || field.type === "code" && field.maxLength || void 0;
235
+ if (typeof maxLength === "number" && sanitized.length > maxLength) {
236
+ return null;
237
+ }
238
+ if (field.type === "text" && field.pattern) {
239
+ let re;
240
+ try {
241
+ re = new RegExp(field.pattern);
242
+ } catch {
243
+ return null;
244
+ }
245
+ if (!re.test(sanitized)) return null;
246
+ }
247
+ return sanitized;
248
+ }
249
+ case "url": {
250
+ if (typeof raw !== "string") return null;
251
+ const trimmed = raw.trim();
252
+ if (trimmed === "") {
253
+ return field.required ? null : "";
254
+ }
255
+ if (/^\s*(javascript|vbscript|data|blob|file):/i.test(trimmed)) {
256
+ return null;
257
+ }
258
+ const allowRelative = field.allowRelative !== false;
259
+ const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
260
+ if (schemeMatch) {
261
+ const scheme = schemeMatch[1].toLowerCase();
262
+ if (scheme !== "http" && scheme !== "https") return null;
263
+ } else if (!allowRelative) {
264
+ return null;
265
+ }
266
+ return trimmed;
267
+ }
268
+ case "boolean": {
269
+ if (typeof raw === "boolean") return raw;
270
+ if (raw === "true" || raw === 1) return true;
271
+ if (raw === "false" || raw === 0) return false;
272
+ return null;
273
+ }
274
+ case "number": {
275
+ if (typeof raw === "number") {
276
+ if (Number.isNaN(raw)) return null;
277
+ } else if (typeof raw === "string") {
278
+ const trimmed = raw.trim();
279
+ if (trimmed === "") return null;
280
+ const n2 = Number(trimmed);
281
+ if (Number.isNaN(n2)) return null;
282
+ raw = n2;
283
+ } else {
284
+ return null;
285
+ }
286
+ const n = raw;
287
+ if (typeof field.min === "number" && n < field.min) return null;
288
+ if (typeof field.max === "number" && n > field.max) return null;
289
+ return n;
290
+ }
291
+ case "select": {
292
+ if (typeof raw !== "string") return null;
293
+ if (raw === "") return null;
294
+ if (!field.options.some((opt) => opt.value === raw)) return null;
295
+ return raw;
296
+ }
297
+ case "json": {
298
+ if (raw === "") return null;
299
+ if (typeof raw === "string") return null;
300
+ return raw;
301
+ }
302
+ }
303
+ }
304
+ function resolvePluginSettings(manifest, stored) {
305
+ const out = {};
306
+ const fields = manifest?.public;
307
+ if (!fields) return out;
308
+ for (const field of fields) {
309
+ if (!isValidPluginKey(field.key)) {
310
+ continue;
311
+ }
312
+ let resolved = void 0;
313
+ if (Object.prototype.hasOwnProperty.call(stored, field.key)) {
314
+ const validated = validatePluginSettingValue(field, stored[field.key]);
315
+ if (validated !== null) resolved = validated;
316
+ }
317
+ if (resolved === void 0 && field.default !== void 0) {
318
+ const validatedDefault = validatePluginSettingValue(field, field.default);
319
+ if (validatedDefault !== null) resolved = validatedDefault;
320
+ }
321
+ if (resolved !== void 0) {
322
+ out[field.key] = resolved;
323
+ }
324
+ }
325
+ return out;
326
+ }
327
+
218
328
  // src/post-images.ts
219
329
  function extractFirstImageUrl(post) {
220
330
  switch (post.format) {
@@ -560,6 +670,7 @@ var VERSION = "0.0.1";
560
670
  export {
561
671
  DEFAULT_ENTRYPOINT,
562
672
  MAX_BUNDLE_BYTES,
673
+ PLUGIN_KEY_PATTERN,
563
674
  SITE_CONFIG_PK,
564
675
  TEXT_EXTENSIONS,
565
676
  VERSION,
@@ -589,6 +700,7 @@ export {
589
700
  hasKvStore,
590
701
  hasPostsProvider,
591
702
  isTagListUrl,
703
+ isValidPluginKey,
592
704
  listPosts,
593
705
  listSiteSettings,
594
706
  mimeTypeFor,
@@ -596,6 +708,7 @@ export {
596
708
  parseLinkList,
597
709
  pickDefaultEntrypoint,
598
710
  resolveLocalized,
711
+ resolvePluginSettings,
599
712
  resolveThemeValues,
600
713
  setKvStore,
601
714
  setPostsProvider,
@@ -607,5 +720,6 @@ export {
607
720
  updatePost,
608
721
  validateBundle,
609
722
  validateBundlePath,
723
+ validatePluginSettingValue,
610
724
  validateThemeValue
611
725
  };