@crowi/plugin-api 1.0.0-alpha.3 → 1.0.0-alpha.4

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.mts CHANGED
@@ -206,6 +206,66 @@ interface PluginLogger {
206
206
  error(message: string, ...args: unknown[]): void;
207
207
  }
208
208
 
209
+ /**
210
+ * Domain events emitted by core. The full event payload shapes live in
211
+ * `@crowi/server`; this contract publishes only the event names so the
212
+ * type signature of `EventBus.on` stays type-safe at the plugin layer.
213
+ *
214
+ * `pluginHooks` are the v2.0 internal-use-only events. Community
215
+ * plugins should NOT subscribe — the surface is reserved while we
216
+ * stabilise it.
217
+ */
218
+ interface PluginEvents {
219
+ 'page:created': {
220
+ pageId: string;
221
+ path: string;
222
+ };
223
+ 'page:updated': {
224
+ pageId: string;
225
+ path: string;
226
+ };
227
+ 'page:deleted': {
228
+ pageId: string;
229
+ path: string;
230
+ };
231
+ 'page:renamed': {
232
+ pageId: string;
233
+ oldPath: string;
234
+ newPath: string;
235
+ };
236
+ 'comment:added': {
237
+ pageId: string;
238
+ commentId: string;
239
+ };
240
+ 'comment:removed': {
241
+ pageId: string;
242
+ commentId: string;
243
+ };
244
+ 'user:registered': {
245
+ userId: string;
246
+ };
247
+ 'user:activated': {
248
+ userId: string;
249
+ };
250
+ }
251
+ interface EventBus {
252
+ on<K extends keyof PluginEvents>(event: K, listener: (payload: PluginEvents[K]) => void | Promise<void>): void;
253
+ }
254
+
255
+ /**
256
+ * HTML-emitting helper for renderer plugins.
257
+ *
258
+ * A renderer plugin that builds HTML from author-controlled or external
259
+ * strings (an OGP title, a math error message, …) must escape them, and
260
+ * that escape is a security primitive — a hardening change has to reach
261
+ * every plugin at once, not whichever local copies someone remembers.
262
+ * This is the SDK's single copy (`@crowi/plugin-renderer-katex` and
263
+ * `@crowi/plugin-renderer-link-card` each carried an identical local one
264
+ * before it was hoisted here).
265
+ */
266
+ /** Escape `&` `<` `>` `"` `'` for interpolation into HTML text or double/single-quoted attribute values. */
267
+ declare function escapeHtml(s: string): string;
268
+
209
269
  /**
210
270
  * Metadata accompanying a `put`. The runtime always provides
211
271
  * `contentType`; drivers are free to store additional fields under
@@ -564,52 +624,6 @@ interface MailSenderRegistry {
564
624
  register(driverName: string, driver: MailSender): void;
565
625
  }
566
626
 
567
- /**
568
- * Domain events emitted by core. The full event payload shapes live in
569
- * `@crowi/server`; this contract publishes only the event names so the
570
- * type signature of `EventBus.on` stays type-safe at the plugin layer.
571
- *
572
- * `pluginHooks` are the v2.0 internal-use-only events. Community
573
- * plugins should NOT subscribe — the surface is reserved while we
574
- * stabilise it.
575
- */
576
- interface PluginEvents {
577
- 'page:created': {
578
- pageId: string;
579
- path: string;
580
- };
581
- 'page:updated': {
582
- pageId: string;
583
- path: string;
584
- };
585
- 'page:deleted': {
586
- pageId: string;
587
- path: string;
588
- };
589
- 'page:renamed': {
590
- pageId: string;
591
- oldPath: string;
592
- newPath: string;
593
- };
594
- 'comment:added': {
595
- pageId: string;
596
- commentId: string;
597
- };
598
- 'comment:removed': {
599
- pageId: string;
600
- commentId: string;
601
- };
602
- 'user:registered': {
603
- userId: string;
604
- };
605
- 'user:activated': {
606
- userId: string;
607
- };
608
- }
609
- interface EventBus {
610
- on<K extends keyof PluginEvents>(event: K, listener: (payload: PluginEvents[K]) => void | Promise<void>): void;
611
- }
612
-
613
627
  /**
614
628
  * Renderer extension contract — type-only. Plugins contribute parse /
615
629
  * transform behaviour to the server-side markdown pipeline through
@@ -691,9 +705,64 @@ interface CodeBlockRenderer {
691
705
  * strip comments, etc.
692
706
  */
693
707
  computeEmbedKey?(info: CodeBlockInfo): string;
708
+ /**
709
+ * Opt into a CPU-bounded admission control pool (`render-admission.ts`,
710
+ * `packages/api/src/renderer/core/render-admission.ts`). When present,
711
+ * `cachedRenderOrPending` (`packages/api/src/renderer/cache/index.ts`)
712
+ * and `renderCodeBlockForPreview` acquire a ticket from the named pool
713
+ * (keyed per `pluginName`) immediately around the `render()` call and
714
+ * release it on completion; when absent (the default — PlantUML /
715
+ * KaTeX / emoji and existing embed/URL-expansion plugins), `render()`
716
+ * is called directly with no admission gate, matching today's
717
+ * behaviour. See spec §6 for the full design (global / per-user
718
+ * concurrency caps + priority queue).
719
+ */
720
+ admissionControl?: AdmissionControlConfig;
721
+ /**
722
+ * Opt into server-rendering during editor live preview
723
+ * (`POST /pages/preview`, which runs with no `pageId`). Default
724
+ * `'source'` (omitted) leaves the fenced block untouched in preview —
725
+ * today's behaviour for every existing `CodeBlockRenderer`. Plugins
726
+ * that declare `'server-render'` MUST be no-I/O and deterministic:
727
+ * `makePreviewCodeBlockDispatch` calls them outside the persisted-
728
+ * cache path (`packages/api/src/renderer/core/code-block-dispatch.ts`).
729
+ */
730
+ previewPolicy?: 'source' | 'server-render';
694
731
  /** Render a single code block. */
695
732
  render(info: CodeBlockInfo, ctx: RenderContext): EmbedFragment | RenderResult | Promise<EmbedFragment | RenderResult>;
696
733
  }
