Skip to content

Commit

Permalink
Feature/reference same file components (#33)
Browse files Browse the repository at this point in the history
Co-authored-by: Luca Schneider <[email protected]>
  • Loading branch information
jantimon and Mad-Kat authored Nov 21, 2023
1 parent d871219 commit 6357f90
Show file tree
Hide file tree
Showing 15 changed files with 264 additions and 39 deletions.
13 changes: 10 additions & 3 deletions packages/example/app/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,17 @@ const PasswordInput = styled(Input).attrs({
border: 2px solid #167f8d;
`;

const Centered = styled.div`
const Wrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
transition: background-color 0.5s ease-in-out;
&:has(
${Input}:where(:hover, :focus),
${PasswordInput}:where(:hover, :focus)
) {
background-color: #4c4c4cb9;
}
`;

const Headline = styled.h2`
Expand All @@ -49,12 +56,12 @@ const Headline = styled.h2`

export const Inputs = () => {
return (
<Centered>
<Wrapper>
<Headline>Styled Inputs</Headline>
<Input placeholder="A small text input" />
<Input $size="2rem" placeholder="A large text input" />
<PasswordInput placeholder="A password input" />
<PasswordInput $size="2rem" placeholder="A large password input" />
</Centered>
</Wrapper>
);
};
2 changes: 1 addition & 1 deletion packages/next-yak/dist/index.cjs
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"use strict";var b=Object.create;var l=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var N=Object.getOwnPropertyNames;var F=Object.getPrototypeOf,w=Object.prototype.hasOwnProperty;var O=(t,e)=>{for(var s in e)l(t,s,{get:e[s],enumerable:!0})},f=(t,e,s,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of N(e))!w.call(t,n)&&n!==s&&l(t,n,{get:()=>e[n],enumerable:!(o=h(e,n))||o.enumerable});return t};var R=(t,e,s)=>(s=t!=null?b(F(t)):{},f(e||!t||!t.__esModule?l(s,"default",{value:t,enumerable:!0}):s,t)),j=t=>f(l({},"__esModule",{value:!0}),t);var X={};O(X,{YakThemeProvider:()=>u.YakThemeProvider,atoms:()=>C,css:()=>y,keyframes:()=>I,styled:()=>k,useTheme:()=>u.useTheme});module.exports=j(X);var v=(...t)=>{let e=[],s=[],o={};for(let n of t)if(typeof n=="string")e.push(n);else if(typeof n=="function")s.push(n);else if(typeof n=="object"&&"style"in n)for(let r in n.style){let a=n.style[r];typeof a=="function"?s.push(i=>({style:{[r]:String(g(i,a))}})):o[r]=a}if(s.length===0){let n=e.join(" ");return()=>({className:n,style:o})}return n=>{let r=[...e],a={...o};for(let i=0;i<s.length;i++)E(n,s[i],r,a);return{className:r.join(" "),style:a}}},E=(t,e,s,o)=>{let n=e(t);for(;n;){if(typeof n=="function"){n=n(t);continue}else if(typeof n=="object"&&("className"in n&&n.className&&s.push(n.className),"style"in n&&n.style))for(let r in n.style)o[r]=n.style[r];break}},g=(t,e)=>{let s=e(t);if(typeof s=="function")return g(t,s);if(process.env.NODE_ENV==="development"&&typeof s!="string"&&typeof s!="number"&&!(s instanceof String))throw new Error(`Dynamic CSS functions must return a string or number but returned ${JSON.stringify(s)}`);return s},y=v;var p=R(require("react"),1),P=require("next-yak/context"),B=t=>Object.assign(p.default.forwardRef(t),{component:t}),M=t=>Object.assign(d(t),{attrs:e=>d(t,e)}),d=(t,e)=>(s,...o)=>{let n=y(s,...o),r=i=>J(i,typeof e=="function"?e(i):e);return B((i,S)=>{let c=r(Object.assign(e||n.length?{theme:(0,P.useTheme)()}:{},i)),m=n(c),T=typeof t=="string"?Y(c):c;return T.className=x(c.className,m.className),T.style="style"in c?{...c.style,...m.style}:m.style,typeof t!="string"&&"yak"in t?t.yak(T,S):(T.ref=S,p.default.createElement(t,{...T}))})},k=new Proxy(M,{get(t,e){return t(e)}});function Y(t){let e={};for(let s in t)!s.startsWith("$")&&s!=="theme"&&(e[s]=t[s]);return e}var x=(t,e)=>t?e?t+" "+e:t:e,A=t=>{let e={};for(let s in t)t[s]!==void 0&&(e[s]=t[s]);return e},J=(t,e)=>e?{..."$__attrs"in t?{...A(e),...t}:{...t,...A(e)},className:x(t.className,e.className),style:{...t.style||{},...e.style||{}},$__attrs:!0}:t;var C=(...t)=>{let e=t.join(" ");return()=>({className:e})};var I=(t,...e)=>t;var u=require("next-yak/context");0&&(module.exports={YakThemeProvider,atoms,css,keyframes,styled,useTheme});
"use strict";var b=Object.create;var l=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var N=Object.getOwnPropertyNames;var F=Object.getPrototypeOf,w=Object.prototype.hasOwnProperty;var O=(t,e)=>{for(var s in e)l(t,s,{get:e[s],enumerable:!0})},f=(t,e,s,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of N(e))!w.call(t,n)&&n!==s&&l(t,n,{get:()=>e[n],enumerable:!(o=h(e,n))||o.enumerable});return t};var R=(t,e,s)=>(s=t!=null?b(F(t)):{},f(e||!t||!t.__esModule?l(s,"default",{value:t,enumerable:!0}):s,t)),j=t=>f(l({},"__esModule",{value:!0}),t);var J={};O(J,{YakThemeProvider:()=>u.YakThemeProvider,atoms:()=>C,css:()=>y,keyframes:()=>I,styled:()=>k,useTheme:()=>u.useTheme});module.exports=j(J);var v=(...t)=>{let e=[],s=[],o={};for(let n of t)if(typeof n=="string")e.push(n);else if(typeof n=="function")s.push(n);else if(typeof n=="object"&&"style"in n)for(let r in n.style){let a=n.style[r];typeof a=="function"?s.push(i=>({style:{[r]:String(g(i,a))}})):o[r]=a}if(s.length===0){let n=e.join(" ");return()=>({className:n,style:o})}return n=>{let r=[...e],a={...o};for(let i=0;i<s.length;i++)E(n,s[i],r,a);return{className:r.join(" "),style:a}}},E=(t,e,s,o)=>{let n=e(t);for(;n;){if(typeof n=="function"){n=n(t);continue}else if(typeof n=="object"&&("className"in n&&n.className&&s.push(n.className),"style"in n&&n.style))for(let r in n.style)o[r]=n.style[r];break}},g=(t,e)=>{let s=e(t);if(typeof s=="function")return g(t,s);if(process.env.NODE_ENV==="development"&&typeof s!="string"&&typeof s!="number"&&!(s instanceof String))throw new Error(`Dynamic CSS functions must return a string or number but returned ${JSON.stringify(s)}`);return s},y=v;var p=R(require("react"),1),P=require("next-yak/context"),_=t=>Object.assign(p.default.forwardRef(t),{component:t}),B=t=>Object.assign(d(t),{attrs:e=>d(t,e)}),d=(t,e)=>(s,...o)=>{let n=y(s,...o),r=i=>Y(i,typeof e=="function"?e(i):e);return _((i,S)=>{let c=r(Object.assign(e||n.length?{theme:(0,P.useTheme)()}:{},i)),m=n(c),T=typeof t=="string"?M(c):c;return T.className=x(c.className,m.className),T.style="style"in c?{...c.style,...m.style}:m.style,typeof t!="string"&&"yak"in t?t.yak(T,S):(T.ref=S,p.default.createElement(t,{...T}))})},k=new Proxy(B,{get(t,e){return t(e)}});function M(t){let e={};for(let s in t)!s.startsWith("$")&&s!=="theme"&&(e[s]=t[s]);return e}var x=(t,e)=>t?e?t+" "+e:t:e,A=t=>{let e={};for(let s in t)t[s]!==void 0&&(e[s]=t[s]);return e},Y=(t,e)=>e?{..."$__attrs"in t?{...A(e),...t}:{...t,...A(e)},className:x(t.className,e.className),style:{...t.style||{},...e.style||{}},$__attrs:!0}:t;var C=(...t)=>{let e=t.join(" ");return()=>({className:e})};var I=(t,...e)=>t;var u=require("next-yak/context");0&&(module.exports={YakThemeProvider,atoms,css,keyframes,styled,useTheme});
//# sourceMappingURL=index.cjs.map
2 changes: 1 addition & 1 deletion packages/next-yak/dist/index.cjs.map

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions packages/next-yak/dist/index.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ type ComponentStyles<TProps = {}> = (props: TProps) => {
[key: string]: string;
};
};
type CSSInterpolation<TProps = {}> = string | number | undefined | null | false | ComponentStyles<TProps> | ((props: TProps) => CSSInterpolation<TProps>);
type CSSInterpolation<TProps = {}> = string | number | undefined | null | false | ComponentStyles<TProps> | {
__yak: true;
} | ((props: TProps) => CSSInterpolation<TProps>);
type CSSFunction = <TProps = {}>(styles: TemplateStringsArray, ...values: CSSInterpolation<TProps & {
theme: YakTheme;
}>[]) => ComponentStyles<TProps>;
Expand Down Expand Up @@ -51,7 +53,9 @@ type Attrs<TBaseProps, TIn extends object = {}, TOut extends AttrsMerged<TBasePr
*/
type StyledLiteral<T> = <TCSSProps extends Record<string, unknown> = {}>(styles: TemplateStringsArray, ...values: Array<CSSInterpolation<T & TCSSProps & {
theme: YakTheme;
}>>) => FunctionComponent<TCSSProps & T>;
}>>) => FunctionComponent<TCSSProps & T> & {
__yak: true;
};
/**
* The `styled` method works perfectly on all of your own or any third-party component,
* as long as they attach the passed className prop to a DOM element.
Expand All @@ -67,10 +71,14 @@ type StyledLiteral<T> = <TCSSProps extends Record<string, unknown> = {}>(styles:
*/
declare const styled: (<T>(Component: keyof JSX.IntrinsicElements | FunctionComponent<T>) => (<TCSSProps extends Record<string, unknown> = {}>(styles: TemplateStringsArray, ...values: CSSInterpolation<T & TCSSProps & {
theme: YakTheme;
}>[]) => FunctionComponent<FastOmit<TCSSProps & T, never>>) & {
}>[]) => FunctionComponent<FastOmit<TCSSProps & T, never>> & {
__yak: true;
}) & {
attrs: <TAttrsIn extends object = {}, TAttrsOut extends AttrsMerged<T, TAttrsIn> = AttrsMerged<T, TAttrsIn>>(attrs: Attrs<T, TAttrsIn, TAttrsOut>) => <TCSSProps_1 extends Record<string, unknown> = {}>(styles: TemplateStringsArray, ...values: CSSInterpolation<T & TCSSProps_1 & {
theme: YakTheme;
}>[]) => FunctionComponent<Substitute<TCSSProps_1 & T, TAttrsIn>>;
}>[]) => FunctionComponent<Substitute<TCSSProps_1 & T, TAttrsIn>> & {
__yak: true;
};
}) & {
symbol: StyledLiteral<React.SVGProps<SVGSymbolElement>> & {
attrs: <TAttrsIn_1 extends object = {}, TAttrsOut_1 extends AttrsMerged<React.SVGProps<SVGSymbolElement>, TAttrsIn_1> = AttrsMerged<React.SVGProps<SVGSymbolElement>, TAttrsIn_1>>(attrs: Attrs<React.SVGProps<SVGSymbolElement>, TAttrsIn_1, TAttrsOut_1>) => StyledLiteral<Substitute<React.SVGProps<SVGSymbolElement>, TAttrsIn_1>>;
Expand Down
16 changes: 12 additions & 4 deletions packages/next-yak/dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ type ComponentStyles<TProps = {}> = (props: TProps) => {
[key: string]: string;
};
};
type CSSInterpolation<TProps = {}> = string | number | undefined | null | false | ComponentStyles<TProps> | ((props: TProps) => CSSInterpolation<TProps>);
type CSSInterpolation<TProps = {}> = string | number | undefined | null | false | ComponentStyles<TProps> | {
__yak: true;
} | ((props: TProps) => CSSInterpolation<TProps>);
type CSSFunction = <TProps = {}>(styles: TemplateStringsArray, ...values: CSSInterpolation<TProps & {
theme: YakTheme;
}>[]) => ComponentStyles<TProps>;
Expand Down Expand Up @@ -51,7 +53,9 @@ type Attrs<TBaseProps, TIn extends object = {}, TOut extends AttrsMerged<TBasePr
*/
type StyledLiteral<T> = <TCSSProps extends Record<string, unknown> = {}>(styles: TemplateStringsArray, ...values: Array<CSSInterpolation<T & TCSSProps & {
theme: YakTheme;
}>>) => FunctionComponent<TCSSProps & T>;
}>>) => FunctionComponent<TCSSProps & T> & {
__yak: true;
};
/**
* The `styled` method works perfectly on all of your own or any third-party component,
* as long as they attach the passed className prop to a DOM element.
Expand All @@ -67,10 +71,14 @@ type StyledLiteral<T> = <TCSSProps extends Record<string, unknown> = {}>(styles:
*/
declare const styled: (<T>(Component: keyof JSX.IntrinsicElements | FunctionComponent<T>) => (<TCSSProps extends Record<string, unknown> = {}>(styles: TemplateStringsArray, ...values: CSSInterpolation<T & TCSSProps & {
theme: YakTheme;
}>[]) => FunctionComponent<FastOmit<TCSSProps & T, never>>) & {
}>[]) => FunctionComponent<FastOmit<TCSSProps & T, never>> & {
__yak: true;
}) & {
attrs: <TAttrsIn extends object = {}, TAttrsOut extends AttrsMerged<T, TAttrsIn> = AttrsMerged<T, TAttrsIn>>(attrs: Attrs<T, TAttrsIn, TAttrsOut>) => <TCSSProps_1 extends Record<string, unknown> = {}>(styles: TemplateStringsArray, ...values: CSSInterpolation<T & TCSSProps_1 & {
theme: YakTheme;
}>[]) => FunctionComponent<Substitute<TCSSProps_1 & T, TAttrsIn>>;
}>[]) => FunctionComponent<Substitute<TCSSProps_1 & T, TAttrsIn>> & {
__yak: true;
};
}) & {
symbol: StyledLiteral<React.SVGProps<SVGSymbolElement>> & {
attrs: <TAttrsIn_1 extends object = {}, TAttrsOut_1 extends AttrsMerged<React.SVGProps<SVGSymbolElement>, TAttrsIn_1> = AttrsMerged<React.SVGProps<SVGSymbolElement>, TAttrsIn_1>>(attrs: Attrs<React.SVGProps<SVGSymbolElement>, TAttrsIn_1, TAttrsOut_1>) => StyledLiteral<Substitute<React.SVGProps<SVGSymbolElement>, TAttrsIn_1>>;
Expand Down
2 changes: 1 addition & 1 deletion packages/next-yak/dist/index.js.map

Large diffs are not rendered by default.

58 changes: 57 additions & 1 deletion packages/next-yak/loaders/__tests__/cssloader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ const newHeadline = styled(headline).attrs({
color: red;
}
.yak_1 {
.yak_2 {
color: black;
}"
`);
Expand Down Expand Up @@ -361,3 +361,59 @@ const FadeInButton = styled.button\`
}"
`);
});


