Merge branch 'develop' into mkjs-n

This commit is contained in:
tamaina 2023-05-10 14:20:07 +00:00
commit 7d8fa48d47
14 changed files with 217 additions and 298 deletions

View File

@ -20,7 +20,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- name: Checkout HEAD - name: Checkout HEAD
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request_target'
run: git checkout ${{ github.head_ref }} run: git checkout ${{ github.head_ref }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
@ -41,12 +41,12 @@ jobs:
- name: Build storybook - name: Build storybook
run: pnpm --filter frontend build-storybook run: pnpm --filter frontend build-storybook
- name: Publish to Chromatic - name: Publish to Chromatic
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master'
run: pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static run: pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static
env: env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
- name: Publish to Chromatic - name: Publish to Chromatic
if: github.event_name != 'pull_request' && github.ref != 'refs/heads/master' if: github.event_name != 'pull_request_target' && github.ref != 'refs/heads/master'
id: chromatic_push id: chromatic_push
run: | run: |
DIFF="${{ github.event.before }} HEAD" DIFF="${{ github.event.before }} HEAD"
@ -61,7 +61,7 @@ jobs:
env: env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
- name: Publish to Chromatic - name: Publish to Chromatic
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request_target'
id: chromatic_pull_request id: chromatic_pull_request
run: | run: |
DIFF="${{ github.base_ref }} HEAD" DIFF="${{ github.base_ref }} HEAD"
@ -77,7 +77,7 @@ jobs:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
- name: Notify that Chromatic will skip testing - name: Notify that Chromatic will skip testing
uses: actions/github-script@v6.4.0 uses: actions/github-script@v6.4.0
if: github.event_name == 'pull_request' && steps.chromatic_pull_request.outputs.skip == 'true' if: github.event_name == 'pull_request_target' && steps.chromatic_pull_request.outputs.skip == 'true'
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

View File

@ -12,7 +12,7 @@
--> -->
## 13.x.x (unreleased) ## 13.12.2
### General ### General
- 投稿したコンテンツのAIによる学習を軽減するオプションを追加 - 投稿したコンテンツのAIによる学習を軽減するオプションを追加
@ -21,7 +21,7 @@
- Fix: ブラーエフェクトを有効にしている状態で高負荷になる問題を修正 - Fix: ブラーエフェクトを有効にしている状態で高負荷になる問題を修正
### Server ### Server
- - センシティブワードの登録にAnd、正規表現が使用できるようになりました。
## 13.12.1 ## 13.12.1

View File

@ -1038,6 +1038,8 @@ thisChannelArchived: "Dieser Kanal wurde archiviert."
displayOfNote: "Anzeige von Notizen" displayOfNote: "Anzeige von Notizen"
initialAccountSetting: "Kontoeinrichtung" initialAccountSetting: "Kontoeinrichtung"
youFollowing: "Gefolgt" youFollowing: "Gefolgt"
preventAiLarning: "Verwendung in machinellem Lernen (AI/KI) ablehnen"
preventAiLarningDescription: "Fordert Crawler auf, gepostetes Text- oder Bildmaterial usw. nicht in Datensätzen für maschinelles Lernen (AI/KI) zu verwenden. Dies wird durch das Hinzufügen eines \"noai\"-HTML-Tags an den jeweiligen Inhalt erreicht. Da dieser Tag jedoch ignoriert werden kann, ist eine vollständige Verhinderung hierdurch nicht möglich."
_initialAccountSetting: _initialAccountSetting:
accountCreated: "Dein Konto wurde erfolgreich erstellt!" accountCreated: "Dein Konto wurde erfolgreich erstellt!"
letsStartAccountSetup: "Lass uns nun dein Konto einrichten." letsStartAccountSetup: "Lass uns nun dein Konto einrichten."

View File

@ -1038,6 +1038,8 @@ thisChannelArchived: "This channel has been archived."
displayOfNote: "Note display" displayOfNote: "Note display"
initialAccountSetting: "Profile setup" initialAccountSetting: "Profile setup"
youFollowing: "Followed" youFollowing: "Followed"
preventAiLarning: "Reject usage in Machine Learning (AI)"
preventAiLarningDescription: "Requests crawlers to not use posted text or image material etc. in machine learning (AI) data sets. This is achieved by adding a \"noai\" HTML-Tag to the respective content. A complete prevention can however not be achieved through this tag, as it may simply be ignored."
_initialAccountSetting: _initialAccountSetting:
accountCreated: "Your account was successfully created!" accountCreated: "Your account was successfully created!"
letsStartAccountSetup: "For starters, let's set up your profile." letsStartAccountSetup: "For starters, let's set up your profile."

View File

@ -990,6 +990,7 @@ rolesAssignedToMe: "自分に割り当てられたロール"
resetPasswordConfirm: "パスワードリセットしますか?" resetPasswordConfirm: "パスワードリセットしますか?"
sensitiveWords: "センシティブワード" sensitiveWords: "センシティブワード"
sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。"
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
notesSearchNotAvailable: "ノート検索は利用できません。" notesSearchNotAvailable: "ノート検索は利用できません。"
license: "ライセンス" license: "ライセンス"
unfavoriteConfirm: "お気に入り解除しますか?" unfavoriteConfirm: "お気に入り解除しますか?"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.12.1", "version": "13.12.2",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -3,6 +3,7 @@ import * as mfm from 'mfm-js';
import { In, DataSource } from 'typeorm'; import { In, DataSource } from 'typeorm';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import RE2 from 're2';
import { extractMentions } from '@/misc/extract-mentions.js'; import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
@ -238,7 +239,8 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.channel != null) data.localOnly = true; if (data.channel != null) data.localOnly = true;
if (data.visibility === 'public' && data.channel == null) { if (data.visibility === 'public' && data.channel == null) {
if ((data.text != null) && (await this.metaService.fetch()).sensitiveWords.some(w => data.text!.includes(w))) { const sensitiveWords = (await this.metaService.fetch()).sensitiveWords;
if (this.isSensitive(data, sensitiveWords)) {
data.visibility = 'home'; data.visibility = 'home';
} else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) {
data.visibility = 'home'; data.visibility = 'home';
@ -670,6 +672,31 @@ export class NoteCreateService implements OnApplicationShutdown {
// Register to search database // Register to search database
this.index(note); this.index(note);
} }
@bindThis
private isSensitive(note: Option, sensitiveWord: string[]): boolean {
if (sensitiveWord.length > 0) {
const text = note.cw ?? note.text ?? '';
if (text === '') return false;
const matched = sensitiveWord.some(filter => {
// represents RegExp
const regexp = filter.match(/^\/(.+)\/(.*)$/);
// This should never happen due to input sanitisation.
if (!regexp) {
const words = filter.split(' ');
return words.every(keyword => text.includes(keyword));
}
try {
return new RE2(regexp[1], regexp[2]).test(text);
} catch (err) {
// This should never happen due to input sanitisation.
return false;
}
});
if (matched) return true;
}
return false;
}
@bindThis @bindThis
private incRenoteCount(renote: Note) { private incRenoteCount(renote: Note) {

View File

@ -541,6 +541,61 @@ describe('Note', () => {
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
}); });
test('センシティブな投稿はhomeになる (単語指定)', async () => {
const sensitive = await api('admin/update-meta', {
sensitiveWords: [
"test",
]
}, alice);
assert.strictEqual(sensitive.status, 204);
await new Promise(x => setTimeout(x, 2));
const note1 = await api('/notes/create', {
text: 'hogetesthuge',
}, alice);
assert.strictEqual(note1.status, 200);
assert.strictEqual(note1.body.createdNote.visibility, 'home');
});
test('センシティブな投稿はhomeになる (正規表現)', async () => {
const sensitive = await api('admin/update-meta', {
sensitiveWords: [
"/Test/i",
]
}, alice);
assert.strictEqual(sensitive.status, 204);
const note2 = await api('/notes/create', {
text: 'hogetesthuge',
}, alice);
assert.strictEqual(note2.status, 200);
assert.strictEqual(note2.body.createdNote.visibility, 'home');
});
test('センシティブな投稿はhomeになる (スペースアンド)', async () => {
const sensitive = await api('admin/update-meta', {
sensitiveWords: [
"Test hoge"
]
}, alice);
assert.strictEqual(sensitive.status, 204);
const note2 = await api('/notes/create', {
text: 'hogeTesthuge',
}, alice);
assert.strictEqual(note2.status, 200);
assert.strictEqual(note2.body.createdNote.visibility, 'home');
});
}); });
describe('notes/delete', () => { describe('notes/delete', () => {

View File

@ -1,144 +0,0 @@
<template>
<div
class="ziffeoms"
:class="{ disabled, checked }"
>
<input
ref="input"
type="checkbox"
:disabled="disabled"
@keydown.enter="toggle"
>
<span ref="button" v-adaptive-border v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle">
<i class="check ti ti-check"></i>
</span>
<span class="label">
<!-- TODO: 無名slotの方は廃止 -->
<span @click="toggle"><slot name="label"></slot><slot></slot></span>
<p class="caption"><slot name="caption"></slot></p>
</span>
</div>
</template>
<script lang="ts" setup>
import { toRefs, Ref } from 'vue';
import * as os from '@/os';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { i18n } from '@/i18n';
const props = defineProps<{
modelValue: boolean | Ref<boolean>;
disabled?: boolean;
}>();
const emit = defineEmits<{
(ev: 'update:modelValue', v: boolean): void;
}>();
let button = $shallowRef<HTMLElement>();
const checked = toRefs(props).modelValue;
const toggle = () => {
if (props.disabled) return;
emit('update:modelValue', !checked.value);
if (!checked.value) {
const rect = button.getBoundingClientRect();
const x = rect.left + (button.offsetWidth / 2);
const y = rect.top + (button.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y, particle: false }, {}, 'end');
}
};
</script>
<style lang="scss" scoped>
.ziffeoms {
position: relative;
display: flex;
transition: all 0.2s ease;
> * {
user-select: none;
}
> input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
> .button {
position: relative;
display: inline-flex;
flex-shrink: 0;
margin: 0;
box-sizing: border-box;
width: 23px;
height: 23px;
outline: none;
background: var(--panel);
border: solid 1px var(--panel);
border-radius: 4px;
cursor: pointer;
transition: inherit;
> .check {
margin: auto;
opacity: 0;
color: var(--fgOnAccent);
font-size: 13px;
transform: scale(0.5);
transition: all 0.2s ease;
}
}
&:hover {
> .button {
border-color: var(--inputBorderHover) !important;
}
}
> .label {
margin-left: 12px;
margin-top: 2px;
display: block;
transition: inherit;
color: var(--fg);
> span {
display: block;
line-height: 20px;
cursor: pointer;
transition: inherit;
}
> .caption {
margin: 8px 0 0 0;
color: var(--fgTransparentWeak);
font-size: 0.85em;
&:empty {
display: none;
}
}
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.checked {
> .button {
background-color: var(--accent) !important;
border-color: var(--accent) !important;
> .check {
opacity: 1;
transform: scale(1);
}
}
}
}
</style>

View File

@ -1,8 +1,7 @@
<template> <template>
<div <div
v-adaptive-border v-adaptive-border
class="novjtctn" :class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]"
:class="{ disabled, checked }"
:aria-checked="checked" :aria-checked="checked"
:aria-disabled="disabled" :aria-disabled="disabled"
@click="toggle" @click="toggle"
@ -10,11 +9,12 @@
<input <input
type="radio" type="radio"
:disabled="disabled" :disabled="disabled"
:class="$style.input"
> >
<span class="button"> <span :class="$style.button">
<span></span> <span></span>
</span> </span>
<span class="label"><slot></slot></span> <span :class="$style.label"><slot></slot></span>
</div> </div>
</template> </template>
@ -39,8 +39,8 @@ function toggle(): void {
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.novjtctn { .root {
position: relative; position: relative;
display: inline-block; display: inline-block;
text-align: left; text-align: left;
@ -53,17 +53,11 @@ function toggle(): void {
border-radius: 6px; border-radius: 6px;
font-size: 90%; font-size: 90%;
transition: all 0.2s; transition: all 0.2s;
user-select: none;
> * {
user-select: none;
}
&.disabled { &.disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed !important;
&, * {
cursor: not-allowed !important;
}
} }
&:hover { &:hover {
@ -74,10 +68,7 @@ function toggle(): void {
background-color: var(--accentedBg) !important; background-color: var(--accentedBg) !important;
border-color: var(--accentedBg) !important; border-color: var(--accentedBg) !important;
color: var(--accent); color: var(--accent);
cursor: default !important;
&, * {
cursor: default !important;
}
> .button { > .button {
border-color: var(--accent); border-color: var(--accent);
@ -89,44 +80,44 @@ function toggle(): void {
} }
} }
} }
}
> input { .input {
position: absolute; position: absolute;
width: 0; width: 0;
height: 0; height: 0;
opacity: 0; opacity: 0;
margin: 0; margin: 0;
} }
> .button { .button {
position: absolute; position: absolute;
width: 14px; width: 14px;
height: 14px; height: 14px;
background: none; background: none;
border: solid 2px var(--inputBorder); border: solid 2px var(--inputBorder);
border-radius: 100%; border-radius: 100%;
transition: inherit; transition: inherit;
&:after { &:after {
content: ''; content: '';
display: block;
position: absolute;
top: 3px;
right: 3px;
bottom: 3px;
left: 3px;
border-radius: 100%;
opacity: 0;
transform: scale(0);
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
}
> .label {
margin-left: 28px;
display: block; display: block;
line-height: 20px; position: absolute;
cursor: pointer; top: 3px;
right: 3px;
bottom: 3px;
left: 3px;
border-radius: 100%;
opacity: 0;
transform: scale(0);
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
} }
} }
.label {
margin-left: 28px;
display: block;
line-height: 20px;
cursor: pointer;
}
</style> </style>

View File

@ -1,21 +1,19 @@
<template> <template>
<div <div :class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]">
class="ziffeomt"
:class="{ disabled, checked }"
>
<input <input
ref="input" ref="input"
type="checkbox" type="checkbox"
:disabled="disabled" :disabled="disabled"
:class="$style.input"
@keydown.enter="toggle" @keydown.enter="toggle"
> >
<span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" data-cy-switch-toggle @click.prevent="toggle"> <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" :class="$style.button" data-cy-switch-toggle @click.prevent="toggle">
<div class="knob"></div> <div :class="$style.knob"></div>
</span> </span>
<span class="label"> <span :class="$style.body">
<!-- TODO: 無名slotの方は廃止 --> <!-- TODO: 無名slotの方は廃止 -->
<span @click="toggle"><slot name="label"></slot><slot></slot></span> <span :class="$style.label" @click="toggle"><slot name="label"></slot><slot></slot></span>
<p class="caption"><slot name="caption"></slot></p> <p :class="$style.caption"><slot name="caption"></slot></p>
</span> </span>
</div> </div>
</template> </template>
@ -45,52 +43,12 @@ const toggle = () => {
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.ziffeomt { .root {
position: relative; position: relative;
display: flex; display: flex;
transition: all 0.2s ease; transition: all 0.2s ease;
user-select: none;
> * {
user-select: none;
}
> input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
> .button {
position: relative;
display: inline-flex;
flex-shrink: 0;
margin: 0;
box-sizing: border-box;
width: 32px;
height: 23px;
outline: none;
background: var(--switchOffBg);
background-clip: content-box;
border: solid 1px var(--switchOffBg);
border-radius: 999px;
cursor: pointer;
transition: inherit;
user-select: none;
> .knob {
position: absolute;
top: 3px;
left: 3px;
width: 15px;
height: 15px;
background: var(--switchOffFg);
border-radius: 999px;
transition: all 0.2s ease;
}
}
&:hover { &:hover {
> .button { > .button {
@ -98,31 +56,6 @@ const toggle = () => {
} }
} }
> .label {
margin-left: 12px;
margin-top: 2px;
display: block;
transition: inherit;
color: var(--fg);
> span {
display: block;
line-height: 20px;
cursor: pointer;
transition: inherit;
}
> .caption {
margin: 8px 0 0 0;
color: var(--fgTransparentWeak);
font-size: 0.85em;
&:empty {
display: none;
}
}
}
&.disabled { &.disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
@ -140,4 +73,66 @@ const toggle = () => {
} }
} }
} }
.input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
.button {
position: relative;
display: inline-flex;
flex-shrink: 0;
margin: 0;
box-sizing: border-box;
width: 32px;
height: 23px;
outline: none;
background: var(--switchOffBg);
background-clip: content-box;
border: solid 1px var(--switchOffBg);
border-radius: 999px;
cursor: pointer;
transition: inherit;
user-select: none;
}
.knob {
position: absolute;
top: 3px;
left: 3px;
width: 15px;
height: 15px;
background: var(--switchOffFg);
border-radius: 999px;
transition: all 0.2s ease;
}
.body {
margin-left: 12px;
margin-top: 2px;
display: block;
transition: inherit;
color: var(--fg);
}
.label {
display: block;
line-height: 20px;
cursor: pointer;
transition: inherit;
}
.caption {
margin: 8px 0 0 0;
color: var(--fgTransparentWeak);
font-size: 0.85em;
&:empty {
display: none;
}
}
</style> </style>

View File

@ -32,6 +32,7 @@
<component :is="`widget-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @update-props="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> <component :is="`widget-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @update-props="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
export type Widget = { export type Widget = {
name: string; name: string;
@ -42,6 +43,7 @@ export type DefaultStoredWidget = {
place: string | null; place: string | null;
} & Widget; } & Widget;
</script> </script>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue'; import { defineAsyncComponent, ref } from 'vue';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';

View File

@ -27,7 +27,7 @@
<MkTextarea v-model="sensitiveWords"> <MkTextarea v-model="sensitiveWords">
<template #label>{{ i18n.ts.sensitiveWords }}</template> <template #label>{{ i18n.ts.sensitiveWords }}</template>
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template> <template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea> </MkTextarea>
</div> </div>
</FormSuspense> </FormSuspense>

View File

@ -28,9 +28,9 @@
<MkFoldableSection ref="tagsEl" :foldable="true" :expanded="false" class="_margin"> <MkFoldableSection ref="tagsEl" :foldable="true" :expanded="false" class="_margin">
<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template> <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
<div class="vxjfqztj"> <div>
<MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/user-tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/user-tags/${tag.tag}`" style="margin-right: 16px; font-weight: bold;">{{ tag.tag }}</MkA>
<MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/user-tags/${tag.tag}`">{{ tag.tag }}</MkA> <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/user-tags/${tag.tag}`" style="margin-right: 16px;">{{ tag.tag }}</MkA>
</div> </div>
</MkFoldableSection> </MkFoldableSection>
@ -132,15 +132,3 @@ os.api('hashtags/list', {
tagsRemote = tags; tagsRemote = tags;
}); });
</script> </script>
<style lang="scss" scoped>
.vxjfqztj {
> * {
margin-right: 16px;
&.local {
font-weight: bold;
}
}
}
</style>