734
+ /**
735
+ * Per-`pluginName` admission-control pool declaration (§6). Shared by
736
+ * `CodeBlockRenderer` and `EmbedRenderer` — `EmbedRenderer` carries it so
737
+ * `code-block-dispatch.ts`'s `codeBlockAsEmbedRenderer` adaptor can copy
738
+ * `CodeBlockRenderer.admissionControl` straight through to the
739
+ * `EmbedRenderer` shape `cachedRenderOrPending` actually consumes.
740
+ */
741
+ interface AdmissionControlConfig {
742
+ /** Process-wide concurrent `render()` calls in flight for this plugin. */
743
+ maxConcurrentGlobal: number;
744
+ /** Concurrent `render()` calls in flight for a single `actor` (kind:'user' only). */
745
+ maxConcurrentPerUser: number;
746
+ /** Max jobs allowed to wait for a slot before new requests are rejected outright. */
747
+ queueDepth: number;
748
+ }
749
+ /**
750
+ * Who is driving this render call. Threaded through `RenderContext` so
751
+ * admission control (§6) can apply a per-user concurrency cap. Today
752
+ * every real call site is authenticated (`createJwtAuth` has no
753
+ * anonymous fallback), so `'user'` is the only variant actually
754
+ * produced — `'anonymous'` / `'system'` are reserved for future
755
+ * unauthenticated-read and offline-tooling call sites and must not be
756
+ * synthesised speculatively.
757
+ */
758
+ type RenderActor = {
759
+ kind: 'user';
760
+ userId: string;
761
+ } | {
762
+ kind: 'anonymous';
763
+ } | {
764
+ kind: 'system';
765
+ };
697
766
  interface CodeBlockInfo {
698
767
  /** The language tag from the fence (the `ts` in ```` ```ts ````). */
699
768
  lang: string;
@@ -737,6 +806,41 @@ interface EmbedRenderer {
737
806
  * volatility (`?utm_*`) or (b) include external state (Accept-Language).
738
807
  */
739
808
  computeEmbedKey?(input: EmbedInput): string;
809
+ /**
810
+ * Opt into admission control (§6). See `CodeBlockRenderer.admissionControl`
811
+ * for the full rationale — declared here too because `cachedRenderOrPending`
812
+ * (`packages/api/src/renderer/cache/index.ts`) is written against the
813
+ * `EmbedRenderer` shape (`code-block-dispatch.ts`'s
814
+ * `codeBlockAsEmbedRenderer` adaptor copies a `CodeBlockRenderer`'s
815
+ * declaration through unchanged). Native `EmbedRenderer` plugins
816
+ * (embed-tags / URL-inline-expansion) can also opt in if a future one
817
+ * turns out to be CPU-bound.
818
+ */
819
+ admissionControl?: AdmissionControlConfig;
820
+ /**
821
+ * Optional per-dispatch cache-bypass predicate
822
+ * (feature-renderer-plugin-boundary Phase 3). Checked by the generic
823
+ * embed-tag dispatcher (`packages/api/src/renderer/core/embed-tags.ts`)
824
+ * BEFORE it touches `CacheStorage` at all for this dispatch — no
825
+ * `get`, no `set`. When it returns `true`, the dispatcher calls
826
+ * `render()` directly (via the same `normalizeRenderResult` error
827
+ * normalisation the preview path uses) and never persists the
828
+ * result.
829
+ *
830
+ * This exists because a renderer whose behaviour is gated by a
831
+ * runtime policy toggle (e.g. link-card's admin
832
+ * `security:linkCardEnabled` switch) cannot enforce a literal
833
+ * zero-cache-access guarantee by checking the toggle only inside
834
+ * `render()` — a cache HIT from before the toggle flipped would
835
+ * short-circuit `render()` entirely and keep serving pre-toggle
836
+ * output (and, symmetrically, writing a toggled-off result to the
837
+ * cache would keep serving it for up to that entry's TTL after the
838
+ * toggle flips back). Declaring the check here instead makes the
839
+ * dispatcher skip the cache outright for that one call. Absent (the
840
+ * default) or returning `false` goes through the normal cached path
841
+ * unchanged.
842
+ */
843
+ shouldBypassCache?(input: EmbedInput): boolean;
740
844
  /** Render a single embed. */
741
845
  render(input: EmbedInput, ctx: RenderContext): RenderResult | Promise<RenderResult>;
742
846
  /**
@@ -790,19 +894,43 @@ interface RenderResult {
790
894
  ttlSec?: number;
791
895
  /**
792
896
  * When the render failed (network / auth / not_found / rate_limit /
793
- * timeout / unknown), plugins should set `error` instead of building
794
- * an html error frame. The core caches the error using `RENDER_ERROR_TTL`
795
- * and substitutes a fixed placeholder when re-rendering the page.
897
+ * timeout / unknown / blocked), plugins should set `error` instead of
898
+ * building an html error frame. The core caches the error using
899
+ * `RENDER_ERROR_TTL` and, absent `errorHtml`, substitutes a fixed
900
+ * placeholder when re-rendering the page.
796
901
  */
797
902
  error?: RenderError;
903
+ /**
904
+ * Optional failure-display HTML, paired with `error`. When `error` is
905
+ * set and `errorHtml` is present, the core shows `errorHtml` instead of
906
+ * the generic `errorPlaceholder()` — e.g. a link-card plugin can keep
907
+ * its URL clickable even when the OGP fetch failed. Same trust
908
+ * contract as `html`: **pre-sanitised, the core does not re-escape it**.
909
+ *
910
+ * Deliberately a separate field rather than "non-empty `html` + `error`
911
+ * means show `html`" — that shape makes a plugin's stray/forgotten
912
+ * `html` leak into the error display by accident. An explicit opt-in
913
+ * field means a plugin that hasn't been updated for `errorHtml` keeps
914
+ * the current safe-by-default behaviour (placeholder).
915
+ *
916
+ * Ignored when `error` is unset.
917
+ */
918
+ errorHtml?: string;
798
919
  }
799
920
  /**
800
921
  * Error categories cached with their own per-code TTLs. See
801
922
  * `packages/api/src/renderer/cache/index.ts:RENDER_ERROR_TTL` for the
802
- * concrete numbers.
923
+ * concrete numbers. `blocked` is a policy-level permanent rejection
924
+ * (SSRF block, disallowed scheme, disallowed content-type) — distinct
925
+ * from `not_found` semantically but sharing its 1h persistent-failure
926
+ * TTL. `busy` is a transient renderer-admission rejection (e.g. a
927
+ * shared fetch/render concurrency semaphore's wait queue was full, or a
928
+ * queued request's wait deadline elapsed) — never a property of the
929
+ * embed's target, so it shares a short transient TTL with
930
+ * `network`/`timeout` rather than `blocked`'s persistent one.
803
931
  */
804
932
  interface RenderError {
805
- code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown';
933
+ code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown' | 'blocked' | 'busy';
806
934
  /** Free-form text for log/debug — NOT inlined into the user-facing placeholder. */
807
935
  message?: string;
808
936
  /**
@@ -895,6 +1023,17 @@ interface CacheEntry {
895
1023
  result: RenderResult;
896
1024
  fetchedAt: Date;
897
1025
  expiresAt: Date;
1026
+ /**
1027
+ * Present ⇔ this is a stale-if-error entry keeping a prior success on
1028
+ * screen (see `packages/api/src/renderer/cache/index.ts:
1029
+ * STALE_IF_ERROR_MAX_AGE_SEC`): the value is that ORIGINAL success's
1030
+ * timestamp, carried forward unchanged across consecutive failed
1031
+ * retries — never the failed attempt's `fetchedAt`. Success entries do
1032
+ * not carry it (their `fetchedAt` IS the last-good time; readers use
1033
+ * that directly, which also covers, value-identically, entries written
1034
+ * while this field was still being set on success).
1035
+ */
1036
+ lastGoodFetchedAt?: Date;
898
1037
  }
899
1038
  /**
900
1039
  * MongoDB-backed cache surface. Phase 4 ships exactly one
@@ -987,6 +1126,25 @@ interface RenderContext {
987
1126
  * encrypted-config-backed implementation.
988
1127
  */
989
1128
  auth?: AuthContext;
1129
+ /**
1130
+ * Who is driving this render. Required so admission control (§6) can
1131
+ * apply its per-user concurrency cap end-to-end — every entry point
1132
+ * (`Renderer.run`/`runMetadata`/`runRender`, `packages/api/src/renderer/
1133
+ * index.ts`) requires callers to supply this. See `RenderActor`'s doc
1134
+ * comment for which variant real call sites actually produce today.
1135
+ */
1136
+ actor: RenderActor;
1137
+ /**
1138
+ * Optional cancellation signal, propagated from the originating HTTP
1139
+ * request (`c.req.raw.signal` on `POST /pages/preview`). A waiting
1140
+ * (not-yet-running) admission-control job is removed from its queue
1141
+ * the instant this fires; an already-running child-process render is
1142
+ * NOT force-killed (§6 — the cost of killing/respawning a worker
1143
+ * outweighs letting an already-cheap render finish and discarding the
1144
+ * result). Absent on the save / read call sites, which have no
1145
+ * request to cancel against.
1146
+ */
1147
+ signal?: AbortSignal;
990
1148
  }
991
1149
  /**
992
1150
  * The registry handed to every plugin's `registerRenderer(scope, ctx)`.
@@ -1002,6 +1160,10 @@ interface RenderContext {
1002
1160
  *
1003
1161
  * Phase 4 stubs (warn-noop):
1004
1162
  * - `addCodeBlockRenderer` (Phase 6 lights this up)
1163
+ *
1164
+ * feature-renderer-plugin-boundary Phase 1 adds `addStylesheet(path)` —
1165
+ * the boot-time CSS-manifest extension point (see that method's own doc
1166
+ * comment).
1005
1167
  */
1006
1168
  interface RendererRegistry {
1007
1169
  /**
@@ -1040,6 +1202,30 @@ interface RendererRegistry {
1040
1202
  * preserved; the first match that returns `'replaced'` wins.
1041
1203
  */
1042
1204
  addUrlInlineExpander(rule: UrlInlineExpansionRule): void;
1205
+ /**
1206
+ * Declare a static CSS asset the plugin needs the browser to load
1207
+ * (e.g. KaTeX's ~30KB math stylesheet). `path` MUST be an
1208
+ * API-relative absolute path confined to the plugin's own
1209
+ * `registerRoutes` namespace — `/api/v2/plugins/<this plugin's
1210
+ * name>/<…>` — the same prefix `PluginRouterScope.route(...)` mounts
1211
+ * that plugin's HTTP routes under. A URL scheme, protocol-relative
1212
+ * `//host`, backslash, `..` traversal segment, or a path outside the
1213
+ * plugin's own namespace all throw synchronously (boot-time reject —
1214
+ * this is not an operator-configurable external URL; see spec
1215
+ * §2.1's "不採用案").
1216
+ *
1217
+ * The call only stages the path in a per-plugin pending set: it is
1218
+ * published to the public `GET /api/v2/app/info` `rendererStylesheets`
1219
+ * manifest ONLY after this plugin's OWN `registerRoutes(scope, ctx)`
1220
+ * completes without throwing (so the manifest never advertises a path
1221
+ * whose route failed to mount). A plugin with no `registerRoutes` at
1222
+ * all, or whose `registerRoutes` throws, never gets its pending
1223
+ * stylesheets committed — dropped wholesale, not partially. Query /
1224
+ * fragment are allowed; duplicate calls with the same path are a
1225
+ * no-op. Call this from `registerRenderer`, not `registerRoutes` —
1226
+ * commit timing depends on this method having already run.
1227
+ */
1228
+ addStylesheet(path: string): void;
1043
1229
  }
