diff --git a/README.md b/README.md
index 4cb1fdd..d091f3f 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,24 @@ License: GPL-3.0-or-later.
Built something on wayBetter? Add it to this list with a pull request: name and link, what it extends, one line on what it does.
+## Build your own
+
+```sh
+npm create waybetter plugin # a *.plugin.js (or --ts: a TypeScript project) on the real plugin API
+npm create waybetter theme # a theme JSON with "$schema", a derived palette and font roles
+npm create waybetter app -- --site kick.com # a whole app: Tauri 2 + waybetter-host + runtime + shell
+```
+
+`create-waybetter` (in `packages/create-waybetter`) asks for what it needs in a terminal, and
+takes every answer as a flag (`--help`). The app skeleton is this README in code: the native
+`SiteSpec`, the page `SiteAdapter`, the three capability files, a shell that reports the tab
+rectangle (`tabs_sync`), the plugin store and one site-only command, with presets for
+youtube.com, kick.com and twitch.tv and every site selector marked as a placeholder. Its tests
+generate each kind into a temp folder and check it against the real contracts: plugin headers with
+`plugins::parse_meta`, plugin code against `createCorePluginApi`'s members, themes against
+`theme.schema.json`, the app's TypeScript with `tsc` against the built package and its Rust with
+`cargo check`/`cargo test` against this crate.
+
## Layout
```
@@ -74,6 +92,8 @@ packages/shell/src/ shell core (TypeScript, no dependencies)
updateModel.ts titlebar update chip
windowControls.ts caption buttons, resize grips, system menu (Tauri API injected)
chrome.ts titlebar/toolbar/resize-border sizes (match policy.rs)
+packages/waybetter/ the published npm package (runtime + shell as ESM + .d.ts, theme.schema.json)
+packages/create-waybetter/ `npm create waybetter`: plugin, theme and app scaffolds (no dependencies)
```
Both TypeScript packages are plain source (`.ts`, ESM, `type: module`) with no runtime
@@ -202,12 +222,16 @@ wayBetter for X adds this repository as a git submodule at `waybetter/` and cons
```sh
bun install # typescript + @types/node for the checks
-bun run typecheck # tsc over both packages and their tests
-bun run test # node --test (runtime + shell) and cargo test (headless host)
+bun run typecheck # tsc over both packages and their tests, and create-waybetter (checkJs)
+bun run test # node --test (runtime + shell), cargo test (headless host), create-waybetter
bun run test:ts
bun run test:rust
+bun run test:create # scaffolds into temp dirs; its cargo check of an app is ~1 min cold
+ # (WAYBETTER_SKIP_CARGO_CHECK=1 skips it; needs the local crate cache)
+bun run check # typecheck + build:packages + test
bun run check:tauri # cargo check with the tauri feature (slow: builds Tauri)
bun run build # typecheck + cargo build
+bun run pack:check # npm pack --dry-run of waybetter and create-waybetter
```
Requires Bun 1.4.2+, Node 24+ and Rust 1.88+.
diff --git a/bun.lock b/bun.lock
index 501b592..4c45498 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,6 +9,13 @@
"typescript": "^5.9.0",
},
},
+ "packages/create-waybetter": {
+ "name": "create-waybetter",
+ "version": "0.1.0",
+ "bin": {
+ "create-waybetter": "bin/create-waybetter.js",
+ },
+ },
"packages/runtime": {
"name": "@waybetter/runtime",
"version": "0.1.0",
@@ -17,6 +24,14 @@
"name": "@waybetter/shell",
"version": "0.1.0",
},
+ "packages/waybetter": {
+ "name": "waybetter",
+ "version": "0.2.0",
+ "devDependencies": {
+ "@types/node": "^24.0.0",
+ "typescript": "^5.9.0",
+ },
+ },
},
"packages": {
"@types/node": ["@types/node@24.19.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew=="],
@@ -25,8 +40,12 @@
"@waybetter/shell": ["@waybetter/shell@workspace:packages/shell"],
+ "create-waybetter": ["create-waybetter@workspace:packages/create-waybetter"],
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+
+ "waybetter": ["waybetter@workspace:packages/waybetter"],
}
}
diff --git a/crates/waybetter-host/examples/plugin_meta.rs b/crates/waybetter-host/examples/plugin_meta.rs
new file mode 100644
index 0000000..536d977
--- /dev/null
+++ b/crates/waybetter-host/examples/plugin_meta.rs
@@ -0,0 +1,32 @@
+//! Prints what the plugin store reads from `*.plugin.js` files, one JSON object per line:
+//! `parse_meta` (name, description, version, author) and `valid_file_name`. create-waybetter's
+//! tests run it on generated plugins so they are checked with the store's own rules.
+//!
+//! cargo run --example plugin_meta -- path/to/one.plugin.js [more.plugin.js ...]
+
+use std::{env, fs, path::Path, process::ExitCode};
+use waybetter_host::plugins::{parse_meta, valid_file_name};
+
+fn main() -> ExitCode {
+ for arg in env::args().skip(1) {
+ let path = Path::new(&arg);
+ let file = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
+ let Ok(source) = fs::read_to_string(path) else {
+ eprintln!("cannot read {arg}");
+ return ExitCode::FAILURE;
+ };
+ let meta = parse_meta(file, &source);
+ println!(
+ "{}",
+ serde_json::json!({
+ "file": meta.file,
+ "name": meta.name,
+ "description": meta.description,
+ "version": meta.version,
+ "author": meta.author,
+ "validFileName": valid_file_name(file),
+ })
+ );
+ }
+ ExitCode::SUCCESS
+}
diff --git a/package.json b/package.json
index 740acfe..f46bece 100644
--- a/package.json
+++ b/package.json
@@ -10,15 +10,16 @@
"packages/*"
],
"scripts": {
- "typecheck": "tsc -p tsconfig.json",
- "test": "bun run test:ts && bun run test:rust",
+ "typecheck": "tsc -p tsconfig.json && tsc -p packages/create-waybetter/tsconfig.json",
+ "test": "bun run test:ts && bun run test:rust && bun run test:create",
"test:ts": "node --test packages/runtime/tests/*.test.ts packages/shell/tests/*.test.ts",
+ "test:create": "node --test packages/create-waybetter/tests/*.test.mjs",
"test:rust": "cargo test --manifest-path crates/waybetter-host/Cargo.toml --locked",
"check:tauri": "cargo check --manifest-path crates/waybetter-host/Cargo.toml --features tauri",
"build": "bun run typecheck && cargo build --manifest-path crates/waybetter-host/Cargo.toml --locked",
"build:packages": "cd packages/waybetter && npm run build",
- "pack:check": "bun run build:packages && npm pack ./packages/waybetter --dry-run",
- "check": "bun run typecheck && bun run test"
+ "pack:check": "bun run build:packages && npm pack ./packages/waybetter --dry-run && npm pack ./packages/create-waybetter --dry-run",
+ "check": "bun run typecheck && bun run build:packages && bun run test"
},
"devDependencies": {
"@types/node": "^24.0.0",
diff --git a/packages/create-waybetter/LICENSE b/packages/create-waybetter/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/packages/create-waybetter/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/packages/create-waybetter/README.md b/packages/create-waybetter/README.md
new file mode 100644
index 0000000..3d9f921
--- /dev/null
+++ b/packages/create-waybetter/README.md
@@ -0,0 +1,67 @@
+# create-waybetter
+
+```sh
+npm create waybetter
+```
+
+Scaffolds something for [wayBetter](https://waybetter.fyi), the framework behind wayBetter for X:
+a **plugin**, a **theme**, or a whole new **wayBetter app** for another site. It asks what it needs
+when run in a terminal; every answer is also a flag. No dependencies, Node 18.3+.
+
+## Plugin
+
+```sh
+npm create waybetter plugin -- --name "Hide Trends"
+npm create waybetter plugin -- --name "Word Badges" --ts # TypeScript project
+```
+
+One `*.plugin.js` file with the header the plugin store reads (`@name`, `@description`,
+`@version`, `@author`) and a class with `start()` / `stop()`. The example uses the real plugin
+API: `api.scope()` for cleanup, `api.storage()`, `scope.observe()`, `scope.onKey()`, the UI kit
+(`scope.ui.toast` with an action, a settings `ui.dialog` built with `ui.h`) and the theme API
+(`api.theme.current()`, `--wb-*` CSS variables). `--ts` makes a small TypeScript project typed by
+the `waybetter` package; `npm run build` writes the loadable `dist/.plugin.js` with the header
+first. `--app any` targets another wayBetter app (core API only; wayBetter for X adds
+`api.tweet`, `api.account`, `api.users`).
+
+Install: **wayBetter → Plugins → Open folder**, copy the file in, **Rescan**, turn it on.
+
+## Theme
+
+```sh
+npm create waybetter theme -- --name "Midnight Mint" --accent "#3ddc97" --font "Geist, system-ui"
+```
+
+A theme JSON with `"$schema": "https://unpkg.com/waybetter/theme.schema.json"` (editor completion
+and checks), a full palette derived from `--bg`, `--text` and `--accent` the way the runtime derives
+missing roles, and the font roles (`font.family` for the interface, `font.tweet` for post text).
+Presets: `midnight`, `paper`, `dusk`. Import it in **wayBetter → Appearance → Import**.
+
+## App
+
+```sh
+npm create waybetter app -- --site kick.com
+npm create waybetter app -- --site youtube.com --name "wayBetter for YouTube"
+```
+
+A wayBetter app skeleton, the same system as wayBetter for X: a Tauri 2 window with a trusted
+local shell and one native child webview per site tab (`waybetter-host`), the page runtime and
+shell models from the `waybetter` package, and the site adapter in its three parts:
+
+- `src-tauri/src/site.rs`: the native `SiteSpec` (hosts, runtime global, account rules);
+- `src/site.ts`: the page `SiteAdapter` (plugin API settings, account reader, link rules);
+- `src-tauri/capabilities/`: `shell`, `tab-chrome` and `site-tab`, keyed by webview label.
+
+Presets for `youtube.com`, `kick.com` and `twitch.tv` (any host works); every selector and handle
+rule is a marked PLACEHOLDER to check against the live site. The host crate comes from a git
+submodule at `waybetter/` (default), a local path (`--host-source path --host-path `) or git
+(`--host-source git`).
+
+## Options
+
+`npm create waybetter -- --help` lists every flag. `--yes` takes defaults for anything not given,
+`--dry-run` prints the files, `--force` writes into a non-empty directory.
+
+## License
+
+GPL-3.0-or-later. See [LICENSE](./LICENSE).
diff --git a/packages/create-waybetter/bin/create-waybetter.js b/packages/create-waybetter/bin/create-waybetter.js
new file mode 100644
index 0000000..268dece
--- /dev/null
+++ b/packages/create-waybetter/bin/create-waybetter.js
@@ -0,0 +1,10 @@
+#!/usr/bin/env node
+import { main } from '../src/cli.js';
+
+main(process.argv.slice(2)).then(
+ code => { process.exitCode = code; },
+ error => {
+ console.error(error);
+ process.exitCode = 1;
+ },
+);
diff --git a/packages/create-waybetter/package.json b/packages/create-waybetter/package.json
new file mode 100644
index 0000000..6d22f63
--- /dev/null
+++ b/packages/create-waybetter/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "create-waybetter",
+ "version": "0.1.0",
+ "license": "GPL-3.0-or-later",
+ "type": "module",
+ "description": "npm create waybetter: scaffold a wayBetter plugin (*.plugin.js, JS or TypeScript), theme (JSON with the waybetter theme schema) or a whole wayBetter app (Tauri 2 + waybetter-host + the waybetter runtime). No dependencies.",
+ "keywords": [
+ "waybetter",
+ "create",
+ "scaffold",
+ "plugin",
+ "theme",
+ "tauri"
+ ],
+ "homepage": "https://waybetter.fyi",
+ "repository": {
+ "type": "git",
+ "url": "git+https://git.crt.fyi/CRT/waybetter.git",
+ "directory": "packages/create-waybetter"
+ },
+ "bugs": {
+ "url": "https://git.crt.fyi/CRT/waybetter/issues"
+ },
+ "bin": {
+ "create-waybetter": "bin/create-waybetter.js"
+ },
+ "exports": {
+ ".": "./src/cli.js",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "bin",
+ "src",
+ "README.md",
+ "LICENSE"
+ ],
+ "engines": {
+ "node": ">=18.3.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "test": "cd ../.. && node --test packages/create-waybetter/tests/*.test.mjs"
+ }
+}
diff --git a/packages/create-waybetter/src/app.js b/packages/create-waybetter/src/app.js
new file mode 100644
index 0000000..2f5893b
--- /dev/null
+++ b/packages/create-waybetter/src/app.js
@@ -0,0 +1,1250 @@
+// `npm create waybetter -- app`: a whole wayBetter app skeleton for one site, per "One system for
+// every app" in the waybetter README: a Tauri 2 window (trusted local shell + one child webview
+// per site tab) on the `waybetter-host` crate, the page runtime and shell models from the
+// `waybetter` package, and the site adapter in its three parts (native SiteSpec, page SiteAdapter,
+// capabilities). Selectors are placeholders to fill in from the site's own DOM.
+
+import { deflateSync } from 'node:zlib';
+import { UsageError, camelCase, headerValue, lines, pascalCase, rustStr, slugify, str } from './names.js';
+
+/**
+ * Known starting points. Everything site-specific is a placeholder until checked against the
+ * live site in devtools: selectors, handle rules, the avatar CDN.
+ */
+export const SITE_PRESETS = {
+ 'youtube.com': {
+ label: 'YouTube',
+ hosts: ['www.youtube.com'],
+ apiMarks: ['/youtubei/'],
+ avatarPrefixes: ['https://yt3.ggpht.com/'],
+ handle: { min: 3, max: 30, extra: '._-', sample: 'example_channel' },
+ selectors: {
+ item: 'ytd-rich-item-renderer, ytd-video-renderer',
+ accountLink: 'ytd-active-account-header-renderer a[href^="/@"]',
+ accountAvatar: '#avatar-btn img',
+ signedOut: 'ytd-button-renderer a[href*="ServiceLogin"]',
+ itemLink: 'a[href^="/watch"], a[href^="/@"], a[href^="/shorts/"]',
+ },
+ itemPath: '^/(watch|shorts/|@)',
+ accent: '#ff0033',
+ },
+ 'kick.com': {
+ label: 'Kick',
+ hosts: ['kick.com'],
+ apiMarks: ['/api/'],
+ avatarPrefixes: ['https://files.kick.com/'],
+ handle: { min: 3, max: 25, extra: '_', sample: 'example_user' },
+ selectors: {
+ item: '[data-chat-entry]',
+ accountLink: 'a[data-testid="user-profile-link"]',
+ accountAvatar: 'button[data-testid="user-menu"] img',
+ signedOut: 'button[data-testid="login"]',
+ itemLink: 'a[href^="/"]',
+ },
+ itemPath: '^/[A-Za-z0-9_]{3,25}(/|$)',
+ accent: '#53fc18',
+ },
+ 'twitch.tv': {
+ label: 'Twitch',
+ hosts: ['www.twitch.tv'],
+ apiMarks: ['gql.twitch.tv'],
+ avatarPrefixes: ['https://static-cdn.jtvnw.net/'],
+ handle: { min: 4, max: 25, extra: '_', sample: 'example_user' },
+ selectors: {
+ item: '[data-a-target="chat-line-message"]',
+ accountLink: '[data-a-target="user-display-name"]',
+ accountAvatar: '[data-a-target="user-menu-toggle"] img',
+ signedOut: '[data-a-target="login-button"]',
+ itemLink: 'a[data-a-target="preview-card-image-link"]',
+ },
+ itemPath: '^/[A-Za-z0-9_]{4,25}(/|$)',
+ accent: '#9146ff',
+ },
+};
+
+const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
+
+/**
+ * @typedef {{ name?: string, site: string, global?: string, identifier?: string,
+ * hostSource?: string, hostPath?: string, gitUrl?: string }} AppInput
+ */
+
+/** @param {AppInput} input */
+export function normalizeAppOptions(input) {
+ const raw = String(input.site ?? '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
+ if (!raw) throw new UsageError('--site is required (for example youtube.com, kick.com, twitch.tv).');
+ const presetKey = raw.replace(/^www\./, '');
+ const preset = SITE_PRESETS[/** @type {keyof typeof SITE_PRESETS} */ (presetKey)];
+ if (!HOSTNAME.test(raw)) throw new UsageError(`--site "${input.site}" is not a host name like kick.com.`);
+ const label = preset?.label ?? pascalCase(presetKey.split('.')[0]);
+ const site = preset ?? {
+ label,
+ hosts: [raw],
+ apiMarks: ['/api/'],
+ avatarPrefixes: [`https://${raw}/`],
+ handle: { min: 1, max: 30, extra: '_', sample: 'example_user' },
+ selectors: {
+ item: '[data-example-item]',
+ accountLink: '[data-example-account] a[href^="/"]',
+ accountAvatar: '[data-example-account] img',
+ signedOut: '[data-example-sign-in]',
+ itemLink: 'a[href^="/"]',
+ },
+ itemPath: '^/[^/]+/?$',
+ accent: '#3b82f6',
+ };
+ const product = headerValue('App name', input.name || `wayBetter for ${label}`, { max: 60, required: true });
+ const slug = slugify(product, 48);
+ if (!slug) throw new UsageError(`App name "${product}" needs at least one letter or digit.`);
+ const runtimeGlobal = String(input.global || pascalCase(product)).trim();
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/.test(runtimeGlobal) || runtimeGlobal === 'wayBetter') {
+ throw new UsageError(`--global "${runtimeGlobal}" must be a JS identifier (and not "wayBetter", which is the alias every app sets).`);
+ }
+ const identifier = String(input.identifier || `com.example.${slug.replace(/-/g, '')}`).trim();
+ if (!/^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)+$/.test(identifier)) {
+ throw new UsageError(`--identifier "${identifier}" must be reverse-DNS like com.example.myapp.`);
+ }
+ const hostSource = String(input.hostSource || 'submodule');
+ if (!['submodule', 'path', 'git'].includes(hostSource)) throw new UsageError('--host-source must be submodule, path or git.');
+ if (hostSource === 'path' && !input.hostPath) throw new UsageError('--host-source path needs --host-path .');
+ const key = slugify(label, 24) || 'site';
+ return {
+ product,
+ slug,
+ crate: slug,
+ lib: `${slug.replace(/-/g, '_')}_lib`,
+ label,
+ site,
+ runtimeGlobal,
+ identifier,
+ hostSource,
+ hostPath: input.hostPath ? String(input.hostPath).replace(/\\/g, '/') : '',
+ gitUrl: String(input.gitUrl || 'https://git.crt.fyi/CRT/waybetter.git'),
+ prefix: `wb-${key}`,
+ dataKey: camelCase(`wb ${key} plugin`),
+ origin: `https://${site.hosts[0]}`,
+ };
+}
+
+/** @typedef {ReturnType} AppOptions */
+
+/** Where `npm run build` writes what native code and Tauri load (tests check these exist). */
+export const APP_OUTPUTS = { runtime: 'src-tauri/runtime/runtime.js', shell: 'dist/index.html' };
+
+/** The native commands and who may call them (build.rs manifest and capability files). */
+const COMMANDS = {
+ shell: ['tabs_sync', 'plugins_list', 'plugin_set_enabled', 'plugins_open_folder'],
+ tab: ['tab_report'],
+ site: ['site_ready'],
+};
+const permission = command => `allow-${command.replace(/_/g, '-')}`;
+
+// ---------------------------------------------------------------------------------------------
+// Icons: a small RGBA PNG (Tauri decodes it for the window icon) and an ICO wrapping it (the
+// Windows resource). Replace both with real art (`npx tauri icon art.png`).
+
+function crc32(bytes) {
+ let crc = ~0;
+ for (const byte of bytes) {
+ crc ^= byte;
+ for (let k = 0; k < 8; k += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
+ }
+ return ~crc >>> 0;
+}
+
+function chunk(type, data) {
+ const out = Buffer.alloc(12 + data.length);
+ out.writeUInt32BE(data.length, 0);
+ out.write(type, 4, 'ascii');
+ data.copy(out, 8);
+ out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
+ return out;
+}
+
+/** A `size`x`size` RGBA PNG: a rounded square in `color` with a lighter ring. */
+export function iconPng(size, color) {
+ const [r, g, b] = [1, 3, 5].map(i => parseInt(color.slice(i, i + 2), 16));
+ const raw = Buffer.alloc((size * 4 + 1) * size);
+ const radius = size * 0.22;
+ for (let y = 0; y < size; y += 1) {
+ raw[y * (size * 4 + 1)] = 0;
+ for (let x = 0; x < size; x += 1) {
+ const dx = Math.max(radius - x - 0.5, x + 0.5 - (size - radius), 0);
+ const dy = Math.max(radius - y - 0.5, y + 0.5 - (size - radius), 0);
+ const inside = Math.hypot(dx, dy) <= radius;
+ const ring = Math.abs(Math.hypot(x + 0.5 - size / 2, y + 0.5 - size / 2) - size * 0.26) < size * 0.06;
+ const at = y * (size * 4 + 1) + 1 + x * 4;
+ raw[at] = ring ? 255 : r;
+ raw[at + 1] = ring ? 255 : g;
+ raw[at + 2] = ring ? 255 : b;
+ raw[at + 3] = inside ? 255 : 0;
+ }
+ }
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(size, 0);
+ ihdr.writeUInt32BE(size, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 6; // RGBA
+ return Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ chunk('IHDR', ihdr),
+ chunk('IDAT', deflateSync(raw)),
+ chunk('IEND', Buffer.alloc(0)),
+ ]);
+}
+
+/** An ICO with one PNG image (Windows Vista+ and the `ico` crate read PNG entries). */
+export function iconIco(png, size) {
+ const head = Buffer.alloc(22);
+ head.writeUInt16LE(0, 0);
+ head.writeUInt16LE(1, 2); // icon
+ head.writeUInt16LE(1, 4); // one image
+ head[6] = size >= 256 ? 0 : size;
+ head[7] = size >= 256 ? 0 : size;
+ head.writeUInt16LE(1, 10); // planes
+ head.writeUInt16LE(32, 12); // bits per pixel
+ head.writeUInt32LE(png.length, 14);
+ head.writeUInt32LE(22, 18);
+ return Buffer.concat([head, png]);
+}
+
+// ---------------------------------------------------------------------------------------------
+// Rust
+
+/** @param {AppOptions} o */
+function hostDependency(o) {
+ if (o.hostSource === 'git') return `waybetter-host = { git = ${rustStr(o.gitUrl)} }`;
+ if (o.hostSource === 'path') {
+ const path = /\/crates\/waybetter-host\/?$/.test(o.hostPath) ? o.hostPath.replace(/\/$/, '') : `${o.hostPath.replace(/\/$/, '')}/crates/waybetter-host`;
+ return `waybetter-host = { path = ${rustStr(path)} }`;
+ }
+ return 'waybetter-host = { path = "../waybetter/crates/waybetter-host" }';
+}
+
+/** @param {AppOptions} o */
+function cargoToml(o) {
+ return lines(
+ '[package]',
+ `name = ${rustStr(o.crate)}`,
+ 'version = "0.1.0"',
+ `description = ${rustStr(`${o.product}: ${o.site.hosts[0]} in a light native window, extended in place`)}`,
+ '# waybetter-host is GPL-3.0-or-later, so an app built on it is too.',
+ 'license = "GPL-3.0-or-later"',
+ 'edition = "2021"',
+ 'rust-version = "1.88"',
+ '',
+ '[lib]',
+ `name = ${rustStr(o.lib)}`,
+ 'path = "src/lib.rs"',
+ '',
+ '[[bin]]',
+ `name = ${rustStr(o.crate)}`,
+ 'path = "src/main.rs"',
+ 'required-features = ["desktop"]',
+ '',
+ '[features]',
+ '# `desktop` is the Tauri app; without it (`--no-default-features`) the site adapter and plugin',
+ '# host build and test headless.',
+ 'default = ["desktop"]',
+ 'desktop = ["dep:tauri", "dep:tauri-build", "waybetter-host/tauri"]',
+ '',
+ '[build-dependencies]',
+ 'tauri-build = { version = "2", features = [], optional = true }',
+ '',
+ '[dependencies]',
+ '# The wayBetter framework: security policy, profiles, plugin store, tab payloads and, with',
+ '# the desktop feature, its Tauri glue.',
+ hostDependency(o),
+ 'tauri = { version = "2", features = ["unstable"], optional = true }',
+ 'serde = { version = "1", features = ["derive"] }',
+ 'serde_json = "1"',
+ 'url = "2"',
+ );
+}
+
+function buildRs() {
+ const all = [...COMMANDS.shell, ...COMMANDS.tab, ...COMMANDS.site];
+ return lines(
+ 'fn main() {',
+ ' // The app-command manifest: tauri-build generates an `allow-` permission for each,',
+ ' // which the capability files grant per webview label.',
+ ' #[cfg(feature = "desktop")]',
+ ' tauri_build::try_build(tauri_build::Attributes::new().app_manifest(',
+ ` tauri_build::AppManifest::new().commands(&[${all.map(c => `"${c}"`).join(', ')}]),`,
+ ' ))',
+ ' .expect("Tauri build configuration must be valid");',
+ '}',
+ );
+}
+
+/** @param {AppOptions} o */
+function siteRs(o) {
+ const { handle } = o.site;
+ const extra = [...handle.extra].map(ch => `b'${ch === "'" ? "\\'" : ch}'`);
+ const byteRule = extra.length ? `b.is_ascii_alphanumeric() || ${extra.map(ch => `b == ${ch}`).join(' || ')}` : 'b.is_ascii_alphanumeric()';
+ return lines(
+ `//! The native half of the ${o.label} site adapter: which webviews are site tabs, the page`,
+ '//! runtime\'s global, and how the account seen inside a profile is validated.',
+ '',
+ 'use waybetter_host::site::{AccountRules, SiteSpec};',
+ '',
+ `/// PLACEHOLDER: a handle ${o.label} could really have (without \`@\`). Check the site's own rules.`,
+ 'pub fn valid_handle(handle: &str) -> bool {',
+ ` (${handle.min}..=${handle.max}).contains(&handle.len()) && handle.bytes().all(|b| ${byteRule})`,
+ '}',
+ '',
+ 'pub static SITE: SiteSpec = SiteSpec {',
+ ` product: ${rustStr(o.product)},`,
+ ' // Exact https origin(s) of site tabs: the only definition of "a site tab".',
+ ` hosts: &[${o.site.hosts.map(rustStr).join(', ')}],`,
+ ' // window. of the page runtime (src/runtime/main.ts); plugins use the',
+ ' // `window.wayBetter` alias.',
+ ` runtime_global: ${rustStr(o.runtimeGlobal)},`,
+ ' accounts: AccountRules {',
+ ' valid_handle,',
+ ` // PLACEHOLDER: the site's own avatar CDN; other avatar URLs are dropped.`,
+ ` avatar_prefixes: &[${o.site.avatarPrefixes.map(rustStr).join(', ')}],`,
+ ' max_name_chars: 50,',
+ ' max_avatar_len: 512,',
+ ' },',
+ '};',
+ '',
+ '#[cfg(test)]',
+ 'mod tests {',
+ ' use super::*;',
+ ' use url::Url;',
+ '',
+ ' #[test]',
+ ' fn only_the_exact_site_origin_is_a_site_tab() {',
+ ` assert!(SITE.is_origin(&Url::parse(${rustStr(`${o.origin}/some/page`)}).unwrap()));`,
+ ` assert!(!SITE.is_origin(&Url::parse(${rustStr(`http://${o.site.hosts[0]}/`)}).unwrap()));`,
+ ` assert!(!SITE.is_origin(&Url::parse(${rustStr(`https://user:pass@${o.site.hosts[0]}/`)}).unwrap()));`,
+ ' assert!(!SITE.is_origin(&Url::parse("https://example.org/").unwrap()));',
+ ' }',
+ '',
+ ' #[test]',
+ ' fn handles_follow_the_site_rules() {',
+ ` assert!(valid_handle(${rustStr(handle.sample)}));`,
+ ' assert!(!valid_handle(""));',
+ ' assert!(!valid_handle("has space"));',
+ ' }',
+ '}',
+ );
+}
+
+/** @param {AppOptions} o */
+function pluginsRs(o) {
+ return lines(
+ '//! BetterDiscord-style plugins for the site page: `*.plugin.js` files in the app-data',
+ '//! `plugins` folder and custom CSS, on the waybetter-host plugin store.',
+ '',
+ 'use std::path::PathBuf;',
+ 'use waybetter_host::plugins::{PluginHost, PluginStore};',
+ '',
+ 'pub static HOST: PluginHost = PluginHost {',
+ ` runtime_global: ${rustStr(o.runtimeGlobal)},`,
+ ' // Plugins run only in the top frame of this exact origin.',
+ ` origin: ${rustStr(o.origin)},`,
+ ` product: ${rustStr(o.product)},`,
+ ` css_element_id: ${rustStr(`${o.prefix}-custom-css`)},`,
+ ' // Written (disabled) into a freshly created plugins folder.',
+ ' example: Some(("example.plugin.js", include_str!("../../plugins/example.plugin.js"))),',
+ ' // First-party plugins shipped with the app: written disabled, upgraded only while untouched.',
+ ' bundled: &[],',
+ '};',
+ '',
+ '/// The plugin store in `dir` (the app-data `plugins` folder).',
+ 'pub fn store(dir: PathBuf) -> PluginStore {',
+ ' PluginStore::new(dir, &HOST)',
+ '}',
+ '',
+ '#[cfg(test)]',
+ 'mod tests {',
+ ' use super::*;',
+ ' use crate::site::SITE;',
+ '',
+ ' #[test]',
+ ' fn plugins_run_on_the_site_origin_with_its_runtime() {',
+ ' assert_eq!(HOST.origin, SITE.primary_origin());',
+ ' assert_eq!(HOST.runtime_global, SITE.runtime_global);',
+ ' }',
+ '',
+ ' #[test]',
+ ' fn the_example_plugin_has_a_header() {',
+ ' let (file, source) = HOST.example.unwrap();',
+ ' assert!(waybetter_host::plugins::valid_file_name(file));',
+ ' assert_eq!(waybetter_host::plugins::parse_meta(file, source).name, "Example");',
+ ' }',
+ '}',
+ );
+}
+
+/** @param {AppOptions} o */
+function libRs(o) {
+ return lines(
+ `//! ${o.product}: ${o.site.hosts[0]} in one light native window (a trusted local shell and one`,
+ '//! child webview per site tab), extended in place with the wayBetter page runtime and plugins.',
+ '',
+ 'pub mod plugins;',
+ 'pub mod site;',
+ '',
+ '#[cfg(feature = "desktop")]',
+ 'mod desktop;',
+ '',
+ '#[cfg(feature = "desktop")]',
+ 'pub use desktop::run;',
+ );
+}
+
+/** @param {AppOptions} o */
+function mainRs(o) {
+ return lines(
+ '#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]',
+ '',
+ 'fn main() {',
+ ` ${o.lib}::run();`,
+ '}',
+ );
+}
+
+/** @param {AppOptions} o */
+function desktopRs(o) {
+ return lines(
+ '//! The native window: the trusted local `shell` webview fills it, and each tab is a native',
+ '//! child webview placed over the pane rectangle the shell reports (`tabs_sync`). Every',
+ '//! command re-checks its caller: the capability files are not the boundary.',
+ '',
+ 'use crate::{plugins, site::SITE};',
+ 'use serde::{Deserialize, Serialize};',
+ 'use std::collections::HashSet;',
+ 'use std::sync::{Mutex, MutexGuard};',
+ 'use tauri::webview::{NewWindowResponse, PageLoadEvent};',
+ 'use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, State, Webview, WebviewBuilder, WebviewUrl};',
+ 'use waybetter_host::plugins::{PluginStore, ScriptPlugin};',
+ 'use waybetter_host::profiles::Account;',
+ 'use waybetter_host::tabs::{parse_web_url, TabLayout, TabState};',
+ 'use waybetter_host::{host, policy};',
+ '',
+ '/// The page runtime (src/runtime/main.ts), written by `npm run build`.',
+ 'const RUNTIME: &str = include_str!("../runtime/runtime.js");',
+ '/// Reports every tab\'s URL and title to the address bar. It may call only `tab_report`.',
+ 'const TAB_BRIDGE: &str = include_str!("../runtime/tab-bridge.js");',
+ '',
+ 'struct AppState {',
+ ' plugins: Mutex,',
+ '}',
+ '',
+ 'fn store<\'a>(state: &\'a State<\'_, AppState>) -> Result, String> {',
+ ' state.plugins.lock().map_err(|_| "PLUGINS: state unavailable".to_string())',
+ '}',
+ '',
+ '// ---------------------------------------------------------------------------------------------',
+ '// Shell-only commands',
+ '',
+ '/// Reconciles tab webviews with the shell\'s panes: creates missing ones, places and shows or',
+ '/// hides all of them, and closes webviews whose tab is gone.',
+ '#[tauri::command]',
+ 'async fn tabs_sync(webview: Webview, app: AppHandle, items: Vec) -> Result<(), String> {',
+ ' host::shell_only(&webview)?;',
+ ' let window = webview.window();',
+ ' let scale = window.scale_factor().map_err(|error| format!("TAB: {error}"))?;',
+ ' let size = window.inner_size().map_err(|error| format!("TAB: {error}"))?.to_logical::(scale);',
+ ' let maximized = window.is_maximized().unwrap_or(false);',
+ ' let frameless = !window.is_decorated().unwrap_or(true);',
+ ' let mut keep = HashSet::new();',
+ ' for item in items {',
+ ' if !policy::valid_tab_id(&item.id) {',
+ ' return Err("VALIDATION: invalid tab id".into());',
+ ' }',
+ ' // One account profile in this skeleton; see waybetter-host `profiles` to add more.',
+ ' if item.profile != waybetter_host::profiles::DEFAULT_PROFILE {',
+ ' return Err("VALIDATION: unknown profile".into());',
+ ' }',
+ ' keep.insert(item.id.clone());',
+ ' let rect = policy::clamp_tab_rect(item.x, item.y, item.width, item.height, size.width, size.height, maximized, frameless)?;',
+ ' let visible = item.visible && rect.is_some();',
+ ' let (x, y, width, height) = rect.unwrap_or((0.0, policy::TITLEBAR_H, 1.0, 1.0));',
+ ' let tab = match host::tab_webview(&app, &item.id) {',
+ ' Some(tab) => tab,',
+ ' None => {',
+ ' let url = parse_web_url(&item.url)?;',
+ ' window',
+ ' .add_child(tab_builder(&app, &item.id, url), LogicalPosition::new(x, y), LogicalSize::new(width, height))',
+ ' .map_err(|error| format!("TAB: {error}"))?',
+ ' }',
+ ' };',
+ ' host::place_child(&tab, x, y, width, height);',
+ ' host::set_tab_visible(&tab, visible, item.parked || rect.is_none());',
+ ' }',
+ ' for existing in window.webviews() {',
+ ' if policy::tab_id(existing.label()).is_some_and(|id| !keep.contains(id)) {',
+ ' let _ = existing.close();',
+ ' }',
+ ' }',
+ ' Ok(())',
+ '}',
+ '',
+ '#[tauri::command]',
+ 'async fn plugins_list(webview: Webview, state: State<\'_, AppState>) -> Result, String> {',
+ ' host::shell_only(&webview)?;',
+ ' store(&state)?.list()',
+ '}',
+ '',
+ '/// Turns a plugin on or off and starts/stops it live in every site tab.',
+ '#[tauri::command]',
+ 'async fn plugin_set_enabled(',
+ ' webview: Webview,',
+ ' app: AppHandle,',
+ ' state: State<\'_, AppState>,',
+ ' file: String,',
+ ' enabled: bool,',
+ ') -> Result, String> {',
+ ' host::shell_only(&webview)?;',
+ ' let store = store(&state)?;',
+ ' // Built first, so a broken file is reported before it is marked enabled.',
+ ' let script = if enabled {',
+ ' store.start_script(&file)?',
+ ' } else {',
+ ' waybetter_host::plugins::stop_script(&plugins::HOST, &file)?',
+ ' };',
+ ' store.set_enabled(&file, enabled)?;',
+ ' let list = store.list()?;',
+ ' drop(store);',
+ ' host::eval_in_site_tabs(&app, &SITE, &script);',
+ ' Ok(list)',
+ '}',
+ '',
+ '#[tauri::command]',
+ 'async fn plugins_open_folder(webview: Webview, state: State<\'_, AppState>) -> Result<(), String> {',
+ ' host::shell_only(&webview)?;',
+ ' let dir = {',
+ ' let store = store(&state)?;',
+ ' store.ensure()?;',
+ ' store.dir().to_path_buf()',
+ ' };',
+ ' #[cfg(target_os = "windows")]',
+ ' let opener = "explorer";',
+ ' #[cfg(target_os = "macos")]',
+ ' let opener = "open";',
+ ' #[cfg(not(any(target_os = "windows", target_os = "macos")))]',
+ ' let opener = "xdg-open";',
+ ' std::process::Command::new(opener).arg(dir).spawn().map(|_| ()).map_err(|error| format!("PLUGINS: {error}"))',
+ '}',
+ '',
+ '// ---------------------------------------------------------------------------------------------',
+ '// Tab commands',
+ '',
+ '#[derive(Deserialize)]',
+ '#[serde(rename_all = "camelCase")]',
+ 'struct TabReport {',
+ ' url: Option,',
+ ' title: Option,',
+ ' account: Option,',
+ ' #[serde(default)]',
+ ' signed_out: bool,',
+ '}',
+ '',
+ '#[derive(Clone, Serialize)]',
+ 'struct TabAccount {',
+ ' id: String,',
+ ' /// None: signed out (or a handle the site could not have).',
+ ' account: Option,',
+ '}',
+ '',
+ '/// Any tab, about itself only: its URL/title for the address bar and, from a tab that is on the',
+ '/// site right now, the account the site\'s own UI shows.',
+ '#[tauri::command]',
+ 'async fn tab_report(webview: Webview, app: AppHandle, report: TabReport) -> Result<(), String> {',
+ ' let id = host::tab_only(&webview)?;',
+ ' if report.url.is_some() || report.title.is_some() {',
+ ' // The URL is the webview\'s own, never the page\'s claim.',
+ ' let url = webview.url().ok().map(|url| url.to_string());',
+ ' host::emit_tab_state(&app, TabState::reported(id.clone(), url, report.title, None, None));',
+ ' }',
+ ' if (report.account.is_some() || report.signed_out) && host::site_tab_only(&SITE, &webview).is_ok() {',
+ ' let account = report.account.and_then(|account| account.validated(&SITE.accounts));',
+ ' host::emit_shell(&app, "tab-account", TabAccount { id, account });',
+ ' }',
+ ' Ok(())',
+ '}',
+ '',
+ '#[derive(Clone, Serialize)]',
+ 'struct SiteReady {',
+ ' id: String,',
+ ' url: String,',
+ '}',
+ '',
+ '/// A site-only command: callable only from a `tab-*` webview that is on the exact site origin',
+ '/// (capability `site-tab`, re-checked here). Add the app\'s own site commands the same way.',
+ '#[tauri::command]',
+ 'async fn site_ready(webview: Webview, app: AppHandle) -> Result<(), String> {',
+ ' let url = host::site_tab_only(&SITE, &webview)?;',
+ ' let id = host::tab_only(&webview)?;',
+ ' host::emit_shell(&app, "site-ready", SiteReady { id, url: url.to_string() });',
+ ' Ok(())',
+ '}',
+ '',
+ '// ---------------------------------------------------------------------------------------------',
+ '// Tab webviews',
+ '',
+ 'fn tab_builder(app: &AppHandle, id: &str, url: url::Url) -> WebviewBuilder {',
+ ' let (load_app, load_id) = (app.clone(), id.to_owned());',
+ ' let (title_app, title_id) = (app.clone(), id.to_owned());',
+ ' WebviewBuilder::new(policy::tab_label(id), WebviewUrl::External(url))',
+ ' .initialization_script(TAB_BRIDGE)',
+ ' // The runtime checks the origin itself and stays inert on other sites.',
+ ' .initialization_script(RUNTIME)',
+ ' // Pop-ups are refused in this skeleton; open them as tabs with `tabs::OpenRequest`.',
+ ' .on_new_window(|_, _| NewWindowResponse::Deny)',
+ ' .on_page_load(move |tab, payload| {',
+ ' let finished = matches!(payload.event(), PageLoadEvent::Finished);',
+ ' host::emit_tab_state(',
+ ' &load_app,',
+ ' TabState { id: load_id.clone(), url: Some(payload.url().to_string()), loading: Some(!finished), ..Default::default() },',
+ ' );',
+ ' // Enabled plugins and custom CSS, evaluated natively (the page\'s CSP cannot block them).',
+ ' if finished && SITE.is_origin(payload.url()) {',
+ ' let scripts = load_app',
+ ' .state::()',
+ ' .plugins',
+ ' .lock()',
+ ' .map(|store| {',
+ ' let mut scripts: Vec = waybetter_host::plugins::css_script(&plugins::HOST, &store.css()).into_iter().collect();',
+ ' scripts.extend(store.start_scripts());',
+ ' scripts',
+ ' })',
+ ' .unwrap_or_default();',
+ ' for script in scripts {',
+ ' let _ = tab.eval(&script);',
+ ' }',
+ ' }',
+ ' })',
+ ' .on_document_title_changed(move |_, title| {',
+ ' host::emit_tab_state(&title_app, TabState { id: title_id.clone(), title: Some(title), ..Default::default() });',
+ ' })',
+ '}',
+ '',
+ '// ---------------------------------------------------------------------------------------------',
+ '// App',
+ '',
+ 'pub fn run() {',
+ ' tauri::Builder::default()',
+ ' .setup(|app| {',
+ ' let store = plugins::store(app.path().app_data_dir()?.join("plugins"));',
+ ' store.ensure()?;',
+ ' app.manage(AppState { plugins: Mutex::new(store) });',
+ '',
+ ' let window = tauri::window::WindowBuilder::new(app, policy::MAIN_LABEL)',
+ ' .title(SITE.product)',
+ ' .inner_size(1280.0, 820.0)',
+ ' .min_inner_size(720.0, 480.0)',
+ ' .build()?;',
+ ' let size = window.inner_size()?.to_logical::(window.scale_factor()?);',
+ ' // The shell fills the window; tab webviews are layered over its pane rectangles.',
+ ' let shell = WebviewBuilder::new(policy::SHELL_LABEL, WebviewUrl::App("index.html".into()))',
+ ' .auto_resize()',
+ ' .on_navigation(|url| policy::local_navigation_allowed(url, cfg!(debug_assertions)))',
+ ' .on_new_window(|_, _| NewWindowResponse::Deny);',
+ ' let shell = window.add_child(shell, LogicalPosition::new(0.0, 0.0), LogicalSize::new(size.width, size.height))?;',
+ ' host::attach_shell(&shell);',
+ ' Ok(())',
+ ' })',
+ ` .invoke_handler(tauri::generate_handler![${[...COMMANDS.shell, ...COMMANDS.tab, ...COMMANDS.site].join(', ')}])`,
+ ' .run(tauri::generate_context!())',
+ ` .expect(${rustStr(`failed to run ${o.product}`)});`,
+ '}',
+ );
+}
+
+/** @param {AppOptions} o */
+function tauriConf(o) {
+ return {
+ $schema: 'https://schema.tauri.app/config/2',
+ productName: o.product,
+ version: '0.1.0',
+ identifier: o.identifier,
+ build: {
+ beforeDevCommand: 'npm run build',
+ beforeBuildCommand: 'npm run build',
+ frontendDist: '../dist',
+ },
+ app: {
+ withGlobalTauri: true,
+ // The window is made in desktop.rs (a multiwebview window: shell + tab webviews).
+ windows: [],
+ security: {
+ capabilities: ['shell', 'tab-chrome', 'site-tab'],
+ csp: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'; frame-src 'none'; form-action 'none'",
+ },
+ },
+ bundle: {
+ active: true,
+ targets: 'all',
+ icon: ['icons/icon.png', 'icons/icon.ico'],
+ },
+ };
+}
+
+/** @param {AppOptions} o */
+function capabilities(o) {
+ const cap = value => `${JSON.stringify({ $schema: '../gen/schemas/desktop-schema.json', ...value }, null, 2)}\n`;
+ return [
+ {
+ path: 'src-tauri/capabilities/shell.json',
+ content: cap({
+ identifier: 'shell',
+ description: 'The trusted local shell: tabs and plugins. No generic filesystem or shell APIs.',
+ webviews: [/** policy::SHELL_LABEL */ 'shell'],
+ local: true,
+ permissions: ['core:event:default', ...COMMANDS.shell.map(permission)],
+ }),
+ },
+ {
+ path: 'src-tauri/capabilities/tab-chrome.json',
+ content: cap({
+ identifier: 'tab-chrome',
+ description: 'Every tab (any site): report its own URL, title and, on the site, its account. Nothing else.',
+ webviews: ['tab-*'],
+ local: false,
+ remote: { urls: ['https://*/*', 'http://*/*'] },
+ permissions: COMMANDS.tab.map(permission),
+ }),
+ },
+ {
+ path: 'src-tauri/capabilities/site-tab.json',
+ content: cap({
+ identifier: 'site-tab',
+ description: `Tabs on ${o.site.hosts.join(', ')} only: the app's short, reviewed list of site commands.`,
+ webviews: ['tab-*'],
+ local: false,
+ remote: { urls: o.site.hosts.map(host => `https://${host}/*`) },
+ permissions: COMMANDS.site.map(permission),
+ }),
+ },
+ ];
+}
+
+// ---------------------------------------------------------------------------------------------
+// TypeScript: site adapter, page runtime, shell
+
+/** @param {AppOptions} o */
+function siteTs(o) {
+ const s = o.site.selectors;
+ return lines(
+ `// The page half of the ${o.label} site adapter (waybetter/runtime/site): every runtime module`,
+ '// takes only its piece. Must agree with the native `SiteSpec` in src-tauri/src/site.rs.',
+ '',
+ "import type { SiteAdapter } from 'waybetter/runtime/site';",
+ "import type { SeenAccount } from 'waybetter/runtime/accountReport';",
+ '',
+ '/**',
+ ` * PLACEHOLDER selectors: starting guesses for ${o.site.hosts[0]}. Open the site in devtools and`,
+ ' * replace each one; sites change their markup, so prefer stable attributes (data-*, aria-*).',
+ ' */',
+ 'export const SELECTORS = {',
+ ' /** One item plugins act on (a post, a message, a video card). */',
+ ` item: ${str(s.item)},`,
+ ' /** A link to the signed-in account (its href holds the handle). */',
+ ` accountLink: ${str(s.accountLink)},`,
+ ' /** The signed-in account\'s avatar image. */',
+ ` accountAvatar: ${str(s.accountAvatar)},`,
+ ' /** Only present on the signed-out page. */',
+ ` signedOut: ${str(s.signedOut)},`,
+ ' /** Links to the site\'s own pages that the "links" setting may open beside the pane. */',
+ ` itemLink: ${str(s.itemLink)},`,
+ '} as const;',
+ '',
+ `/** PLACEHOLDER: paths of the site's own pages a click may divert (see links.clickedUrl). */`,
+ `const ITEM_PATH = new RegExp(${str(o.site.itemPath)});`,
+ '',
+ 'function readAccount(root: ParentNode): SeenAccount | null {',
+ ' const link = root.querySelector(SELECTORS.accountLink);',
+ ' if (!link) return null;',
+ " const handle = (link.getAttribute('href') ?? '').replace(/^\\/+@?/, '').split(/[/?#]/)[0] ?? '';",
+ ' if (!handle) return null;',
+ ' const avatar = root.querySelector(SELECTORS.accountAvatar)?.src ?? null;',
+ " return { handle, name: link.textContent?.trim() || handle, avatar };",
+ '}',
+ '',
+ 'function clickedUrl(target: Element | null, location: Location): string | null {',
+ ' const anchor = target?.closest(SELECTORS.itemLink);',
+ ' if (!anchor?.href) return null;',
+ ' const url = new URL(anchor.href, location.href);',
+ ' return url.origin === location.origin && ITEM_PATH.test(url.pathname) && url.href !== location.href ? url.href : null;',
+ '}',
+ '',
+ 'export const SITE: SiteAdapter = {',
+ ` product: ${str(o.product)},`,
+ ` hosts: [${o.site.hosts.map(str).join(', ')}],`,
+ ` runtimeGlobal: ${str(o.runtimeGlobal)},`,
+ ' heavyPath: null,',
+ ` apiMarks: [${o.site.apiMarks.map(str).join(', ')}],`,
+ ' xhrMarks: [],',
+ ' plugins: {',
+ ` name: ${str(o.runtimeGlobal)},`,
+ ` product: ${str(o.product)},`,
+ ` storagePrefix: ${str(`${o.prefix}-plugin:`)},`,
+ ` styleDataKey: ${str(o.dataKey)},`,
+ ` toastId: ${str(`${o.prefix}-ui`)},`,
+ ` toastBackground: ${str(o.site.accent)},`,
+ ' itemSelector: SELECTORS.item,',
+ " // Keys that start one of the site's own two-key shortcuts.",
+ ' chordKeys: [],',
+ ' },',
+ ' account: { read: readAccount, signedOut: root => !!root.querySelector(SELECTORS.signedOut) },',
+ " links: { configKey: 'site', clickedUrl },",
+ '};',
+ );
+}
+
+/** @param {AppOptions} o */
+function addressTs(o) {
+ return lines(
+ '// Address bar chips (waybetter/shell/addressModel): how the shell names this site\'s pages.',
+ '',
+ "import type { AddressSite, Segment } from 'waybetter/shell/addressModel';",
+ "import { SITE } from './site.ts';",
+ '',
+ 'function label(part: string): string {',
+ ' try { return decodeURIComponent(part); } catch { return part; }',
+ '}',
+ '',
+ 'export const ADDRESS_SITE: AddressSite = {',
+ " kind: 'site',",
+ ' hosts: new Set(SITE.hosts),',
+ ` label: ${str(o.label)},`,
+ ' home: origin => `${origin}/`,',
+ ' // PLACEHOLDER: one chip per path part; name the site\'s own pages (a channel, a video) here.',
+ ' segments(url): Segment[] {',
+ " const parts = url.pathname.split('/').filter(Boolean).slice(0, 3);",
+ " return parts.map((part, index) => ({ label: label(part), href: `${url.origin}/${parts.slice(0, index + 1).join('/')}` }));",
+ ' },',
+ '};',
+ );
+}
+
+function runtimeTs() {
+ return lines(
+ '// The page runtime, injected into every tab and active only on the site\'s origin (top frame).',
+ '// It sets `window.` (and the `window.wayBetter` alias plugins use) with the',
+ '// plugin API; native code then evaluates the enabled `*.plugin.js` files against it.',
+ '',
+ "import { configureRuntime, isSiteUrl } from 'waybetter/runtime/site';",
+ "import { createCorePluginApi, type CorePluginApi } from 'waybetter/runtime/pluginApi';",
+ "import { installAccountReport, type Invoke } from 'waybetter/runtime/accountReport';",
+ "import { installLinkRouting } from 'waybetter/runtime/linkRouting';",
+ "import { SITE } from '../site.ts';",
+ '',
+ 'type Runtime = {',
+ ' api: CorePluginApi;',
+ ' /** Running plugins by file (native start/stop scripts manage this). */',
+ ' plugins: Record;',
+ ' /** Load errors by plugin file. */',
+ ' errors: Record;',
+ '};',
+ '',
+ 'type TauriInternals = { invoke(command: string, args: Record): Promise };',
+ '',
+ 'function install(): void {',
+ ' if (window.top !== window || !isSiteUrl(SITE, location.href)) return;',
+ ' const globals = window as unknown as Record;',
+ ' if (globals[SITE.runtimeGlobal]) return;',
+ ' configureRuntime(SITE);',
+ ' // No theme engine yet: `api.theme` reports the page\'s own colors. Pass `{ theme: source }`',
+ ' // (a ThemeSource from waybetter/runtime/themeApi) once the app has themes.',
+ ' const api = createCorePluginApi(window, SITE.plugins);',
+ ' const runtime: Runtime = { api, plugins: {}, errors: {} };',
+ ' globals[SITE.runtimeGlobal] = runtime;',
+ ' globals.wayBetter = runtime;',
+ '',
+ ' const ipc = (globals.__TAURI_INTERNALS__ ?? null) as TauriInternals | null;',
+ ' const invoke: Invoke | undefined = ipc ? (command, args) => ipc.invoke(command, args) : undefined;',
+ ' if (SITE.account) installAccountReport(window, SITE.account, invoke);',
+ ' // "same" until native code configures it: in-page clicks are left alone.',
+ ' if (SITE.links) installLinkRouting(window, SITE.links);',
+ " invoke?.('site_ready', {}).catch(() => {});",
+ '}',
+ '',
+ 'try {',
+ ' install();',
+ '} catch (error) {',
+ ' console.error(`[${SITE.product}] runtime failed`, error);',
+ '}',
+ );
+}
+
+/** @param {AppOptions} o */
+function shellTs(o) {
+ return lines(
+ '// The shell: a trusted local page that fills the window. It draws the toolbar (address chips',
+ '// from waybetter/shell/addressModel, the account, plugins) and reports where the site tab goes;',
+ '// native code places the tab\'s own webview over that rectangle (`tabs_sync`).',
+ '',
+ "import { parseAddress } from 'waybetter/shell/addressModel';",
+ "import { TITLEBAR_H } from 'waybetter/shell/chrome';",
+ "import { ADDRESS_SITE } from '../address.ts';",
+ "import { SITE } from '../site.ts';",
+ '',
+ 'type Plugin = { file: string; name: string; description: string; version: string; author: string; enabled: boolean };',
+ 'type TabStateEvent = { id: string; url?: string; title?: string; loading?: boolean };',
+ 'type TabAccountEvent = { id: string; account: { handle: string; name: string; avatar?: string } | null };',
+ '',
+ "const TAB_ID = '1';",
+ 'const { invoke } = window.__TAURI__.core;',
+ 'const { listen } = window.__TAURI__.event;',
+ '',
+ 'const $ = (id: string) => document.getElementById(id) as T;',
+ "const pane = $('pane');",
+ "const chips = $('chips');",
+ "const account = $('account');",
+ "const pluginsButton = $('plugins-button');",
+ "const pluginsPanel = $('plugins');",
+ "const status = $('status');",
+ "document.documentElement.style.setProperty('--toolbar-h', `${TITLEBAR_H}px`);",
+ "document.title = SITE.product;",
+ '',
+ 'let url = `https://${SITE.hosts[0]}/`;',
+ 'let overlay = false;',
+ '',
+ 'function showError(error: unknown) {',
+ ' status.textContent = String(error);',
+ ' status.hidden = false;',
+ '}',
+ '',
+ '/** Tells native code where the tab goes; hidden while a shell overlay (plugins) is open. */',
+ 'function sync() {',
+ ' const rect = pane.getBoundingClientRect();',
+ ' const items = [{ id: TAB_ID, url, profile: \'default\', x: rect.left, y: rect.top, width: rect.width, height: rect.height, visible: !overlay, parked: false }];',
+ " invoke('tabs_sync', { items }).catch(showError);",
+ '}',
+ '',
+ 'function renderAddress(href: string) {',
+ ' const model = parseAddress(href, ADDRESS_SITE);',
+ ' chips.replaceChildren();',
+ ' if (!model) return;',
+ ' for (const part of [{ label: model.site.label, href: model.site.href }, ...model.segments]) {',
+ " const chip = document.createElement('span');",
+ " chip.className = 'chip';",
+ ' chip.textContent = part.label;',
+ ' chip.title = part.href;',
+ ' chips.append(chip);',
+ ' }',
+ '}',
+ '',
+ 'async function renderPlugins() {',
+ ' const plugins = await invoke(\'plugins_list\');',
+ ' pluginsPanel.replaceChildren();',
+ ' for (const plugin of plugins) {',
+ " const row = document.createElement('label');",
+ " const toggle = document.createElement('input');",
+ " toggle.type = 'checkbox';",
+ ' toggle.checked = plugin.enabled;',
+ " toggle.addEventListener('change', () => {",
+ " invoke('plugin_set_enabled', { file: plugin.file, enabled: toggle.checked }).then(renderPlugins, showError);",
+ ' });',
+ " const text = document.createElement('span');",
+ " text.textContent = `${plugin.name} ${plugin.version}`.trim();",
+ ' row.append(toggle, text);',
+ ' pluginsPanel.append(row);',
+ ' }',
+ " const folder = document.createElement('button');",
+ " folder.textContent = 'Open plugins folder';",
+ " folder.addEventListener('click', () => { invoke('plugins_open_folder').catch(showError); });",
+ ' pluginsPanel.append(folder);',
+ '}',
+ '',
+ "pluginsButton.addEventListener('click', () => {",
+ ' overlay = !overlay;',
+ ' pluginsPanel.hidden = !overlay;',
+ " pluginsButton.setAttribute('aria-expanded', String(overlay));",
+ ' if (overlay) renderPlugins().catch(showError);',
+ ' sync();',
+ '});',
+ '',
+ "listen('tab-state', ({ payload }) => {",
+ ' if (payload.id !== TAB_ID || !payload.url) return;',
+ ' url = payload.url;',
+ ' renderAddress(url);',
+ '});',
+ "listen('tab-account', ({ payload }) => {",
+ " account.textContent = payload.account ? `@${payload.account.handle}` : 'Signed out';",
+ '});',
+ '',
+ 'new ResizeObserver(sync).observe(pane);',
+ 'renderAddress(url);',
+ 'sync();',
+ );
+}
+
+function tauriTypes() {
+ return lines(
+ '// `window.__TAURI__` in the shell (tauri.conf.json: app.withGlobalTauri). Swap for',
+ '// `@tauri-apps/api` if you prefer imports.',
+ '',
+ 'interface Window {',
+ ' __TAURI__: {',
+ ' core: { invoke(command: string, args?: Record): Promise };',
+ ' event: { listen(event: string, handler: (event: { payload: T }) => void): Promise<() => void> };',
+ ' };',
+ '}',
+ );
+}
+
+/** @param {AppOptions} o */
+function indexHtml(o) {
+ return lines(
+ '',
+ '',
+ '',
+ ' ',
+ ' ',
+ ` ${o.product.replace(/[<&]/g, ch => (ch === '<' ? '<' : '&'))}`,
+ ' ',
+ ' ',
+ '',
+ '',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ '',
+ '',
+ );
+}
+
+/** @param {AppOptions} o */
+function shellCss(o) {
+ return lines(
+ ':root { color-scheme: dark; --toolbar-h: 44px; --accent: ' + o.site.accent + '; font: 14px/1.4 system-ui, sans-serif; }',
+ 'html, body { margin: 0; height: 100%; background: #0d0f14; color: #e8eaf0; }',
+ 'body { display: flex; flex-direction: column; overflow: hidden; }',
+ '.toolbar { flex: 0 0 var(--toolbar-h); display: flex; align-items: center; gap: 8px; padding: 0 10px; border-bottom: 1px solid #262a35; }',
+ '.chips { display: flex; gap: 4px; min-width: 0; flex: 1; overflow: hidden; }',
+ '.chip { padding: 3px 10px; border-radius: 999px; background: #1b1f29; white-space: nowrap; }',
+ '.chip:first-child { background: var(--accent); color: #000; font-weight: 600; }',
+ '.account { color: #9aa1b2; }',
+ 'button { font: inherit; color: inherit; background: #1b1f29; border: 1px solid #2f3442; border-radius: 8px; padding: 5px 12px; cursor: pointer; }',
+ '.plugins { display: grid; gap: 8px; padding: 16px; }',
+ '.plugins label { display: flex; gap: 8px; align-items: center; }',
+ '.status { padding: 8px 12px; background: #3a1418; color: #ffd7d7; }',
+ '.pane { flex: 1; min-height: 0; }',
+ );
+}
+
+/** @param {AppOptions} o */
+function examplePlugin(o) {
+ return lines(
+ '/**',
+ ' * @name Example',
+ ` * @description Example plugin for ${o.product}: outlines each item and says hello. Turn it on in Plugins.`,
+ ' * @version 1.0.0',
+ ` * @author ${o.product}`,
+ ' */',
+ 'module.exports = class Example {',
+ ' start() {',
+ ' const api = window.wayBetter.api;',
+ ' this.scope = api.scope();',
+ ` this.scope.css('example', ${str(`${o.site.selectors.item} { outline: 1px dashed var(--wb-accent); }`)});`,
+ " this.scope.ui.toast('Example plugin started', { tone: 'success' });",
+ ' }',
+ '',
+ ' stop() {',
+ ' this.scope.dispose();',
+ ' }',
+ '};',
+ );
+}
+
+function tabBridge() {
+ return lines(
+ '// Injected into every tab (top frame only): reports URL and title changes of single-page apps',
+ '// to the address bar. It can call only `tab_report` (capability tab-chrome), about this tab.',
+ '(() => {',
+ ' if (window.top !== window || window.__wbTabBridge) return;',
+ ' window.__wbTabBridge = true;',
+ ' const invoke = (command, args) => {',
+ ' const ipc = window.__TAURI_INTERNALS__;',
+ " if (ipc && typeof ipc.invoke === 'function') ipc.invoke(command, args).catch(() => {});",
+ ' };',
+ " let last = '';",
+ ' const report = () => {',
+ ' const key = `${location.href}\\n${document.title}`;',
+ ' if (key === last) return;',
+ ' last = key;',
+ " invoke('tab_report', { report: { url: location.href, title: document.title } });",
+ ' };',
+ ' const soon = () => setTimeout(report, 50);',
+ " for (const method of ['pushState', 'replaceState']) {",
+ ' const original = history[method];',
+ ' history[method] = function (...args) {',
+ ' const result = original.apply(this, args);',
+ ' soon();',
+ ' return result;',
+ ' };',
+ ' }',
+ " addEventListener('popstate', soon);",
+ " addEventListener('DOMContentLoaded', report);",
+ '})();',
+ );
+}
+
+function buildMjs() {
+ return lines(
+ '// Builds the two bundles: the page runtime (one IIFE that native code injects into tabs) and the',
+ '// shell (the local page Tauri serves from dist/).',
+ "import { build } from 'esbuild';",
+ "import { copyFileSync, mkdirSync } from 'node:fs';",
+ '',
+ "mkdirSync('dist', { recursive: true });",
+ 'await build({',
+ " entryPoints: ['src/runtime/main.ts'],",
+ ' bundle: true,',
+ " format: 'iife',",
+ " target: 'es2022',",
+ ` outfile: ${str(APP_OUTPUTS.runtime)},`,
+ " legalComments: 'none',",
+ '});',
+ 'await build({',
+ " entryPoints: ['src/shell/main.ts'],",
+ ' bundle: true,',
+ " format: 'esm',",
+ " target: 'es2022',",
+ " outfile: 'dist/shell.js',",
+ '});',
+ "copyFileSync('src/shell/index.html', 'dist/index.html');",
+ "copyFileSync('src/shell/shell.css', 'dist/shell.css');",
+ "console.log('built dist/ and " + APP_OUTPUTS.runtime + "');",
+ );
+}
+
+/** @param {AppOptions} o */
+function readme(o) {
+ const submodule = o.hostSource === 'submodule';
+ return lines(
+ `# ${o.product}`,
+ '',
+ `${o.site.hosts[0]} in one light native window, extended in place: a wayBetter app made with`,
+ '`npm create waybetter`. It is the same system as every wayBetter app: a Tauri 2 window with a',
+ 'trusted local **shell** and one native child webview per **tab**, the **page runtime** injected',
+ 'into tabs on the site, and a small **site adapter**.',
+ '',
+ '## Run it',
+ '',
+ '```sh',
+ ...(submodule ? [`git init && git submodule add ${o.gitUrl} waybetter # the framework (host crate)`] : []),
+ 'npm install',
+ 'npm run build # dist/ (shell) and ' + APP_OUTPUTS.runtime + ' (page runtime)',
+ 'npm run tauri dev # the app',
+ 'npm run test:rust # site adapter + plugin host tests, headless',
+ '```',
+ '',
+ 'Requires Node 18+, Rust 1.88+ and the Tauri 2 prerequisites for your platform.',
+ '',
+ '## Layout',
+ '',
+ '```',
+ 'src/site.ts SiteAdapter: the page half of the adapter (PLACEHOLDER selectors)',
+ 'src/address.ts address bar chips for the site\'s pages',
+ 'src/runtime/main.ts page runtime: window.' + o.runtimeGlobal + ' (+ window.wayBetter), plugin API, account report',
+ 'src/shell/ the shell page: toolbar, plugins, tabs_sync',
+ 'plugins/example.plugin.js written (disabled) into a new plugins folder',
+ 'src-tauri/src/site.rs SiteSpec: the native half (hosts, runtime global, account rules)',
+ 'src-tauri/src/plugins.rs the plugin store (PluginHost)',
+ 'src-tauri/src/desktop.rs window, shell, tab webviews and the commands',
+ 'src-tauri/capabilities/ shell / tab-chrome / site-tab, keyed by webview label',
+ '```',
+ '',
+ '## Make it yours',
+ '',
+ `1. Open ${o.origin} in a browser's devtools and replace every PLACEHOLDER in \`src/site.ts\``,
+ ' and `src-tauri/src/site.rs` (selectors, handle rules, the avatar CDN).',
+ '2. Keep `hosts` and the runtime global identical in `site.ts` and `site.rs`.',
+ '3. Add site-only commands like `site_ready`: `host::site_tab_only(&SITE, &webview)?` first,',
+ ' then list them in `build.rs` and `capabilities/site-tab.json`.',
+ '4. Next steps from the framework: account profiles (`waybetter_host::profiles`), split panes',
+ ' (`waybetter/shell/paneModel`), the rail hider (`waybetter/runtime/paneNav`), themes',
+ ' (`waybetter/runtime/themeApi`, `theme.schema.json`), signed updates (`waybetter_host::update`).',
+ '',
+ '## Security model',
+ '',
+ '- Only the local `shell` webview may call shell commands (`host::shell_only`).',
+ '- Any tab may only report about itself (`tab_report`); the URL used is the webview\'s own.',
+ '- Tabs on the exact site origin may call the short list in `site-tab.json`, re-checked natively.',
+ '- Plugins are files the owner copies into the plugins folder; native code evaluates them only',
+ ' in the site\'s top frame.',
+ '',
+ 'License: GPL-3.0-or-later (waybetter-host is GPL-3.0-or-later).',
+ );
+}
+
+/** @param {AppOptions} o */
+export function appFiles(o) {
+ const png = iconPng(64, o.site.accent);
+ const json = value => `${JSON.stringify(value, null, 2)}\n`;
+ const pkg = {
+ name: o.slug,
+ version: '0.1.0',
+ private: true,
+ type: 'module',
+ license: 'GPL-3.0-or-later',
+ description: `${o.product}: ${o.site.hosts[0]} in a light native window, extended in place.`,
+ scripts: {
+ build: 'node scripts/build.mjs',
+ typecheck: 'tsc -p tsconfig.json',
+ tauri: 'tauri',
+ 'test:rust': 'cargo test --manifest-path src-tauri/Cargo.toml --no-default-features',
+ },
+ dependencies: { waybetter: '^0.2.0' },
+ devDependencies: { '@tauri-apps/cli': '^2.0.0', esbuild: '^0.25.0', typescript: '^5.9.0' },
+ };
+ const tsconfig = {
+ compilerOptions: {
+ target: 'ES2022',
+ lib: ['ES2022', 'DOM', 'DOM.Iterable'],
+ module: 'ESNext',
+ moduleResolution: 'Bundler',
+ allowImportingTsExtensions: true,
+ noEmit: true,
+ strict: true,
+ noUnusedLocals: true,
+ skipLibCheck: true,
+ isolatedModules: true,
+ types: [],
+ },
+ include: ['src'],
+ };
+ return [
+ { path: 'README.md', content: readme(o) },
+ { path: 'package.json', content: json(pkg) },
+ { path: 'tsconfig.json', content: json(tsconfig) },
+ { path: '.gitignore', content: lines('node_modules/', 'dist/', 'src-tauri/target/', 'src-tauri/gen/', APP_OUTPUTS.runtime) },
+ { path: 'scripts/build.mjs', content: buildMjs() },
+ { path: 'src/site.ts', content: siteTs(o) },
+ { path: 'src/address.ts', content: addressTs(o) },
+ { path: 'src/runtime/main.ts', content: runtimeTs() },
+ { path: 'src/shell/main.ts', content: shellTs(o) },
+ { path: 'src/shell/index.html', content: indexHtml(o) },
+ { path: 'src/shell/shell.css', content: shellCss(o) },
+ { path: 'src/tauri.d.ts', content: tauriTypes() },
+ { path: 'plugins/example.plugin.js', content: examplePlugin(o) },
+ { path: 'src-tauri/Cargo.toml', content: cargoToml(o) },
+ { path: 'src-tauri/build.rs', content: buildRs() },
+ { path: 'src-tauri/tauri.conf.json', content: json(tauriConf(o)) },
+ ...capabilities(o),
+ { path: 'src-tauri/icons/icon.png', content: png },
+ { path: 'src-tauri/icons/icon.ico', content: iconIco(png, 64) },
+ { path: 'src-tauri/src/main.rs', content: mainRs(o) },
+ { path: 'src-tauri/src/lib.rs', content: libRs(o) },
+ { path: 'src-tauri/src/site.rs', content: siteRs(o) },
+ { path: 'src-tauri/src/plugins.rs', content: pluginsRs(o) },
+ { path: 'src-tauri/src/desktop.rs', content: desktopRs(o) },
+ { path: 'src-tauri/runtime/tab-bridge.js', content: tabBridge() },
+ // Replaced by `npm run build`; here so `cargo check` works before the first build.
+ { path: APP_OUTPUTS.runtime, content: lines('// Placeholder: `npm run build` writes the page runtime here (src/runtime/main.ts).') },
+ ];
+}
diff --git a/packages/create-waybetter/src/cli.js b/packages/create-waybetter/src/cli.js
new file mode 100644
index 0000000..26ec01e
--- /dev/null
+++ b/packages/create-waybetter/src/cli.js
@@ -0,0 +1,253 @@
+// `npm create waybetter`: scaffolds a wayBetter plugin, theme or app. Interactive when run in a
+// terminal without the answers; fully flag-driven otherwise (`--yes` takes the defaults).
+
+import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join, relative, resolve } from 'node:path';
+import { createInterface } from 'node:readline/promises';
+import { parseArgs } from 'node:util';
+import { SITE_PRESETS, appFiles, normalizeAppOptions } from './app.js';
+import { UsageError, slugify } from './names.js';
+import { PLUGIN_APPS, normalizePluginOptions, pluginFiles } from './plugin.js';
+import { THEME_MODES, THEME_PRESETS, normalizeThemeOptions, themeFiles } from './theme.js';
+
+export const KINDS = ['plugin', 'theme', 'app'];
+
+/** @type {import('node:util').ParseArgsConfig['options']} */
+const OPTIONS = ({
+ type: { type: 'string', short: 't' },
+ name: { type: 'string', short: 'n' },
+ description: { type: 'string' },
+ author: { type: 'string' },
+ 'plugin-version': { type: 'string' },
+ ts: { type: 'boolean' },
+ typescript: { type: 'boolean' },
+ app: { type: 'string' },
+ mode: { type: 'string' },
+ preset: { type: 'string' },
+ bg: { type: 'string' },
+ text: { type: 'string' },
+ accent: { type: 'string' },
+ font: { type: 'string' },
+ 'post-font': { type: 'string' },
+ radius: { type: 'string' },
+ schema: { type: 'string' },
+ site: { type: 'string', short: 's' },
+ global: { type: 'string' },
+ identifier: { type: 'string' },
+ 'host-source': { type: 'string' },
+ 'host-path': { type: 'string' },
+ 'git-url': { type: 'string' },
+ yes: { type: 'boolean', short: 'y' },
+ force: { type: 'boolean' },
+ 'dry-run': { type: 'boolean' },
+ help: { type: 'boolean', short: 'h' },
+ version: { type: 'boolean', short: 'v' },
+});
+
+export const HELP = `Usage: npm create waybetter [plugin|theme|app] [dir] -- [options]
+ npx create-waybetter [plugin|theme|app] [dir] [options]
+
+Scaffolds a wayBetter plugin, theme or whole app. Asks for anything missing when run in a
+terminal; --yes takes the defaults instead.
+
+Common
+ -t, --type plugin, theme or app
+ -n, --name display name (plugin/theme name, or the app's product name)
+ --author plugin/theme author (default "you")
+ --description
+ -y, --yes no questions; use defaults for anything not given
+ --force write into a directory that is not empty
+ --dry-run print the files instead of writing them
+ -h, --help this help
+ -v, --version print the create-waybetter version
+
+Plugin (one *.plugin.js with the header the plugin store reads)
+ --plugin-version plugin version (default 1.0.0)
+ --ts a TypeScript project (types from the waybetter package)
+ --app x: wayBetter for X (adds api.tweet/account/users); any: another app
+
+Theme (JSON with "$schema" pointing at the waybetter theme schema)
+ --preset ${Object.keys(THEME_PRESETS).join(', ')} (seed colors, default midnight)
+ --mode ${THEME_MODES.join(', ')}
+ --bg, --text, --accent <#hex> seed colors; the rest of the palette is derived
+ --font interface fonts, e.g. "Geist, system-ui"
+ --post-font post text fonts
+ --radius <0-28> card corner radius (default 16)
+ --schema "$schema" as the unpkg URL (default) or ./node_modules path
+
+App (Tauri 2 + waybetter-host + the waybetter runtime and shell models)
+ -s, --site ${Object.keys(SITE_PRESETS).join(', ')} or any host name
+ --global the page runtime's window global (default from the name)
+ --identifier Tauri bundle identifier (default com.example.)
+ --host-source submodule (default: ../waybetter as a git submodule), path or git
+ --host-path with --host-source path: the waybetter repository (or its host crate)
+ --git-url the waybetter repository for submodule/git
+
+Examples
+ npm create waybetter plugin -- --name "Hide Trends"
+ npm create waybetter theme my-theme -- --name "Midnight Mint" --accent "#3ddc97" --yes
+ npm create waybetter app wb-kick -- --site kick.com --yes
+`;
+
+/** @typedef {{ stdout: { write(text: string): unknown }, stderr: { write(text: string): unknown }, stdin: NodeJS.ReadableStream & { isTTY?: boolean }, cwd: string }} Io */
+
+function packageVersion() {
+ try {
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
+ } catch {
+ return 'unknown';
+ }
+}
+
+/** Minimal line prompts over readline (no dependencies). */
+function prompter(io) {
+ /** @type {import('node:readline/promises').Interface | null} */
+ let rl = null;
+ const ask = async (question, fallback = '') => {
+ rl ??= createInterface({ input: io.stdin, output: /** @type {any} */ (io.stdout) });
+ const answer = (await rl.question(`${question}${fallback ? ` (${fallback})` : ''}: `)).trim();
+ return answer || fallback;
+ };
+ return {
+ ask,
+ async choose(question, choices, fallback) {
+ for (;;) {
+ const answer = (await ask(`${question} [${choices.join('/')}]`, fallback)).toLowerCase();
+ if (choices.includes(answer)) return answer;
+ io.stdout.write(` Choose one of: ${choices.join(', ')}\n`);
+ }
+ },
+ async confirm(question, fallback) {
+ const answer = (await ask(`${question} [y/n]`, fallback ? 'y' : 'n')).toLowerCase();
+ return answer.startsWith('y');
+ },
+ close() { rl?.close(); },
+ };
+}
+
+/**
+ * Runs the CLI. Returns the exit code; never calls process.exit (tests call it in-process).
+ * @param {string[]} argv arguments after the program name
+ * @param {Partial} [ioInput]
+ */
+export async function main(argv, ioInput = {}) {
+ /** @type {Io} */
+ const io = { stdout: process.stdout, stderr: process.stderr, stdin: process.stdin, cwd: process.cwd(), ...ioInput };
+ let parsed;
+ try {
+ parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
+ } catch (error) {
+ io.stderr.write(`${/** @type {Error} */ (error).message}\n\nRun with --help for usage.\n`);
+ return 1;
+ }
+ /** @type {Record} */
+ const values = parsed.values;
+ const positionals = parsed.positionals;
+ if (values.help) { io.stdout.write(HELP); return 0; }
+ if (values.version) { io.stdout.write(`${packageVersion()}\n`); return 0; }
+
+ const interactive = !values.yes && Boolean(io.stdin.isTTY);
+ const ui = prompter(io);
+ try {
+ // `npm create waybetter plugin [dir]`: a leading kind is the type.
+ let type = values.type ?? (KINDS.includes(positionals[0] ?? '') ? positionals.shift() : undefined);
+ if (!type) {
+ if (!interactive) throw new UsageError('--type is required (plugin, theme or app) when not asking questions.');
+ type = await ui.choose('What do you want to make?', KINDS, 'plugin');
+ }
+ if (!KINDS.includes(type)) throw new UsageError(`--type must be one of: ${KINDS.join(', ')}.`);
+ const ask = async (value, question, fallback) => value ?? (interactive ? await ui.ask(question, fallback) : fallback);
+
+ /** @type {Array<{ path: string, content: string | Uint8Array }>} */
+ let files;
+ let defaultDir;
+ let next;
+ const warnings = [];
+ if (type === 'plugin') {
+ const name = await ask(values.name, 'Plugin name', 'My Plugin');
+ const options = normalizePluginOptions({
+ name,
+ description: await ask(values.description, 'Description', `${name}, a wayBetter plugin.`),
+ author: await ask(values.author, 'Author', 'you'),
+ version: values['plugin-version'],
+ typescript: values.ts ?? values.typescript ?? (interactive ? await ui.confirm('TypeScript?', false) : false),
+ app: values.app ?? (interactive ? await ui.choose('Which app is it for? (x = wayBetter for X)', PLUGIN_APPS, 'x') : 'x'),
+ });
+ files = pluginFiles(options);
+ defaultDir = options.id;
+ next = options.typescript
+ ? ['npm install', 'npm run build', `copy dist/${options.file} into wayBetter → Plugins → Open folder, then Rescan`]
+ : [`copy ${options.file} into wayBetter → Plugins → Open folder, then Rescan`];
+ } else if (type === 'theme') {
+ const name = await ask(values.name, 'Theme name', 'My Theme');
+ const preset = await ask(values.preset, `Start from (${Object.keys(THEME_PRESETS).join(', ')})`, 'midnight');
+ const seed = THEME_PRESETS[/** @type {keyof typeof THEME_PRESETS} */ (preset)] ?? THEME_PRESETS.midnight;
+ const options = normalizeThemeOptions({
+ name,
+ preset,
+ author: await ask(values.author, 'Author', 'you'),
+ description: values.description,
+ mode: values.mode,
+ bg: await ask(values.bg, 'Background color', seed.bg),
+ text: await ask(values.text, 'Text color', seed.text),
+ accent: await ask(values.accent, 'Accent color', seed.accent),
+ font: values.font,
+ postFont: values['post-font'],
+ radius: values.radius,
+ schema: values.schema,
+ });
+ warnings.push(...options.warnings);
+ files = themeFiles(options);
+ defaultDir = options.id;
+ next = [`import ${options.id}.theme.json in wayBetter → Appearance → Import`];
+ } else {
+ const site = await ask(values.site, `Site (${Object.keys(SITE_PRESETS).join(', ')} or any host)`, 'kick.com');
+ const label = SITE_PRESETS[/** @type {keyof typeof SITE_PRESETS} */ (site.replace(/^www\./, ''))]?.label;
+ const options = normalizeAppOptions({
+ site,
+ name: await ask(values.name, 'App name', label ? `wayBetter for ${label}` : undefined),
+ global: values.global,
+ identifier: values.identifier,
+ hostSource: values['host-source'],
+ hostPath: values['host-path'],
+ gitUrl: values['git-url'],
+ });
+ files = appFiles(options);
+ defaultDir = options.slug;
+ next = [
+ ...(options.hostSource === 'submodule' ? [`git init && git submodule add ${options.gitUrl} waybetter`] : []),
+ 'npm install', 'npm run build', 'npm run tauri dev',
+ 'then replace the PLACEHOLDER selectors in src/site.ts and src-tauri/src/site.rs',
+ ];
+ }
+
+ const dirArg = positionals[0] ?? (interactive ? await ui.ask('Directory', defaultDir) : defaultDir);
+ const target = resolve(io.cwd, dirArg || defaultDir || slugify(type));
+ if (values['dry-run']) {
+ io.stdout.write(`Would write ${files.length} files to ${target}:\n`);
+ for (const file of files) io.stdout.write(` ${file.path}\n`);
+ return 0;
+ }
+ if (existsSync(target) && readdirSync(target).length > 0 && !values.force) {
+ throw new UsageError(`${target} is not empty. Pick another directory or pass --force.`);
+ }
+ for (const file of files) {
+ const path = join(target, file.path);
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, file.content);
+ }
+ const shown = relative(io.cwd, target) || '.';
+ io.stdout.write(`\nCreated a wayBetter ${type} in ${shown} (${files.length} files).\n`);
+ for (const warning of warnings) io.stdout.write(` note: ${warning}\n`);
+ io.stdout.write(`\nNext:\n${shown === '.' ? '' : ` cd ${shown}\n`}${next.map(step => ` ${step}\n`).join('')}`);
+ return 0;
+ } catch (error) {
+ if (error instanceof UsageError) {
+ io.stderr.write(`${error.message}\n`);
+ return 1;
+ }
+ throw error;
+ } finally {
+ ui.close();
+ }
+}
diff --git a/packages/create-waybetter/src/names.js b/packages/create-waybetter/src/names.js
new file mode 100644
index 0000000..70c99c5
--- /dev/null
+++ b/packages/create-waybetter/src/names.js
@@ -0,0 +1,73 @@
+// Names and small validators shared by the generators. Pure; no I/O.
+
+/** A usage error: shown to the user as-is, exit code 1, no stack. */
+export class UsageError extends Error {}
+
+/** `Hide Trends!` -> `hide-trends` (ASCII letters and digits, dashes between). */
+export function slugify(value, max = 64) {
+ return String(value)
+ .normalize('NFKD')
+ .replace(/\p{M}/gu, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, max)
+ .replace(/-+$/, '');
+}
+
+/** `hide trends 2` -> `HideTrends2`; always a valid JS/Rust type identifier. */
+export function pascalCase(value) {
+ const words = String(value).normalize('NFKD').replace(/\p{M}/gu, '').replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean);
+ const name = words.map(word => word[0].toUpperCase() + word.slice(1)).join('');
+ return /^[A-Za-z]/.test(name) ? name : `Plugin${name}`;
+}
+
+/** `wayBetter for Kick` -> `wayBetterForKick` (camelCase, for `data-*` keys). */
+export function camelCase(value) {
+ const name = pascalCase(value);
+ return name[0].toLowerCase() + name.slice(1);
+}
+
+/**
+ * A value for a `@name` / `@description` / ... plugin header line, or a theme string. The plugin
+ * store reads each header line up to its end, drops control characters and keeps 300 characters
+ * (waybetter-host `plugins::parse_meta`), so values are one line and cannot end the comment.
+ */
+export function headerValue(label, value, { max = 300, required = false } = {}) {
+ const text = String(value ?? '').trim();
+ if (required && !text) throw new UsageError(`${label} is required.`);
+ if (/[\u0000-\u001f\u007f]/.test(text)) throw new UsageError(`${label} must be one line of text.`);
+ if (text.includes('*/')) throw new UsageError(`${label} cannot contain "*/".`);
+ if (text.length > max) throw new UsageError(`${label} is longer than ${max} characters.`);
+ return text;
+}
+
+/** Semver-ish plugin version (`1.0.0`, `0.3.1-beta.2`). */
+export function versionValue(value) {
+ const text = String(value ?? '1.0.0').trim() || '1.0.0';
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(text)) throw new UsageError(`Version "${text}" is not like 1.0.0.`);
+ return text;
+}
+
+export const HEX = /^#[0-9a-fA-F]{6}$/;
+
+/** `#abc` / `abc123` / `#ABC123` -> `#aabbcc` style lowercase six-digit hex. */
+export function hexValue(label, value) {
+ let text = String(value ?? '').trim().toLowerCase();
+ if (!text.startsWith('#')) text = `#${text}`;
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(text);
+ if (short) text = `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
+ if (!HEX.test(text)) throw new UsageError(`${label} must be a hex color like #1d9bf0 (got "${value}").`);
+ return text;
+}
+
+/** A JS/TS string literal. */
+export const str = value => JSON.stringify(String(value));
+
+/** A Rust string literal (JSON escapes are valid Rust escapes for the characters we allow). */
+export function rustStr(value) {
+ return JSON.stringify(String(value)).replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => `\\u{${hex}}`);
+}
+
+/** Joins lines with `\n` and ends the file with one newline. */
+export const lines = (...parts) => `${parts.flat().join('\n').replace(/\n+$/, '')}\n`;
diff --git a/packages/create-waybetter/src/plugin.js b/packages/create-waybetter/src/plugin.js
new file mode 100644
index 0000000..8c4f643
--- /dev/null
+++ b/packages/create-waybetter/src/plugin.js
@@ -0,0 +1,426 @@
+// `npm create waybetter -- plugin`: one BetterDiscord-style `*.plugin.js` file (a class with
+// start/stop and the header the plugin store reads), optionally as a small TypeScript project
+// whose build writes that file. The example uses the real plugin API: `api.scope()`, storage,
+// observe, onKey, the UI kit (`scope.ui`: toast with an action, a settings dialog built with
+// `ui.h`) and the theme API (`api.theme`, `--wb-*` CSS variables).
+
+import { UsageError, headerValue, lines, pascalCase, slugify, str, versionValue } from './names.js';
+
+/** Which app the plugin targets: wayBetter for X adds post/account readers to the core API. */
+export const PLUGIN_APPS = ['x', 'any'];
+
+/**
+ * @typedef {{ name: string, description?: string, author?: string, version?: string,
+ * typescript?: boolean, app?: string }} PluginInput
+ * @typedef {{ name: string, description: string, author: string, version: string,
+ * typescript: boolean, app: 'x' | 'any', id: string, className: string, file: string }} PluginOptions
+ * @typedef {{ path: string, content: string | Uint8Array }} OutputFile
+ */
+
+/** @param {PluginInput} input @returns {PluginOptions} */
+export function normalizePluginOptions(input) {
+ const name = headerValue('Plugin name', input.name, { max: 60, required: true });
+ const id = slugify(name, 48);
+ if (!id) throw new UsageError(`Plugin name "${name}" needs at least one letter or digit.`);
+ const app = String(input.app ?? 'x');
+ if (!PLUGIN_APPS.includes(app)) throw new UsageError(`--app must be one of: ${PLUGIN_APPS.join(', ')}.`);
+ return {
+ name,
+ description: headerValue('Description', input.description || `${name}, a wayBetter plugin.`, { max: 200 }),
+ author: headerValue('Author', input.author || 'you', { max: 60 }),
+ version: versionValue(input.version),
+ typescript: Boolean(input.typescript),
+ app: /** @type {'x' | 'any'} */ (app),
+ id,
+ className: pascalCase(name),
+ // A bare `name.plugin.js`: letters, digits, `-` only (waybetter-host `valid_file_name`).
+ file: `${id}.plugin.js`,
+ };
+}
+
+/** @param {PluginOptions} o */
+function header(o) {
+ return lines(
+ '/**',
+ ` * @name ${o.name}`,
+ ` * @description ${o.description}`,
+ ` * @version ${o.version}`,
+ ` * @author ${o.author}`,
+ ' */',
+ );
+}
+
+/** The item a badge is added to, and how its text is read. @param {PluginOptions} o */
+function itemParts(o) {
+ if (o.app === 'x') {
+ return {
+ selector: `'article[data-testid="tweet"]'`,
+ selectorNote: '// One post on X.',
+ text: 'this.api.tweet(item)?.text ?? \'\'',
+ anchor: `item.querySelector('[data-testid="User-Name"]') ?? item`,
+ };
+ }
+ return {
+ selector: `'[data-example-item]'`,
+ selectorNote: '// PLACEHOLDER: a selector for one item of your app\'s site (a post, a message, a video).',
+ text: `item.textContent ?? ''`,
+ anchor: 'item',
+ };
+}
+
+/** @param {PluginOptions} o */
+function jsSource(o) {
+ const item = itemParts(o);
+ return lines(
+ header(o).trimEnd(),
+ '// Made with `npm create waybetter`. The API is documented in PLUGINS.md (wayBetter for X:',
+ '// docs/PLUGINS.md) and in the waybetter package README (plugin UI kit, themes).',
+ '',
+ `const ID = ${str(o.id)}; // storage namespace and CSS id`,
+ `const ITEM = ${item.selector}; ${item.selectorNote}`,
+ '',
+ `module.exports = class ${o.className} {`,
+ ' start() {',
+ ' const api = window.wayBetter.api;',
+ ' this.api = api;',
+ ' // A scope collects everything the plugin adds; stop() undoes it all with scope.dispose().',
+ ' this.scope = api.scope();',
+ ' this.store = api.storage(ID);',
+ ' this.settings = { badges: true, ...this.store.get(\'settings\', {}) };',
+ '',
+ ' // Plugin CSS follows the active theme through the --wb-* variables.',
+ ' this.scope.css(ID, `',
+ ' .${ID}-badge { margin-inline-start: 8px; padding: 1px 8px; border: 1px solid var(--wb-border);',
+ ' border-radius: var(--wb-button-radius); background: var(--wb-surface); color: var(--wb-muted);',
+ ' font: 500 12px/18px var(--wb-font); white-space: nowrap; }',
+ ' html[data-${ID}-off] .${ID}-badge { display: none; }',
+ ' `);',
+ ' this.apply();',
+ ' this.scope.defer(() => document.documentElement.removeAttribute(`data-${ID}-off`));',
+ '',
+ ' // Once for every matching element, now and as the page renders more (batched per frame).',
+ ' this.scope.observe(ITEM, item => this.decorate(item));',
+ '',
+ ' // A single-key shortcut, skipped while typing: Shift+W opens the settings.',
+ ' this.scope.onKey(event => { if (event.key === \'W\') this.openSettings(); });',
+ '',
+ ' if (!this.store.get(\'welcomed\', false)) {',
+ ' this.store.set(\'welcomed\', true);',
+ ` this.scope.ui.toast(${str(`${o.name} is on`)}, { tone: 'success', action: { label: 'Settings', onClick: () => this.openSettings() } });`,
+ ' }',
+ ' }',
+ '',
+ ' decorate(item) {',
+ ` const text = ${item.text};`,
+ ' const words = text.trim() ? text.trim().split(/\\s+/).length : 0;',
+ ' // scope.add: removed again on dispose.',
+ ' const badge = this.scope.add(document.createElement(\'span\'));',
+ ' badge.className = `${ID}-badge`;',
+ ' badge.textContent = `${words} ${words === 1 ? \'word\' : \'words\'}`;',
+ ` ${item.anchor}.append(badge);`,
+ ' }',
+ '',
+ ' apply() {',
+ ' document.documentElement.toggleAttribute(`data-${ID}-off`, !this.settings.badges);',
+ ' }',
+ '',
+ ' openSettings() {',
+ ' const ui = this.scope.ui; // scoped: the dialog closes if the plugin stops',
+ ' const h = ui.h; // safe element builder: strings become text, on* functions listeners',
+ ' const theme = this.api.theme.current();',
+ ' const badges = h(\'input\', { type: \'checkbox\', checked: this.settings.badges });',
+ ' ui.dialog({',
+ ` title: ${str(o.name)},`,
+ ' message: `Drawn in the active theme: ${theme.name || theme.id}, ${theme.mode}.`,',
+ ' content: body => body.append(',
+ ' h(\'label\', { class: \'wb-check\' }, badges, h(\'span\', null, \'Show word counts\', h(\'small\', null, \'A small badge on each item.\'))),',
+ ' ),',
+ ' actions: [',
+ ' { label: \'Cancel\' },',
+ ' { label: \'Save\', tone: \'primary\', default: true, onClick: handle => {',
+ ' const next = { ...this.settings, badges: badges.checked };',
+ ' if (!this.store.set(\'settings\', next)) {',
+ ' handle.setError(\'Could not save.\');',
+ ' return false; // keeps the dialog open',
+ ' }',
+ ' this.settings = next;',
+ ' this.apply();',
+ ' ui.toast(\'Saved\', { tone: \'success\' });',
+ ' } },',
+ ' ],',
+ ' });',
+ ' }',
+ '',
+ ' stop() {',
+ ' this.scope?.dispose();',
+ ' this.scope = null;',
+ ' }',
+ '};',
+ );
+}
+
+/** @param {PluginOptions} o */
+function tsSource(o) {
+ const item = itemParts(o);
+ return lines(
+ header(o).trimEnd(),
+ '// Made with `npm create waybetter`. `npm run build` writes dist/' + o.file + ' (header first).',
+ '',
+ "import type { PluginScope, PluginStorage } from 'waybetter/runtime/pluginApi';",
+ "import type { PluginApi } from './waybetter-env';",
+ '',
+ `const ID = ${str(o.id)}; // storage namespace and CSS id`,
+ `const ITEM = ${item.selector}; ${item.selectorNote}`,
+ '',
+ 'type Settings = { badges: boolean };',
+ '',
+ `export default class ${o.className} {`,
+ ' private api!: PluginApi;',
+ ' private scope: PluginScope | null = null;',
+ ' private store!: PluginStorage;',
+ ' private settings: Settings = { badges: true };',
+ '',
+ ' start(): void {',
+ ' const api = window.wayBetter.api;',
+ ' this.api = api;',
+ ' // A scope collects everything the plugin adds; stop() undoes it all with scope.dispose().',
+ ' const scope = this.scope = api.scope();',
+ ' this.store = api.storage(ID);',
+ ' this.settings = { badges: true, ...this.store.get>(\'settings\', {}) };',
+ '',
+ ' // Plugin CSS follows the active theme through the --wb-* variables.',
+ ' scope.css(ID, `',
+ ' .${ID}-badge { margin-inline-start: 8px; padding: 1px 8px; border: 1px solid var(--wb-border);',
+ ' border-radius: var(--wb-button-radius); background: var(--wb-surface); color: var(--wb-muted);',
+ ' font: 500 12px/18px var(--wb-font); white-space: nowrap; }',
+ ' html[data-${ID}-off] .${ID}-badge { display: none; }',
+ ' `);',
+ ' this.apply();',
+ ' scope.defer(() => document.documentElement.removeAttribute(`data-${ID}-off`));',
+ '',
+ ' // Once for every matching element, now and as the page renders more (batched per frame).',
+ ' scope.observe(ITEM, item => this.decorate(scope, item));',
+ '',
+ ' // A single-key shortcut, skipped while typing: Shift+W opens the settings.',
+ ' scope.onKey(event => { if (event.key === \'W\') this.openSettings(); });',
+ '',
+ ' if (!this.store.get(\'welcomed\', false)) {',
+ ' this.store.set(\'welcomed\', true);',
+ ` scope.ui.toast(${str(`${o.name} is on`)}, { tone: 'success', action: { label: 'Settings', onClick: () => this.openSettings() } });`,
+ ' }',
+ ' }',
+ '',
+ ' private decorate(scope: PluginScope, item: Element): void {',
+ ` const text = ${item.text};`,
+ ' const words = text.trim() ? text.trim().split(/\\s+/).length : 0;',
+ ' // scope.add: removed again on dispose.',
+ ' const badge = scope.add(document.createElement(\'span\'));',
+ ' badge.className = `${ID}-badge`;',
+ ' badge.textContent = `${words} ${words === 1 ? \'word\' : \'words\'}`;',
+ ` ${item.anchor}.append(badge);`,
+ ' }',
+ '',
+ ' private apply(): void {',
+ ' document.documentElement.toggleAttribute(`data-${ID}-off`, !this.settings.badges);',
+ ' }',
+ '',
+ ' private openSettings(): void {',
+ ' if (!this.scope) return;',
+ ' const ui = this.scope.ui; // scoped: the dialog closes if the plugin stops',
+ ' const h = ui.h; // safe element builder: strings become text, on* functions listeners',
+ ' const theme = this.api.theme.current();',
+ ' const badges = h(\'input\', { type: \'checkbox\', checked: this.settings.badges }) as HTMLInputElement;',
+ ' ui.dialog({',
+ ` title: ${str(o.name)},`,
+ ' message: `Drawn in the active theme: ${theme.name || theme.id}, ${theme.mode}.`,',
+ ' content: body => body.append(',
+ ' h(\'label\', { class: \'wb-check\' }, badges, h(\'span\', null, \'Show word counts\', h(\'small\', null, \'A small badge on each item.\'))),',
+ ' ),',
+ ' actions: [',
+ ' { label: \'Cancel\' },',
+ ' { label: \'Save\', tone: \'primary\', default: true, onClick: handle => {',
+ ' const next: Settings = { ...this.settings, badges: badges.checked };',
+ ' if (!this.store.set(\'settings\', next)) {',
+ ' handle.setError(\'Could not save.\');',
+ ' return false; // keeps the dialog open',
+ ' }',
+ ' this.settings = next;',
+ ' this.apply();',
+ ' ui.toast(\'Saved\', { tone: \'success\' });',
+ ' return true;',
+ ' } },',
+ ' ],',
+ ' });',
+ ' }',
+ '',
+ ' stop(): void {',
+ ' this.scope?.dispose();',
+ ' this.scope = null;',
+ ' }',
+ '}',
+ );
+}
+
+/** Types of `window.wayBetter` for the TypeScript project. @param {PluginOptions} o */
+function envTypes(o) {
+ const x = o.app === 'x';
+ return lines(
+ '// `window.wayBetter`: the page runtime plugins run against. The core API and its types come',
+ '// from the `waybetter` package; an app adds its own readers on top (wayBetter for X: posts,',
+ '// the signed-in account and users, see docs/PLUGINS.md).',
+ "import type { CorePluginApi } from 'waybetter/runtime/pluginApi';",
+ x ? "import type { SeenAccount } from 'waybetter/runtime/accountReport';" : '',
+ '',
+ x ? lines(
+ '/** A rendered post, read by `api.tweet(element)`. */',
+ 'export type TweetInfo = {',
+ ' id: string;',
+ ' url: string;',
+ ' handle: string;',
+ ' name: string;',
+ ' /** Emoji as characters, t.co links as the destination X shows. */',
+ ' text: string;',
+ ' /** ISO timestamp of the post\'s `