docs: handle Card and CardList components in cross-links plugin (#7338)
This commit is contained in:
1 parent
490586f566
commit
1160a34f3d
6 files changed
+361
-113
No files matched your search
@@ -1,45 +1,142 @@
|
|||||||
|
/* eslint-disable no-case-declarations */
|
||||||
import type { Transformer } from "unified"
|
import type { Transformer } from "unified"
|
||||||
import type {
|
import type {
|
||||||
CrossProjectLinksOptions,
|
CrossProjectLinksOptions,
|
||||||
|
ExpressionJsVar,
|
||||||
UnistNode,
|
UnistNode,
|
||||||
|
UnistNodeWithData,
|
||||||
UnistTree,
|
UnistTree,
|
||||||
} from "./types/index.js"
|
} from "./types/index.js"
|
||||||
|
import { estreeToJs } from "./utils/estree-to-js.js"
|
||||||
|
import getAttribute from "./utils/get-attribute.js"
|
||||||
|
import {
|
||||||
|
isExpressionJsVarLiteral,
|
||||||
|
isExpressionJsVarObj,
|
||||||
|
} from "./utils/expression-is-utils.js"
|
||||||
|
|
||||||
const PROJECT_REGEX = /^!(?<area>[\w-]+)!/
|
const PROJECT_REGEX = /^!(?<area>[\w-]+)!/
|
||||||
|
|
||||||
export function crossProjectLinksPlugin({
|
function matchAndFixLinks(
|
||||||
baseUrl,
|
link: string,
|
||||||
projectUrls,
|
{ baseUrl, projectUrls }: CrossProjectLinksOptions
|
||||||
}: CrossProjectLinksOptions): Transformer {
|
): string {
|
||||||
return async (tree) => {
|
const projectArea = PROJECT_REGEX.exec(link)
|
||||||
const { visit } = await import("unist-util-visit")
|
|
||||||
visit(tree as UnistTree, "element", (node: UnistNode) => {
|
if (!projectArea?.groups?.area) {
|
||||||
if (node.tagName !== "a" || !node.properties?.href) {
|
return link
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualUrl = link.replace(PROJECT_REGEX, "")
|
||||||
|
|
||||||
|
const base =
|
||||||
|
projectUrls &&
|
||||||
|
Object.hasOwn(projectUrls, projectArea.groups.area) &&
|
||||||
|
projectUrls[projectArea.groups.area]?.url
|
||||||
|
? projectUrls[projectArea.groups.area].url
|
||||||
|
: baseUrl
|
||||||
|
const path =
|
||||||
|
projectUrls &&
|
||||||
|
Object.hasOwn(projectUrls, projectArea.groups.area) &&
|
||||||
|
projectUrls[projectArea.groups.area]?.path
|
||||||
|
? projectUrls[projectArea.groups.area].path
|
||||||
|
: projectArea.groups.area
|
||||||
|
|
||||||
|
return `${base}/${path}${actualUrl}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkElmFixer(node: UnistNode, options: CrossProjectLinksOptions) {
|
||||||
|
if (!node.properties) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.properties.href = matchAndFixLinks(node.properties.href, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentFixer(
|
||||||
|
node: UnistNodeWithData,
|
||||||
|
options: CrossProjectLinksOptions
|
||||||
|
) {
|
||||||
|
if (!node.name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (node.name) {
|
||||||
|
case "CardList":
|
||||||
|
const itemsAttribute = getAttribute(node, "items")
|
||||||
|
|
||||||
|
if (
|
||||||
|
!itemsAttribute?.value ||
|
||||||
|
typeof itemsAttribute.value === "string" ||
|
||||||
|
!itemsAttribute.value.data?.estree
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectArea = PROJECT_REGEX.exec(node.properties.href)
|
const jsVar = estreeToJs(itemsAttribute.value.data.estree)
|
||||||
|
|
||||||
if (!projectArea?.groups?.area) {
|
if (!jsVar) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualUrl = node.properties.href.replace(PROJECT_REGEX, "")
|
const fixProperty = (item: ExpressionJsVar) => {
|
||||||
|
if (!isExpressionJsVarObj(item)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const base =
|
Object.entries(item).forEach(([key, value]) => {
|
||||||
projectUrls &&
|
if (key !== "href" || !isExpressionJsVarLiteral(value)) {
|
||||||
Object.hasOwn(projectUrls, projectArea.groups.area) &&
|
return
|
||||||
projectUrls[projectArea.groups.area]?.url
|
}
|
||||||
? projectUrls[projectArea.groups.area].url
|
|
||||||
: baseUrl
|
|
||||||
const path =
|
|
||||||
projectUrls &&
|
|
||||||
Object.hasOwn(projectUrls, projectArea.groups.area) &&
|
|
||||||
projectUrls[projectArea.groups.area]?.path
|
|
||||||
? projectUrls[projectArea.groups.area].path
|
|
||||||
: projectArea.groups.area
|
|
||||||
|
|
||||||
node.properties.href = `${base}/${path}${actualUrl}`
|
value.original.value = matchAndFixLinks(
|
||||||
})
|
value.original.value as string,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
value.original.raw = JSON.stringify(value.original.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(jsVar)) {
|
||||||
|
jsVar.forEach(fixProperty)
|
||||||
|
} else {
|
||||||
|
fixProperty(jsVar)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "Card":
|
||||||
|
const hrefAttribute = getAttribute(node, "href")
|
||||||
|
|
||||||
|
if (!hrefAttribute?.value || typeof hrefAttribute.value !== "string") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hrefAttribute.value = matchAndFixLinks(hrefAttribute.value, options)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crossProjectLinksPlugin(
|
||||||
|
options: CrossProjectLinksOptions
|
||||||
|
): Transformer {
|
||||||
|
return async (tree) => {
|
||||||
|
const { visit } = await import("unist-util-visit")
|
||||||
|
|
||||||
|
visit(
|
||||||
|
tree as UnistTree,
|
||||||
|
["element", "mdxJsxFlowElement"],
|
||||||
|
(node: UnistNode) => {
|
||||||
|
const isComponent = node.name === "Card" || node.name === "CardList"
|
||||||
|
const isLink = node.tagName === "a" && node.properties?.href
|
||||||
|
if (!isComponent && !isLink) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isComponent) {
|
||||||
|
componentFixer(node as UnistNodeWithData, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
linkElmFixer(node, options)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Transformer } from "unified"
|
import { Transformer } from "unified"
|
||||||
import {
|
import {
|
||||||
|
ExpressionJsVar,
|
||||||
TypeListLinkFixerOptions,
|
TypeListLinkFixerOptions,
|
||||||
UnistNode,
|
|
||||||
UnistNodeWithData,
|
UnistNodeWithData,
|
||||||
UnistTree,
|
UnistTree,
|
||||||
} from "./types/index.js"
|
} from "./types/index.js"
|
||||||
import { FixLinkOptions, fixLinkUtil } from "./index.js"
|
import { FixLinkOptions, fixLinkUtil } from "./index.js"
|
||||||
|
import getAttribute from "./utils/get-attribute.js"
|
||||||
|
import { estreeToJs } from "./utils/estree-to-js.js"
|
||||||
|
import {
|
||||||
|
isExpressionJsVarLiteral,
|
||||||
|
isExpressionJsVarObj,
|
||||||
|
} from "./utils/expression-is-utils.js"
|
||||||
|
|
||||||
const LINK_REGEX = /\[(.*?)\]\((?<link>.*?)\)/gm
|
const LINK_REGEX = /\[(.*?)\]\((?<link>.*?)\)/gm
|
||||||
|
|
||||||
@@ -32,24 +38,34 @@ function matchLinks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function traverseTypes(
|
function traverseTypes(
|
||||||
types: Record<string, unknown>[],
|
types: ExpressionJsVar[] | ExpressionJsVar,
|
||||||
linkOptions: Omit<FixLinkOptions, "linkedPath">
|
linkOptions: Omit<FixLinkOptions, "linkedPath">
|
||||||
) {
|
) {
|
||||||
return types.map((typeItem) => {
|
if (Array.isArray(types)) {
|
||||||
typeItem.type = matchLinks(typeItem.type as string, linkOptions)
|
types.forEach((item) => traverseTypes(item, linkOptions))
|
||||||
typeItem.description = matchLinks(
|
} else if (isExpressionJsVarLiteral(types)) {
|
||||||
typeItem.description as string,
|
types.original.value = matchLinks(
|
||||||
|
types.original.value as string,
|
||||||
linkOptions
|
linkOptions
|
||||||
)
|
)
|
||||||
if (typeItem.children) {
|
types.original.raw = JSON.stringify(types.original.value)
|
||||||
typeItem.children = traverseTypes(
|
} else {
|
||||||
typeItem.children as Record<string, unknown>[],
|
Object.values(types).forEach((value) => {
|
||||||
|
if (Array.isArray(value) || isExpressionJsVarObj(value)) {
|
||||||
|
return traverseTypes(value, linkOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isExpressionJsVarLiteral(value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
value.original.value = matchLinks(
|
||||||
|
value.original.value as string,
|
||||||
linkOptions
|
linkOptions
|
||||||
)
|
)
|
||||||
}
|
value.original.raw = JSON.stringify(value.original.value)
|
||||||
|
})
|
||||||
return typeItem
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function typeListLinkFixerPlugin(
|
export function typeListLinkFixerPlugin(
|
||||||
@@ -76,22 +92,17 @@ export function typeListLinkFixerPlugin(
|
|||||||
""
|
""
|
||||||
)
|
)
|
||||||
const appsPath = basePath || path.join(file.cwd, "app")
|
const appsPath = basePath || path.join(file.cwd, "app")
|
||||||
visit(tree as UnistTree, "mdxJsxFlowElement", (node: UnistNode) => {
|
visit(tree as UnistTree, "mdxJsxFlowElement", (node: UnistNodeWithData) => {
|
||||||
if (node.name !== "TypeList") {
|
if (node.name !== "TypeList") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const typesAttributeIndex = node.attributes?.findIndex(
|
const typesAttribute = getAttribute(node, "types")
|
||||||
(attribute) => attribute.name === "types"
|
|
||||||
)
|
|
||||||
if (typesAttributeIndex === undefined || typesAttributeIndex === -1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const typesAttribute = node.attributes![typesAttributeIndex]
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!typesAttribute ||
|
!typesAttribute ||
|
||||||
!(typesAttribute.value as Record<string, unknown>)?.value
|
typeof typesAttribute.value === "string" ||
|
||||||
|
!typesAttribute.value.data?.estree
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,56 +112,15 @@ export function typeListLinkFixerPlugin(
|
|||||||
appsPath,
|
appsPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
let newItems: Record<string, unknown>[]
|
// let newItems: Record<string, unknown>[]
|
||||||
|
|
||||||
try {
|
const typesJsVar = estreeToJs(typesAttribute.value.data.estree)
|
||||||
newItems = traverseTypes(
|
|
||||||
JSON.parse(
|
if (!typesJsVar) {
|
||||||
(typesAttribute.value as Record<string, unknown>).value as string
|
|
||||||
) as Record<string, unknown>[],
|
|
||||||
linkOptions
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(
|
|
||||||
`[type-list-link-fixer-plugin]: An error occurred while parsing items for page ${file.history[0]}: ${e}`
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
;(
|
traverseTypes(typesJsVar, linkOptions)
|
||||||
node.attributes![typesAttributeIndex].value as Record<string, unknown>
|
|
||||||
).value = JSON.stringify(newItems)
|
|
||||||
|
|
||||||
if (
|
|
||||||
(node as UnistNodeWithData).attributes![typesAttributeIndex].value?.data
|
|
||||||
?.estree?.body?.length
|
|
||||||
) {
|
|
||||||
const oldItems = (node as UnistNodeWithData).attributes[
|
|
||||||
typesAttributeIndex
|
|
||||||
].value.data!.estree!.body![0].expression!.elements!
|
|
||||||
|
|
||||||
;(node as UnistNodeWithData).attributes[
|
|
||||||
typesAttributeIndex
|
|
||||||
].value.data!.estree!.body![0].expression!.elements = newItems.map(
|
|
||||||
(newItem, index) => {
|
|
||||||
oldItems[index].properties = oldItems[index].properties.map(
|
|
||||||
(property) => {
|
|
||||||
if (Object.hasOwn(newItem, property.key.value)) {
|
|
||||||
property.value.value = newItem[property.key.value]
|
|
||||||
property.value.raw = JSON.stringify(
|
|
||||||
newItem[property.key.value]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return property
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return oldItems[index]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,38 +16,70 @@ export interface UnistNode extends Node {
|
|||||||
children?: UnistNode[]
|
children?: UnistNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ArrayExpression = {
|
||||||
|
type: "ArrayExpression"
|
||||||
|
elements: Expression[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObjectExpression = {
|
||||||
|
type: "ObjectExpression"
|
||||||
|
properties: AttributeProperty[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LiteralExpression = {
|
||||||
|
type: "Literal"
|
||||||
|
value: unknown
|
||||||
|
raw: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Expression =
|
||||||
|
| {
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
| ArrayExpression
|
||||||
|
| ObjectExpression
|
||||||
|
| LiteralExpression
|
||||||
|
|
||||||
|
export interface Estree {
|
||||||
|
body?: {
|
||||||
|
type?: string
|
||||||
|
expression?: Expression
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface UnistNodeWithData extends UnistNode {
|
export interface UnistNodeWithData extends UnistNode {
|
||||||
attributes: {
|
attributes: {
|
||||||
name: string
|
name: string
|
||||||
value: {
|
value:
|
||||||
data?: {
|
| {
|
||||||
estree?: {
|
data?: {
|
||||||
body?: {
|
estree?: Estree
|
||||||
type?: string
|
}
|
||||||
expression?: {
|
value?: string
|
||||||
type?: string
|
|
||||||
elements?: {
|
|
||||||
properties: AttributeProperty[]
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
}[]
|
|
||||||
}
|
}
|
||||||
}
|
| string
|
||||||
value?: string
|
|
||||||
}
|
|
||||||
type?: string
|
type?: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttributeProperty {
|
export interface AttributeProperty {
|
||||||
key: {
|
key: {
|
||||||
value: string
|
name?: string
|
||||||
raw: string
|
value?: string
|
||||||
}
|
|
||||||
value: {
|
|
||||||
value: unknown
|
|
||||||
raw: string
|
raw: string
|
||||||
}
|
}
|
||||||
|
value:
|
||||||
|
| {
|
||||||
|
type: "Literal"
|
||||||
|
value: unknown
|
||||||
|
raw: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "JSXElement"
|
||||||
|
// TODO add correct type if necessary
|
||||||
|
openingElement: unknown
|
||||||
|
}
|
||||||
|
| ArrayExpression
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnistTree extends Node {
|
export interface UnistTree extends Node {
|
||||||
@@ -94,3 +126,23 @@ export declare type LocalLinkOptions = {
|
|||||||
filePath?: string
|
filePath?: string
|
||||||
basePath?: string
|
basePath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExpressionJsVarItem = {
|
||||||
|
original: AttributeProperty
|
||||||
|
data?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpressionJsVarLiteral = {
|
||||||
|
original: {
|
||||||
|
type: "Literal"
|
||||||
|
value: unknown
|
||||||
|
raw: string
|
||||||
|
}
|
||||||
|
data?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpressionJsVarObj = {
|
||||||
|
[k: string]: ExpressionJsVarItem | ExpressionJsVar | ExpressionJsVar[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpressionJsVar = ExpressionJsVarObj | ExpressionJsVarLiteral
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/* eslint-disable no-case-declarations */
|
||||||
|
import {
|
||||||
|
ArrayExpression,
|
||||||
|
Estree,
|
||||||
|
Expression,
|
||||||
|
ExpressionJsVar,
|
||||||
|
ExpressionJsVarLiteral,
|
||||||
|
LiteralExpression,
|
||||||
|
ObjectExpression,
|
||||||
|
} from "../types/index.js"
|
||||||
|
|
||||||
|
export function estreeToJs(estree: Estree) {
|
||||||
|
// TODO improve on this utility. Currently it's implemented to work
|
||||||
|
// for specific use cases as we don't have a lot of info on other
|
||||||
|
// use cases.
|
||||||
|
if (
|
||||||
|
!estree.body?.length ||
|
||||||
|
estree.body[0].type !== "ExpressionStatement" ||
|
||||||
|
!estree.body[0].expression
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return expressionToJs(estree.body[0].expression)
|
||||||
|
}
|
||||||
|
|
||||||
|
function expressionToJs(
|
||||||
|
expression: Expression
|
||||||
|
): ExpressionJsVar | ExpressionJsVar[] | undefined {
|
||||||
|
switch (expression.type) {
|
||||||
|
case "ArrayExpression":
|
||||||
|
const arrVar: ExpressionJsVar[] = []
|
||||||
|
;(expression as ArrayExpression).elements.forEach((elm) => {
|
||||||
|
const elmJsVar = expressionToJs(elm)
|
||||||
|
if (!elmJsVar) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Array.isArray(elmJsVar)) {
|
||||||
|
arrVar.push(...elmJsVar)
|
||||||
|
} else {
|
||||||
|
arrVar.push(elmJsVar)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return arrVar
|
||||||
|
case "ObjectExpression":
|
||||||
|
const objVar: ExpressionJsVar = {}
|
||||||
|
;(expression as ObjectExpression).properties.forEach((property) => {
|
||||||
|
const keyName = property.key.name ?? property.key.value
|
||||||
|
|
||||||
|
if (!keyName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const jsVal = expressionToJs(property.value)
|
||||||
|
if (!jsVal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
objVar[keyName] = jsVal
|
||||||
|
})
|
||||||
|
return objVar
|
||||||
|
case "Literal":
|
||||||
|
return {
|
||||||
|
original: expression,
|
||||||
|
data: (expression as LiteralExpression).value,
|
||||||
|
} as ExpressionJsVarLiteral
|
||||||
|
case "JSXElement":
|
||||||
|
// ignore JSXElements
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
console.warn(
|
||||||
|
`[expressionToJs] can't parse expression of type ${expression.type}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
ExpressionJsVarItem,
|
||||||
|
ExpressionJsVarLiteral,
|
||||||
|
ExpressionJsVarObj,
|
||||||
|
} from "../types/index.js"
|
||||||
|
|
||||||
|
export function isExpressionJsVarLiteral(
|
||||||
|
expression: unknown
|
||||||
|
): expression is ExpressionJsVarLiteral {
|
||||||
|
return (
|
||||||
|
typeof expression === "object" &&
|
||||||
|
expression !== null &&
|
||||||
|
Object.hasOwn(expression, "original")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExpressionJsVarObj(
|
||||||
|
expression: unknown
|
||||||
|
): expression is ExpressionJsVarObj {
|
||||||
|
return (
|
||||||
|
typeof expression === "object" &&
|
||||||
|
expression !== null &&
|
||||||
|
!Object.hasOwn(expression, "original")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExpressionJsVarItem(
|
||||||
|
expression: unknown
|
||||||
|
): expression is ExpressionJsVarItem {
|
||||||
|
return (
|
||||||
|
typeof expression === "object" &&
|
||||||
|
expression !== null &&
|
||||||
|
Object.hasOwn(expression, "original")
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { UnistNodeWithData } from "../types/index.js"
|
||||||
|
|
||||||
|
export default function getAttribute(
|
||||||
|
node: UnistNodeWithData,
|
||||||
|
attrName: string
|
||||||
|
) {
|
||||||
|
const attributeIndex = node.attributes?.findIndex(
|
||||||
|
(attribute) => attribute.name === attrName
|
||||||
|
)
|
||||||
|
if (attributeIndex === undefined || attributeIndex === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const attribute = node.attributes![attributeIndex]
|
||||||
|
|
||||||
|
if (!attribute) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return attribute
|
||||||
|
}
|
||||||
Reference in new issue
Block a user