1044
1230
 
1045
1231
  /**
@@ -1387,4 +1573,4 @@ interface ActionAnnotation {
1387
1573
  */
1388
1574
  declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
1389
1575
 
1390
- export { ACTION_FIELD_MARKER, type AppInfo, type AuthContext, type AuthDriver, type AuthProfile, type AuthRegistry, type AuthVerifyResult, type CacheEntry, type CacheKey, type CacheStorage, type CodeBlockInfo, type CodeBlockRenderer, type CrowiPlugin, type EmailMessage, type EmbedFragment, type EmbedInput, type EmbedRenderer, type EventBus, type InlineExpansion, type MailSender, type MailSenderRegistry, type NodeRenderer, type NotificationPayload, type NotifierDriver, type NotifierRegistry, type PageMetadataAccessor, type PluginContext, type PluginEvents, type PluginLogger, type PluginRouteHandler, type PluginRouteMethod, type PluginRouteOptions, type PluginRouterScope, type RenderContext, type RenderError, type RenderPhase, type RenderResult, type RendererRegistry, type Reservation, SENSITIVE_FIELD_MARKER, type ScopedCacheStorage, type SearchDriver, type SearchHit, type SearchHits, type SearchPageType, type SearchQuery, type SearchQueryGrants, type SearchQueryViewer, type SearchRegistry, type SearchableDoc, type StateCell, type StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type UrlInlineExpansionRule, getActionAnnotation, isSensitiveField };
1576
+ export { ACTION_FIELD_MARKER, type AdmissionControlConfig, type AppInfo, type AuthContext, type AuthDriver, type AuthProfile, type AuthRegistry, type AuthVerifyResult, type CacheEntry, type CacheKey, type CacheStorage, type CodeBlockInfo, type CodeBlockRenderer, type CrowiPlugin, type EmailMessage, type EmbedFragment, type EmbedInput, type EmbedRenderer, type EventBus, type InlineExpansion, type MailSender, type MailSenderRegistry, type NodeRenderer, type NotificationPayload, type NotifierDriver, type NotifierRegistry, type PageMetadataAccessor, type PluginContext, type PluginEvents, type PluginLogger, type PluginRouteHandler, type PluginRouteMethod, type PluginRouteOptions, type PluginRouterScope, type RenderActor, type RenderContext, type RenderError, type RenderPhase, type RenderResult, type RendererRegistry, type Reservation, SENSITIVE_FIELD_MARKER, type ScopedCacheStorage, type SearchDriver, type SearchHit, type SearchHits, type SearchPageType, type SearchQuery, type SearchQueryGrants, type SearchQueryViewer, type SearchRegistry, type SearchableDoc, type StateCell, type StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type UrlInlineExpansionRule, escapeHtml, getActionAnnotation, isSensitiveField };
package/dist/index.d.ts CHANGED
@@ -206,6 +206,66 @@ interface PluginLogger {
206
206
  error(message: string, ...args: unknown[]): void;
207
207
  }
208
208
 
209
+ /**
210
+ * Domain events emitted by core. The full event payload shapes live in
211
+ * `@crowi/server`; this contract publishes only the event names so the
212
+ * type signature of `EventBus.on` stays type-safe at the plugin layer.
213
+ *
214
+ * `pluginHooks` are the v2.0 internal-use-only events. Community
215
+ * plugins should NOT subscribe — the surface is reserved while we
216
+ * stabilise it.
217
+ */
218
+ interface PluginEvents {
219
+ 'page:created': {
220
+ pageId: string;
221
+ path: string;
222
+ };
223
+ 'page:updated': {
224
+ pageId: string;
225
+ path: string;
226
+ };
227
+ 'page:deleted': {
228
+ pageId: string;
229
+ path: string;
230
+ };
231
+ 'page:renamed': {
232
+ pageId: string;
233
+ oldPath: string;
234
+ newPath: string;
235
+ };
236
+ 'comment:added': {
237
+ pageId: string;
238
+ commentId: string;
239
+ };
240
+ 'comment:removed': {
241
+ pageId: string;
242
+ commentId: string;
243
+ };
244
+ 'user:registered': {
245
+ userId: string;
246
+ };
247
+ 'user:activated': {
248
+ userId: string;
249
+ };
250
+ }
251
+ interface EventBus {
252
+ on<K extends keyof PluginEvents>(event: K, listener: (payload: PluginEvents[K]) => void | Promise<void>): void;
253
+ }
254
+
255
+ /**
256
+ * HTML-emitting helper for renderer plugins.
257
+ *
258
+ * A renderer plugin that builds HTML from author-controlled or external
259
+ * strings (an OGP title, a math error message, …) must escape them, and
260
+ * that escape is a security primitive — a hardening change has to reach
261
+ * every plugin at once, not whichever local copies someone remembers.
262
+ * This is the SDK's single copy (`@crowi/plugin-renderer-katex` and
263
+ * `@crowi/plugin-renderer-link-card` each carried an identical local one
264
+ * before it was hoisted here).
265
+ */
266
+ /** Escape `&` `<` `>` `"` `'` for interpolation into HTML text or double/single-quoted attribute values. */
267
+ declare function escapeHtml(s: string): string;
268
+
209
269
  /**
210
270
  * Metadata accompanying a `put`. The runtime always provides
211
271
  * `contentType`; drivers are free to store additional fields under
@@ -564,52 +624,6 @@ interface MailSenderRegistry {
564
624
  register(driverName: string, driver: MailSender): void;
565
625
  }
566
626
 
567
- /**
568
- * Domain events emitted by core. The full event payload shapes live in
569
- * `@crowi/server`; this contract publishes only the event names so the
570
- * type signature of `EventBus.on` stays type-safe at the plugin layer.
571
- *
572
- * `pluginHooks` are the v2.0 internal-use-only events. Community
573
- * plugins should NOT subscribe — the surface is reserved while we
574
- * stabilise it.
575
- */
576
- interface PluginEvents {
577
- 'page:created': {
578
- pageId: string;
579
- path: string;
580
- };
581
- 'page:updated': {
582
- pageId: string;
583
- path: string;
584
- };
585
- 'page:deleted': {
586
- pageId: string;
587
- path: string;
588
- };
589
- 'page:renamed': {
590
- pageId: string;
591
- oldPath: string;
592
- newPath: string;
593
- };
594
- 'comment:added': {
595
- pageId: string;
596
- commentId: string;
597
- };
598
- 'comment:removed': {
599
- pageId: string;
600
- commentId: string;
601
- };
602
- 'user:registered': {
603
- userId: string;
604
- };
605
- 'user:activated': {
606
- userId: string;
607
- };
608
- }
609
- interface EventBus {
610
- on<K extends keyof PluginEvents>(event: K, listener: (payload: PluginEvents[K]) => void | Promise<void>): void;
611
- }
612
-
613
627
  /**
614
628
  * Renderer extension contract — type-only. Plugins contribute parse /
615
629
  * transform behaviour to the server-side markdown pipeline through
@@ -691,9 +705,64 @@ interface CodeBlockRenderer {
691
705
  * strip comments, etc.
692
706
  */
693
707
  computeEmbedKey?(info: CodeBlockInfo): string;
708
+ /**
709
+ * Opt into a CPU-bounded admission control pool (`render-admission.ts`,
710
+ * `packages/api/src/renderer/core/render-admission.ts`). When present,
711
+ * `cachedRenderOrPending` (`packages/api/src/renderer/cache/index.ts`)
712
+ * and `renderCodeBlockForPreview` acquire a ticket from the named pool
713
+ * (keyed per `pluginName`) immediately around the `render()` call and
714
+ * release it on completion; when absent (the default — PlantUML /
715
+ * KaTeX / emoji and existing embed/URL-expansion plugins), `render()`
716
+ * is called directly with no admission gate, matching today's
717
+ * behaviour. See spec §6 for the full design (global / per-user
718
+ * concurrency caps + priority queue).
719
+ */
720
+ admissionControl?: AdmissionControlConfig;
721
+ /**
722
+ * Opt into server-rendering during editor live preview
723
+ * (`POST /pages/preview`, which runs with no `pageId`). Default
724
+ * `'source'` (omitted) leaves the fenced block untouched in preview —
725
+ * today's behaviour for every existing `CodeBlockRenderer`. Plugins
726
+ * that declare `'server-render'` MUST be no-I/O and deterministic:
727
+ * `makePreviewCodeBlockDispatch` calls them outside the persisted-
728
+ * cache path (`packages/api/src/renderer/core/code-block-dispatch.ts`).
729
+ */
730
+ previewPolicy?: 'source' | 'server-render';
694
731
  /** Render a single code block. */
