studio-engine 0.115.1.rc1 → 0.115.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fabe1d2bfd93ef94f291f09e43bc3c6116cbd80023cfffb91d77af00d9bbfbc6
4
- data.tar.gz: ec6b825a90b3bbbf1ef0c4183ffe1f0b2555a34429df2d6877c8f7c4c94f838f
3
+ metadata.gz: 34882e3c979d460aab938a9bd07a2a520222068592dd7b2bbc5e6d7b114cbd4f
4
+ data.tar.gz: e3a4a039830a9c071526bc9410c11f62f7ff2ed70a3c68d842f2ec0502daf93f
5
5
  SHA512:
6
- metadata.gz: 8ac4e8a7fc5a8510ec63eb126ed44110a3205e072d2adf4861dfefcae0584de24ac2eb3624958c15c0a15e11a986dc1608116b51013d76879fb8cc26b1129516
7
- data.tar.gz: d358e292cf4c84be548bf75235ec4613d9d9a3b9c9ddec008879976b696198b7ff1608ca3b46a82bd20d2e419f39f97b12aa7df08556ab808faca510a853d84c
6
+ metadata.gz: 94324fdda382908f9b3630cf6f7a3866f75e111b879888e65aff870d2e5e9dccc985315cbe14935197e091f284fa6b454f9f9341a341eb5cb2bd1dd8bdf3b4b5
7
+ data.tar.gz: 6dc861ab2e2d4837549d77b43cf03e751c35051e06981df58cee66aa9c20a89ca395a8c23397b939aacbde8f96e4abaaca927faf3775f8db31c19f78bd1f1f16
data/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Added
8
+
9
+ - **One Node tooling package, shipped in the gem** (`lib/studio/node_tooling/`,
10
+ see its `README.md`). `versions.json` pins Node 22, `@playwright/test`
11
+ 1.62.1 and `axe-core` 4.11.0. `playwright_base.js` holds the browser lane's
12
+ shared defaults; `e2e_helpers.js` holds `watchPageErrors` and
13
+ `blockOffsiteRequests`; `node_test/register.mjs` lets `node --test` import a
14
+ module by its importmap name. `bin/rails studio:node:link` links it at
15
+ `node_modules/.studio-engine` (run it after `npm ci`), and
16
+ `bin/rails studio:node:check` exits 1, naming each pin a host drifts from. No
17
+ host change is needed until a host adopts it.
18
+
19
+ ### Changed
20
+
21
+ - **The engine runs Node 22.** `.tool-versions` reads `nodejs 22`, both
22
+ `engine-ci.yml` Node steps read it through `node-version-file`, and
23
+ `package.json` pins `@playwright/test` at exactly 1.62.1. The engine's
24
+ Playwright config and e2e helpers build on the shared package, and resolve to
25
+ the same config as before.
26
+
27
+ ### Fixed
28
+
29
+ - **`studio/studio_confetti` resolves `"canvas-confetti"` on every page.** In
30
+ 0.115.1.rc1 the head loaded it with `javascript_include_tag ..., type:
31
+ "module"`, which also sends a `Link: rel=modulepreload` response header. The
32
+ browser acts on that header before it parses the import map, so on a page
33
+ whose head parsed slowly it resolved the module's bare import against no map
34
+ and threw `TypeError: Failed to resolve module specifier "canvas-confetti"`
35
+ (11 hub e2e specs: the boards and `/dreams`). The head now imports it with
36
+ `javascript_import_module_tag` below the map, which sends no header. No host
37
+ change is needed. A module the head loads by URL may import no bare specifier
38
+ (`test/views/head_url_modules_import_no_bare_specifier_test.rb`), and the e2e
39
+ lab now sends the Link header Rails sends, which its stub had dropped.
40
+
7
41
  ## 0.115.1 — 2026-10-27
8
42
 
9
43
  ### Changed
data/README.md CHANGED
@@ -127,6 +127,23 @@ A migrating host also moves every other reader of the preset, not only the
127
127
  build: any production code or test that `require`s or reads the preset's text
128
128
  reads `theme.css` instead.
