@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.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/_chunks/DiagramPage-F0OgJkQF.mjs +275 -0
- package/dist/_chunks/DiagramPage-LORj1QlF.js +1 -0
- package/dist/admin/index.js +1 -0
- package/dist/admin/index.mjs +43 -0
- package/dist/admin/src/api.d.ts +50 -0
- package/dist/admin/src/components/Canvas.d.ts +46 -0
- package/dist/admin/src/index.d.ts +11 -0
- package/dist/admin/src/pages/DiagramPage.d.ts +3 -0
- package/dist/admin/src/pluginId.d.ts +6 -0
- package/dist/server/index.js +35 -0
- package/dist/server/index.mjs +438 -0
- package/dist/server/src/config/index.d.ts +21 -0
- package/dist/server/src/controllers/diagram.d.ts +27 -0
- package/dist/server/src/controllers/index.d.ts +11 -0
- package/dist/server/src/index.d.ts +54 -0
- package/dist/server/src/policies/index.d.ts +4 -0
- package/dist/server/src/register.d.ts +12 -0
- package/dist/server/src/routes/admin.d.ts +12 -0
- package/dist/server/src/routes/index.d.ts +14 -0
- package/dist/server/src/services/graph.d.ts +69 -0
- package/dist/server/src/services/index.d.ts +15 -0
- package/dist/server/src/services/layout.d.ts +42 -0
- package/dist/server/src/services/svg.d.ts +23 -0
- package/dist/server/src/types.d.ts +83 -0
- package/dist/server/src/uids.d.ts +7 -0
- package/package.json +121 -0
|
@@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'"),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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"), 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,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;
|