695
732
  render(info: CodeBlockInfo, ctx: RenderContext): EmbedFragment | RenderResult | Promise<EmbedFragment | RenderResult>;
696
733
  }
734
+ /**
735
+ * Per-`pluginName` admission-control pool declaration (§6). Shared by
736
+ * `CodeBlockRenderer` and `EmbedRenderer` — `EmbedRenderer` carries it so
737
+ * `code-block-dispatch.ts`'s `codeBlockAsEmbedRenderer` adaptor can copy
738
+ * `CodeBlockRenderer.admissionControl` straight through to the
739
+ * `EmbedRenderer` shape `cachedRenderOrPending` actually consumes.
740
+ */
741
+ interface AdmissionControlConfig {
742
+ /** Process-wide concurrent `render()` calls in flight for this plugin. */
743
+ maxConcurrentGlobal: number;
744
+ /** Concurrent `render()` calls in flight for a single `actor` (kind:'user' only). */
745
+ maxConcurrentPerUser: number;
746
+ /** Max jobs allowed to wait for a slot before new requests are rejected outright. */
747
+ queueDepth: number;
748
+ }
749
+ /**
750
+ * Who is driving this render call. Threaded through `RenderContext` so
751
+ * admission control (§6) can apply a per-user concurrency cap. Today
752
+ * every real call site is authenticated (`createJwtAuth` has no
753
+ * anonymous fallback), so `'user'` is the only variant actually
754
+ * produced — `'anonymous'` / `'system'` are reserved for future
755
+ * unauthenticated-read and offline-tooling call sites and must not be
756
+ * synthesised speculatively.
757
+ */
758
+ type RenderActor = {
759
+ kind: 'user';
760
+ userId: string;
761
+ } | {
762
+ kind: 'anonymous';
763
+ } | {
764
+ kind: 'system';
765
+ };
697
766
  interface CodeBlockInfo {
698
767
  /** The language tag from the fence (the `ts` in ```` ```ts ````). */
699
768
  lang: string;
@@ -737,6 +806,41 @@ interface EmbedRenderer {
737
806
  * volatility (`?utm_*`) or (b) include external state (Accept-Language).
738
807
  */
739
808
  computeEmbedKey?(input: EmbedInput): string;
809
+ /**
810
+ * Opt into admission control (§6). See `CodeBlockRenderer.admissionControl`
811
+ * for the full rationale — declared here too because `cachedRenderOrPending`
812
+ * (`packages/api/src/renderer/cache/index.ts`) is written against the
813
+ * `EmbedRenderer` shape (`code-block-dispatch.ts`'s
814
+ * `codeBlockAsEmbedRenderer` adaptor copies a `CodeBlockRenderer`'s
815
+ * declaration through unchanged). Native `EmbedRenderer` plugins
816
+ * (embed-tags / URL-inline-expansion) can also opt in if a future one
817
+ * turns out to be CPU-bound.
818
+ */
819
+ admissionControl?: AdmissionControlConfig;
820
+ /**
821
+ * Optional per-dispatch cache-bypass predicate
822
+ * (feature-renderer-plugin-boundary Phase 3). Checked by the generic
823
+ * embed-tag dispatcher (`packages/api/src/renderer/core/embed-tags.ts`)
824
+ * BEFORE it touches `CacheStorage` at all for this dispatch — no
825
+ * `get`, no `set`. When it returns `true`, the dispatcher calls
826
+ * `render()` directly (via the same `normalizeRenderResult` error
827
+ * normalisation the preview path uses) and never persists the
828
+ * result.
829
+ *
830
+ * This exists because a renderer whose behaviour is gated by a
831
+ * runtime policy toggle (e.g. link-card's admin
832
+ * `security:linkCardEnabled` switch) cannot enforce a literal
833
+ * zero-cache-access guarantee by checking the toggle only inside
834
+ * `render()` — a cache HIT from before the toggle flipped would
835
+ * short-circuit `render()` entirely and keep serving pre-toggle
836
+ * output (and, symmetrically, writing a toggled-off result to the
837
+ * cache would keep serving it for up to that entry's TTL after the
838
+ * toggle flips back). Declaring the check here instead makes the
839
+ * dispatcher skip the cache outright for that one call. Absent (the
840
+ * default) or returning `false` goes through the normal cached path
841
+ * unchanged.
842
+ */
843
+ shouldBypassCache?(input: EmbedInput): boolean;
740
844
  /** Render a single embed. */
741
845
  render(input: EmbedInput, ctx: RenderContext): RenderResult | Promise<RenderResult>;
742
846
  /**
@@ -790,19 +894,43 @@ interface RenderResult {
790
894
  ttlSec?: number;
791
895
  /**
792
896
  * When the render failed (network / auth / not_found / rate_limit /
793
- * timeout / unknown), plugins should set `error` instead of building
794
- * an html error frame. The core caches the error using `RENDER_ERROR_TTL`
795
- * and substitutes a fixed placeholder when re-rendering the page.
897
+ * timeout / unknown / blocked), plugins should set `error` instead of
898
+ * building an html error frame. The core caches the error using
899
+ * `RENDER_ERROR_TTL` and, absent `errorHtml`, substitutes a fixed
900
+ * placeholder when re-rendering the page.
796
901
  */
797
902
  error?: RenderError;
903
+ /**
904
+ * Optional failure-display HTML, paired with `error`. When `error` is
905
+ * set and `errorHtml` is present, the core shows `errorHtml` instead of
906
+ * the generic `errorPlaceholder()` — e.g. a link-card plugin can keep
907
+ * its URL clickable even when the OGP fetch failed. Same trust
908
+ * contract as `html`: **pre-sanitised, the core does not re-escape it**.
909
+ *
910
+ * Deliberately a separate field rather than "non-empty `html` + `error`
911
+ * means show `html`" — that shape makes a plugin's stray/forgotten
912
+ * `html` leak into the error display by accident. An explicit opt-in
913
+ * field means a plugin that hasn't been updated for `errorHtml` keeps
914
+ * the current safe-by-default behaviour (placeholder).
915
+ *
916
+ * Ignored when `error` is unset.
917
+ */
918
+ errorHtml?: string;
798
919
  }
799
920
  /**
800
921
  * Error categories cached with their own per-code TTLs. See
801
922
  * `packages/api/src/renderer/cache/index.ts:RENDER_ERROR_TTL` for the
802
- * concrete numbers.
923
+ * concrete numbers. `blocked` is a policy-level permanent rejection
924
+ * (SSRF block, disallowed scheme, disallowed content-type) — distinct
925
+ * from `not_found` semantically but sharing its 1h persistent-failure
926
+ * TTL. `busy` is a transient renderer-admission rejection (e.g. a
927
+ * shared fetch/render concurrency semaphore's wait queue was full, or a
928
+ * queued request's wait deadline elapsed) — never a property of the
929
+ * embed's target, so it shares a short transient TTL with
930
+ * `network`/`timeout` rather than `blocked`'s persistent one.
803
931
  */
804
932
  interface RenderError {
805
- code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown';
933
+ code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown' | 'blocked' | 'busy';
806
934
  /** Free-form text for log/debug — NOT inlined into the user-facing placeholder. */
807
935
  message?: string;
808
936
  /**
@@ -895,6 +1023,17 @@ interface CacheEntry {
895
1023
  result: RenderResult;
896
1024
  fetchedAt: Date;
897
1025
  expiresAt: Date;
1026
+ /**
1027
+ * Present ⇔ this is a stale-if-error entry keeping a prior success on
1028
+ * screen (see `packages/api/src/renderer/cache/index.ts:
1029
+ * STALE_IF_ERROR_MAX_AGE_SEC`): the value is that ORIGINAL success's
1030
+ * timestamp, carried forward unchanged across consecutive failed
1031
+ * retries — never the failed attempt's `fetchedAt`. Success entries do
1032
+ * not carry it (their `fetchedAt` IS the last-good time; readers use
1033
+ * that directly, which also covers, value-identically, entries written
1034
+ * while this field was still being set on success).
1035
+ */
1036
+ lastGoodFetchedAt?: Date;
898
1037
  }
899
1038
  /**
900
1039
  * MongoDB-backed cache surface. Phase 4 ships exactly one
@@ -987,6 +1126,25 @@ interface RenderContext {
987
1126
  * encrypted-config-backed implementation.
988
1127
  */
989
1128
  auth?: AuthContext;
1129
+ /**
1130
+ * Who is driving this render. Required so admission control (§6) can
1131
+ * apply its per-user concurrency cap end-to-end — every entry point
1132
+ * (`Renderer.run`/`runMetadata`/`runRender`, `packages/api/src/renderer/
1133
+ * index.ts`) requires callers to supply this. See `RenderActor`'s doc
1134
+ * comment for which variant real call sites actually produce today.
1135
+ */
1136
+ actor: RenderActor;
1137
+ /**
1138
+ * Optional cancellation signal, propagated from the originating HTTP
1139
+ * request (`c.req.raw.signal` on `POST /pages/preview`). A waiting
1140
+ * (not-yet-running) admission-control job is removed from its queue
1141
+ * the instant this fires; an already-running child-process render is
1142
+ * NOT force-killed (§6 — the cost of killing/respawning a worker
1143
+ * outweighs letting an already-cheap render finish and discarding the
1144
+ * result). Absent on the save / read call sites, which have no
1145
+ * request to cancel against.
1146
+ */
1147
+ signal?: AbortSignal;
990
1148
  }
