diff --git a/.reach.exs b/.reach.exs index c59429c..96382fd 100644 --- a/.reach.exs +++ b/.reach.exs @@ -83,6 +83,7 @@ model = [ logic = [ "Volt.Builder.Artifact", "Volt.Builder.CSS.Prepared", + "Volt.Builder.Declarations", "Volt.Builder.Plan", "Volt.Assets", "Volt.Assets.Query", diff --git a/CHANGELOG.md b/CHANGELOG.md index d708d55..9678dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Added + +- `mix volt.build --declarations` and `config :volt, declarations: true` write a library's type declarations with its bundle: one `.d.ts` per entry, beside it and without a hash, holding what the entry exports and the types those exports reach. Modules are emitted through OXC's isolated declarations (oxc 0.19), so exported declarations need explicit types and the build fails naming the module that lacks them; the declarations are then bundled as `rollup-plugin-dts` does, with types-only modules included, imports of other packages hoisted, and same-named declarations of different modules told apart. `.vue` modules are declared by the Vue plugin ([#71](https://github.com/elixir-volt/volt/issues/71)). + +### Changed + +- Require oxc 0.19 and quickbeam 0.11.3. + ## 0.22.1 - 2026-10-07 ### Fixed diff --git a/guides/deployment/production-builds.md b/guides/deployment/production-builds.md index 0561274..4a370c7 100644 --- a/guides/deployment/production-builds.md +++ b/guides/deployment/production-builds.md @@ -150,6 +150,18 @@ For IIFE output the global name is derived from the specifier (`phoenix_html` be config :volt, external: %{"phoenix" => "Phoenix", "vue" => "Vue"} ``` +## Type Declarations for Libraries + +A library that ships a JavaScript client can write its type declarations with its bundle: + +```elixir +"volt.build --entry priv/ts/client/my_lib.ts --format esm --name my_lib --declarations" +``` + +or `config :volt, declarations: true`. Each entry gets one `my_lib.d.ts` beside its bundle, without a hash, holding what the entry exports and the types those exports refer to, including types from modules that only hold types. Imports of other packages stay imports, for the consumer's `node_modules` to resolve. Point `package.json`'s `types` at the file. + +Declarations are emitted per module without a type checker, as `tsc --isolatedDeclarations` does, so exported declarations need explicit types: a function without a return type or a `const` whose type is inferred fails the build, naming the module and line. Set `"isolatedDeclarations": true` in `tsconfig.json` to have the editor and `mix volt.js.check --type-aware` report the same places. Two modules declaring the same name are told apart with a `$1` suffix on the one further from the entry; `import * as` and `export * as` of the library's own modules are not bundled. + ## Module Preloading For code-split builds, the production manifest records static imports, dynamic imports, chunk-local CSS, and emitted assets. Use `Volt.Preload.tags/2` in your layout to preload the entry and its static chunk dependencies: diff --git a/lib/mix/tasks/volt/build.ex b/lib/mix/tasks/volt/build.ex index 099df16..e5af25c 100644 --- a/lib/mix/tasks/volt/build.ex +++ b/lib/mix/tasks/volt/build.ex @@ -31,6 +31,7 @@ defmodule Mix.Tasks.Volt.Build do * `--no-tree-shaking` — preserve unused exports * `--mode` — build mode for env variables (default: `"production"`) * `--format` — output format: `iife`, `esm`, or `cjs` (default from config) + * `--declarations` — write one `.d.ts` per entry beside its bundle, for a library * `--tailwind` — build Tailwind CSS * `--tailwind-css` — custom Tailwind input CSS file * `--tailwind-source` — source directory for Tailwind scanning (repeatable) @@ -61,6 +62,7 @@ defmodule Mix.Tasks.Volt.Build do resolve_dir: [:string, :keep], external: [:string, :keep], code_splitting: :boolean, + declarations: :boolean, tree_shaking: :boolean, tailwind: :boolean, tailwind_css: :string, @@ -127,6 +129,7 @@ defmodule Mix.Tasks.Volt.Build do mode: Keyword.get(parsed, :mode) || to_string(config.mode), format: parse_format(Keyword.get(parsed, :format), config.format), code_splitting: Keyword.get(parsed, :code_splitting, config.code_splitting), + declarations: Keyword.get(parsed, :declarations, config.declarations), tree_shaking: Keyword.get(parsed, :tree_shaking, config.tree_shaking), chunks: config.chunks, env_prefix: config.env_prefix, @@ -176,6 +179,12 @@ defmodule Mix.Tasks.Volt.Build do Mix.shell().info(" #{Path.basename(style.path)} #{format_file(style.path)}") end + for declaration <- assets.declarations do + Mix.shell().info( + " #{Path.basename(declaration.path)} #{format_file(declaration.path)}" + ) + end + Mix.shell().info(" manifest.json #{map_size(manifest)} entries") Mix.shell().info("Built in #{ms}ms") diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index d3dfb84..3eea110 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -42,6 +42,7 @@ defmodule Volt.Builder do * `:asset_url_prefix` — public URL prefix for emitted asset references (default: `"/assets"`) * `:code_splitting` — split dynamic imports and multi-entry ESM shared modules into chunks (default: `true`) * `:tree_shaking` — remove unused exports (default: `true`) + * `:declarations` — write one `.d.ts` per script entry beside its bundle, with the types the entry exports (default: `false`). See `Volt.Builder.Declarations`. * `:chunks` — manual chunk definitions, map of chunk name to list of patterns: chunks: %{"vendor" => ["vue", "vue-router"], "ui" => ["assets/src/components"]} @@ -152,7 +153,13 @@ defmodule Volt.Builder do build_isolated_entries(entries, ctx, build_ctx) |> finalize_build_results() result -> - merge_worker_metadata(result, worker_metadata) + result + |> merge_worker_metadata(worker_metadata) + |> with_declarations( + Enum.map(entries, fn {entry, :script, name} -> {entry, name} end), + ctx, + build_ctx + ) end else true -> build_isolated_entries(entries, ctx, build_ctx) |> finalize_build_results() @@ -336,7 +343,9 @@ defmodule Volt.Builder do Output.prepare_single(entry, name, compiled, out) end - merge_worker_metadata(prepared, worker_metadata) + prepared + |> merge_worker_metadata(worker_metadata) + |> with_declarations([{entry, name}], ctx, build_ctx) end end @@ -398,6 +407,7 @@ defmodule Volt.Builder do asset_url_prefix = Keyword.get(opts, :asset_url_prefix, Paths.prefix()) aliases = Keyword.get(opts, :aliases, %{}) code_splitting = Keyword.get(opts, :code_splitting, true) + declarations = Keyword.get(opts, :declarations, false) tree_shaking = Keyword.get(opts, :tree_shaking, true) chunks = Keyword.get(opts, :chunks, %{}) format = Keyword.get(opts, :format, :iife) @@ -463,6 +473,7 @@ defmodule Volt.Builder do bundle_opts: bundle_opts, asset_url_prefix: asset_url_prefix, code_splitting: code_splitting, + declarations: declarations, sourcemap_hidden: sourcemap_opt == :hidden, chunks: chunks } @@ -515,6 +526,39 @@ defmodule Volt.Builder do end end + # A library's types go beside its bundle, under the entry's name and no hash: + # `package.json` names the file. + defp with_declarations({:ok, result, plan}, entries, ctx, %{declarations: true} = build_ctx) do + Enum.reduce_while(entries, {:ok, [], []}, fn {entry, name}, {:ok, outputs, artifacts} -> + case Volt.Builder.Declarations.bundle(entry, ctx) do + {:ok, dts} -> + file = "#{name}.d.ts" + artifact = %Volt.Builder.Artifact{file: file, content: dts} + + output = %Volt.Builder.OutputFile{ + path: Path.join(build_ctx.outdir, file), + size: byte_size(dts) + } + + {:cont, {:ok, [output | outputs], [artifact | artifacts]}} + + error -> + {:halt, error} + end + end) + |> case do + {:ok, outputs, artifacts} -> + with {:ok, plan} <- Volt.Builder.Plan.new(plan.artifacts ++ Enum.reverse(artifacts)) do + {:ok, %{result | declarations: result.declarations ++ Enum.reverse(outputs)}, plan} + end + + error -> + error + end + end + + defp with_declarations(prepared, _entries, _ctx, _build_ctx), do: prepared + defp merge_worker_metadata({:error, _} = error, _workers), do: error defp merge_worker_metadata({:ok, result, plan}, workers) do @@ -966,6 +1010,7 @@ defmodule Volt.Builder do css: result.css || acc.css, styles: [result.styles | acc.styles], chunks: [result.chunks | acc.chunks], + declarations: [result.declarations | acc.declarations], manifest: Map.merge(acc.manifest, result.manifest) } @@ -989,7 +1034,8 @@ defmodule Volt.Builder do result | js: js, styles: result.styles |> Enum.reverse() |> List.flatten() |> Enum.uniq(), - chunks: result.chunks |> Enum.reverse() |> List.flatten() + chunks: result.chunks |> Enum.reverse() |> List.flatten(), + declarations: result.declarations |> Enum.reverse() |> List.flatten() }, plan} end diff --git a/lib/volt/builder/build_context.ex b/lib/volt/builder/build_context.ex index 77e2cb8..3dfe08f 100644 --- a/lib/volt/builder/build_context.ex +++ b/lib/volt/builder/build_context.ex @@ -8,6 +8,7 @@ defmodule Volt.Builder.BuildContext do ctx: nil, asset_url_prefix: Volt.Paths.prefix(), code_splitting: true, + declarations: false, sourcemap_hidden: false, chunks: %{} end diff --git a/lib/volt/builder/context.ex b/lib/volt/builder/context.ex index f6be16c..f0089e9 100644 --- a/lib/volt/builder/context.ex +++ b/lib/volt/builder/context.ex @@ -16,4 +16,22 @@ defmodule Volt.Builder.Context do asset_url_prefix: Volt.Paths.prefix(), asset_outdir: nil, asset_root: nil + + @type t :: %__MODULE__{ + node_modules: Path.t() | nil, + resolve_dirs: [Path.t()], + package_scopes: list(), + aliases: %{String.t() => String.t()}, + plugins: [module() | {module(), keyword()}], + external: MapSet.t(String.t()), + external_globals: %{String.t() => String.t()}, + loaders: map(), + module_types: map(), + import_source: String.t() | nil, + target: String.t(), + define: map(), + asset_url_prefix: String.t(), + asset_outdir: Path.t() | nil, + asset_root: Path.t() | nil + } end diff --git a/lib/volt/builder/declarations.ex b/lib/volt/builder/declarations.ex new file mode 100644 index 0000000..0f2e5a0 --- /dev/null +++ b/lib/volt/builder/declarations.ex @@ -0,0 +1,618 @@ +defmodule Volt.Builder.Declarations do + @moduledoc """ + Writes the type declarations of a library entry as one `.d.ts`. + + Each TypeScript module of the entry's graph is emitted through OXC's isolated + declarations, which need explicit types on exported declarations and report + the ones they cannot infer; a plugin-owned module, such as a `.vue` file, is + declared by its plugin. The declarations are then bundled the way + `rollup-plugin-dts` bundles them: the graph is walked from the entry along + the declarations' own imports, so modules that only hold types are included; + only the declarations the entry's exports reach are kept, along with ambient + declarations such as `declare module`; imports of other packages are hoisted + and merged; and two modules declaring the same name are told apart by + renaming the one further from the entry. + + Declarations are module-scoped, so names resolve at the top level of each + module: an identifier is the module's own declaration of that name, or what + the module imports under that name. Property names, type parameters and + parameters are left alone. + """ + + alias Volt.Builder.Resolver + + @declarations [ + :ts_type_alias_declaration, + :ts_interface_declaration, + :class_declaration, + :function_declaration, + :ts_declare_function, + :ts_enum_declaration, + :ts_module_declaration + ] + + @doc """ + Bundle the declarations of the module at `entry` into one `.d.ts` source. + + Returns `{:error, {:declarations, path, reason}}` when a module's declarations + cannot be emitted (`reason` is then the emitter's diagnostics) or hold a + construct the bundler does not handle (`reason` is then a message). + """ + @spec bundle(Path.t(), Volt.Builder.Context.t()) :: {:ok, String.t()} | {:error, term()} + def bundle(entry, ctx) do + entry = Path.expand(entry) + + with {:ok, modules} <- collect(entry, ctx, %{}) do + bundle_modules(entry, modules) + end + end + + # ── Collecting ──────────────────────────────────────────────────── + + # `modules` maps a path to its declarations: the imports of other packages + # as written, the bindings imported from project modules, the statements, + # and the exports. + defp collect(path, _ctx, modules) when is_map_key(modules, path), do: {:ok, modules} + + defp collect(path, ctx, modules) do + with {:ok, dts} <- declaration_source(path, ctx), + {:ok, ast} <- OXC.parse(dts, Path.basename(path) <> ".d.ts"), + {:ok, module} <- read_module(path, dts, ast, ctx) do + Enum.reduce_while(module.sources, {:ok, Map.put(modules, path, module)}, fn dep, + {:ok, acc} -> + case collect(dep, ctx, acc) do + {:ok, acc} -> {:cont, {:ok, acc}} + error -> {:halt, error} + end + end) + else + {:error, {:declarations, _path, _reason}} = error -> error + {:error, reason} -> {:error, {:declarations, path, reason}} + end + end + + defp declaration_source(path, ctx) do + with {:ok, source} <- File.read(path) do + case Volt.PluginRunner.declaration(ctx.plugins, path, source, []) do + {:ok, dts} -> {:ok, dts} + {:error, reason} -> {:error, reason} + nil -> OXC.isolated_declarations(source, Path.basename(path)) + end + end + end + + defp read_module(path, dts, ast, ctx) do + module = %{ + path: path, + external_imports: [], + imports: %{}, + statements: [], + exports: %{}, + export_all: [], + sources: [] + } + + ast.body + |> Enum.reduce_while({:ok, module, 0}, fn node, {:ok, module, previous_end} -> + case read_statement(node, {dts, previous_end}, ctx, module) do + {:ok, module} -> {:cont, {:ok, module, node.end}} + {:error, message} -> {:halt, {:error, {:declarations, path, message}}} + end + end) + |> case do + {:ok, module, _end} -> + {:ok, + %{ + module + | statements: Enum.reverse(module.statements), + external_imports: Enum.reverse(module.external_imports), + sources: module.sources |> Enum.reverse() |> Enum.uniq() + }} + + error -> + error + end + end + + defp read_statement(%{type: :import_declaration} = node, {dts, _previous_end}, ctx, module) do + case resolve_source(node.source.value, module.path, ctx) do + {:project, dep} -> + imports = + Enum.reduce(node.specifiers, module.imports, fn specifier, imports -> + Map.put(imports, specifier.local.name, {dep, imported_name(specifier)}) + end) + + {:ok, %{module | imports: imports, sources: [dep | module.sources]}} + + :external -> + {:ok, %{module | external_imports: [slice(dts, node) | module.external_imports]}} + end + end + + defp read_statement(%{type: :export_all_declaration} = node, _dts, ctx, module) do + case {node.exported, resolve_source(node.source.value, module.path, ctx)} do + {nil, {:project, dep}} -> + {:ok, %{module | export_all: [dep | module.export_all], sources: [dep | module.sources]}} + + {nil, :external} -> + {:error, + "`export * from #{inspect(node.source.value)}` of another package is not bundled"} + + {%{name: name}, _} -> + {:error, "`export * as #{name}` is not bundled; import the module and export it"} + end + end + + # `export { a, b as c }`, with or without `from`. + defp read_statement( + %{type: :export_named_declaration, declaration: nil} = node, + _dts, + ctx, + module + ) do + with {:ok, from} <- export_source(node, module.path, ctx) do + exports = + Enum.reduce(node.specifiers, module.exports, fn specifier, exports -> + target = + if from, + do: {:import, from, specifier.local.name}, + else: {:binding, specifier.local.name} + + Map.put(exports, specifier.exported.name, target) + end) + + {:ok, %{module | exports: exports, sources: List.wrap(from) ++ module.sources}} + end + end + + defp read_statement( + %{type: :export_named_declaration, declaration: declaration} = node, + dts, + _ctx, + module + ) do + statement = statement(module.path, dts, declaration, node) + exports = Enum.reduce(statement.names, module.exports, &Map.put(&2, &1, {:binding, &1})) + {:ok, %{module | statements: [statement | module.statements], exports: exports}} + end + + defp read_statement( + %{type: :export_default_declaration, declaration: %{type: :identifier, name: name}}, + _dts, + _ctx, + module + ) do + {:ok, %{module | exports: Map.put(module.exports, "default", {:binding, name})}} + end + + defp read_statement( + %{type: :export_default_declaration, declaration: declaration} = node, + dts, + _ctx, + module + ) do + case statement(module.path, dts, declaration, node) do + %{names: [name]} = statement -> + exports = Map.put(module.exports, "default", {:binding, name}) + {:ok, %{module | statements: [statement | module.statements], exports: exports}} + + _anonymous -> + {:error, "an anonymous `export default` cannot be referenced from the bundle; name it"} + end + end + + defp read_statement(node, dts, _ctx, module) do + {:ok, %{module | statements: [statement(module.path, dts, node, node) | module.statements]}} + end + + defp export_source(%{source: nil}, _path, _ctx), do: {:ok, nil} + + defp export_source(%{source: %{value: specifier}}, path, ctx) do + case resolve_source(specifier, path, ctx) do + {:project, dep} -> + {:ok, dep} + + :external -> + {:error, "`export { ... } from #{inspect(specifier)}` of another package is not bundled"} + end + end + + defp imported_name(%{type: :import_default_specifier}), do: "default" + defp imported_name(%{type: :import_namespace_specifier}), do: "*" + defp imported_name(%{imported: imported}), do: imported.name + + # A module of the project is bundled; anything that resolves into a package, + # or is external to the build, stays an import for the consumer to resolve. + defp resolve_source(specifier, importer, ctx) do + case Resolver.resolve(specifier, importer, ctx) do + {:ok, resolved} -> + {resolved_path, _query} = Volt.URL.split_query(resolved) + + if Resolver.inside_node_modules?(resolved) or not File.regular?(resolved_path), + do: :external, + else: {:project, Path.expand(resolved_path)} + + _skip_or_error -> + :external + end + end + + # A top-level statement: its text, the names it declares and the + # identifiers it refers to, with offsets into the text. + # + # The text is the declaration without its `export`, led by the comments + # between the previous statement and this one, which hold its JSDoc. A + # declaration that `export default` left without `declare` gets it back, as + # it no longer has the `export` that implied it. + defp statement(path, {dts, previous_end}, node, outer) do + lead = dts |> binary_part(previous_end, outer.start - previous_end) |> String.trim() + declare = if implied_declare?(node), do: "declare ", else: "" + prefix = if lead == "", do: declare, else: lead <> "\n" <> declare + + %{ + module: path, + text: prefix <> slice(dts, node), + names: declared_names(node), + references: references(node, node.start - byte_size(prefix)) + } + end + + defp implied_declare?(%{type: type, declare: false}) + when type in [ + :class_declaration, + :function_declaration, + :ts_declare_function, + :variable_declaration, + :ts_enum_declaration + ], + do: true + + defp implied_declare?(_node), do: false + + defp slice(source, %{start: start, end: finish}), do: binary_part(source, start, finish - start) + + # ── Names ───────────────────────────────────────────────────────── + + defp declared_names(%{type: :variable_declaration, declarations: declarations}), + do: for(%{id: %{type: :identifier, name: name}} <- declarations, do: name) + + defp declared_names(%{type: type, id: %{type: :identifier, name: name}}) + when type in @declarations, + do: [name] + + defp declared_names(_node), do: [] + + # Names the statement binds itself, property names, type parameters, + # parameters and member accesses are not references. + defp references(node, base) do + {_node, {references, bound}} = + OXC.postwalk(node, {[], MapSet.new()}, fn + %{type: :identifier, name: name, start: start, end: finish} = id, {refs, bound} -> + {id, {[%{start: start - base, end: finish - base, name: name} | refs], bound}} + + other, {refs, bound} -> + {other, {refs, bind(other, bound)}} + end) + + references + |> Enum.reject(&MapSet.member?(bound, &1.start + base)) + |> Enum.sort_by(& &1.start) + end + + defp bind(%{type: :ts_type_parameter, name: %{start: start}}, bound), + do: MapSet.put(bound, start) + + defp bind(%{type: :ts_qualified_name, right: %{start: start}}, bound), + do: MapSet.put(bound, start) + + defp bind(%{type: :ts_enum_member, id: %{start: start}}, bound), do: MapSet.put(bound, start) + + defp bind(%{type: :variable_declarator, id: %{start: start}}, bound), + do: MapSet.put(bound, start) + + defp bind(%{type: :member_expression, computed: false, property: %{start: start}}, bound), + do: MapSet.put(bound, start) + + defp bind(%{computed: false, key: %{type: :identifier, start: start}}, bound), + do: MapSet.put(bound, start) + + defp bind(%{type: type, id: %{type: :identifier, start: start}} = node, bound) + when type in @declarations, + do: node |> Map.delete(:id) |> bind(MapSet.put(bound, start)) + + # Parameters bind their names: `(items: string[])` refers to nothing. + defp bind(%{params: params}, bound) when is_list(params) do + Enum.reduce(params, bound, fn + %{type: :identifier, start: start}, bound -> MapSet.put(bound, start) + %{type: :assignment_pattern, left: %{start: start}}, bound -> MapSet.put(bound, start) + %{type: :rest_element, argument: %{start: start}}, bound -> MapSet.put(bound, start) + _pattern, bound -> bound + end) + end + + defp bind(_node, bound), do: bound + + # ── Bundling ────────────────────────────────────────────────────── + + defp bundle_modules(entry, modules) do + declared = declared_index(modules) + + with {:ok, entry_exports} <- exports_of(entry, modules, MapSet.new()), + roots = for({_exported, target} <- entry_exports, do: target), + {:ok, reached} <- reach(roots, modules, declared, MapSet.new()) do + statements = emitted_statements(modules, reached, entry) + renames = renames(statements, entry) + paths = statements |> Enum.map(& &1.module) |> Enum.uniq() + + parts = [ + external_imports(paths, modules), + Enum.map_join(statements, "\n", &rewrite(&1, modules, renames)), + export_list(entry_exports, renames) + ] + + {:ok, Enum.map_join(Enum.reject(parts, &(&1 == "")), "\n", & &1) <> "\n"} + end + end + + # What a module exports, as `%{exported_name => {module, declared_name}}`, + # following re-exports and `export *` to the declaring module. + defp exports_of(path, modules, visiting) do + if MapSet.member?(visiting, path) do + {:ok, %{}} + else + module = Map.fetch!(modules, path) + visiting = MapSet.put(visiting, path) + + with {:ok, starred} <- star_exports(module.export_all, modules, visiting) do + named_exports(module, modules, visiting, starred) + end + end + end + + defp named_exports(module, modules, visiting, acc) do + Enum.reduce_while(module.exports, {:ok, acc}, fn {exported, target}, {:ok, acc} -> + case resolve_target(target, module, modules, visiting) do + {:ok, resolved} -> {:cont, {:ok, Map.put(acc, exported, resolved)}} + error -> {:halt, error} + end + end) + end + + defp star_exports(sources, modules, visiting) do + Enum.reduce_while(sources, {:ok, %{}}, fn dep, {:ok, acc} -> + case exports_of(dep, modules, visiting) do + {:ok, exports} -> {:cont, {:ok, Map.merge(acc, Map.delete(exports, "default"))}} + error -> {:halt, error} + end + end) + end + + defp resolve_target({:import, dep, name}, module, modules, visiting), + do: resolve_imported(module.path, dep, name, modules, visiting) + + defp resolve_target({:binding, name}, module, modules, visiting) do + case resolve_name(module, name, modules, visiting) do + {:ok, target} -> + {:ok, target} + + :none -> + {:error, + {:declarations, module.path, "`#{name}` is exported but not declared or imported"}} + + error -> + error + end + end + + # A name at a module's top level is its own declaration or an import. + defp resolve_name(module, name, modules, visiting \\ MapSet.new()) do + cond do + Enum.any?(module.statements, &(name in &1.names)) -> + {:ok, {module.path, name}} + + match?(%{^name => _}, module.imports) -> + {dep, imported} = module.imports[name] + resolve_imported(module.path, dep, imported, modules, visiting) + + true -> + :none + end + end + + defp resolve_imported(importer, _dep, "*", _modules, _visiting) do + {:error, + {:declarations, importer, + "`import * as` of a project module is not bundled; import its names"}} + end + + defp resolve_imported(importer, dep, name, modules, visiting) do + with {:ok, exports} <- exports_of(dep, modules, visiting) do + case exports do + %{^name => resolved} -> + {:ok, resolved} + + _ -> + {:error, + {:declarations, importer, "#{Path.relative_to_cwd(dep)} does not export `#{name}`"}} + end + end + end + + # `%{{module, name} => [{statement_index, statement}]}`: an interface merged + # over several statements, or an overloaded function, is declared by all of + # them. + defp declared_index(modules) do + for {path, module} <- modules, + {statement, index} <- Enum.with_index(module.statements), + name <- statement.names, + reduce: %{} do + acc -> Map.update(acc, {path, name}, [{index, statement}], &[{index, statement} | &1]) + end + end + + # The statements reachable from `roots` through references, as a set of + # `{module, statement_index}`. + defp reach([], _modules, _declared, reached), do: {:ok, reached} + + defp reach([{path, name} | rest], modules, declared, reached) do + pending = + declared + |> Map.get({path, name}, []) + |> Enum.reject(fn {index, _statement} -> MapSet.member?(reached, {path, index}) end) + + reached = + Enum.reduce(pending, reached, fn {index, _statement}, acc -> + MapSet.put(acc, {path, index}) + end) + + pending + |> Enum.reduce_while({:ok, rest}, fn {_index, statement}, {:ok, rest} -> + case reference_targets(statement, modules) do + {:ok, targets} -> {:cont, {:ok, Enum.reduce(targets, rest, &[&1 | &2])}} + error -> {:halt, error} + end + end) + |> case do + {:ok, rest} -> reach(rest, modules, declared, reached) + error -> error + end + end + + # Where a statement's references lead. Names that are neither declared nor + # imported, such as globals and the lib's types, lead nowhere. + defp reference_targets(statement, modules) do + module = modules[statement.module] + + statement.references + |> Enum.map(& &1.name) + |> Enum.uniq() + |> Enum.reduce_while({:ok, []}, fn name, {:ok, acc} -> + case resolve_name(module, name, modules) do + {:ok, target} -> {:cont, {:ok, [target | acc]}} + :none -> {:cont, {:ok, acc}} + error -> {:halt, error} + end + end) + end + + # Reached statements and ambient ones, dependencies before dependents with + # the entry last, and a module's statements in their own order. + defp emitted_statements(modules, reached, entry) do + entry + |> module_order(modules, {[], MapSet.new()}) + |> elem(0) + |> Enum.reverse() + |> Enum.flat_map(fn path -> + modules[path].statements + |> Enum.with_index() + |> Enum.filter(fn {statement, index} -> + statement.names == [] or MapSet.member?(reached, {path, index}) + end) + |> Enum.map(fn {statement, _index} -> statement end) + end) + end + + defp module_order(path, modules, {order, seen}) do + if MapSet.member?(seen, path) do + {order, seen} + else + {order, seen} = + Enum.reduce( + modules[path].sources, + {order, MapSet.put(seen, path)}, + &module_order(&1, modules, &2) + ) + + {[path | order], seen} + end + end + + # Names declared by more than one module keep their spelling in the module + # nearest the entry, in emission order, and gain a `$1`, `$2`, ... elsewhere. + defp renames(statements, entry) do + statements + |> Enum.sort_by(&(&1.module != entry)) + |> Enum.flat_map(&Enum.map(&1.names, fn name -> {&1.module, name} end)) + |> Enum.uniq() + |> Enum.reduce({%{}, MapSet.new()}, fn {module, name}, {renames, taken} -> + if MapSet.member?(taken, name) do + renamed = + Enum.find( + Stream.map(Stream.iterate(1, &(&1 + 1)), &"#{name}$#{&1}"), + &(not MapSet.member?(taken, &1)) + ) + + {Map.put(renames, {module, name}, renamed), MapSet.put(taken, renamed)} + else + {renames, MapSet.put(taken, name)} + end + end) + |> elem(0) + end + + defp emitted_name({module, name}, renames), do: Map.get(renames, {module, name}, name) + + # A statement's text with every identifier that resolves to a declaration + # spelled as that declaration is emitted, and its own names likewise. + defp rewrite(statement, modules, renames) do + module = modules[statement.module] + + reference_patches = + Enum.flat_map(statement.references, fn reference -> + with {:ok, target} <- resolve_name(module, reference.name, modules), + emitted when emitted != reference.name <- emitted_name(target, renames) do + [Volt.JS.Patch.new(reference.start, reference.end, emitted)] + else + _unresolved_or_unchanged -> [] + end + end) + + own_patches = + Enum.flat_map(statement.names, fn name -> + case Map.fetch(renames, {statement.module, name}) do + {:ok, renamed} -> declaration_name_patches(statement.text, name, renamed) + :error -> [] + end + end) + + OXC.patch_string(statement.text, reference_patches ++ own_patches) + end + + # The binding identifier of a renamed declaration, found by parsing the + # statement again: it was excluded from the references on purpose. + defp declaration_name_patches(text, name, renamed) do + {:ok, ast} = OXC.parse(text, "statement.d.ts") + + for node <- ast.body, + %{start: start, end: finish} <- declaration_ids(node), + binary_part(text, start, finish - start) == name, + do: Volt.JS.Patch.new(start, finish, renamed) + end + + defp declaration_ids(%{type: :variable_declaration, declarations: declarations}), + do: Enum.map(declarations, & &1.id) + + defp declaration_ids(%{id: %{type: :identifier} = id}), do: [id] + defp declaration_ids(_node), do: [] + + # Imports of other packages, as written, in the order the modules are + # emitted, without repeats. + defp external_imports(paths, modules) do + paths + |> Enum.flat_map(&modules[&1].external_imports) + |> Enum.uniq() + |> Enum.join("\n") + end + + defp export_list(entry_exports, renames) do + specifiers = + entry_exports + |> Enum.sort() + |> Enum.map(fn {exported, target} -> + emitted = emitted_name(target, renames) + if emitted == exported, do: emitted, else: "#{emitted} as #{exported}" + end) + + if specifiers == [], do: "", else: "export { #{Enum.join(specifiers, ", ")} };" + end +end diff --git a/lib/volt/builder/resolver.ex b/lib/volt/builder/resolver.ex index dcebca9..dc22658 100644 --- a/lib/volt/builder/resolver.ex +++ b/lib/volt/builder/resolver.ex @@ -225,9 +225,11 @@ defmodule Volt.Builder.Resolver do end end - defp inside_node_modules?(nil), do: false + @doc "Return whether a module path, with or without a query, lies in a `node_modules` directory." + @spec inside_node_modules?(String.t() | nil) :: boolean() + def inside_node_modules?(nil), do: false - defp inside_node_modules?(importer) do + def inside_node_modules?(importer) do {importer_path, _query} = Volt.URL.split_query(importer) "node_modules" in Path.split(Path.expand(importer_path)) end diff --git a/lib/volt/builder/result.ex b/lib/volt/builder/result.ex index 3554b43..129ca63 100644 --- a/lib/volt/builder/result.ex +++ b/lib/volt/builder/result.ex @@ -1,14 +1,15 @@ defmodule Volt.Builder.Result do @moduledoc "Production build result returned from `Volt.Builder`." - defstruct js: [], css: nil, styles: [], manifest: %{}, chunks: [] + defstruct js: [], css: nil, styles: [], manifest: %{}, chunks: [], declarations: [] @type t :: %__MODULE__{ js: Volt.Builder.OutputFile.t() | [Volt.Builder.OutputFile.t()], css: Volt.Builder.OutputFile.t() | nil, styles: [Volt.Builder.OutputFile.t()], manifest: %{String.t() => Volt.Builder.ManifestEntry.t()}, - chunks: [Volt.Builder.OutputFile.t()] + chunks: [Volt.Builder.OutputFile.t()], + declarations: [Volt.Builder.OutputFile.t()] } @behaviour Access diff --git a/lib/volt/config/build.ex b/lib/volt/config/build.ex index 7868d4f..5bb388f 100644 --- a/lib/volt/config/build.ex +++ b/lib/volt/config/build.ex @@ -12,6 +12,7 @@ defmodule Volt.Config.Build do hash: true, code_splitting: true, tree_shaking: true, + declarations: false, format: :iife, mode: :production, env_prefix: "VOLT_", diff --git a/mix.exs b/mix.exs index ce20d08..0a113eb 100644 --- a/mix.exs +++ b/mix.exs @@ -38,10 +38,10 @@ defmodule Volt.MixProject do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1.12"}, - {:oxc, "~> 0.18.1"}, + {:oxc, "~> 0.19.0"}, {:vize, "~> 0.17.1"}, {:oxide_ex, "~> 0.2.2"}, - {:quickbeam, "~> 0.11.2"}, + {:quickbeam, "~> 0.11.3"}, {:dotenvy, "~> 1.1"}, {:floki, "~> 0.38"}, {:plug, "~> 1.16"}, diff --git a/mix.lock b/mix.lock index ccc9263..fb88ccb 100644 --- a/mix.lock +++ b/mix.lock @@ -43,7 +43,7 @@ "npm": {:hex, :npm, "0.7.6", "de8ca3dd26a40fac7ab8a17680088b8d55bab0e0294be9770b029c276361482f", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:npm_semver, "~> 0.1.0", [hex: :npm_semver, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "e0a1ce77e3e49ec108e095aba133bd315e86da298dba26b142a105972141d9ef"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, - "oxc": {:hex, :oxc, "0.18.1", "9fb4cd232ee2143364e86a021992958b1aecf07583432ebb3319386e2f6ce6c0", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "2653908736b6064c8363415280472bc687ad3028ee7f1441cb6ac458fc8e9a62"}, + "oxc": {:hex, :oxc, "0.19.0", "9786fc88ec3da84da1fec5e08bb94b108e3917288cdac07d62842edfcd2500ac", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "ffeec25c488d87144fc12e14d30bca92997576303be2458e80e6e84dd756a296"}, "oxide_ex": {:hex, :oxide_ex, "0.2.2", "063e0d2899e81632b7efc7849eb1dd89a7a623290a075c446b43bef14cf5795e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "577a2fbd97c46904a31143bb700cdb374105085cfd1f7605416bb6c012cb7f60"}, "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, "pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"}, @@ -52,9 +52,9 @@ "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "protoss": {:hex, :protoss, "1.1.0", "853533313989751c7da5b0e1ce71501a23f3dc41f128709478af40daccc6e959", [:mix], [], "hexpm", "c2f874383dd047fcfdf467b814dd33a208a4d24a467aef90d86185b41a0752ad"}, "quackdb": {:hex, :quackdb, "0.5.13", "854294a5a85e0f2972d2229da8dfb25afd2cda500e80e477f6987bff823c2b21", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.7", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:explorer, "~> 0.11", [hex: :explorer, repo: "hexpm", optional: true]}, {:fsst, "~> 0.1.2", [hex: :fsst, repo: "hexpm", optional: true]}, {:geo, "~> 4.1", [hex: :geo, repo: "hexpm", optional: true]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:muontrap, "~> 1.5", [hex: :muontrap, repo: "hexpm", optional: false]}, {:table, "~> 0.1", [hex: :table, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "a5d18e54de682bde9f4ab1866126aa90c26223866bc693bf56cdf9dafa3859d3"}, - "quickbeam": {:hex, :quickbeam, "0.11.2", "ea4e4bfd7a8999381f86b773b13d028c02753137f8b56c1d3f3cce4649dfee71", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: true]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8 or ~> 0.18.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f7652948f43484fcc5c0fe007fdcddbd62db595daa16a6f53fb9b3553f66c608"}, + "quickbeam": {:hex, :quickbeam, "0.11.3", "a670f982f2a7e074bbd06289e2bd3be8291246967d3fa7d8822c72c2510db329", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: true]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8 or ~> 0.18.0 or ~> 0.19.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "ee35486b0987e535d05b5f846786d0d4aadde34bffc46d83ecc69b419adc070f"}, "reach": {:hex, :reach, "2.6.1", "33f749a1e15baa843aedd3e3e9a282322eef1e6c59d9b8a5072c8427359f9658", [:mix], [{:boxart, "~> 0.3.3", [hex: :boxart, repo: "hexpm", optional: true]}, {:ex_ast, "~> 0.12.0", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:ex_dna, "~> 1.5", [hex: :ex_dna, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:libgraph, "~> 0.16.0", [hex: :libgraph, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: true]}, {:makeup_js, "~> 0.1", [hex: :makeup_js, repo: "hexpm", optional: true]}, {:quickbeam, "~> 0.10", [hex: :quickbeam, repo: "hexpm", optional: true]}], "hexpm", "2d7e8a26a999be50cf716cf5786c5d1570d04b397ee047d430cab1d6956714cc"}, - "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, + "req": {:hex, :req, "0.7.5", "0c3293cbf7717aba20b1b1b3ea13fe8d3fdf6f1557490f390c3f1ef6dd9db955", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "576853c4bf403faa3c1fa5d093e0759227083b78f8a6864c67d62c0a9c45514d"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.10.0", "8f07ff0865cb09a6a0f75e11701e9a46e331e05f737da4e5b01374dcd25b68c0", [:mix], [{:rustler, "~> 0.28", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c68c9dc732c12acfdf4a7fa06f4e6f48a641179e6be49a474c8a60809929c430"}, diff --git a/test/volt/builder/declarations_test.exs b/test/volt/builder/declarations_test.exs new file mode 100644 index 0000000..40dfe9b --- /dev/null +++ b/test/volt/builder/declarations_test.exs @@ -0,0 +1,247 @@ +defmodule Volt.Builder.DeclarationsTest do + use Volt.TestSupport.BuilderCase + + alias Volt.Builder.Declarations + + @node_modules Path.join(@fixture_dir, "node_modules") + + setup do + File.mkdir_p!(Path.join(@node_modules, "vue")) + + File.write!( + Path.join(@node_modules, "vue/package.json"), + Jason.encode!(%{"name" => "vue", "main" => "index.js", "types" => "index.d.ts"}) + ) + + File.write!( + Path.join(@node_modules, "vue/index.js"), + "export const ref = (v) => ({ value: v })" + ) + + File.write!( + Path.join(@node_modules, "vue/index.d.ts"), + "export declare const ref: (v: T) => { value: T };" + ) + + :ok + end + + defp write!(path, source) do + path = Path.join(@fixture_dir, path) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, source) + path + end + + defp context(plugins \\ []), + do: %Volt.Builder.Context{node_modules: @node_modules, plugins: plugins} + + describe "bundle/2" do + test "bundles the declarations the entry exports reach into one module" do + write!("src/types.ts", """ + /** Where a recording goes. */ + export interface Target { url: string; retries?: number } + export type Mode = 'live' | 'replay' + export interface Unused { never: true } + """) + + write!("src/recorder.ts", """ + import type { Target, Mode } from './types' + import { ref } from 'vue' + export class Recorder { + mode: Mode = 'live' + start(target: Target): void { console.log(target, ref(1)) } + } + export default class Session { id: string = '' } + export const hidden: number = 1 + """) + + write!("src/extra.ts", """ + export const EXTRA: string = 'extra' + export const ALSO: number = 2 + """) + + write!("src/index.ts", """ + import { ref } from 'vue' + import Session from './recorder' + import type { Target as RecordTarget } from './types' + export { Recorder as Rec } from './recorder' + export * from './extra' + export type { Mode } from './types' + export const current: RecordTarget | null = null + export const session: typeof Session = Session + export const counter: ReturnType> = ref(0) + declare module 'vue' { interface Augmented { fromIndex: true } } + """) + + assert {:ok, dts} = Declarations.bundle(Path.join(@fixture_dir, "src/index.ts"), context()) + + # Imports of packages are hoisted once; project imports are gone. + assert String.starts_with?(dts, ~s(import { ref } from "vue";\n)) + assert length(Regex.scan(~r/from "vue"/, dts)) == 1 + refute dts =~ "./types" + refute dts =~ "./recorder" + + # Declarations keep their docs and come before what refers to them. + assert dts =~ "/** Where a recording goes. */\ninterface Target {" + assert :binary.match(dts, "interface Target") < :binary.match(dts, "declare class Recorder") + assert dts =~ "type Mode = \"live\" | \"replay\";" + assert dts =~ "declare class Session {" + assert dts =~ "declare const EXTRA: string;" + assert dts =~ "declare const current: Target | null;" + assert dts =~ "declare module \"vue\" {" + + # Only what the entry exports, under the entry's names. + assert dts =~ "export { ALSO, EXTRA, Mode, Recorder as Rec, counter, current, session };\n" + refute dts =~ "Unused" + refute dts =~ "hidden" + refute dts =~ "export declare" + end + + test "tells apart the same name declared in two modules" do + write!("src/a.ts", """ + /** A's options. */ + export interface Options { a: true } + export const configureA: (o: Options) => void = () => {} + """) + + write!("src/b.ts", """ + export interface Options { b: true } + export const configureB: (o: Options) => Options = (o) => o + """) + + write!("src/index.ts", """ + export { configureA } from './a' + export { configureB } from './b' + export interface Options { index: true } + export const configure: (o: Options) => void = () => {} + """) + + assert {:ok, dts} = Declarations.bundle(Path.join(@fixture_dir, "src/index.ts"), context()) + + assert dts =~ "interface Options {\n\tindex: true;\n}" + assert dts =~ "/** A's options. */\ninterface Options$1 {\n\ta: true;\n}" + assert dts =~ "interface Options$2 {\n\tb: true;\n}" + assert dts =~ "declare const configureA: (o: Options$1) => void;" + assert dts =~ "declare const configureB: (o: Options$2) => Options$2;" + assert dts =~ "declare const configure: (o: Options) => void;" + assert dts =~ "export { Options, configure, configureA, configureB };" + end + + test "reports the module whose declarations cannot be emitted" do + write!("src/inferred.ts", "export function size() { return compute() }\n") + write!("src/index.ts", "export { size } from './inferred'\n") + + assert {:error, {:declarations, path, [%{message: "TS9007" <> _, position: {1, 17}}]}} = + Declarations.bundle(Path.join(@fixture_dir, "src/index.ts"), context()) + + assert path == Path.join(@fixture_dir, "src/inferred.ts") + end + + test "names the constructs it does not bundle" do + write!("src/ns.ts", "export const n: number = 1\n") + write!("src/index.ts", "import * as ns from './ns'\nexport const all: typeof ns = ns\n") + + assert {:error, {:declarations, _path, "`import * as`" <> _}} = + Declarations.bundle(Path.join(@fixture_dir, "src/index.ts"), context()) + + write!("src/index.ts", "export * as ns from './ns'\n") + + assert {:error, {:declarations, _path, "`export * as ns`" <> _}} = + Declarations.bundle(Path.join(@fixture_dir, "src/index.ts"), context()) + end + + test "declares a component through its plugin" do + write!("src/Button.vue", """ + + + """) + + write!("src/index.ts", """ + import Button from './Button.vue' + export const button: typeof Button = Button + """) + + assert {:ok, dts} = + Declarations.bundle( + Path.join(@fixture_dir, "src/index.ts"), + context([Volt.Plugin.Vue]) + ) + + assert dts =~ "type Props = { variant?: 'primary' | 'ghost' };" + assert dts =~ "declare const __vize_component__:" + assert dts =~ "declare const button: typeof __vize_component__;" + assert dts =~ "export { button };" + end + end + + describe "build/1 with declarations" do + test "writes one .d.ts per entry beside its bundle, without a hash" do + write!("src/lib.ts", """ + import { ref } from 'vue' + export const count: ReturnType> = ref(0) + export function reset(): void { count.value = 0 } + """) + + assert {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/lib.ts"), + outdir: @outdir, + node_modules: @node_modules, + format: :esm, + name: "lib", + declarations: true, + minify: false, + sourcemap: false + ) + + assert [%Volt.Builder.OutputFile{path: path}] = result.declarations + assert path == Path.join(@outdir, "lib.d.ts") + assert File.read!(path) =~ "export { count, reset };" + assert Path.basename(result.js.path) =~ ~r/^lib-[a-f0-9]+\.js$/ + end + + test "writes declarations for each of several ESM entries" do + write!("src/one.ts", "export const one: number = 1\n") + + write!( + "src/two.ts", + "import { one } from './one'\nexport const two: typeof one = one + 1\n" + ) + + assert {:ok, result} = + Volt.Builder.build( + entry: [ + Path.join(@fixture_dir, "src/one.ts"), + Path.join(@fixture_dir, "src/two.ts") + ], + outdir: @outdir, + node_modules: @node_modules, + format: :esm, + declarations: true, + minify: false, + sourcemap: false + ) + + assert Enum.map(result.declarations, &Path.basename(&1.path)) == ["one.d.ts", "two.d.ts"] + assert File.read!(Path.join(@outdir, "two.d.ts")) =~ "declare const one: number;" + end + + test "fails the build with the module that has no explicit types" do + write!("src/lib.ts", "export const value = compute()\n") + + assert {:error, {:declarations, path, [%{message: "TS9010" <> _}]}} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/lib.ts"), + outdir: @outdir, + node_modules: @node_modules, + format: :esm, + declarations: true + ) + + assert path == Path.join(@fixture_dir, "src/lib.ts") + end + end +end