-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathweb.cppm
More file actions
465 lines (420 loc) · 21.9 KB
/
Copy pathweb.cppm
File metadata and controls
465 lines (420 loc) · 21.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
// mcpp.dist.web -- the wasm32-emscripten stem family becomes a static
// directory a browser can load, with a page this member writes (`index.html`
// unless `options::page` names another).
//
// WHY THIS IS NEITHER A RULE NOR A TOOL, AND WHY IT COMPILES NO TRANSLATION
// UNIT. Same shape `dist/appimage.cppm` and `dist/apple.cppm` already state:
// this member consumes what the LINK and `mcpp pack`'s STAGING already
// produced, and turns it into something a browser installs -- a directory
// served over HTTP rather than a file a package manager unpacks, but the
// same third category.
//
// WHY THE STAGED TREE ALREADY HOLDS EVERYTHING THIS MEMBER NEEDS, AND WHY
// THAT MAKES THIS MEMBER SMALL. #622 A5 gave `wasm32-emscripten` a fixed
// naming (`bin/<name>.js`, with `bin/<name>.wasm` an implicit link output)
// and taught `mcpp pack` to stage "every file the link wrote with this
// stem" beside it (`.data` when `--preload-file` is present, `.worker.js`,
// `.wasm.map`); #622 A4 taught `mcpp::deploy` to land its files at the same
// executable-relative path, which for this row is also under `bin/`. So
// `${mcpp.stage_dir}/bin/` already contains exactly the file set the A4
// table's web row names ("the same relative path in the static directory,
// served beside `<name>.js`") -- this member's own job is to copy that one
// directory to `<out_dir>/web/`, dropping the `bin/` prefix a browser has
// no use for, and to write the one file neither the link nor `mcpp::deploy`
// produces: a page that loads the script.
//
// A DEPLOY'D FILE'S RELATIVE PATH IS TAKEN FROM THE STAGED TREE, AND NOT
// RECOMPUTED FROM `to`, ON PURPOSE. `mcpp::deploy(from, to)`'s `to` names a
// DIRECTORY, executable-relative -- `types.cppm`'s own `DeployEntry` comment
// ("a file placed in a directory relative to the executable") and the design
// record's own criterion (`to = "myapp.resources/huxerui"` with `from =
// ".../resources.bin"` lands at `bin/myapp.resources/huxerui/resources.bin`,
// keeping `from`'s own basename) -- so a caller who passes a full destination
// FILE path as `to` (as `tests/web-consumer/build.mcpp` once did, by mistake)
// gets exactly that: the file deployed INTO a directory of that name,
// nested under its own basename a second time. This member has no accessor
// for a deploy directive's `to` and cannot see that mistake from here; what
// it CAN do, and does, is mirror the staged tree's actual relative paths
// faithfully rather than reconstructing them from a rule it would then have
// to get right twice. A caller's `to` is therefore the whole of the
// contract, and this member's own fixture is the worked example of getting
// it right: `to = "assets"`, not `to = "assets/greeting.txt"`.
//
// REFUSED BY NAME ON EVERY OTHER TARGET, deliberately, and not merely
// because no other row happens to produce a `.js` launcher: `--format web`
// on an ELF or Mach-O row would otherwise silently ship a native binary
// inside a directory named for a browser, which is a worse failure than a
// refusal naming the one target this format serves.
//
// THE ENGINE COPIES, NOT `cp`. Each staged file was one `cp SRC DST` action,
// argv only, no shell -- exactly the shape `mcpp::action` is built for (a
// graph edge per file, skippable on a cache hit) -- and that made this
// member POSIX-host only, because neither precedent (`dist/appimage.cppm`'s
// files handed straight to `appimagetool`'s own argument list; `dist/
// apple.cppm`'s directories copied with `ditto`, macOS only) is a portable
// multi-file copier. Lifting that needed either a `copy`-argv branch on the
// host OS or a small copier this member carried itself, and both are the
// wrong shape: `cmd /c copy` is a shell, is the 8191-character limit, and is
// the switch-quoting this repository has already been bitten by twice; a
// copier carried by the member is a host tool sub-build (#355) for one `cp`.
//
// The engine already has the copier. `mcpp stage --output <dst> <src>` is
// the subcommand every `stage_file` edge in `build.ninja` already runs: it
// creates the destination's parent, compares content and writes only on
// difference. `${mcpp.self}`, an action argv substitution for the engine's
// own absolute path, is what lets an action NAME it, so each copy step is
// `{ "${mcpp.self}", "stage", "--verify", "content", "--output", dst, src }`
// -- `--verify content` spelled out rather than defaulted, because a
// contract must not depend on which of "the help text's default" (`size`)
// and "the code's default" (`content`) a reader believes. Plan-time
// `create_directories` is gone with it: `stage` creates the destination's
// parent itself.
//
// `${mcpp.self}` AND `mcpp stage`'S ARGUMENT SHAPE ARE THE ENGINE CONTRACT
// SINCE 2026.9.13.1, not a convenience this member happens to use -- see
// docs/30's substitution table in that release. This member's floor is that
// release for exactly this reason: an older engine leaves `${mcpp.self}`
// literal in the command, and the action fails at run time with a
// not-found for a path that reads as a token.
//
// WHY `index.html` IS WRITTEN AT PLAN TIME TO A SIDE FILE AND COPIED, RATHER
// THAN WRITTEN DIRECTLY TO ITS FINAL PATH. `dist/apple.cppm`'s own header
// gives the reason and it applies unchanged: a stray write to a path no
// action declares is invisible to the graph, so a template or title change
// would leave the previous page in place, reported as up to date. Declaring
// the side file as the copy action's input is what makes an edit reach
// `<out_dir>/web/index.html`.
module;
#include <cstdio>
export module mcpp.dist.web;
import std;
import mcpp;
import mcpp.plugins;
export namespace mcpp::dist::web {
// ─── Options ───────────────────────────────────────────────────────────────
struct options {
// The program target this directory wraps. Empty means the package
// name, which is the target `mcpp pack` itself selects by convention --
// the same field every other `dist-*` member's `options` starts with.
std::string target;
// A project file, package-root-relative, rendered with `{{name}}` (the
// target's name, the `.js` launcher's stem) and `{{title}}` (the page
// `<title>`, see below) substituted verbatim wherever they appear.
// Empty uses the built-in default, which loads the script with a plain
// `<script src="<name>.js">` -- correct for a `bin` whose `main` runs on
// load. A project whose page instead calls an exported factory (a
// `-sMODULARIZE=1` build, #622's own manifest example) supplies its own
// template, because which shape the page needs is the program's
// contract, not this member's guess.
std::string template_file;
// `{{title}}` in the template. Empty means `[package] name`.
std::string title;
// The page's file name inside the produced directory. Empty means
// `index.html`, what a static file server answers for the directory itself.
// A bare `*.html` name with no directory component: an Emscripten build
// through CMake names the page after the target (`<target>.html`), and a
// project that ships such a page keeps its name (#649 P2). A name with a
// separator, or without the `.html` extension, is refused at plan time.
std::string page;
// Where the produced directory lands. Empty means `<out_dir>/web`.
std::string output;
std::string out_dir = std::string(mcpp::out_dir());
};
// ─── The plan ──────────────────────────────────────────────────────────────
struct step {
const char* id;
const char* role;
const char* description;
std::vector<std::string> argv;
std::vector<std::string> inputs;
std::vector<std::string> outputs;
};
struct plan {
// False when this build is not `mcpp pack --format web`, which is every
// ordinary build. `reason` then says which of the several ways.
bool applies = false;
std::string reason;
std::string web_dir; // <out_dir>/web
std::string stage_bin; // ${mcpp.stage_dir}/bin, kept for the floor check
std::vector<step> steps;
explicit operator bool() const { return applies; }
};
// ─── Internals ─────────────────────────────────────────────────────────────
inline bool is_file(const std::string& p) {
std::error_code ec;
return !p.empty() && std::filesystem::is_regular_file(p, ec);
}
// Written only when the bytes differ, for the reason every other member in
// this collection gives: rewriting identical bytes moves the mtime, and a
// moved mtime on a declared input is indistinguishable from a changed one --
// so a second pack of an unchanged project would re-copy `index.html`.
inline bool write_if_different(const std::filesystem::path& path,
std::string_view bytes) {
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
if (std::ifstream in(path, std::ios::binary); in) {
std::string old((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
if (old == bytes) return true;
}
std::ofstream out(path, std::ios::binary | std::ios::trunc);
if (!out) return false;
out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
return static_cast<bool>(out);
}
inline std::string target_for(const options& opt) {
if (!opt.target.empty()) return opt.target;
const char* n = mcpp::package_name();
return (n && *n) ? std::string(n) : std::string();
}
// The page name `options::page` asks for, or the reason it cannot be used.
// Bare, because the page is written beside the launcher it loads with a
// relative `<script src>`; a page in a subdirectory would load nothing.
inline std::expected<std::string, std::string> page_name(const options& opt) {
if (opt.page.empty()) return std::string("index.html");
const std::string& n = opt.page;
if (n.find('/') != std::string::npos || n.find('\\') != std::string::npos)
return std::unexpected(std::format(
"`options::page` names '{}', which has a directory component; the "
"page is written beside `<name>.js`, so it is a bare file name", n));
if (n == ".html" || n.size() <= 5 || !n.ends_with(".html"))
return std::unexpected(std::format(
"`options::page` names '{}', which is not a `*.html` file name", n));
if (n.starts_with("."))
return std::unexpected(std::format(
"`options::page` names '{}', a hidden file a server does not list", n));
return n;
}
inline std::string replace_all_copy(std::string s, std::string_view from, std::string_view to) {
if (from.empty()) return s;
std::size_t pos = 0;
while ((pos = s.find(from, pos)) != std::string::npos) {
s.replace(pos, from.size(), to);
pos += to.size();
}
return s;
}
// No bundler, no hashing, and no shape a project cannot already read: a
// `<script>` tag and nothing else. A modularised program supplies its own
// template rather than this member growing a second default.
inline std::string default_template(const std::string& name, const std::string& title) {
return std::format(
"<!doctype html>\n"
"<meta charset=\"utf-8\">\n"
"<title>{0}</title>\n"
"<script src=\"{1}.js\"></script>\n",
title, name);
}
// ─── Plan ──────────────────────────────────────────────────────────────────
inline plan plan_for(options opt = {}) {
plan p;
// NOT THIS PASS. Every ordinary build lands here, and the empty
// `pack_format()` is what says so -- see `generate` for why the
// DECLARATION must not be gated the same way.
const std::string requested = mcpp::pack_format();
if (requested != "web") {
p.reason = requested.empty()
? "this build is not packaging"
: std::format("--format {} was requested, not web", requested);
return p;
}
// wasm32-emscripten only, and this is a refusal rather than a silent
// skip: a user who typed `--format web` on a native row asked for
// something that does not exist there, and the engine has already
// accepted the value because the graph declared it.
if (const std::string os = mcpp::target_os(); os != "emscripten") {
std::cerr << std::format(
"mcpp.dist.web: a web directory wraps a wasm32-emscripten "
"program, and this build targets '{}'.\n"
" use: --format tar, or build for --target wasm32-emscripten",
os.empty() ? "unknown" : os) << '\n';
p.reason = "not the emscripten target";
return p;
}
const std::string stage = mcpp::pack_stage_dir();
if (stage.empty()) {
std::cerr << "mcpp.dist.web: mcpp reported no staged tree. This "
"member needs mcpp 2026.9.12.2 or newer.\n";
p.reason = "no staged tree";
return p;
}
const std::string target = target_for(opt);
if (target.empty()) {
std::cerr << "mcpp.dist.web: no target to wrap. Set "
"`options::target` to the program target's name.\n";
p.reason = "no target";
return p;
}
// THE LAUNCHER'S NAME IS NOT DISCOVERED -- IT IS THE ONE #622 A5 FIXED.
// Every `wasm32-emscripten` `bin`/`app` names its launcher
// `bin/<name>.js`, on every host, by the naming this member's own
// engine floor guarantees; there is no second convention to search the
// way `dist/appimage.cppm`'s `launcher_in` has to.
const std::string stageBin = stage + "/bin";
const std::string launcher = stageBin + "/" + target + ".js";
if (!is_file(launcher)) {
std::cerr << std::format(
"mcpp.dist.web: the staged tree at {0} carries no launcher for "
"target '{1}'.\n"
" expected: {0}/{1}.js",
stageBin, target) << '\n';
p.reason = "no launcher in the staged tree";
return p;
}
const auto pageFile = page_name(opt);
if (!pageFile) {
std::cerr << "mcpp.dist.web: " << pageFile.error() << '\n';
mcpp::warning(("mcpp.dist.web: " + pageFile.error()).c_str());
p.reason = "page name refused";
return p;
}
if (!opt.template_file.empty()) {
const std::string tpl =
(std::filesystem::path(mcpp::manifest_dir()) / opt.template_file).string();
if (!is_file(tpl)) {
std::cerr << std::format(
"mcpp.dist.web: the template {} was not found", tpl) << '\n';
p.reason = "template not found";
return p;
}
}
const char* nm = mcpp::package_name();
const std::string title = !opt.title.empty() ? opt.title
: (nm && *nm ? std::string(nm) : target);
std::string templateBytes;
if (!opt.template_file.empty()) {
std::ifstream in((std::filesystem::path(mcpp::manifest_dir()) / opt.template_file),
std::ios::binary);
templateBytes.assign((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
} else {
templateBytes = default_template(target, title);
}
templateBytes = replace_all_copy(std::move(templateBytes), "{{name}}", target);
templateBytes = replace_all_copy(std::move(templateBytes), "{{title}}", title);
const std::string webDir = !opt.output.empty() ? opt.output
: (std::filesystem::path(opt.out_dir) / "web").string();
// The side file carries the page name, so two packs of one program with
// different pages do not overwrite each other's rendering; the default
// keeps the name 0.11 wrote (`<target>-index.html`).
const std::string indexSrc =
(std::filesystem::path(opt.out_dir) / (target + "-" + *pageFile)).string();
if (!write_if_different(indexSrc, templateBytes)) {
std::cerr << std::format("mcpp.dist.web: cannot write {}", indexSrc) << '\n';
p.reason = std::format("cannot write {}", *pageFile);
return p;
}
p.web_dir = webDir;
p.stage_bin = stageBin;
// ── The stem family and every deploy'd file, discovered where they
// already live: `${mcpp.stage_dir}/bin/`. #622 A5's implicit `.wasm`
// output, an optional `.data`, and #622 A4's deploy'd files (staged
// executable-relative, which on this row IS `bin/`-relative) all land
// here through the engine's OWN staging, so this member reads the
// directory rather than re-deriving the stem family or a deploy list a
// build program has no accessor for. Sorted for a deterministic plan.
std::vector<std::string> relFiles;
{
std::error_code ec;
for (auto const& e : std::filesystem::recursive_directory_iterator(stageBin, ec)) {
if (ec) break;
if (!e.is_regular_file(ec)) continue;
relFiles.push_back(
std::filesystem::relative(e.path(), stageBin).generic_string());
}
std::ranges::sort(relFiles);
}
if (relFiles.empty()) {
// Cannot happen: `launcher` above already proved `<target>.js`
// exists under `stageBin`. Kept as a named refusal rather than an
// assertion, because a member's job on a broken invariant is to say
// so, not to crash the build program that is packaging someone
// else's project.
std::cerr << std::format(
"mcpp.dist.web: {} carries no files to stage", stageBin) << '\n';
p.reason = "staged bin/ is empty";
return p;
}
for (auto const& rel : relFiles) {
const std::string dst = webDir + "/" + rel;
// No `create_directories` here: `mcpp stage` creates the
// destination's parent itself, which is the part of this step the
// engine now does that the member used to.
step s;
s.id = "mcpp.dist.web.file";
s.role = "artifact";
s.description = "WEB FILE";
// `${mcpp.stage_dir}` in the argv, not the absolute path this
// program just read `stageBin` from -- the same reasoning every
// other member gives: the path in the graph and the path here
// cannot disagree, and naming it earns this action the engine's
// automatic dependency on the staged tree's manifest. `${mcpp.self}`
// is the same substitution family, naming the engine's own
// executable so this action's command is an argv the engine
// interprets on every host, with no shell and no host-specific copy
// tool.
const std::string src = "${mcpp.stage_dir}/bin/" + rel;
s.argv = { "${mcpp.self}", "stage", "--verify", "content", "--output", dst, src };
s.inputs = { src };
s.outputs = { dst };
p.steps.push_back(std::move(s));
}
step page;
page.id = "mcpp.dist.web.index";
page.role = "artifact";
page.description = "INDEX.HTML";
page.argv = { "${mcpp.self}", "stage", "--verify", "content", "--output",
webDir + "/" + *pageFile, indexSrc };
page.inputs = { indexSrc };
page.outputs = { webDir + "/" + *pageFile };
p.steps.push_back(std::move(page));
p.applies = true;
return p;
}
// ─── Submit ────────────────────────────────────────────────────────────────
inline bool submit(const plan& p) {
if (!p.applies) return true;
for (auto const& s : p.steps) {
mcpp::action a;
a.id = s.id;
a.role = s.role;
a.description = s.description;
for (auto const& tok : s.argv) a.arg(tok.c_str());
for (auto const& in : s.inputs) a.input(in.c_str());
for (auto const& out : s.outputs) a.output(out.c_str());
a.submit();
}
// A FLOOR ON THIS MEMBER'S OWN OUTPUT, ON THE SUCCESS PATH -- the same
// shape `dist/appimage.cppm` and `dist/apple.cppm` both give: the copies
// have not run yet when this program exits, only been declared, so what
// is measured is the staged tree the copy steps are about to read.
std::error_code ec;
std::uintmax_t bytes = 0;
for (auto const& e : std::filesystem::recursive_directory_iterator(p.stage_bin, ec)) {
if (ec) break;
if (e.is_regular_file(ec)) bytes += std::filesystem::file_size(e.path(), ec);
}
// Loose on purpose, matching this collection's own bound: this exists to
// catch "nothing was linked or staged", not to police a size budget. A
// wasm launcher plus its module is comfortably larger than this on any
// program that links `import std`.
if (bytes < 4u * 1024u) {
static char msg[512];
std::snprintf(msg, sizeof msg,
"mcpp.dist.web: %s holds only %llu bytes, which is not a "
"program; the page will load nothing useful",
p.stage_bin.c_str(), static_cast<unsigned long long>(bytes));
mcpp::warning(msg);
}
return true;
}
// ─── The one call a consumer makes ─────────────────────────────────────────
// DECLARE UNCONDITIONALLY, SUBMIT CONDITIONALLY -- and both halves are here
// so a consumer cannot do one without the other. See `dist/appimage.cppm`'s
// own comment on this function for why the declaration cannot be gated the
// same way `plan_for` gates its submission.
inline bool generate(options opt = {}) {
mcpp::provides_pack_format("web");
return submit(plan_for(std::move(opt)));
}
} // namespace mcpp::dist::web