991
1149
  /**
992
1150
  * The registry handed to every plugin's `registerRenderer(scope, ctx)`.
@@ -1002,6 +1160,10 @@ interface RenderContext {
1002
1160
  *
1003
1161
  * Phase 4 stubs (warn-noop):
1004
1162
  * - `addCodeBlockRenderer` (Phase 6 lights this up)
1163
+ *
1164
+ * feature-renderer-plugin-boundary Phase 1 adds `addStylesheet(path)` —
1165
+ * the boot-time CSS-manifest extension point (see that method's own doc
1166
+ * comment).
1005
1167
  */
1006
1168
  interface RendererRegistry {
1007
1169
  /**
@@ -1040,6 +1202,30 @@ interface RendererRegistry {
1040
1202
  * preserved; the first match that returns `'replaced'` wins.
1041
1203
  */
1042
1204
  addUrlInlineExpander(rule: UrlInlineExpansionRule): void;
1205
+ /**
1206
+ * Declare a static CSS asset the plugin needs the browser to load
1207
+ * (e.g. KaTeX's ~30KB math stylesheet). `path` MUST be an
1208
+ * API-relative absolute path confined to the plugin's own
1209
+ * `registerRoutes` namespace — `/api/v2/plugins/<this plugin's
1210
+ * name>/<…>` — the same prefix `PluginRouterScope.route(...)` mounts
1211
+ * that plugin's HTTP routes under. A URL scheme, protocol-relative
1212
+ * `//host`, backslash, `..` traversal segment, or a path outside the
1213
+ * plugin's own namespace all throw synchronously (boot-time reject —
1214
+ * this is not an operator-configurable external URL; see spec
1215
+ * §2.1's "不採用案").
1216
+ *
1217
+ * The call only stages the path in a per-plugin pending set: it is
1218
+ * published to the public `GET /api/v2/app/info` `rendererStylesheets`
1219
+ * manifest ONLY after this plugin's OWN `registerRoutes(scope, ctx)`
1220
+ * completes without throwing (so the manifest never advertises a path
1221
+ * whose route failed to mount). A plugin with no `registerRoutes` at
1222
+ * all, or whose `registerRoutes` throws, never gets its pending
1223
+ * stylesheets committed — dropped wholesale, not partially. Query /
1224
+ * fragment are allowed; duplicate calls with the same path are a
1225
+ * no-op. Call this from `registerRenderer`, not `registerRoutes` —
1226
+ * commit timing depends on this method having already run.
1227
+ */
1228
+ addStylesheet(path: string): void;
1043
1229
  }
1044
1230
 
1045
1231
  /**
@@ -1387,4 +1573,4 @@ interface ActionAnnotation {
1387
1573
  */
1388
1574
  declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
1389
1575
 
1390
- export { ACTION_FIELD_MARKER, type AppInfo, type AuthContext, type AuthDriver, type AuthProfile, type AuthRegistry, type AuthVerifyResult, type CacheEntry, type CacheKey, type CacheStorage, type CodeBlockInfo, type CodeBlockRenderer, type CrowiPlugin, type EmailMessage, type EmbedFragment, type EmbedInput, type EmbedRenderer, type EventBus, type InlineExpansion, type MailSender, type MailSenderRegistry, type NodeRenderer, type NotificationPayload, type NotifierDriver, type NotifierRegistry, type PageMetadataAccessor, type PluginContext, type PluginEvents, type PluginLogger, type PluginRouteHandler, type PluginRouteMethod, type PluginRouteOptions, type PluginRouterScope, type RenderContext, type RenderError, type RenderPhase, type RenderResult, type RendererRegistry, type Reservation, SENSITIVE_FIELD_MARKER, type ScopedCacheStorage, type SearchDriver, type SearchHit, type SearchHits, type SearchPageType, type SearchQuery, type SearchQueryGrants, type SearchQueryViewer, type SearchRegistry, type SearchableDoc, type StateCell, type StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type UrlInlineExpansionRule, getActionAnnotation, isSensitiveField };
1576
+ export { ACTION_FIELD_MARKER, type AdmissionControlConfig, type AppInfo, type AuthContext, type AuthDriver, type AuthProfile, type AuthRegistry, type AuthVerifyResult, type CacheEntry, type CacheKey, type CacheStorage, type CodeBlockInfo, type CodeBlockRenderer, type CrowiPlugin, type EmailMessage, type EmbedFragment, type EmbedInput, type EmbedRenderer, type EventBus, type InlineExpansion, type MailSender, type MailSenderRegistry, type NodeRenderer, type NotificationPayload, type NotifierDriver, type NotifierRegistry, type PageMetadataAccessor, type PluginContext, type PluginEvents, type PluginLogger, type PluginRouteHandler, type PluginRouteMethod, type PluginRouteOptions, type PluginRouterScope, type RenderActor, type RenderContext, type RenderError, type RenderPhase, type RenderResult, type RendererRegistry, type Reservation, SENSITIVE_FIELD_MARKER, type ScopedCacheStorage, type SearchDriver, type SearchHit, type SearchHits, type SearchPageType, type SearchQuery, type SearchQueryGrants, type SearchQueryViewer, type SearchRegistry, type SearchableDoc, type StateCell, type StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type UrlInlineExpansionRule, escapeHtml, getActionAnnotation, isSensitiveField };
package/dist/index.js CHANGED
@@ -22,11 +22,32 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  ACTION_FIELD_MARKER: () => ACTION_FIELD_MARKER,
24
24
  SENSITIVE_FIELD_MARKER: () => SENSITIVE_FIELD_MARKER,
25
+ escapeHtml: () => escapeHtml,
25
26
  getActionAnnotation: () => getActionAnnotation,
26
27
  isSensitiveField: () => isSensitiveField
27
28
  });
28
29
  module.exports = __toCommonJS(index_exports);
29
30
 
31
+ // src/html.ts
32
+ function escapeHtml(s) {
33
+ return s.replace(/[&<>"']/g, (c) => {
34
+ switch (c) {
35
+ case "&":
36
+ return "&amp;";
37
+ case "<":
38
+ return "&lt;";
39
+ case ">":
40
+ return "&gt;";
41
+ case '"':
42
+ return "&quot;";
43
+ case "'":
44
+ return "&#39;";
45
+ default:
46
+ return c;
47
+ }
48
+ });
49
+ }
50
+
30
51
  // src/schema-markers.ts
31
52
  var SENSITIVE_FIELD_MARKER = "@sensitive";
32
53
  var ACTION_FIELD_MARKER = "@action";
