feat(docs): add translation status (#1689)

Co-authored-by: Michel EDIGHOFFER <edimitchel@gmail.com>
This commit is contained in:
Joaquín Sánchez 2023-02-11 17:15:08 +01:00 committed by GitHub
parent 0eefcfa281
commit 972a13499f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 705 additions and 3 deletions

View file

@ -9,3 +9,4 @@ public/
https-dev-config/localhost.crt
https-dev-config/localhost.key
Dockerfile
docs/translation-status.json

View file

@ -198,7 +198,7 @@ const buildLocales = () => {
return useLocales.sort((a, b) => a.code.localeCompare(b.code))
}
const currentLocales = buildLocales()
export const currentLocales = buildLocales()
const datetimeFormats = Object.values(currentLocales).reduce((acc, data) => {
const dateTimeFormats = data.dateTimeFormats

1
docs/.gitignore vendored
View file

@ -10,3 +10,4 @@ dist
sw.*
.env
.output
translation-status.json

View file

@ -0,0 +1,15 @@
<script>
export default {
name: 'ClipboardIcon',
props: { copy: Boolean },
}
</script>
<template>
<svg v-if="copy" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2v2Z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2v2m.5 6.5L9 12l2 2l4.5-4.5L17 11l-6 6l-3.5-3.5Z" />
</svg>
</template>

View file

@ -0,0 +1,15 @@
<script>
export default {
name: 'ToogleIcon',
props: { up: Boolean },
}
</script>
<template>
<svg v-if="up" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="currentColor" d="m12 10.828l-4.95 4.95l-1.414-1.414L12 8l6.364 6.364l-1.414 1.414z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="currentColor" d="m12 13.172l4.95-4.95l1.414 1.414L12 16L5.636 9.636L7.05 8.222z" />
</svg>
</template>

View file

@ -0,0 +1,338 @@
<script setup lang="ts">
import type { TranslationStatus } from '../../types'
const localesStatuses: TranslationStatus = await import('../../translation-status.json').then(m => m.default)
const totalReference = localesStatuses.en.total
type Tab = 'missing' | 'outdated'
const hidden = ref(true)
const locale = ref()
const localeTab = ref<Tab>('missing')
const copied = ref(false)
const currentLocale = computed(() => {
if (hidden.value || !locale.value)
return undefined
return localesStatuses as Record<string, any>
})
const localeTitle = computed(() => {
if (hidden.value || !locale.value)
return undefined
return localeTab.value === 'missing'
? `Missing keys in ${locale.value.file}`
: `Outdated keys in ${locale.value.file}`
})
const missingEntries = computed<string[]>(() => {
if (hidden.value || !currentLocale.value || localeTab.value !== 'missing')
return []
return localesStatuses[locale.value].missing
})
const outdatedEntries = computed<string[]>(() => {
if (hidden.value || !currentLocale.value || localeTab.value !== 'outdated')
return []
return localesStatuses[locale.value]!.outdated
})
const showDetail = (key: string, tab: Tab = 'missing', fromTab = false) => {
if (key === locale.value && tab === localeTab.value) {
if (fromTab)
return
nextTick().then(() => hidden.value = !hidden.value)
return
}
locale.value = key
localeTab.value = tab
nextTick().then(() => hidden.value = false)
}
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText([
`# ${localeTitle.value}`,
(localeTab.value === 'missing' ? missingEntries.value : outdatedEntries.value).join('\n')].join('\n'),
)
copied.value = true
setTimeout(() => copied.value = false, 750)
}
catch {}
}
</script>
<template>
<div>
<table class="w-full">
<caption>
<div>You can see the detail (missing and outdated keys) by clicking on the corresponding row.</div>
<div>
If you want to send a PR, click on <strong>Edit</strong> link on the corresponding translation file, it will open <strong>Codeflow</strong>:
<NuxtLink
target="_blank"
href="https://developer.stackblitz.com/codeflow/working-in-codeflow-ide#making-a-pr-with-codeflow-ide"
title="How to make a PR with Codeflow IDE (opens in new window)"
>
read the following guide
</NuxtLink>
</div>
</caption>
<thead>
<tr>
<th>Language</th>
<th title="Keys correctly translated">
Translated
</th>
<th title="Keys missing from source which need translation for the language">
Missing
</th>
<th title="Keys which could be safely removed">
Outdated
</th>
<th>Total</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template v-for="({ title, file, translated, missing, outdated, total, isSource }, key) in localesStatuses" :key="key">
<tr
v-if="totalReference > 0"
:class="[{ expandable: !isSource }]"
:title="!isSource ? 'Click to show detail' : undefined"
@click="!isSource && showDetail(key, 'missing')"
>
<td :class="[{ expandable: !isSource }]">
<div>
<ToogleIcon v-if="!isSource" :up="hidden || key !== locale" />
{{ title }}
</div>
</td>
<template v-if="isSource">
<td colspan="5" class="source-text">
<div>
{{ total }} keys as source
</div>
</td>
</template>
<template v-else>
<td>
<strong>{{ `${translated?.length ?? 0}` }}</strong> {{ `(${(100 * (translated?.length ?? 0) / totalReference).toFixed(1)}%)` }}
</td>
<td>
<strong>{{ `${missing?.length ?? 0}` }}</strong> {{ `(${(100 * (missing?.length ?? 0) / totalReference).toFixed(1)}%)` }}
</td>
<td>
<strong>{{ `${outdated?.length ?? 0}` }}</strong> {{ `(${(100 * (outdated?.length ?? 0) / totalReference).toFixed(1)}%)` }}
</td>
<td><strong>{{ `${total}` }}</strong></td>
<td>
<NuxtLink
v-if="outdated.length > 0 || missing.length > 0"
:href="`https://pr.new/github.com/elk-zone/elk/tree/main/locales/${file}`"
target="_blank"
class="codeflow"
title="Raise a PR with Codeflow (opens in new window)"
@click.stop
>
Edit
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="currentColor" d="M5 21q-.825 0-1.413-.587Q3 19.825 3 19V5q0-.825.587-1.413Q4.175 3 5 3h7v2H5v14h14v-7h2v7q0 .825-.587 1.413Q19.825 21 19 21Zm4.7-5.3l-1.4-1.4L17.6 5H14V3h7v7h-2V6.4Z" />
</svg>
</NuxtLink>
</td>
</template>
</tr>
<template v-if="key === locale && !hidden">
<tr>
<td colspan="6">
<div class="detail">
<header>
<h2 class="tabs">
<button
:class="localeTab === 'missing' ? 'current' : null"
@click="showDetail(key, 'missing', true)"
>
Missing keys
</button>
<button
:class="localeTab === 'outdated' ? 'current' : null"
@click="showDetail(key, 'outdated', true)"
>
Outdated keys
</button>
</h2>
</header>
<ul v-if="localeTab === 'missing'">
<li v-for="entry in missingEntries" :key="entry">
<pre>{{ entry }}</pre>
</li>
</ul>
<ul v-else>
<li v-for="entry in outdatedEntries" :key="entry">
<pre>{{ entry }}</pre>
</li>
</ul>
<button @click="copyToClipboard()">
<ClipboardIcon :copy="!copied" />
Copy to clipboard
</button>
</div>
</td>
</tr>
</template>
</template>
</tbody>
</table>
</div>
</template>
<style scoped>
table {
font-size: 0.9rem;
width: 100%;
border-collapse: collapse;
border: 1px solid #ccc;
}
pre {
font-size: 0.75rem;
}
caption {
padding: 0.3rem;
background: #eee;
border: 1px solid #ccc;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
border-bottom: none;
}
caption a {
text-decoration: underline;
}
th {
text-align: left;
border-bottom: 1px solid #ccc;
padding: 0.5rem;
}
th:not(:first-of-type),
td:not(:first-of-type) {
border-left: 1px solid #eee;
}
td {
padding: 0.5rem;
}
tr.expandable td:first-of-type {
padding-left: 4px;
}
tr.expandable, tr.expandable td {
cursor: pointer;
}
a.codeflow,
td.expandable div {
display: flex;
align-items: center;
flex-direction: row;
column-gap: 4px;
}
td.expandable > svg {
color: currentColor;
}
tbody tr td {
border-bottom: 1px solid #eee;
}
th[title] {
text-decoration: underline dotted white;
}
.source-text {
text-align: center;
font-weight: bold;
padding: 10px 0;
text-transform: uppercase;
}
.detail {
border: 1px solid #ccc;
border-radius: 3px;
}
.detail header {
padding: 0 0.3rem;
display: flex;
background: #eee;
justify-content: space-between;
align-items: center;
}
.detail header h2 button {
font-weight: bold;
padding: 0.5rem;
background-color: #eee;
}
.detail header .tabs button.current {
background-color: white;
}
.detail header .tabs + .heading-buttons {
display: flex;
flex-direction: row;
column-gap: 0.4rem;
align-items: center;
}
.detail ul {
padding: 0.3rem 0.5rem;
max-height: 250px;
min-height: 250px;
overflow-y: auto;
border-bottom: 1px solid #eee;
}
.detail > button {
display: flex;
/*justify-content: space-between;*/
align-items: center;
column-gap: 0.3rem;
padding: 0.3rem 0.5rem;
background: transparent;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 3px;
margin: 0.5rem;
}
@media (prefers-color-scheme: dark) {
.detail header {
background: #333;
color: #fff;
}
.detail header h2 button {
background-color: #333;
color: #fff;
}
.detail header .tabs button.current {
background-color: white;
color: #333;
}
table caption {
background: #333;
color: #fff;
}
}
</style>

View file

@ -34,6 +34,10 @@ Elk uses [Vitest](https://vitest.dev). You can run the test suite with:
nr test
```
## Translation status
<TranslationState />
# Stack
- [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling

200
docs/package-lock.json generated Normal file
View file

@ -0,0 +1,200 @@
{
"name": "elk-docs",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "elk-docs",
"version": "0.1.0",
"devDependencies": {
"@nuxt-themes/docus": "^1.6.1",
"@types/flat": "^5.0.2",
"flat": "^5.0.2",
"flatten": "^1.0.3",
"iso-639-1": "^2.1.15",
"nuxt": "^3.1.1",
"vite-plugin-virtual": "^0.1.1"
}
},
"../node_modules/.pnpm/@nuxt-themes+docus@1.6.3_nuxt@3.1.1/node_modules/@nuxt-themes/docus": {
"version": "1.6.3",
"dev": true,
"dependencies": {
"@nuxt-themes/elements": "^0.5.2",
"@nuxt-themes/tokens": "^1.6.2",
"@nuxt-themes/typography": "^0.6.0",
"@nuxt/content": "^2.4.1",
"@nuxthq/studio": "^0.6.5",
"@vueuse/nuxt": "^9.11.1"
},
"devDependencies": {
"@algolia/client-search": "^4.14.3",
"@docsearch/css": "^3.3.2",
"@docsearch/js": "^3.3.2",
"@nuxtjs/algolia": "^1.5.0",
"@nuxtjs/eslint-config-typescript": "^12.0.0",
"eslint": "^8.32.0",
"nuxt": "3.1.1",
"nuxt-plausible": "^0.1.2",
"release-it": "^15.6.0",
"typescript": "^4.9.4",
"vue": "^3.2.45"
}
},
"../node_modules/.pnpm/flat@5.0.2/node_modules/flat": {
"version": "5.0.2",
"dev": true,
"license": "BSD-3-Clause",
"bin": {
"flat": "cli.js"
},
"devDependencies": {
"mocha": "~8.1.1",
"standard": "^14.3.4"
}
},
"../node_modules/.pnpm/flatten@1.0.3/node_modules/flatten": {
"version": "1.0.3",
"dev": true,
"license": "MIT",
"devDependencies": {}
},
"../node_modules/.pnpm/iso-639-1@2.1.15/node_modules/iso-639-1": {
"version": "2.1.15",
"dev": true,
"license": "MIT",
"devDependencies": {
"babel-cli": "^6.26.0",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"clean-webpack-plugin": "^0.1.17",
"mocha": "^4.0.1",
"webpack": "^3.10.0"
},
"engines": {
"node": ">=6.0"
}
},
"../node_modules/.pnpm/nuxt@3.1.1/node_modules/nuxt": {
"version": "3.1.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@nuxt/devalue": "^2.0.0",
"@nuxt/kit": "3.1.1",
"@nuxt/schema": "3.1.1",
"@nuxt/telemetry": "^2.1.9",
"@nuxt/ui-templates": "^1.1.0",
"@nuxt/vite-builder": "3.1.1",
"@unhead/ssr": "^1.0.18",
"@vue/reactivity": "^3.2.45",
"@vue/shared": "^3.2.45",
"@vueuse/head": "^1.0.23",
"chokidar": "^3.5.3",
"cookie-es": "^0.5.0",
"defu": "^6.1.2",
"destr": "^1.2.2",
"escape-string-regexp": "^5.0.0",
"estree-walker": "^3.0.3",
"fs-extra": "^11.1.0",
"globby": "^13.1.3",
"h3": "^1.0.2",
"hash-sum": "^2.0.0",
"hookable": "^5.4.2",
"jiti": "^1.16.2",
"knitwork": "^1.0.0",
"magic-string": "^0.27.0",
"mlly": "^1.1.0",
"nitropack": "^2.0.0",
"nuxi": "3.1.1",
"ofetch": "^1.0.0",
"ohash": "^1.0.0",
"pathe": "^1.1.0",
"perfect-debounce": "^0.1.3",
"scule": "^1.0.0",
"strip-literal": "^1.0.0",
"ufo": "^1.0.1",
"ultrahtml": "^1.2.0",
"unctx": "^2.1.1",
"unenv": "^1.0.1",
"unhead": "^1.0.18",
"unimport": "^2.0.1",
"unplugin": "^1.0.1",
"untyped": "^1.2.2",
"vue": "^3.2.45",
"vue-bundle-renderer": "^1.0.0",
"vue-devtools-stub": "^0.1.0",
"vue-router": "^4.1.6"
},
"bin": {
"nuxi": "bin/nuxt.mjs",
"nuxt": "bin/nuxt.mjs"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/hash-sum": "^1.0.0",
"unbuild": "latest"
},
"engines": {
"node": "^14.16.0 || ^16.10.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"../node_modules/.pnpm/vite-plugin-virtual@0.1.1/node_modules/vite-plugin-virtual": {
"version": "0.1.1",
"dev": true,
"license": "MIT",
"devDependencies": {
"@antfu/eslint-config": "^0.6.2",
"@types/jest": "^26.0.22",
"@types/node": "^14.14.37",
"@typescript-eslint/eslint-plugin": "^4.20.0",
"eslint": "^7.23.0",
"jest": "^26.6.3",
"jest-esbuild": "^0.1.5",
"rollup": "^2.44.0",
"ts-node": "^9.1.1",
"tsup": "^4.8.21",
"typescript": "^4.2.3",
"vite": "^2.1.5"
},
"peerDependencies": {
"vite": "^2.0.0"
}
},
"node_modules/@nuxt-themes/docus": {
"resolved": "../node_modules/.pnpm/@nuxt-themes+docus@1.6.3_nuxt@3.1.1/node_modules/@nuxt-themes/docus",
"link": true
},
"node_modules/@types/flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-3zsplnP2djeps5P9OyarTxwRpMLoe5Ash8aL9iprw0JxB+FAHjY+ifn4yZUuW4/9hqtnmor6uvjSRzJhiVbrEQ==",
"dev": true
},
"node_modules/flat": {
"resolved": "../node_modules/.pnpm/flat@5.0.2/node_modules/flat",
"link": true
},
"node_modules/flatten": {
"resolved": "../node_modules/.pnpm/flatten@1.0.3/node_modules/flatten",
"link": true
},
"node_modules/iso-639-1": {
"resolved": "../node_modules/.pnpm/iso-639-1@2.1.15/node_modules/iso-639-1",
"link": true
},
"node_modules/nuxt": {
"resolved": "../node_modules/.pnpm/nuxt@3.1.1/node_modules/nuxt",
"link": true
},
"node_modules/vite-plugin-virtual": {
"resolved": "../node_modules/.pnpm/vite-plugin-virtual@0.1.1/node_modules/vite-plugin-virtual",
"link": true
}
}
}

View file

@ -6,10 +6,13 @@
"dev": "nuxi dev",
"build": "nuxi build",
"generate": "nuxi generate",
"preview": "nuxi preview"
"preview": "nuxi preview",
"prepare-translation-status": "nuxi prepare && esno scripts/prepare-translation-status.ts"
},
"devDependencies": {
"@nuxt-themes/docus": "^1.6.1",
"@types/flat": "^5.0.2",
"flat": "^5.0.2",
"nuxt": "^3.1.1"
}
}

View file

@ -0,0 +1,105 @@
import { flatten } from 'flat'
import { createResolver } from '@nuxt/kit'
import { readFile, writeFile } from 'fs-extra'
import { currentLocales } from '../../config/i18n'
import vsCodeConfig from '../../.vscode/settings.json'
import type { LocaleEntry } from '../types'
export const localeData: [code: string, file: string[], title: string][]
= currentLocales.map((l: any) => [l.code, l.files ? l.files : [l.file!], l.name ?? l.code])
function merge(src: Record<string, any>, dst: Record<string, any>) {
for (const key in src) {
if (typeof src[key] === 'object') {
if (!dst[key])
dst[key] = {}
merge(src[key], dst[key])
}
else {
dst[key] = src[key]
}
}
}
async function readI18nFile(file: string | string[]) {
const resolver = createResolver(import.meta.url)
if (Array.isArray(file)) {
const files = await Promise.all(file.map(f => async () => {
return JSON.parse(Buffer.from(
await readFile(resolver.resolve(`../../locales/${f}`), 'utf-8'),
).toString())
})).then(f => f.map(f => f()))
const data: Record<string, any> = files[0]
files.splice(0, 1)
files.forEach(f => merge(f, data))
return data
}
else {
return JSON.parse(Buffer.from(
await readFile(resolver.resolve(`../../locales/${file}`), 'utf-8'),
).toString())
}
}
async function compare(
baseEntries: Record<string, string>,
file: string | string[],
data: LocaleEntry,
) {
const baseEntriesKeys = Object.keys(baseEntries)
const entries: Record<string, any> = await readI18nFile(file)
const flatEntriesKeys = Object.keys(flatten<typeof entries, Record<string, string>>(entries))
data.translated = flatEntriesKeys.filter(e => baseEntriesKeys.includes(e))
data.missing = baseEntriesKeys.filter(e => !flatEntriesKeys.includes(e))
data.outdated = flatEntriesKeys.filter(e => !baseEntriesKeys.includes(e))
data.total = flatEntriesKeys.length
}
async function prepareTranslationStatus() {
const sourceLanguageLocale = localeData.find(l => l[0] === vsCodeConfig['i18n-ally.sourceLanguage'])!
const entries: Record<string, any> = await readI18nFile(sourceLanguageLocale[1])
const flatEntries = flatten<typeof entries, Record<string, string>>(entries)
const data: Record<string, LocaleEntry> = {
en: {
translated: [],
file: 'en.json',
missing: [],
outdated: [],
title: 'English (source)',
total: Object.keys(flatEntries).length,
isSource: true,
},
}
await Promise.all(localeData.filter(l => l[0] !== 'en-US').map(async ([code, file, title]) => {
// eslint-disable-next-line no-console
console.info(`Comparing ${code}...`, title)
data[code] = {
title,
file: Array.isArray(file) ? file[file.length - 1] : file,
translated: [],
missing: [],
outdated: [],
total: 0,
}
await compare(flatEntries, file, data[code])
}))
const sorted: Record<string, any> = { en: { ...data.en } }
Object.keys(data).filter(k => k !== 'en').sort((a, b) => {
return data[a].translated.length - data[b].translated.length
}).forEach((k) => {
sorted[k] = { ...data[k] }
})
await writeFile(
createResolver(import.meta.url).resolve('../translation-status.json'),
JSON.stringify(sorted, null, 2),
{ encoding: 'utf-8' },
)
}
prepareTranslationStatus()

11
docs/types.ts Normal file
View file

@ -0,0 +1,11 @@
export interface LocaleEntry {
title: string
file: string
translated: string[]
missing: string[]
outdated: string[]
total: number
isSource?: boolean
}
export type TranslationStatus = Record<string, LocaleEntry>

View file

@ -23,7 +23,8 @@
"test:typecheck": "stale-dep && vue-tsc --noEmit && vue-tsc --noEmit --project service-worker/tsconfig.json",
"test": "nr test:unit",
"update:team:avatars": "esno scripts/avatars.ts",
"postinstall": "ignore-dependency-scripts \"stale-dep -u && simple-git-hooks && nuxi prepare\"",
"prepare-translation-status": "pnpm -C docs run prepare-translation-status",
"postinstall": "ignore-dependency-scripts \"stale-dep -u && simple-git-hooks && nuxi prepare && nr prepare-translation-status\"",
"release": "stale-dep && bumpp && esno scripts/release.ts"
},
"dependencies": {

View file

@ -228,9 +228,13 @@ importers:
docs:
specifiers:
'@nuxt-themes/docus': ^1.6.1
'@types/flat': ^5.0.2
flat: ^5.0.2
nuxt: ^3.1.1
devDependencies:
'@nuxt-themes/docus': 1.6.3_nuxt@3.1.1
'@types/flat': 5.0.2
flat: 5.0.2
nuxt: 3.1.1
packages:
@ -3328,6 +3332,10 @@ packages:
resolution: {integrity: sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==}
dev: true
/@types/flat/5.0.2:
resolution: {integrity: sha512-3zsplnP2djeps5P9OyarTxwRpMLoe5Ash8aL9iprw0JxB+FAHjY+ifn4yZUuW4/9hqtnmor6uvjSRzJhiVbrEQ==}
dev: true
/@types/fnando__sparkline/0.3.4:
resolution: {integrity: sha512-FWU1zw7CVJYVeDk77FGphTUabfPims4F/Yq+WFB0Gh647lLtiXHWn8vpfT95Fl65IsNBDOhEbxJdhmERMGubNQ==}
dev: true