├── .yarnrc.yml ├── lib ├── js-routes.rb ├── templates │ ├── routes.js.erb │ ├── erb.js │ └── initializer.rb ├── js_routes │ ├── version.rb │ ├── utils.rb │ ├── generators │ │ ├── base.rb │ │ ├── webpacker.rb │ │ └── middleware.rb │ ├── middleware.rb │ ├── types.rb │ ├── engine.rb │ ├── configuration.rb │ ├── instance.rb │ └── route.rb ├── tasks │ └── js_routes.rake ├── js_routes.rb ├── routes.d.ts ├── routes.js └── routes.ts ├── spec ├── dummy │ ├── app │ │ └── assets │ │ │ ├── javascripts │ │ │ └── .gitkeep │ │ │ └── config │ │ │ └── manifest.js │ └── config │ │ └── routes.rb ├── tsconfig.json ├── js_routes │ ├── generators │ │ └── middleware_spec.rb │ ├── module_types │ │ ├── cjs_spec.rb │ │ ├── amd_spec.rb │ │ ├── dts │ │ │ ├── test.spec.ts │ │ │ └── routes.spec.d.ts │ │ ├── esm_spec.rb │ │ ├── nil_spec.rb │ │ ├── umd_spec.rb │ │ └── dts_spec.rb │ ├── default_serializer_spec.rb │ ├── route_specification_spec.rb │ ├── zzz_after_initialization_spec.rb │ ├── options_spec.rb │ └── rails_routes_compatibility_spec.rb ├── support │ └── routes.rb └── spec_helper.rb ├── sorbet ├── config └── tapioca │ ├── require.rb │ └── config.yml ├── logo.webp ├── app └── assets │ └── javascripts │ └── js-routes.js.erb ├── gemfiles ├── rails50_sprockets_3.gemfile ├── rails51_sprockets_3.gemfile ├── rails52_sprockets_3.gemfile ├── rails80_sprockets_4.gemfile └── rails70_sprockets_4.gemfile ├── .github └── workflows │ ├── scripts │ ├── tag.sh │ ├── version.sh │ └── changelog.sh │ ├── ci.yml │ └── release.yml ├── Gemfile ├── .eslintrc.js ├── Appraisals ├── tsconfig.json ├── Rakefile ├── LICENSE.txt ├── package.json ├── .gitignore ├── js-routes.gemspec ├── VERSION_2_UPGRADE.md ├── CHANGELOG.md └── Readme.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /lib/js-routes.rb: -------------------------------------------------------------------------------- 1 | require 'js_routes' 2 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/templates/routes.js.erb: -------------------------------------------------------------------------------- 1 | <%= JsRoutes.generate %> 2 | -------------------------------------------------------------------------------- /sorbet/config: -------------------------------------------------------------------------------- 1 | --dir 2 | . 3 | --ignore=tmp/ 4 | --ignore=vendor/ 5 | -------------------------------------------------------------------------------- /logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsware/js-routes/HEAD/logo.webp -------------------------------------------------------------------------------- /app/assets/javascripts/js-routes.js.erb: -------------------------------------------------------------------------------- 1 | <%# encoding: UTF-8 %> 2 | <%= JsRoutes.generate %> 3 | -------------------------------------------------------------------------------- /lib/js_routes/version.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | module JsRoutes 3 | VERSION = "2.3.6" 4 | end 5 | -------------------------------------------------------------------------------- /spec/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./**/*.spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | App.routes.draw do 2 | resources :users 3 | resources :posts 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /sorbet/tapioca/require.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | # Add your extra requires here (`bin/tapioca require` can be used to bootstrap this list) 5 | -------------------------------------------------------------------------------- /gemfiles/rails50_sprockets_3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "railties", "~> 5.0.5" 6 | gem "sprockets", "~> 3.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails51_sprockets_3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "railties", "~> 5.1.3" 6 | gem "sprockets", "~> 3.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails52_sprockets_3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "railties", "~> 5.2.3" 6 | gem "sprockets", "~> 3.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails80_sprockets_4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "railties", "~> 8.0" 6 | gem "sprockets", "~> 4.2" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails70_sprockets_4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "railties", "~> 7.0.3.1" 6 | gem "sprockets", "~> 4.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /lib/templates/erb.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | module: { 3 | rules: [ 4 | { 5 | test: /\.erb$/, 6 | enforce: 'pre', 7 | loader: 'rails-erb-loader' 8 | }, 9 | ] 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /.github/workflows/scripts/tag.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | SCRIPT_DIR=$(dirname "$(realpath "$0")") 5 | VERSION=$($SCRIPT_DIR/version.sh) 6 | 7 | git tag "v$VERSION" || echo "Tag already exists." 8 | git push origin --force --tags 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in js-routes.gemspec 4 | gemspec 5 | 6 | group :development do 7 | gem 'sorbet', '>= 0.5.11518' 8 | gem 'tapioca' 9 | gem 'debug' 10 | gem 'bump' 11 | end 12 | -------------------------------------------------------------------------------- /sorbet/tapioca/config.yml: -------------------------------------------------------------------------------- 1 | gem: 2 | # Add your `gem` command parameters here: 3 | # 4 | # exclude: 5 | # - gem_name 6 | # doc: true 7 | # workers: 5 8 | dsl: 9 | # Add your `dsl` command parameters here: 10 | # 11 | # exclude: 12 | # - SomeGeneratorName 13 | # workers: 5 14 | -------------------------------------------------------------------------------- /spec/js_routes/generators/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe JsRoutes::Generators::Middleware do 4 | 5 | it "has correct source_root" do 6 | expect(JsRoutes::Generators::Middleware.source_root).to eq(gem_root.join('lib/templates').to_s) 7 | end 8 | 9 | end 10 | -------------------------------------------------------------------------------- /.github/workflows/scripts/version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | VERSION_FILE="./lib/js_routes/version.rb" 6 | if [ ! -f "$VERSION_FILE" ]; then 7 | echo "Version file not found!" 8 | exit 1 9 | fi 10 | 11 | VERSION=$(ruby -r $VERSION_FILE -e "puts JsRoutes::VERSION") 12 | 13 | if [ -z "$VERSION" ]; then 14 | echo "Could not extract version from $VERSION_FILE" 15 | exit 1 16 | fi 17 | 18 | echo "$VERSION" 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/eslint-recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'prettier', 8 | ], 9 | parser: '@typescript-eslint/parser', 10 | plugins: ['@typescript-eslint'], 11 | rules: { 12 | '@typescript-eslint/ban-types': 'off', 13 | '@typescript-eslint/no-explicit-any': 'off' 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /spec/js_routes/module_types/cjs_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe JsRoutes, "compatibility with CJS" do 4 | before(:each) do 5 | evaljs("module = { exports: null }") 6 | evaljs(JsRoutes.generate( 7 | module_type: 'CJS', 8 | include: /^inboxes/ 9 | )) 10 | end 11 | 12 | it "should define module exports" do 13 | expectjs("module.exports.inboxes_path()").to eq(test_routes.inboxes_path()) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | def define_appraisal(rails, version, sprockets) 2 | sprockets.each do |sprocket| 3 | appraise "#{rails}-sprockets-#{sprocket}" do 4 | gem "railties", "~> #{version}" 5 | gem "sprockets", "~> #{sprocket}.0" 6 | end 7 | end 8 | end 9 | 10 | [ 11 | [:rails50, '5.0.5', [3]], 12 | [:rails51, '5.1.3', [3]], 13 | [:rails52, '5.2.3', [3]], 14 | [:rails70, '7.0.3.1', [4]] 15 | ].each do |name, version, sprockets| 16 | define_appraisal(name, version, sprockets) 17 | end 18 | -------------------------------------------------------------------------------- /lib/tasks/js_routes.rake: -------------------------------------------------------------------------------- 1 | namespace :js do 2 | desc "Make a js file with all rails route URL helpers" 3 | task routes: :environment do 4 | require "js-routes" 5 | JsRoutes.generate!(typed: true) 6 | end 7 | 8 | namespace :routes do 9 | desc "Make a js file with all rails route URL helpers and typescript definitions for them" 10 | task typescript: "js:routes" do 11 | JsRoutes::Utils.deprecator.warn( 12 | "`js:routes:typescript` task is deprecated. Please use `js:routes` instead." 13 | ) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/js_routes/utils.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | module JsRoutes 4 | module Utils 5 | extend T::Sig 6 | sig {returns(T.untyped)} 7 | def self.shakapacker 8 | if defined?(::Shakapacker) 9 | ::Shakapacker 10 | elsif defined?(::Webpacker) 11 | ::Webpacker 12 | else 13 | nil 14 | end 15 | end 16 | 17 | sig { returns(T.untyped) } 18 | def self.deprecator 19 | if defined?(Rails) && Rails.version >= "7.1.0" 20 | Rails.deprecator 21 | else 22 | ActiveSupport::Deprecation 23 | end 24 | end 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "lib": ["es2020", "dom"], 5 | "module": "commonjs", 6 | "sourceMap": false, 7 | "declaration": true, 8 | "allowSyntheticDefaultImports": true, 9 | "resolveJsonModule": false, 10 | "strictNullChecks": true, 11 | "noImplicitAny": true, 12 | "noImplicitThis": true, 13 | "strictBindCallApply": true, 14 | "strictFunctionTypes": true, 15 | "alwaysStrict": false, 16 | 17 | "esModuleInterop": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "emitDecoratorMetadata": true, 20 | "experimentalDecorators": true 21 | 22 | }, 23 | "ts-node": { 24 | "files": true, 25 | "transpileOnly": false 26 | }, 27 | "include": ["lib/*.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /lib/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | JsRoutes.setup do |c| 2 | # Setup your JS module system: 3 | # ESM, CJS, AMD, UMD or nil. 4 | # c.module_type = "ESM" 5 | 6 | # Legacy setup for no modules system. 7 | # Sets up a global variable `Routes` 8 | # that holds route helpers. 9 | # c.module_type = nil 10 | # c.namespace = "Routes" 11 | 12 | # Follow javascript naming convention 13 | # but lose the ability to match helper name 14 | # on backend and frontend consistently. 15 | # c.camel_case = true 16 | 17 | # Generate only helpers that match specific pattern. 18 | # c.exclude = /^api_/ 19 | # c.include = /^admin_/ 20 | 21 | # Generate `*_url` helpers besides `*_path` 22 | # for apps that work on multiple domains. 23 | # c.url_links = true 24 | 25 | # More options: 26 | # @see https://github.com/railsware/js-routes#available-options 27 | end 28 | -------------------------------------------------------------------------------- /.github/workflows/scripts/changelog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | CHANGELOG="CHANGELOG.md" 6 | OUTPUT="./release_changelog.md" 7 | SCRIPT_DIR=$(dirname "$(realpath "$0")") 8 | VERSION=$($SCRIPT_DIR/version.sh) 9 | 10 | if [ -z "$VERSION" ]; then 11 | echo "VERSION is not specified" 12 | exit 1 13 | fi 14 | 15 | if [ ! -f "$CHANGELOG" ]; then 16 | echo "CHANGELOG.md file not found!" 17 | exit 1 18 | fi 19 | 20 | if ! grep -q "^## \[$VERSION\]" $CHANGELOG; then 21 | echo "No changelog found for version $VERSION in $CHANGELOG" 22 | exit 1 23 | fi 24 | 25 | echo "## Changes" > $OUTPUT 26 | echo "" >> $OUTPUT 27 | ruby -e "puts File.read('$CHANGELOG').split('## [$VERSION]')[1]&.split('## ')&.first&.strip" >> $OUTPUT 28 | echo "Release notes:" 29 | echo ---------------------------------------- 30 | cat $OUTPUT 31 | echo ---------------------------------------- 32 | 33 | -------------------------------------------------------------------------------- /spec/js_routes/default_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe JsRoutes, "#serialize" do 4 | 5 | before(:each) do 6 | evallib(module_type: nil, namespace: 'Routes') 7 | end 8 | 9 | it "should provide this method" do 10 | expectjs("Routes.serialize({a: 1, b: [2,3], c: {d: 4, e: 5}, f: ''})").to eq( 11 | "a=1&b%5B%5D=2&b%5B%5D=3&c%5Bd%5D=4&c%5Be%5D=5&f=" 12 | ) 13 | end 14 | 15 | it "should provide this method" do 16 | expectjs("Routes.serialize({a: 1, b: [2,3], c: {d: 4, e: 5}, f: ''})").to eq( 17 | "a=1&b%5B%5D=2&b%5B%5D=3&c%5Bd%5D=4&c%5Be%5D=5&f=" 18 | ) 19 | end 20 | 21 | it "works with JS suckiness" do 22 | expectjs( 23 | [ 24 | "const query = Object.create(null);", 25 | "query.a = 1;", 26 | "query.b = 2;", 27 | "Routes.serialize(query);", 28 | ].join("\n") 29 | ).to eq("a=1&b=2") 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/js_routes/generators/base.rb: -------------------------------------------------------------------------------- 1 | 2 | require "rails/generators" 3 | 4 | class JsRoutes::Generators::Base < Rails::Generators::Base 5 | 6 | def self.inherited(subclass) 7 | super 8 | subclass.source_root(File.expand_path(__FILE__ + "/../../../templates")) 9 | end 10 | 11 | protected 12 | 13 | def application_js_path 14 | [ 15 | "app/javascript/packs/application.ts", 16 | "app/javascript/packs/application.js", 17 | "app/javascript/controllers/application.ts", 18 | "app/javascript/controllers/application.js", 19 | ].find do |path| 20 | File.exist?(Rails.root.join(path)) 21 | end 22 | end 23 | 24 | def depends_on?(gem_name) 25 | !!Bundler.load.gems.find {|g| g.name == gem_name} 26 | end 27 | 28 | def depends_on_js_bundling? 29 | depends_on?('jsbundling-rails') 30 | end 31 | 32 | def depends_on_webpacker? 33 | depends_on?('webpacker') 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/js_routes/generators/webpacker.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators" 2 | require 'js_routes/utils' 3 | 4 | class JsRoutes::Generators::Webpacker < Rails::Generators::Base 5 | 6 | def create_webpack 7 | copy_file "initializer.rb", "config/initializers/js_routes.rb" 8 | copy_file "erb.js", "config/webpack/loaders/erb.js" 9 | copy_file "routes.js.erb", "#{JsRoutes::Utils.shakapacker.config.source_path}/routes.js.erb" 10 | inject_into_file "config/webpack/environment.js", loader_content 11 | if path = application_js_path 12 | inject_into_file path, pack_content 13 | end 14 | command = Rails.root.join("./bin/yarn add rails-erb-loader") 15 | run command 16 | end 17 | 18 | protected 19 | 20 | def pack_content 21 | <<-JS 22 | import * as Routes from 'routes.js.erb'; 23 | alert(Routes.root_path()) 24 | JS 25 | end 26 | 27 | def loader_content 28 | <<-JS 29 | const erb = require('./loaders/erb') 30 | environment.loaders.append('erb', erb) 31 | JS 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'rubygems' 3 | require 'bundler' 4 | begin 5 | Bundler.setup(:default, :development) 6 | rescue Bundler::BundlerError => e 7 | $stderr.puts e.message 8 | $stderr.puts "Run `bundle install` to install missing gems" 9 | exit e.status_code 10 | end 11 | require 'bundler/gem_tasks' 12 | require 'rspec/core' 13 | require 'rspec/core/rake_task' 14 | require 'appraisal' 15 | require 'rails/version' 16 | if Rails.version < "6.1" 17 | load "rails/tasks/routes.rake" 18 | end 19 | 20 | RSpec::Core::RakeTask.new(:spec) 21 | 22 | task :test_all => :appraisal # test all rails 23 | 24 | task :default => :spec 25 | 26 | 27 | namespace :spec do 28 | desc "Print all routes defined in test env" 29 | task :routes do 30 | require './spec/spec_helper' 31 | require 'action_dispatch/routing/inspector' 32 | draw_routes 33 | all_routes = Rails.application.routes.routes 34 | inspector = ActionDispatch::Routing::RoutesInspector.new(all_routes) 35 | puts inspector.format(ActionDispatch::Routing::ConsoleFormatter::Sheet.new) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Bogdan Gusiev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-routes", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "git@github.com:railsware/js-routes.git", 6 | "author": "Bogdan Gusiev , Alexey Vasiliev ", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "typescript": "^4.1.2" 10 | }, 11 | "dependencies": { 12 | "@typescript-eslint/eslint-plugin": "^4.9.0", 13 | "@typescript-eslint/parser": "^4.9.0", 14 | "eslint": "^8.35.0", 15 | "eslint-config-prettier": "^8.7.0", 16 | "eslint-plugin-import": "^2.27.5", 17 | "husky": "^4.3.0", 18 | "lint-staged": "^10.5.2", 19 | "pinst": "^2.1.1", 20 | "prettier": "^2.8.4" 21 | }, 22 | "scripts": { 23 | "build": "tsc && yarn lint:fix", 24 | "lint:fix": "yarn eslint --fix && yarn prettier --write lib/routes.ts", 25 | "postinstall": "yarn husky-upgrade" 26 | }, 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "yarn lint:fix" 30 | } 31 | }, 32 | "lint-staged": { 33 | "./lib/routes.ts": [ 34 | "yarn lint:fix" 35 | ] 36 | }, 37 | "packageManager": "yarn@4.5.3" 38 | } 39 | -------------------------------------------------------------------------------- /spec/js_routes/module_types/amd_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe JsRoutes, "compatibility with AMD/require.js" do 4 | 5 | before(:each) do 6 | evaljs("var global = this;", force: true) 7 | evaljs("global.GlobalCheck = {};") 8 | evaljs("global.define = function (requirs, callback) { global.GlobalCheck['js-routes'] = callback.call(this); return global.GlobalCheck['js-routes']; };") 9 | evaljs("global.define.amd = { jQuery: true };") 10 | strRequire =< "2" }, 24 | { toParam: () => true } 25 | ); 26 | inbox_message_attachment_path(1, "2", true, { format: "json" }); 27 | inboxes_path.toString(); 28 | inboxes_path.requiredParams(); 29 | 30 | // serialize test 31 | const SerializerArgument = { 32 | locale: "en", 33 | search: { 34 | q: "ukraine", 35 | page: 3, 36 | keywords: ["large", "small", { advanced: true }], 37 | }, 38 | }; 39 | serialize(SerializerArgument); 40 | config().serializer(SerializerArgument); 41 | 42 | // configure test 43 | configure({ 44 | default_url_options: { port: 1, host: null }, 45 | prefix: "", 46 | special_options_key: "_options", 47 | serializer: (value) => JSON.stringify(value), 48 | }); 49 | 50 | // config tests 51 | const Config = config(); 52 | console.log( 53 | Config.prefix, 54 | Config.default_url_options, 55 | Config.special_options_key 56 | ); 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | 4 | # rdoc generated 5 | rdoc 6 | 7 | # yard generated 8 | doc 9 | .yardoc 10 | log 11 | 12 | # bundler 13 | .bundle 14 | 15 | # jeweler generated 16 | pkg 17 | tmp 18 | 19 | node_modules 20 | 21 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 22 | # 23 | # * Create a file at ~/.gitignore 24 | # * Include files you want ignored 25 | # * Run: git config --global core.excludesfile ~/.gitignore 26 | # 27 | # After doing this, these files will be ignored in all your git projects, 28 | # saving you from having to 'pollute' every project you touch with them 29 | # 30 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 31 | # 32 | # For MacOS: 33 | # 34 | #.DS_Store 35 | 36 | # For TextMate 37 | #*.tmproj 38 | #tmtags 39 | 40 | # For emacs: 41 | #*~ 42 | #\#* 43 | #.\#* 44 | 45 | # For vim: 46 | #*.swp 47 | 48 | # For redcar: 49 | #.redcar 50 | 51 | # For rubinius: 52 | #*.rbc 53 | .rvmrc 54 | .ruby-version 55 | .pnp.* 56 | .yarn/* 57 | !.yarn/patches 58 | !.yarn/plugins 59 | !.yarn/releases 60 | !.yarn/sdks 61 | !.yarn/versions 62 | 63 | Gemfile.lock 64 | gemfiles/*.lock 65 | 66 | .DS_Store 67 | 68 | /spec/dummy/app/assets/javascripts/routes.js 69 | /spec/dummy/logs 70 | /spec/dummy/tmp 71 | node_modules 72 | sorbet/rbi 73 | 74 | /release_changelog.md 75 | -------------------------------------------------------------------------------- /spec/js_routes/module_types/esm_spec.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/string/strip" 2 | require "spec_helper" 3 | 4 | describe JsRoutes, "compatibility with ESM" do 5 | 6 | let(:generated_js) { 7 | JsRoutes.generate(module_type: 'ESM', include: /\Ainbox/) 8 | } 9 | 10 | before(:each) do 11 | # export keyword is not supported by a simulated js environment 12 | evaljs(generated_js.gsub("export const ", "const ")) 13 | end 14 | 15 | it "defines route helpers" do 16 | expectjs("inboxes_path()").to eq(test_routes.inboxes_path()) 17 | end 18 | 19 | it "exports route helpers" do 20 | expect(generated_js).to include(<<-DOC.rstrip) 21 | /** 22 | * Generates rails route to 23 | * /inboxes(.:format) 24 | * @param {object | undefined} options 25 | * @returns {string} route path 26 | */ 27 | export const inboxes_path = /*#__PURE__*/ __jsr.r( 28 | DOC 29 | end 30 | 31 | it "exports utility methods" do 32 | expect(generated_js).to include("export const serialize = ") 33 | end 34 | 35 | it "defines utility methods" do 36 | expectjs("serialize({a: 1, b: 2})").to eq({a: 1, b: 2}.to_param) 37 | end 38 | 39 | describe "compiled javascript asset" do 40 | subject { ERB.new(File.read("app/assets/javascripts/js-routes.js.erb")).result(binding) } 41 | it "should have js routes code" do 42 | is_expected.to include("export const inbox_message_path = /*#__PURE__*/ __jsr.r(") 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/js_routes/generators/middleware.rb: -------------------------------------------------------------------------------- 1 | require "js_routes/generators/base" 2 | 3 | class JsRoutes::Generators::Middleware < JsRoutes::Generators::Base 4 | 5 | def create_middleware 6 | copy_file "initializer.rb", "config/initializers/js_routes.rb" 7 | inject_into_file "config/environments/development.rb", middleware_content, before: /^end\n\z/ 8 | inject_into_file "Rakefile", rakefile_content 9 | inject_into_file ".gitignore", gitignore_content 10 | if path = application_js_path 11 | inject_into_file path, pack_content 12 | end 13 | JsRoutes.generate!(typed: true) 14 | end 15 | 16 | protected 17 | 18 | def pack_content 19 | <<-JS 20 | import {root_path} from '../routes'; 21 | alert(`JsRoutes installed.\\nYour root path is ${root_path()}`) 22 | JS 23 | end 24 | 25 | def middleware_content 26 | <<-RB 27 | 28 | # Automatically update js-routes file 29 | # when routes.rb is changed 30 | config.middleware.use(JsRoutes::Middleware) 31 | RB 32 | end 33 | 34 | def rakefile_content 35 | enhanced_task = depends_on_js_bundling? ? "javascript:build" : "assets:precompile" 36 | <<-RB 37 | # Update js-routes file before javascript build 38 | task "#{enhanced_task}" => "js:routes" 39 | RB 40 | end 41 | 42 | def gitignore_content 43 | banner = <<-TXT 44 | 45 | # Ignore automatically generated js-routes files. 46 | TXT 47 | 48 | banner + [ 49 | {}, 50 | {module_type: 'DTS'} 51 | ].map do |config| 52 | File.join('/', JsRoutes::Configuration.new(config).output_file) + "\n" 53 | end.join 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/js_routes/types.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | require "action_dispatch/journey/route" 3 | require "pathname" 4 | require "sorbet-runtime" 5 | 6 | module JsRoutes 7 | module Types 8 | extend T::Sig 9 | 10 | UntypedArray = T.type_alias {T::Array[T.untyped]} 11 | StringArray = T.type_alias {T::Array[String]} 12 | SymbolArray = T.type_alias {T::Array[Symbol]} 13 | StringHash = T.type_alias { T::Hash[String, T.untyped] } 14 | Options = T.type_alias { T::Hash[Symbol, T.untyped] } 15 | SpecNode = T.type_alias { T.any(String, RouteSpec, NilClass) } 16 | Literal = T.type_alias { T.any(String, Symbol) } 17 | JourneyRoute = T.type_alias{ActionDispatch::Journey::Route} 18 | RouteSpec = T.type_alias {T.untyped} 19 | Application = T.type_alias do 20 | T.any(T::Class[Rails::Engine], Rails::Application) 21 | end 22 | ApplicationCaller = T.type_alias do 23 | T.any(Application, T.proc.returns(Application)) 24 | end 25 | BannerCaller = T.type_alias do 26 | T.any(String, NilClass, T.proc.returns(T.any(String, NilClass))) 27 | end 28 | Clusivity = T.type_alias { T.any(Regexp, T::Array[Regexp]) } 29 | FileName = T.type_alias { T.any(String, Pathname, NilClass) } 30 | ConfigurationBlock = T.type_alias do 31 | T.proc.params(arg0: JsRoutes::Configuration).void 32 | end 33 | Prefix = T.type_alias do 34 | T.any(T.proc.returns(String), String, NilClass) 35 | end 36 | 37 | module RackApp 38 | extend T::Sig 39 | extend T::Helpers 40 | 41 | interface! 42 | 43 | sig { abstract.params(input: StringHash).returns(UntypedArray) } 44 | def call(input); end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /js-routes.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'js_routes/version' 5 | 6 | Gem::Specification.new do |s| 7 | version = JsRoutes::VERSION 8 | s.name = %q{js-routes} 9 | s.version = version 10 | 11 | s.authors = ["Bogdan Gusiev"] 12 | s.description = %q{Exposes all Rails Routes URL helpers as javascript module} 13 | s.email = %q{agresso@gmail.com} 14 | s.extra_rdoc_files = [ 15 | "LICENSE.txt" 16 | ] 17 | s.required_ruby_version = '>= 2.4.0' 18 | s.files = Dir[ 19 | 'app/**/*', 20 | 'lib/**/*', 21 | 'CHANGELOG.md', 22 | 'LICENSE.txt', 23 | 'Readme.md', 24 | ] 25 | s.homepage = %q{http://github.com/railsware/js-routes} 26 | s.licenses = ["MIT"] 27 | s.require_paths = ["lib"] 28 | s.summary = %q{Brings Rails named routes to javascript} 29 | s.metadata = { 30 | "bug_tracker_uri" => "https://github.com/railsware/js-routes/issues", 31 | "changelog_uri" => "https://github.com/railsware/js-routes/blob/v#{version}/CHANGELOG.md", 32 | "documentation_uri" => "https://github.com/railsware/js-routes", 33 | "source_code_uri" => "https://github.com/railsware/js-routes/tree/v#{version}", 34 | "rubygems_mfa_required" => "true", 35 | "github_repo" => "ssh://github.com/railsware/js-routes", 36 | } 37 | 38 | s.add_runtime_dependency(%q, [">= 5"]) 39 | s.add_runtime_dependency(%q) 40 | 41 | s.add_development_dependency(%q) 42 | s.add_development_dependency(%q, [">= 3.10.0"]) 43 | s.add_development_dependency(%q, [">= 2.2.25"]) 44 | s.add_development_dependency(%q, [">= 0.5.2"]) 45 | if defined?(JRUBY_VERSION) 46 | s.add_development_dependency(%q, [">= 2.0.4"]) 47 | else 48 | s.add_development_dependency(%q, [">= 0.4.0"]) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish RubyGem 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | JS_ROUTES_RUBYGEMS_KEY: 7 | description: "RubyGems account API key" 8 | required: true 9 | 10 | 11 | workflow_dispatch: # Trigger the workflow manually 12 | inputs: 13 | otp_code: 14 | description: 'Enter the RubyGems OTP code' 15 | required: true 16 | type: string 17 | 18 | permissions: 19 | contents: write 20 | 21 | jobs: 22 | release: 23 | if: github.actor == 'bogdan' 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | 28 | - name: Checkout code 29 | uses: actions/checkout@v3 30 | 31 | - name: Set up Ruby 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: '3.3.4' 35 | 36 | - name: Extract Gem Version 37 | id: extract_version 38 | run: | 39 | VERSION=$(./.github/workflows/scripts/version.sh) 40 | echo "Version: $VERSION" 41 | echo "gem_version=$VERSION" >> $GITHUB_ENV 42 | 43 | - name: Install dependencies 44 | run: bundle install 45 | 46 | - name: Extract Changelog 47 | run: ./.github/workflows/scripts/changelog.sh 48 | 49 | - name: Build the gem 50 | run: gem build *.gemspec 51 | 52 | - name: Tag the release version 53 | run: ./.github/workflows/scripts/tag.sh 54 | 55 | - name: Publish to RubyGems 56 | env: 57 | GEM_HOST_API_KEY: ${{ secrets.JS_ROUTES_RUBYGEMS_KEY }} 58 | run: | 59 | echo "API Key: ${GEM_HOST_API_KEY:0:16}..." 60 | gem push *.gem --otp ${{ github.event.inputs.otp_code }} 61 | 62 | - name: Create GitHub Release 63 | uses: softprops/action-gh-release@v2 64 | with: 65 | name: "v${{ env.gem_version }}" 66 | tag_name: "v${{ env.gem_version }}" 67 | body_path: ./release_changelog.md 68 | draft: false 69 | prerelease: false 70 | 71 | -------------------------------------------------------------------------------- /VERSION_2_UPGRADE.md: -------------------------------------------------------------------------------- 1 | ## Version 2.0 upgrade notes 2 | 3 | ### Using ESM module by default 4 | 5 | New version of JsRoutes doesn't try to guess your javascript environment module system because JS has generated a ton of legacy module systems in the past. 6 | [ESM](/Readme.md#webpacker) upgrade is recommended. 7 | 8 | However, if you don't want to follow that pass, specify `module_type` configuration option instead based on module system available in your JS environment. 9 | Here are supported values: 10 | 11 | * CJS 12 | * UMD 13 | * AMD 14 | * ESM 15 | * nil 16 | 17 | [Explaination Article](https://dev.to/iggredible/what-the-heck-are-cjs-amd-umd-and-esm-ikm) 18 | 19 | If you don't want to use any JS module system and make routes available via a **global variable**, specify `nil` as a `module_type` and use `namespace` option: 20 | 21 | ``` ruby 22 | JsRoutes.setup do |config| 23 | config.module_type = nil 24 | config.namespace = "Routes" 25 | end 26 | ``` 27 | 28 | ### JSDoc comment 29 | 30 | New version of js-routes generates function comment in the [JSDoc](https://jsdoc.app) format. 31 | If you have any problems with that, you can disable it like this: 32 | 33 | 34 | ``` ruby 35 | JsRoutes.setup do |config| 36 | config.documentation = false 37 | end 38 | ``` 39 | 40 | ### `required_params` renamed 41 | 42 | In case you are using `required_params` property, it is now renamed and converted to a method: 43 | 44 | ``` javascript 45 | // Old style 46 | Routes.post_path.required_params // => ['id'] 47 | // New style 48 | Routes.post_path.requiredParams() // => ['id'] 49 | ``` 50 | 51 | ### ParameterMissing error rework 52 | 53 | `ParameterMissing` is renamed to `ParametersMissing` error and now list all missing parameters instead of just first encountered in its message. Missing parameters are now available via `ParametersMissing#keys` property. 54 | 55 | ``` javascript 56 | try { 57 | return Routes.inbox_path(); 58 | } catch(error) { 59 | if (error.name === 'ParametersMissing') { 60 | console.warn(`Missing route keys ${error.keys.join(', ')}. Ignoring.`); 61 | return "#"; 62 | } else { 63 | throw error; 64 | } 65 | } 66 | ``` 67 | -------------------------------------------------------------------------------- /lib/js_routes/engine.rb: -------------------------------------------------------------------------------- 1 | require "rails/engine" 2 | 3 | module JsRoutes 4 | class SprocketsExtension 5 | def initialize(filename, &block) 6 | @filename = filename 7 | @source = block.call 8 | end 9 | 10 | def render(context, empty_hash_wtf) 11 | self.class.run(@filename, @source, context) 12 | end 13 | 14 | def self.run(filename, source, context) 15 | if context.logical_path == 'js-routes' 16 | routes = Rails.root.join('config', 'routes.rb').to_s 17 | context.depend_on(routes) 18 | end 19 | source 20 | end 21 | 22 | def self.call(input) 23 | filename = input[:filename] 24 | source = input[:data] 25 | context = input[:environment].context_class.new(input) 26 | 27 | result = run(filename, source, context) 28 | context.metadata.merge(data: result) 29 | end 30 | end 31 | 32 | 33 | class Engine < ::Rails::Engine 34 | def self.install_sprockets! 35 | return if defined?(@installed_sprockets) 36 | require 'sprockets/version' 37 | v2 = Gem::Dependency.new('', ' ~> 2') 38 | vgte3 = Gem::Dependency.new('', ' >= 3') 39 | sprockets_version = Gem::Version.new(::Sprockets::VERSION).release 40 | initializer_args = case sprockets_version 41 | when -> (v) { v2.match?('', v) } 42 | { after: "sprockets.environment" } 43 | when -> (v) { vgte3.match?('', v) } 44 | { after: :engines_blank_point, before: :finisher_hook } 45 | else 46 | raise StandardError, "Sprockets version #{sprockets_version} is not supported" 47 | end 48 | 49 | initializer 'js-routes.dependent_on_routes', initializer_args do 50 | case sprockets_version 51 | when -> (v) { v2.match?('', v) }, 52 | -> (v) { vgte3.match?('', v) } 53 | 54 | Rails.application.config.assets.configure do |config| 55 | config.register_preprocessor( 56 | "application/javascript", 57 | SprocketsExtension, 58 | ) 59 | end 60 | else 61 | raise StandardError, "Sprockets version #{sprockets_version} is not supported" 62 | end 63 | end 64 | @installed_sprockets = true 65 | end 66 | if defined?(::Sprockets::Railtie) 67 | install_sprockets! 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/js_routes.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | if defined?(::Rails) 3 | require 'js_routes/engine' 4 | end 5 | require 'js_routes/version' 6 | require "js_routes/configuration" 7 | require "js_routes/instance" 8 | require "js_routes/types" 9 | require 'active_support/core_ext/string/indent' 10 | require "digest/sha2" 11 | 12 | module JsRoutes 13 | extend T::Sig 14 | 15 | # 16 | # API 17 | # 18 | 19 | class << self 20 | include JsRoutes::Types 21 | extend T::Sig 22 | 23 | sig { params(block: ConfigurationBlock).void } 24 | def setup(&block) 25 | configuration.setup(&block) 26 | end 27 | 28 | sig { returns(JsRoutes::Configuration) } 29 | def configuration 30 | @configuration ||= T.let(Configuration.new, T.nilable(JsRoutes::Configuration)) 31 | end 32 | 33 | sig { params(opts: T.untyped).returns(String) } 34 | def generate(**opts) 35 | Instance.new(**opts).generate 36 | end 37 | 38 | sig { params(file_name: FileName, typed: T::Boolean, opts: T.untyped).void } 39 | def generate!(file_name = configuration.file, typed: false, **opts) 40 | instance = Instance.new(file: file_name, **opts) 41 | instance.generate! 42 | if typed && instance.configuration.modern? 43 | definitions!(file_name, **opts) 44 | end 45 | end 46 | 47 | sig { params(file_name: FileName, opts: T.untyped).void } 48 | def remove!(file_name = configuration.file, **opts) 49 | Instance.new(file: file_name, **opts).remove! 50 | end 51 | 52 | sig { params(opts: T.untyped).returns(String) } 53 | def definitions(**opts) 54 | generate(**opts, module_type: 'DTS') 55 | end 56 | 57 | sig { params(file_name: FileName, opts: T.untyped).void } 58 | def definitions!(file_name = nil, **opts) 59 | file_name ||= configuration.file 60 | 61 | file_name = file_name&.sub(%r{(\.d)?\.(j|t)s\Z}, ".d.ts") 62 | generate!(file_name, **opts, module_type: 'DTS') 63 | end 64 | 65 | sig { params(value: T.untyped).returns(String) } 66 | def json(value) 67 | ActiveSupport::JSON.encode(value) 68 | end 69 | 70 | sig { returns(String) } 71 | def digest 72 | Digest::SHA256.file( 73 | Rails.root.join("config/routes.rb") 74 | ).hexdigest 75 | end 76 | end 77 | module Generators 78 | end 79 | end 80 | 81 | require "js_routes/middleware" 82 | require "js_routes/generators/webpacker" 83 | require "js_routes/generators/middleware" 84 | -------------------------------------------------------------------------------- /spec/support/routes.rb: -------------------------------------------------------------------------------- 1 | def draw_routes 2 | Planner::Engine.routes.draw do 3 | get "/manage" => 'foo#foo', as: :manage 4 | end 5 | 6 | BlogEngine::Engine.routes.draw do 7 | root to: "application#index" 8 | resources :posts, only: [:show, :index] 9 | end 10 | App.routes.draw do 11 | 12 | mount Planner::Engine, at: "/(locale/:locale)", as: :planner 13 | 14 | mount BlogEngine::Engine => "/blog", as: :blog_app 15 | get 'support(/page/:page)', to: BlogEngine::Engine, as: 'support' 16 | 17 | resources :inboxes, only: [:index, :show] do 18 | resources :messages, only: [:index, :show] do 19 | resources :attachments, only: [:new, :show], format: false 20 | end 21 | end 22 | 23 | get "(/:space)/campaigns" => "foo#foo", as: :campaigns, defaults: {space: nil} 24 | 25 | root :to => "inboxes#index" 26 | 27 | namespace :admin do 28 | resources :users, only: [:index] 29 | end 30 | 31 | scope "/returns/:return" do 32 | resources :objects, only: [:show] 33 | end 34 | 35 | scope "(/optional/:optional_id)" do 36 | resources :things, only: [:show, :index] 37 | end 38 | 39 | get "(/sep1/:first_optional)/sep2/:second_required/sep3/:third_required(/:forth_optional)", 40 | as: :thing_deep, controller: :things, action: :show 41 | 42 | get "/other_optional/(:optional_id)" => "foo#foo", :as => :foo 43 | get '/other_optional(/*optional_id)' => 'foo#foo', :as => :foo_all 44 | 45 | get 'books/*section/:title' => 'books#show', :as => :book 46 | get 'books/:title/*section' => 'books#show', :as => :book_title 47 | 48 | get '/no_format' => "foo#foo", :format => false, :as => :no_format 49 | 50 | get '/json_only' => "foo#foo", :format => true, :constraints => {:format => /json/}, :as => :json_only 51 | 52 | get '/привет' => "foo#foo", :as => :hello 53 | get '(/o/:organization)/search/:q' => "foo#foo", as: :search 54 | 55 | resources :sessions, :only => [:new, :create], :protocol => 'https' 56 | get '/' => 'sso#login', host: 'sso.example.com', as: :sso 57 | get "/" => "a#b", subdomain: 'www', host: 'example.com', port: 88, as: :secret_root 58 | 59 | resources :portals, :port => 8080, only: [:index] 60 | 61 | get '/with_defaults' => 'foo#foo', defaults: { bar: 'tested', format: :json }, format: true 62 | 63 | namespace :api, format: true, defaults: {format: 'json'} do 64 | get "/purchases" => "purchases#index" 65 | end 66 | 67 | resources :budgies, only: [:show, :index] do 68 | get "descendents" 69 | end 70 | 71 | namespace :backend, path: '', constraints: {subdomain: 'backend'} do 72 | root to: 'backend#index' 73 | end 74 | 75 | end 76 | 77 | end 78 | -------------------------------------------------------------------------------- /spec/js_routes/module_types/nil_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe JsRoutes, "compatibility with NIL (legacy browser)" do 4 | let(:generated_js) do 5 | JsRoutes.generate( 6 | module_type: nil, 7 | include: /book|inboxes|inbox_message/, 8 | **_options 9 | ) 10 | end 11 | 12 | let(:_options) { {} } 13 | describe "generated js" do 14 | subject do 15 | generated_js 16 | end 17 | 18 | it "should call route function for each route" do 19 | is_expected.to include("inboxes_path: __jsr.r(") 20 | end 21 | it "should have correct function without arguments signature" do 22 | is_expected.to include('inboxes_path: __jsr.r({"format":{}}') 23 | end 24 | it "should have correct function with arguments signature" do 25 | is_expected.to include('inbox_message_path: __jsr.r({"inbox_id":{"r":true},"id":{"r":true},"format":{}}') 26 | end 27 | it "should have correct function signature with unordered hash" do 28 | is_expected.to include('inbox_message_attachment_path: __jsr.r({"inbox_id":{"r":true},"message_id":{"r":true},"id":{"r":true}}') 29 | end 30 | end 31 | 32 | describe "inline generation" do 33 | let(:_options) { {namespace: nil} } 34 | before do 35 | evaljs("const r = #{generated_js}") 36 | end 37 | 38 | it "should be possible" do 39 | expectjs("r.inboxes_path()").to eq(test_routes.inboxes_path()) 40 | end 41 | end 42 | 43 | describe "namespace option" do 44 | let(:_options) { {namespace: "PHM"} } 45 | let(:_presetup) { "" } 46 | before do 47 | evaljs("var window = this;") 48 | evaljs("window.PHM = {}") 49 | evaljs(_presetup) 50 | evaljs(generated_js) 51 | end 52 | it "should use this namespace for routing" do 53 | expectjs("window.Routes").to be_nil 54 | expectjs("window.PHM.inboxes_path").not_to be_nil 55 | end 56 | 57 | describe "is nested" do 58 | context "and defined on client" do 59 | let(:_presetup) { "window.PHM = {}" } 60 | let(:_options) { {namespace: "PHM.Routes"} } 61 | 62 | it "should use this namespace for routing" do 63 | expectjs("PHM.Routes.inboxes_path").not_to be_nil 64 | end 65 | end 66 | 67 | context "but undefined on client" do 68 | let(:_options) { {namespace: "PHM.Routes"} } 69 | 70 | it "should initialize namespace" do 71 | expectjs("window.PHM.Routes.inboxes_path").not_to be_nil 72 | end 73 | end 74 | 75 | context "and some parts are defined" do 76 | let(:_presetup) { "window.PHM = { Utils: {} };" } 77 | let(:_options) { {namespace: "PHM.Routes"} } 78 | 79 | it "should not overwrite existing parts" do 80 | expectjs("window.PHM.Utils").not_to be_nil 81 | expectjs("window.PHM.Routes.inboxes_path").not_to be_nil 82 | end 83 | end 84 | end 85 | end 86 | end 87 | 88 | -------------------------------------------------------------------------------- /spec/js_routes/module_types/umd_spec.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/string/strip" 2 | require "fileutils" 3 | require 'spec_helper' 4 | 5 | describe JsRoutes, "compatibility with UMD" do 6 | describe "generated js" do 7 | subject do 8 | JsRoutes.generate( 9 | module_type: 'UMD', 10 | include: /book|inboxes|inbox_message/, 11 | ) 12 | end 13 | 14 | it "should include a comment in the header" do 15 | is_expected.to include("@file Generated by js-routes #{JsRoutes::VERSION}") 16 | is_expected.to include("Based on Rails #{Rails.version} routes of App") 17 | is_expected.to include("@see https://github.com/railsware/js-routes") 18 | is_expected.to include("@version #{JsRoutes.digest}") 19 | end 20 | 21 | it "should call route function for each route" do 22 | is_expected.to include("inboxes_path: __jsr.r(") 23 | end 24 | it "should have correct function without arguments signature" do 25 | is_expected.to include('inboxes_path: __jsr.r({"format":{}}') 26 | end 27 | it "should have correct function with arguments signature" do 28 | is_expected.to include('inbox_message_path: __jsr.r({"inbox_id":{"r":true},"id":{"r":true},"format":{}}') 29 | end 30 | it "should have correct function signature with unordered hash" do 31 | is_expected.to include('inbox_message_attachment_path: __jsr.r({"inbox_id":{"r":true},"message_id":{"r":true},"id":{"r":true}}') 32 | end 33 | 34 | it "should have correct function comment with options argument" do 35 | is_expected.to include(<<-DOC.rstrip) 36 | /** 37 | * Generates rails route to 38 | * /inboxes(.:format) 39 | * @param {object | undefined} options 40 | * @returns {string} route path 41 | */ 42 | inboxes_path: __jsr.r 43 | DOC 44 | end 45 | it "should have correct function comment with arguments" do 46 | is_expected.to include(<<-DOC.rstrip) 47 | /** 48 | * Generates rails route to 49 | * /inboxes/:inbox_id/messages/:message_id/attachments/new 50 | * @param {any} inbox_id 51 | * @param {any} message_id 52 | * @param {object | undefined} options 53 | * @returns {string} route path 54 | */ 55 | new_inbox_message_attachment_path: __jsr.r 56 | DOC 57 | end 58 | 59 | it "routes should be sorted in alphabetical order" do 60 | expect(subject.index("book_path")).to be <= subject.index("inboxes_path") 61 | end 62 | end 63 | 64 | describe ".generate!" do 65 | 66 | let(:name) { Rails.root.join('app', 'assets', 'javascripts', 'routes.js') } 67 | 68 | before(:each) do 69 | JsRoutes.remove!(name) 70 | JsRoutes.generate!(file: name) 71 | end 72 | 73 | after(:each) do 74 | JsRoutes.remove!(name) 75 | end 76 | 77 | after(:all) do 78 | FileUtils.rm_f("#{File.dirname(__FILE__)}/../routes.js") # let(:name) is not available here 79 | end 80 | 81 | it "should not generate file before initialization" do 82 | expect(File.exist?(name)).to be_falsey 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/routes.d.ts: -------------------------------------------------------------------------------- 1 | declare type Optional = { 2 | [P in keyof T]?: T[P] | null; 3 | }; 4 | declare type Collection = Record; 5 | declare type BaseRouteParameter = string | boolean | Date | number | bigint; 6 | declare type MethodRouteParameter = BaseRouteParameter | (() => BaseRouteParameter); 7 | declare type ModelRouteParameter = { 8 | id: MethodRouteParameter; 9 | } | { 10 | to_param: MethodRouteParameter; 11 | } | { 12 | toParam: MethodRouteParameter; 13 | }; 14 | declare type RequiredRouteParameter = BaseRouteParameter | ModelRouteParameter; 15 | declare type OptionalRouteParameter = undefined | null | RequiredRouteParameter; 16 | declare type QueryRouteParameter = OptionalRouteParameter | QueryRouteParameter[] | { 17 | [k: string]: QueryRouteParameter; 18 | }; 19 | declare type RouteParameters = Collection; 20 | declare type Serializable = Collection; 21 | declare type Serializer = (value: Serializable) => string; 22 | declare type RouteHelperExtras = { 23 | requiredParams(): string[]; 24 | toString(): string; 25 | }; 26 | declare type RequiredParameters = T extends 1 ? [RequiredRouteParameter] : T extends 2 ? [RequiredRouteParameter, RequiredRouteParameter] : T extends 3 ? [RequiredRouteParameter, RequiredRouteParameter, RequiredRouteParameter] : T extends 4 ? [ 27 | RequiredRouteParameter, 28 | RequiredRouteParameter, 29 | RequiredRouteParameter, 30 | RequiredRouteParameter 31 | ] : RequiredRouteParameter[]; 32 | declare type RouteHelperOptions = RouteOptions & Collection; 33 | declare type RouteHelper = ((...args: [...RequiredParameters, RouteHelperOptions]) => string) & RouteHelperExtras; 34 | declare type RouteHelpers = Collection; 35 | declare type Configuration = { 36 | prefix: string; 37 | default_url_options: RouteParameters; 38 | special_options_key: string; 39 | serializer: Serializer; 40 | }; 41 | interface RouterExposedMethods { 42 | config(): Configuration; 43 | configure(arg: Partial): Configuration; 44 | serialize: Serializer; 45 | } 46 | declare type KeywordUrlOptions = Optional<{ 47 | host: string; 48 | protocol: string; 49 | subdomain: string; 50 | port: string | number; 51 | anchor: string; 52 | trailing_slash: boolean; 53 | script_name: string; 54 | params: RouteParameters; 55 | }>; 56 | declare type RouteOptions = KeywordUrlOptions & RouteParameters; 57 | declare type PartsTable = Collection<{ 58 | r?: boolean; 59 | d?: OptionalRouteParameter; 60 | }>; 61 | declare type ModuleType = "CJS" | "AMD" | "UMD" | "ESM" | "DTS" | "NIL"; 62 | declare const RubyVariables: { 63 | PREFIX: string; 64 | DEPRECATED_FALSE_PARAMETER_BEHAVIOR: boolean; 65 | DEPRECATED_NIL_QUERY_PARAMETER_BEHAVIOR: boolean; 66 | SPECIAL_OPTIONS_KEY: string; 67 | DEFAULT_URL_OPTIONS: RouteParameters; 68 | SERIALIZER: Serializer; 69 | ROUTES_OBJECT: RouteHelpers; 70 | MODULE_TYPE: ModuleType; 71 | WRAPPER: (callback: T) => T; 72 | }; 73 | declare const define: undefined | (((arg: unknown[], callback: () => unknown) => void) & { 74 | amd?: unknown; 75 | }); 76 | declare const module: { 77 | exports: unknown; 78 | } | undefined; 79 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 4 | $:.unshift(File.dirname(__FILE__)) 5 | require 'rspec' 6 | # https://github.com/rails/rails/issues/54263 7 | require 'logger' 8 | require 'rails/all' 9 | require 'js-routes' 10 | require 'active_support/core_ext/hash/slice' 11 | 12 | unless ENV['CI'] 13 | code = system("yarn build") 14 | unless code 15 | exit(1) 16 | end 17 | end 18 | 19 | 20 | if defined?(JRUBY_VERSION) 21 | require 'rhino' 22 | JS_LIB_CLASS = Rhino 23 | else 24 | require 'mini_racer' 25 | JS_LIB_CLASS = MiniRacer 26 | end 27 | 28 | def jscontext(force = false) 29 | if force 30 | @jscontext = JS_LIB_CLASS::Context.new 31 | else 32 | @jscontext ||= JS_LIB_CLASS::Context.new 33 | end 34 | end 35 | 36 | def js_error_class 37 | if defined?(JRUBY_VERSION) 38 | JS_LIB_CLASS::JSError 39 | else 40 | JS_LIB_CLASS::Error 41 | end 42 | end 43 | 44 | def evaljs(string, force: false, filename: 'context.js') 45 | jscontext(force).eval(string, filename: filename) 46 | rescue MiniRacer::ParseError => e 47 | trace = e.message 48 | _, _, line, _ = trace.split(':') 49 | if line 50 | code = string.split("\n")[line.to_i-1] 51 | raise "#{trace}. Code: #{code.strip}"; 52 | else 53 | raise e 54 | end 55 | rescue MiniRacer::RuntimeError => e 56 | raise e 57 | end 58 | 59 | def evallib(**options) 60 | evaljs(JsRoutes.generate(**options), filename: 'lib/routes.js') 61 | end 62 | 63 | def test_routes 64 | ::App.routes.url_helpers 65 | end 66 | 67 | def blog_routes 68 | BlogEngine::Engine.routes.url_helpers 69 | end 70 | 71 | def planner_routes 72 | Planner::Engine.routes.url_helpers 73 | end 74 | 75 | def log(string) 76 | evaljs("console.log(#{string})") 77 | end 78 | 79 | def expectjs(string) 80 | expect(evaljs(string)) 81 | end 82 | 83 | def gem_root 84 | Pathname.new(File.expand_path('..', __dir__)) 85 | end 86 | 87 | ActiveSupport::Inflector.inflections do |inflect| 88 | inflect.irregular "budgie", "budgies" 89 | end 90 | 91 | 92 | module Planner 93 | class Engine < Rails::Engine 94 | isolate_namespace Planner 95 | end 96 | end 97 | 98 | module BlogEngine 99 | class Engine < Rails::Engine 100 | isolate_namespace BlogEngine 101 | end 102 | 103 | end 104 | 105 | 106 | class ::App < Rails::Application 107 | config.paths['config/routes.rb'] << 'spec/config/routes.rb' 108 | config.root = File.expand_path('../dummy', __FILE__) 109 | end 110 | 111 | 112 | # prevent warning 113 | Rails.configuration.active_support.deprecation = :log 114 | 115 | # Requires supporting files with custom matchers and macros, etc, 116 | # in ./support/ and its subdirectories. 117 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f} 118 | 119 | RSpec.configure do |config| 120 | config.expect_with :rspec do |c| 121 | c.syntax = :expect 122 | end 123 | 124 | config.before(:suite) do 125 | draw_routes 126 | end 127 | 128 | config.before :each do 129 | log = proc do |*values| 130 | puts values.map(&:inspect).join(", ") 131 | end 132 | 133 | if defined?(JRUBY_VERSION) 134 | jscontext[:"console.log"] = lambda do |context, *values| 135 | log(*values) 136 | end 137 | else 138 | jscontext.attach("console.log", log) 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/js_routes/module_types/dts/routes.spec.d.ts: -------------------------------------------------------------------------------- 1 | declare type Optional = { 2 | [P in keyof T]?: T[P] | null; 3 | }; 4 | declare type Collection = Record; 5 | declare type BaseRouteParameter = string | boolean | Date | number | bigint; 6 | declare type MethodRouteParameter = BaseRouteParameter | (() => BaseRouteParameter); 7 | declare type ModelRouteParameter = { 8 | id: MethodRouteParameter; 9 | } | { 10 | to_param: MethodRouteParameter; 11 | } | { 12 | toParam: MethodRouteParameter; 13 | }; 14 | declare type RequiredRouteParameter = BaseRouteParameter | ModelRouteParameter; 15 | declare type OptionalRouteParameter = undefined | null | RequiredRouteParameter; 16 | declare type QueryRouteParameter = OptionalRouteParameter | QueryRouteParameter[] | { 17 | [k: string]: QueryRouteParameter; 18 | }; 19 | declare type RouteParameters = Collection; 20 | declare type Serializable = Collection; 21 | declare type Serializer = (value: Serializable) => string; 22 | declare type RouteHelperExtras = { 23 | requiredParams(): string[]; 24 | toString(): string; 25 | }; 26 | declare type RequiredParameters = T extends 1 ? [RequiredRouteParameter] : T extends 2 ? [RequiredRouteParameter, RequiredRouteParameter] : T extends 3 ? [RequiredRouteParameter, RequiredRouteParameter, RequiredRouteParameter] : T extends 4 ? [ 27 | RequiredRouteParameter, 28 | RequiredRouteParameter, 29 | RequiredRouteParameter, 30 | RequiredRouteParameter 31 | ] : RequiredRouteParameter[]; 32 | declare type RouteHelperOptions = RouteOptions & Collection; 33 | declare type RouteHelper = ((...args: [...RequiredParameters, RouteHelperOptions]) => string) & RouteHelperExtras; 34 | declare type RouteHelpers = Collection; 35 | declare type Configuration = { 36 | prefix: string; 37 | default_url_options: RouteParameters; 38 | special_options_key: string; 39 | serializer: Serializer; 40 | }; 41 | interface RouterExposedMethods { 42 | config(): Configuration; 43 | configure(arg: Partial): Configuration; 44 | serialize: Serializer; 45 | } 46 | declare type KeywordUrlOptions = Optional<{ 47 | host: string; 48 | protocol: string; 49 | subdomain: string; 50 | port: string | number; 51 | anchor: string; 52 | trailing_slash: boolean; 53 | script_name: string; 54 | params: RouteParameters; 55 | }>; 56 | declare type RouteOptions = KeywordUrlOptions & RouteParameters; 57 | declare type PartsTable = Collection<{ 58 | r?: boolean; 59 | d?: OptionalRouteParameter; 60 | }>; 61 | declare type ModuleType = "CJS" | "AMD" | "UMD" | "ESM" | "DTS" | "NIL"; 62 | declare const RubyVariables: { 63 | PREFIX: string; 64 | DEPRECATED_FALSE_PARAMETER_BEHAVIOR: boolean; 65 | DEPRECATED_NIL_QUERY_PARAMETER_BEHAVIOR: boolean; 66 | SPECIAL_OPTIONS_KEY: string; 67 | DEFAULT_URL_OPTIONS: RouteParameters; 68 | SERIALIZER: Serializer; 69 | ROUTES_OBJECT: RouteHelpers; 70 | MODULE_TYPE: ModuleType; 71 | WRAPPER: (callback: T) => T; 72 | }; 73 | declare const define: undefined | (((arg: unknown[], callback: () => unknown) => void) & { 74 | amd?: unknown; 75 | }); 76 | declare const module: { 77 | exports: unknown; 78 | } | undefined; 79 | export const configure: RouterExposedMethods['configure']; 80 | 81 | export const config: RouterExposedMethods['config']; 82 | 83 | export const serialize: RouterExposedMethods['serialize']; 84 | 85 | /** 86 | * Generates rails route to 87 | * /inboxes/:inbox_id/messages/:message_id/attachments/:id 88 | * @param {any} inbox_id 89 | * @param {any} message_id 90 | * @param {any} id 91 | * @param {object | undefined} options 92 | * @returns {string} route path 93 | */ 94 | export const inbox_message_attachment_path: (( 95 | inbox_id: RequiredRouteParameter, 96 | message_id: RequiredRouteParameter, 97 | id: RequiredRouteParameter, 98 | options?: RouteOptions 99 | ) => string) & RouteHelperExtras; 100 | 101 | /** 102 | * Generates rails route to 103 | * /inboxes(.:format) 104 | * @param {object | undefined} options 105 | * @returns {string} route path 106 | */ 107 | export const inboxes_path: (( 108 | options?: {format?: OptionalRouteParameter} & RouteOptions 109 | ) => string) & RouteHelperExtras; 110 | 111 | // By some reason this line prevents all types in a file 112 | // from being automatically exported 113 | export {}; 114 | -------------------------------------------------------------------------------- /spec/js_routes/module_types/dts_spec.rb: -------------------------------------------------------------------------------- 1 | 2 | require "active_support/core_ext/string/strip" 3 | require "fileutils" 4 | require "open3" 5 | require "spec_helper" 6 | 7 | describe JsRoutes, "compatibility with DTS" do 8 | 9 | let(:extra_options) do 10 | {} 11 | end 12 | 13 | let(:options) do 14 | { 15 | module_type: 'DTS', 16 | include: [/^inboxes$/, /^inbox_message_attachment$/], 17 | **extra_options 18 | } 19 | end 20 | 21 | let(:generated_js) do 22 | JsRoutes.generate( 23 | module_type: 'DTS', 24 | include: [/^inboxes$/, /^inbox_message_attachment$/], 25 | **extra_options 26 | ) 27 | end 28 | 29 | context "when file is generated" do 30 | let(:extra_options) do 31 | { banner: nil } 32 | end 33 | 34 | let(:dir_name) do 35 | File.expand_path(__dir__ + "/dts") 36 | end 37 | 38 | let(:file_name) do 39 | dir_name + "/routes.spec.d.ts" 40 | end 41 | 42 | before do 43 | FileUtils.mkdir_p(dir_name) 44 | File.write(file_name, generated_js) 45 | end 46 | 47 | it "has no compile errors", :slow do 48 | command = "yarn tsc --strict --noEmit -p spec/tsconfig.json" 49 | stdout, stderr, status = Open3.capture3(command) 50 | expect(stderr).to eq("") 51 | expect(stdout).to eq("") 52 | expect(status).to eq(0) 53 | end 54 | end 55 | 56 | context "when camel case is enabled" do 57 | let(:extra_options) { {camel_case: true} } 58 | 59 | it "camelizes route name and arguments" do 60 | 61 | expect(generated_js).to include(<<-DOC.rstrip) 62 | /** 63 | * Generates rails route to 64 | * /inboxes/:inbox_id/messages/:message_id/attachments/:id 65 | * @param {any} inboxId 66 | * @param {any} messageId 67 | * @param {any} id 68 | * @param {object | undefined} options 69 | * @returns {string} route path 70 | */ 71 | export const inboxMessageAttachmentPath: (( 72 | inboxId: RequiredRouteParameter, 73 | messageId: RequiredRouteParameter, 74 | id: RequiredRouteParameter, 75 | options?: RouteOptions 76 | ) => string) & RouteHelperExtras; 77 | DOC 78 | end 79 | end 80 | 81 | context "when optional_definition_params specified" do 82 | let(:extra_options) { { optional_definition_params: true } } 83 | 84 | it "makes all route params optional" do 85 | expect(generated_js).to include(<<~JS.rstrip) 86 | export const inbox_message_attachment_path: (( 87 | inbox_id?: RequiredRouteParameter, 88 | message_id?: RequiredRouteParameter, 89 | id?: RequiredRouteParameter, 90 | options?: RouteOptions 91 | ) => string) & RouteHelperExtras; 92 | JS 93 | end 94 | end 95 | 96 | it "exports route helpers" do 97 | expect(generated_js).to include(<<-DOC.rstrip) 98 | /** 99 | * Generates rails route to 100 | * /inboxes(.:format) 101 | * @param {object | undefined} options 102 | * @returns {string} route path 103 | */ 104 | export const inboxes_path: (( 105 | options?: {format?: OptionalRouteParameter} & RouteOptions 106 | ) => string) & RouteHelperExtras; 107 | DOC 108 | expect(generated_js).to include(<<-DOC.rstrip) 109 | /** 110 | * Generates rails route to 111 | * /inboxes/:inbox_id/messages/:message_id/attachments/:id 112 | * @param {any} inbox_id 113 | * @param {any} message_id 114 | * @param {any} id 115 | * @param {object | undefined} options 116 | * @returns {string} route path 117 | */ 118 | export const inbox_message_attachment_path: (( 119 | inbox_id: RequiredRouteParameter, 120 | message_id: RequiredRouteParameter, 121 | id: RequiredRouteParameter, 122 | options?: RouteOptions 123 | ) => string) & RouteHelperExtras 124 | DOC 125 | end 126 | 127 | it "exports utility methods" do 128 | expect(generated_js).to include("export const serialize: RouterExposedMethods['serialize'];") 129 | end 130 | 131 | it "prevents all types from automatic export" do 132 | expect(generated_js).to include("export {};") 133 | end 134 | 135 | describe "compiled javascript asset" do 136 | subject { ERB.new(File.read("app/assets/javascripts/js-routes.js.erb")).result(binding) } 137 | it "should have js routes code" do 138 | is_expected.to include("export const inbox_message_path = /*#__PURE__*/ __jsr.r(") 139 | end 140 | end 141 | 142 | describe ".definitions" do 143 | let(:extra_options) { { module_type: 'ESM' } } 144 | 145 | it "uses DTS module automatically" do 146 | generated_js = JsRoutes.definitions(**options) 147 | expect(generated_js).to include('export {};') 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/js_routes/configuration.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | require "pathname" 4 | require "js_routes/types" 5 | require 'js_routes/utils' 6 | require 'js_routes/version' 7 | 8 | module JsRoutes 9 | class Configuration 10 | include JsRoutes::Types 11 | extend T::Sig 12 | 13 | sig { returns(T.nilable(String)) } 14 | attr_accessor :namespace 15 | sig { returns(Clusivity) } 16 | attr_accessor :exclude 17 | sig { returns(Clusivity) } 18 | attr_accessor :include 19 | sig { returns(FileName) } 20 | attr_accessor :file 21 | sig { returns(Prefix) } 22 | attr_reader :prefix 23 | sig { returns(T::Boolean) } 24 | attr_accessor :url_links 25 | sig { returns(T::Boolean) } 26 | attr_accessor :camel_case 27 | sig { returns(Options) } 28 | attr_accessor :default_url_options 29 | sig { returns(T::Boolean) } 30 | attr_accessor :compact 31 | sig { returns(T.nilable(String)) } 32 | attr_accessor :serializer 33 | sig { returns(Literal) } 34 | attr_accessor :special_options_key 35 | sig { returns(ApplicationCaller) } 36 | attr_accessor :application 37 | sig { returns(T::Boolean) } 38 | attr_accessor :documentation 39 | sig { returns(T.nilable(String)) } 40 | attr_accessor :module_type 41 | sig { returns(T::Boolean) } 42 | attr_accessor :optional_definition_params 43 | sig { returns(BannerCaller) } 44 | attr_accessor :banner 45 | 46 | sig {params(attributes: T.nilable(Options)).void } 47 | def initialize(attributes = nil) 48 | @namespace = nil 49 | @exclude = T.let([], Clusivity) 50 | @include = T.let([//], Clusivity) 51 | @file = T.let(nil, FileName) 52 | @prefix = T.let(-> { Rails.application.config.relative_url_root || "" }, T.untyped) 53 | @url_links = T.let(false, T::Boolean) 54 | @camel_case = T.let(false, T::Boolean) 55 | @default_url_options = T.let(T.unsafe({}), Options) 56 | @compact = T.let(false, T::Boolean) 57 | @serializer = T.let(nil, T.nilable(String)) 58 | @special_options_key = T.let("_options", Literal) 59 | @application = T.let(T.unsafe(-> { Rails.application }), ApplicationCaller) 60 | @module_type = T.let('ESM', T.nilable(String)) 61 | @documentation = T.let(true, T::Boolean) 62 | @optional_definition_params = T.let(false, T::Boolean) 63 | @banner = T.let(default_banner, BannerCaller) 64 | 65 | return unless attributes 66 | assign(attributes) 67 | end 68 | 69 | sig do 70 | params( 71 | attributes: Options, 72 | ).returns(JsRoutes::Configuration) 73 | end 74 | def assign(attributes) 75 | if attributes 76 | attributes.each do |attribute, value| 77 | public_send(:"#{attribute}=", value) 78 | end 79 | end 80 | normalize_and_verify 81 | self 82 | end 83 | 84 | sig { params(block: ConfigurationBlock).returns(T.self_type) } 85 | def setup(&block) 86 | tap(&block) 87 | end 88 | 89 | sig { params(attribute: Literal).returns(T.untyped) } 90 | def [](attribute) 91 | public_send(attribute) 92 | end 93 | 94 | def prefix=(value) 95 | JsRoutes::Utils.deprecator.warn("JsRoutes configuration prefix is deprecated in favor of default_url_options.script_name.") 96 | @prefix = value 97 | end 98 | 99 | sig { params(attributes: Options).returns(JsRoutes::Configuration) } 100 | def merge(attributes) 101 | clone.assign(attributes) 102 | end 103 | 104 | sig {returns(T::Boolean)} 105 | def esm? 106 | module_type === 'ESM' 107 | end 108 | 109 | sig {returns(T::Boolean)} 110 | def dts? 111 | self.module_type === 'DTS' 112 | end 113 | 114 | sig {returns(T::Boolean)} 115 | def modern? 116 | esm? || dts? 117 | end 118 | 119 | sig { void } 120 | def require_esm 121 | raise "ESM module type is required" unless modern? 122 | end 123 | 124 | sig { returns(String) } 125 | def source_file 126 | File.dirname(__FILE__) + "/../" + default_file_name 127 | end 128 | 129 | sig { returns(Pathname) } 130 | def output_file 131 | shakapacker = JsRoutes::Utils.shakapacker 132 | shakapacker_dir = shakapacker ? 133 | shakapacker.config.source_path : pathname('app', 'javascript') 134 | sprockets_dir = pathname('app','assets','javascripts') 135 | file_name = file || default_file_name 136 | sprockets_file = sprockets_dir.join(file_name) 137 | webpacker_file = shakapacker_dir.join(file_name) 138 | !Dir.exist?(shakapacker_dir) && defined?(::Sprockets) ? sprockets_file : webpacker_file 139 | end 140 | 141 | protected 142 | 143 | sig { void } 144 | def normalize_and_verify 145 | normalize 146 | verify 147 | end 148 | 149 | sig { params(parts: String).returns(Pathname) } 150 | def pathname(*parts) 151 | Pathname.new(File.join(*T.unsafe(parts))) 152 | end 153 | 154 | sig { returns(String) } 155 | def default_file_name 156 | dts? ? "routes.d.ts" : "routes.js" 157 | end 158 | 159 | sig {void} 160 | def normalize 161 | self.module_type = module_type&.upcase || 'NIL' 162 | end 163 | 164 | sig { void } 165 | def verify 166 | if module_type != 'NIL' && namespace 167 | raise "JsRoutes namespace option can only be used if module_type is nil" 168 | end 169 | end 170 | 171 | sig { returns(T.proc.returns(String)) } 172 | def default_banner 173 | -> () { 174 | app = application.is_a?(Proc) ? T.unsafe(application).call : application 175 | <<~TXT 176 | @file Generated by js-routes #{JsRoutes::VERSION}. Based on Rails #{Rails.version} routes of #{app.class}. 177 | @version #{JsRoutes.digest} 178 | @see https://github.com/railsware/js-routes 179 | TXT 180 | 181 | } 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /spec/js_routes/zzz_after_initialization_spec.rb: -------------------------------------------------------------------------------- 1 | # we need to run post_rails_init_spec as the latest 2 | # because it cause unrevertable changes to runtime 3 | # what is why I added "zzz_last" in the beginning 4 | 5 | require "sprockets" 6 | require "sprockets/railtie" 7 | require 'spec_helper' 8 | require "fileutils" 9 | 10 | describe "after Rails initialization", :slow do 11 | NAME = Rails.root.join('app', 'assets', 'javascripts', 'routes.js').to_s 12 | CONFIG_ROUTES = Rails.root.join('config','routes.rb').to_s 13 | 14 | def sprockets_v3? 15 | Sprockets::VERSION.to_i >= 3 16 | end 17 | 18 | def sprockets_v4? 19 | Sprockets::VERSION.to_i >= 4 20 | end 21 | 22 | def sprockets_context(environment, name, filename) 23 | if sprockets_v3? 24 | Sprockets::Context.new(environment: environment, name: name, filename: filename.to_s, metadata: {}) 25 | else 26 | Sprockets::Context.new(environment, name, filename) 27 | end 28 | end 29 | 30 | def evaluate(ctx, file) 31 | if sprockets_v3? 32 | ctx.load(ctx.environment.find_asset(file, pipeline: :default).uri).to_s 33 | else 34 | ctx.evaluate(file) 35 | end 36 | end 37 | 38 | before(:each) do 39 | FileUtils.mkdir_p(Rails.root.join('tmp')) 40 | FileUtils.rm_rf Rails.root.join('tmp/cache') 41 | JsRoutes.remove!(NAME) 42 | JsRoutes.generate!(NAME) 43 | end 44 | 45 | before(:all) do 46 | JsRoutes::Engine.install_sprockets! 47 | Rails.configuration.eager_load = false 48 | Rails.application.initialize! 49 | end 50 | 51 | it "should generate routes file" do 52 | expect(File.exist?(NAME)).to be_truthy 53 | end 54 | 55 | it "should not rewrite routes file if nothing changed" do 56 | routes_file_mtime = File.mtime(NAME) 57 | JsRoutes.generate!(NAME) 58 | expect(File.mtime(NAME)).to eq(routes_file_mtime) 59 | end 60 | 61 | it "should rewrite routes file if file content changed" do 62 | # Change content of existed routes file (add space to the end of file). 63 | File.open(NAME, 'a') { |f| f << ' ' } 64 | routes_file_mtime = File.mtime(NAME) 65 | sleep(0.1) 66 | JsRoutes.generate!(NAME) 67 | expect(File.mtime(NAME)).not_to eq(routes_file_mtime) 68 | end 69 | 70 | describe JsRoutes::Middleware do 71 | def file_content 72 | File.read(NAME) 73 | end 74 | 75 | it "works" do 76 | JsRoutes.remove! 77 | 78 | real_digest = JsRoutes.digest 79 | stub_digest = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' 80 | 81 | expect(File.exist?(NAME)).to be(false) 82 | app = lambda do |env| 83 | [200, {}, ""] 84 | end 85 | middleware = JsRoutes::Middleware.new(app) 86 | middleware.call({}) 87 | 88 | expect(File.exist?(NAME)).to be(true) 89 | expect(file_content).to include(real_digest) 90 | JsRoutes.remove! 91 | middleware.call({}) 92 | expect(File.exist?(NAME)).to be(false) 93 | 94 | allow(JsRoutes).to receive(:digest).and_return(stub_digest) 95 | middleware.call({}) 96 | 97 | expect(File.exist?(NAME)).to be(true) 98 | expect(file_content).to include(stub_digest) 99 | end 100 | end 101 | 102 | describe ".generate!" do 103 | let(:dir) { Rails.root.join('tmp') } 104 | it "works" do 105 | file = dir.join('typed_routes.js') 106 | JsRoutes.remove!(file) 107 | expect(File.exist?(file)).to be(false) 108 | expect(File.exist?(dir.join('typed_routes.d.ts'))).to be(false) 109 | JsRoutes.generate!(file, module_type: 'ESM', typed: true) 110 | expect(File.exist?(file)).to be(true) 111 | expect(File.exist?(dir.join('typed_routes.d.ts'))).to be(true) 112 | end 113 | 114 | it "skips definitions if module is not ESM" do 115 | file = dir.join('typed_routes.js') 116 | definitions = dir.join('typed_routes.d.ts') 117 | JsRoutes.remove!(file) 118 | expect(File.exist?(file)).to be(false) 119 | expect(File.exist?(definitions)).to be(false) 120 | JsRoutes.generate!(file, module_type: nil, typed: true) 121 | expect(File.exist?(file)).to be(true) 122 | expect(File.exist?(definitions)).to be(false) 123 | end 124 | end 125 | 126 | 127 | context "JsRoutes::Engine" do 128 | TEST_ASSET_PATH = Rails.root.join('app','assets','javascripts','test.js') 129 | 130 | before(:all) do 131 | File.open(TEST_ASSET_PATH,'w') do |f| 132 | f.puts "function() {}" 133 | end 134 | end 135 | after(:all) do 136 | FileUtils.rm_f(TEST_ASSET_PATH) 137 | end 138 | 139 | context "the preprocessor" do 140 | before(:each) do 141 | if sprockets_v3? || sprockets_v4? 142 | expect_any_instance_of(Sprockets::Context).to receive(:depend_on) 143 | else 144 | expect(ctx).to receive(:depend_on).with(CONFIG_ROUTES.to_s) 145 | end 146 | end 147 | let!(:ctx) do 148 | sprockets_context(Rails.application.assets, 149 | 'js-routes.js', 150 | Pathname.new('js-routes.js')) 151 | end 152 | 153 | context "when dealing with js-routes.js" do 154 | context "with Rails" do 155 | context "and initialize on precompile" do 156 | before(:each) do 157 | Rails.application.config.assets.initialize_on_precompile = true 158 | end 159 | it "should render some javascript" do 160 | expect(evaluate(ctx, 'js-routes.js')).to match(/utils\.define_module/) 161 | end 162 | end 163 | context "and not initialize on precompile" do 164 | before(:each) do 165 | Rails.application.config.assets.initialize_on_precompile = false 166 | end 167 | it "should raise an exception if 3 version" do 168 | expect(evaluate(ctx, 'js-routes.js')).to match(/utils\.define_module/) 169 | end 170 | end 171 | 172 | end 173 | end 174 | 175 | 176 | end 177 | context "when not dealing with js-routes.js" do 178 | it "should not depend on routes.rb" do 179 | ctx = sprockets_context(Rails.application.assets, 180 | 'test.js', 181 | TEST_ASSET_PATH) 182 | expect(ctx).not_to receive(:depend_on) 183 | evaluate(ctx, 'test.js') 184 | end 185 | end 186 | end 187 | end 188 | 189 | describe "JSRoutes thread safety", :slow do 190 | before do 191 | begin 192 | Rails.application.initialize! 193 | rescue 194 | end 195 | end 196 | 197 | it "can produce the routes from multiple threads" do 198 | threads = 2.times.map do 199 | Thread.start do 200 | 10.times { 201 | expect { JsRoutes.generate }.to_not raise_error 202 | } 203 | end 204 | end 205 | 206 | threads.each do |thread| 207 | thread.join 208 | end 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /lib/js_routes/instance.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | require "js_routes/configuration" 3 | require "js_routes/route" 4 | require "js_routes/types" 5 | require 'fileutils' 6 | 7 | module JsRoutes 8 | class Instance # :nodoc: 9 | include JsRoutes::Types 10 | extend T::Sig 11 | 12 | sig { returns(JsRoutes::Configuration) } 13 | attr_reader :configuration 14 | # 15 | # Implementation 16 | # 17 | 18 | sig { params(options: T.untyped).void } 19 | def initialize(**options) 20 | options = T.let(options, Options) 21 | @configuration = T.let(JsRoutes.configuration.merge(options), JsRoutes::Configuration) 22 | end 23 | 24 | sig {returns(String)} 25 | def generate 26 | # Ensure routes are loaded. If they're not, load them. 27 | 28 | application = T.unsafe(self.application) 29 | if routes_from(application).empty? 30 | if application.is_a?(Rails::Application) 31 | if Rails.version >= "8.0.0" 32 | T.unsafe(application).reload_routes_unless_loaded 33 | else 34 | T.unsafe(application).reload_routes! 35 | end 36 | end 37 | end 38 | content = File.read(@configuration.source_file) 39 | 40 | unless @configuration.dts? 41 | content = js_variables.inject(content) do |js, (key, value)| 42 | js.gsub!("RubyVariables.#{key}", value.to_s) || 43 | raise("Missing key #{key} in JS template") 44 | end 45 | end 46 | banner + content + routes_export + prevent_types_export 47 | end 48 | 49 | sig { returns(String) } 50 | def banner 51 | banner = @configuration.banner 52 | banner = banner.call if banner.is_a?(Proc) 53 | return "" if banner.blank? 54 | [ 55 | "/**", 56 | *banner.split("\n").map { |line| " * #{line}" }, 57 | " */", 58 | "", 59 | ].join("\n") 60 | end 61 | 62 | sig { void } 63 | def generate! 64 | # Some libraries like Devise did not load their routes yet 65 | # so we will wait until initialization process finishes 66 | # https://github.com/railsware/js-routes/issues/7 67 | T.unsafe(Rails).configuration.after_initialize do 68 | file_path = Rails.root.join(@configuration.output_file) 69 | source_code = generate 70 | 71 | # We don't need to rewrite file if it already exist and have same content. 72 | # It helps asset pipeline or webpack understand that file wasn't changed. 73 | next if File.exist?(file_path) && File.read(file_path) == source_code 74 | 75 | File.open(file_path, 'w') do |f| 76 | f.write source_code 77 | end 78 | end 79 | end 80 | 81 | sig { void } 82 | def remove! 83 | path = Rails.root.join(@configuration.output_file) 84 | FileUtils.rm_rf(path) 85 | FileUtils.rm_rf(path.sub(%r{\.js\z}, '.d.ts')) 86 | end 87 | 88 | protected 89 | 90 | sig { returns(T::Hash[String, String]) } 91 | def js_variables 92 | prefix = @configuration.prefix 93 | prefix = prefix.call if prefix.is_a?(Proc) 94 | { 95 | 'ROUTES_OBJECT' => routes_object, 96 | 'DEPRECATED_FALSE_PARAMETER_BEHAVIOR' => Rails.version < '7.0.0', 97 | 'DEPRECATED_NIL_QUERY_PARAMETER_BEHAVIOR' => Rails.version < '8.1.0', 98 | 'DEFAULT_URL_OPTIONS' => json(@configuration.default_url_options), 99 | 'PREFIX' => json(prefix), 100 | 'SPECIAL_OPTIONS_KEY' => json(@configuration.special_options_key), 101 | 'SERIALIZER' => @configuration.serializer || json(nil), 102 | 'MODULE_TYPE' => json(@configuration.module_type), 103 | 'WRAPPER' => wrapper_variable, 104 | } 105 | end 106 | 107 | sig { returns(String) } 108 | def wrapper_variable 109 | case @configuration.module_type 110 | when 'ESM' 111 | 'const __jsr = ' 112 | when 'NIL' 113 | namespace = @configuration.namespace 114 | if namespace 115 | if namespace.include?('.') 116 | "#{namespace} = " 117 | else 118 | "(typeof window !== 'undefined' ? window : this).#{namespace} = " 119 | end 120 | else 121 | '' 122 | end 123 | else 124 | '' 125 | end 126 | end 127 | 128 | sig { returns(Application) } 129 | def application 130 | app = @configuration.application 131 | app.is_a?(Proc) ? app.call : app 132 | end 133 | 134 | sig { params(value: T.untyped).returns(String) } 135 | def json(value) 136 | JsRoutes.json(value) 137 | end 138 | 139 | sig {params(application: Application).returns(T::Array[JourneyRoute])} 140 | def routes_from(application) 141 | T.unsafe(application).routes.named_routes.to_h.values.sort_by(&:name) 142 | end 143 | 144 | sig { returns(String) } 145 | def routes_object 146 | return json({}) if @configuration.modern? 147 | properties = routes_list.map do |comment, name, body| 148 | "#{comment}#{name}: #{body}".indent(2) 149 | end 150 | "{\n" + properties.join(",\n\n") + "}\n" 151 | end 152 | 153 | sig { returns(T::Array[StringArray]) } 154 | def static_exports 155 | [:configure, :config, :serialize].map do |name| 156 | [ 157 | "", name.to_s, 158 | @configuration.dts? ? 159 | "RouterExposedMethods['#{name}']" : 160 | "__jsr.#{name}" 161 | ] 162 | end 163 | end 164 | 165 | sig { returns(String) } 166 | def routes_export 167 | return "" unless @configuration.modern? 168 | [*static_exports, *routes_list].map do |comment, name, body| 169 | "#{comment}export const #{name}#{export_separator}#{body};\n\n" 170 | end.join 171 | end 172 | 173 | sig { returns(String) } 174 | def prevent_types_export 175 | return "" unless @configuration.dts? 176 | <<-JS 177 | // By some reason this line prevents all types in a file 178 | // from being automatically exported 179 | export {}; 180 | JS 181 | end 182 | 183 | sig { returns(String) } 184 | def export_separator 185 | @configuration.dts? ? ': ' : ' = ' 186 | end 187 | 188 | sig { returns(T::Array[StringArray]) } 189 | def routes_list 190 | routes_from(application).flat_map do |route| 191 | route_helpers_if_match(route) + mounted_app_routes(route) 192 | end 193 | end 194 | 195 | sig { params(route: JourneyRoute).returns(T::Array[StringArray]) } 196 | def mounted_app_routes(route) 197 | rails_engine_app = T.unsafe(app_from_route(route)) 198 | if rails_engine_app.is_a?(Class) && 199 | rails_engine_app < Rails::Engine && !route.path.anchored 200 | routes_from(rails_engine_app).flat_map do |engine_route| 201 | route_helpers_if_match(engine_route, route) 202 | end 203 | else 204 | [] 205 | end 206 | end 207 | 208 | sig { params(route: JourneyRoute).returns(T.untyped) } 209 | def app_from_route(route) 210 | app = route.app 211 | # Rails Engine can use additional 212 | # ActionDispatch::Routing::Mapper::Constraints, which contain app 213 | if app.is_a?(T.unsafe(ActionDispatch::Routing::Mapper::Constraints)) 214 | app.app 215 | else 216 | app 217 | end 218 | end 219 | 220 | sig { params(route: JourneyRoute, parent_route: T.nilable(JourneyRoute)).returns(T::Array[StringArray]) } 221 | def route_helpers_if_match(route, parent_route = nil) 222 | Route.new(@configuration, route, parent_route).helpers 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /lib/js_routes/route.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | require "js_routes/types" 4 | require "action_dispatch/journey/route" 5 | 6 | module JsRoutes 7 | class Route #:nodoc: 8 | include JsRoutes::Types 9 | extend T::Sig 10 | 11 | FILTERED_DEFAULT_PARTS = T.let([:controller, :action].freeze, SymbolArray) 12 | URL_OPTIONS = T.let([:protocol, :domain, :host, :port, :subdomain].freeze, SymbolArray) 13 | NODE_TYPES = T.let({ 14 | GROUP: 1, 15 | CAT: 2, 16 | SYMBOL: 3, 17 | OR: 4, 18 | STAR: 5, 19 | LITERAL: 6, 20 | SLASH: 7, 21 | DOT: 8 22 | }.freeze, T::Hash[Symbol, Integer]) 23 | 24 | sig {returns(JsRoutes::Configuration)} 25 | attr_reader :configuration 26 | 27 | sig {returns(JourneyRoute)} 28 | attr_reader :route 29 | 30 | sig {returns(T.nilable(JourneyRoute))} 31 | attr_reader :parent_route 32 | 33 | sig { params(configuration: JsRoutes::Configuration, route: JourneyRoute, parent_route: T.nilable(JourneyRoute)).void } 34 | def initialize(configuration, route, parent_route = nil) 35 | @configuration = configuration 36 | @route = route 37 | @parent_route = parent_route 38 | end 39 | 40 | sig { returns(T::Array[StringArray]) } 41 | def helpers 42 | helper_types.map do |absolute| 43 | [ documentation, helper_name(absolute), body(absolute) ] 44 | end 45 | end 46 | 47 | sig {returns(T::Array[T::Boolean])} 48 | def helper_types 49 | return [] unless match_configuration? 50 | @configuration.url_links ? [true, false] : [false] 51 | end 52 | 53 | sig { params(absolute: T::Boolean).returns(String) } 54 | def body(absolute) 55 | if @configuration.dts? 56 | definition_body 57 | else 58 | # For tree-shaking ESM, add a #__PURE__ comment informing Webpack/minifiers that the call to `__jsr.r` 59 | # has no side-effects (e.g. modifying global variables) and is safe to remove when unused. 60 | # https://webpack.js.org/guides/tree-shaking/#clarifying-tree-shaking-and-sidyeeffects 61 | pure_comment = @configuration.esm? ? '/*#__PURE__*/ ' : '' 62 | "#{pure_comment}__jsr.r(#{arguments(absolute).map{|a| json(a)}.join(', ')})" 63 | end 64 | end 65 | 66 | sig { returns(String) } 67 | def definition_body 68 | options_type = optional_parts_type ? "#{optional_parts_type} & RouteOptions" : "RouteOptions" 69 | predicate = @configuration.optional_definition_params ? '?' : '' 70 | args = required_parts.map{|p| "#{apply_case(p)}#{predicate}: RequiredRouteParameter"} 71 | args << "options?: #{options_type}" 72 | "((\n#{args.join(",\n").indent(2)}\n) => string) & RouteHelperExtras" 73 | end 74 | 75 | sig { returns(T.nilable(String)) } 76 | def optional_parts_type 77 | return nil if optional_parts.empty? 78 | @optional_parts_type ||= T.let( 79 | "{" + optional_parts.map {|p| "#{p}?: OptionalRouteParameter"}.join(', ') + "}", 80 | T.nilable(String) 81 | ) 82 | end 83 | 84 | protected 85 | 86 | 87 | sig { params(absolute: T::Boolean).returns(UntypedArray) } 88 | def arguments(absolute) 89 | absolute ? [*base_arguments, true] : base_arguments 90 | end 91 | 92 | sig { returns(T::Boolean) } 93 | def match_configuration? 94 | !match?(@configuration.exclude) && match?(@configuration.include) 95 | end 96 | 97 | sig { returns(T.nilable(String)) } 98 | def base_name 99 | @base_name ||= T.let(parent_route ? 100 | [parent_route&.name, route.name].join('_') : route.name, T.nilable(String)) 101 | end 102 | 103 | sig { returns(T.nilable(RouteSpec)) } 104 | def parent_spec 105 | parent_route&.path&.spec 106 | end 107 | 108 | sig { returns(RouteSpec) } 109 | def spec 110 | route.path.spec 111 | end 112 | 113 | sig { params(value: T.untyped).returns(String) } 114 | def json(value) 115 | JsRoutes.json(value) 116 | end 117 | 118 | sig { params(absolute: T::Boolean).returns(String) } 119 | def helper_name(absolute) 120 | suffix = absolute ? :url : @configuration.compact ? nil : :path 121 | apply_case(base_name, suffix) 122 | end 123 | 124 | sig { returns(String) } 125 | def documentation 126 | return '' unless @configuration.documentation 127 | <<-JS 128 | /** 129 | * Generates rails route to 130 | * #{parent_spec}#{spec}#{documentation_params} 131 | * @param {object | undefined} options 132 | * @returns {string} route path 133 | */ 134 | JS 135 | end 136 | 137 | sig { returns(SymbolArray) } 138 | def required_parts 139 | route.required_parts 140 | end 141 | 142 | sig { returns(SymbolArray) } 143 | def optional_parts 144 | route.path.optional_names 145 | end 146 | 147 | sig { returns(UntypedArray) } 148 | def base_arguments 149 | @base_arguments ||= T.let([parts_table, serialize(spec, parent_spec)], T.nilable(UntypedArray)) 150 | end 151 | 152 | sig { returns(T::Hash[Symbol, Options]) } 153 | def parts_table 154 | parts_table = {} 155 | route.parts.each do |part, hash| 156 | parts_table[part] ||= {} 157 | if required_parts.include?(part) 158 | # Using shortened keys to reduce js file size 159 | parts_table[part][:r] = true 160 | end 161 | end 162 | route.defaults.each do |part, value| 163 | if FILTERED_DEFAULT_PARTS.exclude?(part) && 164 | URL_OPTIONS.include?(part) || parts_table[part] 165 | parts_table[part] ||= {} 166 | # Using shortened keys to reduce js file size 167 | parts_table[part][:d] = value 168 | end 169 | end 170 | parts_table 171 | end 172 | 173 | sig { returns(String) } 174 | def documentation_params 175 | required_parts.map do |param| 176 | "\n * @param {any} #{apply_case(param)}" 177 | end.join 178 | end 179 | 180 | sig { params(matchers: Clusivity).returns(T::Boolean) } 181 | def match?(matchers) 182 | Array(matchers).any? { |regex| base_name =~ regex } 183 | end 184 | 185 | sig { params(values: T.nilable(Literal)).returns(String) } 186 | def apply_case(*values) 187 | value = values.compact.map(&:to_s).join('_') 188 | @configuration.camel_case ? value.camelize(:lower) : value 189 | end 190 | 191 | # This function serializes Journey route into JSON structure 192 | # We do not use Hash for human readable serialization 193 | # And preffer Array serialization because it is shorter. 194 | # Routes.js file will be smaller. 195 | sig { params(spec: SpecNode, parent_spec: T.nilable(RouteSpec)).returns(T.nilable(T.any(UntypedArray, String))) } 196 | def serialize(spec, parent_spec=nil) 197 | return nil unless spec 198 | # Removing special character prefix from route variable name 199 | # * for globbing 200 | # : for common parameter 201 | return spec.tr(':*', '') if spec.is_a?(String) 202 | 203 | result = serialize_spec(spec, parent_spec) 204 | if parent_spec && result[1].is_a?(String) && parent_spec.type != :SLASH 205 | result = [ 206 | # We encode node symbols as integer 207 | # to reduce the routes.js file size 208 | NODE_TYPES[:CAT], 209 | serialize_spec(parent_spec), 210 | result 211 | ] 212 | end 213 | result 214 | end 215 | 216 | sig { params(spec: RouteSpec, parent_spec: T.nilable(RouteSpec)).returns(UntypedArray) } 217 | def serialize_spec(spec, parent_spec = nil) 218 | [ 219 | NODE_TYPES[spec.type], 220 | serialize(spec.left, parent_spec), 221 | spec.respond_to?(:right) ? serialize(spec.right) : nil 222 | ].compact 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2.3.6] 4 | 5 | * Fixed serialization of empty `Array` and empty `Hash`. Fixes [#336](https://github.com/railsware/js-routes/issues/336). 6 | 7 | ``` javascript 8 | blog_path({filters: {}, columns: []}) // => /blog 9 | ``` 10 | 11 | * Support new Rails 8.1 nil parameter serialization. 12 | [Rails #53962](https://github.com/rails/rails/pull/53962) 13 | JsRoutes consistently follows current rails version behavior: 14 | 15 | ``` ruby 16 | root_path(hello: nil) 17 | # 8.0 => "/?hello=" 18 | # 8.1 => "/?hello" 19 | ``` 20 | 21 | ## [2.3.5] 22 | 23 | * Support `bigint` route parameter 24 | 25 | ``` typescript 26 | import {nft_path} from "./routes" 27 | 28 | nft_path(123456789012345678901234567890n) 29 | // => /nfts/123456789012345678901234567890 30 | ``` 31 | 32 | * Fix rake task for non-esm modules. [#316](https://github.com/railsware/js-routes/issues/316) 33 | 34 | ## [2.3.4] 35 | 36 | * Fix deprecator usage in `rake js:routes:typescript` [#327](https://github.com/railsware/js-routes/issues/327) 37 | * Migrate to yarn 4 38 | * Deprecated `prefix` option in favor of `default_url_options.script_name`. 39 | * Add support for `script_name` [Rails helper option](https://api.rubyonrails.org/classes/ActionDispatch/Routing/UrlFor.html#method-i-url_for). 40 | 41 | ``` javascript 42 | Routes.post_path(1, { script_name: "/myapp" }) 43 | // => /myapp/post/1 44 | 45 | Routes.configure({ 46 | default_url_options: { script_name: "/myapp" } 47 | }) 48 | 49 | Routes.post_path(1) // => /myapp/post/1 50 | ``` 51 | 52 | ## [2.3.3] 53 | 54 | * Rework default banner to use `routes.rb` digest instead of timestamp to ensure 55 | consistent routes.js version accross environments. 56 | * Use [JSDoc Tags](https://jsdoc.app/) in banner by default. 57 | 58 | 59 | The new default banner: 60 | 61 | ``` js 62 | /** 63 | * @file Generated by js-routes 2.3.3. Based on Rails 7.2.0 routes of App. 64 | * @see https://github.com/railsware/js-routes 65 | * @version e289929fc3fbe69b77aa24c5c3a58fcb1246ad1ea3f4e883f7eafa915ca929f3 66 | */ 67 | ``` 68 | 69 | ## [2.3.2] 70 | 71 | * Add `banner` option that allow to control JSDoc on top of generated file. [#324](https://github.com/bogdan/repo/issues/324). 72 | 73 | ``` ruby 74 | JsRoutes.setup do |c| 75 | c.banner = -> { 76 | <<~DOC 77 | @file Javascript Route helpers of my magic pony app. 78 | @author Bogdan Gusiev 79 | @license MIT 80 | @version #{JsRoutes.digest} 81 | DOC 82 | } 83 | end 84 | ``` 85 | 86 | ## [2.3.1] 87 | 88 | * Add timestamp on when routes.js was generated into banner. 89 | * Fix application specified directly without proc. [#323](https://github.com/railsware/js-routes/issues/323) 90 | * Support `optional_definition_params` option. See [Related Docs](./Readme.md#optional-definition-params). 91 | 92 | ## [2.3.0] 93 | 94 | * Drop support of Rails 4.x 95 | * Fix support of shakapacker [#321](https://github.com/railsware/js-routes/issues/321). 96 | * Fix support for Rails 8 [#319](https://github.com/railsware/js-routes/issues/319) 97 | * Deprecated `rake js:routes:typescript`. 98 | `rake js:routes` now automatically detects if types support can be used on not. 99 | 100 | ## [2.2.10] 101 | 102 | * Remove sorbet files from repo 103 | * Clearly define files included in gem 104 | * Fix Middleware and Middleware generator bugs [#316](https://github.com/railsware/js-routes/issues/316) 105 | * Remove empty object linter warning on DTS module 106 | * Generators: Add `.ts` extension when searching for main JS file of the project 107 | 108 | ## [2.2.9] 109 | 110 | * Fix middleware error for non-modern setup. 111 | * Added [Sorbet](https://sorbet.org/) method signatures. 112 | * Always use DTS module type when calling JsRoutes.definitions or .definitions!. 113 | [#313](https://github.com/railsware/js-routes/issues/313) 114 | 115 | ## [2.2.8] 116 | 117 | * Leave emoji symbols intact when encoding URI fragment [#312](https://github.com/railsware/js-routes/issues/312) 118 | * Use webpacker config variable instead of hardcode [#309](https://github.com/railsware/js-routes/issues/309) 119 | * Use `File.exist?` to be compatible with all versions of ruby [#310](https://github.com/railsware/js-routes/issues/310) 120 | 121 | ## [2.2.7] 122 | 123 | * Fix ESM Tree Shaking [#306](https://github.com/railsware/js-routes/issues/306) 124 | 125 | ## [2.2.6] 126 | 127 | * Prefer to extend `javascript:build` instead of `assets:precompile`. [#305](https://github.com/railsware/js-routes/issues/305) 128 | * Add stimulus framework application.js location to generators 129 | 130 | ## [2.2.5] 131 | 132 | * Upgraded eslint and prettier versions [#304](https://github.com/railsware/js-routes/issues/304) 133 | * Fix middleware generator [#300](https://github.com/railsware/js-routes/issues/300) 134 | * Support `params` special parameter 135 | 136 | ## [2.2.4] 137 | 138 | * Fix rails engine loading if sprockets is not in Gemfile. Fixes [#294](https://github.com/railsware/js-routes/issues/294) 139 | 140 | ## [2.2.3] 141 | 142 | * Fixed NIL module type namespace defintion [#297](https://github.com/railsware/js-routes/issues/297). 143 | * The patch may cause a problem with nested `namespace` option. 144 | * Ex. Value like `MyProject.Routes` requires to define `window.MyProject` before importing the routes file 145 | 146 | ## [2.2.2] 147 | 148 | * Fix custom file path [#295](https://github.com/railsware/js-routes/issues/295) 149 | 150 | ## [2.2.1] 151 | 152 | * Improve generator to update route files on `assets:precompile` and add them to `.gitignore` by default [#288](https://github.com/railsware/js-routes/issues/288#issuecomment-1012182815) 153 | 154 | ## [2.2.0] 155 | 156 | * Use Rack Middleware to automatically update routes file in development [#288](https://github.com/railsware/js-routes/issues/288) 157 | * This setup is now a default recommended due to lack of any downside comparing to [ERB Loader](./Readme.md#webpacker) and [Manual Setup](./Readme.md#advanced-setup) 158 | 159 | ## [2.1.3] 160 | 161 | * Fix `default_url_options` bug. [#290](https://github.com/railsware/js-routes/issues/290) 162 | 163 | ## [2.1.2] 164 | 165 | * Improve browser window object detection. [#287](https://github.com/railsware/js-routes/issues/287) 166 | 167 | ## [2.1.1] 168 | 169 | * Added webpacker generator `./bin/rails generate js_routes:webpacker` 170 | * Reorganized Readme to describe different setups with their pros and cons more clearly 171 | 172 | ## [2.1.0] 173 | 174 | * Support typescript defintions file aka `routes.d.ts`. See [Readme.md](./Readme.md#definitions) for more information. 175 | 176 | ## [2.0.8] 177 | 178 | * Forbid usage of `namespace` option if `module_type` is not `nil`. [#281](https://github.com/railsware/js-routes/issues/281). 179 | 180 | ## [2.0.7] 181 | 182 | * Remove source map annotation from JS file. Fixes [#277](https://github.com/railsware/js-routes/issues/277) 183 | * Generated file is not minified, so it is better to use app side bundler/compressor for source maps 184 | 185 | 186 | ## [2.0.6] 187 | 188 | * Disable `namespace` option default for all envs [#278](https://github.com/railsware/js-routes/issues/278) 189 | 190 | ## [2.0.5] 191 | 192 | * Fixed backward compatibility issue [#276](https://github.com/railsware/js-routes/issues/276) 193 | 194 | ## [2.0.4] 195 | 196 | * Fixed backward compatibility issue [#275](https://github.com/railsware/js-routes/issues/275) 197 | 198 | ## [2.0.3] 199 | 200 | * Fixed backward compatibility issue [#275](https://github.com/railsware/js-routes/issues/275) 201 | 202 | ## [2.0.2] 203 | 204 | * Fixed backward compatibility issue [#274](https://github.com/railsware/js-routes/issues/274) 205 | 206 | ## [2.0.1] 207 | 208 | * Fixed backward compatibility issue [#272](https://github.com/railsware/js-routes/issues/272) 209 | 210 | ## [2.0.0] 211 | 212 | Version 2.0 has some breaking changes. 213 | See [UPGRADE TO 2.0](./VERSION_2_UPGRADE.md) for guidance. 214 | 215 | * `module_type` option support 216 | * `documentation` option spport 217 | * Migrated implementation to typescript 218 | * ESM tree shaking support 219 | * Support camel case `toParam` version of `to_param` property 220 | 221 | ## v1.4.14 222 | 223 | * Fix compatibility with UMD modules #237 [Comment](https://github.com/railsware/js-routes/issues/237#issuecomment-752754679) 224 | 225 | ## v1.4.13 226 | 227 | * Improve compatibility with node environment #269. 228 | * Change default file location configuration to Webpacker if both Webpacker and Sprockets are loaded 229 | 230 | ## v1.4.11 231 | 232 | * Use app/javascript/routes.js as a default file location if app/javascript directory exists 233 | * Add `default` export for better experience when used as es6 module 234 | 235 | ## v1.4.10 236 | 237 | * Require engine only when sprockets is loaded #257. 238 | 239 | ## v1.4.9 240 | 241 | * Allow to specify null namespace and receive routes as an object without assigning it anywhere #247 242 | 243 | ## v1.4.7 244 | 245 | * Fix a LocalJumpError on secondary initialization of the app #248 246 | 247 | ## v1.4.6 248 | 249 | * Fix regression of #244 in #243 250 | 251 | ## v1.4.5 252 | 253 | * Fix escaping inside route parameters and globbing #244 254 | 255 | ## v1.4.4 256 | 257 | * More informative stack trace for ParameterMissing error #235 258 | 259 | ## v1.4.3 260 | 261 | * Proper implementation of the :subdomain option in routes generation 262 | 263 | ## v1.4.2 264 | 265 | * Added JsRoutes namespace to Engine #230 266 | 267 | ## v1.4.1 268 | 269 | * Fixed bug when js-routes is used in envs without window.location #224 270 | 271 | ## v1.4.0 272 | 273 | * Implemented Routes.config() and Routes.configure instead of Routes.defaults 274 | 275 | New methods support 4 options at the moment: 276 | 277 | ``` js 278 | Routes.configuration(); // => 279 | /* 280 | { 281 | prefix: "", 282 | default_url_options: {}, 283 | special_options_key: '_options', 284 | serializer: function(...) { ... } 285 | } 286 | */ 287 | 288 | Routes.configure({ 289 | prefix: '/app', 290 | default_url_options: {format: 'json'}, 291 | special_options_key: '_my_options_key', 292 | serializer: function(...) { ... } 293 | }); 294 | ``` 295 | 296 | ## v1.3.3 297 | 298 | * Improved optional parameters support #216 299 | 300 | ## v1.3.2 301 | 302 | * Added `application` option #214 303 | 304 | ## v1.3.1 305 | 306 | * Raise error object with id null passed as route paramter #209 307 | * Sprockets bugfixes #212 308 | 309 | ## v1.3.0 310 | 311 | * Introduce the special _options key. Fixes #86 312 | 313 | ## v1.2.9 314 | 315 | * Fixed deprecation varning on Sprockets 3.7 316 | 317 | ## v1.2.8 318 | 319 | * Bugfix warning on Sprockets 4.0 #202 320 | 321 | ## v1.2.7 322 | 323 | * Drop support 1.9.3 324 | * Add helper for indexOf, if no native implementation in JS engine 325 | * Add sprockets3 compatibility 326 | * Bugfix domain defaults to path #197 327 | 328 | ## v1.2.6 329 | 330 | * Use default prefix from `Rails.application.config.relative_url_root` #186 331 | * Bugfix route globbing with optional fragments bug #191 332 | 333 | ## v1.2.5 334 | 335 | * Bugfix subdomain default parameter in routes #184 336 | * Bugfix infinite recursion in some specific route sets #183 337 | 338 | ## v1.2.4 339 | 340 | * Additional bugfixes to support all versions of Sprockets: 2.x and 3.x 341 | 342 | ## v1.2.3 343 | 344 | * Sprockets ~= 3.0 support 345 | 346 | ## v1.2.2 347 | 348 | * Sprockets ~= 3.0 support 349 | * Support default parameters specified in route.rb file 350 | 351 | ## v1.2.1 352 | 353 | * Fixes for Rails 5 354 | 355 | ## v1.2.0 356 | 357 | * Support host, port and protocol inline parameters 358 | * Support host, port and protocol parameters given to a route explicitly 359 | * Remove all incompatibilities between Actiondispatch and js-routes 360 | 361 | ## v1.1.2 362 | 363 | * Bugfix support nested object null parameters #164 364 | * Bugfix support for nested optional parameters #162 #163 365 | 366 | ## v1.1.1 367 | 368 | * Bugfix regression in serialisation on blank strings caused by [#155](https://github.com/railsware/js-routes/pull/155/files) 369 | 370 | ## v1.1.0 371 | 372 | * Ensure routes are loaded, prior to generating them [#148](https://github.com/railsware/js-routes/pull/148) 373 | * Use `flat_map` rather than `map{...}.flatten` [#149](https://github.com/railsware/js-routes/pull/149) 374 | * URL escape routes.rb url to fix bad URI(is not URI?) error [#150](https://github.com/railsware/js-routes/pull/150) 375 | * Fix for rails 5 - test rails-edge on travis allowing failure [#151](https://github.com/railsware/js-routes/pull/151) 376 | * Adds `serializer` option [#155](https://github.com/railsware/js-routes/pull/155/files) 377 | 378 | ## v1.0.1 379 | 380 | * Support sprockets-3 381 | * Performance optimization of include/exclude options 382 | 383 | ## v1.0.0 384 | 385 | * Add the compact mode [#125](https://github.com/railsware/js-routes/pull/125) 386 | * Add support for host, protocol, and port configuration [#137](https://github.com/railsware/js-routes/pull/137) 387 | * Routes path specs [#135](https://github.com/railsware/js-routes/pull/135) 388 | * Support Rails 4.2 and Ruby 2.2 [#140](https://github.com/railsware/js-routes/pull/140) 389 | 390 | ## v0.9.9 391 | 392 | * Bugfix Rails Engine subapplication route generation when they are nested [#120](https://github.com/railsware/js-routes/pull/120) 393 | 394 | ## v0.9.8 395 | 396 | * Support AMD/Require.js [#111](https://github.com/railsware/js-routes/pull/111) 397 | * Support trailing slash [#106](https://github.com/railsware/js-routes/pull/106) 398 | 399 | ## v0.9.7 400 | 401 | * Depend on railties [#97](https://github.com/railsware/js-routes/pull/97) 402 | * Fix typeof error for IE [#95](https://github.com/railsware/js-routes/pull/95) 403 | * Fix testing on ruby-head [#92](https://github.com/railsware/js-routes/pull/92) 404 | * Correct thread safety issue in js-routes generation [#90](https://github.com/railsware/js-routes/pull/90) 405 | * Use the `of` operator to detect for `to_param` and `id` in objects [#87](https://github.com/railsware/js-routes/pull/87) 406 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # JsRoutes 2 | 3 | [![CI](https://github.com/railsware/js-routes/actions/workflows/ci.yml/badge.svg)](https://github.com/railsware/js-routes/actions/workflows/ci.yml) 4 | 5 | Logo 6 | 7 | Generates javascript file that defines all Rails named routes as javascript helpers: 8 | 9 | ``` js 10 | import { root_path, api_user_path } from './routes'; 11 | 12 | root_path() # => / 13 | api_user_path(25, include_profile: true, format: 'json') // => /api/users/25.json?include_profile=true 14 | ``` 15 | 16 | [More Examples](#usage) 17 | 18 | ## Intallation 19 | 20 | Your Rails Gemfile: 21 | 22 | ``` ruby 23 | gem "js-routes" 24 | ``` 25 | 26 | ## Setup 27 | 28 | There are several possible ways to setup JsRoutes: 29 | 30 | 1. [Quick and easy](#quick-start) - Recommended 31 | * Uses Rack Middleware to automatically update routes locally 32 | * Automatically generates routes files on javascript build 33 | * Works great for a simple Rails application 34 | 2. [Advanced Setup](#advanced-setup) 35 | * Allows very custom setups 36 | * Automatic updates need to be customized 37 | 3. [Webpacker ERB Loader](#webpacker) - Legacy 38 | * Requires ESM module system (the default) 39 | * Doesn't support typescript definitions 40 | 4. [Sprockets](#sprockets) - Legacy 41 | * Deprecated and not recommended for modern apps 42 | 43 |
44 | 45 | ### Quick Start 46 | 47 | Setup [Rack Middleware](https://guides.rubyonrails.org/rails_on_rack.html#action-dispatcher-middleware-stack) 48 | to automatically generate and maintain `routes.js` file and corresponding 49 | [Typescript definitions](https://www.typescriptlang.org/docs/handbook/declaration-files/templates/module-d-ts.html) `routes.d.ts`: 50 | 51 | #### Use a Generator 52 | 53 | Run a command: 54 | 55 | ``` sh 56 | rails generate js_routes:middleware 57 | ``` 58 | 59 | #### Setup Manually 60 | 61 | Add the following to `config/environments/development.rb`: 62 | 63 | ``` ruby 64 | config.middleware.use(JsRoutes::Middleware) 65 | ``` 66 | 67 | Use it in any JS file: 68 | 69 | ``` javascript 70 | import {post_path} from '../routes'; 71 | 72 | alert(post_path(1)) 73 | ``` 74 | 75 | Upgrade js building process to update js-routes files in `Rakefile`: 76 | 77 | ``` ruby 78 | task "javascript:build" => "js:routes" 79 | # For setups without jsbundling-rails 80 | task "assets:precompile" => "js:routes" 81 | ``` 82 | 83 | Add js-routes files to `.gitignore`: 84 | 85 | ``` 86 | /app/javascript/routes.js 87 | /app/javascript/routes.d.ts 88 | ``` 89 | 90 |
91 | 92 | ### Webpacker ERB loader 93 | 94 | **IMPORTANT**: the setup doesn't support IDE autocompletion with [Typescript](https://www.typescriptlang.org/docs/handbook/declaration-files/templates/module-d-ts.html) 95 | 96 | #### Use a Generator 97 | 98 | Run a command: 99 | 100 | ``` sh 101 | ./bin/rails generate js_routes:webpacker 102 | ``` 103 | 104 | #### Setup manually 105 | 106 | The routes files can be automatically updated without `rake` task being called manually. 107 | It requires [rails-erb-loader](https://github.com/usabilityhub/rails-erb-loader) npm package to work. 108 | 109 | Add `erb` loader to webpacker: 110 | 111 | ``` sh 112 | yarn add rails-erb-loader 113 | rm -f app/javascript/routes.js # delete static file if any 114 | ``` 115 | 116 | Create webpack ERB config `config/webpack/loaders/erb.js`: 117 | 118 | ``` javascript 119 | module.exports = { 120 | rules: [ 121 | { 122 | test: /\.erb$/, 123 | enforce: "pre", 124 | loader: "rails-erb-loader", 125 | }, 126 | ], 127 | }; 128 | ``` 129 | 130 | Enable `erb` extension in `config/webpack/environment.js`: 131 | 132 | ``` javascript 133 | const erb = require('./loaders/erb') 134 | environment.loaders.append('erb', erb) 135 | ``` 136 | 137 | Create routes file `app/javascript/routes.js.erb`: 138 | 139 | ``` erb 140 | <%= JsRoutes.generate() %> 141 | ``` 142 | 143 | Use routes wherever you need them: 144 | 145 | ``` javascript 146 | import {post_path} from 'routes.js.erb'; 147 | 148 | alert(post_path(2)); 149 | ``` 150 | 151 |
152 | 153 | ### Advanced Setup 154 | 155 | In case you need multiple route files for different parts of your application, there are low level methods: 156 | 157 | ``` ruby 158 | # Returns a routes file as a string 159 | JsRoutes.generate(options) 160 | # Writes routes to specific file location 161 | JsRoutes.generate!(file_name, options) 162 | # Writes Typescript definitions file for routes 163 | JsRoutes.definitions!(file_name, options) 164 | ``` 165 | 166 | They can also be used in ERB context 167 | 168 | ``` erb 169 | 174 | ``` 175 | 176 | Routes can be returns via API: 177 | 178 | ``` ruby 179 | class Api::RoutesController < Api::BaseController 180 | def index 181 | options = { 182 | include: /\Aapi_/, 183 | default_url_options: { format: 'json' }, 184 | } 185 | render json: { 186 | routes: { 187 | source: JsRoutes.generate(options), 188 | definitions: JsRoutes.definitions(options), 189 | } 190 | } 191 | end 192 | end 193 | 194 | ``` 195 | 196 | Default auto-update middleware for development 197 | doesn't support configuration out of the box, 198 | but it can be extended through inheritence: 199 | 200 | ``` ruby 201 | class AdvancedJsRoutesMiddleware < JsRoutes::Middleware 202 | def regenerate 203 | path = Rails.root.join("app/javascript") 204 | 205 | JsRoutes.generate!( 206 | "#{path}/app_routes.js", exclude: [/^admin_/, /^api_/] 207 | ) 208 | JsRoutes.generate!( 209 | "#{path}/adm_routes.js", include: /^admin_/ 210 | ) 211 | JsRoutes.generate!( 212 | "#{path}/api_routes.js", include: /^api_/, default_url_options: {format: "json"} 213 | ) 214 | end 215 | end 216 | ``` 217 | 218 |
219 | 220 | #### Typescript Definitions 221 | 222 | JsRoutes has typescript support out of the box. 223 | 224 | Restrictions: 225 | 226 | * Only available if `module_type` is set to `ESM` (strongly recommended and default). 227 | * Webpacker Automatic Updates are not available because typescript compiler can not be configured to understand `.erb` extensions. 228 | 229 | For the basic setup of typscript definitions see [Quick Start](#quick-start) setup. 230 | More advanced setup would involve calling manually: 231 | 232 | ``` ruby 233 | JsRoutes.definitions! # to output to file 234 | # or 235 | JsRoutes.definitions # to output to string 236 | ``` 237 | 238 | Even more advanced setups can be achieved by setting `module_type` to `DTS` inside [configuration](#module_type) 239 | which will cause any `JsRoutes` instance to generate defintions instead of routes themselves. 240 | 241 |
242 | 243 | ### Sprockets (Deprecated) 244 | 245 | If you are using [Sprockets](https://github.com/rails/sprockets-rails) you may configure js-routes in the following way. 246 | 247 | Setup the initializer (e.g. `config/initializers/js_routes.rb`): 248 | 249 | ``` ruby 250 | JsRoutes.setup do |config| 251 | config.module_type = nil 252 | config.namespace = 'Routes' 253 | end 254 | ``` 255 | 256 | Require JsRoutes in `app/assets/javascripts/application.js` or other bundle 257 | 258 | ``` js 259 | //= require js-routes 260 | ``` 261 | 262 | Also in order to flush asset pipeline cache sometimes you might need to run: 263 | 264 | ``` sh 265 | rake tmp:cache:clear 266 | ``` 267 | 268 | This cache is not flushed on server restart in development environment. 269 | 270 | **Important:** If routes.js file is not updated after some configuration change you need to run this rake task again. 271 | 272 | ## Configuration 273 | 274 | You can configure JsRoutes in two main ways. Either with an initializer (e.g. `config/initializers/js_routes.rb`): 275 | 276 | ``` ruby 277 | JsRoutes.setup do |config| 278 | config.option = value 279 | end 280 | ``` 281 | 282 | Or dynamically in JavaScript, although only [Formatter Options](#formatter-options) are supported: 283 | 284 | ``` js 285 | import {configure, config} from 'routes' 286 | 287 | configure({ 288 | option: value 289 | }); 290 | config(); // current config 291 | ``` 292 | 293 | ### Available Options 294 | 295 | #### Generator Options 296 | 297 | Options to configure JavaScript file generator. These options are only available in Ruby context but not JavaScript. 298 | 299 |
300 | 301 | * `module_type` - JavaScript module type for generated code. [Article](https://dev.to/iggredible/what-the-heck-are-cjs-amd-umd-and-esm-ikm) 302 | * Options: `ESM`, `UMD`, `CJS`, `AMD`, `DTS`, `nil`. 303 | * Default: `ESM` 304 | * `nil` option can be used in case you don't want generated code to export anything. 305 | * `documentation` - specifies if each route should be annotated with [JSDoc](https://jsdoc.app/) comment 306 | * Default: `true` 307 | * `exclude` - Array of regexps to exclude from routes. 308 | * Default: `[]` 309 | * The regexp applies only to the name before the `_path` suffix, eg: you want to match exactly `settings_path`, the regexp should be `/^settings$/` 310 | * `include` - Array of regexps to include in routes. 311 | * Default: `[]` 312 | * The regexp applies only to the name before the `_path` suffix, eg: you want to match exactly `settings_path`, the regexp should be `/^settings$/` 313 | * `namespace` - global object used to access routes. 314 | * Only available if `module_type` option is set to `nil`. 315 | * Supports nested namespace like `MyProject.routes` 316 | * Default: `nil` 317 | * `camel_case` - specifies if route helpers should be generated in camel case instead of underscore case. 318 | * Default: `false` 319 | * `url_links` - specifies if `*_url` helpers should be generated (in addition to the default `*_path` helpers). 320 | * Default: `false` 321 | * Note: generated URLs will first use the protocol, host, and port options specified in the route definition. Otherwise, the URL will be based on the option specified in the `default_url_options` config. If no default option has been set, then the URL will fallback to the current URL based on `window.location`. 322 | * `compact` - Remove `_path` suffix in path routes(`*_url` routes stay untouched if they were enabled) 323 | * Default: `false` 324 | * Sample route call when option is set to true: `users() // => /users` 325 | * `application` - a key to specify which rails engine you want to generate routes too. 326 | * This option allows to only generate routes for a specific rails engine, that is mounted into routes instead of all Rails app routes 327 | * It is recommended to wrap the value with `lambda`. This will reduce the reliance on order during initialization your application. 328 | * Default: `-> { Rails.application }` 329 | * `file` - a file location where generated routes are stored 330 | * Default: `app/javascript/routes.js` if setup with Webpacker, otherwise `app/assets/javascripts/routes.js` if setup with Sprockets. 331 | * `optional_definition_params` - make all route paramters in definition optional 332 | * See [related compatibility issue](#optional-definition-params) 333 | * Default: `false` 334 | * `banner` - specify a JSDoc comment on top of the file. 335 | * It is not stripped by minifiers by default and helps to originate the content when debugging the build. 336 | * You may want to control how much information from backend is exposed to potential attacker at the cost of your own comfort. 337 | * See [JSDoc Guide](https://github.com/shri/JSDoc-Style-Guide/blob/master/README.md#files) 338 | * Supports a lazy generation with `Proc`. 339 | * Default: A string that generates the following: 340 | 341 | ``` 342 | /** 343 | * File generated by js-routes 2.3.1 on 2024-12-04 09:45:59 +0100 344 | * Based on Rails 7.2.0 routes of App 345 | */ 346 | ``` 347 | 348 |
349 | 350 | #### Formatter Options 351 | 352 | Options to configure routes formatting. These options are available both in Ruby and JavaScript context. 353 | 354 | * `default_url_options` - default parameters used when generating URLs 355 | * Example: `{format: "json", trailing_slash: true, protocol: "https", subdomain: "api", host: "example.com", port: 3000}` 356 | * See [`url_for` doc](https://api.rubyonrails.org/classes/ActionDispatch/Routing/UrlFor.html#method-i-url_for) for list of supported options 357 | * Default: `{}` 358 | * `serializer` - a JS function that serializes a Javascript Hash object into URL paramters like `{a: 1, b: 2} => "a=1&b=2"`. 359 | * Default: `nil`. Uses built-in serializer compatible with Rails 360 | * Example: `jQuery.param` - use jQuery's serializer algorithm. You can attach serialize function from your favorite AJAX framework. 361 | * Example: `function (object) { ... }` - use completely custom serializer of your application. 362 | * `special_options_key` - a special key that helps JsRoutes to destinguish serialized model from options hash 363 | * This option exists because JS doesn't provide a difference between an object and a hash 364 | * Default: `_options` 365 | 366 | 367 | ## Usage 368 | 369 | Configuration above will create a nice javascript file with `Routes` object that has all the rails routes available: 370 | 371 | ``` js 372 | import { 373 | user_path, user_project_path, company_path 374 | } from 'routes'; 375 | 376 | users_path() 377 | // => "/users" 378 | 379 | user_path(1) 380 | // => "/users/1" 381 | 382 | user_path(1, {format: 'json'}) 383 | // => "/users/1.json" 384 | 385 | user_path(1, {anchor: 'profile'}) 386 | // => "/users/1#profile" 387 | 388 | new_user_project_path(1, {format: 'json'}) 389 | // => "/users/1/projects/new.json" 390 | 391 | user_project_path(1,2, {q: 'hello', custom: true}) 392 | // => "/users/1/projects/2?q=hello&custom=true" 393 | 394 | user_project_path(1,2, {hello: ['world', 'mars']}) 395 | // => "/users/1/projects/2?hello%5B%5D=world&hello%5B%5D=mars" 396 | 397 | var google = {id: 1, name: "Google"}; 398 | company_path(google) 399 | // => "/companies/1" 400 | 401 | var google = {id: 1, name: "Google", to_param: "google"}; 402 | company_path(google) 403 | // => "/companies/google" 404 | ``` 405 | 406 | In order to make routes helpers available globally: 407 | 408 | ``` js 409 | import * as Routes from '../routes'; 410 | jQuery.extend(window, Routes) 411 | ``` 412 | 413 | ### Get spec of routes and required params 414 | 415 | Possible to get `spec` of route by function `toString`: 416 | 417 | ```js 418 | import {user_path, users_path} from '../routes' 419 | 420 | users_path.toString() // => "/users(.:format)" 421 | user_path.toString() // => "/users/:id(.:format)" 422 | ``` 423 | 424 | 425 | Route function also contain method `requiredParams` inside which returns required param names array: 426 | 427 | ```js 428 | users_path.requiredParams() // => [] 429 | user_path.requiredParams() // => ['id'] 430 | ``` 431 | 432 | 433 | ## Rails Compatibility 434 | 435 | JsRoutes tries to replicate the Rails routing API as closely as possible. 436 | There are only 2 known issues with compatibility that happen very rarely and have their workarounds. 437 | 438 | If you find any incompatibilities outside of ones below, please [open an issue](https://github.com/railsware/js-routes/issues/new). 439 | 440 | ### Object and Hash distinction issue 441 | 442 | Sometimes the destinction between JS Hash and Object can not be found by JsRoutes. 443 | In this case you would need to pass a special key to help: 444 | 445 | ``` js 446 | import {company_project_path} from '../routes' 447 | 448 | company_project_path({company_id: 1, id: 2}) 449 | // => Not enough parameters 450 | company_project_path({company_id: 1, id: 2, _options: true}) 451 | // => "/companies/1/projects/2" 452 | ``` 453 | 454 | Use `special_options_key` to configure the `_options` parameter name. 455 | 456 |
457 | 458 | ### Rails required parameters specified as optional 459 | 460 | Rails is very flexible on how route parameters can be specified. 461 | All of the following calls will make the same result: 462 | 463 | ``` ruby 464 | # Given route 465 | # /inboxes/:inbox_id/messages/:message_id/attachments/:id 466 | # every call below returns: 467 | # => "/inboxes/1/messages/2/attachments/3" 468 | 469 | inbox_message_attachment_path(1, 2, 3) 470 | inbox_message_attachment_path(1, 2, id: 3) 471 | inbox_message_attachment_path(1, message_id: 2, id: 3) 472 | inbox_message_attachment_path(inbox_id: 1, message_id: 2, id: 3) 473 | 474 | # including these mad versions 475 | inbox_message_attachment_path(2, inbox_id: 1, id: 3) 476 | inbox_message_attachment_path(1, 3, message_id: 2) 477 | inbox_message_attachment_path(3, inbox_id: 1, message_id: 2) 478 | ``` 479 | 480 | While all of these methods are supported by JsRoutes, it is impossible to support them in `DTS` type definitions. 481 | If you are using routes like this, use the following configuration that will prevent required parameters presence to be validated by definition: 482 | 483 | ``` ruby 484 | JsRoutes.setup do |c| 485 | c.optional_definition_params = true 486 | end 487 | ``` 488 | 489 | This will enforce the following route signature: 490 | 491 | ``` typescript 492 | export const inbox_message_attachment_path: (( 493 | inbox_id?: RequiredRouteParameter, 494 | message_id?: RequiredRouteParameter, 495 | id?: RequiredRouteParameter, 496 | options?: RouteOptions 497 | ) => string) & RouteHelperExtras; 498 | ``` 499 | 500 | That will make every call above valid. 501 | 502 | ## What about security? 503 | 504 | JsRoutes itself does not have security holes. 505 | It makes URLs without access protection more reachable by potential attacker. 506 | If that is an issue for you, you may use one of the following solutions: 507 | 508 | ### ESM Tree shaking 509 | 510 | Make sure `module_type` is set to `ESM` (the default). Modern JS bundlers like 511 | [Webpack](https://webpack.js.org) can statically determine which ESM exports are used, and remove 512 | the unused exports to reduce bundle size. This is known as [Tree 513 | Shaking](https://webpack.js.org/guides/tree-shaking/). 514 | 515 | JS files can use named imports to import only required routes into the file, like: 516 | 517 | ``` javascript 518 | import { 519 | inbox_path, 520 | inboxes_path, 521 | inbox_message_path, 522 | inbox_attachment_path, 523 | user_path, 524 | } from '../routes' 525 | ``` 526 | 527 | JS files can also use star imports (`import * as`) for tree shaking, as long as only explicit property accesses are used. 528 | 529 | ``` javascript 530 | import * as routes from '../routes'; 531 | 532 | console.log(routes.inbox_path); // OK, only `inbox_path` is included in the bundle 533 | 534 | console.log(Object.keys(routes)); // forces bundler to include all exports, breaking tree shaking 535 | ``` 536 | 537 | ### Exclude/Include options 538 | 539 | Split your routes into multiple files related to each section of your website like: 540 | 541 | ``` ruby 542 | JsRoutes.generate!('app/javascript/admin-routes.js', include: /^admin_/) %> 543 | JsRoutes.generate!('app/javascript/app-routes.js', exclude: /^admin_/) %> 544 | ``` 545 | 546 | ## Advantages over alternatives 547 | 548 | There are some alternatives available. Most of them has only basic feature and don't reach the level of quality I accept. 549 | Advantages of this one are: 550 | 551 | * Actively maintained 552 | * [ESM Tree shaking](https://webpack.js.org/guides/tree-shaking/) support 553 | * Rich options set 554 | * Full rails compatibility 555 | * Support Rails `#to_param` convention for seo optimized paths 556 | * Well tested 557 | -------------------------------------------------------------------------------- /spec/js_routes/options_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe JsRoutes, "options" do 4 | 5 | let(:generated_js) do 6 | JsRoutes.generate( 7 | module_type: nil, 8 | namespace: 'Routes', 9 | **_options 10 | ) 11 | end 12 | 13 | before(:each) do 14 | evaljs(_presetup) if _presetup 15 | with_warnings(_warnings) do 16 | evaljs(generated_js) 17 | App.routes.default_url_options = _options[:default_url_options] || {} 18 | end 19 | end 20 | 21 | after(:each) do 22 | App.routes.default_url_options = {} 23 | end 24 | 25 | let(:_presetup) { nil } 26 | let(:_options) { {} } 27 | let(:_warnings) { true } 28 | 29 | describe "serializer" do 30 | context "when specified" do 31 | # define custom serializer 32 | # this is a nonsense serializer, which always returns foo=bar 33 | # for all inputs 34 | let(:_presetup){ %q(function myCustomSerializer(object, prefix) { return "foo=bar"; }) } 35 | let(:_options) { {:serializer => "myCustomSerializer"} } 36 | 37 | it "should set configurable serializer" do 38 | # expect the nonsense serializer above to have appened foo=bar 39 | # to the end of the path 40 | expectjs(%q(Routes.inboxes_path())).to eql("/inboxes?foo=bar") 41 | end 42 | end 43 | 44 | context "when specified, but not function" do 45 | let(:_presetup){ %q(var myCustomSerializer = 1) } 46 | let(:_options) { {:serializer => "myCustomSerializer"} } 47 | 48 | it "should throw error" do 49 | expect { 50 | evaljs(%q(Routes.inboxes_path({a: 1}))) 51 | }.to raise_error(js_error_class) 52 | end 53 | end 54 | 55 | context "when configured in js" do 56 | let(:_options) { {:serializer =>%q(function (object, prefix) { return "foo=bar"; })} } 57 | 58 | it "uses JS serializer" do 59 | evaljs("Routes.configure({serializer: function (object, prefix) { return 'bar=baz'; }})") 60 | expectjs(%q(Routes.inboxes_path({a: 1}))).to eql("/inboxes?bar=baz") 61 | end 62 | end 63 | end 64 | 65 | context "when exclude is specified" do 66 | 67 | let(:_options) { {exclude: [/^admin_/]} } 68 | 69 | it "should exclude specified routes from file" do 70 | expectjs("Routes.admin_users_path").to be_nil 71 | end 72 | 73 | it "should not exclude routes not under specified pattern" do 74 | expectjs("Routes.inboxes_path()").not_to be_nil 75 | end 76 | 77 | context "for rails engine" do 78 | let(:_options) { {:exclude => [/^blog_app_posts/]} } 79 | 80 | it "should exclude specified engine route" do 81 | expectjs("Routes.blog_app_posts_path").to be_nil 82 | end 83 | end 84 | end 85 | 86 | context "when include is specified" do 87 | 88 | let(:_options) { {include: [/^admin_/]} } 89 | 90 | it "should exclude specified routes from file" do 91 | expectjs("Routes.admin_users_path()").not_to be_nil 92 | end 93 | 94 | it "should not exclude routes not under specified pattern" do 95 | expectjs("Routes.inboxes_path").to be_nil 96 | end 97 | 98 | context "with camel_case option" do 99 | let(:_options) { {include: [/^admin_/], camel_case: true} } 100 | 101 | it "should exclude specified routes from file" do 102 | expectjs("Routes.adminUsersPath()").not_to be_nil 103 | end 104 | 105 | it "should not exclude routes not under specified pattern" do 106 | expectjs("Routes.inboxesPath").to be_nil 107 | end 108 | end 109 | 110 | context "for rails engine" do 111 | let(:_options) { {include: [/^blog_app_posts/]} } 112 | 113 | it "should include specified engine route" do 114 | expectjs("Routes.blog_app_posts_path()").not_to be_nil 115 | end 116 | end 117 | end 118 | 119 | describe "prefix option" do 120 | around do |e| 121 | JsRoutes::Utils.deprecator.silence do 122 | e.run 123 | end 124 | end 125 | 126 | context "with trailing slash is specified" do 127 | let(:_options) { {:prefix => "/myprefix/" } } 128 | 129 | it "should render routing with prefix" do 130 | expectjs("Routes.inbox_path(1)").to eq("/myprefix#{test_routes.inbox_path(1)}") 131 | end 132 | 133 | it "should render routing with prefix set in JavaScript" do 134 | evaljs("Routes.configure({prefix: '/newprefix/'})") 135 | expectjs("Routes.config().prefix").to eq("/newprefix/") 136 | expectjs("Routes.inbox_path(1)").to eq("/newprefix#{test_routes.inbox_path(1)}") 137 | end 138 | end 139 | 140 | context "with http:// is specified" do 141 | let(:_options) { {:prefix => "http://localhost:3000" } } 142 | 143 | it "should render routing with prefix" do 144 | expectjs("Routes.inbox_path(1)").to eq(_options[:prefix] + test_routes.inbox_path(1)) 145 | end 146 | end 147 | 148 | context "without trailing slash is specified" do 149 | let(:_options) { {:prefix => "/myprefix" } } 150 | 151 | it "should render routing with prefix" do 152 | expectjs("Routes.inbox_path(1)").to eq("/myprefix#{test_routes.inbox_path(1)}") 153 | end 154 | 155 | it "should render routing with prefix set in JavaScript" do 156 | evaljs("Routes.configure({prefix: '/newprefix/'})") 157 | expectjs("Routes.inbox_path(1)").to eq("/newprefix#{test_routes.inbox_path(1)}") 158 | end 159 | end 160 | 161 | context "combined with url links and default_url_options" do 162 | let(:_options) { { :prefix => "/api", :url_links => true, :default_url_options => {:host => 'example.com'} } } 163 | it "should generate path and url links" do 164 | expectjs("Routes.inbox_url(1)").to eq("http://example.com/api#{test_routes.inbox_path(1)}") 165 | end 166 | end 167 | end 168 | 169 | context "when default format is specified" do 170 | let(:_options) { {:default_url_options => {format: "json"}} } 171 | let(:_warnings) { nil } 172 | 173 | it "should render routing with default_format" do 174 | expectjs("Routes.inbox_path(1)").to eq(test_routes.inbox_path(1)) 175 | end 176 | 177 | it "should render routing with default_format and zero object" do 178 | expectjs("Routes.inbox_path(0)").to eq(test_routes.inbox_path(0)) 179 | end 180 | 181 | it "should override default_format when spefified implicitly" do 182 | expectjs("Routes.inbox_path(1, {format: 'xml'})").to eq(test_routes.inbox_path(1, :format => "xml")) 183 | end 184 | 185 | it "should override nullify implicitly when specified implicitly" do 186 | expectjs("Routes.inbox_path(1, {format: null})").to eq(test_routes.inbox_path(1, format: nil)) 187 | end 188 | 189 | it "shouldn't require the format" do 190 | expectjs("Routes.json_only_path()").to eq(test_routes.json_only_path) 191 | end 192 | end 193 | 194 | it "shouldn't include the format when {:format => false} is specified" do 195 | expectjs("Routes.no_format_path()").to eq(test_routes.no_format_path()) 196 | expectjs("Routes.no_format_path({format: 'json'})").to eq(test_routes.no_format_path(format: 'json')) 197 | end 198 | 199 | describe "default_url_options" do 200 | context "with optional route parts" do 201 | context "provided by the default_url_options" do 202 | let(:_options) { { :default_url_options => { :optional_id => "12", :format => "json" } } } 203 | it "should use this options to fill optional parameters" do 204 | expectjs("Routes.things_path()").to eq(test_routes.things_path(12)) 205 | end 206 | end 207 | 208 | context "provided inline by the method parameters" do 209 | let(:options) { { :default_url_options => { :optional_id => "12" } } } 210 | it "should overwrite the default_url_options" do 211 | expectjs("Routes.things_path({ optional_id: 34 })").to eq(test_routes.things_path(optional_id: 34)) 212 | end 213 | end 214 | 215 | context "not provided" do 216 | let(:_options) { { :default_url_options => { :format => "json" } } } 217 | it "breaks" do 218 | expectjs("Routes.foo_all_path()").to eq(test_routes.foo_all_path) 219 | end 220 | end 221 | end 222 | 223 | context "with required route parts" do 224 | let(:_options) { { :default_url_options => { :inbox_id => "12" } } } 225 | it "should use this options to fill optional parameters" do 226 | expectjs("Routes.inbox_messages_path()").to eq(test_routes.inbox_messages_path) 227 | end 228 | end 229 | 230 | context "with optional and required route parts" do 231 | let(:_options) { {:default_url_options => { :optional_id => "12" } } } 232 | it "should use this options to fill the optional parameters" do 233 | expectjs("Routes.thing_path(1)").to eq test_routes.thing_path(1, { optional_id: "12" }) 234 | end 235 | 236 | context "when passing options that do not have defaults" do 237 | it "should use this options to fill the optional parameters" do 238 | # test_routes.thing_path needs optional_id here to generate the correct route. Not sure why. 239 | expectjs("Routes.thing_path(1, { format: 'json' })").to eq test_routes.thing_path(1, { optional_id: "12", format: "json" } ) 240 | end 241 | end 242 | end 243 | 244 | describe "script_name option" do 245 | let(:_options) { { default_url_options: { script_name: "/myapp" } }} 246 | it "is supported" do 247 | expectjs("Routes.inboxes_path()").to eq( 248 | test_routes.inboxes_path(script_name: '/myapp') 249 | ) 250 | end 251 | end 252 | 253 | context "when overwritten on JS level" do 254 | let(:_options) { { :default_url_options => { :format => "json" } } } 255 | it "uses JS defined value" do 256 | evaljs("Routes.configure({default_url_options: {format: 'xml'}})") 257 | expectjs("Routes.inboxes_path()").to eq(test_routes.inboxes_path(format: 'xml')) 258 | end 259 | end 260 | end 261 | 262 | describe "trailing_slash" do 263 | context "with default option" do 264 | let(:_options) { Hash.new } 265 | it "should working in params" do 266 | expectjs("Routes.inbox_path(1, {trailing_slash: true})").to eq(test_routes.inbox_path(1, :trailing_slash => true)) 267 | end 268 | 269 | it "should working with additional params" do 270 | expectjs("Routes.inbox_path(1, {trailing_slash: true, test: 'params'})").to eq(test_routes.inbox_path(1, :trailing_slash => true, :test => 'params')) 271 | end 272 | end 273 | 274 | context "with default_url_options option" do 275 | let(:_options) { {:default_url_options => {:trailing_slash => true}} } 276 | it "should working" do 277 | expectjs("Routes.inbox_path(1, {test: 'params'})").to eq(test_routes.inbox_path(1, :trailing_slash => true, :test => 'params')) 278 | end 279 | 280 | it "should remove it by params" do 281 | expectjs("Routes.inbox_path(1, {trailing_slash: false})").to eq(test_routes.inbox_path(1, trailing_slash: false)) 282 | end 283 | end 284 | 285 | context "with disabled default_url_options option" do 286 | let(:_options) { {:default_url_options => {:trailing_slash => false}} } 287 | it "should not use trailing_slash" do 288 | expectjs("Routes.inbox_path(1, {test: 'params'})").to eq(test_routes.inbox_path(1, :test => 'params')) 289 | end 290 | 291 | it "should use it by params" do 292 | expectjs("Routes.inbox_path(1, {trailing_slash: true})").to eq(test_routes.inbox_path(1, :trailing_slash => true)) 293 | end 294 | end 295 | end 296 | 297 | describe "camel_case" do 298 | context "with default option" do 299 | let(:_options) { Hash.new } 300 | it "should use snake case routes" do 301 | expectjs("Routes.inbox_path(1)").to eq(test_routes.inbox_path(1)) 302 | expectjs("Routes.inboxPath").to be_nil 303 | end 304 | end 305 | 306 | context "with true" do 307 | let(:_options) { { :camel_case => true } } 308 | it "should generate camel case routes" do 309 | expectjs("Routes.inbox_path").to be_nil 310 | expectjs("Routes.inboxPath").not_to be_nil 311 | expectjs("Routes.inboxPath(1)").to eq(test_routes.inbox_path(1)) 312 | expectjs("Routes.inboxMessagesPath(10)").to eq(test_routes.inbox_messages_path(:inbox_id => 10)) 313 | end 314 | end 315 | end 316 | 317 | describe "url_links" do 318 | context "with default option" do 319 | let(:_options) { Hash.new } 320 | it "should generate only path links" do 321 | expectjs("Routes.inbox_path(1)").to eq(test_routes.inbox_path(1)) 322 | expectjs("Routes.inbox_url").to be_nil 323 | end 324 | end 325 | 326 | context "when configuring with default_url_options" do 327 | context "when only host option is specified" do 328 | let(:_options) { { :url_links => true, :default_url_options => {:host => "example.com"} } } 329 | 330 | it "uses the specified host, defaults protocol to http, defaults port to 80 (leaving it blank)" do 331 | expectjs("Routes.inbox_url(1)").to eq("http://example.com#{test_routes.inbox_path(1)}") 332 | end 333 | 334 | it "does not override protocol when specified in route" do 335 | expectjs("Routes.new_session_url()").to eq("https://example.com#{test_routes.new_session_path}") 336 | end 337 | 338 | it "does not override host when specified in route" do 339 | expectjs("Routes.sso_url()").to eq(test_routes.sso_url) 340 | end 341 | 342 | it "does not override port when specified in route" do 343 | expectjs("Routes.portals_url()").to eq("http://example.com:8080#{test_routes.portals_path}") 344 | end 345 | end 346 | 347 | context "when default host and protocol are specified" do 348 | let(:_options) { { :url_links => true, :default_url_options => {:host => "example.com", :protocol => "ftp"} } } 349 | 350 | it "uses the specified protocol and host, defaults port to 80 (leaving it blank)" do 351 | expectjs("Routes.inbox_url(1)").to eq("ftp://example.com#{test_routes.inbox_path(1)}") 352 | end 353 | 354 | it "does not override protocol when specified in route" do 355 | expectjs("Routes.new_session_url()").to eq("https://example.com#{test_routes.new_session_path}") 356 | end 357 | 358 | it "does not override host when host is specified in route" do 359 | expectjs("Routes.sso_url()").to eq("ftp://sso.example.com#{test_routes.sso_path}") 360 | end 361 | 362 | it "does not override port when specified in route" do 363 | expectjs("Routes.portals_url()").to eq("ftp://example.com:8080#{test_routes.portals_path}") 364 | end 365 | end 366 | 367 | context "when default host and port are specified" do 368 | let(:_options) { { :url_links => true, :default_url_options => {:host => "example.com", :port => 3000} } } 369 | 370 | it "uses the specified host and port, defaults protocol to http" do 371 | expectjs("Routes.inbox_url(1)").to eq("http://example.com:3000#{test_routes.inbox_path(1)}") 372 | end 373 | 374 | it "does not override protocol when specified in route" do 375 | expectjs("Routes.new_session_url()").to eq("https://example.com:3000#{test_routes.new_session_path}") 376 | end 377 | 378 | it "does not override host, protocol, or port when host is specified in route" do 379 | expectjs("Routes.sso_url()").to eq("http://sso.example.com:3000" + test_routes.sso_path) 380 | end 381 | 382 | it "does not override parts when specified in route" do 383 | expectjs("Routes.secret_root_url()").to eq(test_routes.secret_root_url) 384 | end 385 | end 386 | 387 | context "with camel_case option" do 388 | let(:_options) { { :camel_case => true, :url_links => true, :default_url_options => {:host => "example.com"} } } 389 | it "should generate path and url links" do 390 | expectjs("Routes.inboxUrl(1)").to eq("http://example.com#{test_routes.inbox_path(1)}") 391 | end 392 | end 393 | 394 | context "with compact option" do 395 | let(:_options) { { :compact => true, :url_links => true, :default_url_options => {:host => 'example.com'} } } 396 | it "does not affect url helpers" do 397 | expectjs("Routes.inbox_url(1)").to eq("http://example.com#{test_routes.inbox_path(1)}") 398 | end 399 | end 400 | end 401 | 402 | context 'when window.location is present' do 403 | let(:current_protocol) { 'http:' } # window.location.protocol includes the colon character 404 | let(:current_hostname) { 'current.example.com' } 405 | let(:current_port){ '' } # an empty string means port 80 406 | let(:current_host) do 407 | host = "#{current_hostname}" 408 | host += ":#{current_port}" unless current_port == '' 409 | host 410 | end 411 | 412 | let(:_presetup) do 413 | location = { 414 | protocol: current_protocol, 415 | hostname: current_hostname, 416 | port: current_port, 417 | host: current_host, 418 | } 419 | [ 420 | "const window = this;", 421 | "window.location = #{ActiveSupport::JSON.encode(location)};", 422 | ].join("\n") 423 | end 424 | 425 | context "without specifying a default host" do 426 | let(:_options) { { :url_links => true } } 427 | 428 | it "uses the current host" do 429 | expectjs("Routes.inbox_path").not_to be_nil 430 | expectjs("Routes.inbox_url").not_to be_nil 431 | expectjs("Routes.inbox_url(1)").to eq("http://current.example.com#{test_routes.inbox_path(1)}") 432 | expectjs("Routes.inbox_url(1, { test_key: \"test_val\" })").to eq("http://current.example.com#{test_routes.inbox_path(1, :test_key => "test_val")}") 433 | expectjs("Routes.new_session_url()").to eq("https://current.example.com#{test_routes.new_session_path}") 434 | end 435 | 436 | it "doesn't use current when specified in the route" do 437 | expectjs("Routes.sso_url()").to eq(test_routes.sso_url) 438 | end 439 | 440 | it "uses host option as an argument" do 441 | expectjs("Routes.secret_root_url({host: 'another.com'})").to eq(test_routes.secret_root_url(host: 'another.com')) 442 | end 443 | 444 | it "uses port option as an argument" do 445 | expectjs("Routes.secret_root_url({host: 'localhost', port: 8080})").to eq(test_routes.secret_root_url(host: 'localhost', port: 8080)) 446 | end 447 | 448 | it "uses protocol option as an argument" do 449 | expectjs("Routes.secret_root_url({host: 'localhost', protocol: 'https'})").to eq(test_routes.secret_root_url(protocol: 'https', host: 'localhost')) 450 | end 451 | 452 | it "uses subdomain option as an argument" do 453 | expectjs("Routes.secret_root_url({subdomain: 'custom'})").to eq(test_routes.secret_root_url(subdomain: 'custom')) 454 | end 455 | end 456 | end 457 | 458 | context 'when window.location is not present' do 459 | context 'without specifying a default host' do 460 | let(:_options) { { url_links: true } } 461 | 462 | it 'generates path' do 463 | expectjs("Routes.inbox_url(1)").to eq test_routes.inbox_path(1) 464 | expectjs("Routes.new_session_url()").to eq test_routes.new_session_path 465 | end 466 | end 467 | end 468 | end 469 | 470 | describe "when the compact mode is enabled" do 471 | let(:_options) { { :compact => true } } 472 | it "removes _path suffix from path helpers" do 473 | expectjs("Routes.inbox_path").to be_nil 474 | expectjs("Routes.inboxes()").to eq(test_routes.inboxes_path()) 475 | expectjs("Routes.inbox(2)").to eq(test_routes.inbox_path(2)) 476 | end 477 | 478 | context "with url_links option" do 479 | let(:_options) { { :compact => true, :url_links => true, default_url_options: {host: 'localhost'} } } 480 | it "should not strip urls" do 481 | expectjs("Routes.inbox(1)").to eq(test_routes.inbox_path(1)) 482 | expectjs("Routes.inbox_url(1)").to eq("http://localhost#{test_routes.inbox_path(1)}") 483 | end 484 | end 485 | end 486 | 487 | describe "special_options_key" do 488 | let(:_options) { { special_options_key: :__options__ } } 489 | it "can be redefined" do 490 | expect { 491 | expectjs("Routes.inbox_message_path({inbox_id: 1, id: 2, _options: true})").to eq("") 492 | }.to raise_error(js_error_class) 493 | expectjs("Routes.inbox_message_path({inbox_id: 1, id: 2, __options__: true})").to eq(test_routes.inbox_message_path(inbox_id: 1, id: 2)) 494 | end 495 | end 496 | 497 | describe "when application is specified" do 498 | context "as proc" do 499 | let(:_options) { {application: -> {BlogEngine::Engine}} } 500 | 501 | it "should include specified engine route" do 502 | expectjs("Routes.posts_path()").not_to be_nil 503 | end 504 | end 505 | 506 | context "directly" do 507 | let(:_options) { {application: BlogEngine::Engine} } 508 | 509 | it "should include specified engine route" do 510 | expectjs("Routes.posts_path()").not_to be_nil 511 | end 512 | 513 | end 514 | end 515 | 516 | describe "documentation option" do 517 | let(:_options) { {documentation: false} } 518 | 519 | it "disables documentation generation" do 520 | expect(generated_js).not_to include("@param") 521 | expect(generated_js).not_to include("@returns") 522 | end 523 | end 524 | 525 | describe "banner option" do 526 | let(:_options) { {banner: nil} } 527 | 528 | it "disables banner generation" do 529 | expect(generated_js).not_to include("File generated by js-routes") 530 | expect(generated_js).not_to include("Based on Rails") 531 | end 532 | end 533 | end 534 | -------------------------------------------------------------------------------- /lib/routes.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | RubyVariables.WRAPPER( 3 | // eslint-disable-next-line 4 | () => { 5 | const hasProp = (value, key) => Object.prototype.hasOwnProperty.call(value, key); 6 | let NodeTypes; 7 | (function (NodeTypes) { 8 | NodeTypes[NodeTypes["GROUP"] = 1] = "GROUP"; 9 | NodeTypes[NodeTypes["CAT"] = 2] = "CAT"; 10 | NodeTypes[NodeTypes["SYMBOL"] = 3] = "SYMBOL"; 11 | NodeTypes[NodeTypes["OR"] = 4] = "OR"; 12 | NodeTypes[NodeTypes["STAR"] = 5] = "STAR"; 13 | NodeTypes[NodeTypes["LITERAL"] = 6] = "LITERAL"; 14 | NodeTypes[NodeTypes["SLASH"] = 7] = "SLASH"; 15 | NodeTypes[NodeTypes["DOT"] = 8] = "DOT"; 16 | })(NodeTypes || (NodeTypes = {})); 17 | const isBrowser = typeof window !== "undefined"; 18 | const UnescapedSpecials = "-._~!$&'()*+,;=:@" 19 | .split("") 20 | .map((s) => s.charCodeAt(0)); 21 | const UnescapedRanges = [ 22 | ["a", "z"], 23 | ["A", "Z"], 24 | ["0", "9"], 25 | ].map((range) => range.map((s) => s.charCodeAt(0))); 26 | const ModuleReferences = { 27 | CJS: { 28 | define(routes) { 29 | if (module) { 30 | module.exports = routes; 31 | } 32 | }, 33 | isSupported() { 34 | return typeof module === "object"; 35 | }, 36 | }, 37 | AMD: { 38 | define(routes) { 39 | if (define) { 40 | define([], function () { 41 | return routes; 42 | }); 43 | } 44 | }, 45 | isSupported() { 46 | return typeof define === "function" && !!define.amd; 47 | }, 48 | }, 49 | UMD: { 50 | define(routes) { 51 | if (ModuleReferences.AMD.isSupported()) { 52 | ModuleReferences.AMD.define(routes); 53 | } 54 | else { 55 | if (ModuleReferences.CJS.isSupported()) { 56 | try { 57 | ModuleReferences.CJS.define(routes); 58 | } 59 | catch (error) { 60 | if (error.name !== "TypeError") 61 | throw error; 62 | } 63 | } 64 | } 65 | }, 66 | isSupported() { 67 | return (ModuleReferences.AMD.isSupported() || 68 | ModuleReferences.CJS.isSupported()); 69 | }, 70 | }, 71 | ESM: { 72 | define() { 73 | // Module can only be defined using ruby code generation 74 | }, 75 | isSupported() { 76 | // Its impossible to check if "export" keyword is supported 77 | return true; 78 | }, 79 | }, 80 | NIL: { 81 | define() { 82 | // Defined using RubyVariables.WRAPPER 83 | }, 84 | isSupported() { 85 | return true; 86 | }, 87 | }, 88 | DTS: { 89 | // Acts the same as ESM 90 | define(routes) { 91 | ModuleReferences.ESM.define(routes); 92 | }, 93 | isSupported() { 94 | return ModuleReferences.ESM.isSupported(); 95 | }, 96 | }, 97 | }; 98 | class ParametersMissing extends Error { 99 | constructor(...keys) { 100 | super(`Route missing required keys: ${keys.join(", ")}`); 101 | this.keys = keys; 102 | Object.setPrototypeOf(this, Object.getPrototypeOf(this)); 103 | this.name = ParametersMissing.name; 104 | } 105 | } 106 | const ReservedOptions = [ 107 | "anchor", 108 | "trailing_slash", 109 | "subdomain", 110 | "host", 111 | "port", 112 | "protocol", 113 | "script_name", 114 | ]; 115 | class UtilsClass { 116 | constructor() { 117 | this.configuration = { 118 | prefix: RubyVariables.PREFIX, 119 | default_url_options: RubyVariables.DEFAULT_URL_OPTIONS, 120 | special_options_key: RubyVariables.SPECIAL_OPTIONS_KEY, 121 | serializer: RubyVariables.SERIALIZER || this.default_serializer.bind(this), 122 | }; 123 | } 124 | default_serializer(value, prefix) { 125 | if (!prefix && !this.is_object(value)) { 126 | throw new Error("Url parameters should be a javascript hash"); 127 | } 128 | prefix = prefix || ""; 129 | const result = []; 130 | if (this.is_array(value)) { 131 | for (const element of value) { 132 | result.push(this.default_serializer(element, prefix + "[]")); 133 | } 134 | } 135 | else if (this.is_object(value)) { 136 | for (let key in value) { 137 | if (!hasProp(value, key)) 138 | continue; 139 | let prop = value[key]; 140 | if (prefix) { 141 | key = prefix + "[" + key + "]"; 142 | } 143 | const subvalue = this.default_serializer(prop, key); 144 | if (subvalue.length) { 145 | result.push(subvalue); 146 | } 147 | } 148 | } 149 | else { 150 | result.push(this.is_not_nullable(value) || 151 | RubyVariables.DEPRECATED_NIL_QUERY_PARAMETER_BEHAVIOR 152 | ? encodeURIComponent(prefix) + 153 | "=" + 154 | encodeURIComponent("" + (value !== null && value !== void 0 ? value : "")) 155 | : encodeURIComponent(prefix)); 156 | } 157 | return result.join("&"); 158 | } 159 | serialize(object) { 160 | return this.configuration.serializer(object); 161 | } 162 | extract_options(number_of_params, args) { 163 | const last_el = args[args.length - 1]; 164 | if ((args.length > number_of_params && last_el === 0) || 165 | (this.is_object(last_el) && 166 | !this.looks_like_serialized_model(last_el))) { 167 | if (this.is_object(last_el)) { 168 | delete last_el[this.configuration.special_options_key]; 169 | } 170 | return { 171 | args: args.slice(0, args.length - 1), 172 | options: last_el, 173 | }; 174 | } 175 | else { 176 | return { args, options: {} }; 177 | } 178 | } 179 | looks_like_serialized_model(object) { 180 | return (this.is_object(object) && 181 | !(this.configuration.special_options_key in object) && 182 | ("id" in object || "to_param" in object || "toParam" in object)); 183 | } 184 | path_identifier(object) { 185 | const result = this.unwrap_path_identifier(object); 186 | return this.is_nullable(result) || 187 | (RubyVariables.DEPRECATED_FALSE_PARAMETER_BEHAVIOR && 188 | result === false) 189 | ? "" 190 | : "" + result; 191 | } 192 | unwrap_path_identifier(object) { 193 | let result = object; 194 | if (!this.is_object(object)) { 195 | return object; 196 | } 197 | if ("to_param" in object) { 198 | result = object.to_param; 199 | } 200 | else if ("toParam" in object) { 201 | result = object.toParam; 202 | } 203 | else if ("id" in object) { 204 | result = object.id; 205 | } 206 | else { 207 | result = object; 208 | } 209 | return this.is_callable(result) ? result.call(object) : result; 210 | } 211 | partition_parameters(parts, required_params, default_options, call_arguments) { 212 | // eslint-disable-next-line prefer-const 213 | let { args, options } = this.extract_options(parts.length, call_arguments); 214 | if (args.length > parts.length) { 215 | throw new Error("Too many parameters provided for path"); 216 | } 217 | let use_all_parts = args.length > required_params.length; 218 | const parts_options = { 219 | ...this.configuration.default_url_options, 220 | }; 221 | for (const key in options) { 222 | const value = options[key]; 223 | if (!hasProp(options, key)) 224 | continue; 225 | use_all_parts = true; 226 | if (parts.includes(key)) { 227 | parts_options[key] = value; 228 | } 229 | } 230 | options = { 231 | ...this.configuration.default_url_options, 232 | ...default_options, 233 | ...options, 234 | }; 235 | const keyword_parameters = {}; 236 | let query_parameters = {}; 237 | for (const key in options) { 238 | if (!hasProp(options, key)) 239 | continue; 240 | const value = options[key]; 241 | if (key === "params") { 242 | if (this.is_object(value)) { 243 | query_parameters = { 244 | ...query_parameters, 245 | ...value, 246 | }; 247 | } 248 | else { 249 | throw new Error("params value should always be an object"); 250 | } 251 | } 252 | else if (this.is_reserved_option(key)) { 253 | keyword_parameters[key] = value; 254 | } 255 | else { 256 | if (!this.is_nullable(value) && 257 | (value !== default_options[key] || required_params.includes(key))) { 258 | query_parameters[key] = value; 259 | } 260 | } 261 | } 262 | const route_parts = use_all_parts ? parts : required_params; 263 | let i = 0; 264 | for (const part of route_parts) { 265 | if (i < args.length) { 266 | const value = args[i]; 267 | if (!hasProp(parts_options, part)) { 268 | query_parameters[part] = value; 269 | ++i; 270 | } 271 | } 272 | } 273 | return { keyword_parameters, query_parameters }; 274 | } 275 | build_route(parts, required_params, default_options, route, absolute, args) { 276 | const { keyword_parameters, query_parameters } = this.partition_parameters(parts, required_params, default_options, args); 277 | let { trailing_slash, anchor, script_name } = keyword_parameters; 278 | const missing_params = required_params.filter((param) => !hasProp(query_parameters, param) || 279 | this.is_nullable(query_parameters[param])); 280 | if (missing_params.length) { 281 | throw new ParametersMissing(...missing_params); 282 | } 283 | let result = this.get_prefix() + this.visit(route, query_parameters); 284 | if (trailing_slash) { 285 | result = result.replace(/(.*?)[/]?$/, "$1/"); 286 | } 287 | const url_params = this.serialize(query_parameters); 288 | if (url_params.length) { 289 | result += "?" + url_params; 290 | } 291 | if (anchor) { 292 | result += "#" + anchor; 293 | } 294 | if (script_name) { 295 | const last_index = script_name.length - 1; 296 | if (script_name[last_index] == "/" && result[0] == "/") { 297 | script_name = script_name.slice(0, last_index); 298 | } 299 | result = script_name + result; 300 | } 301 | if (absolute) { 302 | result = this.route_url(keyword_parameters) + result; 303 | } 304 | return result; 305 | } 306 | visit(route, parameters, optional = false) { 307 | switch (route[0]) { 308 | case NodeTypes.GROUP: 309 | return this.visit(route[1], parameters, true); 310 | case NodeTypes.CAT: 311 | return this.visit_cat(route, parameters, optional); 312 | case NodeTypes.SYMBOL: 313 | return this.visit_symbol(route, parameters, optional); 314 | case NodeTypes.STAR: 315 | return this.visit_globbing(route[1], parameters, true); 316 | case NodeTypes.LITERAL: 317 | case NodeTypes.SLASH: 318 | case NodeTypes.DOT: 319 | return route[1]; 320 | default: 321 | throw new Error("Unknown Rails node type"); 322 | } 323 | } 324 | is_not_nullable(object) { 325 | return !this.is_nullable(object); 326 | } 327 | is_nullable(object) { 328 | return object === undefined || object === null; 329 | } 330 | visit_cat( 331 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 332 | [_type, left, right], parameters, optional) { 333 | const left_part = this.visit(left, parameters, optional); 334 | let right_part = this.visit(right, parameters, optional); 335 | if (optional && 336 | ((this.is_optional_node(left[0]) && !left_part) || 337 | (this.is_optional_node(right[0]) && !right_part))) { 338 | return ""; 339 | } 340 | // if left_part ends on '/' and right_part starts on '/' 341 | if (left_part[left_part.length - 1] === "/" && right_part[0] === "/") { 342 | // strip slash from right_part 343 | // to prevent double slash 344 | right_part = right_part.substring(1); 345 | } 346 | return left_part + right_part; 347 | } 348 | visit_symbol( 349 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 350 | [_type, key], parameters, optional) { 351 | const value = this.path_identifier(parameters[key]); 352 | delete parameters[key]; 353 | if (value.length) { 354 | return this.encode_segment(value); 355 | } 356 | if (optional) { 357 | return ""; 358 | } 359 | else { 360 | throw new ParametersMissing(key); 361 | } 362 | } 363 | encode_segment(segment) { 364 | if (segment.match(/^[a-zA-Z0-9-]$/)) { 365 | // Performance optimization for 99% of cases 366 | return segment; 367 | } 368 | return (segment.match(/./gu) || []) 369 | .map((ch) => { 370 | const code = ch.charCodeAt(0); 371 | if (UnescapedRanges.find((range) => code >= range[0] && code <= range[1]) || 372 | UnescapedSpecials.includes(code)) { 373 | return ch; 374 | } 375 | else { 376 | return encodeURIComponent(ch); 377 | } 378 | }) 379 | .join(""); 380 | } 381 | is_optional_node(node) { 382 | return [NodeTypes.STAR, NodeTypes.SYMBOL, NodeTypes.CAT].includes(node); 383 | } 384 | build_path_spec(route, wildcard = false) { 385 | let key; 386 | switch (route[0]) { 387 | case NodeTypes.GROUP: 388 | return `(${this.build_path_spec(route[1])})`; 389 | case NodeTypes.CAT: 390 | return (this.build_path_spec(route[1]) + this.build_path_spec(route[2])); 391 | case NodeTypes.STAR: 392 | return this.build_path_spec(route[1], true); 393 | case NodeTypes.SYMBOL: 394 | key = route[1]; 395 | if (wildcard) { 396 | return (key.startsWith("*") ? "" : "*") + key; 397 | } 398 | else { 399 | return ":" + key; 400 | } 401 | break; 402 | case NodeTypes.SLASH: 403 | case NodeTypes.DOT: 404 | case NodeTypes.LITERAL: 405 | return route[1]; 406 | default: 407 | throw new Error("Unknown Rails node type"); 408 | } 409 | } 410 | visit_globbing(route, parameters, optional) { 411 | const key = route[1]; 412 | let value = parameters[key]; 413 | delete parameters[key]; 414 | if (this.is_nullable(value)) { 415 | return this.visit(route, parameters, optional); 416 | } 417 | if (this.is_array(value)) { 418 | value = value.join("/"); 419 | } 420 | const result = this.path_identifier(value); 421 | return encodeURI(result); 422 | } 423 | get_prefix() { 424 | const prefix = this.configuration.prefix; 425 | return prefix.match("/$") 426 | ? prefix.substring(0, prefix.length - 1) 427 | : prefix; 428 | } 429 | route(parts_table, route_spec, absolute = false) { 430 | const required_params = []; 431 | const parts = []; 432 | const default_options = {}; 433 | for (const [part, { r: required, d: value }] of Object.entries(parts_table)) { 434 | parts.push(part); 435 | if (required) { 436 | required_params.push(part); 437 | } 438 | if (this.is_not_nullable(value)) { 439 | default_options[part] = value; 440 | } 441 | } 442 | const result = (...args) => { 443 | return this.build_route(parts, required_params, default_options, route_spec, absolute, args); 444 | }; 445 | result.requiredParams = () => required_params; 446 | result.toString = () => { 447 | return this.build_path_spec(route_spec); 448 | }; 449 | return result; 450 | } 451 | route_url(route_defaults) { 452 | const hostname = route_defaults.host || this.current_host(); 453 | if (!hostname) { 454 | return ""; 455 | } 456 | const subdomain = route_defaults.subdomain 457 | ? route_defaults.subdomain + "." 458 | : ""; 459 | const protocol = route_defaults.protocol || this.current_protocol(); 460 | let port = route_defaults.port || 461 | (!route_defaults.host ? this.current_port() : undefined); 462 | port = port ? ":" + port : ""; 463 | return protocol + "://" + subdomain + hostname + port; 464 | } 465 | current_host() { 466 | var _a; 467 | return (isBrowser && ((_a = window === null || window === void 0 ? void 0 : window.location) === null || _a === void 0 ? void 0 : _a.hostname)) || ""; 468 | } 469 | current_protocol() { 470 | var _a, _b; 471 | return ((isBrowser && ((_b = (_a = window === null || window === void 0 ? void 0 : window.location) === null || _a === void 0 ? void 0 : _a.protocol) === null || _b === void 0 ? void 0 : _b.replace(/:$/, ""))) || "http"); 472 | } 473 | current_port() { 474 | var _a; 475 | return (isBrowser && ((_a = window === null || window === void 0 ? void 0 : window.location) === null || _a === void 0 ? void 0 : _a.port)) || ""; 476 | } 477 | is_object(value) { 478 | return (typeof value === "object" && 479 | Object.prototype.toString.call(value) === "[object Object]"); 480 | } 481 | is_array(object) { 482 | return object instanceof Array; 483 | } 484 | is_callable(object) { 485 | return typeof object === "function" && !!object.call; 486 | } 487 | is_reserved_option(key) { 488 | return ReservedOptions.includes(key); 489 | } 490 | configure(new_config) { 491 | if (new_config.prefix) { 492 | console.warn("JsRoutes configuration prefix option is deprecated in favor of default_url_options.script_name."); 493 | } 494 | this.configuration = { ...this.configuration, ...new_config }; 495 | return this.configuration; 496 | } 497 | config() { 498 | return { ...this.configuration }; 499 | } 500 | is_module_supported(name) { 501 | return ModuleReferences[name].isSupported(); 502 | } 503 | ensure_module_supported(name) { 504 | if (!this.is_module_supported(name)) { 505 | throw new Error(`${name} is not supported by runtime`); 506 | } 507 | } 508 | define_module(name, module) { 509 | this.ensure_module_supported(name); 510 | ModuleReferences[name].define(module); 511 | return module; 512 | } 513 | } 514 | const utils = new UtilsClass(); 515 | // We want this helper name to be short 516 | const __jsr = { 517 | r(parts_table, route_spec, absolute) { 518 | return utils.route(parts_table, route_spec, absolute); 519 | }, 520 | }; 521 | return utils.define_module(RubyVariables.MODULE_TYPE, { 522 | ...__jsr, 523 | configure: (config) => { 524 | return utils.configure(config); 525 | }, 526 | config: () => { 527 | return utils.config(); 528 | }, 529 | serialize: (object) => { 530 | return utils.serialize(object); 531 | }, 532 | ...RubyVariables.ROUTES_OBJECT, 533 | }); 534 | })(); 535 | -------------------------------------------------------------------------------- /spec/js_routes/rails_routes_compatibility_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe JsRoutes, "compatibility with Rails" do 4 | 5 | before(:each) do 6 | evallib(module_type: nil, namespace: 'Routes') 7 | end 8 | 9 | it "should generate collection routing" do 10 | expectjs("Routes.inboxes_path()").to eq(test_routes.inboxes_path()) 11 | end 12 | 13 | it "should generate member routing" do 14 | expectjs("Routes.inbox_path(1)").to eq(test_routes.inbox_path(1)) 15 | end 16 | 17 | it "should raise error if required argument is not passed", :aggregate_failures do 18 | expect { evaljs("Routes.thing_path()") } 19 | .to raise_error(/Route missing required keys: id/) 20 | expect { evaljs("Routes.search_path()") } 21 | .to raise_error(/Route missing required keys: q/) 22 | expect { evaljs("Routes.book_path()") } 23 | .to raise_error(/Route missing required keys: section, title/) 24 | expect { evaljs("Routes.book_title_path()") } 25 | .to raise_error(/Route missing required keys: title/) 26 | 27 | expectjs("try {Routes.thing_path()} catch (e) { e.name }") .to eq('ParametersMissing') 28 | expectjs("try {Routes.thing_path()} catch (e) { e.keys }") .to eq(['id']) 29 | end 30 | 31 | it "should produce error stacktraces including function names" do 32 | stacktrace = evaljs(" 33 | (function(){ 34 | try { 35 | Routes.thing_path() 36 | } catch(e) { 37 | return e.stack; 38 | } 39 | })() 40 | ") 41 | expect(stacktrace).to include "thing_path" 42 | end 43 | 44 | it "should support 0 as a member parameter" do 45 | expectjs("Routes.inbox_path(0)").to eq(test_routes.inbox_path(0)) 46 | end 47 | 48 | it "should generate nested routing with one parameter" do 49 | expectjs("Routes.inbox_messages_path(1)").to eq(test_routes.inbox_messages_path(1)) 50 | end 51 | 52 | it "should generate nested routing" do 53 | expectjs("Routes.inbox_message_path(1,2)").to eq(test_routes.inbox_message_path(1, 2)) 54 | end 55 | 56 | it "should generate routing with format" do 57 | expectjs("Routes.inbox_path(1, {format: 'json'})").to eq(test_routes.inbox_path(1, :format => "json")) 58 | end 59 | 60 | it "should support routes with reserved javascript words as parameters" do 61 | expectjs("Routes.object_path(1, 2)").to eq(test_routes.object_path(1,2)) 62 | end 63 | 64 | it "should support routes with trailing_slash" do 65 | expectjs("Routes.inbox_path(1, {trailing_slash: true})").to eq(test_routes.inbox_path(1, trailing_slash: true)) 66 | end 67 | 68 | it "should support url anchor given as parameter" do 69 | expectjs("Routes.inbox_path(1, {anchor: 'hello'})").to eq(test_routes.inbox_path(1, :anchor => "hello")) 70 | end 71 | 72 | it "should support url anchor and get parameters" do 73 | expectjs("Routes.inbox_path(1, {expanded: true, anchor: 'hello'})").to eq(test_routes.inbox_path(1, :expanded => true, :anchor => "hello")) 74 | end 75 | 76 | it "should support required parameters given as options hash" do 77 | expectjs("Routes.search_path({q: 'hello'})").to eq(test_routes.search_path(:q => 'hello')) 78 | end 79 | 80 | it "should use irregular ActiveSupport pluralizations" do 81 | expectjs("Routes.budgies_path()").to eq(test_routes.budgies_path) 82 | expectjs("Routes.budgie_path(1)").to eq(test_routes.budgie_path(1)) 83 | expectjs("Routes.budgy_path").to eq(nil) 84 | expectjs("Routes.budgie_descendents_path(1)").to eq(test_routes.budgie_descendents_path(1)) 85 | end 86 | 87 | describe "url parameters encoding" do 88 | 89 | it "should support route with parameters containing symbols that need URI-encoding", :aggregate_failures do 90 | expectjs("Routes.inbox_path('#hello')").to eq(test_routes.inbox_path('#hello')) 91 | expectjs("Routes.inbox_path('some param')").to eq(test_routes.inbox_path('some param')) 92 | expectjs("Routes.inbox_path('some param with more & more encode symbols')").to eq(test_routes.inbox_path('some param with more & more encode symbols')) 93 | end 94 | 95 | it "should support route with parameters containing symbols not need URI-encoding", :aggregate_failures do 96 | expectjs("Routes.inbox_path(':some_id')").to eq(test_routes.inbox_path(':some_id')) 97 | expectjs("Routes.inbox_path('.+')").to eq(test_routes.inbox_path('.+')) 98 | end 99 | 100 | it "supports emoji characters", :aggregate_failures do 101 | expectjs("Routes.inbox_path('💗')").to eq(test_routes.inbox_path('💗')) 102 | end 103 | end 104 | 105 | describe "when route has defaults" do 106 | it "should support route default format" do 107 | expectjs("Routes.api_purchases_path()").to eq(test_routes.api_purchases_path) 108 | end 109 | 110 | it 'should support route default subdomain' do 111 | expectjs("Routes.backend_root_path()").to eq(test_routes.backend_root_path) 112 | end 113 | 114 | it "should support default format override" do 115 | expectjs("Routes.api_purchases_path({format: 'xml'})").to eq(test_routes.api_purchases_path(format: 'xml')) 116 | end 117 | 118 | it "should support default format override by passing it in args" do 119 | expectjs("Routes.api_purchases_path('xml')").to eq(test_routes.api_purchases_path('xml')) 120 | end 121 | 122 | it "doesn't apply defaults to path" do 123 | expectjs("Routes.with_defaults_path()").to eq(test_routes.with_defaults_path) 124 | expectjs("Routes.with_defaults_path({format: 'json'})").to eq(test_routes.with_defaults_path(format: 'json')) 125 | end 126 | end 127 | 128 | context "with rails engines" do 129 | it "should support simple route" do 130 | expectjs("Routes.blog_app_posts_path()").to eq(blog_routes.posts_path()) 131 | end 132 | 133 | it "should support root route" do 134 | expectjs("Routes.blog_app_path()").to eq(test_routes.blog_app_path()) 135 | end 136 | 137 | it "should support route with parameters" do 138 | expectjs("Routes.blog_app_post_path(1)").to eq(blog_routes.post_path(1)) 139 | end 140 | it "should support root path" do 141 | expectjs("Routes.blog_app_root_path()").to eq(blog_routes.root_path) 142 | end 143 | it "should support single route mapping" do 144 | expectjs("Routes.support_path({page: 3})").to eq(test_routes.support_path(:page => 3)) 145 | end 146 | 147 | it 'works' do 148 | expectjs("Routes.planner_manage_path({locale: 'ua'})").to eq(planner_routes.manage_path(locale: 'ua')) 149 | expectjs("Routes.planner_manage_path()").to eq(planner_routes.manage_path) 150 | end 151 | end 152 | 153 | it "shouldn't require the format" do 154 | expectjs("Routes.json_only_path({format: 'json'})").to eq(test_routes.json_only_path(:format => 'json')) 155 | end 156 | 157 | it "should serialize object with empty string value" do 158 | expectjs("Routes.inboxes_path({a: '', b: 1})").to eq(test_routes.inboxes_path(:a => '', :b => 1)) 159 | end 160 | 161 | it "should support utf-8 route" do 162 | expectjs("Routes.hello_path()").to eq(test_routes.hello_path) 163 | end 164 | 165 | it "should support root_path" do 166 | expectjs("Routes.root_path()").to eq(test_routes.root_path) 167 | end 168 | 169 | describe "params parameter" do 170 | it "works" do 171 | expectjs("Routes.inboxes_path({params: {key: 'value'}})").to eq(test_routes.inboxes_path(params: {key: 'value'})) 172 | end 173 | 174 | it "allows keyword key as a query parameter" do 175 | expectjs("Routes.inboxes_path({params: {anchor: 'a', params: 'p'}})").to eq(test_routes.inboxes_path(params: {anchor: 'a', params: 'p'})) 176 | end 177 | 178 | it "throws when value is not an object" do 179 | expect { 180 | evaljs("Routes.inboxes_path({params: 1})") 181 | }.to raise_error(js_error_class) 182 | end 183 | end 184 | 185 | describe "get parameters" do 186 | it "should support simple get parameters" do 187 | expectjs("Routes.inbox_path(1, {format: 'json', lang: 'ua', q: 'hello'})").to eq(test_routes.inbox_path(1, :lang => "ua", :q => "hello", :format => "json")) 188 | end 189 | 190 | it "should support array get parameters" do 191 | expectjs("Routes.inbox_path(1, {hello: ['world', 'mars']})").to eq(test_routes.inbox_path(1, :hello => [:world, :mars])) 192 | end 193 | 194 | it "should support empty array get parameters" do 195 | expectjs("Routes.inboxes_path({ a: [], b: {} })").to eq(test_routes.inboxes_path({ a: [], b: {} })) 196 | end 197 | 198 | context "object without prototype" do 199 | before(:each) do 200 | evaljs("let params = Object.create(null); params.q = 'hello';") 201 | evaljs("let inbox = Object.create(null); inbox.to_param = 1;") 202 | end 203 | 204 | it "should still work correctly" do 205 | expectjs("Routes.inbox_path(inbox, params)").to eq( 206 | test_routes.inbox_path(1, q: "hello") 207 | ) 208 | end 209 | end 210 | 211 | it "should support nested get parameters" do 212 | expectjs("Routes.inbox_path(1, {format: 'json', env: 'test', search: { category_ids: [2,5], q: 'hello'}})").to eq( 213 | test_routes.inbox_path(1, :env => 'test', :search => {:category_ids => [2,5], :q => "hello"}, :format => "json") 214 | ) 215 | end 216 | 217 | it "should support null and undefined parameters" do 218 | expectjs("Routes.inboxes_path({uri: null, key: undefined})").to eq(test_routes.inboxes_path(:uri => nil, :key => nil)) 219 | end 220 | 221 | it "should escape get parameters" do 222 | expectjs("Routes.inboxes_path({uri: 'http://example.com'})").to eq(test_routes.inboxes_path(:uri => 'http://example.com')) 223 | end 224 | 225 | it "should support nested object null parameters" do 226 | expectjs("Routes.inboxes_path({hello: {world: null}})").to eq(test_routes.inboxes_path(:hello => {:world => nil})) 227 | end 228 | end 229 | 230 | 231 | context "routes globbing" do 232 | it "should be supported as parameters" do 233 | expectjs("Routes.book_path('thrillers', 1)").to eq(test_routes.book_path('thrillers', 1)) 234 | end 235 | 236 | it "should support routes globbing as array" do 237 | expectjs("Routes.book_path(['thrillers'], 1)").to eq(test_routes.book_path(['thrillers'], 1)) 238 | end 239 | 240 | it "should support routes globbing as array" do 241 | expectjs("Routes.book_path([1, 2, 3], 1)").to eq(test_routes.book_path([1, 2, 3], 1)) 242 | end 243 | 244 | it "should support routes globbing with slash" do 245 | expectjs("Routes.book_path('a_test/b_test/c_test', 1)").to eq(test_routes.book_path('a_test/b_test/c_test', 1)) 246 | end 247 | 248 | it "should support routes globbing as hash" do 249 | expectjs("Routes.book_path('a%b', 1)").to eq(test_routes.book_path('a%b', 1)) 250 | end 251 | 252 | it "should support routes globbing as array with optional params" do 253 | expectjs("Routes.book_path([1, 2, 3, 5], 1, {c: '1'})").to eq(test_routes.book_path([1, 2, 3, 5], 1, { :c => "1" })) 254 | end 255 | 256 | it "should support routes globbing in book_title route as array" do 257 | expectjs("Routes.book_title_path('john', ['thrillers', 'comedian'])").to eq(test_routes.book_title_path('john', ['thrillers', 'comedian'])) 258 | end 259 | 260 | it "should support routes globbing in book_title route as array with optional params" do 261 | expectjs("Routes.book_title_path('john', ['thrillers', 'comedian'], {some_key: 'some_value'})").to eq(test_routes.book_title_path('john', ['thrillers', 'comedian'], {:some_key => 'some_value'})) 262 | end 263 | end 264 | 265 | context "using optional path fragments" do 266 | context "including not optional parts" do 267 | it "should include everything that is not optional" do 268 | expectjs("Routes.foo_path()").to eq(test_routes.foo_path) 269 | end 270 | end 271 | 272 | context "but not including them" do 273 | it "should not include the optional parts" do 274 | expectjs("Routes.things_path()").to eq(test_routes.things_path) 275 | expectjs("Routes.things_path({ q: 'hello' })").to eq(test_routes.things_path(q: 'hello')) 276 | end 277 | 278 | it "treats false as absent optional part" do 279 | if Rails.version < "7.0" 280 | pending("https://github.com/rails/rails/issues/42280") 281 | end 282 | expectjs("Routes.things_path(false)").to eq(test_routes.things_path(false)) 283 | end 284 | 285 | it "treats false as absent optional part when default is specified" do 286 | expectjs("Routes.campaigns_path(false)").to eq(test_routes.campaigns_path(false)) 287 | end 288 | 289 | it "should not require the optional parts as arguments" do 290 | expectjs("Routes.thing_path(null, 5)").to eq(test_routes.thing_path(nil, 5)) 291 | end 292 | 293 | it "should treat undefined as non-given optional part" do 294 | expectjs("Routes.thing_path(5, {optional_id: undefined})").to eq(test_routes.thing_path(5, :optional_id => nil)) 295 | end 296 | 297 | it "should raise error when passing non-full list of arguments and some query params" do 298 | expect { evaljs("Routes.thing_path(5, {q: 'hello'})") } 299 | .to raise_error(/Route missing required keys: id/) 300 | end 301 | 302 | it "should treat null as non-given optional part" do 303 | expectjs("Routes.thing_path(5, {optional_id: null})").to eq(test_routes.thing_path(5, :optional_id => nil)) 304 | end 305 | 306 | it "should work when passing required params in options" do 307 | expectjs("Routes.thing_deep_path({second_required: 1, third_required: 2})").to eq(test_routes.thing_deep_path(second_required: 1, third_required: 2)) 308 | end 309 | 310 | it "should skip leading and trailing optional parts" do 311 | expectjs("Routes.thing_deep_path(1, 2)").to eq(test_routes.thing_deep_path(1, 2)) 312 | end 313 | end 314 | 315 | context "and including them" do 316 | it "should fail when insufficient arguments are given" do 317 | expect { evaljs("Routes.thing_deep_path(2)") }.to raise_error(/Route missing required keys: third_required/) 318 | end 319 | 320 | it "should include the optional parts" do 321 | expectjs("Routes.things_path({optional_id: 5})").to eq(test_routes.things_path(:optional_id => 5)) 322 | expectjs("Routes.things_path(5)").to eq(test_routes.things_path(5)) 323 | expectjs("Routes.thing_deep_path(1, { third_required: 3, second_required: 2 })").to eq( 324 | test_routes.thing_deep_path(1, third_required: 3, second_required: 2) 325 | ) 326 | expectjs("Routes.thing_deep_path(1, { third_required: 3, second_required: 2, forth_optional: 4 })").to eq( 327 | test_routes.thing_deep_path(1, third_required: 3, second_required: 2, forth_optional: 4) 328 | ) 329 | expectjs("Routes.thing_deep_path(2, { third_required: 3, first_optional: 1 })").to eq( 330 | test_routes.thing_deep_path(2, third_required: 3, first_optional: 1) 331 | ) 332 | expectjs("Routes.thing_deep_path(3, { first_optional: 1, second_required: 2 })").to eq( 333 | test_routes.thing_deep_path(3, first_optional: 1, second_required: 2) 334 | ) 335 | expectjs("Routes.thing_deep_path(3, { first_optional: 1, second_required: 2, forth_optional: 4 })").to eq( 336 | test_routes.thing_deep_path(3, first_optional: 1, second_required: 2, forth_optional: 4) 337 | ) 338 | expectjs("Routes.thing_deep_path(4, { first_optional: 1, second_required: 2, third_required: 3 })").to eq( 339 | test_routes.thing_deep_path(4, first_optional: 1, second_required: 2, third_required: 3) 340 | ) 341 | expectjs("Routes.thing_deep_path(2, 3)").to eq( 342 | test_routes.thing_deep_path(2, 3) 343 | ) 344 | expectjs("Routes.thing_deep_path(1, 2, { third_required: 3 })").to eq( 345 | test_routes.thing_deep_path(1, 2, third_required: 3) 346 | ) 347 | expectjs("Routes.thing_deep_path(1,2, {third_required: 3, q: 'bogdan'})").to eq( 348 | test_routes.thing_deep_path(1,2, {third_required: 3, q: 'bogdan'}) 349 | ) 350 | expectjs("Routes.thing_deep_path(1, 2, { forth_optional: 4, third_required: 3 })").to eq( 351 | test_routes.thing_deep_path(1, 2, forth_optional: 4, third_required: 3) 352 | ) 353 | expectjs("Routes.thing_deep_path(1, 3, { second_required: 2 })").to eq( 354 | test_routes.thing_deep_path(1, 3, second_required: 2) 355 | ) 356 | expectjs("Routes.thing_deep_path(1, 4, { second_required: 2, third_required: 3 })").to eq( 357 | test_routes.thing_deep_path(1, 4, second_required: 2, third_required: 3) 358 | ) 359 | expectjs("Routes.thing_deep_path(2, 3, { first_optional: 1 })").to eq( 360 | test_routes.thing_deep_path(2, 3, first_optional: 1) 361 | ) 362 | expectjs("Routes.thing_deep_path(2, 3, { first_optional: 1, forth_optional: 4 })").to eq( 363 | test_routes.thing_deep_path(2, 3, first_optional: 1, forth_optional: 4) 364 | ) 365 | expectjs("Routes.thing_deep_path(2, 4, { first_optional: 1, third_required: 3 })").to eq( 366 | test_routes.thing_deep_path(2, 4, first_optional: 1, third_required: 3) 367 | ) 368 | expectjs("Routes.thing_deep_path(3, 4, { first_optional: 1, second_required: 2 })").to eq( 369 | test_routes.thing_deep_path(3, 4, first_optional: 1, second_required: 2) 370 | ) 371 | expectjs("Routes.thing_deep_path(1, 2, 3)").to eq( 372 | test_routes.thing_deep_path(1, 2, 3) 373 | ) 374 | expectjs("Routes.thing_deep_path(1, 2, 3, { forth_optional: 4 })").to eq( 375 | test_routes.thing_deep_path(1, 2, 3, forth_optional: 4) 376 | ) 377 | expectjs("Routes.thing_deep_path(1, 2, 4, { third_required: 3 })").to eq( 378 | test_routes.thing_deep_path(1, 2, 4, third_required: 3) 379 | ) 380 | expectjs("Routes.thing_deep_path(1, 3, 4, { second_required: 2 })").to eq( 381 | test_routes.thing_deep_path(1, 3, 4, second_required: 2) 382 | ) 383 | expectjs("Routes.thing_deep_path(2, 3, 4, { first_optional: 1 })").to eq( 384 | test_routes.thing_deep_path(2, 3, 4, first_optional: 1) 385 | ) 386 | expectjs("Routes.thing_deep_path(1, 2, 3, 4)").to eq( 387 | test_routes.thing_deep_path(1, 2, 3, 4) 388 | ) 389 | 390 | end 391 | 392 | context "on nested optional parts" do 393 | if Rails.version <= "5.0.0" 394 | # this type of routing is deprecated 395 | it "should include everything that is not optional" do 396 | expectjs("Routes.classic_path({controller: 'classic', action: 'edit'})").to eq(test_routes.classic_path(controller: :classic, action: :edit)) 397 | end 398 | end 399 | end 400 | end 401 | end 402 | 403 | context "when wrong parameters given" do 404 | 405 | it "should throw Exception if not enough parameters" do 406 | expect { 407 | evaljs("Routes.inbox_path()") 408 | }.to raise_error(js_error_class) 409 | end 410 | 411 | it "should throw Exception if required parameter is not defined" do 412 | expect { 413 | evaljs("Routes.inbox_path(null)") 414 | }.to raise_error(js_error_class) 415 | end 416 | 417 | it "should throw Exception if required parameter is not defined" do 418 | expect { 419 | evaljs("Routes.inbox_path(undefined)") 420 | }.to raise_error(js_error_class) 421 | end 422 | 423 | it "should throw Exceptions if when there is too many parameters" do 424 | expect { 425 | evaljs("Routes.inbox_path(1,2,3)") 426 | }.to raise_error(js_error_class) 427 | end 428 | end 429 | 430 | context "when javascript engine without Array#indexOf is used" do 431 | before(:each) do 432 | evaljs("Array.prototype.indexOf = null") 433 | end 434 | it "should still work correctly" do 435 | expectjs("Routes.inboxes_path()").to eq(test_routes.inboxes_path()) 436 | end 437 | end 438 | 439 | context "when arguments are objects" do 440 | 441 | let(:klass) { Struct.new(:id, :to_param) } 442 | let(:inbox) { klass.new(1,"my") } 443 | 444 | it "should throw Exceptions if when pass id with null" do 445 | expect { 446 | evaljs("Routes.inbox_path({id: null})") 447 | }.to raise_error(js_error_class) 448 | end 449 | 450 | it "should throw Exceptions if when pass to_param with null" do 451 | expect { 452 | evaljs("Routes.inbox_path({to_param: null})") 453 | }.to raise_error(js_error_class) 454 | end 455 | it "should support 0 as a to_param option" do 456 | expectjs("Routes.inbox_path({to_param: 0})").to eq(test_routes.inbox_path(Struct.new(:to_param).new('0'))) 457 | end 458 | 459 | it "should check for options special key" do 460 | expectjs("Routes.inbox_path({id: 7, q: 'hello', _options: true})").to eq(test_routes.inbox_path(id: 7, q: 'hello')) 461 | expect { 462 | evaljs("Routes.inbox_path({to_param: 7, _options: true})") 463 | }.to raise_error(js_error_class) 464 | expectjs("Routes.inbox_message_path(5, {id: 7, q: 'hello', _options: true})").to eq(test_routes.inbox_message_path(5, id: 7, q: 'hello')) 465 | end 466 | 467 | it "should support 0 as an id option" do 468 | expectjs("Routes.inbox_path({id: 0})").to eq(test_routes.inbox_path(0)) 469 | end 470 | 471 | it "should use id property of the object in path" do 472 | expectjs("Routes.inbox_path({id: 1})").to eq(test_routes.inbox_path(1)) 473 | end 474 | 475 | it "should prefer to_param property over id property" do 476 | expectjs("Routes.inbox_path({id: 1, to_param: 'my'})").to eq(test_routes.inbox_path(inbox)) 477 | end 478 | 479 | it "should call to_param if it is a function" do 480 | expectjs("Routes.inbox_path({id: 1, to_param: function(){ return 'my';}})").to eq(test_routes.inbox_path(inbox)) 481 | end 482 | 483 | it "should call id if it is a function" do 484 | expectjs("Routes.inbox_path({id: function() { return 1;}})").to eq(test_routes.inbox_path(1)) 485 | end 486 | 487 | it "should support options argument" do 488 | expectjs( 489 | "Routes.inbox_message_path({id:1, to_param: 'my'}, {id:2}, {custom: true, format: 'json'})" 490 | ).to eq(test_routes.inbox_message_path(inbox, 2, :custom => true, :format => "json")) 491 | end 492 | 493 | it "supports camel case property name" do 494 | expectjs("Routes.inbox_path({id: 1, toParam: 'my'})").to eq(test_routes.inbox_path(inbox)) 495 | end 496 | 497 | it "supports camel case method name" do 498 | expectjs("Routes.inbox_path({id: 1, toParam: function(){ return 'my';}})").to eq(test_routes.inbox_path(inbox)) 499 | end 500 | 501 | context "when globbing" do 502 | it "should prefer to_param property over id property" do 503 | expectjs("Routes.book_path({id: 1, to_param: 'my'}, 1)").to eq(test_routes.book_path(inbox, 1)) 504 | end 505 | 506 | it "should call to_param if it is a function" do 507 | expectjs("Routes.book_path({id: 1, to_param: function(){ return 'my';}}, 1)").to eq(test_routes.book_path(inbox, 1)) 508 | end 509 | 510 | it "should call id if it is a function" do 511 | expectjs("Routes.book_path({id: function() { return 'technical';}}, 1)").to eq(test_routes.book_path('technical', 1)) 512 | end 513 | 514 | it "should support options argument" do 515 | expectjs( 516 | "Routes.book_path({id:1, to_param: 'my'}, {id:2}, {custom: true, format: 'json'})" 517 | ).to eq(test_routes.book_path(inbox, 2, :custom => true, :format => "json")) 518 | end 519 | end 520 | end 521 | 522 | describe "script_name option" do 523 | it "preceeds the path" do 524 | expectjs( 525 | "Routes.inboxes_path({ script_name: '/myapp' })" 526 | ).to eq( 527 | test_routes.inboxes_path(script_name: '/myapp') 528 | ) 529 | end 530 | 531 | it "strips double slash" do 532 | expectjs( 533 | "Routes.inboxes_path({ script_name: '/myapp/' })" 534 | ).to eq( 535 | test_routes.inboxes_path(script_name: '/myapp/') 536 | ) 537 | end 538 | 539 | it "preserves no preceding slash" do 540 | expectjs( 541 | "Routes.inboxes_path({ script_name: 'myapp' })" 542 | ).to eq( 543 | test_routes.inboxes_path(script_name: 'myapp') 544 | ) 545 | end 546 | end 547 | 548 | describe "bigint parameter" do 549 | it "works" do 550 | number = 10**20 551 | expectjs( 552 | "Routes.inbox_path(#{number}n)" 553 | ).to eq( 554 | test_routes.inbox_path(number) 555 | ) 556 | end 557 | end 558 | end 559 | -------------------------------------------------------------------------------- /lib/routes.ts: -------------------------------------------------------------------------------- 1 | type Optional = { [P in keyof T]?: T[P] | null }; 2 | type Collection = Record; 3 | 4 | type BaseRouteParameter = string | boolean | Date | number | bigint; 5 | type MethodRouteParameter = BaseRouteParameter | (() => BaseRouteParameter); 6 | type ModelRouteParameter = 7 | | { id: MethodRouteParameter } 8 | | { to_param: MethodRouteParameter } 9 | | { toParam: MethodRouteParameter }; 10 | type RequiredRouteParameter = BaseRouteParameter | ModelRouteParameter; 11 | type OptionalRouteParameter = undefined | null | RequiredRouteParameter; 12 | type QueryRouteParameter = 13 | | OptionalRouteParameter 14 | | QueryRouteParameter[] 15 | | { [k: string]: QueryRouteParameter }; 16 | type RouteParameters = Collection; 17 | 18 | type Serializable = Collection; 19 | type Serializer = (value: Serializable) => string; 20 | type RouteHelperExtras = { 21 | requiredParams(): string[]; 22 | toString(): string; 23 | }; 24 | 25 | type RequiredParameters = T extends 1 26 | ? [RequiredRouteParameter] 27 | : T extends 2 28 | ? [RequiredRouteParameter, RequiredRouteParameter] 29 | : T extends 3 30 | ? [RequiredRouteParameter, RequiredRouteParameter, RequiredRouteParameter] 31 | : T extends 4 32 | ? [ 33 | RequiredRouteParameter, 34 | RequiredRouteParameter, 35 | RequiredRouteParameter, 36 | RequiredRouteParameter 37 | ] 38 | : RequiredRouteParameter[]; 39 | 40 | type RouteHelperOptions = RouteOptions & Collection; 41 | 42 | type RouteHelper = (( 43 | ...args: [...RequiredParameters, RouteHelperOptions] 44 | ) => string) & 45 | RouteHelperExtras; 46 | 47 | type RouteHelpers = Collection; 48 | 49 | type Configuration = { 50 | prefix: string; 51 | default_url_options: RouteParameters; 52 | special_options_key: string; 53 | serializer: Serializer; 54 | }; 55 | 56 | interface RouterExposedMethods { 57 | config(): Configuration; 58 | configure(arg: Partial): Configuration; 59 | serialize: Serializer; 60 | } 61 | 62 | type KeywordUrlOptions = Optional<{ 63 | host: string; 64 | protocol: string; 65 | subdomain: string; 66 | port: string | number; 67 | anchor: string; 68 | trailing_slash: boolean; 69 | script_name: string; 70 | params: RouteParameters; 71 | }>; 72 | 73 | type RouteOptions = KeywordUrlOptions & RouteParameters; 74 | 75 | type PartsTable = Collection<{ r?: boolean; d?: OptionalRouteParameter }>; 76 | 77 | type ModuleType = "CJS" | "AMD" | "UMD" | "ESM" | "DTS" | "NIL"; 78 | 79 | declare const RubyVariables: { 80 | PREFIX: string; 81 | DEPRECATED_FALSE_PARAMETER_BEHAVIOR: boolean; 82 | DEPRECATED_NIL_QUERY_PARAMETER_BEHAVIOR: boolean; 83 | SPECIAL_OPTIONS_KEY: string; 84 | DEFAULT_URL_OPTIONS: RouteParameters; 85 | SERIALIZER: Serializer; 86 | ROUTES_OBJECT: RouteHelpers; 87 | MODULE_TYPE: ModuleType; 88 | WRAPPER: (callback: T) => T; 89 | }; 90 | 91 | declare const define: 92 | | undefined 93 | | (((arg: unknown[], callback: () => unknown) => void) & { amd?: unknown }); 94 | 95 | declare const module: { exports: unknown } | undefined; 96 | 97 | // eslint-disable-next-line 98 | RubyVariables.WRAPPER( 99 | // eslint-disable-next-line 100 | (): RouterExposedMethods => { 101 | const hasProp = (value: unknown, key: string): boolean => 102 | Object.prototype.hasOwnProperty.call(value, key); 103 | enum NodeTypes { 104 | GROUP = 1, 105 | CAT = 2, 106 | SYMBOL = 3, 107 | OR = 4, 108 | STAR = 5, 109 | LITERAL = 6, 110 | SLASH = 7, 111 | DOT = 8, 112 | } 113 | type RouteNodes = { 114 | [NodeTypes.GROUP]: { left: RouteTree; right: never }; 115 | [NodeTypes.STAR]: { left: RouteTree; right: never }; 116 | [NodeTypes.LITERAL]: { left: string; right: never }; 117 | [NodeTypes.SLASH]: { left: "/"; right: never }; 118 | [NodeTypes.DOT]: { left: "."; right: never }; 119 | [NodeTypes.CAT]: { left: RouteTree; right: RouteTree }; 120 | [NodeTypes.SYMBOL]: { left: string; right: never }; 121 | }; 122 | type RouteNode = [ 123 | T, 124 | RouteNodes[T]["left"], 125 | RouteNodes[T]["right"] 126 | ]; 127 | type RouteTree = { 128 | [T in keyof RouteNodes]: RouteNode; 129 | }[keyof RouteNodes]; 130 | 131 | const isBrowser = typeof window !== "undefined"; 132 | type ModuleDefinition = { 133 | define: (routes: RouterExposedMethods) => void; 134 | isSupported: () => boolean; 135 | }; 136 | 137 | const UnescapedSpecials = "-._~!$&'()*+,;=:@" 138 | .split("") 139 | .map((s) => s.charCodeAt(0)); 140 | const UnescapedRanges = [ 141 | ["a", "z"], 142 | ["A", "Z"], 143 | ["0", "9"], 144 | ].map((range) => range.map((s) => s.charCodeAt(0))); 145 | 146 | const ModuleReferences: Record = { 147 | CJS: { 148 | define(routes) { 149 | if (module) { 150 | module.exports = routes; 151 | } 152 | }, 153 | isSupported() { 154 | return typeof module === "object"; 155 | }, 156 | }, 157 | AMD: { 158 | define(routes) { 159 | if (define) { 160 | define([], function () { 161 | return routes; 162 | }); 163 | } 164 | }, 165 | isSupported() { 166 | return typeof define === "function" && !!define.amd; 167 | }, 168 | }, 169 | UMD: { 170 | define(routes) { 171 | if (ModuleReferences.AMD.isSupported()) { 172 | ModuleReferences.AMD.define(routes); 173 | } else { 174 | if (ModuleReferences.CJS.isSupported()) { 175 | try { 176 | ModuleReferences.CJS.define(routes); 177 | } catch (error) { 178 | if (error.name !== "TypeError") throw error; 179 | } 180 | } 181 | } 182 | }, 183 | isSupported() { 184 | return ( 185 | ModuleReferences.AMD.isSupported() || 186 | ModuleReferences.CJS.isSupported() 187 | ); 188 | }, 189 | }, 190 | ESM: { 191 | define() { 192 | // Module can only be defined using ruby code generation 193 | }, 194 | isSupported() { 195 | // Its impossible to check if "export" keyword is supported 196 | return true; 197 | }, 198 | }, 199 | NIL: { 200 | define() { 201 | // Defined using RubyVariables.WRAPPER 202 | }, 203 | isSupported() { 204 | return true; 205 | }, 206 | }, 207 | DTS: { 208 | // Acts the same as ESM 209 | define(routes) { 210 | ModuleReferences.ESM.define(routes); 211 | }, 212 | isSupported() { 213 | return ModuleReferences.ESM.isSupported(); 214 | }, 215 | }, 216 | }; 217 | 218 | class ParametersMissing extends Error { 219 | readonly keys: string[]; 220 | constructor(...keys: string[]) { 221 | super(`Route missing required keys: ${keys.join(", ")}`); 222 | this.keys = keys; 223 | Object.setPrototypeOf(this, Object.getPrototypeOf(this)); 224 | this.name = ParametersMissing.name; 225 | } 226 | } 227 | 228 | const ReservedOptions = [ 229 | "anchor", 230 | "trailing_slash", 231 | "subdomain", 232 | "host", 233 | "port", 234 | "protocol", 235 | "script_name", 236 | ] as const; 237 | 238 | type ReservedOption = (typeof ReservedOptions)[any]; 239 | 240 | class UtilsClass { 241 | configuration: Configuration = { 242 | prefix: RubyVariables.PREFIX, 243 | default_url_options: RubyVariables.DEFAULT_URL_OPTIONS, 244 | special_options_key: RubyVariables.SPECIAL_OPTIONS_KEY, 245 | serializer: 246 | RubyVariables.SERIALIZER || this.default_serializer.bind(this), 247 | }; 248 | 249 | default_serializer(value: unknown, prefix?: string | null): string { 250 | if (!prefix && !this.is_object(value)) { 251 | throw new Error("Url parameters should be a javascript hash"); 252 | } 253 | prefix = prefix || ""; 254 | const result: string[] = []; 255 | if (this.is_array(value)) { 256 | for (const element of value) { 257 | result.push(this.default_serializer(element, prefix + "[]")); 258 | } 259 | } else if (this.is_object(value)) { 260 | for (let key in value) { 261 | if (!hasProp(value, key)) continue; 262 | let prop = value[key]; 263 | if (prefix) { 264 | key = prefix + "[" + key + "]"; 265 | } 266 | const subvalue = this.default_serializer(prop, key); 267 | if (subvalue.length) { 268 | result.push(subvalue); 269 | } 270 | } 271 | } else { 272 | result.push( 273 | this.is_not_nullable(value) || 274 | RubyVariables.DEPRECATED_NIL_QUERY_PARAMETER_BEHAVIOR 275 | ? encodeURIComponent(prefix) + 276 | "=" + 277 | encodeURIComponent("" + (value ?? "")) 278 | : encodeURIComponent(prefix) 279 | ); 280 | } 281 | return result.join("&"); 282 | } 283 | 284 | serialize(object: Serializable): string { 285 | return this.configuration.serializer(object); 286 | } 287 | 288 | extract_options( 289 | number_of_params: number, 290 | args: OptionalRouteParameter[] 291 | ): { 292 | args: OptionalRouteParameter[]; 293 | options: RouteOptions; 294 | } { 295 | const last_el = args[args.length - 1]; 296 | if ( 297 | (args.length > number_of_params && last_el === 0) || 298 | (this.is_object(last_el) && 299 | !this.looks_like_serialized_model(last_el)) 300 | ) { 301 | if (this.is_object(last_el)) { 302 | delete last_el[this.configuration.special_options_key]; 303 | } 304 | return { 305 | args: args.slice(0, args.length - 1), 306 | options: last_el as unknown as RouteOptions, 307 | }; 308 | } else { 309 | return { args, options: {} }; 310 | } 311 | } 312 | 313 | looks_like_serialized_model( 314 | object: unknown 315 | ): object is ModelRouteParameter { 316 | return ( 317 | this.is_object(object) && 318 | !(this.configuration.special_options_key in object) && 319 | ("id" in object || "to_param" in object || "toParam" in object) 320 | ); 321 | } 322 | 323 | path_identifier(object: QueryRouteParameter): string { 324 | const result = this.unwrap_path_identifier(object); 325 | return this.is_nullable(result) || 326 | (RubyVariables.DEPRECATED_FALSE_PARAMETER_BEHAVIOR && 327 | result === false) 328 | ? "" 329 | : "" + result; 330 | } 331 | 332 | unwrap_path_identifier(object: QueryRouteParameter): unknown { 333 | let result: unknown = object; 334 | if (!this.is_object(object)) { 335 | return object; 336 | } 337 | if ("to_param" in object) { 338 | result = object.to_param; 339 | } else if ("toParam" in object) { 340 | result = object.toParam; 341 | } else if ("id" in object) { 342 | result = object.id; 343 | } else { 344 | result = object; 345 | } 346 | return this.is_callable(result) ? result.call(object) : result; 347 | } 348 | 349 | partition_parameters( 350 | parts: string[], 351 | required_params: string[], 352 | default_options: RouteParameters, 353 | call_arguments: OptionalRouteParameter[] 354 | ): { 355 | keyword_parameters: KeywordUrlOptions; 356 | query_parameters: RouteParameters; 357 | } { 358 | // eslint-disable-next-line prefer-const 359 | let { args, options } = this.extract_options( 360 | parts.length, 361 | call_arguments 362 | ); 363 | if (args.length > parts.length) { 364 | throw new Error("Too many parameters provided for path"); 365 | } 366 | let use_all_parts = args.length > required_params.length; 367 | const parts_options: RouteParameters = { 368 | ...this.configuration.default_url_options, 369 | }; 370 | for (const key in options) { 371 | const value = options[key]; 372 | if (!hasProp(options, key)) continue; 373 | use_all_parts = true; 374 | if (parts.includes(key)) { 375 | parts_options[key] = value; 376 | } 377 | } 378 | options = { 379 | ...this.configuration.default_url_options, 380 | ...default_options, 381 | ...options, 382 | }; 383 | 384 | const keyword_parameters: KeywordUrlOptions = {}; 385 | let query_parameters: RouteParameters = {}; 386 | for (const key in options) { 387 | if (!hasProp(options, key)) continue; 388 | const value = options[key]; 389 | if (key === "params") { 390 | if (this.is_object(value)) { 391 | query_parameters = { 392 | ...query_parameters, 393 | ...(value as RouteParameters), 394 | }; 395 | } else { 396 | throw new Error("params value should always be an object"); 397 | } 398 | } else if (this.is_reserved_option(key)) { 399 | keyword_parameters[key] = value as any; 400 | } else { 401 | if ( 402 | !this.is_nullable(value) && 403 | (value !== default_options[key] || required_params.includes(key)) 404 | ) { 405 | query_parameters[key] = value; 406 | } 407 | } 408 | } 409 | const route_parts = use_all_parts ? parts : required_params; 410 | let i = 0; 411 | for (const part of route_parts) { 412 | if (i < args.length) { 413 | const value = args[i]; 414 | if (!hasProp(parts_options, part)) { 415 | query_parameters[part] = value; 416 | ++i; 417 | } 418 | } 419 | } 420 | return { keyword_parameters, query_parameters }; 421 | } 422 | 423 | build_route( 424 | parts: string[], 425 | required_params: string[], 426 | default_options: RouteParameters, 427 | route: RouteTree, 428 | absolute: boolean, 429 | args: OptionalRouteParameter[] 430 | ): string { 431 | const { keyword_parameters, query_parameters } = 432 | this.partition_parameters( 433 | parts, 434 | required_params, 435 | default_options, 436 | args 437 | ); 438 | 439 | let { trailing_slash, anchor, script_name } = keyword_parameters; 440 | const missing_params = required_params.filter( 441 | (param) => 442 | !hasProp(query_parameters, param) || 443 | this.is_nullable(query_parameters[param]) 444 | ); 445 | if (missing_params.length) { 446 | throw new ParametersMissing(...missing_params); 447 | } 448 | let result = this.get_prefix() + this.visit(route, query_parameters); 449 | if (trailing_slash) { 450 | result = result.replace(/(.*?)[/]?$/, "$1/"); 451 | } 452 | const url_params = this.serialize(query_parameters); 453 | if (url_params.length) { 454 | result += "?" + url_params; 455 | } 456 | if (anchor) { 457 | result += "#" + anchor; 458 | } 459 | if (script_name) { 460 | const last_index = script_name.length - 1; 461 | if (script_name[last_index] == "/" && result[0] == "/") { 462 | script_name = script_name.slice(0, last_index); 463 | } 464 | result = script_name + result; 465 | } 466 | if (absolute) { 467 | result = this.route_url(keyword_parameters) + result; 468 | } 469 | return result; 470 | } 471 | 472 | visit( 473 | route: RouteTree, 474 | parameters: RouteParameters, 475 | optional = false 476 | ): string { 477 | switch (route[0]) { 478 | case NodeTypes.GROUP: 479 | return this.visit(route[1], parameters, true); 480 | case NodeTypes.CAT: 481 | return this.visit_cat(route, parameters, optional); 482 | case NodeTypes.SYMBOL: 483 | return this.visit_symbol(route, parameters, optional); 484 | case NodeTypes.STAR: 485 | return this.visit_globbing(route[1], parameters, true); 486 | case NodeTypes.LITERAL: 487 | case NodeTypes.SLASH: 488 | case NodeTypes.DOT: 489 | return route[1]; 490 | default: 491 | throw new Error("Unknown Rails node type"); 492 | } 493 | } 494 | 495 | is_not_nullable(object: T): object is NonNullable { 496 | return !this.is_nullable(object); 497 | } 498 | 499 | is_nullable(object: unknown): object is null | undefined { 500 | return object === undefined || object === null; 501 | } 502 | 503 | visit_cat( 504 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 505 | [_type, left, right]: RouteNode, 506 | parameters: RouteParameters, 507 | optional: boolean 508 | ): string { 509 | const left_part = this.visit(left, parameters, optional); 510 | let right_part = this.visit(right, parameters, optional); 511 | if ( 512 | optional && 513 | ((this.is_optional_node(left[0]) && !left_part) || 514 | (this.is_optional_node(right[0]) && !right_part)) 515 | ) { 516 | return ""; 517 | } 518 | // if left_part ends on '/' and right_part starts on '/' 519 | if (left_part[left_part.length - 1] === "/" && right_part[0] === "/") { 520 | // strip slash from right_part 521 | // to prevent double slash 522 | right_part = right_part.substring(1); 523 | } 524 | return left_part + right_part; 525 | } 526 | 527 | visit_symbol( 528 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 529 | [_type, key]: RouteNode, 530 | parameters: RouteParameters, 531 | optional: boolean 532 | ): string { 533 | const value = this.path_identifier(parameters[key]); 534 | delete parameters[key]; 535 | if (value.length) { 536 | return this.encode_segment(value); 537 | } 538 | if (optional) { 539 | return ""; 540 | } else { 541 | throw new ParametersMissing(key); 542 | } 543 | } 544 | 545 | encode_segment(segment: string): string { 546 | if (segment.match(/^[a-zA-Z0-9-]$/)) { 547 | // Performance optimization for 99% of cases 548 | return segment; 549 | } 550 | return (segment.match(/./gu) || []) 551 | .map((ch) => { 552 | const code = ch.charCodeAt(0); 553 | if ( 554 | UnescapedRanges.find( 555 | (range) => code >= range[0] && code <= range[1] 556 | ) || 557 | UnescapedSpecials.includes(code) 558 | ) { 559 | return ch; 560 | } else { 561 | return encodeURIComponent(ch); 562 | } 563 | }) 564 | .join(""); 565 | } 566 | 567 | is_optional_node(node: NodeTypes): boolean { 568 | return [NodeTypes.STAR, NodeTypes.SYMBOL, NodeTypes.CAT].includes(node); 569 | } 570 | 571 | build_path_spec(route: RouteTree, wildcard = false): string { 572 | let key: string; 573 | switch (route[0]) { 574 | case NodeTypes.GROUP: 575 | return `(${this.build_path_spec(route[1])})`; 576 | case NodeTypes.CAT: 577 | return ( 578 | this.build_path_spec(route[1]) + this.build_path_spec(route[2]) 579 | ); 580 | case NodeTypes.STAR: 581 | return this.build_path_spec(route[1], true); 582 | case NodeTypes.SYMBOL: 583 | key = route[1]; 584 | if (wildcard) { 585 | return (key.startsWith("*") ? "" : "*") + key; 586 | } else { 587 | return ":" + key; 588 | } 589 | break; 590 | case NodeTypes.SLASH: 591 | case NodeTypes.DOT: 592 | case NodeTypes.LITERAL: 593 | return route[1]; 594 | default: 595 | throw new Error("Unknown Rails node type"); 596 | } 597 | } 598 | 599 | visit_globbing( 600 | route: RouteTree, 601 | parameters: RouteParameters, 602 | optional: boolean 603 | ): string { 604 | const key = route[1] as string; 605 | let value = parameters[key]; 606 | delete parameters[key]; 607 | if (this.is_nullable(value)) { 608 | return this.visit(route, parameters, optional); 609 | } 610 | if (this.is_array(value)) { 611 | value = value.join("/"); 612 | } 613 | const result = this.path_identifier(value as any); 614 | return encodeURI(result); 615 | } 616 | 617 | get_prefix(): string { 618 | const prefix = this.configuration.prefix; 619 | return prefix.match("/$") 620 | ? prefix.substring(0, prefix.length - 1) 621 | : prefix; 622 | } 623 | 624 | route( 625 | parts_table: PartsTable, 626 | route_spec: RouteTree, 627 | absolute = false 628 | ): RouteHelper { 629 | const required_params: string[] = []; 630 | const parts: string[] = []; 631 | const default_options: RouteParameters = {}; 632 | for (const [part, { r: required, d: value }] of Object.entries( 633 | parts_table 634 | )) { 635 | parts.push(part); 636 | if (required) { 637 | required_params.push(part); 638 | } 639 | if (this.is_not_nullable(value)) { 640 | default_options[part] = value; 641 | } 642 | } 643 | const result = (...args: OptionalRouteParameter[]): string => { 644 | return this.build_route( 645 | parts, 646 | required_params, 647 | default_options, 648 | route_spec, 649 | absolute, 650 | args 651 | ); 652 | }; 653 | result.requiredParams = () => required_params; 654 | result.toString = () => { 655 | return this.build_path_spec(route_spec); 656 | }; 657 | return result as any; 658 | } 659 | 660 | route_url(route_defaults: KeywordUrlOptions): string { 661 | const hostname = route_defaults.host || this.current_host(); 662 | if (!hostname) { 663 | return ""; 664 | } 665 | const subdomain = route_defaults.subdomain 666 | ? route_defaults.subdomain + "." 667 | : ""; 668 | const protocol = route_defaults.protocol || this.current_protocol(); 669 | let port = 670 | route_defaults.port || 671 | (!route_defaults.host ? this.current_port() : undefined); 672 | port = port ? ":" + port : ""; 673 | return protocol + "://" + subdomain + hostname + port; 674 | } 675 | 676 | current_host(): string { 677 | return (isBrowser && window?.location?.hostname) || ""; 678 | } 679 | 680 | current_protocol(): string { 681 | return ( 682 | (isBrowser && window?.location?.protocol?.replace(/:$/, "")) || "http" 683 | ); 684 | } 685 | 686 | current_port(): string { 687 | return (isBrowser && window?.location?.port) || ""; 688 | } 689 | 690 | is_object(value: unknown): value is Collection { 691 | return ( 692 | typeof value === "object" && 693 | Object.prototype.toString.call(value) === "[object Object]" 694 | ); 695 | } 696 | 697 | is_array(object: unknown | T[]): object is T[] { 698 | return object instanceof Array; 699 | } 700 | 701 | is_callable(object: unknown): object is Function { 702 | return typeof object === "function" && !!object.call; 703 | } 704 | 705 | is_reserved_option(key: unknown): key is ReservedOption { 706 | return ReservedOptions.includes(key as any); 707 | } 708 | 709 | configure(new_config: Partial): Configuration { 710 | if (new_config.prefix) { 711 | console.warn( 712 | "JsRoutes configuration prefix option is deprecated in favor of default_url_options.script_name." 713 | ); 714 | } 715 | this.configuration = { ...this.configuration, ...new_config }; 716 | return this.configuration; 717 | } 718 | 719 | config(): Configuration { 720 | return { ...this.configuration }; 721 | } 722 | 723 | is_module_supported(name: ModuleType): boolean { 724 | return ModuleReferences[name].isSupported(); 725 | } 726 | 727 | ensure_module_supported(name: ModuleType): void { 728 | if (!this.is_module_supported(name)) { 729 | throw new Error(`${name} is not supported by runtime`); 730 | } 731 | } 732 | 733 | define_module( 734 | name: ModuleType, 735 | module: RouterExposedMethods 736 | ): RouterExposedMethods { 737 | this.ensure_module_supported(name); 738 | ModuleReferences[name].define(module); 739 | return module; 740 | } 741 | } 742 | 743 | const utils = new UtilsClass(); 744 | 745 | // We want this helper name to be short 746 | const __jsr = { 747 | r( 748 | parts_table: PartsTable, 749 | route_spec: RouteTree, 750 | absolute?: boolean 751 | ): RouteHelper { 752 | return utils.route(parts_table, route_spec, absolute); 753 | }, 754 | }; 755 | 756 | return utils.define_module(RubyVariables.MODULE_TYPE, { 757 | ...__jsr, 758 | configure: (config: Partial) => { 759 | return utils.configure(config); 760 | }, 761 | config: (): Configuration => { 762 | return utils.config(); 763 | }, 764 | serialize: (object: Serializable): string => { 765 | return utils.serialize(object); 766 | }, 767 | ...RubyVariables.ROUTES_OBJECT, 768 | }); 769 | } 770 | )(); 771 | --------------------------------------------------------------------------------