@qkix/strapi-plugin-blueprint 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ "use strict";const E=require("@dagrejs/dagre"),W=e=>e&&e.__esModule?e:{default:e},v=W(E),z={default:{exclude:[]},validator(e){if(e.exclude!==void 0&&!Array.isArray(e.exclude))throw new Error("[blueprint] `exclude` must be an array of content-type uids.")}},c={headerHeight:30,rowHeight:18,paddingX:12,charWidth:6.6,minWidth:150,maxWidth:320,typeGutter:74},f=12,A=e=>e.fields.length<=f?{fields:e.fields,hidden:0}:{fields:e.fields.slice(0,f),hidden:e.fields.length-f},F=e=>{const{fields:t,hidden:n}=A(e),i=Math.max(e.label.length+4,...t.map(l=>l.name.length+2),0),s=Math.min(c.maxWidth,Math.max(c.minWidth,i*c.charWidth+c.paddingX*2+c.typeGutter));return{width:Math.round(s),height:c.headerHeight+(t.length+(n>0?1:0))*c.rowHeight+8}},w=(e,t="TB")=>{const n=new v.default.graphlib.Graph({multigraph:!0});n.setGraph({rankdir:t,nodesep:40,ranksep:70,marginx:20,marginy:20}),n.setDefaultEdgeLabel(()=>({}));const i=new Map;for(const a of e.nodes){const r=F(a);i.set(a.uid,r),n.setNode(a.uid,{...r})}e.edges.forEach((a,r)=>{n.setEdge(a.from,a.to,{},`e${r}`)}),v.default.layout(n);const s=e.nodes.map(a=>{const r=n.node(a.uid),o=i.get(a.uid);return{...a,x:Math.round(r.x-o.width/2),y:Math.round(r.y-o.height/2),width:o.width,height:o.height}}),l=e.edges.map((a,r)=>{const o=n.edge(a.from,a.to,`e${r}`);return{...a,points:(o?.points??[]).map(x=>({x:Math.round(x.x),y:Math.round(x.y)}))}}),d=n.graph();return{nodes:s,edges:l,width:Math.ceil(d.width??0),height:Math.ceil(d.height??0)}},p=e=>e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;"),b=(e,t)=>{const n=Math.floor(t/c.charWidth);return e.length<=n?e:`${e.slice(0,Math.max(1,n-1))}…`},H=`
2
+ .bp-surface { fill: var(--bp-surface, #ffffff); }
3
+ .bp-box { fill: var(--bp-box, #ffffff); stroke: var(--bp-border, #dcdce4); }
4
+ .bp-box-component { fill: var(--bp-box-component, #f6f6f9); stroke: var(--bp-border, #dcdce4); }
5
+ /* A saturated stripe rather than a pale filled header: the 100-tones that
6
+ look right on a white page are almost black in a dark theme, and the three
7
+ kinds of box stop being told apart. These read on either. */
8
+ .bp-accent-collectionType { fill: var(--bp-accent, #4945ff); }
9
+ .bp-accent-component { fill: var(--bp-accent-component, #2f6846); }
10
+ .bp-accent-singleType { fill: var(--bp-accent-single, #d9822f); }
11
+ .bp-divider { stroke: var(--bp-border, #dcdce4); }
12
+ .bp-title { fill: var(--bp-title, #32324d); font-weight: 600; }
13
+ .bp-field { fill: var(--bp-field, #32324d); }
14
+ .bp-type { fill: var(--bp-type, #8e8ea9); }
15
+ .bp-edge { stroke: var(--bp-edge, #8e8ea9); fill: none; }
16
+ .bp-edge-component { stroke-dasharray: 5 3; }
17
+ .bp-edge-dynamiczone { stroke-dasharray: 2 3; }
18
+ text { font-family: var(--bp-font, ui-sans-serif, system-ui, sans-serif); font-size: 11px; }
19
+ .bp-title-text { font-size: 12px; }
20
+ /* Every box is a link to its content type, so it says so on hover. Written
21
+ into the drawing rather than added by the admin panel because the hit area
22
+ is the box, and only the drawing knows where that is. Harmless in a
23
+ downloaded file, where nothing is hovering anything. */
24
+ /* Only boxes that lead somewhere invite a click. A plugin's internal tables
25
+ are on the diagram because they are part of the schema, but the Content-Type
26
+ Builder has no page for them, so they get no pointer and no highlight. */
27
+ .bp-nodes > g[data-navigable="true"] { cursor: pointer; }
28
+ .bp-nodes > g[data-navigable="true"]:hover .bp-box,
29
+ .bp-nodes > g[data-navigable="true"]:hover .bp-box-component { fill: var(--bp-hover, #f0f0ff); }
30
+ /* The outline picks up the box's own stripe colour, so hovering a component
31
+ does not turn it the colour of a content type. */
32
+ .bp-nodes > g[data-navigable="true"][data-kind="collectionType"]:hover .bp-box { stroke: var(--bp-accent, #4945ff); }
33
+ .bp-nodes > g[data-navigable="true"][data-kind="singleType"]:hover .bp-box { stroke: var(--bp-accent-single, #d9822f); }
34
+ .bp-nodes > g[data-navigable="true"][data-kind="component"]:hover .bp-box-component { stroke: var(--bp-accent-component, #2f6846); }
35
+ `,L=e=>`bp-accent-${e.kind}`,X=e=>{const{fields:t,hidden:n}=A(e),i=n>0?[`<text class="bp-type" x="${e.x+c.paddingX}" `,`y="${e.y+c.headerHeight+t.length*c.rowHeight+13}">`,`+${n} more`,"</text>"].join(""):"",s=t.map((l,d)=>{const a=e.y+c.headerHeight+d*c.rowHeight+13,r=e.width-c.paddingX*2-c.typeGutter,o=l.relation??l.type;return[`<text class="bp-field" x="${e.x+c.paddingX}" y="${a}">`,p(b(l.name,r)),l.required?'<tspan class="bp-type"> *</tspan>':"","</text>",`<text class="bp-type" x="${e.x+e.width-c.paddingX}" y="${a}" text-anchor="end">`,p(b(o,c.typeGutter)),"</text>"].join("")}).join("");return[`<g data-uid="${p(e.uid)}" data-kind="${e.kind}" data-navigable="${e.navigable}">`,`<rect class="${e.kind==="component"?"bp-box-component":"bp-box"}" `,`x="${e.x}" y="${e.y}" width="${e.width}" height="${e.height}" rx="4" />`,`<path class="${L(e)}" d="M${e.x},${e.y+4} a4,4 0 0 1 4,-4 h${e.width-8} a4,4 0 0 1 4,4 v2 h-${e.width} z" />`,`<line class="bp-divider" x1="${e.x}" y1="${e.y+c.headerHeight}" x2="${e.x+e.width}" y2="${e.y+c.headerHeight}" />`,`<text class="bp-title bp-title-text" x="${e.x+c.paddingX}" y="${e.y+21}">`,p(b(e.label,e.width-c.paddingX*2)),"</text>",s,i,"</g>"].join("")},D=e=>{if(e.length<3)return e.map((i,s)=>`${s===0?"M":"L"}${i.x},${i.y}`).join(" ");const t=[`M${e[0].x},${e[0].y}`];for(let i=1;i<e.length-1;i+=1){const s=e[i],l=e[i+1],d=Math.round((s.x+l.x)/2),a=Math.round((s.y+l.y)/2);t.push(`Q${s.x},${s.y} ${d},${a}`)}const n=e[e.length-1];return t.push(`L${n.x},${n.y}`),t.join(" ")},G=e=>{if(e.points.length===0)return"";const t=D(e.points);return[`<path class="bp-edge bp-edge-${e.kind}" d="${t}" `,`marker-end="url(#bp-arrow)" data-field="${p(e.field)}" />`].join("")},$=e=>{const t=Math.max(e.width,1),n=Math.max(e.height,1);return[`<svg xmlns="http://www.w3.org/2000/svg" width="${t}" height="${n}" `,`viewBox="0 0 ${t} ${n}" role="img" aria-label="Content type diagram">`,`<style>${H}</style>`,"<defs>",'<marker id="bp-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">','<path d="M0,0 L10,5 L0,10 z" fill="var(--bp-edge, #8e8ea9)" />',"</marker>","</defs>",`<rect class="bp-surface" x="0" y="0" width="${t}" height="${n}" />`,`<g class="bp-edges">${e.edges.map(G).join("")}</g>`,`<g class="bp-nodes">${e.nodes.map(X).join("")}</g>`,"</svg>"].join("")},u="blueprint",h=e=>e.plugin(u).service("graph"),m=(e,t)=>e===void 0||e===""?t:e!=="false"&&e!=="0"&&e!==!1,k=e=>{const t=Array.isArray(e)?e:typeof e=="string"?e.split(","):[];return[...new Set(t.map(n=>String(n).trim()).filter(Boolean))]},y=(e,t={})=>({includeForeign:m(t.foreign,!1),includeComponents:m(t.components,!0),includeDefaultFields:m(t.defaultFields,!1),exclude:[...k(t.exclude),...k(e.config.get(`plugin::${u}.exclude`,[]))]}),T=(e={})=>e.direction==="LR"?"LR":"TB",S=({strapi:e})=>({async svg(t){const n=h(e).build(y(e,t.query)),i=w(n,T(t.query));t.body={svg:$(i),width:i.width,height:i.height,counts:{nodes:n.nodes.length,edges:n.edges.length,components:n.nodes.filter(s=>s.kind==="component").length}}},async download(t){const n=h(e).build(y(e,t.query)),i=w(n,T(t.query));t.set("Content-Type","image/svg+xml; charset=utf-8"),t.set("Content-Disposition",'attachment; filename="blueprint.svg"'),t.body=$(i)},async graph(t){t.body=h(e).build(y(e,t.query))},async catalogue(t){t.body={contentTypes:h(e).catalogue()}}}),B={diagram:S},C=e=>t=>{const n=t.state?.userAbility;return n?n.can(`plugin::${u}.${e}`):!1},_={canRead:C("read")},q=({strapi:e})=>{const t=[{section:"plugins",displayName:"Access the schema diagram",uid:"read",pluginName:u}];e.admin?.services?.permission?.actionProvider?.registerMany?.(t)},g=["plugin::blueprint.canRead"],N={type:"admin",routes:[{method:"GET",path:"/diagram",handler:"diagram.svg",config:{policies:g}},{method:"GET",path:"/diagram.svg",handler:"diagram.download",config:{policies:g}},{method:"GET",path:"/graph",handler:"diagram.graph",config:{policies:g}},{method:"GET",path:"/content-types",handler:"diagram.catalogue",config:{policies:g}}]},R={admin:N},M=new Set(["createdAt","updatedAt","publishedAt","createdBy","updatedBy","locale","localizations"]),P={includeForeign:!1,includeComponents:!0,includeDefaultFields:!1,exclude:[]},I=e=>e.modelType==="component"||e.pluginOptions?.["content-type-builder"]?.visible!==!1,U=e=>{if(e.startsWith("api::"))return"api";const[t]=e.split(".");return t??e},O=e=>e.startsWith("api::"),j=e=>e.info?.displayName??e.info?.singularName??e.uid,Y=({strapi:e})=>{const t={build(n={}){const i={...P,...n},s=new Set(i.exclude),l=Object.values(e.contentTypes),d=Object.values(e.components??{}),a=new Map;for(const o of l)s.has(o.uid)||!i.includeForeign&&!O(o.uid)||a.set(o.uid,o);if(i.includeComponents)for(const o of d)s.has(o.uid)||a.set(o.uid,o);return{nodes:[...a.values()].map(o=>({uid:o.uid,kind:o.modelType==="component"?"component":o.kind==="singleType"?"singleType":"collectionType",label:j(o),group:o.modelType==="component"?o.category??"components":U(o.uid),foreign:o.modelType==="component"?!1:!O(o.uid),navigable:I(o),fields:t.fieldsOf(o,i)})),edges:t.edgesOf(a,i)}},fieldsOf(n,i){const s=[];for(const[l,d]of Object.entries(n.attributes??{}))!i.includeDefaultFields&&M.has(l)||s.push({name:l,type:d.type,...d.relation?{relation:d.relation}:{},...d.required?{required:!0}:{}});return s},edgesOf(n,i){const s=[],l=d=>{n.has(d.to)&&s.push(d)};for(const d of n.values())for(const[a,r]of Object.entries(d.attributes??{}))if(!(!i.includeDefaultFields&&M.has(a))){if(r.type==="relation"&&r.target){if(r.mappedBy)continue;l({from:d.uid,to:r.target,kind:"relation",field:a,cardinality:r.relation,many:(r.relation??"").endsWith("Many")});continue}if(r.type==="component"&&r.component){l({from:d.uid,to:r.component,kind:"component",field:a,many:r.repeatable===!0});continue}if(r.type==="dynamiczone")for(const o of r.components??[])l({from:d.uid,to:o,kind:"dynamiczone",field:a,many:!0})}return s},catalogue(){const n=Object.values(e.contentTypes),i=Object.values(e.components??{});return[...n,...i].map(s=>({uid:s.uid,label:j(s),kind:s.modelType==="component"?"component":s.kind??"collectionType"}))}};return t},Q={graph:Y},J={register:q,config:z,controllers:B,policies:_,routes:R,services:Q};module.exports=J;
@@ -0,0 +1,438 @@
1
+ import v from "@dagrejs/dagre";
2
+ const E = {
3
+ default: {
4
+ /**
5
+ * Uids never to draw, whatever the UI asks for.
6
+ *
7
+ * For schemas with a corner nobody wants on the picture — a bulky
8
+ * integration type, a legacy import table. The UI can hide things too; this
9
+ * is the version that survives a page reload and applies to the API as well.
10
+ *
11
+ * blueprint: {
12
+ * enabled: true,
13
+ * config: { exclude: ['api::import-log.import-log'] },
14
+ * }
15
+ */
16
+ exclude: []
17
+ },
18
+ validator(e) {
19
+ if (e.exclude !== void 0 && !Array.isArray(e.exclude))
20
+ throw new Error("[blueprint] `exclude` must be an array of content-type uids.");
21
+ }
22
+ }, c = {
23
+ headerHeight: 30,
24
+ rowHeight: 18,
25
+ paddingX: 12,
26
+ /** Rough width of one character at the font size the renderer uses. */
27
+ charWidth: 6.6,
28
+ minWidth: 150,
29
+ maxWidth: 320,
30
+ /** Room for the type, drawn right-aligned on the same row as the name. */
31
+ typeGutter: 74
32
+ }, f = 12, A = (e) => e.fields.length <= f ? { fields: e.fields, hidden: 0 } : {
33
+ fields: e.fields.slice(0, f),
34
+ hidden: e.fields.length - f
35
+ }, W = (e) => {
36
+ const { fields: t, hidden: n } = A(e), i = Math.max(
37
+ e.label.length + 4,
38
+ ...t.map((l) => l.name.length + 2),
39
+ 0
40
+ ), s = Math.min(
41
+ c.maxWidth,
42
+ Math.max(
43
+ c.minWidth,
44
+ i * c.charWidth + c.paddingX * 2 + c.typeGutter
45
+ )
46
+ );
47
+ return {
48
+ width: Math.round(s),
49
+ height: c.headerHeight + (t.length + (n > 0 ? 1 : 0)) * c.rowHeight + 8
50
+ };
51
+ }, w = (e, t = "TB") => {
52
+ const n = new v.graphlib.Graph({ multigraph: !0 });
53
+ n.setGraph({
54
+ rankdir: t,
55
+ nodesep: 40,
56
+ ranksep: 70,
57
+ marginx: 20,
58
+ marginy: 20
59
+ }), n.setDefaultEdgeLabel(() => ({}));
60
+ const i = /* @__PURE__ */ new Map();
61
+ for (const a of e.nodes) {
62
+ const r = W(a);
63
+ i.set(a.uid, r), n.setNode(a.uid, { ...r });
64
+ }
65
+ e.edges.forEach((a, r) => {
66
+ n.setEdge(a.from, a.to, {}, `e${r}`);
67
+ }), v.layout(n);
68
+ const s = e.nodes.map((a) => {
69
+ const r = n.node(a.uid), o = i.get(a.uid);
70
+ return {
71
+ ...a,
72
+ // dagre reports centres; everything downstream wants a top-left corner.
73
+ x: Math.round(r.x - o.width / 2),
74
+ y: Math.round(r.y - o.height / 2),
75
+ width: o.width,
76
+ height: o.height
77
+ };
78
+ }), l = e.edges.map((a, r) => {
79
+ const o = n.edge(a.from, a.to, `e${r}`);
80
+ return {
81
+ ...a,
82
+ points: (o?.points ?? []).map((x) => ({
83
+ x: Math.round(x.x),
84
+ y: Math.round(x.y)
85
+ }))
86
+ };
87
+ }), d = n.graph();
88
+ return {
89
+ nodes: s,
90
+ edges: l,
91
+ width: Math.ceil(d.width ?? 0),
92
+ height: Math.ceil(d.height ?? 0)
93
+ };
94
+ }, p = (e) => e.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;"), b = (e, t) => {
95
+ const n = Math.floor(t / c.charWidth);
96
+ return e.length <= n ? e : `${e.slice(0, Math.max(1, n - 1))}…`;
97
+ }, z = `
98
+ .bp-surface { fill: var(--bp-surface, #ffffff); }
99
+ .bp-box { fill: var(--bp-box, #ffffff); stroke: var(--bp-border, #dcdce4); }
100
+ .bp-box-component { fill: var(--bp-box-component, #f6f6f9); stroke: var(--bp-border, #dcdce4); }
101
+ /* A saturated stripe rather than a pale filled header: the 100-tones that
102
+ look right on a white page are almost black in a dark theme, and the three
103
+ kinds of box stop being told apart. These read on either. */
104
+ .bp-accent-collectionType { fill: var(--bp-accent, #4945ff); }
105
+ .bp-accent-component { fill: var(--bp-accent-component, #2f6846); }
106
+ .bp-accent-singleType { fill: var(--bp-accent-single, #d9822f); }
107
+ .bp-divider { stroke: var(--bp-border, #dcdce4); }
108
+ .bp-title { fill: var(--bp-title, #32324d); font-weight: 600; }
109
+ .bp-field { fill: var(--bp-field, #32324d); }
110
+ .bp-type { fill: var(--bp-type, #8e8ea9); }
111
+ .bp-edge { stroke: var(--bp-edge, #8e8ea9); fill: none; }
112
+ .bp-edge-component { stroke-dasharray: 5 3; }
113
+ .bp-edge-dynamiczone { stroke-dasharray: 2 3; }
114
+ text { font-family: var(--bp-font, ui-sans-serif, system-ui, sans-serif); font-size: 11px; }
115
+ .bp-title-text { font-size: 12px; }
116
+ /* Every box is a link to its content type, so it says so on hover. Written
117
+ into the drawing rather than added by the admin panel because the hit area
118
+ is the box, and only the drawing knows where that is. Harmless in a
119
+ downloaded file, where nothing is hovering anything. */
120
+ /* Only boxes that lead somewhere invite a click. A plugin's internal tables
121
+ are on the diagram because they are part of the schema, but the Content-Type
122
+ Builder has no page for them, so they get no pointer and no highlight. */
123
+ .bp-nodes > g[data-navigable="true"] { cursor: pointer; }
124
+ .bp-nodes > g[data-navigable="true"]:hover .bp-box,
125
+ .bp-nodes > g[data-navigable="true"]:hover .bp-box-component { fill: var(--bp-hover, #f0f0ff); }
126
+ /* The outline picks up the box's own stripe colour, so hovering a component
127
+ does not turn it the colour of a content type. */
128
+ .bp-nodes > g[data-navigable="true"][data-kind="collectionType"]:hover .bp-box { stroke: var(--bp-accent, #4945ff); }
129
+ .bp-nodes > g[data-navigable="true"][data-kind="singleType"]:hover .bp-box { stroke: var(--bp-accent-single, #d9822f); }
130
+ .bp-nodes > g[data-navigable="true"][data-kind="component"]:hover .bp-box-component { stroke: var(--bp-accent-component, #2f6846); }
131
+ `, F = (e) => `bp-accent-${e.kind}`, H = (e) => {
132
+ const { fields: t, hidden: n } = A(e), i = n > 0 ? [
133
+ `<text class="bp-type" x="${e.x + c.paddingX}" `,
134
+ `y="${e.y + c.headerHeight + t.length * c.rowHeight + 13}">`,
135
+ `+${n} more`,
136
+ "</text>"
137
+ ].join("") : "", s = t.map((l, d) => {
138
+ const a = e.y + c.headerHeight + d * c.rowHeight + 13, r = e.width - c.paddingX * 2 - c.typeGutter, o = l.relation ?? l.type;
139
+ return [
140
+ `<text class="bp-field" x="${e.x + c.paddingX}" y="${a}">`,
141
+ p(b(l.name, r)),
142
+ l.required ? '<tspan class="bp-type"> *</tspan>' : "",
143
+ "</text>",
144
+ `<text class="bp-type" x="${e.x + e.width - c.paddingX}" y="${a}" text-anchor="end">`,
145
+ p(b(o, c.typeGutter)),
146
+ "</text>"
147
+ ].join("");
148
+ }).join("");
149
+ return [
150
+ `<g data-uid="${p(e.uid)}" data-kind="${e.kind}" data-navigable="${e.navigable}">`,
151
+ `<rect class="${e.kind === "component" ? "bp-box-component" : "bp-box"}" `,
152
+ `x="${e.x}" y="${e.y}" width="${e.width}" height="${e.height}" rx="4" />`,
153
+ `<path class="${F(e)}" d="M${e.x},${e.y + 4} a4,4 0 0 1 4,-4 h${e.width - 8} a4,4 0 0 1 4,4 v2 h-${e.width} z" />`,
154
+ `<line class="bp-divider" x1="${e.x}" y1="${e.y + c.headerHeight}" x2="${e.x + e.width}" y2="${e.y + c.headerHeight}" />`,
155
+ `<text class="bp-title bp-title-text" x="${e.x + c.paddingX}" y="${e.y + 21}">`,
156
+ p(b(e.label, e.width - c.paddingX * 2)),
157
+ "</text>",
158
+ s,
159
+ i,
160
+ "</g>"
161
+ ].join("");
162
+ }, L = (e) => {
163
+ if (e.length < 3)
164
+ return e.map((i, s) => `${s === 0 ? "M" : "L"}${i.x},${i.y}`).join(" ");
165
+ const t = [`M${e[0].x},${e[0].y}`];
166
+ for (let i = 1; i < e.length - 1; i += 1) {
167
+ const s = e[i], l = e[i + 1], d = Math.round((s.x + l.x) / 2), a = Math.round((s.y + l.y) / 2);
168
+ t.push(`Q${s.x},${s.y} ${d},${a}`);
169
+ }
170
+ const n = e[e.length - 1];
171
+ return t.push(`L${n.x},${n.y}`), t.join(" ");
172
+ }, X = (e) => {
173
+ if (e.points.length === 0) return "";
174
+ const t = L(e.points);
175
+ return [
176
+ `<path class="bp-edge bp-edge-${e.kind}" d="${t}" `,
177
+ `marker-end="url(#bp-arrow)" data-field="${p(e.field)}" />`
178
+ ].join("");
179
+ }, $ = (e) => {
180
+ const t = Math.max(e.width, 1), n = Math.max(e.height, 1);
181
+ return [
182
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${t}" height="${n}" `,
183
+ `viewBox="0 0 ${t} ${n}" role="img" aria-label="Content type diagram">`,
184
+ `<style>${z}</style>`,
185
+ "<defs>",
186
+ '<marker id="bp-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">',
187
+ '<path d="M0,0 L10,5 L0,10 z" fill="var(--bp-edge, #8e8ea9)" />',
188
+ "</marker>",
189
+ "</defs>",
190
+ `<rect class="bp-surface" x="0" y="0" width="${t}" height="${n}" />`,
191
+ // Edges first so a line never crosses over the box it points at.
192
+ `<g class="bp-edges">${e.edges.map(X).join("")}</g>`,
193
+ `<g class="bp-nodes">${e.nodes.map(H).join("")}</g>`,
194
+ "</svg>"
195
+ ].join("");
196
+ }, u = "blueprint", h = (e) => e.plugin(u).service("graph"), m = (e, t) => e === void 0 || e === "" ? t : e !== "false" && e !== "0" && e !== !1, k = (e) => {
197
+ const t = Array.isArray(e) ? e : typeof e == "string" ? e.split(",") : [];
198
+ return [...new Set(t.map((n) => String(n).trim()).filter(Boolean))];
199
+ }, y = (e, t = {}) => ({
200
+ includeForeign: m(t.foreign, !1),
201
+ includeComponents: m(t.components, !0),
202
+ includeDefaultFields: m(t.defaultFields, !1),
203
+ exclude: [
204
+ ...k(t.exclude),
205
+ ...k(e.config.get(`plugin::${u}.exclude`, []))
206
+ ]
207
+ }), T = (e = {}) => e.direction === "LR" ? "LR" : "TB", G = ({ strapi: e }) => ({
208
+ /**
209
+ * The diagram, already drawn.
210
+ *
211
+ * The admin panel receives finished SVG rather than a graph to render itself.
212
+ * That is the design: one renderer, on the server, so what you see in the
213
+ * panel and what you download are the same bytes, and so the diagram can be
214
+ * produced by anything that can make an HTTP request — a docs build, CI — and
215
+ * not only by a browser running the admin panel.
216
+ */
217
+ async svg(t) {
218
+ const n = h(e).build(y(e, t.query)), i = w(n, T(t.query));
219
+ t.body = {
220
+ svg: $(i),
221
+ width: i.width,
222
+ height: i.height,
223
+ counts: {
224
+ nodes: n.nodes.length,
225
+ edges: n.edges.length,
226
+ components: n.nodes.filter((s) => s.kind === "component").length
227
+ }
228
+ };
229
+ },
230
+ /**
231
+ * The same drawing as a file.
232
+ *
233
+ * Separate from `svg` because a download wants `image/svg+xml` and a
234
+ * filename, not JSON with a string in it.
235
+ */
236
+ async download(t) {
237
+ const n = h(e).build(y(e, t.query)), i = w(n, T(t.query));
238
+ t.set("Content-Type", "image/svg+xml; charset=utf-8"), t.set("Content-Disposition", 'attachment; filename="blueprint.svg"'), t.body = $(i);
239
+ },
240
+ /** The graph itself, for anyone who would rather draw it their own way. */
241
+ async graph(t) {
242
+ t.body = h(e).build(y(e, t.query));
243
+ },
244
+ /** Everything that could be hidden, so the UI can offer a list. */
245
+ async catalogue(t) {
246
+ t.body = { contentTypes: h(e).catalogue() };
247
+ }
248
+ }), S = {
249
+ diagram: G
250
+ }, D = (e) => (t) => {
251
+ const n = t.state?.userAbility;
252
+ return n ? n.can(`plugin::${u}.${e}`) : !1;
253
+ }, B = {
254
+ canRead: D("read")
255
+ }, C = ({ strapi: e }) => {
256
+ const t = [
257
+ {
258
+ section: "plugins",
259
+ displayName: "Access the schema diagram",
260
+ uid: "read",
261
+ pluginName: u
262
+ }
263
+ ];
264
+ e.admin?.services?.permission?.actionProvider?.registerMany?.(t);
265
+ }, g = ["plugin::blueprint.canRead"], N = {
266
+ type: "admin",
267
+ routes: [
268
+ {
269
+ method: "GET",
270
+ path: "/diagram",
271
+ handler: "diagram.svg",
272
+ config: { policies: g }
273
+ },
274
+ {
275
+ method: "GET",
276
+ path: "/diagram.svg",
277
+ handler: "diagram.download",
278
+ config: { policies: g }
279
+ },
280
+ {
281
+ method: "GET",
282
+ path: "/graph",
283
+ handler: "diagram.graph",
284
+ config: { policies: g }
285
+ },
286
+ {
287
+ method: "GET",
288
+ path: "/content-types",
289
+ handler: "diagram.catalogue",
290
+ config: { policies: g }
291
+ }
292
+ ]
293
+ }, q = {
294
+ admin: N
295
+ }, M = /* @__PURE__ */ new Set([
296
+ "createdAt",
297
+ "updatedAt",
298
+ "publishedAt",
299
+ "createdBy",
300
+ "updatedBy",
301
+ "locale",
302
+ "localizations"
303
+ ]), R = {
304
+ includeForeign: !1,
305
+ includeComponents: !0,
306
+ includeDefaultFields: !1,
307
+ exclude: []
308
+ }, P = (e) => e.modelType === "component" || e.pluginOptions?.["content-type-builder"]?.visible !== !1, I = (e) => {
309
+ if (e.startsWith("api::")) return "api";
310
+ const [t] = e.split(".");
311
+ return t ?? e;
312
+ }, O = (e) => e.startsWith("api::"), j = (e) => e.info?.displayName ?? e.info?.singularName ?? e.uid, U = ({ strapi: e }) => {
313
+ const t = {
314
+ /**
315
+ * The whole schema as a graph.
316
+ *
317
+ * Reads Strapi's own loaded models rather than the files on disk, so a
318
+ * content type a plugin adds at runtime is on the diagram too, and nothing
319
+ * has to be parsed.
320
+ */
321
+ build(n = {}) {
322
+ const i = { ...R, ...n }, s = new Set(i.exclude), l = Object.values(
323
+ e.contentTypes
324
+ ), d = Object.values(
325
+ e.components ?? {}
326
+ ), a = /* @__PURE__ */ new Map();
327
+ for (const o of l)
328
+ s.has(o.uid) || !i.includeForeign && !O(o.uid) || a.set(o.uid, o);
329
+ if (i.includeComponents)
330
+ for (const o of d)
331
+ s.has(o.uid) || a.set(o.uid, o);
332
+ return { nodes: [...a.values()].map((o) => ({
333
+ uid: o.uid,
334
+ kind: o.modelType === "component" ? "component" : o.kind === "singleType" ? "singleType" : "collectionType",
335
+ label: j(o),
336
+ group: o.modelType === "component" ? o.category ?? "components" : I(o.uid),
337
+ foreign: o.modelType === "component" ? !1 : !O(o.uid),
338
+ navigable: P(o),
339
+ fields: t.fieldsOf(o, i)
340
+ })), edges: t.edgesOf(a, i) };
341
+ },
342
+ fieldsOf(n, i) {
343
+ const s = [];
344
+ for (const [l, d] of Object.entries(n.attributes ?? {}))
345
+ !i.includeDefaultFields && M.has(l) || s.push({
346
+ name: l,
347
+ type: d.type,
348
+ ...d.relation ? { relation: d.relation } : {},
349
+ ...d.required ? { required: !0 } : {}
350
+ });
351
+ return s;
352
+ },
353
+ /**
354
+ * Edges, one per reason two boxes are joined.
355
+ *
356
+ * Both ends of a two-sided relation describe the same line. Strapi marks
357
+ * the non-owning end with `mappedBy`, so skipping those leaves exactly one
358
+ * edge — deduplicating by node pair instead would silently merge two
359
+ * genuinely different relations between the same two types.
360
+ *
361
+ * Edges to something not on the diagram are dropped rather than drawn into
362
+ * nothing: with `includeComponents` off, every component attribute would
363
+ * otherwise become a line to a box that is not there.
364
+ *
365
+ * A field that is not drawn does not get a line either, and that is not a
366
+ * detail. i18n adds a `localizations` relation **to every content type,
367
+ * pointing at itself**, so skipping it in the field list but not here put a
368
+ * self-loop on every single box on the diagram.
369
+ */
370
+ edgesOf(n, i) {
371
+ const s = [], l = (d) => {
372
+ n.has(d.to) && s.push(d);
373
+ };
374
+ for (const d of n.values())
375
+ for (const [a, r] of Object.entries(d.attributes ?? {}))
376
+ if (!(!i.includeDefaultFields && M.has(a))) {
377
+ if (r.type === "relation" && r.target) {
378
+ if (r.mappedBy) continue;
379
+ l({
380
+ from: d.uid,
381
+ to: r.target,
382
+ kind: "relation",
383
+ field: a,
384
+ cardinality: r.relation,
385
+ many: (r.relation ?? "").endsWith("Many")
386
+ });
387
+ continue;
388
+ }
389
+ if (r.type === "component" && r.component) {
390
+ l({
391
+ from: d.uid,
392
+ to: r.component,
393
+ kind: "component",
394
+ field: a,
395
+ many: r.repeatable === !0
396
+ });
397
+ continue;
398
+ }
399
+ if (r.type === "dynamiczone")
400
+ for (const o of r.components ?? [])
401
+ l({
402
+ from: d.uid,
403
+ to: o,
404
+ kind: "dynamiczone",
405
+ field: a,
406
+ many: !0
407
+ });
408
+ }
409
+ return s;
410
+ },
411
+ /** Everything that could be excluded, for the settings UI. */
412
+ catalogue() {
413
+ const n = Object.values(
414
+ e.contentTypes
415
+ ), i = Object.values(
416
+ e.components ?? {}
417
+ );
418
+ return [...n, ...i].map((s) => ({
419
+ uid: s.uid,
420
+ label: j(s),
421
+ kind: s.modelType === "component" ? "component" : s.kind ?? "collectionType"
422
+ }));
423
+ }
424
+ };
425
+ return t;
426
+ }, Y = {
427
+ graph: U
428
+ }, Q = {
429
+ register: C,
430
+ config: E,
431
+ controllers: S,
432
+ policies: B,
433
+ routes: q,
434
+ services: Y
435
+ };
436
+ export {
437
+ Q as default
438
+ };
@@ -0,0 +1,21 @@
1
+ declare const _default: {
2
+ default: {
3
+ /**
4
+ * Uids never to draw, whatever the UI asks for.
5
+ *
6
+ * For schemas with a corner nobody wants on the picture — a bulky
7
+ * integration type, a legacy import table. The UI can hide things too; this
8
+ * is the version that survives a page reload and applies to the API as well.
9
+ *
10
+ * blueprint: {
11
+ * enabled: true,
12
+ * config: { exclude: ['api::import-log.import-log'] },
13
+ * }
14
+ */
15
+ exclude: string[];
16
+ };
17
+ validator(config: {
18
+ exclude?: unknown;
19
+ }): void;
20
+ };
21
+ export default _default;
@@ -0,0 +1,27 @@
1
+ import type { Core } from '@strapi/strapi';
2
+ declare const controller: ({ strapi }: {
3
+ strapi: Core.Strapi;
4
+ }) => {
5
+ /**
6
+ * The diagram, already drawn.
7
+ *
8
+ * The admin panel receives finished SVG rather than a graph to render itself.
9
+ * That is the design: one renderer, on the server, so what you see in the
10
+ * panel and what you download are the same bytes, and so the diagram can be
11
+ * produced by anything that can make an HTTP request — a docs build, CI — and
12
+ * not only by a browser running the admin panel.
13
+ */
14
+ svg(ctx: any): Promise<void>;
15
+ /**
16
+ * The same drawing as a file.
17
+ *
18
+ * Separate from `svg` because a download wants `image/svg+xml` and a
19
+ * filename, not JSON with a string in it.
20
+ */
21
+ download(ctx: any): Promise<void>;
22
+ /** The graph itself, for anyone who would rather draw it their own way. */
23
+ graph(ctx: any): Promise<void>;
24
+ /** Everything that could be hidden, so the UI can offer a list. */
25
+ catalogue(ctx: any): Promise<void>;
26
+ };
27
+ export default controller;
@@ -0,0 +1,11 @@
1
+ declare const _default: {
2
+ diagram: ({ strapi }: {
3
+ strapi: import("@strapi/types/dist/core").Strapi;
4
+ }) => {
5
+ svg(ctx: any): Promise<void>;
6
+ download(ctx: any): Promise<void>;
7
+ graph(ctx: any): Promise<void>;
8
+ catalogue(ctx: any): Promise<void>;
9
+ };
10
+ };
11
+ export default _default;
@@ -0,0 +1,54 @@
1
+ declare const _default: {
2
+ register: ({ strapi }: {
3
+ strapi: import("@strapi/types/dist/core").Strapi;
4
+ }) => void;
5
+ config: {
6
+ default: {
7
+ exclude: string[];
8
+ };
9
+ validator(config: {
10
+ exclude?: unknown;
11
+ }): void;
12
+ };
13
+ controllers: {
14
+ diagram: ({ strapi }: {
15
+ strapi: import("@strapi/types/dist/core").Strapi;
16
+ }) => {
17
+ svg(ctx: any): Promise<void>;
18
+ download(ctx: any): Promise<void>;
19
+ graph(ctx: any): Promise<void>;
20
+ catalogue(ctx: any): Promise<void>;
21
+ };
22
+ };
23
+ policies: {
24
+ canRead: (policyContext: any) => any;
25
+ };
26
+ routes: {
27
+ admin: {
28
+ type: string;
29
+ routes: {
30
+ method: string;
31
+ path: string;
32
+ handler: string;
33
+ config: {
34
+ policies: string[];
35
+ };
36
+ }[];
37
+ };
38
+ };
39
+ services: {
40
+ graph: ({ strapi }: {
41
+ strapi: import("@strapi/types/dist/core").Strapi;
42
+ }) => {
43
+ build(options?: Partial<import("./types").GraphOptions>): import("./types").Graph;
44
+ fieldsOf(model: import("./services/graph").RawModel, opts: import("./types").GraphOptions): import("./types").Field[];
45
+ edgesOf(present: Map<string, import("./services/graph").RawModel>, opts: import("./types").GraphOptions): import("./types").Edge[];
46
+ catalogue(): {
47
+ uid: string;
48
+ label: string;
49
+ kind: string;
50
+ }[];
51
+ };
52
+ };
53
+ };
54
+ export default _default;
@@ -0,0 +1,4 @@
1
+ declare const _default: {
2
+ canRead: (policyContext: any) => any;
3
+ };
4
+ export default _default;
@@ -0,0 +1,12 @@
1
+ import type { Core } from '@strapi/strapi';
2
+ /**
3
+ * One permission: who may see the diagram.
4
+ *
5
+ * There is nothing to configure and nothing to write, so there is nothing else
6
+ * to guard. The schema this draws is already visible to anyone who can open the
7
+ * Content-Type Builder.
8
+ */
9
+ declare const register: ({ strapi }: {
10
+ strapi: Core.Strapi;
11
+ }) => void;
12
+ export default register;
@@ -0,0 +1,12 @@
1
+ declare const _default: {
2
+ type: string;
3
+ routes: {
4
+ method: string;
5
+ path: string;
6
+ handler: string;
7
+ config: {
8
+ policies: string[];
9
+ };
10
+ }[];
11
+ };
12
+ export default _default;
@@ -0,0 +1,14 @@
1
+ declare const _default: {
2
+ admin: {
3
+ type: string;
4
+ routes: {
5
+ method: string;
6
+ path: string;
7
+ handler: string;
8
+ config: {
9
+ policies: string[];
10
+ };
11
+ }[];
12
+ };
13
+ };
14
+ export default _default;