129
129
 
130
+ ## Node tooling
131
+
132
+ The gem ships the one Node tooling package every app shares, in
133
+ `lib/studio/node_tooling/` (its `README.md` lists the files): the pins
134
+ (`versions.json`: Node 22, `@playwright/test` 1.62.1, `axe-core` 4.11.0), a
135
+ Playwright config base, the shared e2e helpers, and the `node --test` importmap
136
+ hooks.
137
+
138
+ ```bash
139
+ bin/rails studio:node:link # node_modules/.studio-engine -> the package; run after npm ci
140
+ bin/rails studio:node:check # exits 1, naming each pin this app drifts from
141
+ ```
142
+
143
+ An app pins Node in `.tool-versions` (`nodejs 22`), the file mise and
144
+ `actions/setup-node`'s `node-version-file` both read.
145
+ `docs/FRONT_END_STANDARD.md`, "Node tooling", has the rest.
146
+
130
147
  ## Configuration
131
148
 
132
149
  Each consuming app configures the engine in `config/initializers/studio.rb`:
@@ -3,9 +3,10 @@
3
3
  vendored canvas-confetti (app/assets/javascripts/studio/canvas_confetti.js,
4
4
  pinned as "canvas-confetti", no CDN).
5
5
 
6
- An ES module (the head loads it with a type="module" tag after the import
7
- map). It imports canvas-confetti for its side effect, which defines the
8
- library's global `confetti`, and exports:
6
+ An ES module (the head imports it with an inline module tag after the import
7
+ map, never by URL: a URL tag's Link modulepreload header would resolve the
8
+ bare import below before the map exists). It imports canvas-confetti for its
9
+ side effect, which defines the library's global `confetti`, and exports:
9
10
 
10
11
  import { burst, cannons, confetti } from "studio/studio_confetti"
11
12
  burst(cardEl) // radial burst behind a card/modal
@@ -195,7 +195,13 @@
195
195
  take every bare specifier on the page down with it. The ones below that load by
196
196
  URL (type="module" with a src) are external files, not inline scripts: they
197
197
  carry no body and need no nonce under a policy that allows 'self'. The session
198
- store (studio/_session_stamp, above) is the one classic script, and says why. %>
198
+ store (studio/_session_stamp, above) is the one classic script, and says why.
199
+ A MODULE LOADED BY URL MUST IMPORT NO BARE SPECIFIER. javascript_include_tag
200
+ also sends a `Link: rel=modulepreload` response header, which the browser
201
+ acts on before it parses this map, so a bare import in that module can
202
+ resolve against no map at all and fail for good (0.115.1.rc1, task
203
+ confetti-resolves-on-hub). A module with bare imports takes a
204
+ javascript_import_module_tag, which sends no header. %>
199
205
  <%# THE ENGINE'S BROWSER BOOT (studio/application): its every-page Stimulus
200
206
  controllers, the pinned stack, and the Alpine shims. A module tag, so it
201
207
  carries the nonce. %>
@@ -238,13 +244,19 @@
238
244
  module that owns it. Its imports import nothing, and studio/alpine_shims
239
245
  imports it, so a failed boot leaves a page its uploads. %>
240
246
  <%= javascript_import_module_tag "studio/alpine_scopes" %>
