diff --git a/examples/by-features/custom-scope-range/.vscode/i18n-ally-custom-framework.yml b/examples/by-features/custom-scope-range/.vscode/i18n-ally-custom-framework.yml
new file mode 100644
index 00000000..aac3d974
--- /dev/null
+++ b/examples/by-features/custom-scope-range/.vscode/i18n-ally-custom-framework.yml
@@ -0,0 +1,7 @@
+languageIds:
+ - javascript
+ - typescript
+ - javascriptreact
+ - typescriptreact
+scopeRangeRegex: "customScopeRangeFn\\(\\s*\\[?\\s*['\"`](.*?)['\"`]"
+monopoly: false
diff --git a/examples/by-features/custom-scope-range/.vscode/settings.json b/examples/by-features/custom-scope-range/.vscode/settings.json
new file mode 100644
index 00000000..8111f69d
--- /dev/null
+++ b/examples/by-features/custom-scope-range/.vscode/settings.json
@@ -0,0 +1,7 @@
+{
+ "i18n-ally.localesPaths": "public/locales",
+ "i18n-ally.enabledFrameworks": ["react", "i18next", "custom"],
+ "i18n-ally.namespace": true,
+ "i18n-ally.pathMatcher": "{locale}/{namespaces}.json",
+ "i18n-ally.keystyle": "nested"
+}
diff --git a/examples/by-features/custom-scope-range/package.json b/examples/by-features/custom-scope-range/package.json
new file mode 100644
index 00000000..b6cbdf89
--- /dev/null
+++ b/examples/by-features/custom-scope-range/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "custom-scope-range",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "i18next": "20.3.0",
+ "i18next-xhr-backend": "3.2.2",
+ "react": "17.0.2",
+ "react-dom": "17.0.2",
+ "react-i18next": "11.9.0"
+ }
+}
diff --git a/examples/by-features/custom-scope-range/public/index.html b/examples/by-features/custom-scope-range/public/index.html
new file mode 100644
index 00000000..018b5a51
--- /dev/null
+++ b/examples/by-features/custom-scope-range/public/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ React App
+
+
+
+
+
+
diff --git a/examples/by-features/custom-scope-range/public/locales/en/pages/home.json b/examples/by-features/custom-scope-range/public/locales/en/pages/home.json
new file mode 100644
index 00000000..117b3803
--- /dev/null
+++ b/examples/by-features/custom-scope-range/public/locales/en/pages/home.json
@@ -0,0 +1,3 @@
+{
+ "title": "Home"
+}
diff --git a/examples/by-features/custom-scope-range/public/locales/en/translation.json b/examples/by-features/custom-scope-range/public/locales/en/translation.json
new file mode 100644
index 00000000..a02edf00
--- /dev/null
+++ b/examples/by-features/custom-scope-range/public/locales/en/translation.json
@@ -0,0 +1,11 @@
+{
+ "title": "hello",
+ "description": {
+ "part1": "To get started, edit <1>src/App.js1> and save to reload.",
+ "part2": "Switch language between english and german using buttons above."
+ },
+ "titlew": "ok",
+ "foo": {
+ "bar": "foobar"
+ }
+}
\ No newline at end of file
diff --git a/examples/by-features/custom-scope-range/src/App.css b/examples/by-features/custom-scope-range/src/App.css
new file mode 100644
index 00000000..75fd982e
--- /dev/null
+++ b/examples/by-features/custom-scope-range/src/App.css
@@ -0,0 +1,10 @@
+.App {
+ text-align: center;
+}
+
+.App-header {
+ background-color: #222;
+ height: 100px;
+ padding: 20px;
+ color: white;
+}
diff --git a/examples/by-features/custom-scope-range/src/App.jsx b/examples/by-features/custom-scope-range/src/App.jsx
new file mode 100644
index 00000000..0394be34
--- /dev/null
+++ b/examples/by-features/custom-scope-range/src/App.jsx
@@ -0,0 +1,78 @@
+/* eslint-disable no-undef */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/* eslint-disable no-unused-vars */
+// eslint-disable-next-line no-use-before-define
+import React from 'react'
+import { Trans } from 'react-i18next'
+import { customScopeRangeFn } from './customScopeRange'
+import './App.css'
+
+// Component using the Trans component
+function MyComponent() {
+ return (
+
+ To get started, edit src/App.js
and save to reload.
+
+ )
+}
+
+// page uses the hook
+function Page() {
+ const { t } = customScopeRangeFn()
+
+ return (
+
+
+
+
+
{t('translation:description.part2')}
+ {/* plain
, #423 */}
+ Fallback text
+
+ )
+}
+
+// function with scope
+function Page2() {
+ const { t } = customScopeRangeFn(['translation:foo'])
+
+ // inside default namespace ("foo.bar")
+ t('bar')
+
+ // explicit namespace
+ t('pages.home:title')
+ t('pages/home:title')
+}
+
+// function with another scope
+function Page3() {
+ const { t } = customScopeRangeFn('pages/home')
+
+ t('title')
+
+ // explicit namespace
+ t('translation:title')
+}
+
+// function with scope in options
+function Page4() {
+ const { t } = customScopeRangeFn('pages/home')
+
+ t('title')
+
+ // explicit namespace
+ t('title', { ns: 'translation' })
+}
+
+// component with scope in props
+function Page5() {
+ const { t } = customScopeRangeFn('pages/home')
+
+ return (
+
+
+ {/* explicit namespace */}
+
+
+ )
+}
diff --git a/examples/by-features/custom-scope-range/src/customScopeRange.js b/examples/by-features/custom-scope-range/src/customScopeRange.js
new file mode 100644
index 00000000..5fd94754
--- /dev/null
+++ b/examples/by-features/custom-scope-range/src/customScopeRange.js
@@ -0,0 +1 @@
+export { useTranslation as customScopeRangeFn } from 'react-i18next'
diff --git a/examples/by-features/custom-scope-range/src/i18n.js b/examples/by-features/custom-scope-range/src/i18n.js
new file mode 100644
index 00000000..15024953
--- /dev/null
+++ b/examples/by-features/custom-scope-range/src/i18n.js
@@ -0,0 +1,18 @@
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import React from 'react'
+import i18n from 'i18next'
+import { initReactI18next } from 'react-i18next'
+
+i18n
+ .use(Backend)
+ .use(initReactI18next)
+ .init({
+ fallbackLng: 'en',
+ debug: true,
+
+ interpolation: {
+ escapeValue: false, // not needed for react as it escapes by default
+ },
+ })
+
+export default i18n
diff --git a/examples/by-features/custom-scope-range/src/index.js b/examples/by-features/custom-scope-range/src/index.js
new file mode 100644
index 00000000..31d229e7
--- /dev/null
+++ b/examples/by-features/custom-scope-range/src/index.js
@@ -0,0 +1,8 @@
+// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-use-before-define
+import React from 'react'
+import ReactDOM from 'react-dom'
+import App from './App'
+
+import './i18n'
+
+ReactDOM.render(, document.getElementById('root'))
diff --git a/src/frameworks/custom.ts b/src/frameworks/custom.ts
index e99ef21b..5fc8af19 100644
--- a/src/frameworks/custom.ts
+++ b/src/frameworks/custom.ts
@@ -1,8 +1,8 @@
import path from 'path'
import fs from 'fs'
-import { workspace, FileSystemWatcher } from 'vscode'
+import { workspace, FileSystemWatcher, TextDocument } from 'vscode'
import YAML from 'js-yaml'
-import { Framework } from './base'
+import { Framework, ScopeRange } from './base'
import { Global } from '~/core'
import { LanguageId, File, Log } from '~/utils'
@@ -11,6 +11,7 @@ const CustomFrameworkConfigFilename = './.vscode/i18n-ally-custom-framework.yml'
interface CustomFrameworkConfig {
languageIds?: LanguageId[] | LanguageId
usageMatchRegex?: string[] | string
+ scopeRangeRegex?: string
refactorTemplates?: string[]
monopoly?: boolean
@@ -81,6 +82,38 @@ class CustomFramework extends Framework {
.map(i => i.replace(/\$1/g, keypath))
}
+ getScopeRange(document: TextDocument): ScopeRange[] | undefined {
+ if (!this.data?.scopeRangeRegex)
+ return undefined
+
+ if (!this.languageIds.includes(document.languageId as any))
+ return
+
+ const ranges: ScopeRange[] = []
+ const text = document.getText()
+ const reg = new RegExp(this.data.scopeRangeRegex, 'g')
+
+ for (const match of text.matchAll(reg)) {
+ if (match?.index == null)
+ continue
+
+ // end previous scope
+ if (ranges.length)
+ ranges[ranges.length - 1].end = match.index
+
+ // start new scope if namespace provides
+ if (match[1]) {
+ ranges.push({
+ start: match.index,
+ end: text.length,
+ namespace: match[1] as string,
+ })
+ }
+ }
+
+ return ranges
+ }
+
startWatch(root?: string) {
if (this.watchingFor) {
this.watchingFor = undefined