@viglet/create-shio-app 2026.3.2 → 2026.3.3
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 +201 -201
- package/README.md +3 -3
- package/package.json +4 -4
- package/src/emit.mjs +292 -292
- package/src/published.mjs +15 -6
- package/template/README.md +3 -2
- package/template/app/layout.tsx +1 -1
- package/template/gitignore +6 -0
- package/template/lib/redirects.ts +106 -0
- package/template/middleware.ts +31 -0
package/LICENSE
CHANGED
|
@@ -1,201 +1,201 @@
|
|
|
1
|
-
Apache License
|
|
2
|
-
Version 2.0, January 2004
|
|
3
|
-
http://www.apache.org/licenses/
|
|
4
|
-
|
|
5
|
-
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
-
|
|
7
|
-
1. Definitions.
|
|
8
|
-
|
|
9
|
-
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
-
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
-
|
|
12
|
-
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
-
the copyright owner that is granting the License.
|
|
14
|
-
|
|
15
|
-
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
-
other entities that control, are controlled by, or are under common
|
|
17
|
-
control with that entity. For the purposes of this definition,
|
|
18
|
-
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
-
direction or management of such entity, whether by contract or
|
|
20
|
-
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
-
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
-
|
|
23
|
-
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
-
exercising permissions granted by this License.
|
|
25
|
-
|
|
26
|
-
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
-
including but not limited to software source code, documentation
|
|
28
|
-
source, and configuration files.
|
|
29
|
-
|
|
30
|
-
"Object" form shall mean any form resulting from mechanical
|
|
31
|
-
transformation or translation of a Source form, including but
|
|
32
|
-
not limited to compiled object code, generated documentation,
|
|
33
|
-
and conversions to other media types.
|
|
34
|
-
|
|
35
|
-
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
-
Object form, made available under the License, as indicated by a
|
|
37
|
-
copyright notice that is included in or attached to the work
|
|
38
|
-
(an example is provided in the Appendix below).
|
|
39
|
-
|
|
40
|
-
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
-
form, that is based on (or derived from) the Work and for which the
|
|
42
|
-
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
-
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
-
of this License, Derivative Works shall not include works that remain
|
|
45
|
-
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
-
the Work and Derivative Works thereof.
|
|
47
|
-
|
|
48
|
-
"Contribution" shall mean any work of authorship, including
|
|
49
|
-
the original version of the Work and any modifications or additions
|
|
50
|
-
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
-
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
-
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
-
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
-
means any form of electronic, verbal, or written communication sent
|
|
55
|
-
to the Licensor or its representatives, including but not limited to
|
|
56
|
-
communication on electronic mailing lists, source code control systems,
|
|
57
|
-
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
-
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
-
excluding communication that is conspicuously marked or otherwise
|
|
60
|
-
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
-
|
|
62
|
-
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
-
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
-
subsequently incorporated within the Work.
|
|
65
|
-
|
|
66
|
-
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
-
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
-
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
-
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
-
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
-
Work and such Derivative Works in Source or Object form.
|
|
72
|
-
|
|
73
|
-
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
-
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
-
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
-
(except as stated in this section) patent license to make, have made,
|
|
77
|
-
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
-
where such license applies only to those patent claims licensable
|
|
79
|
-
by such Contributor that are necessarily infringed by their
|
|
80
|
-
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
-
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
-
institute patent litigation against any entity (including a
|
|
83
|
-
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
-
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
-
or contributory patent infringement, then any patent licenses
|
|
86
|
-
granted to You under this License for that Work shall terminate
|
|
87
|
-
as of the date such litigation is filed.
|
|
88
|
-
|
|
89
|
-
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
-
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
-
modifications, and in Source or Object form, provided that You
|
|
92
|
-
meet the following conditions:
|
|
93
|
-
|
|
94
|
-
(a) You must give any other recipients of the Work or
|
|
95
|
-
Derivative Works a copy of this License; and
|
|
96
|
-
|
|
97
|
-
(b) You must cause any modified files to carry prominent notices
|
|
98
|
-
stating that You changed the files; and
|
|
99
|
-
|
|
100
|
-
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
-
that You distribute, all copyright, patent, trademark, and
|
|
102
|
-
attribution notices from the Source form of the Work,
|
|
103
|
-
excluding those notices that do not pertain to any part of
|
|
104
|
-
the Derivative Works; and
|
|
105
|
-
|
|
106
|
-
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
-
distribution, then any Derivative Works that You distribute must
|
|
108
|
-
include a readable copy of the attribution notices contained
|
|
109
|
-
within such NOTICE file, excluding those notices that do not
|
|
110
|
-
pertain to any part of the Derivative Works, in at least one
|
|
111
|
-
of the following places: within a NOTICE text file distributed
|
|
112
|
-
as part of the Derivative Works; within the Source form or
|
|
113
|
-
documentation, if provided along with the Derivative Works; or,
|
|
114
|
-
within a display generated by the Derivative Works, if and
|
|
115
|
-
wherever such third-party notices normally appear. The contents
|
|
116
|
-
of the NOTICE file are for informational purposes only and
|
|
117
|
-
do not modify the License. You may add Your own attribution
|
|
118
|
-
notices within Derivative Works that You distribute, alongside
|
|
119
|
-
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
-
that such additional attribution notices cannot be construed
|
|
121
|
-
as modifying the License.
|
|
122
|
-
|
|
123
|
-
You may add Your own copyright statement to Your modifications and
|
|
124
|
-
may provide additional or different license terms and conditions
|
|
125
|
-
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
-
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
-
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
-
the conditions stated in this License.
|
|
129
|
-
|
|
130
|
-
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
-
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
-
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
-
this License, without any additional terms or conditions.
|
|
134
|
-
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
-
the terms of any separate license agreement you may have executed
|
|
136
|
-
with Licensor regarding such Contributions.
|
|
137
|
-
|
|
138
|
-
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
-
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
-
except as required for reasonable and customary use in describing the
|
|
141
|
-
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
-
|
|
143
|
-
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
-
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
-
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
-
implied, including, without limitation, any warranties or conditions
|
|
148
|
-
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
-
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
-
appropriateness of using or redistributing the Work and assume any
|
|
151
|
-
risks associated with Your exercise of permissions under this License.
|
|
152
|
-
|
|
153
|
-
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
-
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
-
unless required by applicable law (such as deliberate and grossly
|
|
156
|
-
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
-
liable to You for damages, including any direct, indirect, special,
|
|
158
|
-
incidental, or consequential damages of any character arising as a
|
|
159
|
-
result of this License or out of the use or inability to use the
|
|
160
|
-
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
-
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
-
other commercial damages or losses), even if such Contributor
|
|
163
|
-
has been advised of the possibility of such damages.
|
|
164
|
-
|
|
165
|
-
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
-
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
-
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
-
or other liability obligations and/or rights consistent with this
|
|
169
|
-
License. However, in accepting such obligations, You may act only
|
|
170
|
-
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
-
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
-
defend, and hold each Contributor harmless for any liability
|
|
173
|
-
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
-
of your accepting any such warranty or additional liability.
|
|
175
|
-
|
|
176
|
-
END OF TERMS AND CONDITIONS
|
|
177
|
-
|
|
178
|
-
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
-
|
|
180
|
-
To apply the Apache License to your work, attach the following
|
|
181
|
-
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
-
replaced with your own identifying information. (Don't include
|
|
183
|
-
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
-
comment syntax for the file format. We also recommend that a
|
|
185
|
-
file or class name and description of purpose be included on the
|
|
186
|
-
same "printed page" as the copyright notice for easier
|
|
187
|
-
identification within third-party archives.
|
|
188
|
-
|
|
189
|
-
Copyright [yyyy] [name of copyright owner]
|
|
190
|
-
|
|
191
|
-
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
-
you may not use this file except in compliance with the License.
|
|
193
|
-
You may obtain a copy of the License at
|
|
194
|
-
|
|
195
|
-
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
-
|
|
197
|
-
Unless required by applicable law or agreed to in writing, software
|
|
198
|
-
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
-
See the License for the specific language governing permissions and
|
|
201
|
-
limitations under the License.
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@viglet/create-shio-app)
|
|
4
4
|
[](https://github.com/openviglet/shio/blob/HEAD/LICENSE)
|
|
5
|
-
[](https://openviglet.github.io/shio/)
|
|
6
6
|
|
|
7
7
|
Scaffold a **Next.js (App Router)** site wired to the Content Delivery API of
|
|
8
|
-
**[Viglet Shio](https://
|
|
8
|
+
**[Viglet Shio](https://openviglet.github.io/shio/)**, the open source, **agent-ready CMS
|
|
9
9
|
for coding agents**.
|
|
10
10
|
|
|
11
11
|
The flagship "JS dev, zero Java" starter — a JavaScript/TypeScript developer
|
|
@@ -92,7 +92,7 @@ npm test # node:test — scaffolds into a temp dir and asserts the output
|
|
|
92
92
|
|
|
93
93
|
## License
|
|
94
94
|
|
|
95
|
-
Apache-2.0 — [Viglet Shio](https://
|
|
95
|
+
Apache-2.0 — [Viglet Shio](https://openviglet.github.io/shio/) ·
|
|
96
96
|
[github.com/openviglet/shio](https://github.com/openviglet/shio) ·
|
|
97
97
|
[docs.viglet.org/shio](https://docs.viglet.org/shio/) ·
|
|
98
98
|
part of the [Viglet](https://www.viglet.org) open source family.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viglet/create-shio-app",
|
|
3
|
-
"version": "2026.3.
|
|
4
|
-
"description": "Scaffold a Next.js (App Router) site wired to the Shio
|
|
5
|
-
"homepage": "https://
|
|
3
|
+
"version": "2026.3.3",
|
|
4
|
+
"description": "Scaffold a Next.js (App Router) site wired to the Viglet Shio Content Delivery API. The flagship 'JS dev, zero Java' starter.",
|
|
5
|
+
"homepage": "https://openviglet.github.io/shio/",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/openviglet/shio.git",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"license": "Apache-2.0",
|
|
54
54
|
"scripts": {
|
|
55
55
|
"emit": "node src/emit.mjs",
|
|
56
|
-
"test": "node --test \"test/*.test.mjs\"",
|
|
56
|
+
"test": "node --import ../scripts/test-watchdog.mjs --test \"test/*.test.mjs\"",
|
|
57
57
|
"emit:check": "node scripts/emit-guard.mjs"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/emit.mjs
CHANGED
|
@@ -1,292 +1,292 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (C) 2016-2026 the original author or authors.
|
|
3
|
-
*
|
|
4
|
-
* Licensed under the Apache License, Version 2.0.
|
|
5
|
-
*
|
|
6
|
-
* SH119 — generate the `nextjs-starter` blueprint package from this package's
|
|
7
|
-
* `template/` tree, so there is one starter with two ways to get it: offline
|
|
8
|
-
* (`npm create shio-app`, which copies the tree) and from an instance
|
|
9
|
-
* (`shio init --blueprint nextjs-starter`, which downloads the package's `files`).
|
|
10
|
-
*
|
|
11
|
-
* SH156 — and generate the section-vocabulary packages' front-end half the same way, as
|
|
12
|
-
* **the same tree plus an overlay**. That is the answer to "how do packages share a tree
|
|
13
|
-
* instead of each carrying a copy": they each carry a copy and *nobody writes one*. A
|
|
14
|
-
* hand-maintained second starter would drift within a release; a generated one cannot,
|
|
15
|
-
* because `test/emit.test.mjs` fails the build the moment it disagrees with its sources.
|
|
16
|
-
*
|
|
17
|
-
* Why generate rather than share. A running Shio reads blueprints off the classpath
|
|
18
|
-
* and cannot run a JS build, so the bytes have to exist under
|
|
19
|
-
* `shio-app/src/main/resources/blueprints/`. That is a second copy by necessity, which
|
|
20
|
-
* is precisely why the drift test exists — the same discipline `shio-sections`' `emit`
|
|
21
|
-
* uses for post-type defs.
|
|
22
|
-
*
|
|
23
|
-
* Run: `pnpm -C create-shio-app run emit`
|
|
24
|
-
*/
|
|
25
|
-
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
26
|
-
import { existsSync } from "node:fs";
|
|
27
|
-
import { dirname, join } from "node:path";
|
|
28
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
29
|
-
|
|
30
|
-
import { rangeFor, unpublished } from "./published.mjs";
|
|
31
|
-
import { readTemplateFiles } from "./scaffold.mjs";
|
|
32
|
-
|
|
33
|
-
/** Where the blueprint packages live, relative to this file. */
|
|
34
|
-
const BLUEPRINTS = fileURLToPath(
|
|
35
|
-
new URL("../../shio-app/src/main/resources/blueprints/", import.meta.url)
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* The section vocabulary's front-end half, authored once (SH156).
|
|
40
|
-
*
|
|
41
|
-
* <p>It lives in `shio-sections` rather than here because the vocabulary owns how it
|
|
42
|
-
* renders — the same argument that puts its post-type defs in `src/post-types.ts`. This
|
|
43
|
-
* package owns the *starter*, that one owns the *sections*, and `emit` is where they
|
|
44
|
-
* meet.</p>
|
|
45
|
-
*/
|
|
46
|
-
const SECTIONS_OVERLAY = fileURLToPath(new URL("../../shio-sections/front-end/", import.meta.url));
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* A package's own index page, one directory per package (SH216).
|
|
50
|
-
*
|
|
51
|
-
* <h2>Why these live here and the section overlay does not</h2>
|
|
52
|
-
*
|
|
53
|
-
* <p>The section overlay lives in `shio-sections` because <b>the vocabulary owns how it
|
|
54
|
-
* renders</b> — the same argument that puts its post-type defs in `src/post-types.ts`. These have
|
|
55
|
-
* no vocabulary. `blog`, `docs` and `product-catalog` are ordinary posts with an HTML field, and
|
|
56
|
-
* what each is missing is one page the starter cannot provide because it is about the *model*
|
|
57
|
-
* rather than about any page: the posts newest first, the documentation tree, the catalogue grid.
|
|
58
|
-
* That belongs to whoever owns the starter, which is this package.</p>
|
|
59
|
-
*
|
|
60
|
-
* <p><b>Not beside the blueprint</b>, which was the tempting answer: `blueprints/<name>/files/` is
|
|
61
|
-
* the tree `emit` <em>writes</em>, so a source overlay in the same directory would put input and
|
|
62
|
-
* output in one place and make the drift test compare a thing with itself.</p>
|
|
63
|
-
*/
|
|
64
|
-
const packageOverlay = (name) =>
|
|
65
|
-
fileURLToPath(new URL(`../overlays/${name}/`, import.meta.url));
|
|
66
|
-
|
|
67
|
-
/** `@viglet/shio-sections`' own package.json — the version the overlay depends on. */
|
|
68
|
-
const SECTIONS_MANIFEST = fileURLToPath(
|
|
69
|
-
new URL("../../shio-sections/package.json", import.meta.url)
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Every package generated from the starter tree.
|
|
74
|
-
*
|
|
75
|
-
* <p>Named explicitly rather than globbed, for the same reason `shio-sections`' target
|
|
76
|
-
* list is: a package that stops carrying a front end is a decision, and it should look
|
|
77
|
-
* like one here.</p>
|
|
78
|
-
*
|
|
79
|
-
* <p><b>`blog`, `docs` and `product-catalog` carry an index page instead of the section
|
|
80
|
-
* overlay (SH216).</b> They do not compose sections — their models are ordinary posts with an
|
|
81
|
-
* HTML field, which the starter's catch-all already renders — so the section tree would ship a
|
|
82
|
-
* dependency they never import and let three packages claim a front end that renders their pages
|
|
83
|
-
* no better than the plain starter does. What each was actually missing is one page the starter
|
|
84
|
-
* cannot provide because it is about the <em>model</em>: a post list at `/blog`, a navigation tree
|
|
85
|
-
* at `/docs`, a grid at `/products`. Three shapes, which is exactly why it is three overlays and
|
|
86
|
-
* not one.</p>
|
|
87
|
-
*/
|
|
88
|
-
export const PACKAGES = [
|
|
89
|
-
{ name: "nextjs-starter", overlay: null },
|
|
90
|
-
{ name: "marketing-site", overlay: SECTIONS_OVERLAY, needs: ["@viglet/shio-sections"] },
|
|
91
|
-
{ name: "landing", overlay: SECTIONS_OVERLAY, needs: ["@viglet/shio-sections"] },
|
|
92
|
-
// No `needs`: an index page uses the SDK the starter already depends on. That is the whole
|
|
93
|
-
// distinction from the section overlay, which brings a vocabulary with it.
|
|
94
|
-
{ name: "blog", overlay: packageOverlay("blog") },
|
|
95
|
-
{ name: "docs", overlay: packageOverlay("docs") },
|
|
96
|
-
{ name: "product-catalog", overlay: packageOverlay("product-catalog") },
|
|
97
|
-
];
|
|
98
|
-
|
|
99
|
-
/** Where the carried tree lives inside a package. */
|
|
100
|
-
const FILES_SUBDIR = "files";
|
|
101
|
-
|
|
102
|
-
/** The starter's package directory — kept as a named export for SH119's callers. */
|
|
103
|
-
export const PACKAGE_DIR = join(BLUEPRINTS, "nextjs-starter/");
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* The manifest's `files` refs for a carried tree.
|
|
107
|
-
*
|
|
108
|
-
* <p>`path` is where the file goes in the caller's project; `from` is where its bytes
|
|
109
|
-
* live in the package. Both are always stated: the tree lives under `files/` so the
|
|
110
|
-
* package root stays readable (manifest, model, content, tree), and the two names differ
|
|
111
|
-
* for dotfiles anyway — a leading dot is stripped inside the package, which is the same
|
|
112
|
-
* dodge the npm template uses for `gitignore` and which the `path` now undoes *in the
|
|
113
|
-
* format* rather than in each consumer's code.</p>
|
|
114
|
-
*
|
|
115
|
-
* @param {Array<{path: string, text: string}>} files
|
|
116
|
-
*/
|
|
117
|
-
export function fileRefs(files) {
|
|
118
|
-
return files.map((file) => ({ path: file.path, from: sourcePath(file) }));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Where a row's bytes are written inside the package. */
|
|
122
|
-
export function sourcePath(file) {
|
|
123
|
-
return `${FILES_SUBDIR}/${file.path.replace(/^\./, "")}`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Read an overlay directory as the same `{path, text}` rows the template produces. */
|
|
127
|
-
export async function readOverlay(dir) {
|
|
128
|
-
if (!dir || !existsSync(dir)) return [];
|
|
129
|
-
const rows = [];
|
|
130
|
-
for (const rel of await listFiles(dir)) {
|
|
131
|
-
rows.push({ path: rel, text: await readFile(join(dir, rel), "utf8") });
|
|
132
|
-
}
|
|
133
|
-
return rows.sort((a, b) => a.path.localeCompare(b.path));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function listFiles(dir, prefix = "") {
|
|
137
|
-
const out = [];
|
|
138
|
-
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
139
|
-
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
140
|
-
if (entry.isDirectory()) {
|
|
141
|
-
out.push(...(await listFiles(join(dir, entry.name), rel)));
|
|
142
|
-
} else {
|
|
143
|
-
out.push(rel);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return out;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* The tree one package carries: the starter, with the overlay's files replacing or
|
|
151
|
-
* joining it, and the dependencies the overlay needs added to `package.json`.
|
|
152
|
-
*
|
|
153
|
-
* <p>Replace-by-path is what keeps the overlay small. The section-aware catch-all route
|
|
154
|
-
* is the starter's route plus one branch, so it <em>is</em> `app/[...slug]/page.tsx`
|
|
155
|
-
* rather than a second route the project would have to be told about.</p>
|
|
156
|
-
*
|
|
157
|
-
* @param {Array<{path: string, text: string}>} base the starter tree
|
|
158
|
-
* @param {Array<{path: string, text: string}>} overlay files that replace or join it
|
|
159
|
-
* @param {Record<string, string>} dependencies added to the project's package.json
|
|
160
|
-
*/
|
|
161
|
-
export function compose(base, overlay, dependencies = {}) {
|
|
162
|
-
const byPath = new Map(base.map((file) => [file.path, file]));
|
|
163
|
-
for (const file of overlay) {
|
|
164
|
-
byPath.set(file.path, file);
|
|
165
|
-
}
|
|
166
|
-
if (Object.keys(dependencies).length > 0) {
|
|
167
|
-
const manifest = byPath.get("package.json");
|
|
168
|
-
if (!manifest) {
|
|
169
|
-
throw new Error("emit: the starter tree has no package.json to add dependencies to");
|
|
170
|
-
}
|
|
171
|
-
const parsed = JSON.parse(manifest.text);
|
|
172
|
-
// Sorted, so adding one dependency does not reorder the ones already there and the
|
|
173
|
-
// generated diff is the change and nothing else (P5).
|
|
174
|
-
parsed.dependencies = Object.fromEntries(
|
|
175
|
-
Object.entries({ ...parsed.dependencies, ...dependencies }).sort(([a], [b]) =>
|
|
176
|
-
a.localeCompare(b)
|
|
177
|
-
)
|
|
178
|
-
);
|
|
179
|
-
byPath.set("package.json", {
|
|
180
|
-
path: "package.json",
|
|
181
|
-
text: `${JSON.stringify(parsed, null, 2)}\n`,
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* The dependency ranges an overlay's `needs` resolve to.
|
|
189
|
-
*
|
|
190
|
-
* <h3>The comment that used to be here was exactly backwards (SH446)</h3>
|
|
191
|
-
*
|
|
192
|
-
* <p>It read: "read from the workspace rather than written down here, so a release that bumps
|
|
193
|
-
* `@viglet/shio-sections` cannot leave the generated packages pointing at a version nobody
|
|
194
|
-
* published." Reading it from the workspace is what <em>guarantees</em> pointing at a version nobody
|
|
195
|
-
* published — the workspace is the version being <em>built</em>, and it is installable only in the
|
|
196
|
-
* instant after a release. Measured on `@viglet/shio-client`, whose template range was derived the
|
|
197
|
-
* same way: `^2026.3.1` against a registry that had `2026.2.1`, so `npm install` in a fresh scaffold
|
|
198
|
-
* failed `ETARGET` and nothing in the repository noticed, because nothing installs from the
|
|
199
|
-
* registry.</p>
|
|
200
|
-
*
|
|
201
|
-
* <p>So the published range wins when there is one ({@link rangeFor}), and the workspace's is the
|
|
202
|
-
* fallback for a package that has never been published at all — where no range is correct and
|
|
203
|
-
* writing *something* at least leaves a project that is right the day the release runs.
|
|
204
|
-
* {@link unpublishedNeeds} is what reports that state, and `emit` prints it.</p>
|
|
205
|
-
*/
|
|
206
|
-
export async function resolveNeeds(needs) {
|
|
207
|
-
if (!needs || needs.length === 0) return {};
|
|
208
|
-
const { version } = JSON.parse(await readFile(SECTIONS_MANIFEST, "utf8"));
|
|
209
|
-
return Object.fromEntries(needs.map((name) => [name, rangeFor(name) ?? `^${version}`]));
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** The `needs` across every package that name something the registry does not have (SH446). */
|
|
213
|
-
export function unpublishedNeeds() {
|
|
214
|
-
return unpublished([...new Set(PACKAGES.flatMap((pkg) => pkg.needs ?? []))]);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** The whole tree one entry of {@link PACKAGES} should carry. */
|
|
218
|
-
export async function filesFor(pkg, base) {
|
|
219
|
-
const template = base ?? (await readTemplateFiles());
|
|
220
|
-
if (!pkg.overlay) return template;
|
|
221
|
-
return compose(template, await readOverlay(pkg.overlay), await resolveNeeds(pkg.needs));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Read a package's manifest with its `files` list replaced by what its tree currently
|
|
226
|
-
* holds — the shape `emit` would write. Pure, so the drift test can compare it against
|
|
227
|
-
* what is on disk without touching the filesystem twice.
|
|
228
|
-
*/
|
|
229
|
-
export async function expectedManifest(files, name = "nextjs-starter") {
|
|
230
|
-
const manifest = JSON.parse(await readFile(join(BLUEPRINTS, name, "blueprint.json"), "utf8"));
|
|
231
|
-
return { ...manifest, files: fileRefs(files) };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** The bytes a package should carry, keyed by package-relative path. */
|
|
235
|
-
export function expectedSources(files) {
|
|
236
|
-
return new Map(files.map((file) => [sourcePath(file), file.text]));
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export async function emit({ log = console.log } = {}) {
|
|
240
|
-
const base = await readTemplateFiles();
|
|
241
|
-
const emitted = {};
|
|
242
|
-
|
|
243
|
-
for (const pkg of PACKAGES) {
|
|
244
|
-
const dir = join(BLUEPRINTS, pkg.name);
|
|
245
|
-
if (!existsSync(dir)) {
|
|
246
|
-
throw new Error(
|
|
247
|
-
`emit: the ${pkg.name} blueprint package is missing at ${dir}. ` +
|
|
248
|
-
"The manifest is hand-authored (it declares the params and the promise); only " +
|
|
249
|
-
"`files` and the carried tree are generated."
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
const files = await filesFor(pkg, base);
|
|
253
|
-
|
|
254
|
-
// Rewritten wholesale so a file deleted from the template disappears here too — a
|
|
255
|
-
// stale leftover would be shipped to every project that applies the package.
|
|
256
|
-
await rm(join(dir, FILES_SUBDIR), { recursive: true, force: true });
|
|
257
|
-
for (const file of files) {
|
|
258
|
-
const target = join(dir, sourcePath(file));
|
|
259
|
-
await mkdir(dirname(target), { recursive: true });
|
|
260
|
-
await writeFile(target, file.text, "utf8");
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const manifest = await expectedManifest(files, pkg.name);
|
|
264
|
-
await writeFile(join(dir, "blueprint.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
265
|
-
|
|
266
|
-
log(`✓ emitted ${files.length} file(s) into blueprints/${pkg.name}`);
|
|
267
|
-
emitted[pkg.name] = files.map((file) => file.path);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// SH446 — said at the moment the file is written, because the alternative is a stranger's
|
|
271
|
-
// `npm install` saying it, and by then the scaffold is on their disk and this repository is not.
|
|
272
|
-
const missing = unpublishedNeeds();
|
|
273
|
-
if (missing.length) {
|
|
274
|
-
log(
|
|
275
|
-
`\n! ${missing.join(" ")} ${missing.length === 1 ? "is" : "are"} NOT on the registry, so the `
|
|
276
|
-
+ `starter${missing.length === 1 ? "" : "s"} carrying the section overlay cannot `
|
|
277
|
-
+ `npm install outside this monorepo.\n`
|
|
278
|
-
+ ` No range fixes it — publish them, then record the range in src/published.mjs.\n`
|
|
279
|
-
+ ` \`shio init\` (no --blueprint) is unaffected: it carries only published packages.\n`,
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return { files: emitted["nextjs-starter"], packages: emitted, unpublished: missing };
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// `pathToFileURL`, not a hand-built `file://` string: on Windows `process.argv[1]` is
|
|
287
|
-
// `D:\...` while `import.meta.url` is `file:///D:/...`, so the concatenated form never
|
|
288
|
-
// matched and running the script did nothing at all — silently, which is the worst way for
|
|
289
|
-
// a generator to fail.
|
|
290
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
291
|
-
await emit();
|
|
292
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2016-2026 the original author or authors.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0.
|
|
5
|
+
*
|
|
6
|
+
* SH119 — generate the `nextjs-starter` blueprint package from this package's
|
|
7
|
+
* `template/` tree, so there is one starter with two ways to get it: offline
|
|
8
|
+
* (`npm create shio-app`, which copies the tree) and from an instance
|
|
9
|
+
* (`shio init --blueprint nextjs-starter`, which downloads the package's `files`).
|
|
10
|
+
*
|
|
11
|
+
* SH156 — and generate the section-vocabulary packages' front-end half the same way, as
|
|
12
|
+
* **the same tree plus an overlay**. That is the answer to "how do packages share a tree
|
|
13
|
+
* instead of each carrying a copy": they each carry a copy and *nobody writes one*. A
|
|
14
|
+
* hand-maintained second starter would drift within a release; a generated one cannot,
|
|
15
|
+
* because `test/emit.test.mjs` fails the build the moment it disagrees with its sources.
|
|
16
|
+
*
|
|
17
|
+
* Why generate rather than share. A running Shio reads blueprints off the classpath
|
|
18
|
+
* and cannot run a JS build, so the bytes have to exist under
|
|
19
|
+
* `shio-app/src/main/resources/blueprints/`. That is a second copy by necessity, which
|
|
20
|
+
* is precisely why the drift test exists — the same discipline `shio-sections`' `emit`
|
|
21
|
+
* uses for post-type defs.
|
|
22
|
+
*
|
|
23
|
+
* Run: `pnpm -C create-shio-app run emit`
|
|
24
|
+
*/
|
|
25
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { dirname, join } from "node:path";
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
29
|
+
|
|
30
|
+
import { rangeFor, unpublished } from "./published.mjs";
|
|
31
|
+
import { readTemplateFiles } from "./scaffold.mjs";
|
|
32
|
+
|
|
33
|
+
/** Where the blueprint packages live, relative to this file. */
|
|
34
|
+
const BLUEPRINTS = fileURLToPath(
|
|
35
|
+
new URL("../../shio-app/src/main/resources/blueprints/", import.meta.url)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The section vocabulary's front-end half, authored once (SH156).
|
|
40
|
+
*
|
|
41
|
+
* <p>It lives in `shio-sections` rather than here because the vocabulary owns how it
|
|
42
|
+
* renders — the same argument that puts its post-type defs in `src/post-types.ts`. This
|
|
43
|
+
* package owns the *starter*, that one owns the *sections*, and `emit` is where they
|
|
44
|
+
* meet.</p>
|
|
45
|
+
*/
|
|
46
|
+
const SECTIONS_OVERLAY = fileURLToPath(new URL("../../shio-sections/front-end/", import.meta.url));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A package's own index page, one directory per package (SH216).
|
|
50
|
+
*
|
|
51
|
+
* <h2>Why these live here and the section overlay does not</h2>
|
|
52
|
+
*
|
|
53
|
+
* <p>The section overlay lives in `shio-sections` because <b>the vocabulary owns how it
|
|
54
|
+
* renders</b> — the same argument that puts its post-type defs in `src/post-types.ts`. These have
|
|
55
|
+
* no vocabulary. `blog`, `docs` and `product-catalog` are ordinary posts with an HTML field, and
|
|
56
|
+
* what each is missing is one page the starter cannot provide because it is about the *model*
|
|
57
|
+
* rather than about any page: the posts newest first, the documentation tree, the catalogue grid.
|
|
58
|
+
* That belongs to whoever owns the starter, which is this package.</p>
|
|
59
|
+
*
|
|
60
|
+
* <p><b>Not beside the blueprint</b>, which was the tempting answer: `blueprints/<name>/files/` is
|
|
61
|
+
* the tree `emit` <em>writes</em>, so a source overlay in the same directory would put input and
|
|
62
|
+
* output in one place and make the drift test compare a thing with itself.</p>
|
|
63
|
+
*/
|
|
64
|
+
const packageOverlay = (name) =>
|
|
65
|
+
fileURLToPath(new URL(`../overlays/${name}/`, import.meta.url));
|
|
66
|
+
|
|
67
|
+
/** `@viglet/shio-sections`' own package.json — the version the overlay depends on. */
|
|
68
|
+
const SECTIONS_MANIFEST = fileURLToPath(
|
|
69
|
+
new URL("../../shio-sections/package.json", import.meta.url)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every package generated from the starter tree.
|
|
74
|
+
*
|
|
75
|
+
* <p>Named explicitly rather than globbed, for the same reason `shio-sections`' target
|
|
76
|
+
* list is: a package that stops carrying a front end is a decision, and it should look
|
|
77
|
+
* like one here.</p>
|
|
78
|
+
*
|
|
79
|
+
* <p><b>`blog`, `docs` and `product-catalog` carry an index page instead of the section
|
|
80
|
+
* overlay (SH216).</b> They do not compose sections — their models are ordinary posts with an
|
|
81
|
+
* HTML field, which the starter's catch-all already renders — so the section tree would ship a
|
|
82
|
+
* dependency they never import and let three packages claim a front end that renders their pages
|
|
83
|
+
* no better than the plain starter does. What each was actually missing is one page the starter
|
|
84
|
+
* cannot provide because it is about the <em>model</em>: a post list at `/blog`, a navigation tree
|
|
85
|
+
* at `/docs`, a grid at `/products`. Three shapes, which is exactly why it is three overlays and
|
|
86
|
+
* not one.</p>
|
|
87
|
+
*/
|
|
88
|
+
export const PACKAGES = [
|
|
89
|
+
{ name: "nextjs-starter", overlay: null },
|
|
90
|
+
{ name: "marketing-site", overlay: SECTIONS_OVERLAY, needs: ["@viglet/shio-sections"] },
|
|
91
|
+
{ name: "landing", overlay: SECTIONS_OVERLAY, needs: ["@viglet/shio-sections"] },
|
|
92
|
+
// No `needs`: an index page uses the SDK the starter already depends on. That is the whole
|
|
93
|
+
// distinction from the section overlay, which brings a vocabulary with it.
|
|
94
|
+
{ name: "blog", overlay: packageOverlay("blog") },
|
|
95
|
+
{ name: "docs", overlay: packageOverlay("docs") },
|
|
96
|
+
{ name: "product-catalog", overlay: packageOverlay("product-catalog") },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** Where the carried tree lives inside a package. */
|
|
100
|
+
const FILES_SUBDIR = "files";
|
|
101
|
+
|
|
102
|
+
/** The starter's package directory — kept as a named export for SH119's callers. */
|
|
103
|
+
export const PACKAGE_DIR = join(BLUEPRINTS, "nextjs-starter/");
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The manifest's `files` refs for a carried tree.
|
|
107
|
+
*
|
|
108
|
+
* <p>`path` is where the file goes in the caller's project; `from` is where its bytes
|
|
109
|
+
* live in the package. Both are always stated: the tree lives under `files/` so the
|
|
110
|
+
* package root stays readable (manifest, model, content, tree), and the two names differ
|
|
111
|
+
* for dotfiles anyway — a leading dot is stripped inside the package, which is the same
|
|
112
|
+
* dodge the npm template uses for `gitignore` and which the `path` now undoes *in the
|
|
113
|
+
* format* rather than in each consumer's code.</p>
|
|
114
|
+
*
|
|
115
|
+
* @param {Array<{path: string, text: string}>} files
|
|
116
|
+
*/
|
|
117
|
+
export function fileRefs(files) {
|
|
118
|
+
return files.map((file) => ({ path: file.path, from: sourcePath(file) }));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Where a row's bytes are written inside the package. */
|
|
122
|
+
export function sourcePath(file) {
|
|
123
|
+
return `${FILES_SUBDIR}/${file.path.replace(/^\./, "")}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Read an overlay directory as the same `{path, text}` rows the template produces. */
|
|
127
|
+
export async function readOverlay(dir) {
|
|
128
|
+
if (!dir || !existsSync(dir)) return [];
|
|
129
|
+
const rows = [];
|
|
130
|
+
for (const rel of await listFiles(dir)) {
|
|
131
|
+
rows.push({ path: rel, text: await readFile(join(dir, rel), "utf8") });
|
|
132
|
+
}
|
|
133
|
+
return rows.sort((a, b) => a.path.localeCompare(b.path));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function listFiles(dir, prefix = "") {
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
139
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
out.push(...(await listFiles(join(dir, entry.name), rel)));
|
|
142
|
+
} else {
|
|
143
|
+
out.push(rel);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The tree one package carries: the starter, with the overlay's files replacing or
|
|
151
|
+
* joining it, and the dependencies the overlay needs added to `package.json`.
|
|
152
|
+
*
|
|
153
|
+
* <p>Replace-by-path is what keeps the overlay small. The section-aware catch-all route
|
|
154
|
+
* is the starter's route plus one branch, so it <em>is</em> `app/[...slug]/page.tsx`
|
|
155
|
+
* rather than a second route the project would have to be told about.</p>
|
|
156
|
+
*
|
|
157
|
+
* @param {Array<{path: string, text: string}>} base the starter tree
|
|
158
|
+
* @param {Array<{path: string, text: string}>} overlay files that replace or join it
|
|
159
|
+
* @param {Record<string, string>} dependencies added to the project's package.json
|
|
160
|
+
*/
|
|
161
|
+
export function compose(base, overlay, dependencies = {}) {
|
|
162
|
+
const byPath = new Map(base.map((file) => [file.path, file]));
|
|
163
|
+
for (const file of overlay) {
|
|
164
|
+
byPath.set(file.path, file);
|
|
165
|
+
}
|
|
166
|
+
if (Object.keys(dependencies).length > 0) {
|
|
167
|
+
const manifest = byPath.get("package.json");
|
|
168
|
+
if (!manifest) {
|
|
169
|
+
throw new Error("emit: the starter tree has no package.json to add dependencies to");
|
|
170
|
+
}
|
|
171
|
+
const parsed = JSON.parse(manifest.text);
|
|
172
|
+
// Sorted, so adding one dependency does not reorder the ones already there and the
|
|
173
|
+
// generated diff is the change and nothing else (P5).
|
|
174
|
+
parsed.dependencies = Object.fromEntries(
|
|
175
|
+
Object.entries({ ...parsed.dependencies, ...dependencies }).sort(([a], [b]) =>
|
|
176
|
+
a.localeCompare(b)
|
|
177
|
+
)
|
|
178
|
+
);
|
|
179
|
+
byPath.set("package.json", {
|
|
180
|
+
path: "package.json",
|
|
181
|
+
text: `${JSON.stringify(parsed, null, 2)}\n`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The dependency ranges an overlay's `needs` resolve to.
|
|
189
|
+
*
|
|
190
|
+
* <h3>The comment that used to be here was exactly backwards (SH446)</h3>
|
|
191
|
+
*
|
|
192
|
+
* <p>It read: "read from the workspace rather than written down here, so a release that bumps
|
|
193
|
+
* `@viglet/shio-sections` cannot leave the generated packages pointing at a version nobody
|
|
194
|
+
* published." Reading it from the workspace is what <em>guarantees</em> pointing at a version nobody
|
|
195
|
+
* published — the workspace is the version being <em>built</em>, and it is installable only in the
|
|
196
|
+
* instant after a release. Measured on `@viglet/shio-client`, whose template range was derived the
|
|
197
|
+
* same way: `^2026.3.1` against a registry that had `2026.2.1`, so `npm install` in a fresh scaffold
|
|
198
|
+
* failed `ETARGET` and nothing in the repository noticed, because nothing installs from the
|
|
199
|
+
* registry.</p>
|
|
200
|
+
*
|
|
201
|
+
* <p>So the published range wins when there is one ({@link rangeFor}), and the workspace's is the
|
|
202
|
+
* fallback for a package that has never been published at all — where no range is correct and
|
|
203
|
+
* writing *something* at least leaves a project that is right the day the release runs.
|
|
204
|
+
* {@link unpublishedNeeds} is what reports that state, and `emit` prints it.</p>
|
|
205
|
+
*/
|
|
206
|
+
export async function resolveNeeds(needs) {
|
|
207
|
+
if (!needs || needs.length === 0) return {};
|
|
208
|
+
const { version } = JSON.parse(await readFile(SECTIONS_MANIFEST, "utf8"));
|
|
209
|
+
return Object.fromEntries(needs.map((name) => [name, rangeFor(name) ?? `^${version}`]));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The `needs` across every package that name something the registry does not have (SH446). */
|
|
213
|
+
export function unpublishedNeeds() {
|
|
214
|
+
return unpublished([...new Set(PACKAGES.flatMap((pkg) => pkg.needs ?? []))]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The whole tree one entry of {@link PACKAGES} should carry. */
|
|
218
|
+
export async function filesFor(pkg, base) {
|
|
219
|
+
const template = base ?? (await readTemplateFiles());
|
|
220
|
+
if (!pkg.overlay) return template;
|
|
221
|
+
return compose(template, await readOverlay(pkg.overlay), await resolveNeeds(pkg.needs));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Read a package's manifest with its `files` list replaced by what its tree currently
|
|
226
|
+
* holds — the shape `emit` would write. Pure, so the drift test can compare it against
|
|
227
|
+
* what is on disk without touching the filesystem twice.
|
|
228
|
+
*/
|
|
229
|
+
export async function expectedManifest(files, name = "nextjs-starter") {
|
|
230
|
+
const manifest = JSON.parse(await readFile(join(BLUEPRINTS, name, "blueprint.json"), "utf8"));
|
|
231
|
+
return { ...manifest, files: fileRefs(files) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The bytes a package should carry, keyed by package-relative path. */
|
|
235
|
+
export function expectedSources(files) {
|
|
236
|
+
return new Map(files.map((file) => [sourcePath(file), file.text]));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function emit({ log = console.log } = {}) {
|
|
240
|
+
const base = await readTemplateFiles();
|
|
241
|
+
const emitted = {};
|
|
242
|
+
|
|
243
|
+
for (const pkg of PACKAGES) {
|
|
244
|
+
const dir = join(BLUEPRINTS, pkg.name);
|
|
245
|
+
if (!existsSync(dir)) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`emit: the ${pkg.name} blueprint package is missing at ${dir}. ` +
|
|
248
|
+
"The manifest is hand-authored (it declares the params and the promise); only " +
|
|
249
|
+
"`files` and the carried tree are generated."
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const files = await filesFor(pkg, base);
|
|
253
|
+
|
|
254
|
+
// Rewritten wholesale so a file deleted from the template disappears here too — a
|
|
255
|
+
// stale leftover would be shipped to every project that applies the package.
|
|
256
|
+
await rm(join(dir, FILES_SUBDIR), { recursive: true, force: true });
|
|
257
|
+
for (const file of files) {
|
|
258
|
+
const target = join(dir, sourcePath(file));
|
|
259
|
+
await mkdir(dirname(target), { recursive: true });
|
|
260
|
+
await writeFile(target, file.text, "utf8");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const manifest = await expectedManifest(files, pkg.name);
|
|
264
|
+
await writeFile(join(dir, "blueprint.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
265
|
+
|
|
266
|
+
log(`✓ emitted ${files.length} file(s) into blueprints/${pkg.name}`);
|
|
267
|
+
emitted[pkg.name] = files.map((file) => file.path);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// SH446 — said at the moment the file is written, because the alternative is a stranger's
|
|
271
|
+
// `npm install` saying it, and by then the scaffold is on their disk and this repository is not.
|
|
272
|
+
const missing = unpublishedNeeds();
|
|
273
|
+
if (missing.length) {
|
|
274
|
+
log(
|
|
275
|
+
`\n! ${missing.join(" ")} ${missing.length === 1 ? "is" : "are"} NOT on the registry, so the `
|
|
276
|
+
+ `starter${missing.length === 1 ? "" : "s"} carrying the section overlay cannot `
|
|
277
|
+
+ `npm install outside this monorepo.\n`
|
|
278
|
+
+ ` No range fixes it — publish them, then record the range in src/published.mjs.\n`
|
|
279
|
+
+ ` \`shio init\` (no --blueprint) is unaffected: it carries only published packages.\n`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { files: emitted["nextjs-starter"], packages: emitted, unpublished: missing };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// `pathToFileURL`, not a hand-built `file://` string: on Windows `process.argv[1]` is
|
|
287
|
+
// `D:\...` while `import.meta.url` is `file:///D:/...`, so the concatenated form never
|
|
288
|
+
// matched and running the script did nothing at all — silently, which is the worst way for
|
|
289
|
+
// a generator to fail.
|
|
290
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
291
|
+
await emit();
|
|
292
|
+
}
|
package/src/published.mjs
CHANGED
|
@@ -40,15 +40,24 @@
|
|
|
40
40
|
* A `null` is not a placeholder to fill in with a guess: it means the package has no version on the
|
|
41
41
|
* registry at all, so **no range is correct** and any scaffold carrying it cannot install. It is
|
|
42
42
|
* recorded rather than hidden so the failure has a name, and `unpublished()` is what reports it.
|
|
43
|
+
*
|
|
44
|
+
* The `null` form is kept now that nothing uses it (SH502). It is the shape the next unpublished
|
|
45
|
+
* package needs, and the two readers that branch on it — `init`'s warning and the SH448 conformance
|
|
46
|
+
* — are written against `unpublished()` rather than against a fixed list, so they already work in
|
|
47
|
+
* both worlds. Deleting it would mean rediscovering the answer the next time a package is added
|
|
48
|
+
* ahead of its release, which is the situation these two rows spent two blocks in.
|
|
43
49
|
*/
|
|
44
50
|
export const PUBLISHED = {
|
|
45
51
|
"@viglet/shio-client": "^2026.2.1",
|
|
46
|
-
// SH156 made
|
|
47
|
-
// starters (`marketing-site`, `landing`)
|
|
48
|
-
// by choosing a different range
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
// SH502 — these were `null` for two blocks: SH156 made them publishable and no release had run, so
|
|
53
|
+
// both answered E404 and the section-carrying starters (`marketing-site`, `landing`), which import
|
|
54
|
+
// the vocabulary, could not be made installable by choosing a different range. Only by publishing.
|
|
55
|
+
//
|
|
56
|
+
// The release landed on 2026-08-12 at `2026.3.2` and both now resolve. Caret on the published
|
|
57
|
+
// version, the same rule `@viglet/shio-client` follows one line up and for the same reason: it
|
|
58
|
+
// resolves today and picks up the next release without this file being edited again.
|
|
59
|
+
"@viglet/shio-model": "^2026.3.2",
|
|
60
|
+
"@viglet/shio-sections": "^2026.3.2",
|
|
52
61
|
};
|
|
53
62
|
|
|
54
63
|
/** The packages a scaffold would need and the registry does not have. */
|
package/template/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# {{projectName}}
|
|
2
2
|
|
|
3
|
-
A [Next.js](https://nextjs.org) (App Router) site powered by **Shio
|
|
3
|
+
A [Next.js](https://nextjs.org) (App Router) site powered by **Viglet Shio** via the
|
|
4
4
|
Content Delivery API — headless, TypeScript-first, **zero Java**.
|
|
5
5
|
|
|
6
6
|
## Getting started
|
|
@@ -26,6 +26,7 @@ Content Delivery API — headless, TypeScript-first, **zero Java**.
|
|
|
26
26
|
- [`lib/shio.ts`](lib/shio.ts) creates a shared [`@viglet/shio-client`](https://www.npmjs.com/package/@viglet/shio-client) instance from the environment.
|
|
27
27
|
- [`app/page.tsx`](app/page.tsx) lists the site's published posts (`shio.query`).
|
|
28
28
|
- [`app/[...slug]/page.tsx`](app/[...slug]/page.tsx) resolves any path to a post via `shio.getPostByUrl(siteId, "/the/path")` — friendly-URL routing straight from the CDA, with `generateStaticParams` (SSG) + `revalidate` (ISR).
|
|
29
|
+
- [`middleware.ts`](middleware.ts) answers a path that moved. Redirects written in Shio (a `Redirect` post, or keeping an old address working when a page is renamed) come from the CDA's `/site/{siteId}/redirects` table, held for 60 seconds, and a moved path gets its `301` or `302` before any page is looked up. The logic is in [`lib/redirects.ts`](lib/redirects.ts).
|
|
29
30
|
|
|
30
31
|
All content comes from the Shio CDA (`/api/v2/cda/**`). You never touch the Java backend — just edit React/TypeScript.
|
|
31
32
|
|
|
@@ -104,5 +105,5 @@ by hand.
|
|
|
104
105
|
|
|
105
106
|
## Learn more
|
|
106
107
|
|
|
107
|
-
- [Shio
|
|
108
|
+
- [Viglet Shio](https://github.com/openviglet/shio)
|
|
108
109
|
- [Next.js Docs](https://nextjs.org/docs)
|
package/template/app/layout.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
export const metadata = {
|
|
4
4
|
title: "Shio site",
|
|
5
|
-
description: "Powered by Shio
|
|
5
|
+
description: "Powered by Viglet Shio — headless, JavaScript-first.",
|
|
6
6
|
};
|
|
7
7
|
|
|
8
8
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
package/template/gitignore
CHANGED
|
@@ -24,3 +24,9 @@ yarn-error.log*
|
|
|
24
24
|
# typescript
|
|
25
25
|
*.tsbuildinfo
|
|
26
26
|
next-env.d.ts
|
|
27
|
+
|
|
28
|
+
# shio: the CLI's derived state — a clone's progress and inventory, a conversion
|
|
29
|
+
# proposal, and SH1117's conditional-read cache. None of it is content, all of it
|
|
30
|
+
# is reproducible, and the read cache holds response bodies that belong to nobody's
|
|
31
|
+
# history.
|
|
32
|
+
/shio/.shio/
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's redirect table, consulted before a page is looked up (Shio roadmap SH1133).
|
|
3
|
+
*
|
|
4
|
+
* A redirect written in Shio — a `Redirect` post, or the "keep the old address working" offer when
|
|
5
|
+
* a page is renamed — is honoured by Shio's own `/sites/**` delivery. This front end serves the
|
|
6
|
+
* site's URLs itself, so it has to do the redirecting: `middleware.ts` asks here first.
|
|
7
|
+
*
|
|
8
|
+
* Plain TypeScript with no `next` import, so it can be tested with `node --test` directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One row of `GET /api/v2/cda/site/{siteId}/redirects`. */
|
|
12
|
+
export interface Redirect {
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
permanent: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Where a moved path goes, and with which status. */
|
|
19
|
+
export interface Moved {
|
|
20
|
+
destination: string;
|
|
21
|
+
status: 301 | 302;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The spelling Shio matches paths in: repeated slashes collapsed, one leading slash, no trailing
|
|
26
|
+
* one (the root stays `/`). Case is kept, as it is on the server.
|
|
27
|
+
*/
|
|
28
|
+
export function canonicalPath(path: string): string {
|
|
29
|
+
const collapsed = ("/" + (path ?? "")).replace(/\/{2,}/g, "/");
|
|
30
|
+
// Repeated slashes are already collapsed, so there is at most one to take off the end.
|
|
31
|
+
return collapsed.length > 1 ? collapsed.replace(/\/$/, "") : collapsed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The row for this path, or `null` when it did not move. */
|
|
35
|
+
export function findRedirect(
|
|
36
|
+
table: readonly Redirect[] | null | undefined,
|
|
37
|
+
path: string
|
|
38
|
+
): Moved | null {
|
|
39
|
+
const wanted = canonicalPath(path);
|
|
40
|
+
for (const row of table ?? []) {
|
|
41
|
+
if (row.to && canonicalPath(row.from) === wanted) {
|
|
42
|
+
return { destination: row.to, status: row.permanent ? 301 : 302 };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A cached lookup over a table loaded by `load`.
|
|
50
|
+
*
|
|
51
|
+
* The table is small and changes rarely, so it is held for `ttlMs` (the same 60 seconds the pages'
|
|
52
|
+
* ISR window uses) rather than fetched per request. A load that fails keeps the last table it had —
|
|
53
|
+
* an unreachable CMS must never turn every request into an error — and is retried after the same
|
|
54
|
+
* interval rather than on the next request.
|
|
55
|
+
*/
|
|
56
|
+
export function redirectLookup(
|
|
57
|
+
load: () => Promise<readonly Redirect[] | null>,
|
|
58
|
+
ttlMs = 60_000,
|
|
59
|
+
now: () => number = Date.now
|
|
60
|
+
): (path: string) => Promise<Moved | null> {
|
|
61
|
+
let table: readonly Redirect[] = [];
|
|
62
|
+
let loadedAt = Number.NEGATIVE_INFINITY;
|
|
63
|
+
// Requests arriving while a load is in flight wait for that load rather than starting their own.
|
|
64
|
+
let pending: Promise<void> | null = null;
|
|
65
|
+
return async (path: string) => {
|
|
66
|
+
if (!pending && now() - loadedAt >= ttlMs) {
|
|
67
|
+
loadedAt = now();
|
|
68
|
+
pending = load()
|
|
69
|
+
.then((loaded) => {
|
|
70
|
+
table = loaded ?? [];
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {
|
|
73
|
+
// Keep the last table: see the note above.
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
76
|
+
pending = null;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (pending) await pending;
|
|
80
|
+
return findRedirect(table, path);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The loader `middleware.ts` uses: the CDA's redirect table for one site.
|
|
86
|
+
*
|
|
87
|
+
* It calls the endpoint with `fetch` rather than through `@viglet/shio-client`, because the SDK's
|
|
88
|
+
* `getRedirects` is newer than the release this template installs. A Shio instance that predates the
|
|
89
|
+
* endpoint answers 404, which is an empty table here rather than an error.
|
|
90
|
+
*/
|
|
91
|
+
export function cdaRedirects(
|
|
92
|
+
baseUrl: string,
|
|
93
|
+
siteId: string,
|
|
94
|
+
token?: string
|
|
95
|
+
): () => Promise<Redirect[] | null> {
|
|
96
|
+
return async () => {
|
|
97
|
+
if (!siteId) return null;
|
|
98
|
+
const url = `${baseUrl.replace(/\/+$/, "")}/api/v2/cda/site/${encodeURIComponent(siteId)}/redirects`;
|
|
99
|
+
const response = await fetch(url, {
|
|
100
|
+
headers: { Accept: "application/json", ...(token ? { Key: token } : {}) },
|
|
101
|
+
});
|
|
102
|
+
if (response.status === 404) return [];
|
|
103
|
+
if (!response.ok) throw new Error(`redirect table: HTTP ${response.status}`);
|
|
104
|
+
return (await response.json()) as Redirect[];
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { cdaRedirects, redirectLookup } from "./lib/redirects";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Answers a path that moved with the redirect written for it in Shio (roadmap SH1133).
|
|
6
|
+
*
|
|
7
|
+
* Shio's own delivery honours its redirects; this site does not go through that delivery, so the
|
|
8
|
+
* table is consulted here, before any page is looked up. A redirect's source is its own address in
|
|
9
|
+
* Shio, so it never shadows a page: the CMS refuses a redirect over a page that exists.
|
|
10
|
+
*/
|
|
11
|
+
const moved = redirectLookup(
|
|
12
|
+
cdaRedirects(
|
|
13
|
+
process.env.SHIO_URL ?? "http://localhost:2710",
|
|
14
|
+
process.env.SHIO_SITE_ID ?? "",
|
|
15
|
+
process.env.SHIO_CDA_TOKEN
|
|
16
|
+
)
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export async function middleware(request: NextRequest) {
|
|
20
|
+
const found = await moved(request.nextUrl.pathname);
|
|
21
|
+
if (!found) return NextResponse.next();
|
|
22
|
+
// A relative destination is on this site; an absolute one is passed through as written.
|
|
23
|
+
const destination = new URL(found.destination, request.url);
|
|
24
|
+
if (!destination.search) destination.search = request.nextUrl.search;
|
|
25
|
+
return NextResponse.redirect(destination, found.status);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const config = {
|
|
29
|
+
// Everything but Next's own assets and this app's route handlers; a moved path may end in `.html`.
|
|
30
|
+
matcher: ["/((?!_next/|api/).*)"],
|
|
31
|
+
};
|