241
- <%# CONFETTI, BY URL. studio/studio_confetti imports the vendored canvas-confetti
242
- (pinned "canvas-confetti", app/assets/javascripts/studio/canvas_confetti.js:
243
- no CDN, same-origin) and exports burst, cannons and confetti. It also keeps
244
- the deprecated globals window.studioConfetti and window.confetti, which
245
- studio/head_chrome's fireSuccessConfetti and host classic code still call.
246
- Before Alpine, so both exist by the time an x-init can fire one. %>
247
- <%= javascript_include_tag "studio/studio_confetti", type: "module", "data-turbo-track": "reload" %>
247
+ <%# CONFETTI, BY ITS OWN DOOR, AND NOT BY URL. studio/studio_confetti imports the
248
+ vendored canvas-confetti by its bare pin ("canvas-confetti",
249
+ app/assets/javascripts/studio/canvas_confetti.js: no CDN, same-origin) and
250
+ exports burst, cannons and confetti. It also keeps the deprecated globals
251
+ window.studioConfetti and window.confetti, which studio/head_chrome's
252
+ fireSuccessConfetti and host classic code still call. Before Alpine, so both
253
+ exist by the time an x-init can fire one.
254
+ An inline module tag, never javascript_include_tag: that helper's Link
255
+ modulepreload header had the browser resolve "canvas-confetti" before the
256
+ map above existed, so on a page whose head parsed slowly (the hub's boards
257
+ and /dreams) the module failed with "Failed to resolve module specifier"
258
+ (e2e/confetti_resolves_under_a_slow_head.spec.js). %>
259
+ <%= javascript_import_module_tag "studio/studio_confetti" %>
248
260
  <%# ALPINE LOADS AFTER THE MODULE TAGS, and the order is the contract. Module
249
261
  scripts (and deferred classic ones) execute in document order, so by the time
250
262
  Alpine starts, studio/application has defined the shims it evaluates