it("should allow to target components", async () => {
expect(
await cssloader.call(
loaderContext,
`
import { styled, keyframes } from "next-yak";
const Link = styled.a\`
color: palevioletred;
\`
const Icon = styled.svg\`
fill: currentColor;
width: 1em;
height: 1em;
\${Link}:hover & {
color: red;
}
\${Link}:focus & {
color: red;
}
\`
const Wrapper = styled.div\`
&:has(> \${Link}) {
padding: 10px;
}
\`
`
)
).toMatchInlineSnapshot(`
".yak_0 {
color: palevioletred;
}
.yak_2 {
fill: currentColor;
width: 1em;
height: 1em;
.yak_1:hover & {
color: red;
}
.yak_1:focus & {
color: red;
}
}
.yak_4 {
&:has(> .yak_1) {
padding: 10px;
}
}"
`);
});
53 changes: 47 additions & 6 deletions packages/next-yak/loaders/__tests__/tsloader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ const FancyButton = styled(Button)\`
import { styled, css } from \\"next-yak\\";
import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\";
const x = Math.random();
const Button = styled.button(__styleYak.yak_0, x > 0.5 && css(__styleYak.yak_1));
const FancyButton = styled(Button)(__styleYak.yak_2);"
const Button = styled.button(__styleYak.yak_0, x > 0.5 && css(__styleYak.yak_2));
const FancyButton = styled(Button)(__styleYak.yak_3);"
`);
});

