@aglyn/aglyn 1.0.0-beta.149 → 1.0.0-beta.150

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.
Files changed (27) hide show
  1. package/package.json +10 -10
  2. package/src/lib/app-utils/console-routes.d.ts +0 -34
  3. package/src/lib/app-utils/console-routes.js +15 -11
  4. package/src/lib/app-utils/console-routes.js.map +1 -1
  5. package/src/lib/app-utils/contexts.d.ts +1 -0
  6. package/src/lib/app-utils/contexts.js +1 -0
  7. package/src/lib/app-utils/contexts.js.map +1 -1
  8. package/src/lib/app-utils/docs-help.generated.d.ts +12 -0
  9. package/src/lib/app-utils/docs-help.generated.js +34 -0
  10. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  11. package/src/lib/app-utils/media-src.d.ts +93 -0
  12. package/src/lib/app-utils/media-src.js +114 -0
  13. package/src/lib/app-utils/media-src.js.map +1 -0
  14. package/src/lib/app-utils/page-header-help-context.d.ts +83 -0
  15. package/src/lib/app-utils/page-header-help-context.js +56 -0
  16. package/src/lib/app-utils/page-header-help-context.js.map +1 -0
  17. package/src/lib/app-utils/release-flags.js +5 -4
  18. package/src/lib/app-utils/release-flags.js.map +1 -1
  19. package/src/lib/plugin-manager/feature-plugins.d.ts +52 -23
  20. package/src/lib/plugin-manager/feature-plugins.js +15 -19
  21. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  22. package/src/lib/plugin-manager/plugin-media-publish.d.ts +88 -0
  23. package/src/lib/plugin-manager/plugin-media-publish.js +118 -0
  24. package/src/lib/plugin-manager/plugin-media-publish.js.map +1 -0
  25. package/src/lib/plugin-manager/plugin-route-metadata.d.ts +141 -0
  26. package/src/lib/plugin-manager/plugin-route-metadata.js +140 -0
  27. package/src/lib/plugin-manager/plugin-route-metadata.js.map +1 -0