@@ -0,0 +1,27 @@
1
+ # studio-engine Node tooling
2
+
3
+ The one Node tooling package every host shares. It ships inside the gem, so a
4
+ host gets it with the engine it already bundles: no npm registry, no token.
5
+
6
+ - `versions.json`: the pin set. Node's major, and the exact version of every
7
+ shared npm package. A host that declares one of these packages declares it at
8
+ exactly this version.
9
+ - `playwright_base.js`: the browser lane's shared defaults (one chromium
10
+ project, one worker, a Rails `webServer` on `/up`).
11
+ - `e2e_helpers.js`: `watchPageErrors` and `blockOffsiteRequests`.
12
+ - `node_test/register.mjs` and `node_test/importmap_hooks.mjs`: let `node --test`
13
+ import a module by the bare name the importmap pins it under.
14
+
15
+ Every file here requires only `node:` builtins and its own siblings, because it
16
+ is loaded from the gem's directory, which has no `node_modules`.
17
+
18
+ A host links the package into its own tree and checks itself against the pins:
19
+
20
+ ```sh
21
+ bin/rails studio:node:link # node_modules/.studio-engine -> this directory
22
+ bin/rails studio:node:check # exits 1, naming each pin the host drifts from
23
+ ```
24
+
25
+ Run `link` after `npm ci`, because `npm ci` deletes `node_modules`. Then
26
+ `require("./node_modules/.studio-engine/playwright_base.js")`. The adoption
27
+ steps are in the engine repository's `docs/FRONT_END_STANDARD.md`, "Node tooling".
@@ -0,0 +1,74 @@
1
+ // studio-engine's shared browser-lane helpers. Requires only node: builtins, so it
2
+ // loads from the gem's directory (see README.md).
3
+
4
+ const fs = require("node:fs");
5
+
6
+ const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost"]);
7
+
8
+ function isOffOrigin(url) {
9
+ if (!url || !/^https?:/.test(url)) return false;
10
+ return !LOCAL_HOSTS.has(new URL(url).hostname);
11
+ }
12
+
13
+ // Collect everything the page THREW or logged as an error.
14
+ //
15
+ // `pageerror` is the cheapest detector there is for a script that never ran: the
16
+ // phantom-element defect threw a SyntaxError on every load while the response
17
+ // bytes were valid and the server-side guard passed.
18
+ //
19
+ // It hears every frame on the page, a third party's included: Playwright delivers a
20
+ // cross-origin frame's console to the page's `console` event. `ownOriginOnly: true`
21
+ // scopes that out BY ORIGIN, never by message text. A console error is dropped only
22
+ // when the script that logged it was served from another host, and a thrown error
23
+ // only when every frame of its stack was. An error with no location or no stack is
24
+ // kept, because it cannot be shown to be someone else's. The default counts
25
+ // everything.
26
+ function watchPageErrors(page, { ownOriginOnly = false } = {}) {
27
+ const errors = [];
28
+ page.on("pageerror", (error) => {
29
+ if (ownOriginOnly) {
30
+ const sources = (error.stack || "").match(/https?:\/\/[^\s)]+/g) || [];
31
+ if (sources.length > 0 && sources.every(isOffOrigin)) return;
32
+ }
33
+ errors.push(`pageerror: ${error.message}`);
34
+ });
35
+ page.on("console", (message) => {
36
+ if (message.type() !== "error") return;
37
+ if (ownOriginOnly && isOffOrigin(message.location().url)) return;
38
+ errors.push(`console.error: ${message.text()}`);
39
+ });
40
+ return errors;
41
+ }
42
+
43
+ // Answer every request that leaves this origin, so the lane cannot depend on the
44
+ // public internet.
45
+ //
46
+ // ANSWERED, NOT ABORTED. route.abort() makes Chromium log `Failed to load resource:
47
+ // net::ERR_FAILED`, which watchPageErrors counts. An empty 200 logs nothing, and
48
+ // the page falls back as it would offline.
49
+ //
50
+ // `pinned` maps an off-origin URL to `{ file, contentType }`: a file loaded with
51
+ // Subresource Integrity cannot be answered empty (the digest fails and Chromium
52
+ // logs it), so it is answered with a byte-exact local copy. The response carries
53
+ // `access-control-allow-origin: *`, because a `crossorigin` tag refuses a CORS
54
+ // response without it before checking the digest.
55
+ async function blockOffsiteRequests(page, { pinned = {} } = {}) {
56
+ await page.route("**/*", (route) => {
57
+ const url = new URL(route.request().url());
58
+ if (LOCAL_HOSTS.has(url.hostname)) return route.continue();
59
+
60
+ const copy = pinned[url.href];
61
+ if (copy) {
62
+ return route.fulfill({
63
+ status: 200,
64
+ contentType: copy.contentType,
65
+ headers: { "access-control-allow-origin": "*" },
66
+ body: fs.readFileSync(copy.file),
67
+ });
68
+ }
69
+
70
+ return route.fulfill({ status: 200, body: "" });
71
+ });
72
+ }
73
+
74
+ module.exports = { isOffOrigin, watchPageErrors, blockOffsiteRequests };
@@ -0,0 +1,48 @@
1
+ // Resolve importmap specifiers under `node --test` the way the browser does.
2
+ //
3
+ // In the browser, config/importmap.rb pins a directory under a prefix (`board`,
4
+ // `cyvasse`, `studio`), and the modules import each other as "board/colors".
5
+ // Node has no import map, so this hook maps each prefix onto the same files. A
6
+ // bare top-level name with a file of its own in `topLevelDir`
7
+ // (`import … from "chat_follow"`, pinned by name) maps the same way.
8
+ //
9
+ // A Rails app has no package.json "type", so Node has to guess each .js file's
10
+ // format. Files under `esmDirs` are ES modules (that is what the browser loads),
11
+ // so this hook says so instead.
12
+ //
13
+ // Configured by register.mjs through `initialize(data)`; it holds no app's names.
14
+ import { existsSync } from "node:fs";
15
+
16
+ let prefixes = [];
17
+ let topLevel = null;
18
+ let esmDirs = [];
19
+
20
+ const asDir = (url) => (url.endsWith("/") ? url : `${url}/`);
21
+
22
+ export function initialize({ root, prefixes: map = {}, topLevelDir = null, esmDirs: dirs = [] } = {}) {
23
+ const base = asDir(String(root));
24
+ prefixes = Object.entries(map).map(([prefix, dir]) => [prefix, asDir(new URL(dir, base).href)]);
25
+ topLevel = topLevelDir ? asDir(new URL(topLevelDir, base).href) : null;
26
+ esmDirs = dirs.map((dir) => asDir(new URL(dir, base).href));
27
+ }
28
+
29
+ function mapped(specifier) {
30
+ for (const [prefix, dir] of prefixes) {
31
+ const match = specifier.startsWith(`${prefix}/`) && /^[\w/-]+$/.test(specifier.slice(prefix.length + 1));
32
+ if (match) return new URL(`${specifier.slice(prefix.length + 1)}.js`, dir).href;
33
+ }
34
+ if (topLevel && /^[a-z_]+$/.test(specifier)) {
35
+ const file = new URL(`${specifier}.js`, topLevel);
36
+ if (existsSync(file)) return file.href;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ export async function resolve(specifier, context, nextResolve) {
42
+ const target = mapped(specifier);
43
+ const resolved = await nextResolve(target || specifier, context);
44
+ if (resolved.url.endsWith(".js") && esmDirs.some((dir) => resolved.url.startsWith(dir))) {
45
+ return { ...resolved, format: "module" };
46
+ }
47
+ return resolved;
48
+ }
@@ -0,0 +1,20 @@
1
+ // Installs importmap_hooks.mjs for `node --test`. A host's own register file
2
+ // (loaded with `node --import`) names its prefixes and calls this once:
3
+ //
4
+ // import { registerImportmapHooks } from "../../../node_modules/.studio-engine/node_test/register.mjs";
5
+ // registerImportmapHooks({
6
+ // root: new URL("../../../", import.meta.url),
7
+ // prefixes: { board: "app/javascript/board/" },
8
+ // topLevelDir: "app/javascript/",
9
+ // esmDirs: ["app/javascript/", "test/javascript/"],
10
+ // });
11
+ //
12
+ // Paths are relative to `root`, the host's Rails root.
13
+ import { register } from "node:module";
14
+
15
+ export function registerImportmapHooks({ root, prefixes = {}, topLevelDir = null, esmDirs = [] }) {
16
+ if (!root) throw new Error("registerImportmapHooks: root is required");
17
+ register("./importmap_hooks.mjs", import.meta.url, {
18
+ data: { root: String(root), prefixes, topLevelDir, esmDirs },
19
+ });
20
+ }
@@ -0,0 +1,63 @@
1
+ // studio-engine's shared Playwright defaults.
2
+ //
3
+ // A plain object, not a defineConfig() call: this file is loaded from the gem's
4
+ // directory, which has no node_modules, so it cannot require @playwright/test.
5
+ // The host requires its own copy and wraps the result:
6
+ //
7
+ // const { defineConfig } = require("@playwright/test");
8
+ // const { studioPlaywrightConfig } = require("./node_modules/.studio-engine/playwright_base.js");
9
+ // module.exports = defineConfig(studioPlaywrightConfig({
10
+ // defaultPort: "3000",
11
+ // webServer: { command: "bin/rails server -p $E2E_PORT" },
12
+ // }));
13
+ //
14
+ // Every key of `overrides` replaces the default of the same name; `use` merges
15
+ // one level deep, so a host can add `reducedMotion` and keep `baseURL`.
16
+
17
+ const DEFAULTS = Object.freeze({
18
+ testDir: "./e2e",
19
+ timeout: 30_000,
20
+ retries: 0,
21
+ workers: 1,
22
+ });
23
+
24
+ // The base URL, from the environment first: PW_BASE_URL (or BASE_URL) points the
25
+ // lane at a stack someone else booted, and then no webServer is started.
26
+ function studioBaseURL({ defaultPort, env = process.env } = {}) {
27
+ const port = env.E2E_PORT || String(defaultPort);
28
+ const external = env.PW_BASE_URL || env.BASE_URL || null;
29
+ return { port, external, baseURL: external || `http://127.0.0.1:${port}` };
30
+ }
31
+
32
+ // A Rails server Playwright boots and waits on. `/up` is the Rails health check
33
+ // every app in the ecosystem keeps.
34
+ function railsWebServer({ command, port, env = {}, timeout = 120_000, ci = !!process.env.CI }) {
35
+ if (!command) throw new Error("railsWebServer: a command is required");
36
+ return {
37
+ command,
38
+ url: `http://127.0.0.1:${port}/up`,
39
+ reuseExistingServer: !ci,
40
+ timeout,
41
+ stdout: "pipe",
42
+ stderr: "pipe",
43
+ env: { RAILS_ENV: "test", E2E_PORT: String(port), ...env },
44
+ };
45
+ }
46
+
47
+ function studioPlaywrightConfig({ defaultPort, webServer, env = process.env, use = {}, ...overrides } = {}) {
48
+ if (!defaultPort) throw new Error("studioPlaywrightConfig: defaultPort is required");
49
+ const { port, external, baseURL } = studioBaseURL({ defaultPort, env });
50
+ const config = {
51
+ ...DEFAULTS,
52
+ forbidOnly: !!env.CI,
53
+ projects: [{ name: "chromium", use: { browserName: "chromium" } }],
54
+ ...overrides,
55
+ use: { baseURL, headless: true, ...use },
56
+ };
57
+ if (!external && webServer) {
58
+ config.webServer = railsWebServer({ ci: !!env.CI, ...webServer, port });
59
+ }
60
+ return config;
61
+ }
62
+
63
+ module.exports = { DEFAULTS, studioBaseURL, railsWebServer, studioPlaywrightConfig };
@@ -0,0 +1,5 @@
1
+ {
2
+ "node": "22",
3
+ "@playwright/test": "1.62.1",
4
+ "axe-core": "4.11.0"
5
+ }
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+
7
+ module Studio
8
+ # The engine's Node tooling package (lib/studio/node_tooling/README.md): the
9
+ # one pin set, the shared Playwright base and helpers, and the node:test hooks.
10
+ #
11
+ # The gem's path is resolved here in Ruby, never by Node running bundler (the
12
+ # same rule Studio::TailwindEntry keeps). `link!` puts a symlink at a fixed
13
+ # path in the host, so a host's playwright.config.js or test register file
14
+ # requires `./node_modules/.studio-engine/...` with no shell-out.
15
+ #
16
+ # `drift` holds a host to the pins: Node's major in `.tool-versions` (what
17
+ # mise and actions/setup-node's `node-version-file` read) and package.json
18
+ # `engines`, and the exact version of every pinned package the host declares,
19
+ # in package.json and in package-lock.json. A pinned package the host does not
20
+ # declare is not drift: no host is made to install what it does not use.
21
+ module NodeTooling
22
+ ROOT = Pathname(__dir__).join("node_tooling").freeze
23
+ LINK = "node_modules/.studio-engine"
24
+
25
+ module_function
26
+
27
+ def root = ROOT
28
+
29
+ # The host the rake tasks act on. A method of its own so a test can point
30
+ # the tasks at a temporary host without defining a Rails constant.
31
+ def host_root = ::Rails.root
32
+
33
+ def versions = JSON.parse(ROOT.join("versions.json").read)
34
+
35
+ def link_path(app_root) = Pathname(app_root).join(LINK)
36
+
37
+ # Idempotent. Replaces a stale link (an older gem's directory); refuses a
38
+ # real directory or file at the path rather than deleting someone's work.
39
+ def link!(app_root)
40
+ path = link_path(app_root)
41
+ if path.symlink?
42
+ return path if path.readlink == ROOT
43
+
44
+ path.delete
45
+ elsif path.exist?
46
+ raise ArgumentError, "#{path} exists and is not a symlink; move it aside and link again"
47
+ end
48
+ FileUtils.mkdir_p(path.dirname)
49
+ File.symlink(ROOT, path)
50
+ path
51
+ end
52
+
53
+ # Every way the host at `app_root` differs from versions.json, one sentence
54
+ # each. Empty when it matches.
55
+ def drift(app_root)
56
+ app_root = Pathname(app_root)
57
+ pins = versions
58
+ major = pins.fetch("node")
59
+ problems = node_version_drift(app_root, major)
60
+
61
+ package = read_json(app_root.join("package.json"))
62
+ return problems unless package
63
+
64
+ engines = package.dig("engines", "node")
65
+ unless engines == "#{major}.x"
66
+ problems << "package.json engines.node is #{engines.inspect}; the pin is \"#{major}.x\""
67
+ end
68
+
69
+ lock = read_json(app_root.join("package-lock.json"))
70
+ pins.except("node").each do |name, version|
71
+ declared = package.dig("devDependencies", name) || package.dig("dependencies", name)
72
+ next unless declared
73
+
74
+ problems << "package.json declares #{name} #{declared.inspect}; the pin is \"#{version}\"" unless declared == version
75
+ locked = lock&.dig("packages", "node_modules/#{name}", "version")
76
+ problems << "package-lock.json locks #{name} at #{locked.inspect}; the pin is \"#{version}\"" unless locked == version
77
+ end
78
+ problems
79
+ end
80
+
81
+ def node_version_drift(app_root, major)
82
+ file = app_root.join(".tool-versions")
83
+ return ["no .tool-versions; it should read \"nodejs #{major}\""] unless file.file?
84
+
85
+ line = file.read.lines.map(&:split).find { |tool, *| %w[nodejs node].include?(tool) }
86
+ return [".tool-versions names no nodejs; it should read \"nodejs #{major}\""] unless line
87
+ return [] if line[1] == major
88
+
89
+ [".tool-versions reads \"#{line.join(" ")}\"; it should read \"nodejs #{major}\""]
90
+ end
91
+
92
+ def read_json(path)
93
+ path.file? ? JSON.parse(path.read) : nil
94
+ end
95
+ end
96
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.115.1.rc1"
2
+ VERSION = "0.115.1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -22,6 +22,7 @@ require "studio/geo/lookup"
22
22
  require "studio/link_preview"
23
23
  require "studio/engine"
24
24
  require "studio/tailwind_entry"
25
+ require "studio/node_tooling"
25
26
  require "studio/color_scale"
26
27
  require "studio/environment_banner"
27
28
  require "studio/theme_resolver"
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The engine's Node tooling package (lib/studio/node_tooling/README.md). Neither
4
+ # task loads :environment; both need only Rails.root (Studio::NodeTooling.host_root).
5
+ namespace :studio do
6
+ namespace :node do
7
+ desc "Link studio-engine's Node tooling package at node_modules/.studio-engine (run after npm ci)"
8
+ task :link do
9
+ root = Studio::NodeTooling.host_root
10
+ path = Studio::NodeTooling.link!(root)
11
+ puts "studio-engine: #{path.relative_path_from(root)} -> #{Studio::NodeTooling.root}"
12
+ end
13
+
14
+ desc "Fail when this app's Node pins drift from studio-engine's versions.json"
15
+ task :check do
16
+ problems = Studio::NodeTooling.drift(Studio::NodeTooling.host_root)
17
+ if problems.empty?
18
+ puts "studio-engine: Node tooling matches the pins #{Studio::NodeTooling.versions.to_json}"
19
+ else
20
+ abort(["studio-engine: Node tooling drifts from the pins:", *problems.map { |p| " - #{p}" }].join("\n"))
21
+ end
22
+ end
23
+ end
24
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.115.1.rc1
4
+ version: 0.115.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -774,6 +774,13 @@ files:
774
774
  - lib/studio/navbar_slots.rb
775
775
  - lib/studio/navbar_wordmark.rb
776
776
  - lib/studio/newsletter.rb
777
+ - lib/studio/node_tooling.rb
778
+ - lib/studio/node_tooling/README.md
779
+ - lib/studio/node_tooling/e2e_helpers.js
780
+ - lib/studio/node_tooling/node_test/importmap_hooks.mjs
781
+ - lib/studio/node_tooling/node_test/register.mjs
782
+ - lib/studio/node_tooling/playwright_base.js
783
+ - lib/studio/node_tooling/versions.json
777
784
  - lib/studio/oauth_identity.rb
778
785
  - lib/studio/partial_block.rb
779
786
  - lib/studio/profile_image.rb
@@ -799,6 +806,7 @@ files:
799
806
  - lib/studio/version.rb
800
807
  - lib/tasks/studio_email.rake
801
808
  - lib/tasks/studio_knowledge.rake
809
+ - lib/tasks/studio_node.rake
802
810
  - lib/tasks/studio_trash.rake
803
811
  - studio-engine.gemspec
804
812
  homepage: https://github.com/McRitchie-Studio/studio-engine