Expand Down Expand Up @@ -163,8 +163,8 @@ const FancyButton = styled(Button)\`
const x = Math.random();
const Button = styled.button(__styleYak.yak_0, ({
theme
}) => theme.mode === \\"dark\\" && css(__styleYak.yak_1));
const FancyButton = styled(Button)(__styleYak.yak_2);"
}) => theme.mode === \\"dark\\" && css(__styleYak.yak_2));
const FancyButton = styled(Button)(__styleYak.yak_3);"
`);
});
});
Expand Down Expand Up @@ -216,7 +216,7 @@ const newHeadline = styled(headline).attrs({
const headline = styled.input(__styleYak.yak_0);
const newHeadline = styled(headline).attrs({
type: \\"text\\"
})(__styleYak.yak_1);"
})(__styleYak.yak_2);"
`);
});

Expand Down Expand Up @@ -286,4 +286,45 @@ const FadeInButton = styled.button\`
}
});"
`);
});
});


it("should allow to target components", async () => {
expect(
await tsloader.call(
loaderContext,
`
import { styled, keyframes } from "next-yak";
const Link = styled.a\`
color: palevioletred;
\`
const Icon = styled.svg\`
fill: currentColor;
width: 1em;
height: 1em;
\${Link}:hover & {
color: red;
}
\${Link}:focus & {
color: red;
}
\`
const Wrapper = styled.div\`
&:has(> \${Link}) {
padding: 10px;
}
\`
`
)
).toMatchInlineSnapshot(`
"import { styled, keyframes } from \\"next-yak\\";
import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\";
const Link = styled.a(__styleYak.yak_0, __styleYak.yak_1);
const Icon = styled.svg(__styleYak.yak_2);
const Wrapper = styled.div(__styleYak.yak_4);"
`);
});
39 changes: 37 additions & 2 deletions packages/next-yak/loaders/babel-yak-plugin.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const replaceQuasiExpressionTokens = require("./lib/replaceQuasiExpressionTokens
const murmurhash2_32_gc = require("./lib/hash.cjs");
const { relative, resolve, basename } = require("path");
const localIdent = require("./lib/localIdent.cjs");
const getStyledComponentName = require("./lib/getStyledComponentName.cjs");

/** @typedef {{replaces: Record<string, unknown>, rootContext?: string}} YakBabelPluginOptions */

Expand All @@ -14,7 +15,7 @@ const localIdent = require("./lib/localIdent.cjs");
*
* @param {import("@babel/core")} babel
* @param {YakBabelPluginOptions} options
* @returns {babel.PluginObj<import("@babel/core").PluginPass & {localVarNames: {css?: string, styled?: string, keyframes?: string}, isImportedInCurrentFile: boolean, classNameCount: number, varIndex: number}>}
* @returns {babel.PluginObj<import("@babel/core").PluginPass & {localVarNames: {css?: string, styled?: string, keyframes?: string}, isImportedInCurrentFile: boolean, classNameCount: number, varIndex: number, variableNameToStyledCall: Map<string, {wasAdded: boolean, className: string, astNode: import("@babel/types").CallExpression}>}>}
*/
module.exports = function (babel, options) {
const { replaces } = options;
Expand All @@ -36,6 +37,7 @@ module.exports = function (babel, options) {
this.isImportedInCurrentFile = false;
this.classNameCount = 0;
this.varIndex = 0;
this.variableNameToStyledCall = new Map();
},
visitor: {
/**
Expand Down Expand Up @@ -143,7 +145,28 @@ module.exports = function (babel, options) {
return;
}

replaceQuasiExpressionTokens(path.node.quasi, replaces, t);
replaceQuasiExpressionTokens(path.node.quasi, (name) => {
if (name in replaces) {
return replaces[name];
}
const styledCall = this.variableNameToStyledCall.get(name);
if (styledCall) {
const { wasAdded, className, astNode } = styledCall;
// on first usage of another styled component, add a
// the className to it so it can be targeted
if (!wasAdded) {
styledCall.wasAdded = true;
astNode.arguments.push(
t.memberExpression(
t.identifier("__styleYak"),
t.identifier(className)
)
);
}
return className;
}
return false;
}, t);

// Keep the same selector for all quasis belonging to the same css block
const classNameExpression = t.memberExpression(
Expand Down Expand Up @@ -246,6 +269,18 @@ module.exports = function (babel, options) {

const styledCall = t.callExpression(tag, [...newArguments]);
path.replaceWith(styledCall);

// Store reference to AST node to allow other components to target the styled literal inside css like
// e.g. `& ${Button} { ... }`
if (isStyledLiteral || isStyledCall || isAttrsCall) {
const variableName = getStyledComponentName(path);
// TODO: reuse existing class name if possible
this.variableNameToStyledCall.set(variableName, {
wasAdded: false,
className: localIdent(this.classNameCount++, "className"),
astNode: styledCall,
});
}
},
},
};
Expand Down
28 changes: 26 additions & 2 deletions packages/next-yak/loaders/cssloader.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const babel = require("@babel/core");
const quasiClassifier = require("./lib/quasiClassifier.cjs");
const localIdent = require("./lib/localIdent.cjs");
const replaceQuasiExpressionTokens = require("./lib/replaceQuasiExpressionTokens.cjs");
const getStyledComponentName = require("./lib/getStyledComponentName.cjs");
const murmurhash2_32_gc = require("./lib/hash.cjs");
const { relative } = require("path");

Expand Down Expand Up @@ -65,6 +66,9 @@ module.exports = async function cssLoader(source) {
/** @type {string | null} */
let hashedFile = null;

/** @type {Map<string, string>} */
const variableNameToStyledClassName = new Map();

/**
* find all css template literals in ast
* @type {{ code: string, loc: number }[]}
Expand Down Expand Up @@ -151,8 +155,15 @@ module.exports = async function cssLoader(source) {
) {
return;
}

replaceQuasiExpressionTokens(path.node.quasi, replaces, t);
replaceQuasiExpressionTokens(path.node.quasi, (name) => {
if (name in replaces) {
return replaces[name];
}
if (variableNameToStyledClassName.has(name)) {
return variableNameToStyledClassName.get(name)
}
return false;
}, t);

// Keep the same selector for all quasis belonging to the same css block
const literalSelector = localIdent(
Expand Down Expand Up @@ -209,6 +220,19 @@ module.exports = async function cssLoader(source) {
loc: quasi.loc?.start.line || 0,
});
}

// Store class name for the created variable for later replacements
// e.g. const MyStyledDiv = styled.div`color: red;`
// "MyStyledDiv" -> "selector-0"
if (isStyledLiteral || isStyledCall || isAttrsCall) {
const variableName = getStyledComponentName(path);
// TODO: reuse existing class name if possible
variableNameToStyledClassName.set(variableName, localIdent(
index++,
"selector"
));
}

},
});

Expand Down
Loading

0 comments on commit 6357f90

Please sign in to comment.