create-waybetter: npm create waybetter scaffolds plugins (JS/TS), themes and whole apps
- plugin: *.plugin.js with the store header and start/stop on api.scope, storage, observe, onKey, the UI kit (toast with action, ui.h settings dialog) and api.theme/--wb-* vars; --ts makes a TypeScript project typed by the waybetter package (build keeps the header first) - theme: JSON with "$schema" -> theme.schema.json, palette derived like themeFromColors, font roles (family, tweet), presets - app: Tauri 2 + waybetter-host + runtime + shell models: SiteSpec, SiteAdapter, shell/tab-chrome/ site-tab capabilities, tabs_sync, plugin store, a site-only command; presets for youtube.com, kick.com, twitch.tv with placeholder selectors; host crate via submodule, path or git - tests generate into temp dirs: headers via parse_meta (new plugin_meta example), plugin code against createCorePluginApi's members, themes against the schema, app TS with tsc against the built package, app Rust with cargo metadata/check/test - wired into bun run check (typecheck incl. checkJs, build:packages, test:create) and pack:check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b3f2500314
commit
67851cd8f5
17 changed files with 3510 additions and 6 deletions
28
README.md
28
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+.
|
||||
|
|
|
|||
19
bun.lock
19
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"],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
crates/waybetter-host/examples/plugin_meta.rs
Normal file
32
crates/waybetter-host/examples/plugin_meta.rs
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
674
packages/create-waybetter/LICENSE
Normal file
674
packages/create-waybetter/LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
67
packages/create-waybetter/README.md
Normal file
67
packages/create-waybetter/README.md
Normal file
|
|
@ -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/<name>.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 <dir>`) 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).
|
||||
10
packages/create-waybetter/bin/create-waybetter.js
Normal file
10
packages/create-waybetter/bin/create-waybetter.js
Normal file
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
46
packages/create-waybetter/package.json
Normal file
46
packages/create-waybetter/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
1250
packages/create-waybetter/src/app.js
Normal file
1250
packages/create-waybetter/src/app.js
Normal file
File diff suppressed because it is too large
Load diff
253
packages/create-waybetter/src/cli.js
Normal file
253
packages/create-waybetter/src/cli.js
Normal file
|
|
@ -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 <kind> plugin, theme or app
|
||||
-n, --name <name> display name (plugin/theme name, or the app's product name)
|
||||
--author <name> plugin/theme author (default "you")
|
||||
--description <text>
|
||||
-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 <v> plugin version (default 1.0.0)
|
||||
--ts a TypeScript project (types from the waybetter package)
|
||||
--app <x|any> x: wayBetter for X (adds api.tweet/account/users); any: another app
|
||||
|
||||
Theme (JSON with "$schema" pointing at the waybetter theme schema)
|
||||
--preset <name> ${Object.keys(THEME_PRESETS).join(', ')} (seed colors, default midnight)
|
||||
--mode <mode> ${THEME_MODES.join(', ')}
|
||||
--bg, --text, --accent <#hex> seed colors; the rest of the palette is derived
|
||||
--font <stack> interface fonts, e.g. "Geist, system-ui"
|
||||
--post-font <stack> post text fonts
|
||||
--radius <0-28> card corner radius (default 16)
|
||||
--schema <url|local> "$schema" as the unpkg URL (default) or ./node_modules path
|
||||
|
||||
App (Tauri 2 + waybetter-host + the waybetter runtime and shell models)
|
||||
-s, --site <host> ${Object.keys(SITE_PRESETS).join(', ')} or any host name
|
||||
--global <Name> the page runtime's window global (default from the name)
|
||||
--identifier <id> Tauri bundle identifier (default com.example.<name>)
|
||||
--host-source <src> submodule (default: ../waybetter as a git submodule), path or git
|
||||
--host-path <dir> with --host-source path: the waybetter repository (or its host crate)
|
||||
--git-url <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<Io>} [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<string, any>} */
|
||||
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();
|
||||
}
|
||||
}
|
||||
73
packages/create-waybetter/src/names.js
Normal file
73
packages/create-waybetter/src/names.js
Normal file
|
|
@ -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`;
|
||||
426
packages/create-waybetter/src/plugin.js
Normal file
426
packages/create-waybetter/src/plugin.js
Normal file
|
|
@ -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<Partial<Settings>>(\'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 `<time datetime>`. */',
|
||||
' time: string | null;',
|
||||
' /** Full-size image URLs of the post itself. */',
|
||||
' media: string[];',
|
||||
' element: Element;',
|
||||
'};',
|
||||
'',
|
||||
'/** What X\'s own responses said about a user this session; null = not seen (never guessed). */',
|
||||
'export type UserInfo = {',
|
||||
' xUserId: string;',
|
||||
' handle: string;',
|
||||
' blue: boolean | null;',
|
||||
' verifiedType: string | null;',
|
||||
' following: boolean | null;',
|
||||
'};',
|
||||
'',
|
||||
'export type PluginApi = CorePluginApi & {',
|
||||
' tweet(element: Element | null | undefined): TweetInfo | null;',
|
||||
' account(): SeenAccount | null;',
|
||||
' users: { get(handle: string): UserInfo | null; onUpdate(callback: (handles: string[]) => void): () => void };',
|
||||
' isTypingTarget(target: EventTarget | null): boolean;',
|
||||
'};',
|
||||
).trimEnd() : 'export type PluginApi = CorePluginApi;',
|
||||
'',
|
||||
'declare global {',
|
||||
' interface Window {',
|
||||
' wayBetter: {',
|
||||
' api: PluginApi;',
|
||||
' /** Load errors by plugin file. */',
|
||||
' errors: Record<string, string>;',
|
||||
' };',
|
||||
' }',
|
||||
'}',
|
||||
).replace(/\n\n\n+/g, '\n\n');
|
||||
}
|
||||
|
||||
/** Builds dist/<file> from src/<file-ts> with the TypeScript compiler; the header stays first. */
|
||||
function buildScript(o) {
|
||||
const tsFile = o.file.replace(/\.js$/, '.ts');
|
||||
return lines(
|
||||
'// Compiles the plugin to one CommonJS file the plugin store can load. The `/** @name … */`',
|
||||
'// header must be the very first thing in the file, so it is cut from the source and put back',
|
||||
'// on top of the compiled output.',
|
||||
"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';",
|
||||
"import ts from 'typescript';",
|
||||
'',
|
||||
`const source = readFileSync(new URL('./src/${tsFile}', import.meta.url), 'utf8');`,
|
||||
'const header = /^\\uFEFF?\\s*(\\/\\*\\*[\\s\\S]*?\\*\\/)/.exec(source);',
|
||||
"if (!header) throw new Error('The plugin must start with its /** @name … */ header.');",
|
||||
'const { outputText, diagnostics } = ts.transpileModule(source.slice(header[0].length), {',
|
||||
` fileName: ${str(tsFile)},`,
|
||||
' reportDiagnostics: true,',
|
||||
' compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, removeComments: false },',
|
||||
'});',
|
||||
'if (diagnostics?.length) {',
|
||||
" for (const d of diagnostics) console.error(ts.flattenDiagnosticMessageText(d.messageText, '\\n'));",
|
||||
' process.exit(1);',
|
||||
'}',
|
||||
"mkdirSync(new URL('./dist/', import.meta.url), { recursive: true });",
|
||||
`writeFileSync(new URL('./dist/${o.file}', import.meta.url), \`\${header[1]}\\n\${outputText}\`);`,
|
||||
`console.log('dist/${o.file}');`,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {PluginOptions} o */
|
||||
function readme(o) {
|
||||
const x = o.app === 'x';
|
||||
const target = x ? 'wayBetter for X' : 'your wayBetter app';
|
||||
const file = o.typescript ? `dist/${o.file}` : o.file;
|
||||
return lines(
|
||||
`# ${o.name}`,
|
||||
'',
|
||||
o.description,
|
||||
'',
|
||||
`A plugin for ${target}: one \`*.plugin.js\` file with a class that has \`start()\` and \`stop()\`.`,
|
||||
'',
|
||||
'## Install',
|
||||
'',
|
||||
...(o.typescript ? ['```sh', 'npm install', 'npm run build # writes ' + file, 'npm run typecheck', '```', ''] : []),
|
||||
`1. Open **wayBetter → Plugins → Open folder** and copy \`${file}\` into it.`,
|
||||
'2. Press **Rescan** and turn the plugin on. It starts and stops live on every page.',
|
||||
'',
|
||||
'The header comment at the top (`@name`, `@description`, `@version`, `@author`) is what the',
|
||||
'Plugins list shows; keep it the first thing in the file.',
|
||||
'',
|
||||
'## What the example shows',
|
||||
'',
|
||||
'- `api.scope()`: everything the plugin adds (CSS, observers, key handlers, nodes, dialogs) is',
|
||||
' undone by one `scope.dispose()` in `stop()`.',
|
||||
'- `api.storage(namespace)`: settings kept in the tab\'s local storage.',
|
||||
'- `scope.observe(selector, fn)`: once per matching element, now and as the page renders more.',
|
||||
'- `scope.onKey(fn)`: a single-key shortcut (Shift+W) that never fires while you type.',
|
||||
'- `scope.ui`: a toast with an action and a settings dialog built with `ui.h`; they use the',
|
||||
' active theme and close when the plugin stops.',
|
||||
'- `api.theme.current()` and the `--wb-*` CSS variables: the badge matches every theme.',
|
||||
x ? '- `api.tweet(element)`: reads a rendered post (X only).' : '- Replace the `ITEM` placeholder selector with one item of your app\'s site.',
|
||||
'',
|
||||
'## Rules',
|
||||
'',
|
||||
'- No network requests and no account actions (like, follow, post, delete).',
|
||||
'- `stop()` must undo everything; use the scope instead of your own observers and timers.',
|
||||
'- A plugin runs inside the page with the page\'s own access: share only code you would trust.',
|
||||
'',
|
||||
x ? 'Reference: `docs/PLUGINS.md` in the wayBetter for X repository, and **wayBetter → Make** in the app.' : 'Reference: the `waybetter` package README (plugin UI kit and themes).',
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {PluginOptions} o @returns {OutputFile[]} */
|
||||
export function pluginFiles(o) {
|
||||
const readmeFile = { path: 'README.md', content: readme(o) };
|
||||
if (!o.typescript) return [{ path: o.file, content: jsSource(o) }, readmeFile];
|
||||
const pkg = {
|
||||
name: o.id,
|
||||
version: o.version,
|
||||
private: true,
|
||||
type: 'module',
|
||||
description: o.description,
|
||||
scripts: { build: 'node build.mjs', typecheck: 'tsc -p tsconfig.json' },
|
||||
devDependencies: { typescript: '^5.9.0', waybetter: '^0.2.0' },
|
||||
};
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: 'ES2022',
|
||||
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
noEmit: true,
|
||||
strict: true,
|
||||
skipLibCheck: true,
|
||||
isolatedModules: true,
|
||||
types: [],
|
||||
},
|
||||
include: ['src'],
|
||||
};
|
||||
return [
|
||||
{ path: `src/${o.file.replace(/\.js$/, '.ts')}`, content: tsSource(o) },
|
||||
{ path: 'src/waybetter-env.d.ts', content: envTypes(o) },
|
||||
{ path: 'build.mjs', content: buildScript(o) },
|
||||
{ path: 'package.json', content: `${JSON.stringify(pkg, null, 2)}\n` },
|
||||
{ path: 'tsconfig.json', content: `${JSON.stringify(tsconfig, null, 2)}\n` },
|
||||
{ path: '.gitignore', content: lines('node_modules/', 'dist/') },
|
||||
readmeFile,
|
||||
];
|
||||
}
|
||||
179
packages/create-waybetter/src/theme.js
Normal file
179
packages/create-waybetter/src/theme.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// `npm create waybetter -- theme`: a wayBetter theme file (JSON, data not a stylesheet) with
|
||||
// "$schema" pointing at the schema published in the `waybetter` package, a full palette derived
|
||||
// from three colors the way the runtime derives missing roles, and the font roles.
|
||||
|
||||
import { UsageError, headerValue, hexValue, lines, slugify } from './names.js';
|
||||
|
||||
/** Where the `waybetter` package publishes the theme JSON Schema (runtime `THEME_SCHEMA_URL`). */
|
||||
export const THEME_SCHEMA_URL = 'https://unpkg.com/waybetter/theme.schema.json';
|
||||
|
||||
export const THEME_MODES = ['dark', 'light', 'auto'];
|
||||
|
||||
/** Generic families the schema accepts in a font stack, besides font names. */
|
||||
const GENERIC_FONTS = ['system-ui', 'sans-serif', 'serif', 'monospace', 'cursive', 'ui-sans-serif', 'ui-serif', 'ui-monospace', 'ui-rounded', '-apple-system', 'BlinkMacSystemFont'];
|
||||
const FONT_NAME = /^[A-Za-z0-9][A-Za-z0-9 .\-]{0,47}$/;
|
||||
|
||||
/** Starting points: `--preset` fills the three seed colors. */
|
||||
export const THEME_PRESETS = {
|
||||
midnight: { mode: 'dark', bg: '#0b1110', text: '#e8f3ef', accent: '#3ddc97' },
|
||||
paper: { mode: 'light', bg: '#fbf8f1', text: '#1f1b16', accent: '#c2410c' },
|
||||
dusk: { mode: 'dark', bg: '#15121f', text: '#ece8f6', accent: '#a78bfa' },
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {{ name: string, description?: string, author?: string, mode?: string, preset?: string,
|
||||
* bg?: string, text?: string, accent?: string, font?: string, postFont?: string,
|
||||
* radius?: number | string, schema?: string }} ThemeInput
|
||||
*/
|
||||
|
||||
/** `"Geist, system-ui"` -> `["Geist", "system-ui"]`, each checked against the schema's font rules. */
|
||||
export function fontStack(label, value) {
|
||||
if (value == null || value === '') return [];
|
||||
const names = (Array.isArray(value) ? value : String(value).split(','))
|
||||
.map(name => String(name).trim().replace(/^["']|["']$/g, ''))
|
||||
.filter(Boolean);
|
||||
if (names.length > 8) throw new UsageError(`${label}: at most 8 fonts.`);
|
||||
for (const name of names) {
|
||||
if (!GENERIC_FONTS.includes(name) && !FONT_NAME.test(name)) throw new UsageError(`${label}: "${name}" is not a font name (letters, digits, spaces, "." and "-").`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function channels(hex) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function luminance(hex) {
|
||||
const [r, g, b] = channels(hex).map(v => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; });
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/** WCAG contrast ratio of two `#rrggbb` colors. */
|
||||
export function contrast(a, b) {
|
||||
const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
|
||||
return (x + 0.05) / (y + 0.05);
|
||||
}
|
||||
|
||||
function mix(a, b, t) {
|
||||
const [x, y] = [channels(a), channels(b)];
|
||||
return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every color role from bg/text/accent, with the proportions the runtime uses for a theme that
|
||||
* sets only those three (`themeFromColors` in waybetter/runtime/themeApi).
|
||||
*/
|
||||
export function derivePalette(bg, text, accent) {
|
||||
const dark = luminance(bg) < luminance(text);
|
||||
return {
|
||||
bg,
|
||||
surface: mix(bg, text, dark ? 0.05 : 0.03),
|
||||
elevated: mix(bg, text, dark ? 0.1 : 0.07),
|
||||
border: mix(bg, text, dark ? 0.2 : 0.15),
|
||||
text,
|
||||
muted: mix(bg, text, 0.62),
|
||||
accent,
|
||||
inverse: text,
|
||||
onInverse: bg,
|
||||
page: bg,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {ThemeInput} input */
|
||||
export function normalizeThemeOptions(input) {
|
||||
const name = headerValue('Theme name', input.name, { max: 60, required: true });
|
||||
const id = slugify(name, 40);
|
||||
if (!id) throw new UsageError(`Theme name "${name}" needs at least one letter or digit.`);
|
||||
const preset = input.preset ? THEME_PRESETS[/** @type {keyof typeof THEME_PRESETS} */ (input.preset)] : THEME_PRESETS.midnight;
|
||||
if (!preset) throw new UsageError(`--preset must be one of: ${Object.keys(THEME_PRESETS).join(', ')}.`);
|
||||
const mode = String(input.mode || preset.mode);
|
||||
if (!THEME_MODES.includes(mode)) throw new UsageError(`--mode must be one of: ${THEME_MODES.join(', ')}.`);
|
||||
const bg = hexValue('Background (--bg)', input.bg || preset.bg);
|
||||
const text = hexValue('Text (--text)', input.text || preset.text);
|
||||
const accent = hexValue('Accent (--accent)', input.accent || preset.accent);
|
||||
const radius = input.radius == null || input.radius === '' ? 16 : Number(input.radius);
|
||||
if (!Number.isInteger(radius) || radius < 0 || radius > 28) throw new UsageError('--radius must be a whole number from 0 to 28.');
|
||||
const schema = String(input.schema || 'url');
|
||||
if (!['url', 'local'].includes(schema)) throw new UsageError('--schema must be url or local.');
|
||||
const warnings = [];
|
||||
const ratio = contrast(bg, text);
|
||||
if (ratio < 7) warnings.push(`Text on background is ${ratio.toFixed(1)}:1; the app raises text to 7:1 when it imports the theme.`);
|
||||
if (contrast(bg, accent) < 4.5) warnings.push(`Accent on background is ${contrast(bg, accent).toFixed(1)}:1; the app adjusts it to 4.5:1 for links.`);
|
||||
return {
|
||||
name,
|
||||
id,
|
||||
description: headerValue('Description', input.description || `${name}, a wayBetter theme.`, { max: 200 }),
|
||||
author: headerValue('Author', input.author || 'you', { max: 60 }),
|
||||
mode,
|
||||
colors: derivePalette(bg, text, accent),
|
||||
family: fontStack('Interface font (--font)', input.font ?? 'system-ui'),
|
||||
postFont: fontStack('Post font (--post-font)', input.postFont),
|
||||
radius,
|
||||
schema,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {ReturnType<typeof normalizeThemeOptions>} o */
|
||||
export function themeDocument(o) {
|
||||
return {
|
||||
$schema: o.schema === 'local' ? './node_modules/waybetter/theme.schema.json' : THEME_SCHEMA_URL,
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
description: o.description,
|
||||
author: o.author,
|
||||
version: 1,
|
||||
mode: o.mode,
|
||||
colors: o.colors,
|
||||
font: {
|
||||
// Font roles: `family` is the interface, `tweet` the text of posts/messages. Empty keeps the site's font.
|
||||
family: o.family,
|
||||
tweet: o.postFont,
|
||||
scale: 1,
|
||||
weight: 400,
|
||||
bold: 700,
|
||||
tracking: 0,
|
||||
leading: 0,
|
||||
},
|
||||
shape: { radius: o.radius, button: 9999, avatar: 'circle' },
|
||||
effects: { motion: 120, hover: 'tint', shadow: 'soft', crt: false },
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {ReturnType<typeof normalizeThemeOptions>} o */
|
||||
export function themeFiles(o) {
|
||||
const file = `${o.id}.theme.json`;
|
||||
return [
|
||||
{ path: file, content: `${JSON.stringify(themeDocument(o), null, 2)}\n` },
|
||||
{
|
||||
path: 'README.md',
|
||||
content: lines(
|
||||
`# ${o.name}`,
|
||||
'',
|
||||
o.description,
|
||||
'',
|
||||
`A wayBetter theme: \`${file}\` is data, not a stylesheet. The app maps the site's own colors`,
|
||||
'onto this palette and keeps every text readable.',
|
||||
'',
|
||||
'## Use it',
|
||||
'',
|
||||
`**wayBetter → Appearance → Import** and pick \`${file}\`. Export any theme from the same place.`,
|
||||
'',
|
||||
'## Edit it',
|
||||
'',
|
||||
'- `colors`: `bg`, `text` and `accent` are required; `surface`, `elevated`, `border`, `muted`,',
|
||||
' `inverse`/`onInverse` (the filled primary button) and `page` were derived from them and can be',
|
||||
' changed or removed (removed roles are derived again). Six-digit hex only.',
|
||||
'- `font.family` (interface) and `font.tweet` (post text): installed font names or generic',
|
||||
' families; `Geist` and `Geist Mono` are bundled with wayBetter for X. `scale`, `weight`, `bold`,',
|
||||
' `tracking` and `leading` fine-tune them.',
|
||||
'- `shape`, `layout`, `background`, `effects` and `css`: see THEMES.md (wayBetter for X:',
|
||||
' `docs/THEMES.md`).',
|
||||
'',
|
||||
'The `"$schema"` line gives editors like VS Code completion and range checks for every field.',
|
||||
'The app is more forgiving than the schema: out-of-range values are clamped on import.',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
372
packages/create-waybetter/tests/create.test.mjs
Normal file
372
packages/create-waybetter/tests/create.test.mjs
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
// create-waybetter: runs the CLI non-interactively into temp directories and checks what it wrote
|
||||
// against the real contracts: plugin headers with the plugin store's own parser (Rust
|
||||
// `parse_meta` via the waybetter-host `plugin_meta` example), plugin code against the real plugin
|
||||
// API surface (createCorePluginApi), themes against theme.schema.json, app skeletons with tsc
|
||||
// against the built `waybetter` package, and `cargo metadata`, `cargo check` (desktop, Tauri) and
|
||||
// `cargo test` (headless) against the waybetter-host crate. The cargo check shares a target
|
||||
// directory in the OS temp folder: about a minute cold, seconds after that; it needs the crates in
|
||||
// the local registry cache (offline). WAYBETTER_SKIP_CARGO_CHECK=1 skips it.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
import { createCorePluginApi } from '../../runtime/src/pluginApi.ts';
|
||||
import { themeFromColors } from '../../runtime/src/themeApi.ts';
|
||||
import { APP_OUTPUTS, SITE_PRESETS } from '../src/app.js';
|
||||
import { main } from '../src/cli.js';
|
||||
import { UsageError, headerValue, pascalCase, slugify } from '../src/names.js';
|
||||
import { normalizeThemeOptions, THEME_PRESETS } from '../src/theme.js';
|
||||
import { validate } from './jsonSchema.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const HOST_CRATE = join(ROOT, 'crates/waybetter-host');
|
||||
const PACKAGE = join(ROOT, 'packages/waybetter');
|
||||
const TSC = join(ROOT, 'node_modules/typescript/bin/tsc');
|
||||
const SCHEMA = JSON.parse(readFileSync(join(PACKAGE, 'theme.schema.json'), 'utf8'));
|
||||
const EXPORTS = Object.keys(JSON.parse(readFileSync(join(PACKAGE, 'package.json'), 'utf8')).exports);
|
||||
const BIN = join(ROOT, 'packages/create-waybetter/bin/create-waybetter.js');
|
||||
|
||||
const work = mkdtempSync(join(tmpdir(), 'create-waybetter-'));
|
||||
test.after(() => rmSync(work, { recursive: true, force: true }));
|
||||
|
||||
const hasCargo = spawnSync('cargo', ['--version'], { encoding: 'utf8', shell: process.platform === 'win32' }).status === 0;
|
||||
const packageBuilt = existsSync(join(PACKAGE, 'dist/runtime/pluginApi.d.ts'));
|
||||
|
||||
/** Runs the CLI in-process in `work`; returns { code, out, err }. */
|
||||
async function create(...args) {
|
||||
let out = '';
|
||||
let err = '';
|
||||
const code = await main(args, {
|
||||
cwd: work,
|
||||
stdout: { write: text => { out += text; } },
|
||||
stderr: { write: text => { err += text; } },
|
||||
stdin: /** @type {any} */ ({ isTTY: false }),
|
||||
});
|
||||
return { code, out, err };
|
||||
}
|
||||
|
||||
const read = (...parts) => readFileSync(join(work, ...parts), 'utf8');
|
||||
|
||||
/** The plugin store's own header parser (Rust), one result per file. */
|
||||
function pluginMeta(...files) {
|
||||
const output = execFileSync('cargo', ['run', '--quiet', '--locked', '--manifest-path', join(HOST_CRATE, 'Cargo.toml'), '--example', 'plugin_meta', '--', ...files], { encoding: 'utf8' });
|
||||
return output.trim().split('\n').map(line => JSON.parse(line));
|
||||
}
|
||||
|
||||
/** node_modules/<name> -> target, as a junction (no admin rights needed on Windows). */
|
||||
function link(dir, name, target) {
|
||||
const path = join(dir, 'node_modules', name);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
if (!existsSync(path)) symlinkSync(target, path, 'junction');
|
||||
}
|
||||
|
||||
function tsc(project) {
|
||||
const result = spawnSync(process.execPath, [TSC, '-p', project], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, `tsc -p ${project}\n${result.stdout}${result.stderr}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The real plugin API surface, from the runtime itself.
|
||||
|
||||
const SITE = { name: 'Test', product: 'Test', storagePrefix: 't:', styleDataKey: 't', toastId: 't-ui', toastBackground: '#1d9bf0', itemSelector: 'article', chordKeys: [] };
|
||||
const core = createCorePluginApi(/** @type {any} */ ({ document: {}, location: { href: 'https://x.com/' } }), SITE);
|
||||
const API_KEYS = new Set(Object.keys(core));
|
||||
const SCOPE_KEYS = new Set(Object.keys(core.scope()));
|
||||
const UI_KEYS = new Set(Object.keys(core.ui));
|
||||
|
||||
/** An object that throws on any member the real API does not have. */
|
||||
function strict(label, keys, impl) {
|
||||
return new Proxy(impl, {
|
||||
get(target, key) {
|
||||
if (typeof key === 'symbol' || key === 'then') return target[key];
|
||||
if (!keys.has(key)) throw new Error(`${label}.${key} is not part of the wayBetter plugin API`);
|
||||
if (!(key in target)) throw new Error(`test harness: ${label}.${key} is not stubbed`);
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function element(tag) {
|
||||
return {
|
||||
tag, className: '', textContent: '', children: [], attrs: new Map(),
|
||||
append(...nodes) { this.children.push(...nodes); },
|
||||
querySelector: () => null,
|
||||
toggleAttribute(name, on) { if (on) this.attrs.set(name, ''); else this.attrs.delete(name); },
|
||||
removeAttribute(name) { this.attrs.delete(name); },
|
||||
};
|
||||
}
|
||||
|
||||
/** Loads a plugin file the way waybetter-host `start_script` does and drives it through a lifecycle. */
|
||||
function runPlugin(source, { extras = {} } = {}) {
|
||||
const calls = [];
|
||||
const store = new Map();
|
||||
const observers = [];
|
||||
const keyHandlers = [];
|
||||
let disposed = false;
|
||||
const ui = strict('ui', UI_KEYS, {
|
||||
toast: (message, options) => { calls.push(['toast', message, options]); return { element: {}, close() {}, closed: Promise.resolve() }; },
|
||||
dialog: options => { calls.push(['dialog', options]); return { element: {}, body: element('div'), open: true, closed: Promise.resolve(), close() {}, setError() {}, setBusy() {} }; },
|
||||
h: (tag, props, ...children) => ({ ...element(tag), props: props ?? {}, checked: props?.checked, children: children.flat() }),
|
||||
});
|
||||
const scope = strict('scope', SCOPE_KEYS, {
|
||||
css: (id, css) => calls.push(['css', id, css]),
|
||||
observe: (selector, callback) => observers.push([selector, callback]),
|
||||
onKey: callback => keyHandlers.push(callback),
|
||||
add: node => { calls.push(['add', node]); return node; },
|
||||
defer: fn => calls.push(['defer', fn]),
|
||||
ui,
|
||||
dispose: () => { disposed = true; },
|
||||
});
|
||||
const api = strict('api', new Set([...API_KEYS, ...Object.keys(extras)]), {
|
||||
scope: () => scope,
|
||||
storage: namespace => ({
|
||||
get: (key, fallback) => (store.has(`${namespace}:${key}`) ? store.get(`${namespace}:${key}`) : fallback),
|
||||
set: (key, value) => { store.set(`${namespace}:${key}`, value); return true; },
|
||||
remove: key => store.delete(`${namespace}:${key}`),
|
||||
keys: () => [],
|
||||
}),
|
||||
theme: { current: () => themeFromColors('#000000', '#e7e9ea', '#1d9bf0'), onChange: () => () => {} },
|
||||
ui,
|
||||
...extras,
|
||||
});
|
||||
const document = { documentElement: element('html'), createElement: element };
|
||||
const context = vm.createContext({ window: { wayBetter: { api } }, document, console });
|
||||
const module = { exports: {} };
|
||||
vm.runInContext(`(function (module, exports) {\n${source}\n})`, context)(module, module.exports);
|
||||
const P = module.exports && module.exports.default ? module.exports.default : module.exports;
|
||||
const instance = typeof P === 'function' ? (P.prototype && (P.prototype.start || P.prototype.stop) ? new P() : P()) : P;
|
||||
return { instance, calls, store, observers, keyHandlers, document, disposed: () => disposed };
|
||||
}
|
||||
|
||||
/** start, one item, the settings dialog (Save), stop. */
|
||||
function exercisePlugin(source, { selector, extras, words }) {
|
||||
const run = runPlugin(source, { extras });
|
||||
run.instance.start();
|
||||
assert.ok(run.calls.some(([kind, id, css]) => kind === 'css' && typeof id === 'string' && css.includes('var(--wb-')), 'themed CSS through the scope');
|
||||
assert.equal(run.observers[0]?.[0], selector);
|
||||
const item = { ...element('article'), textContent: 'one two' };
|
||||
run.observers[0][1](item);
|
||||
assert.equal(item.children[0]?.textContent, `${words} words`, 'badge on the item');
|
||||
const welcome = run.calls.find(([kind]) => kind === 'toast');
|
||||
assert.equal(typeof welcome?.[2]?.action?.onClick, 'function', 'toast with an action');
|
||||
run.keyHandlers[0]({ key: 'W' });
|
||||
const dialog = run.calls.find(([kind]) => kind === 'dialog')?.[1];
|
||||
assert.ok(dialog?.title && Array.isArray(dialog.actions), 'settings dialog');
|
||||
const body = element('div');
|
||||
dialog.content(body);
|
||||
assert.equal(body.children[0].props.class, 'wb-check');
|
||||
const save = dialog.actions.find(action => action.default);
|
||||
assert.notEqual(save.onClick({ setError() {} }), false);
|
||||
// Values come from the plugin's own realm (vm context): compare as JSON.
|
||||
assert.equal(JSON.stringify([...run.store.entries()].find(([key]) => key.endsWith(':settings'))?.[1]), '{"badges":true}');
|
||||
run.instance.stop();
|
||||
assert.ok(run.disposed(), 'stop() disposes the scope');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
test('names: slugs, class names and header values', () => {
|
||||
assert.equal(slugify('Hide Trends!'), 'hide-trends');
|
||||
assert.equal(slugify('Çok Güzel Tema'), 'cok-guzel-tema');
|
||||
assert.equal(pascalCase('2nd best plugin'), 'Plugin2ndBestPlugin');
|
||||
assert.equal(pascalCase('hide trends'), 'HideTrends');
|
||||
assert.throws(() => headerValue('Name', 'a */ b'), UsageError);
|
||||
assert.throws(() => headerValue('Name', 'two\nlines'), UsageError);
|
||||
assert.throws(() => headerValue('Name', '', { required: true }), UsageError);
|
||||
});
|
||||
|
||||
test('cli: help, version, usage errors and dry runs', async () => {
|
||||
assert.equal((await create('--help')).code, 0);
|
||||
assert.match((await create('--version')).out, /^\d+\.\d+\.\d+/);
|
||||
const missing = await create('--name', 'x');
|
||||
assert.equal(missing.code, 1);
|
||||
assert.match(missing.err, /--type is required/);
|
||||
assert.equal((await create('--type', 'nope', '--yes')).code, 1);
|
||||
assert.equal((await create('--bogus')).code, 1);
|
||||
const dry = await create('plugin', 'dry', '--name', 'Dry Run', '--dry-run');
|
||||
assert.equal(dry.code, 0);
|
||||
assert.match(dry.out, /dry-run\.plugin\.js/);
|
||||
assert.equal(existsSync(join(work, 'dry')), false);
|
||||
mkdirSync(join(work, 'busy'));
|
||||
writeFileSync(join(work, 'busy', 'keep.txt'), 'x');
|
||||
const busy = await create('theme', 'busy', '--name', 'Busy', '--yes');
|
||||
assert.equal(busy.code, 1);
|
||||
assert.match(busy.err, /not empty/);
|
||||
assert.equal((await create('theme', 'busy', '--name', 'Busy', '--yes', '--force')).code, 0);
|
||||
});
|
||||
|
||||
test('cli: the bin runs as a program and exits non-zero on errors', () => {
|
||||
const ok = spawnSync(process.execPath, [BIN, 'plugin', 'bin-plugin', '--name', 'Bin Plugin', '--yes'], { cwd: work, encoding: 'utf8' });
|
||||
assert.equal(ok.status, 0, ok.stderr);
|
||||
assert.ok(existsSync(join(work, 'bin-plugin', 'bin-plugin.plugin.js')));
|
||||
const bad = spawnSync(process.execPath, [BIN, '--type', 'theme', '--name', 'Bad', '--accent', 'blue', '--yes'], { cwd: work, encoding: 'utf8' });
|
||||
assert.equal(bad.status, 1);
|
||||
assert.match(bad.stderr, /hex color/);
|
||||
});
|
||||
|
||||
test('plugin (JavaScript, X): header, file name and a full lifecycle on the real API', async () => {
|
||||
const result = await create('plugin', 'js-plugin', '--name', 'Word Badges', '--description', 'Counts words.', '--author', 'Ada', '--plugin-version', '1.2.3', '--yes');
|
||||
assert.equal(result.code, 0, result.err);
|
||||
assert.deepEqual(readdirSync(join(work, 'js-plugin')).sort(), ['README.md', 'word-badges.plugin.js']);
|
||||
const source = read('js-plugin', 'word-badges.plugin.js');
|
||||
assert.ok(source.startsWith('/**\n * @name Word Badges\n'));
|
||||
exercisePlugin(source, {
|
||||
selector: 'article[data-testid="tweet"]',
|
||||
extras: { tweet: () => ({ text: 'three words here' }) },
|
||||
words: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('plugin (JavaScript, any app): generic item selector, core API only', async () => {
|
||||
assert.equal((await create('plugin', 'any-plugin', '--name', 'Any Badges', '--app', 'any', '--yes')).code, 0);
|
||||
const source = read('any-plugin', 'any-badges.plugin.js');
|
||||
assert.doesNotMatch(source, /api\.tweet/);
|
||||
exercisePlugin(source, { selector: '[data-example-item]', extras: {}, words: 2 });
|
||||
});
|
||||
|
||||
test('plugin headers parse with the plugin store\'s own rules (waybetter-host parse_meta)', { skip: !hasCargo && 'cargo not found' }, async () => {
|
||||
assert.equal((await create('plugin', 'meta-plugin', '--name', 'Meta: Ünïcode & Co.', '--description', 'Emoji ✨ ok, "quotes" too', '--author', 'A. Person', '--yes')).code, 0);
|
||||
const files = [join(work, 'js-plugin', 'word-badges.plugin.js'), join(work, 'meta-plugin', readdirSync(join(work, 'meta-plugin')).find(f => f.endsWith('.plugin.js')))];
|
||||
const [first, second] = pluginMeta(...files);
|
||||
assert.deepEqual(first, { file: 'word-badges.plugin.js', name: 'Word Badges', description: 'Counts words.', version: '1.2.3', author: 'Ada', validFileName: true });
|
||||
assert.equal(second.name, 'Meta: Ünïcode & Co.');
|
||||
assert.equal(second.description, 'Emoji ✨ ok, "quotes" too');
|
||||
assert.equal(second.file, 'meta-unicode-co.plugin.js');
|
||||
assert.equal(second.validFileName, true);
|
||||
});
|
||||
|
||||
test('plugin (TypeScript): typechecks against the waybetter package and builds a loadable file', { skip: !packageBuilt && 'run `bun run build:packages` first' }, async () => {
|
||||
assert.equal((await create('plugin', 'ts-plugin', '--name', 'Typed Badges', '--ts', '--yes')).code, 0);
|
||||
const dir = join(work, 'ts-plugin');
|
||||
const pkg = JSON.parse(read('ts-plugin', 'package.json'));
|
||||
assert.ok(pkg.devDependencies.waybetter && pkg.devDependencies.typescript);
|
||||
link(dir, 'waybetter', PACKAGE);
|
||||
link(dir, 'typescript', join(ROOT, 'node_modules/typescript'));
|
||||
tsc(join(dir, 'tsconfig.json'));
|
||||
execFileSync(process.execPath, ['build.mjs'], { cwd: dir });
|
||||
const built = read('ts-plugin', 'dist', 'typed-badges.plugin.js');
|
||||
assert.ok(built.startsWith('/**\n * @name Typed Badges\n'), 'header first in the built file');
|
||||
exercisePlugin(built, { selector: 'article[data-testid="tweet"]', extras: { tweet: () => ({ text: 'a b c d' }) }, words: 4 });
|
||||
if (hasCargo) assert.equal(pluginMeta(join(dir, 'dist', 'typed-badges.plugin.js'))[0].name, 'Typed Badges');
|
||||
});
|
||||
|
||||
test('theme: every preset and option validates against theme.schema.json', async () => {
|
||||
const cases = [
|
||||
['theme-default', '--name', 'Midnight Mint'],
|
||||
...Object.keys(THEME_PRESETS).map(preset => [`theme-${preset}`, '--name', `Preset ${preset}`, '--preset', preset]),
|
||||
['theme-custom', '--name', 'Custom Look', '--mode', 'auto', '--bg', '#fff', '--text', '111111', '--accent', '#C2410C', '--font', 'Geist, system-ui', '--post-font', '"Georgia", serif', '--radius', '0', '--schema', 'local'],
|
||||
];
|
||||
for (const [dir, ...args] of cases) {
|
||||
const result = await create('theme', dir, ...args, '--yes');
|
||||
assert.equal(result.code, 0, result.err);
|
||||
const file = readdirSync(join(work, dir)).find(name => name.endsWith('.theme.json'));
|
||||
const theme = JSON.parse(read(dir, file));
|
||||
assert.deepEqual(validate(SCHEMA, theme), [], `${dir}: ${file}`);
|
||||
assert.ok(theme.$schema.endsWith('theme.schema.json'));
|
||||
assert.ok(theme.font.family && theme.font.tweet, 'font roles');
|
||||
for (const role of ['bg', 'surface', 'elevated', 'border', 'text', 'muted', 'accent', 'inverse', 'onInverse', 'page']) assert.match(theme.colors[role], /^#[0-9a-f]{6}$/);
|
||||
}
|
||||
const custom = JSON.parse(read('theme-custom', 'custom-look.theme.json'));
|
||||
assert.equal(custom.$schema, './node_modules/waybetter/theme.schema.json');
|
||||
assert.deepEqual(custom.font.tweet, ['Georgia', 'serif']);
|
||||
assert.equal(custom.colors.bg, '#ffffff');
|
||||
});
|
||||
|
||||
test('theme: bad input is refused before anything is written', () => {
|
||||
assert.throws(() => normalizeThemeOptions({ name: 'X', accent: 'red' }), UsageError);
|
||||
assert.throws(() => normalizeThemeOptions({ name: 'X', font: 'Comic<Sans>' }), UsageError);
|
||||
assert.throws(() => normalizeThemeOptions({ name: 'X', radius: 40 }), UsageError);
|
||||
assert.throws(() => normalizeThemeOptions({ name: 'X', mode: 'sepia' }), UsageError);
|
||||
assert.equal(normalizeThemeOptions({ name: 'Low', bg: '#777777', text: '#888888' }).warnings.length > 0, true);
|
||||
});
|
||||
|
||||
const APPS = [...Object.keys(SITE_PRESETS), 'example.org'];
|
||||
|
||||
test('app: every site skeleton is consistent across its native and page halves', async () => {
|
||||
for (const site of APPS) {
|
||||
const dir = `app-${site.replace(/\W/g, '-')}`;
|
||||
const result = await create('app', dir, '--site', site, '--host-source', 'path', '--host-path', ROOT, '--yes');
|
||||
assert.equal(result.code, 0, result.err);
|
||||
const file = (...parts) => read(dir, ...parts);
|
||||
const siteRs = file('src-tauri', 'src', 'site.rs');
|
||||
const siteTs = file('src', 'site.ts');
|
||||
// Same hosts and runtime global on both sides.
|
||||
const rsHosts = /hosts: &\[([^\]]*)\]/.exec(siteRs)[1];
|
||||
const tsHosts = /hosts: \[([^\]]*)\]/.exec(siteTs)[1];
|
||||
assert.equal(rsHosts, tsHosts);
|
||||
assert.equal(/runtime_global: "([^"]+)"/.exec(siteRs)[1], /runtimeGlobal: "([^"]+)"/.exec(siteTs)[1]);
|
||||
// Commands: build.rs manifest == generate_handler == capability permissions.
|
||||
const manifest = /commands\(&\[([^\]]*)\]\)/.exec(file('src-tauri', 'build.rs'))[1].match(/"([a-z_]+)"/g).map(c => c.slice(1, -1)).sort();
|
||||
const handler = /generate_handler!\[([^\]]*)\]/.exec(file('src-tauri', 'src', 'desktop.rs'))[1].split(',').map(c => c.trim()).sort();
|
||||
assert.deepEqual(handler, manifest);
|
||||
const caps = ['shell', 'tab-chrome', 'site-tab'].map(name => JSON.parse(file('src-tauri', 'capabilities', `${name}.json`)));
|
||||
const granted = caps.flatMap(cap => cap.permissions).filter(p => p.startsWith('allow-')).map(p => p.slice(6).replace(/-/g, '_')).sort();
|
||||
assert.deepEqual(granted, manifest);
|
||||
assert.deepEqual(caps[1].permissions, ['allow-tab-report'], 'any tab: only about itself');
|
||||
assert.deepEqual(caps[2].remote.urls, JSON.parse(`[${tsHosts}]`).map(host => `https://${host}/*`));
|
||||
assert.equal(caps[0].local, true);
|
||||
assert.deepEqual(JSON.parse(file('src-tauri', 'tauri.conf.json')).app.security.capabilities.sort(), ['shell', 'site-tab', 'tab-chrome']);
|
||||
// Every `waybetter/...` import is a published subpath of the package.
|
||||
const sources = ['src/site.ts', 'src/address.ts', 'src/runtime/main.ts', 'src/shell/main.ts'].map(path => file(...path.split('/'))).join('\n');
|
||||
for (const [, specifier] of sources.matchAll(/from '(waybetter\/[^']+)'/g)) assert.ok(EXPORTS.includes(`./${specifier.slice('waybetter/'.length)}`), `${specifier} is exported`);
|
||||
// Icons Tauri decodes at compile time.
|
||||
const png = readFileSync(join(work, dir, 'src-tauri', 'icons', 'icon.png'));
|
||||
assert.equal(png.subarray(1, 4).toString(), 'PNG');
|
||||
assert.equal(png[25], 6, 'RGBA');
|
||||
const ico = readFileSync(join(work, dir, 'src-tauri', 'icons', 'icon.ico'));
|
||||
assert.deepEqual([...ico.subarray(0, 6)], [0, 0, 1, 0, 1, 0]);
|
||||
assert.ok(existsSync(join(work, dir, APP_OUTPUTS.runtime)), 'runtime placeholder for the first cargo check');
|
||||
}
|
||||
const kick = read('app-kick-com', 'src-tauri', 'src', 'site.rs');
|
||||
assert.match(kick, /hosts: &\["kick\.com"\]/);
|
||||
assert.match(read('app-youtube-com', 'src-tauri', 'Cargo.toml'), /waybetter-host = \{ path = ".*\/crates\/waybetter-host" \}/);
|
||||
});
|
||||
|
||||
test('app: host crate sources (submodule default, git)', async () => {
|
||||
assert.equal((await create('app', 'app-sub', '--site', 'twitch.tv', '--yes')).code, 0);
|
||||
assert.match(read('app-sub', 'src-tauri', 'Cargo.toml'), /waybetter-host = \{ path = "\.\.\/waybetter\/crates\/waybetter-host" \}/);
|
||||
assert.match(read('app-sub', 'README.md'), /git submodule add/);
|
||||
assert.equal((await create('app', 'app-git', '--site', 'twitch.tv', '--host-source', 'git', '--yes')).code, 0);
|
||||
assert.match(read('app-git', 'src-tauri', 'Cargo.toml'), /waybetter-host = \{ git = "https:\/\/git\.crt\.fyi\/CRT\/waybetter\.git" \}/);
|
||||
assert.equal((await create('app', 'app-bad', '--site', 'not a host', '--yes')).code, 1);
|
||||
assert.equal((await create('app', 'app-bad2', '--site', 'kick.com', '--global', 'wayBetter', '--yes')).code, 1);
|
||||
});
|
||||
|
||||
test('app: the TypeScript typechecks against the waybetter package', { skip: !packageBuilt && 'run `bun run build:packages` first' }, () => {
|
||||
for (const site of APPS) {
|
||||
const dir = join(work, `app-${site.replace(/\W/g, '-')}`);
|
||||
link(dir, 'waybetter', PACKAGE);
|
||||
tsc(join(dir, 'tsconfig.json'));
|
||||
}
|
||||
});
|
||||
|
||||
test('app: the Rust resolves against waybetter-host (cargo metadata)', { skip: !hasCargo && 'cargo not found' }, () => {
|
||||
const manifest = join(work, 'app-kick-com', 'src-tauri', 'Cargo.toml');
|
||||
const offline = spawnSync('cargo', ['metadata', '--offline', '--format-version', '1', '--manifest-path', manifest], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
// Without a warm registry cache, fall back to the manifest alone.
|
||||
const result = offline.status === 0 ? offline : spawnSync('cargo', ['metadata', '--no-deps', '--format-version', '1', '--manifest-path', manifest], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const metadata = JSON.parse(result.stdout);
|
||||
const app = metadata.packages.find(pkg => pkg.name === 'waybetter-for-kick');
|
||||
assert.ok(app, 'the app crate');
|
||||
const host = app.dependencies.find(dep => dep.name === 'waybetter-host');
|
||||
assert.ok(host?.path, 'waybetter-host as a path dependency');
|
||||
assert.deepEqual(app.features.desktop.sort(), ['dep:tauri', 'dep:tauri-build', 'waybetter-host/tauri']);
|
||||
});
|
||||
|
||||
test('app: cargo check (desktop) and cargo test (headless) of the generated Rust', { skip: (!hasCargo && 'cargo not found') || (process.env.WAYBETTER_SKIP_CARGO_CHECK === '1' && 'WAYBETTER_SKIP_CARGO_CHECK=1') }, () => {
|
||||
const dir = join(work, 'app-kick-com');
|
||||
mkdirSync(join(dir, 'dist'), { recursive: true });
|
||||
writeFileSync(join(dir, 'dist', 'index.html'), '<!doctype html><title>shell</title>\n');
|
||||
const env = { ...process.env, CARGO_TARGET_DIR: process.env.CARGO_TARGET_DIR || join(tmpdir(), 'create-waybetter-cargo-target') };
|
||||
const manifest = join(dir, 'src-tauri', 'Cargo.toml');
|
||||
for (const args of [['check', '--offline'], ['test', '--offline', '--no-default-features']]) {
|
||||
const result = spawnSync('cargo', [...args, '--manifest-path', manifest], { encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
|
||||
assert.equal(result.status, 0, `cargo ${args.join(' ')}\n${result.stderr.slice(-4000)}`);
|
||||
}
|
||||
});
|
||||
50
packages/create-waybetter/tests/jsonSchema.mjs
Normal file
50
packages/create-waybetter/tests/jsonSchema.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// A small JSON Schema (draft 2020-12) validator for the keywords theme.schema.json uses: type,
|
||||
// required, properties, additionalProperties, enum, const, pattern, min/maxLength, minimum,
|
||||
// maximum, items, maxItems, oneOf, anyOf and local $ref. Returns a list of error paths.
|
||||
|
||||
const typeOf = value => (value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value);
|
||||
|
||||
function matchesType(value, type) {
|
||||
if (type === 'integer') return Number.isInteger(value);
|
||||
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
||||
return typeOf(value) === type;
|
||||
}
|
||||
|
||||
export function validate(schema, value, root = schema, path = '$') {
|
||||
if (schema.$ref) {
|
||||
const target = schema.$ref.replace(/^#\//, '').split('/').reduce((node, key) => node?.[key], root);
|
||||
if (!target) return [`${path}: unresolved ${schema.$ref}`];
|
||||
return validate(target, value, root, path);
|
||||
}
|
||||
const errors = [];
|
||||
if (schema.type && ![].concat(schema.type).some(type => matchesType(value, type))) return [`${path}: expected ${schema.type}, got ${typeOf(value)}`];
|
||||
if ('const' in schema && JSON.stringify(schema.const) !== JSON.stringify(value)) errors.push(`${path}: expected ${JSON.stringify(schema.const)}`);
|
||||
if (schema.enum && !schema.enum.some(item => JSON.stringify(item) === JSON.stringify(value))) errors.push(`${path}: not one of ${schema.enum.join(', ')}`);
|
||||
if (typeof value === 'string') {
|
||||
if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) errors.push(`${path}: does not match ${schema.pattern}`);
|
||||
if (schema.minLength != null && [...value].length < schema.minLength) errors.push(`${path}: shorter than ${schema.minLength}`);
|
||||
if (schema.maxLength != null && [...value].length > schema.maxLength) errors.push(`${path}: longer than ${schema.maxLength}`);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (schema.minimum != null && value < schema.minimum) errors.push(`${path}: below ${schema.minimum}`);
|
||||
if (schema.maximum != null && value > schema.maximum) errors.push(`${path}: above ${schema.maximum}`);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (schema.maxItems != null && value.length > schema.maxItems) errors.push(`${path}: more than ${schema.maxItems} items`);
|
||||
if (schema.items) value.forEach((item, index) => errors.push(...validate(schema.items, item, root, `${path}[${index}]`)));
|
||||
}
|
||||
if (typeOf(value) === 'object') {
|
||||
for (const key of schema.required ?? []) if (!(key in value)) errors.push(`${path}: missing ${key}`);
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const child = schema.properties?.[key];
|
||||
if (child) errors.push(...validate(child, item, root, `${path}.${key}`));
|
||||
else if (schema.additionalProperties === false) errors.push(`${path}: unknown property ${key}`);
|
||||
}
|
||||
}
|
||||
if (schema.oneOf) {
|
||||
const passing = schema.oneOf.filter(option => validate(option, value, root, path).length === 0).length;
|
||||
if (passing !== 1) errors.push(`${path}: matches ${passing} of oneOf (needs exactly 1)`);
|
||||
}
|
||||
if (schema.anyOf && !schema.anyOf.some(option => validate(option, value, root, path).length === 0)) errors.push(`${path}: matches none of anyOf`);
|
||||
return errors;
|
||||
}
|
||||
17
packages/create-waybetter/tsconfig.json
Normal file
17
packages/create-waybetter/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["bin", "src"]
|
||||
}
|
||||
|
|
@ -120,6 +120,17 @@ out-of-range values when it imports a file). Point a theme at it for editor comp
|
|||
|
||||
or, in a project that installs the package, `"$schema": "./node_modules/waybetter/theme.schema.json"`.
|
||||
|
||||
## Build your own
|
||||
|
||||
```sh
|
||||
npm create waybetter plugin # *.plugin.js (or --ts) using api.scope, storage, ui and theme
|
||||
npm create waybetter theme # theme JSON with "$schema" pointing at theme.schema.json
|
||||
npm create waybetter app -- --site youtube.com # a new wayBetter app (Tauri 2 + waybetter-host)
|
||||
```
|
||||
|
||||
[`create-waybetter`](https://www.npmjs.com/package/create-waybetter) generates code against this
|
||||
package's real API and schema; `--help` lists every option.
|
||||
|
||||
## Projects using wayBetter
|
||||
|
||||
| Project | Target | What it is |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue