ember.nvp 1.0.0 → 1.1.0

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.
Files changed (44) hide show
  1. package/.github/workflows/ci.yml +6 -0
  2. package/.release-plan.json +23 -15
  3. package/CHANGELOG.md +30 -0
  4. package/package.json +1 -1
  5. package/packages/rolldown/README.md +62 -0
  6. package/packages/rolldown/package.json +13 -4
  7. package/packages/rolldown/rolldown.config.js +5 -3
  8. package/packages/rolldown/src/app-reexports.test.ts +220 -0
  9. package/packages/rolldown/src/app-reexports.ts +152 -0
  10. package/packages/rolldown/src/config.ts +1 -1
  11. package/packages/rolldown/src/dts.test.ts +166 -0
  12. package/packages/rolldown/src/transform.test.ts +173 -0
  13. package/src/bases/minimal-extension/extension.test.ts +251 -0
  14. package/src/bases/minimal-extension/files/.env.development +8 -0
  15. package/src/bases/minimal-extension/files/README.md +36 -0
  16. package/src/bases/minimal-extension/files/app/app.ts +21 -0
  17. package/src/bases/minimal-extension/files/app/boot.ts +8 -0
  18. package/src/bases/minimal-extension/files/app/config.ts +22 -0
  19. package/src/bases/minimal-extension/files/app/router.ts +10 -0
  20. package/src/bases/minimal-extension/files/app/templates/application.gts +5 -0
  21. package/src/bases/minimal-extension/files/babel.config.js +49 -0
  22. package/src/bases/minimal-extension/files/index.html +17 -0
  23. package/src/bases/minimal-extension/files/package.json +58 -0
  24. package/src/bases/minimal-extension/files/public/manifest.json +9 -0
  25. package/src/bases/minimal-extension/files/tsconfig.json +13 -0
  26. package/src/bases/minimal-extension/files/vite.config.mjs +6 -0
  27. package/src/bases/minimal-extension/index.js +142 -0
  28. package/src/cli/generator.js +4 -0
  29. package/src/cli/questions/project-type.js +9 -2
  30. package/src/layers/renovate/index.js +4 -3
  31. package/src/layers/typescript/index.js +6 -0
  32. package/src/utils/types.ts +1 -1
  33. package/test/ember.nvp/base-library.test.ts +39 -32
  34. package/test/ember.nvp/layer-publish-checks.test.ts +4 -13
  35. package/test/ember.nvp/layer-qunit.test.ts +13 -30
  36. package/test/ember.nvp/layer-vitest.test.ts +4 -18
  37. package/test/ember.nvp/permutations/app-checks.test.ts +3 -0
  38. package/test/ember.nvp/permutations/extension-1.test.ts +3 -0
  39. package/test/ember.nvp/permutations/extension-2.test.ts +3 -0
  40. package/test/ember.nvp/permutations/extension-checks.test.ts +3 -0
  41. package/test/ember.nvp/permutations/library-checks.test.ts +3 -0
  42. package/test/ember.nvp/permutations/shared.ts +68 -19
  43. package/test/test-helpers.ts +25 -30
  44. package/vitest.config.js +1 -1
@@ -0,0 +1,166 @@
1
+ import { mkdtemp, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { build } from "tsdown";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { ember } from "../index.ts";
8
+
9
+ /**
10
+ * Drives `ember()` through a real tsdown build (the dts pipeline included).
11
+ * `files` is a map of relative path -> source, written into a temp dir that
12
+ * becomes the cwd for the build (the externals and isolated-declarations
13
+ * plugins read package.json / tsconfig.json from cwd, like the tsdown CLI).
14
+ */
15
+ async function buildFixture(files: Record<string, string>, entry: string[]): Promise<string> {
16
+ const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-dts-"));
17
+
18
+ const withDefaults: Record<string, string> = {
19
+ "package.json": JSON.stringify({
20
+ name: "fixture",
21
+ version: "0.0.0",
22
+ type: "module",
23
+ exports: { "./*": { types: "./dist/*.d.ts", default: "./dist/*.js" } },
24
+ }),
25
+ "tsconfig.json": JSON.stringify({
26
+ compilerOptions: { isolatedDeclarations: true, declaration: true },
27
+ }),
28
+ ...files,
29
+ };
30
+
31
+ for (const [relative, source] of Object.entries(withDefaults)) {
32
+ const full = path.join(dir, relative);
33
+ await mkdir(path.dirname(full), { recursive: true });
34
+ await writeFile(full, source, "utf8");
35
+ }
36
+
37
+ const previousCwd = process.cwd();
38
+ process.chdir(dir);
39
+ restoreCwd = () => process.chdir(previousCwd);
40
+
41
+ await build({
42
+ entry,
43
+ config: false,
44
+ logLevel: "silent",
45
+ plugins: [ember()],
46
+ });
47
+
48
+ return dir;
49
+ }
50
+
51
+ let restoreCwd: (() => void) | undefined;
52
+
53
+ afterEach(() => {
54
+ restoreCwd?.();
55
+ restoreCwd = undefined;
56
+ });
57
+
58
+ async function listFiles(dir: string): Promise<string[]> {
59
+ const out: string[] = [];
60
+ for (const entry of await readdir(dir, { withFileTypes: true, recursive: true })) {
61
+ if (entry.isFile()) {
62
+ out.push(path.relative(dir, path.join(entry.parentPath, entry.name)));
63
+ }
64
+ }
65
+ return out.sort();
66
+ }
67
+
68
+ describe("declarations (full ember() via tsdown)", () => {
69
+ it("emits .d.ts for an entry that type-imports from a non-entry .gts", async () => {
70
+ // Mirrors the luna-core shape: the component is in the runtime graph via
71
+ // the index entry, while a second entry (a service) only *type*-imports
72
+ // from it. The service's declaration then references the component's
73
+ // (virtual) .d.ts, which only exists in the bundler's module graph.
74
+ const dir = await buildFixture(
75
+ {
76
+ "src/index.ts": `export { default as Popup } from './popup.gts';`,
77
+ "src/service.ts": [
78
+ `import type { PopupBlock } from './popup.gts';`,
79
+ ``,
80
+ `export class TooltipService {`,
81
+ ` registerPopup(context: PopupBlock): void {`,
82
+ ` void context;`,
83
+ ` }`,
84
+ `}`,
85
+ ].join("\n"),
86
+ "src/popup.gts": [
87
+ `import Component from '@glimmer/component';`,
88
+ ``,
89
+ `export interface PopupBlock {`,
90
+ ` open: () => void;`,
91
+ `}`,
92
+ ``,
93
+ `export interface PopupSignature {`,
94
+ ` Element: HTMLDivElement;`,
95
+ ` Blocks: { default: [PopupBlock] };`,
96
+ `}`,
97
+ ``,
98
+ `export default class Popup extends Component<PopupSignature> {`,
99
+ ` <template>popup</template>`,
100
+ `}`,
101
+ ].join("\n"),
102
+ },
103
+ ["./src/index.ts", "./src/service.ts"],
104
+ );
105
+
106
+ const dist = await listFiles(path.join(dir, "dist"));
107
+
108
+ expect(dist).toContain("index.d.ts");
109
+ expect(dist).toContain("service.d.ts");
110
+
111
+ const serviceDts = await readFile(path.join(dir, "dist/service.d.ts"), "utf8");
112
+
113
+ // The PopupBlock type must be reachable: either inlined into the bundled
114
+ // declaration or imported via a specifier that exists in dist.
115
+ expect(serviceDts).toContain("PopupBlock");
116
+ expect(serviceDts).not.toContain(".gts");
117
+ });
118
+
119
+ it("bundles co-located CSS (via @tsdown/css) without breaking declarations", async () => {
120
+ // Ember components routinely import a co-located .css file. Without CSS
121
+ // handling the build dies on tsdown's css-guard — and because the failing
122
+ // component module never loads, every declaration that references it
123
+ // dangles (surfacing as misleading UNLOADABLE_DEPENDENCY errors on
124
+ // <component>.d.ts). @tsdown/css (this package's peer) makes both work.
125
+ const dir = await buildFixture(
126
+ {
127
+ "src/index.ts": `export { default as Popup } from './popup.gts';`,
128
+ "src/service.ts": [
129
+ `import type { PopupBlock } from './popup.gts';`,
130
+ ``,
131
+ `export function register(context: PopupBlock): void {`,
132
+ ` void context;`,
133
+ `}`,
134
+ ].join("\n"),
135
+ "src/popup.css": `.popup { color: red; }`,
136
+ "src/popup.gts": [
137
+ `import './popup.css';`,
138
+ `import Component from '@glimmer/component';`,
139
+ ``,
140
+ `export interface PopupBlock {`,
141
+ ` open: () => void;`,
142
+ `}`,
143
+ ``,
144
+ `export default class Popup extends Component {`,
145
+ ` <template>popup</template>`,
146
+ `}`,
147
+ ].join("\n"),
148
+ },
149
+ ["./src/index.ts", "./src/service.ts"],
150
+ );
151
+
152
+ const dist = await listFiles(path.join(dir, "dist"));
153
+
154
+ const cssFiles = dist.filter((file) => file.endsWith(".css"));
155
+ expect(cssFiles).toHaveLength(1);
156
+
157
+ const css = await readFile(path.join(dir, "dist", cssFiles[0]!), "utf8");
158
+ expect(css).toContain(".popup");
159
+
160
+ // The cascade regression: the css-importing component's declaration must
161
+ // still be generated and reachable from the service's declaration.
162
+ const serviceDts = await readFile(path.join(dir, "dist/service.d.ts"), "utf8");
163
+ expect(serviceDts).toContain("PopupBlock");
164
+ expect(serviceDts).not.toContain(".gts");
165
+ });
166
+ });
@@ -0,0 +1,173 @@
1
+ import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { rolldown } from "rolldown";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { emberTransform } from "./transform.ts";
8
+
9
+ /**
10
+ * Drives the plugin through a real rolldown build. `files` is a map of
11
+ * relative path -> source; the build entry is `index.ts`. Every bare (package)
12
+ * specifier is marked external so the build only exercises the local
13
+ * `.gts`/`.gjs`/`.ts` handling and doesn't need real dependencies installed.
14
+ * Returns the concatenated code of every emitted chunk.
15
+ */
16
+ async function bundle(files: Record<string, string>): Promise<string> {
17
+ const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-build-"));
18
+
19
+ for (const [relative, source] of Object.entries(files)) {
20
+ const full = path.join(dir, relative);
21
+ await mkdir(path.dirname(full), { recursive: true });
22
+ await writeFile(full, source, "utf8");
23
+ }
24
+
25
+ const isLocal = (id: string) => id.startsWith(".") || id.startsWith("/") || path.isAbsolute(id);
26
+
27
+ const build = await rolldown({
28
+ input: path.join(dir, "index.ts"),
29
+ plugins: [emberTransform()],
30
+ external: (id) => !isLocal(id),
31
+ onwarn() {},
32
+ });
33
+
34
+ const { output } = await build.generate({ format: "es" });
35
+
36
+ const code = output
37
+ .filter((chunk): chunk is typeof chunk & { code: string } => "code" in chunk)
38
+ .map((chunk) => chunk.code)
39
+ .join("\n");
40
+
41
+ // rolldown's `//#region <path>` comments reference the random temp dir;
42
+ // collapse them to the bare filename so snapshots stay deterministic.
43
+ return code.replace(/(\/\/#region ).*\/([^/\n]+)$/gm, "$1$2");
44
+ }
45
+
46
+ describe("emberTransform (full plugin via rolldown)", () => {
47
+ it("compiles a .gts module's <template> and keeps bare imports external", async () => {
48
+ const code = await bundle({
49
+ "index.ts": `export { default as Foo } from './foo.gts';`,
50
+ "foo.gts": [
51
+ `import Component from '@glimmer/component';`,
52
+ `export default class Foo extends Component {`,
53
+ ` <template>Hello</template>`,
54
+ `}`,
55
+ ].join("\n"),
56
+ });
57
+
58
+ // <template> is gone, replaced by a content-tag `template(...)` call.
59
+ expect(code).not.toContain("<template>");
60
+ expect(code).toContain("template(");
61
+ // The bare import survived as an external dependency.
62
+ expect(code).toContain(`from "@glimmer/component"`);
63
+ // ...and it pulls in the template-compiler runtime import.
64
+ expect(code).toContain("@ember/template-compiler");
65
+
66
+ expect(code).toMatchInlineSnapshot(`
67
+ "import { template } from "@ember/template-compiler";
68
+ import Component from "@glimmer/component";
69
+ //#region foo.ts
70
+ var Foo = class extends Component {
71
+ static {
72
+ template(\`Hello\`, {
73
+ component: this,
74
+ eval() {
75
+ return eval(arguments[0]);
76
+ }
77
+ });
78
+ }
79
+ };
80
+ //#endregion
81
+ export { Foo };
82
+ "
83
+ `);
84
+ });
85
+
86
+ it("compiles .gjs alongside .gts and plain .ts in one graph", async () => {
87
+ const code = await bundle({
88
+ "index.ts": [
89
+ `export { default as Foo } from './foo.gts';`,
90
+ `export { greet } from './util.gjs';`,
91
+ `export { helper } from './plain.ts';`,
92
+ ].join("\n"),
93
+ "foo.gts": `<template>Foo</template>`,
94
+ "util.gjs": [
95
+ `export function greet() { return 'hi'; }`,
96
+ `<template>{{greet}}</template>`,
97
+ ].join("\n"),
98
+ "plain.ts": `export function helper() { return 1; }`,
99
+ });
100
+
101
+ expect(code).not.toContain("<template>");
102
+ expect(code).toContain("function greet()");
103
+ expect(code).toContain("function helper()");
104
+ expect(code).toContain("export {");
105
+
106
+ expect(code).toMatchInlineSnapshot(`
107
+ "import { template } from "@ember/template-compiler";
108
+ //#region foo.ts
109
+ var foo_default = template(\`Foo\`, { eval() {
110
+ return eval(arguments[0]);
111
+ } });
112
+ //#endregion
113
+ //#region util.js
114
+ function greet() {
115
+ return "hi";
116
+ }
117
+ var util_default = template(\`{{greet}}\`, { eval() {
118
+ return eval(arguments[0]);
119
+ } });
120
+ //#endregion
121
+ //#region plain.ts
122
+ function helper() {
123
+ return 1;
124
+ }
125
+ //#endregion
126
+ export { foo_default as Foo, greet, helper };
127
+ "
128
+ `);
129
+ });
130
+
131
+ it("resolves a .gts module that imports another .gts relatively", async () => {
132
+ const code = await bundle({
133
+ "index.ts": `export { default as Page } from './page.gts';`,
134
+ "page.gts": [
135
+ `import Widget from './widget.gts';`,
136
+ `export default class Page {`,
137
+ ` Widget = Widget;`,
138
+ ` <template><Widget /></template>`,
139
+ `}`,
140
+ ].join("\n"),
141
+ "widget.gts": `<template>widget</template>`,
142
+ });
143
+
144
+ // Both templates compiled, and the relative .gts import resolved (bundled).
145
+ expect(code).not.toContain("<template>");
146
+ expect(code).not.toContain(".gts");
147
+ expect(code).toContain("template(");
148
+
149
+ expect(code).toMatchInlineSnapshot(`
150
+ "import { template } from "@ember/template-compiler";
151
+ //#region widget.ts
152
+ var widget_default = template(\`widget\`, { eval() {
153
+ return eval(arguments[0]);
154
+ } });
155
+ //#endregion
156
+ //#region page.ts
157
+ var Page = class {
158
+ Widget = widget_default;
159
+ static {
160
+ template(\`<Widget />\`, {
161
+ component: this,
162
+ eval() {
163
+ return eval(arguments[0]);
164
+ }
165
+ });
166
+ }
167
+ };
168
+ //#endregion
169
+ export { Page };
170
+ "
171
+ `);
172
+ });
173
+ });
@@ -0,0 +1,251 @@
1
+ import { beforeAll, describe, it, expect as hardExpect, afterAll } from "vitest";
2
+ import { generate, build, reapply } from "#test-helpers";
3
+ import { packageJson } from "ember-apply";
4
+
5
+ import type { Project } from "ember.nvp";
6
+ import { readFile, rm } from "node:fs/promises";
7
+ import { globSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ const expect = hardExpect.soft;
11
+
12
+ describe("typescript", () => {
13
+ let project: Project;
14
+ beforeAll(async () => {
15
+ project = await generate({
16
+ name: "my-extension",
17
+ type: "extension",
18
+ layers: ["typescript"],
19
+ });
20
+
21
+ let { exitCode } = await project.install();
22
+
23
+ hardExpect(exitCode, "Install succeeds").toBe(0);
24
+ });
25
+
26
+ afterAll(async () => {
27
+ await rm(project.directory, { recursive: true, force: true });
28
+ });
29
+
30
+ it("project check", () => {
31
+ expect(project.desires.layers.map((layer) => layer.name)).toMatchInlineSnapshot(`
32
+ [
33
+ "typescript",
34
+ ]
35
+ `);
36
+ expect(project.wantsTypeScript).toBe(true);
37
+ expect(project.type).toBe("extension");
38
+ });
39
+
40
+ it("has the typescript babel plugin", async () => {
41
+ let content = await project.read("babel.config.js");
42
+
43
+ expect(content).toContain("@babel/plugin-transform-typescript");
44
+ });
45
+
46
+ it("named the extension in the manifest", async () => {
47
+ let manifest = JSON.parse((await project.read("public/manifest.json"))!);
48
+
49
+ expect(manifest.name).toBe("my-extension");
50
+ expect(manifest.manifest_version).toBe(3);
51
+ });
52
+
53
+ it("boots from an external script (extension CSP forbids inline scripts)", async () => {
54
+ let html = await project.read("index.html");
55
+
56
+ expect(html).toContain(`src="./app/boot.ts"`);
57
+ expect(html).not.toContain("Application.create");
58
+ });
59
+
60
+ it("build for development (testing, etc)", async () => {
61
+ let { exitCode } = await build(project);
62
+
63
+ expect(exitCode).toBe(0);
64
+ });
65
+
66
+ it("build for production", async () => {
67
+ let { exitCode } = await build(project, "production");
68
+
69
+ expect(exitCode).toBe(0);
70
+ });
71
+
72
+ it("dist is loadable as an unpacked extension", async () => {
73
+ let manifest = JSON.parse(
74
+ await readFile(join(project.directory, "dist/manifest.json"), "utf-8"),
75
+ );
76
+
77
+ expect(manifest.name).toBe("my-extension");
78
+ expect(manifest.action.default_popup).toBe("index.html");
79
+
80
+ let html = await readFile(join(project.directory, "dist/index.html"), "utf-8");
81
+
82
+ expect(html).toContain(`<script type="module"`);
83
+ expect(html).not.toContain("Application.create");
84
+ });
85
+
86
+ it("has no JS files", async () => {
87
+ let results = globSync("app/**/*", {
88
+ exclude: ["node_modules", "dist"],
89
+ cwd: project.directory,
90
+ });
91
+ expect(results).toMatchInlineSnapshot(`
92
+ [
93
+ "app/app.ts",
94
+ "app/boot.ts",
95
+ "app/config.ts",
96
+ "app/router.ts",
97
+ "app/templates",
98
+ "app/templates/application.gts",
99
+ ]
100
+ `);
101
+ });
102
+
103
+ it("when re-applying, it no-ops correctly", async () => {
104
+ let files = new Set(
105
+ globSync("**/*", { cwd: project.directory, exclude: ["node_modules", "dist"] }),
106
+ );
107
+
108
+ await reapply(project, []);
109
+
110
+ let filesAfter = new Set(
111
+ globSync("**/*", { cwd: project.directory, exclude: ["node_modules", "dist"] }),
112
+ );
113
+
114
+ expect(filesAfter).toEqual(files);
115
+ expect(files).toMatchInlineSnapshot(`
116
+ Set {
117
+ "README.md",
118
+ "app",
119
+ "babel.config.js",
120
+ "index.html",
121
+ "package.json",
122
+ "pnpm-lock.yaml",
123
+ "public",
124
+ "tsconfig.json",
125
+ "vite.config.mjs",
126
+ "public/manifest.json",
127
+ "app/app.ts",
128
+ "app/boot.ts",
129
+ "app/config.ts",
130
+ "app/router.ts",
131
+ "app/templates",
132
+ "app/templates/application.gts",
133
+ }
134
+ `);
135
+ });
136
+ });
137
+
138
+ describe("javascript", () => {
139
+ let project: Project;
140
+ beforeAll(async () => {
141
+ project = await generate({
142
+ name: "my-extension",
143
+ type: "extension",
144
+ layers: [],
145
+ });
146
+
147
+ let { exitCode } = await project.install();
148
+
149
+ hardExpect(exitCode, "Install succeeds").toBe(0);
150
+ });
151
+
152
+ afterAll(async () => {
153
+ await rm(project.directory, { recursive: true, force: true });
154
+ });
155
+
156
+ it("project check", () => {
157
+ expect(project.desires.layers.map((layer) => layer.name)).toMatchInlineSnapshot(`[]`);
158
+ expect(project.wantsTypeScript).toBe(false);
159
+ expect(project.type).toBe("extension");
160
+ });
161
+
162
+ it("does not have the typescript babel plugin", async () => {
163
+ let content = await project.read("babel.config.js");
164
+
165
+ expect(content).not.toContain("@babel/plugin-transform-typescript");
166
+ });
167
+
168
+ it("updated the sub-path imports to not have ts extensions", async () => {
169
+ let manifest = await packageJson.read(project.directory);
170
+
171
+ expect(manifest.imports).toMatchInlineSnapshot(`
172
+ {
173
+ "#app/*": "./app/*",
174
+ "#components/*": "./app/components/*",
175
+ "#config": "./app/config.js",
176
+ "#services/*": "./app/services/*",
177
+ "#test-helpers/*": "./tests/helpers/*",
178
+ "#utils/*": "./app/utils/*",
179
+ }
180
+ `);
181
+ });
182
+
183
+ it("the popup points at the renamed boot script", async () => {
184
+ let html = await project.read("index.html");
185
+
186
+ expect(html).toContain(`src="./app/boot.js"`);
187
+ expect(html).not.toContain("boot.ts");
188
+ });
189
+
190
+ it("build for development (testing, etc)", async () => {
191
+ let { exitCode } = await build(project);
192
+
193
+ expect(exitCode).toBe(0);
194
+ });
195
+
196
+ it("build for production", async () => {
197
+ let { exitCode } = await build(project, "production");
198
+
199
+ expect(exitCode).toBe(0);
200
+ });
201
+
202
+ it("has no TS files", async () => {
203
+ let results = globSync("app/**/*", {
204
+ exclude: ["node_modules", "dist"],
205
+ cwd: project.directory,
206
+ });
207
+ expect(results).toMatchInlineSnapshot(`
208
+ [
209
+ "app/app.js",
210
+ "app/boot.js",
211
+ "app/config.js",
212
+ "app/router.js",
213
+ "app/templates",
214
+ "app/templates/application.gjs",
215
+ ]
216
+ `);
217
+ });
218
+
219
+ it("when re-applying, it no-ops correctly", async () => {
220
+ let files = new Set(
221
+ globSync("**/*", { cwd: project.directory, exclude: ["node_modules", "dist"] }),
222
+ );
223
+
224
+ await reapply(project, []);
225
+
226
+ let filesAfter = new Set(
227
+ globSync("**/*", { cwd: project.directory, exclude: ["node_modules", "dist"] }),
228
+ );
229
+
230
+ expect(filesAfter).toEqual(files);
231
+ expect(files).toMatchInlineSnapshot(`
232
+ Set {
233
+ "README.md",
234
+ "app",
235
+ "babel.config.js",
236
+ "index.html",
237
+ "package.json",
238
+ "pnpm-lock.yaml",
239
+ "public",
240
+ "vite.config.mjs",
241
+ "public/manifest.json",
242
+ "app/app.js",
243
+ "app/boot.js",
244
+ "app/config.js",
245
+ "app/router.js",
246
+ "app/templates",
247
+ "app/templates/application.gjs",
248
+ }
249
+ `);
250
+ });
251
+ });
@@ -0,0 +1,8 @@
1
+ # This file is committed to git and should not contain any secrets.
2
+ #
3
+ # Vite recommends using .env.local or .env.[mode].local if you need to manage secrets
4
+ # SEE: https://vite.dev/guide/env-and-mode.html#env-files for more information.
5
+
6
+
7
+ # Default NODE_ENV with vite build --mode=test is production
8
+ NODE_ENV=development
@@ -0,0 +1,36 @@
1
+ # minimal-extension
2
+
3
+ A [Manifest V3](https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3) browser extension whose popup is an Ember app.
4
+
5
+ ## Developing
6
+
7
+ The popup is a regular web page, so the fastest feedback loop is the vite dev server in a normal browser tab:
8
+
9
+ ```bash
10
+ pnpm dev
11
+ ```
12
+
13
+ To run as an actual extension, build (in watch mode) and load the `dist/` directory as an unpacked extension:
14
+
15
+ ```bash
16
+ pnpm build:watch
17
+ ```
18
+
19
+ - **Chrome / Edge / Brave**: `chrome://extensions` → enable "Developer mode" → "Load unpacked" → select `dist/`
20
+ - **Firefox**: `about:debugging#/runtime/this-firefox` → "Load Temporary Add-on…" → select `dist/manifest.json`
21
+
22
+ After a rebuild, re-open the popup (or reload the extension) to see changes.
23
+
24
+ ## Anatomy
25
+
26
+ - `public/manifest.json` — the extension manifest; copied verbatim into `dist/`. Add [`background`](https://developer.chrome.com/docs/extensions/reference/manifest/background), [`content_scripts`](https://developer.chrome.com/docs/extensions/reference/manifest/content-scripts), permissions, and icons here as your extension grows.
27
+ - `index.html` — the popup page (`action.default_popup`). Extension pages are served with a `script-src 'self'` Content-Security-Policy, so the app boots from `app/boot.ts` rather than an inline script.
28
+ - `app/` — a normal Ember app. Routing uses `locationType: "none"`, since extension pages have no URL bar to bind to.
29
+
30
+ ## Building for release
31
+
32
+ ```bash
33
+ pnpm build
34
+ ```
35
+
36
+ Zip the contents of `dist/` and upload it to the extension store(s) of your choosing.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Looking for services that come from addons?
3
+ *
4
+ * See: https://github.com/embroider-build/embroider/issues/2659
5
+ *
6
+ * We currently don't support app-tree merging from libraries.
7
+ *
8
+ * For services, I highly recommend looking in to either of
9
+ * - https://github.com/chancancode/ember-polaris-service-
10
+ * - https://ember-primitives.pages.dev/6-utils/createService.md
11
+ * - https://ember-primitives.pages.dev/6-utils/createAsyncService.md
12
+ */
13
+ import Application from "ember-strict-application-resolver";
14
+
15
+ export default class App extends Application {
16
+ modules = {
17
+ ...import.meta.glob("./router.*", { eager: true }),
18
+ ...import.meta.glob("./templates/**/*", { eager: true }),
19
+ ...import.meta.glob("./services/**/*", { eager: true }),
20
+ };
21
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Extension pages disallow inline scripts (`script-src 'self'`),
3
+ * so booting the app lives here instead of in index.html.
4
+ */
5
+ import Application from "#app/app";
6
+ import environment from "#config";
7
+
8
+ Application.create(environment.APP);
@@ -0,0 +1,22 @@
1
+ interface Config {
2
+ environment: "development" | "production";
3
+ locationType: "history" | "hash" | "none" | "auto";
4
+ rootURL: string;
5
+ EmberENV?: Record<string, unknown>;
6
+ APP: Record<string, unknown> & { rootElement?: string; autoboot?: boolean };
7
+ }
8
+
9
+ const ENV: Config = {
10
+ environment: import.meta.env.DEV ? "development" : "production",
11
+ rootURL: "/",
12
+ /**
13
+ * Extension pages live at chrome-extension://<id>/index.html (or
14
+ * moz-extension://), where history-based routing has nothing to bind
15
+ * to. "none" keeps routing fully programmatic.
16
+ */
17
+ locationType: "none",
18
+ EmberENV: {},
19
+ APP: {},
20
+ };
21
+
22
+ export default ENV;