@@ -49,6 +70,7 @@ function getActionAnnotation(field) {
49
70
  0 && (module.exports = {
50
71
  ACTION_FIELD_MARKER,
51
72
  SENSITIVE_FIELD_MARKER,
73
+ escapeHtml,
52
74
  getActionAnnotation,
53
75
  isSensitiveField
54
76
  });
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/schema-markers.ts"],"sourcesContent":["/**\n * @crowi/plugin-api — type-only contract for Crowi 2.0 plugins.\n *\n * Plugins author against this package. The runtime (@crowi/server) loads\n * plugins listed in `crowi.config.json`, calls each plugin's\n * `register*` callbacks, and routes all the side effects (storage,\n * search, auth, notifications) through the typed registries declared\n * here.\n *\n * For the design rationale see `docs/rfcs/0001-plugin-architecture.md`\n * in the Crowi monorepo.\n */\n\nexport type { CrowiPlugin } from './plugin';\n\nexport type { PluginContext, AppInfo, PageMetadataAccessor, PluginLogger, StateCell } from './context';\n\nexport type { StorageDriver, StorageRegistry, StoragePutMeta, StoragePutResult } from './registries/storage';\n\nexport type {\n SearchDriver,\n SearchRegistry,\n SearchableDoc,\n SearchQuery,\n SearchQueryViewer,\n SearchQueryGrants,\n SearchPageType,\n SearchHits,\n SearchHit,\n} from './registries/search';\n\nexport type { AuthDriver, AuthRegistry, AuthProfile, AuthVerifyResult } from './registries/auth';\n\nexport type { NotifierDriver, NotifierRegistry, NotificationPayload } from './registries/notifier';\n\nexport type { MailSender, MailSenderRegistry, EmailMessage } from './registries/mail';\n\nexport type {\n RendererRegistry,\n NodeRenderer,\n CodeBlockRenderer,\n CodeBlockInfo,\n EmbedRenderer,\n EmbedInput,\n EmbedFragment,\n UrlInlineExpansionRule,\n InlineExpansion,\n RenderContext,\n RenderPhase,\n RenderResult,\n RenderError,\n Reservation,\n CacheStorage,\n ScopedCacheStorage,\n CacheKey,\n CacheEntry,\n AuthContext,\n} from './renderer';\n\nexport type { EventBus, PluginEvents } from './events';\n\nexport type { PluginRouterScope, PluginRouteHandler, PluginRouteMethod, PluginRouteOptions } from './routes';\n\nexport { SENSITIVE_FIELD_MARKER, ACTION_FIELD_MARKER, isSensitiveField, getActionAnnotation } from './schema-markers';\n","import type { z } from 'zod/v3';\n\nimport type { PluginRouteMethod } from './routes';\n\n/**\n * `configSchema` description-string markers.\n *\n * The admin UI walks the schema and looks at each field's\n * `description` (set via `z.string().describe('@sensitive ...')`). A\n * description starting with one of these marker tokens unlocks special\n * UI behaviour without forcing every field to declare a custom Zod\n * type.\n */\n\n/**\n * Marker that flags a config field as sensitive (encrypted at rest).\n * Usage:\n *\n * z.string().describe('@sensitive AWS secret access key')\n *\n * The runtime auto-encrypts on write and decrypts on read, using the\n * same KeyProvider as core sensitive Config. The admin UI renders the\n * field via `<SecretField>` (saved badge / clear pending / undo).\n */\nexport const SENSITIVE_FIELD_MARKER = '@sensitive';\n\n/**\n * Marker that adds an action button next to a config field. Usage:\n *\n * z.string().describe('@action \"Test connection\" POST /test')\n *\n * The admin form renders a button with the given label that calls the\n * plugin's contributed endpoint at the given verb / path (relative to\n * `/api/v2/plugins/<name>/`). Useful for \"Test connection\",\n * \"Authorise with Google\", etc. without forcing every plugin to ship\n * its own React component.\n */\nexport const ACTION_FIELD_MARKER = '@action';\n\n/**\n * True if the schema field is marked `@sensitive`.\n *\n * `field` is `z.ZodTypeAny` (intentionally loose); call sites pass the\n * value type from `configSchema.shape[key]`.\n */\nexport function isSensitiveField(field: z.ZodTypeAny): boolean {\n const description = field.description;\n return typeof description === 'string' && description.trimStart().startsWith(SENSITIVE_FIELD_MARKER);\n}\n\n/**\n * Parsed `@action` annotation extracted from a field's `description`.\n */\nexport interface ActionAnnotation {\n /** Visible button label, e.g. \"Test connection\". */\n label: string;\n /** HTTP verb of the plugin endpoint to call. */\n method: PluginRouteMethod;\n /** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */\n path: string;\n}\n\n/**\n * Parse an `@action` annotation off a field, or return null if absent.\n *\n * Format: `@action \"<label>\" <METHOD> <path>`\n * e.g. `@action \"Test connection\" POST /test`\n *\n * The label may include spaces when wrapped in double quotes; the method\n * must be one of `PluginRouteMethod` (`GET` / `POST` — the only verbs a\n * plugin route can actually be mounted on, see `routes.ts`); the path\n * begins with `/`. A description that starts with the `@action` marker\n * but declares an unsupported verb (e.g. `PUT` / `DELETE`) fails to match\n * and returns `null` here — callers that walk a plugin's `configSchema`\n * (e.g. `PluginManager.activate()`) are expected to warn on that case at\n * boot, since it would otherwise be a silent dead button.\n */\nexport function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null {\n const description = field.description;\n if (typeof description !== 'string') return null;\n const trimmed = description.trimStart();\n if (!trimmed.startsWith(ACTION_FIELD_MARKER)) return null;\n\n const rest = trimmed.slice(ACTION_FIELD_MARKER.length).trimStart();\n // `\"<label>\" <METHOD> <path>`\n const match = rest.match(/^\"([^\"]+)\"\\s+(GET|POST)\\s+(\\/\\S*)/);\n if (!match) return null;\n\n const [, label, method, path] = match;\n return { label, method: method as ActionAnnotation['method'], path };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACwBO,IAAM,yBAAyB;AAa/B,IAAM,sBAAsB;AAQ5B,SAAS,iBAAiB,OAA8B;AAC7D,QAAM,cAAc,MAAM;AAC1B,SAAO,OAAO,gBAAgB,YAAY,YAAY,UAAU,EAAE,WAAW,sBAAsB;AACrG;AA6BO,SAAS,oBAAoB,OAA8C;AAChF,QAAM,cAAc,MAAM;AAC1B,MAAI,OAAO,gBAAgB,SAAU,QAAO;AAC5C,QAAM,UAAU,YAAY,UAAU;AACtC,MAAI,CAAC,QAAQ,WAAW,mBAAmB,EAAG,QAAO;AAErD,QAAM,OAAO,QAAQ,MAAM,oBAAoB,MAAM,EAAE,UAAU;AAEjE,QAAM,QAAQ,KAAK,MAAM,mCAAmC;AAC5D,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,CAAC,EAAE,OAAO,QAAQ,IAAI,IAAI;AAChC,SAAO,EAAE,OAAO,QAA8C,KAAK;AACrE;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/html.ts","../src/schema-markers.ts"],"sourcesContent":["/**\n * @crowi/plugin-api — type-only contract for Crowi 2.0 plugins.\n *\n * Plugins author against this package. The runtime (@crowi/server) loads\n * plugins listed in `crowi.config.json`, calls each plugin's\n * `register*` callbacks, and routes all the side effects (storage,\n * search, auth, notifications) through the typed registries declared\n * here.\n *\n * For the design rationale see `docs/rfcs/0001-plugin-architecture.md`\n * in the Crowi monorepo.\n */\n\nexport type { AppInfo, PageMetadataAccessor, PluginContext, PluginLogger, StateCell } from './context';\nexport type { EventBus, PluginEvents } from './events';\nexport { escapeHtml } from './html';\nexport type { CrowiPlugin } from './plugin';\n\nexport type { AuthDriver, AuthProfile, AuthRegistry, AuthVerifyResult } from './registries/auth';\nexport type { EmailMessage, MailSender, MailSenderRegistry } from './registries/mail';\nexport type { NotificationPayload, NotifierDriver, NotifierRegistry } from './registries/notifier';\nexport type {\n SearchableDoc,\n SearchDriver,\n SearchHit,\n SearchHits,\n SearchPageType,\n SearchQuery,\n SearchQueryGrants,\n SearchQueryViewer,\n SearchRegistry,\n} from './registries/search';\nexport type { StorageDriver, StoragePutMeta, StoragePutResult, StorageRegistry } from './registries/storage';\nexport type {\n AdmissionControlConfig,\n AuthContext,\n CacheEntry,\n CacheKey,\n CacheStorage,\n CodeBlockInfo,\n CodeBlockRenderer,\n EmbedFragment,\n EmbedInput,\n EmbedRenderer,\n InlineExpansion,\n NodeRenderer,\n RenderActor,\n RenderContext,\n RenderError,\n RendererRegistry,\n RenderPhase,\n RenderResult,\n Reservation,\n ScopedCacheStorage,\n UrlInlineExpansionRule,\n} from './renderer';\nexport type { PluginRouteHandler, PluginRouteMethod, PluginRouteOptions, PluginRouterScope } from './routes';\nexport { ACTION_FIELD_MARKER, getActionAnnotation, isSensitiveField, SENSITIVE_FIELD_MARKER } from './schema-markers';\n","/**\n * HTML-emitting helper for renderer plugins.\n *\n * A renderer plugin that builds HTML from author-controlled or external\n * strings (an OGP title, a math error message, …) must escape them, and\n * that escape is a security primitive — a hardening change has to reach\n * every plugin at once, not whichever local copies someone remembers.\n * This is the SDK's single copy (`@crowi/plugin-renderer-katex` and\n * `@crowi/plugin-renderer-link-card` each carried an identical local one\n * before it was hoisted here).\n */\n\n/** Escape `&` `<` `>` `\"` `'` for interpolation into HTML text or double/single-quoted attribute values. */\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => {\n switch (c) {\n case '&':\n return '&amp;';\n case '<':\n return '&lt;';\n case '>':\n return '&gt;';\n case '\"':\n return '&quot;';\n case \"'\":\n return '&#39;';\n default:\n return c;\n }\n });\n}\n","import type { z } from 'zod/v3';\n\nimport type { PluginRouteMethod } from './routes';\n\n/**\n * `configSchema` description-string markers.\n *\n * The admin UI walks the schema and looks at each field's\n * `description` (set via `z.string().describe('@sensitive ...')`). A\n * description starting with one of these marker tokens unlocks special\n * UI behaviour without forcing every field to declare a custom Zod\n * type.\n */\n\n/**\n * Marker that flags a config field as sensitive (encrypted at rest).\n * Usage:\n *\n * z.string().describe('@sensitive AWS secret access key')\n *\n * The runtime auto-encrypts on write and decrypts on read, using the\n * same KeyProvider as core sensitive Config. The admin UI renders the\n * field via `<SecretField>` (saved badge / clear pending / undo).\n */\nexport const SENSITIVE_FIELD_MARKER = '@sensitive';\n\n/**\n * Marker that adds an action button next to a config field. Usage:\n *\n * z.string().describe('@action \"Test connection\" POST /test')\n *\n * The admin form renders a button with the given label that calls the\n * plugin's contributed endpoint at the given verb / path (relative to\n * `/api/v2/plugins/<name>/`). Useful for \"Test connection\",\n * \"Authorise with Google\", etc. without forcing every plugin to ship\n * its own React component.\n */\nexport const ACTION_FIELD_MARKER = '@action';\n\n/**\n * True if the schema field is marked `@sensitive`.\n *\n * `field` is `z.ZodTypeAny` (intentionally loose); call sites pass the\n * value type from `configSchema.shape[key]`.\n */\nexport function isSensitiveField(field: z.ZodTypeAny): boolean {\n const description = field.description;\n return typeof description === 'string' && description.trimStart().startsWith(SENSITIVE_FIELD_MARKER);\n}\n\n/**\n * Parsed `@action` annotation extracted from a field's `description`.\n */\nexport interface ActionAnnotation {\n /** Visible button label, e.g. \"Test connection\". */\n label: string;\n /** HTTP verb of the plugin endpoint to call. */\n method: PluginRouteMethod;\n /** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */\n path: string;\n}\n\n/**\n * Parse an `@action` annotation off a field, or return null if absent.\n *\n * Format: `@action \"<label>\" <METHOD> <path>`\n * e.g. `@action \"Test connection\" POST /test`\n *\n * The label may include spaces when wrapped in double quotes; the method\n * must be one of `PluginRouteMethod` (`GET` / `POST` — the only verbs a\n * plugin route can actually be mounted on, see `routes.ts`); the path\n * begins with `/`. A description that starts with the `@action` marker\n * but declares an unsupported verb (e.g. `PUT` / `DELETE`) fails to match\n * and returns `null` here — callers that walk a plugin's `configSchema`\n * (e.g. `PluginManager.activate()`) are expected to warn on that case at\n * boot, since it would otherwise be a silent dead button.\n */\nexport function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null {\n const description = field.description;\n if (typeof description !== 'string') return null;\n const trimmed = description.trimStart();\n if (!trimmed.startsWith(ACTION_FIELD_MARKER)) return null;\n\n const rest = trimmed.slice(ACTION_FIELD_MARKER.length).trimStart();\n // `\"<label>\" <METHOD> <path>`\n const match = rest.match(/^\"([^\"]+)\"\\s+(GET|POST)\\s+(\\/\\S*)/);\n if (!match) return null;\n\n const [, label, method, path] = match;\n return { label, method: method as ActionAnnotation['method'], path };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACaO,SAAS,WAAW,GAAmB;AAC5C,SAAO,EAAE,QAAQ,YAAY,CAAC,MAAM;AAClC,YAAQ,GAAG;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT;AACE,eAAO;AAAA,IACX;AAAA,EACF,CAAC;AACH;;;ACNO,IAAM,yBAAyB;AAa/B,IAAM,sBAAsB;AAQ5B,SAAS,iBAAiB,OAA8B;AAC7D,QAAM,cAAc,MAAM;AAC1B,SAAO,OAAO,gBAAgB,YAAY,YAAY,UAAU,EAAE,WAAW,sBAAsB;AACrG;AA6BO,SAAS,oBAAoB,OAA8C;AAChF,QAAM,cAAc,MAAM;AAC1B,MAAI,OAAO,gBAAgB,SAAU,QAAO;AAC5C,QAAM,UAAU,YAAY,UAAU;AACtC,MAAI,CAAC,QAAQ,WAAW,mBAAmB,EAAG,QAAO;AAErD,QAAM,OAAO,QAAQ,MAAM,oBAAoB,MAAM,EAAE,UAAU;AAEjE,QAAM,QAAQ,KAAK,MAAM,mCAAmC;AAC5D,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,CAAC,EAAE,OAAO,QAAQ,IAAI,IAAI;AAChC,SAAO,EAAE,OAAO,QAA8C,KAAK;AACrE;","names":[]}
package/dist/index.mjs CHANGED
@@ -1,3 +1,23 @@
1
+ // src/html.ts
2
+ function escapeHtml(s) {
3
+ return s.replace(/[&<>"']/g, (c) => {
4
+ switch (c) {
5
+ case "&":
6
+ return "&amp;";
7
+ case "<":
8
+ return "&lt;";
9
+ case ">":
10
+ return "&gt;";
11
+ case '"':
12
+ return "&quot;";
13
+ case "'":
14
+ return "&#39;";
15
+ default:
16
+ return c;
17
+ }
18
+ });
19
+ }
20
+
1
21
  // src/schema-markers.ts
2
22
  var SENSITIVE_FIELD_MARKER = "@sensitive";
3
23
  var ACTION_FIELD_MARKER = "@action";
@@ -19,6 +39,7 @@ function getActionAnnotation(field) {
19
39
  export {
20
40
  ACTION_FIELD_MARKER,
21
41
  SENSITIVE_FIELD_MARKER,
42
+ escapeHtml,
22
43
  getActionAnnotation,
23
44
  isSensitiveField
24
45
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/schema-markers.ts"],"sourcesContent":["import type { z } from 'zod/v3';\n\nimport type { PluginRouteMethod } from './routes';\n\n/**\n * `configSchema` description-string markers.\n *\n * The admin UI walks the schema and looks at each field's\n * `description` (set via `z.string().describe('@sensitive ...')`). A\n * description starting with one of these marker tokens unlocks special\n * UI behaviour without forcing every field to declare a custom Zod\n * type.\n */\n\n/**\n * Marker that flags a config field as sensitive (encrypted at rest).\n * Usage:\n *\n * z.string().describe('@sensitive AWS secret access key')\n *\n * The runtime auto-encrypts on write and decrypts on read, using the\n * same KeyProvider as core sensitive Config. The admin UI renders the\n * field via `<SecretField>` (saved badge / clear pending / undo).\n */\nexport const SENSITIVE_FIELD_MARKER = '@sensitive';\n\n/**\n * Marker that adds an action button next to a config field. Usage:\n *\n * z.string().describe('@action \"Test connection\" POST /test')\n *\n * The admin form renders a button with the given label that calls the\n * plugin's contributed endpoint at the given verb / path (relative to\n * `/api/v2/plugins/<name>/`). Useful for \"Test connection\",\n * \"Authorise with Google\", etc. without forcing every plugin to ship\n * its own React component.\n */\nexport const ACTION_FIELD_MARKER = '@action';\n\n/**\n * True if the schema field is marked `@sensitive`.\n *\n * `field` is `z.ZodTypeAny` (intentionally loose); call sites pass the\n * value type from `configSchema.shape[key]`.\n */\nexport function isSensitiveField(field: z.ZodTypeAny): boolean {\n const description = field.description;\n return typeof description === 'string' && description.trimStart().startsWith(SENSITIVE_FIELD_MARKER);\n}\n\n/**\n * Parsed `@action` annotation extracted from a field's `description`.\n */\nexport interface ActionAnnotation {\n /** Visible button label, e.g. \"Test connection\". */\n label: string;\n /** HTTP verb of the plugin endpoint to call. */\n method: PluginRouteMethod;\n /** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */\n path: string;\n}\n\n/**\n * Parse an `@action` annotation off a field, or return null if absent.\n *\n * Format: `@action \"<label>\" <METHOD> <path>`\n * e.g. `@action \"Test connection\" POST /test`\n *\n * The label may include spaces when wrapped in double quotes; the method\n * must be one of `PluginRouteMethod` (`GET` / `POST` — the only verbs a\n * plugin route can actually be mounted on, see `routes.ts`); the path\n * begins with `/`. A description that starts with the `@action` marker\n * but declares an unsupported verb (e.g. `PUT` / `DELETE`) fails to match\n * and returns `null` here — callers that walk a plugin's `configSchema`\n * (e.g. `PluginManager.activate()`) are expected to warn on that case at\n * boot, since it would otherwise be a silent dead button.\n */\nexport function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null {\n const description = field.description;\n if (typeof description !== 'string') return null;\n const trimmed = description.trimStart();\n if (!trimmed.startsWith(ACTION_FIELD_MARKER)) return null;\n\n const rest = trimmed.slice(ACTION_FIELD_MARKER.length).trimStart();\n // `\"<label>\" <METHOD> <path>`\n const match = rest.match(/^\"([^\"]+)\"\\s+(GET|POST)\\s+(\\/\\S*)/);\n if (!match) return null;\n\n const [, label, method, path] = match;\n return { label, method: method as ActionAnnotation['method'], path };\n}\n"],"mappings":";AAwBO,IAAM,yBAAyB;AAa/B,IAAM,sBAAsB;AAQ5B,SAAS,iBAAiB,OAA8B;AAC7D,QAAM,cAAc,MAAM;AAC1B,SAAO,OAAO,gBAAgB,YAAY,YAAY,UAAU,EAAE,WAAW,sBAAsB;AACrG;AA6BO,SAAS,oBAAoB,OAA8C;AAChF,QAAM,cAAc,MAAM;AAC1B,MAAI,OAAO,gBAAgB,SAAU,QAAO;AAC5C,QAAM,UAAU,YAAY,UAAU;AACtC,MAAI,CAAC,QAAQ,WAAW,mBAAmB,EAAG,QAAO;AAErD,QAAM,OAAO,QAAQ,MAAM,oBAAoB,MAAM,EAAE,UAAU;AAEjE,QAAM,QAAQ,KAAK,MAAM,mCAAmC;AAC5D,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,CAAC,EAAE,OAAO,QAAQ,IAAI,IAAI;AAChC,SAAO,EAAE,OAAO,QAA8C,KAAK;AACrE;","names":[]}
1
+ {"version":3,"sources":["../src/html.ts","../src/schema-markers.ts"],"sourcesContent":["/**\n * HTML-emitting helper for renderer plugins.\n *\n * A renderer plugin that builds HTML from author-controlled or external\n * strings (an OGP title, a math error message, …) must escape them, and\n * that escape is a security primitive — a hardening change has to reach\n * every plugin at once, not whichever local copies someone remembers.\n * This is the SDK's single copy (`@crowi/plugin-renderer-katex` and\n * `@crowi/plugin-renderer-link-card` each carried an identical local one\n * before it was hoisted here).\n */\n\n/** Escape `&` `<` `>` `\"` `'` for interpolation into HTML text or double/single-quoted attribute values. */\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => {\n switch (c) {\n case '&':\n return '&amp;';\n case '<':\n return '&lt;';\n case '>':\n return '&gt;';\n case '\"':\n return '&quot;';\n case \"'\":\n return '&#39;';\n default:\n return c;\n }\n });\n}\n","import type { z } from 'zod/v3';\n\nimport type { PluginRouteMethod } from './routes';\n\n/**\n * `configSchema` description-string markers.\n *\n * The admin UI walks the schema and looks at each field's\n * `description` (set via `z.string().describe('@sensitive ...')`). A\n * description starting with one of these marker tokens unlocks special\n * UI behaviour without forcing every field to declare a custom Zod\n * type.\n */\n\n/**\n * Marker that flags a config field as sensitive (encrypted at rest).\n * Usage:\n *\n * z.string().describe('@sensitive AWS secret access key')\n *\n * The runtime auto-encrypts on write and decrypts on read, using the\n * same KeyProvider as core sensitive Config. The admin UI renders the\n * field via `<SecretField>` (saved badge / clear pending / undo).\n */\nexport const SENSITIVE_FIELD_MARKER = '@sensitive';\n\n/**\n * Marker that adds an action button next to a config field. Usage:\n *\n * z.string().describe('@action \"Test connection\" POST /test')\n *\n * The admin form renders a button with the given label that calls the\n * plugin's contributed endpoint at the given verb / path (relative to\n * `/api/v2/plugins/<name>/`). Useful for \"Test connection\",\n * \"Authorise with Google\", etc. without forcing every plugin to ship\n * its own React component.\n */\nexport const ACTION_FIELD_MARKER = '@action';\n\n/**\n * True if the schema field is marked `@sensitive`.\n *\n * `field` is `z.ZodTypeAny` (intentionally loose); call sites pass the\n * value type from `configSchema.shape[key]`.\n */\nexport function isSensitiveField(field: z.ZodTypeAny): boolean {\n const description = field.description;\n return typeof description === 'string' && description.trimStart().startsWith(SENSITIVE_FIELD_MARKER);\n}\n\n/**\n * Parsed `@action` annotation extracted from a field's `description`.\n */\nexport interface ActionAnnotation {\n /** Visible button label, e.g. \"Test connection\". */\n label: string;\n /** HTTP verb of the plugin endpoint to call. */\n method: PluginRouteMethod;\n /** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */\n path: string;\n}\n\n/**\n * Parse an `@action` annotation off a field, or return null if absent.\n *\n * Format: `@action \"<label>\" <METHOD> <path>`\n * e.g. `@action \"Test connection\" POST /test`\n *\n * The label may include spaces when wrapped in double quotes; the method\n * must be one of `PluginRouteMethod` (`GET` / `POST` — the only verbs a\n * plugin route can actually be mounted on, see `routes.ts`); the path\n * begins with `/`. A description that starts with the `@action` marker\n * but declares an unsupported verb (e.g. `PUT` / `DELETE`) fails to match\n * and returns `null` here — callers that walk a plugin's `configSchema`\n * (e.g. `PluginManager.activate()`) are expected to warn on that case at\n * boot, since it would otherwise be a silent dead button.\n */\nexport function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null {\n const description = field.description;\n if (typeof description !== 'string') return null;\n const trimmed = description.trimStart();\n if (!trimmed.startsWith(ACTION_FIELD_MARKER)) return null;\n\n const rest = trimmed.slice(ACTION_FIELD_MARKER.length).trimStart();\n // `\"<label>\" <METHOD> <path>`\n const match = rest.match(/^\"([^\"]+)\"\\s+(GET|POST)\\s+(\\/\\S*)/);\n if (!match) return null;\n\n const [, label, method, path] = match;\n return { label, method: method as ActionAnnotation['method'], path };\n}\n"],"mappings":";AAaO,SAAS,WAAW,GAAmB;AAC5C,SAAO,EAAE,QAAQ,YAAY,CAAC,MAAM;AAClC,YAAQ,GAAG;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT;AACE,eAAO;AAAA,IACX;AAAA,EACF,CAAC;AACH;;;ACNO,IAAM,yBAAyB;AAa/B,IAAM,sBAAsB;AAQ5B,SAAS,iBAAiB,OAA8B;AAC7D,QAAM,cAAc,MAAM;AAC1B,SAAO,OAAO,gBAAgB,YAAY,YAAY,UAAU,EAAE,WAAW,sBAAsB;AACrG;AA6BO,SAAS,oBAAoB,OAA8C;AAChF,QAAM,cAAc,MAAM;AAC1B,MAAI,OAAO,gBAAgB,SAAU,QAAO;AAC5C,QAAM,UAAU,YAAY,UAAU;AACtC,MAAI,CAAC,QAAQ,WAAW,mBAAmB,EAAG,QAAO;AAErD,QAAM,OAAO,QAAQ,MAAM,oBAAoB,MAAM,EAAE,UAAU;AAEjE,QAAM,QAAQ,KAAK,MAAM,mCAAmC;AAC5D,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,CAAC,EAAE,OAAO,QAAQ,IAAI,IAAI;AAChC,SAAO,EAAE,OAAO,QAA8C,KAAK;AACrE;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowi/plugin-api",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "Type-only contract for Crowi 2.0 plugins. See docs/rfcs/0001-plugin-architecture.md.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,7 +31,7 @@
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
33
33
  "@types/node": "^24",
34
- "hono": "^4.12.25",
34
+ "hono": "^4.12.31",
35
35
  "jest": "^29.7.0",
36
36
  "ts-jest": "^29.3.4",
37
37
  "tsup": "^8.3.5",