@@ -0,0 +1,118 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * WHO REFUSES TO LET A LIBRARY ASSET BE MADE PUBLIC (AGL-3080).
19
+ *
20
+ * "Publish file" in the media library gives an asset its permanent CDN URL
21
+ * back. For most assets that is what the button is for. For a file somebody
22
+ * is SELLING it is a giveaway: the public URL names the same object as every
23
+ * signed link a buyer was handed, so stripping the signature off any of them
24
+ * starts working again.
25
+ *
26
+ * The console knew that rule, which meant it knew what a product is, which
27
+ * two of its fields hold paid media, and how a deleted one still counts. All
28
+ * of that belongs to whatever sells things. So the plugin holding a reason
29
+ * declares it, and the media route asks before it publishes.
30
+ *
31
+ * ## ⛔ A CHECK THAT DID NOT RUN IS NOT PERMISSION
32
+ *
33
+ * This is the money path, and every way it can be wrong points the same way:
34
+ * towards publishing a file somebody paid for. So the shape is the opposite
35
+ * of the usual one —
36
+ *
37
+ * - a guard that THROWS is a refusal, not a guard to skip. Something it
38
+ * needed was unreadable, and "we could not check" and "nothing holds this"
39
+ * are different answers;
40
+ * - a guard that answers `complete: false` is a refusal for the same reason,
41
+ * even with no blocker to name;
42
+ * - and the CALLER must load the plugins' server surfaces before it asks.
43
+ * An unfilled registry answers "nobody refuses", which is the honest
44
+ * answer for a workspace with no store and the catastrophic one for a
45
+ * process that has not loaded commerce yet (AGL-3025). A caller that
46
+ * cannot load them must refuse rather than publish.
47
+ *
48
+ * `null` from {@link resolvePluginMediaPublishRefusal} therefore means only
49
+ * one thing: every guard that exists ran, and none of them objected.
50
+ *
51
+ * ## Many guards, first refusal wins
52
+ *
53
+ * More than one plugin may sell access to a file, and the person reading the
54
+ * refusal needs one sentence, not a list of every plugin's. The guards are
55
+ * asked in registration order and the first refusal is the answer, because a
56
+ * second reason changes nothing about what they have to do next: the asset
57
+ * stays private either way, and the first blocker is as good a place to start
58
+ * as any.
59
+ *
60
+ * Server-side: reached by its own subpath, never through
61
+ * `plugin-manager/index.ts`.
62
+ */ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
63
+ import { definePluginServiceContract, registerPluginService, resolvePluginServices } from "./plugin-services.js";
64
+ export const PLUGIN_MEDIA_PUBLISH_GUARDS = definePluginServiceContract('core.media-publish-guard', {
65
+ multiple: true
66
+ });
67
+ /**
68
+ * Declares that this plugin has a reason an asset may not be made public.
69
+ * The owner is the loader's marker when a register fn is running, else
70
+ * `options.pluginId`.
71
+ */ export function registerPluginMediaPublishGuard(guard, options) {
72
+ var _ref, _getRegisteringPluginId;
73
+ if (typeof (guard == null ? void 0 : guard.check) !== 'function') {
74
+ throw new Error('a media publish guard needs a check function');
75
+ }
76
+ const pluginId = ((_ref = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options == null ? void 0 : options.pluginId) != null ? _ref : '').trim();
77
+ registerPluginService(PLUGIN_MEDIA_PUBLISH_GUARDS, guard, _extends({}, (options == null ? void 0 : options.pluginId) ? {
78
+ pluginId: options.pluginId
79
+ } : {}, pluginId ? {
80
+ key: pluginId
81
+ } : {}));
82
+ }
83
+ /** Every registered guard, with its plugin, in registration order. */ export function listPluginMediaPublishGuards() {
84
+ return resolvePluginServices(PLUGIN_MEDIA_PUBLISH_GUARDS).map((entry)=>({
85
+ pluginId: entry.pluginId,
86
+ guard: entry.impl
87
+ }));
88
+ }
89
+ /**
90
+ * The first reason this asset may not be made public, or `null` when every
91
+ * guard ran and none objected.
92
+ *
93
+ * ⚠️ Read the module docblock before treating `null` as permission: it is
94
+ * only an answer once the caller has loaded the plugins' server surfaces.
95
+ */ export async function resolvePluginMediaPublishRefusal(request) {
96
+ for (const { pluginId, guard } of listPluginMediaPublishGuards()){
97
+ let verdict;
98
+ try {
99
+ verdict = await guard.check(request);
100
+ } catch (error) {
101
+ // Fail closed, loudly. The asset stays private and somebody can find
102
+ // out why; the alternative is a paid file published because a query
103
+ // failed.
104
+ console.error(`[media-publish] "${pluginId}" could not check ${request.base}/${request.mediaId}`, error);
105
+ return {
106
+ reason: 'We could not check everything that might be using this file, so ' + 'it stays private. Try again in a moment.',
107
+ blockers: [],
108
+ complete: false
109
+ };
110
+ }
111
+ if (verdict && (verdict.blockers.length > 0 || !verdict.complete)) {
112
+ return verdict;
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ //# sourceMappingURL=plugin-media-publish.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-media-publish.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHO REFUSES TO LET A LIBRARY ASSET BE MADE PUBLIC (AGL-3080).\n *\n * \"Publish file\" in the media library gives an asset its permanent CDN URL\n * back. For most assets that is what the button is for. For a file somebody\n * is SELLING it is a giveaway: the public URL names the same object as every\n * signed link a buyer was handed, so stripping the signature off any of them\n * starts working again.\n *\n * The console knew that rule, which meant it knew what a product is, which\n * two of its fields hold paid media, and how a deleted one still counts. All\n * of that belongs to whatever sells things. So the plugin holding a reason\n * declares it, and the media route asks before it publishes.\n *\n * ## ⛔ A CHECK THAT DID NOT RUN IS NOT PERMISSION\n *\n * This is the money path, and every way it can be wrong points the same way:\n * towards publishing a file somebody paid for. So the shape is the opposite\n * of the usual one —\n *\n * - a guard that THROWS is a refusal, not a guard to skip. Something it\n * needed was unreadable, and \"we could not check\" and \"nothing holds this\"\n * are different answers;\n * - a guard that answers `complete: false` is a refusal for the same reason,\n * even with no blocker to name;\n * - and the CALLER must load the plugins' server surfaces before it asks.\n * An unfilled registry answers \"nobody refuses\", which is the honest\n * answer for a workspace with no store and the catastrophic one for a\n * process that has not loaded commerce yet (AGL-3025). A caller that\n * cannot load them must refuse rather than publish.\n *\n * `null` from {@link resolvePluginMediaPublishRefusal} therefore means only\n * one thing: every guard that exists ran, and none of them objected.\n *\n * ## Many guards, first refusal wins\n *\n * More than one plugin may sell access to a file, and the person reading the\n * refusal needs one sentence, not a list of every plugin's. The guards are\n * asked in registration order and the first refusal is the answer, because a\n * second reason changes nothing about what they have to do next: the asset\n * stays private either way, and the first blocker is as good a place to start\n * as any.\n *\n * Server-side: reached by its own subpath, never through\n * `plugin-manager/index.ts`.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/** The asset a caller is about to make public. */\nexport interface PluginMediaPublishRequest {\n /**\n * The library it lives in: `hosts/{hostId}` for a site's own, or\n * `orgs/{orgId}` for one the whole organization shares.\n */\n base: string\n /** The asset's id in that library. */\n mediaId: string\n /**\n * The platform's media bucket, so a raw URL from any other bucket is never\n * mistaken for this asset. Absent where the caller has none to give.\n */\n bucket?: string\n}\n\n/** One thing a guard found that is still using the asset. */\nexport interface PluginMediaPublishBlocker {\n /** What a person reads: a product's name, a page's title. */\n label: string\n /** The document, for a caller that links back to it. */\n refId?: string\n /** The site holding it, where the plugin scopes per site. */\n hostId?: string\n}\n\nexport interface PluginMediaPublishRefusal {\n /**\n * The sentence the person who pressed Publish reads. Customer-safe: the\n * caller may show it exactly as it stands, and it names nothing internal.\n */\n reason: string\n /** What is holding the asset. May be empty — see {@link complete}. */\n blockers: readonly PluginMediaPublishBlocker[]\n /**\n * False when a ceiling or a failure stopped the guard short. An incomplete\n * \"nothing holds this\" is not an answer anyone may publish on, so it is a\n * refusal with no blockers rather than a pass.\n */\n complete: boolean\n}\n\nexport interface PluginMediaPublishGuard {\n /**\n * Answers a refusal, or `null` when nothing this plugin holds objects.\n *\n * ⚠️ A throw is a REFUSAL, handled by the resolver — a guard is not asked\n * to turn its own unreadable state into a sentence.\n */\n check(\n request: PluginMediaPublishRequest,\n ): Promise<PluginMediaPublishRefusal | null>\n}\n\n/** A guard with the plugin that registered it. */\nexport type ResolvedPluginMediaPublishGuard = {\n pluginId: string\n guard: PluginMediaPublishGuard\n}\n\nexport const PLUGIN_MEDIA_PUBLISH_GUARDS =\n definePluginServiceContract<PluginMediaPublishGuard>(\n 'core.media-publish-guard',\n { multiple: true },\n )\n\n/**\n * Declares that this plugin has a reason an asset may not be made public.\n * The owner is the loader's marker when a register fn is running, else\n * `options.pluginId`.\n */\nexport function registerPluginMediaPublishGuard(\n guard: PluginMediaPublishGuard,\n options?: { pluginId?: string },\n): void {\n if (typeof guard?.check !== 'function') {\n throw new Error('a media publish guard needs a check function')\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n registerPluginService(PLUGIN_MEDIA_PUBLISH_GUARDS, guard, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n // Keyed by owner so a plugin registering twice — a hot reload, a second\n // surface — replaces its own rather than being asked twice.\n ...(pluginId ? { key: pluginId } : {}),\n })\n}\n\n/** Every registered guard, with its plugin, in registration order. */\nexport function listPluginMediaPublishGuards(): ResolvedPluginMediaPublishGuard[] {\n return resolvePluginServices(PLUGIN_MEDIA_PUBLISH_GUARDS).map((entry) => ({\n pluginId: entry.pluginId,\n guard: entry.impl,\n }))\n}\n\n/**\n * The first reason this asset may not be made public, or `null` when every\n * guard ran and none objected.\n *\n * ⚠️ Read the module docblock before treating `null` as permission: it is\n * only an answer once the caller has loaded the plugins' server surfaces.\n */\nexport async function resolvePluginMediaPublishRefusal(\n request: PluginMediaPublishRequest,\n): Promise<PluginMediaPublishRefusal | null> {\n for (const { pluginId, guard } of listPluginMediaPublishGuards()) {\n let verdict: PluginMediaPublishRefusal | null\n try {\n verdict = await guard.check(request)\n } catch (error) {\n // Fail closed, loudly. The asset stays private and somebody can find\n // out why; the alternative is a paid file published because a query\n // failed.\n console.error(\n `[media-publish] \"${pluginId}\" could not check ${request.base}/${request.mediaId}`,\n error,\n )\n return {\n reason:\n 'We could not check everything that might be using this file, so ' +\n 'it stays private. Try again in a moment.',\n blockers: [],\n complete: false,\n }\n }\n if (verdict && (verdict.blockers.length > 0 || !verdict.complete)) {\n return verdict\n }\n }\n return null\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_MEDIA_PUBLISH_GUARDS","multiple","registerPluginMediaPublishGuard","guard","options","check","Error","pluginId","trim","key","listPluginMediaPublishGuards","map","entry","impl","resolvePluginMediaPublishRefusal","request","verdict","error","console","base","mediaId","reason","blockers","complete","length"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6CC,GAED,SAASA,sBAAsB,QAAQ,qCAAiC;AACxE,SACEC,2BAA2B,EAC3BC,qBAAqB,EACrBC,qBAAqB,QAChB,uBAAmB;AA8D1B,OAAO,MAAMC,8BACXH,4BACE,4BACA;IAAEI,UAAU;AAAK,GAClB;AAEH;;;;CAIC,GACD,OAAO,SAASC,gCACdC,KAA8B,EAC9BC,OAA+B;QAKbR,MAAAA;IAHlB,IAAI,QAAOO,yBAAAA,MAAOE,KAAK,MAAK,YAAY;QACtC,MAAM,IAAIC,MAAM;IAClB;IACA,MAAMC,WAAW,EAACX,QAAAA,0BAAAA,oCAAAA,0BAA4BQ,2BAAAA,QAASG,QAAQ,YAA7CX,OAAiD,IAAIY,IAAI;IAC3EV,sBAAsBE,6BAA6BG,OAAO,aACpDC,CAAAA,2BAAAA,QAASG,QAAQ,IAAG;QAAEA,UAAUH,QAAQG,QAAQ;IAAC,IAAI,CAAC,GAGtDA,WAAW;QAAEE,KAAKF;IAAS,IAAI,CAAC;AAExC;AAEA,oEAAoE,GACpE,OAAO,SAASG;IACd,OAAOX,sBAAsBC,6BAA6BW,GAAG,CAAC,CAACC,QAAW,CAAA;YACxEL,UAAUK,MAAML,QAAQ;YACxBJ,OAAOS,MAAMC,IAAI;QACnB,CAAA;AACF;AAEA;;;;;;CAMC,GACD,OAAO,eAAeC,iCACpBC,OAAkC;IAElC,KAAK,MAAM,EAAER,QAAQ,EAAEJ,KAAK,EAAE,IAAIO,+BAAgC;QAChE,IAAIM;QACJ,IAAI;YACFA,UAAU,MAAMb,MAAME,KAAK,CAACU;QAC9B,EAAE,OAAOE,OAAO;YACd,qEAAqE;YACrE,oEAAoE;YACpE,UAAU;YACVC,QAAQD,KAAK,CACX,CAAC,iBAAiB,EAAEV,SAAS,kBAAkB,EAAEQ,QAAQI,IAAI,CAAC,CAAC,EAAEJ,QAAQK,OAAO,EAAE,EAClFH;YAEF,OAAO;gBACLI,QACE,qEACA;gBACFC,UAAU,EAAE;gBACZC,UAAU;YACZ;QACF;QACA,IAAIP,WAAYA,CAAAA,QAAQM,QAAQ,CAACE,MAAM,GAAG,KAAK,CAACR,QAAQO,QAAQ,AAAD,GAAI;YACjE,OAAOP;QACT;IACF;IACA,OAAO;AACT"}
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * What a plugin's page says about itself in the `<head>` (AGL-3080).
19
+ *
20
+ * A console page that a plugin draws has its head built by the SHELL: the
21
+ * page is a client component and cannot export metadata, so the nearest
22
+ * server layout does it, and for every plugin route that layout is the
23
+ * generic one. It can title a page from the slugs in the URL and nothing
24
+ * more — which is correct for a hub and wrong for a record. A marketplace
25
+ * listing shared into Slack unfurled as the same generic console card as
26
+ * every other listing until the app grew a hand-written route with a layout
27
+ * of its own, and that route is one of the ~20 that cannot move onto the
28
+ * generic one while this is the only way to have a card.
29
+ *
30
+ * So the plugin declares how to answer for an address under its own route,
31
+ * and the shell asks.
32
+ *
33
+ * ## The head is not a framework's
34
+ *
35
+ * A declaration answers a {@link PluginRouteHead} — a title, a description,
36
+ * an image — and never Next's `Metadata`. Core does not depend on a web
37
+ * framework and a plugin must not have to; more usefully, how a title,
38
+ * description and image become `openGraph` and `twitter` tags is the SHELL's
39
+ * knowledge and the same for every route: which card size degrades without
40
+ * an image, that defining `openGraph` replaces the root layout's wholesale
41
+ * so the site name has to be restated. A plugin that wrote those tags itself
42
+ * would get a different answer per plugin, and the two that got it wrong
43
+ * would look exactly like the two that got it right until somebody shared a
44
+ * link.
45
+ *
46
+ * ## Absent is a real answer, and so is silence
47
+ *
48
+ * `resolve` answers `null` for an address it has nothing to add about — a
49
+ * hub, a tab, a record it must not describe — and the shell keeps the title
50
+ * it would have built. That is the same answer as a plugin that declares
51
+ * nothing at all, deliberately: a head is an enrichment, and a route with no
52
+ * declaration is the ordinary case rather than a degraded one.
53
+ *
54
+ * ⛔ WHICH MAKES THE LOADING ORDER THE WHOLE RISK. This is a runtime
55
+ * registry, and an unfilled one answers "nothing to add" for every address —
56
+ * silently, and indistinguishably from the honest answer. A layout that asked
57
+ * before loading the plugin's server surface would drop every card it was
58
+ * built to produce and stay green. A registry is nonetheless the right shape
59
+ * here, and a compiled table is not: the answer depends on the DOCUMENT
60
+ * behind the address, so it cannot be compiled, and the reader is a server
61
+ * layout that already awaits — so it can load the surface first, which is
62
+ * what `pluginRouteHead` in the console does and what the app's
63
+ * `*-is-registered` spec holds it to.
64
+ *
65
+ * ## One route, one owner
66
+ *
67
+ * A route belongs to the plugin that serves it, so a second plugin's
68
+ * declaration for the same route is refused naming both and the incumbent
69
+ * keeps answering. The same plugin declaring again — a hot reload, a second
70
+ * surface — replaces its own.
71
+ *
72
+ * Server-side: reached by its own subpath, never through
73
+ * `plugin-manager/index.ts`. What a published page does not need, it must
74
+ * not import.
75
+ */
76
+ import type { ResolvedSocialImage } from '../app-utils/social-image';
77
+ /** What one address under a plugin's route says about itself. */
78
+ export interface PluginRouteHead {
79
+ /**
80
+ * The page's own title, before the shell's scope and brand are affixed.
81
+ * Absent leaves the title the shell built from the URL.
82
+ */
83
+ title?: string;
84
+ /** One or two sentences. Already cut to whatever budget the owner keeps. */
85
+ description?: string;
86
+ /**
87
+ * The card image, already resolved and absolute —
88
+ * `resolveSocialImage` answers exactly this shape. A crawler reads it out
89
+ * of band, so a relative URL is not a card, it is a broken one.
90
+ */
91
+ image?: ResolvedSocialImage;
92
+ }
93
+ export interface PluginRouteMetadataDeclaration {
94
+ /**
95
+ * The plugin's own console route, as its `contributes.console.routes`
96
+ * spells it and without the leading slash: `marketplace`.
97
+ */
98
+ route: string;
99
+ /**
100
+ * Answers for one address under that route. The segments are what follows
101
+ * the route itself — `['listing-1']` for `/acme/marketplace/listing-1` —
102
+ * so a declaration reads the address it understands and answers `null` for
103
+ * every other, including the route's own hub.
104
+ *
105
+ * ⚠️ IT RUNS ON THE RENDER PATH and must never throw: an unhandled
106
+ * rejection in a layout's metadata fails the whole route, and this route's
107
+ * job is to serve its page whether or not the card resolves. The shell
108
+ * catches anyway, because a plugin's mistake must not cost a page.
109
+ */
110
+ resolve(segments: readonly string[]): Promise<PluginRouteHead | null>;
111
+ }
112
+ /** A declaration with the plugin that made it. */
113
+ export type ResolvedPluginRouteMetadata = PluginRouteMetadataDeclaration & {
114
+ pluginId: string;
115
+ };
116
+ export declare const PLUGIN_ROUTE_METADATA: import("./plugin-services").PluginServiceContract<PluginRouteMetadataDeclaration>;
117
+ /**
118
+ * Declares how a plugin answers for addresses under one of its routes. The
119
+ * owner is the loader's marker when a register fn is running, else
120
+ * `options.pluginId`.
121
+ */
122
+ export declare function registerPluginRouteMetadata(declaration: PluginRouteMetadataDeclaration, options?: {
123
+ pluginId?: string;
124
+ }): void;
125
+ /** The declaration for one route, or `null` when nobody declared one. */
126
+ export declare function pluginRouteMetadata(route: string): ResolvedPluginRouteMetadata | null;
127
+ /**
128
+ * What one address says about itself, or `null` when nothing does.
129
+ *
130
+ * ⚠️ A DECLARATION THAT THROWS COSTS THE PAGE NOTHING. The head is an
131
+ * enrichment of a page that renders without it, and a layout's metadata
132
+ * rejecting fails the route — so the failure is logged and the shell falls
133
+ * back to the title it built itself.
134
+ *
135
+ * ⛔ It asks the registry as it stands. A caller that has not loaded the
136
+ * plugins' server surfaces gets `null` for every address, which is the same
137
+ * answer as a route nobody declared — see the module docblock.
138
+ */
139
+ export declare function resolvePluginRouteHead(route: string, segments: readonly string[]): Promise<PluginRouteHead | null>;
140
+ /** Every declared route, with its plugin, in registration order. */
141
+ export declare function listPluginRouteMetadata(): ResolvedPluginRouteMetadata[];
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * What a plugin's page says about itself in the `<head>` (AGL-3080).
18
+ *
19
+ * A console page that a plugin draws has its head built by the SHELL: the
20
+ * page is a client component and cannot export metadata, so the nearest
21
+ * server layout does it, and for every plugin route that layout is the
22
+ * generic one. It can title a page from the slugs in the URL and nothing
23
+ * more — which is correct for a hub and wrong for a record. A marketplace
24
+ * listing shared into Slack unfurled as the same generic console card as
25
+ * every other listing until the app grew a hand-written route with a layout
26
+ * of its own, and that route is one of the ~20 that cannot move onto the
27
+ * generic one while this is the only way to have a card.
28
+ *
29
+ * So the plugin declares how to answer for an address under its own route,
30
+ * and the shell asks.
31
+ *
32
+ * ## The head is not a framework's
33
+ *
34
+ * A declaration answers a {@link PluginRouteHead} — a title, a description,
35
+ * an image — and never Next's `Metadata`. Core does not depend on a web
36
+ * framework and a plugin must not have to; more usefully, how a title,
37
+ * description and image become `openGraph` and `twitter` tags is the SHELL's
38
+ * knowledge and the same for every route: which card size degrades without
39
+ * an image, that defining `openGraph` replaces the root layout's wholesale
40
+ * so the site name has to be restated. A plugin that wrote those tags itself
41
+ * would get a different answer per plugin, and the two that got it wrong
42
+ * would look exactly like the two that got it right until somebody shared a
43
+ * link.
44
+ *
45
+ * ## Absent is a real answer, and so is silence
46
+ *
47
+ * `resolve` answers `null` for an address it has nothing to add about — a
48
+ * hub, a tab, a record it must not describe — and the shell keeps the title
49
+ * it would have built. That is the same answer as a plugin that declares
50
+ * nothing at all, deliberately: a head is an enrichment, and a route with no
51
+ * declaration is the ordinary case rather than a degraded one.
52
+ *
53
+ * ⛔ WHICH MAKES THE LOADING ORDER THE WHOLE RISK. This is a runtime
54
+ * registry, and an unfilled one answers "nothing to add" for every address —
55
+ * silently, and indistinguishably from the honest answer. A layout that asked
56
+ * before loading the plugin's server surface would drop every card it was
57
+ * built to produce and stay green. A registry is nonetheless the right shape
58
+ * here, and a compiled table is not: the answer depends on the DOCUMENT
59
+ * behind the address, so it cannot be compiled, and the reader is a server
60
+ * layout that already awaits — so it can load the surface first, which is
61
+ * what `pluginRouteHead` in the console does and what the app's
62
+ * `*-is-registered` spec holds it to.
63
+ *
64
+ * ## One route, one owner
65
+ *
66
+ * A route belongs to the plugin that serves it, so a second plugin's
67
+ * declaration for the same route is refused naming both and the incumbent
68
+ * keeps answering. The same plugin declaring again — a hot reload, a second
69
+ * surface — replaces its own.
70
+ *
71
+ * Server-side: reached by its own subpath, never through
72
+ * `plugin-manager/index.ts`. What a published page does not need, it must
73
+ * not import.
74
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
75
+ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
76
+ import { definePluginServiceContract, registerPluginService, resolvePluginServices } from "./plugin-services.js";
77
+ export const PLUGIN_ROUTE_METADATA = definePluginServiceContract('core.route-metadata', {
78
+ multiple: true
79
+ });
80
+ /**
81
+ * Declares how a plugin answers for addresses under one of its routes. The
82
+ * owner is the loader's marker when a register fn is running, else
83
+ * `options.pluginId`.
84
+ */ export function registerPluginRouteMetadata(declaration, options) {
85
+ var _ref, _ref1, _getRegisteringPluginId;
86
+ var _declaration_route;
87
+ const key = (_ref = (_declaration_route = declaration.route) == null ? void 0 : _declaration_route.trim().replace(/^\/+/, '')) != null ? _ref : '';
88
+ if (!key) throw new Error('a plugin route metadata declaration needs a route');
89
+ if (typeof declaration.resolve !== 'function') {
90
+ throw new Error(`route metadata for "${key}" needs a resolve function`);
91
+ }
92
+ const pluginId = ((_ref1 = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options == null ? void 0 : options.pluginId) != null ? _ref1 : '').trim();
93
+ const incumbent = resolvePluginServices(PLUGIN_ROUTE_METADATA).find((entry)=>entry.key === key);
94
+ if (incumbent && pluginId && incumbent.pluginId !== pluginId) {
95
+ throw new Error(`route metadata for "${key}" is already declared by ` + `"${incumbent.pluginId}"; refused "${pluginId}"`);
96
+ }
97
+ registerPluginService(PLUGIN_ROUTE_METADATA, _extends({}, declaration, {
98
+ route: key
99
+ }), _extends({}, (options == null ? void 0 : options.pluginId) ? {
100
+ pluginId: options.pluginId
101
+ } : {}, {
102
+ key
103
+ }));
104
+ }
105
+ /** The declaration for one route, or `null` when nobody declared one. */ export function pluginRouteMetadata(route) {
106
+ const key = String(route != null ? route : '').trim().replace(/^\/+/, '');
107
+ const entry = resolvePluginServices(PLUGIN_ROUTE_METADATA).find((one)=>one.key === key);
108
+ return entry ? _extends({}, entry.impl, {
109
+ pluginId: entry.pluginId
110
+ }) : null;
111
+ }
112
+ /**
113
+ * What one address says about itself, or `null` when nothing does.
114
+ *
115
+ * ⚠️ A DECLARATION THAT THROWS COSTS THE PAGE NOTHING. The head is an
116
+ * enrichment of a page that renders without it, and a layout's metadata
117
+ * rejecting fails the route — so the failure is logged and the shell falls
118
+ * back to the title it built itself.
119
+ *
120
+ * ⛔ It asks the registry as it stands. A caller that has not loaded the
121
+ * plugins' server surfaces gets `null` for every address, which is the same
122
+ * answer as a route nobody declared — see the module docblock.
123
+ */ export async function resolvePluginRouteHead(route, segments) {
124
+ const declared = pluginRouteMetadata(route);
125
+ if (!declared) return null;
126
+ try {
127
+ var _ref;
128
+ return (_ref = await declared.resolve(segments)) != null ? _ref : null;
129
+ } catch (error) {
130
+ console.error(`[route-metadata] "${declared.pluginId}" failed to answer for ` + `${route}/${segments.join('/')}`, error);
131
+ return null;
132
+ }
133
+ }
134
+ /** Every declared route, with its plugin, in registration order. */ export function listPluginRouteMetadata() {
135
+ return resolvePluginServices(PLUGIN_ROUTE_METADATA).map((entry)=>_extends({}, entry.impl, {
136
+ pluginId: entry.pluginId
137
+ }));
138
+ }
139
+
140
+ //# sourceMappingURL=plugin-route-metadata.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-route-metadata.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * What a plugin's page says about itself in the `<head>` (AGL-3080).\n *\n * A console page that a plugin draws has its head built by the SHELL: the\n * page is a client component and cannot export metadata, so the nearest\n * server layout does it, and for every plugin route that layout is the\n * generic one. It can title a page from the slugs in the URL and nothing\n * more — which is correct for a hub and wrong for a record. A marketplace\n * listing shared into Slack unfurled as the same generic console card as\n * every other listing until the app grew a hand-written route with a layout\n * of its own, and that route is one of the ~20 that cannot move onto the\n * generic one while this is the only way to have a card.\n *\n * So the plugin declares how to answer for an address under its own route,\n * and the shell asks.\n *\n * ## The head is not a framework's\n *\n * A declaration answers a {@link PluginRouteHead} — a title, a description,\n * an image — and never Next's `Metadata`. Core does not depend on a web\n * framework and a plugin must not have to; more usefully, how a title,\n * description and image become `openGraph` and `twitter` tags is the SHELL's\n * knowledge and the same for every route: which card size degrades without\n * an image, that defining `openGraph` replaces the root layout's wholesale\n * so the site name has to be restated. A plugin that wrote those tags itself\n * would get a different answer per plugin, and the two that got it wrong\n * would look exactly like the two that got it right until somebody shared a\n * link.\n *\n * ## Absent is a real answer, and so is silence\n *\n * `resolve` answers `null` for an address it has nothing to add about — a\n * hub, a tab, a record it must not describe — and the shell keeps the title\n * it would have built. That is the same answer as a plugin that declares\n * nothing at all, deliberately: a head is an enrichment, and a route with no\n * declaration is the ordinary case rather than a degraded one.\n *\n * ⛔ WHICH MAKES THE LOADING ORDER THE WHOLE RISK. This is a runtime\n * registry, and an unfilled one answers \"nothing to add\" for every address —\n * silently, and indistinguishably from the honest answer. A layout that asked\n * before loading the plugin's server surface would drop every card it was\n * built to produce and stay green. A registry is nonetheless the right shape\n * here, and a compiled table is not: the answer depends on the DOCUMENT\n * behind the address, so it cannot be compiled, and the reader is a server\n * layout that already awaits — so it can load the surface first, which is\n * what `pluginRouteHead` in the console does and what the app's\n * `*-is-registered` spec holds it to.\n *\n * ## One route, one owner\n *\n * A route belongs to the plugin that serves it, so a second plugin's\n * declaration for the same route is refused naming both and the incumbent\n * keeps answering. The same plugin declaring again — a hot reload, a second\n * surface — replaces its own.\n *\n * Server-side: reached by its own subpath, never through\n * `plugin-manager/index.ts`. What a published page does not need, it must\n * not import.\n */\n\nimport type { ResolvedSocialImage } from '../app-utils/social-image'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/** What one address under a plugin's route says about itself. */\nexport interface PluginRouteHead {\n /**\n * The page's own title, before the shell's scope and brand are affixed.\n * Absent leaves the title the shell built from the URL.\n */\n title?: string\n /** One or two sentences. Already cut to whatever budget the owner keeps. */\n description?: string\n /**\n * The card image, already resolved and absolute —\n * `resolveSocialImage` answers exactly this shape. A crawler reads it out\n * of band, so a relative URL is not a card, it is a broken one.\n */\n image?: ResolvedSocialImage\n}\n\nexport interface PluginRouteMetadataDeclaration {\n /**\n * The plugin's own console route, as its `contributes.console.routes`\n * spells it and without the leading slash: `marketplace`.\n */\n route: string\n /**\n * Answers for one address under that route. The segments are what follows\n * the route itself — `['listing-1']` for `/acme/marketplace/listing-1` —\n * so a declaration reads the address it understands and answers `null` for\n * every other, including the route's own hub.\n *\n * ⚠️ IT RUNS ON THE RENDER PATH and must never throw: an unhandled\n * rejection in a layout's metadata fails the whole route, and this route's\n * job is to serve its page whether or not the card resolves. The shell\n * catches anyway, because a plugin's mistake must not cost a page.\n */\n resolve(segments: readonly string[]): Promise<PluginRouteHead | null>\n}\n\n/** A declaration with the plugin that made it. */\nexport type ResolvedPluginRouteMetadata = PluginRouteMetadataDeclaration & {\n pluginId: string\n}\n\nexport const PLUGIN_ROUTE_METADATA =\n definePluginServiceContract<PluginRouteMetadataDeclaration>(\n 'core.route-metadata',\n { multiple: true },\n )\n\n/**\n * Declares how a plugin answers for addresses under one of its routes. The\n * owner is the loader's marker when a register fn is running, else\n * `options.pluginId`.\n */\nexport function registerPluginRouteMetadata(\n declaration: PluginRouteMetadataDeclaration,\n options?: { pluginId?: string },\n): void {\n const key = declaration.route?.trim().replace(/^\\/+/, '') ?? ''\n if (!key) throw new Error('a plugin route metadata declaration needs a route')\n if (typeof declaration.resolve !== 'function') {\n throw new Error(`route metadata for \"${key}\" needs a resolve function`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n const incumbent = resolvePluginServices(PLUGIN_ROUTE_METADATA).find(\n (entry) => entry.key === key,\n )\n if (incumbent && pluginId && incumbent.pluginId !== pluginId) {\n throw new Error(\n `route metadata for \"${key}\" is already declared by ` +\n `\"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(\n PLUGIN_ROUTE_METADATA,\n { ...declaration, route: key },\n {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n key,\n },\n )\n}\n\n/** The declaration for one route, or `null` when nobody declared one. */\nexport function pluginRouteMetadata(\n route: string,\n): ResolvedPluginRouteMetadata | null {\n const key = String(route ?? '')\n .trim()\n .replace(/^\\/+/, '')\n const entry = resolvePluginServices(PLUGIN_ROUTE_METADATA).find(\n (one) => one.key === key,\n )\n return entry ? { ...entry.impl, pluginId: entry.pluginId } : null\n}\n\n/**\n * What one address says about itself, or `null` when nothing does.\n *\n * ⚠️ A DECLARATION THAT THROWS COSTS THE PAGE NOTHING. The head is an\n * enrichment of a page that renders without it, and a layout's metadata\n * rejecting fails the route — so the failure is logged and the shell falls\n * back to the title it built itself.\n *\n * ⛔ It asks the registry as it stands. A caller that has not loaded the\n * plugins' server surfaces gets `null` for every address, which is the same\n * answer as a route nobody declared — see the module docblock.\n */\nexport async function resolvePluginRouteHead(\n route: string,\n segments: readonly string[],\n): Promise<PluginRouteHead | null> {\n const declared = pluginRouteMetadata(route)\n if (!declared) return null\n try {\n return (await declared.resolve(segments)) ?? null\n } catch (error) {\n console.error(\n `[route-metadata] \"${declared.pluginId}\" failed to answer for ` +\n `${route}/${segments.join('/')}`,\n error,\n )\n return null\n }\n}\n\n/** Every declared route, with its plugin, in registration order. */\nexport function listPluginRouteMetadata(): ResolvedPluginRouteMetadata[] {\n return resolvePluginServices(PLUGIN_ROUTE_METADATA).map((entry) => ({\n ...entry.impl,\n pluginId: entry.pluginId,\n }))\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_ROUTE_METADATA","multiple","registerPluginRouteMetadata","declaration","options","key","route","trim","replace","Error","resolve","pluginId","incumbent","find","entry","pluginRouteMetadata","String","one","impl","resolvePluginRouteHead","segments","declared","error","console","join","listPluginRouteMetadata","map"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0DC;AAGD,SAASA,sBAAsB,QAAQ,qCAAiC;AACxE,SACEC,2BAA2B,EAC3BC,qBAAqB,EACrBC,qBAAqB,QAChB,uBAAmB;AA4C1B,OAAO,MAAMC,wBACXH,4BACE,uBACA;IAAEI,UAAU;AAAK,GAClB;AAEH;;;;CAIC,GACD,OAAO,SAASC,4BACdC,WAA2C,EAC3CC,OAA+B;cAObR,OAAAA;QALNO;IAAZ,MAAME,eAAMF,qBAAAA,YAAYG,KAAK,qBAAjBH,mBAAmBI,IAAI,GAAGC,OAAO,CAAC,QAAQ,sBAAO;IAC7D,IAAI,CAACH,KAAK,MAAM,IAAII,MAAM;IAC1B,IAAI,OAAON,YAAYO,OAAO,KAAK,YAAY;QAC7C,MAAM,IAAID,MAAM,CAAC,oBAAoB,EAAEJ,IAAI,0BAA0B,CAAC;IACxE;IACA,MAAMM,WAAW,EAACf,SAAAA,0BAAAA,oCAAAA,0BAA4BQ,2BAAAA,QAASO,QAAQ,YAA7Cf,QAAiD,IAAIW,IAAI;IAC3E,MAAMK,YAAYb,sBAAsBC,uBAAuBa,IAAI,CACjE,CAACC,QAAUA,MAAMT,GAAG,KAAKA;IAE3B,IAAIO,aAAaD,YAAYC,UAAUD,QAAQ,KAAKA,UAAU;QAC5D,MAAM,IAAIF,MACR,CAAC,oBAAoB,EAAEJ,IAAI,yBAAyB,CAAC,GACnD,CAAC,CAAC,EAAEO,UAAUD,QAAQ,CAAC,YAAY,EAAEA,SAAS,CAAC,CAAC;IAEtD;IACAb,sBACEE,uBACA,aAAKG;QAAaG,OAAOD;QACzB,aACMD,CAAAA,2BAAAA,QAASO,QAAQ,IAAG;QAAEA,UAAUP,QAAQO,QAAQ;IAAC,IAAI,CAAC;QAC1DN;;AAGN;AAEA,uEAAuE,GACvE,OAAO,SAASU,oBACdT,KAAa;IAEb,MAAMD,MAAMW,OAAOV,gBAAAA,QAAS,IACzBC,IAAI,GACJC,OAAO,CAAC,QAAQ;IACnB,MAAMM,QAAQf,sBAAsBC,uBAAuBa,IAAI,CAC7D,CAACI,MAAQA,IAAIZ,GAAG,KAAKA;IAEvB,OAAOS,QAAQ,aAAKA,MAAMI,IAAI;QAAEP,UAAUG,MAAMH,QAAQ;SAAK;AAC/D;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,eAAeQ,uBACpBb,KAAa,EACbc,QAA2B;IAE3B,MAAMC,WAAWN,oBAAoBT;IACrC,IAAI,CAACe,UAAU,OAAO;IACtB,IAAI;YACM;QAAR,QAAQ,OAAA,MAAMA,SAASX,OAAO,CAACU,qBAAvB,OAAqC;IAC/C,EAAE,OAAOE,OAAO;QACdC,QAAQD,KAAK,CACX,CAAC,kBAAkB,EAAED,SAASV,QAAQ,CAAC,uBAAuB,CAAC,GAC7D,GAAGL,MAAM,CAAC,EAAEc,SAASI,IAAI,CAAC,MAAM,EAClCF;QAEF,OAAO;IACT;AACF;AAEA,kEAAkE,GAClE,OAAO,SAASG;IACd,OAAO1B,sBAAsBC,uBAAuB0B,GAAG,CAAC,CAACZ,QAAW,aAC/DA,MAAMI,IAAI;YACbP,UAAUG,MAAMH,QAAQ;;AAE5B"}