<commit_before>const {Router} = require('express');
const body = require('body-parser');
const MagicLink = require('@tryghost/magic-link');
const errors = require('@tryghost/errors');

const MemberAnalyticsService = require('@tryghost/member-analytics-service');
const MembersAnalyticsIngress = require('@tryghost/members-analytics-ingress');
const PaymentsService = require('@tryghost/members-payments');

const TokenService = require('./services/token');
const GeolocationSerice = require('./services/geolocation');
const MemberBREADService = require('./services/member-bread');
const MemberRepository = require('./repositories/member');
const EventRepository = require('./repositories/event');
const ProductRepository = require('./repositories/product');
const RouterController = require('./controllers/router');
const MemberController = require('./controllers/member');
const WellKnownController = require('./controllers/well-known');

module.exports = function MembersAPI({
    tokenConfig: {
        issuer,
        privateKey,
        publicKey
    },
    auth: {
        allowSelfSignup = () => true,
        getSigninURL,
        tokenProvider
    },
    mail: {
        transporter,
        getText,
        getHTML,
        getSubject
    },
    models: {
        EmailRecipient,
        StripeCustomer,
        StripeCustomerSubscription,
        Member,
        MemberCancelEvent,
        MemberSubscribeEvent,
        MemberLoginEvent,
        MemberPaidSubscriptionEvent,
        MemberPaymentEvent,
        MemberStatusEvent,
        MemberProductEvent,
        MemberEmailChangeEvent,
        MemberAnalyticEvent,
        Offer,
        OfferRedemption,
        StripeProduct,
        StripePrice,
        Product,
        Settings
    },
    stripeAPIService,
    offersAPI,
    labsService,
    newslettersService
}) {
    const tokenService = new TokenService({
        privateKey,
        publicKey,
        issuer
    });

    const memberAnalyticsService = MemberAnalyticsService.create(MemberAnalyticEvent);
    memberAnalyticsService.eventHandler.setupSubscribers();

    const productRepository = new ProductRepository({
        Product,
        Settings,
        StripeProduct,
        StripePrice,
        stripeAPIService
    });

    const memberRepository = new MemberRepository({
        stripeAPIService,
        tokenService,
        newslettersService,
        labsService,
        productRepository,
        Member,
        MemberCancelEvent,
        MemberSubscribeEvent,
        MemberPaidSubscriptionEvent,
        MemberEmailChangeEvent,
        MemberStatusEvent,
        MemberProductEvent,
        OfferRedemption,
        StripeCustomer,
        StripeCustomerSubscription,
        offerRepository: offersAPI.repository
    });

    const eventRepository = new EventRepository({
        EmailRecipient,
        MemberSubscribeEvent,
        MemberPaidSubscriptionEvent,
        MemberPaymentEvent,
        MemberStatusEvent,
        MemberLoginEvent,
        labsService
    });

    const memberBREADService = new MemberBREADService({
        offersAPI,
        memberRepository,
        emailService: {
            async sendEmailWithMagicLink({email, requestedType}) {
                return sendEmailWithMagicLink({
                    email,
                    requestedType,
                    options: {
                        forceEmailType: true
                    }
                });
            }
        },
        labsService,
        stripeService: stripeAPIService
    });

    const geolocationService = new GeolocationSerice();

    const magicLinkService = new MagicLink({
        transporter,
        tokenProvider,
        getSigninURL,
        getText,
        getHTML,
        getSubject
    });

    const memberController = new MemberController({
        memberRepository,
        productRepository,
        StripePrice,
        tokenService,
        sendEmailWithMagicLink
    });

    const paymentsService = new PaymentsService({
        Offer,
        offersAPI,
        stripeAPIService
    });

    const routerController = new RouterController({
        offersAPI,
        paymentsService,
        productRepository,
        memberRepository,
        StripePrice,
        allowSelfSignup,
        magicLinkService,
        stripeAPIService,
        tokenService,
        sendEmailWithMagicLink,
        labsService
    });

    const wellKnownController = new WellKnownController({
        tokenService
    });

    async function hasActiveStripeSubscriptions() {
        const firstActiveSubscription = await StripeCustomerSubscription.findOne({
            status: 'active'
        });

        if (firstActiveSubscription) {
            return true;
        }

        const firstTrialingSubscription = await StripeCustomerSubscription.findOne({
            status: 'trialing'
        });

        if (firstTrialingSubscription) {
            return true;
        }

        const firstUnpaidSubscription = await StripeCustomerSubscription.findOne({
            status: 'unpaid'
        });

        if (firstUnpaidSubscription) {
            return true;
        }

        const firstPastDueSubscription = await StripeCustomerSubscription.findOne({
            status: 'past_due'
        });

        if (firstPastDueSubscription) {
            return true;
        }

        return false;
    }

    const users = memberRepository;

    async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, requestSrc = ''}) {
        let type = requestedType;
        if (!options.forceEmailType) {
            const member = await users.get({email});
            if (member) {
                type = 'signin';
            } else if (type !== 'subscribe') {
                type = 'signup';
            }
        }
        return magicLinkService.sendMagicLink({email, type, requestSrc, tokenData: Object.assign({email}, tokenData)});
    }

    function getMagicLink(email) {
        return magicLinkService.getMagicLink({tokenData: {email}, type: 'signin'});
    }

    async function getMemberDataFromMagicLinkToken(token) {
        const {email, labels = [], name = '', oldEmail, newsletters} = await magicLinkService.getDataFromToken(token);
        if (!email) {
            return null;
        }

        const member = oldEmail ? await getMemberIdentityData(oldEmail) : await getMemberIdentityData(email);

        if (member) {
            await MemberLoginEvent.add({member_id: member.id});
            if (oldEmail) {
                // user exists but wants to change their email address
                await users.update({email}, {id: member.id});
                return getMemberIdentityData(email);
            }
            return member;
        }
        const newMember = await users.create({name, email, labels, newsletters});
        await MemberLoginEvent.add({member_id: newMember.id});
        return getMemberIdentityData(email);
    }

    async function getMemberIdentityData(email) {
        return memberBREADService.read({email});
    }

    async function getMemberIdentityToken(email) {
        const member = await getMemberIdentityData(email);
        if (!member) {
            return null;
        }
        return tokenService.encodeIdentityToken({sub: member.email});
    }

    async function setMemberGeolocationFromIp(email, ip) {
        if (!email || !ip) {
            throw new errors.IncorrectUsageError({
                message: 'setMemberGeolocationFromIp() expects email and ip arguments to be present'
            });
        }

        // toJSON() is needed here otherwise users.update() will pick methods off
        // the model object rather than data and fail to edit correctly
        const member = (await users.get({email})).toJSON();

        if (!member) {
            throw new errors.NotFoundError({
                message: `Member with email address ${email} does not exist`
            });
        }

        // max request time is 500ms so shouldn't slow requests down too much
        let geolocation = JSON.stringify(await geolocationService.getGeolocationFromIP(ip));
        if (geolocation) {
            await users.update({geolocation}, {id: member.id});
        }

        return getMemberIdentityData(email);
    }

    const middleware = {
        sendMagicLink: Router().use(
            body.json(),
            (req, res) => routerController.sendMagicLink(req, res)
        ),
        createCheckoutSession: Router().use(
            body.json(),
            (req, res) => routerController.createCheckoutSession(req, res)
        ),
        createCheckoutSetupSession: Router().use(
            body.json(),
            (req, res) => routerController.createCheckoutSetupSession(req, res)
        ),
        createEvents: Router().use(
            body.json(),
            (req, res) => MembersAnalyticsIngress.createEvents(req, res)
        ),
        updateEmailAddress: Router().use(
            body.json(),
            (req, res) => memberController.updateEmailAddress(req, res)
        ),
        updateSubscription: Router({mergeParams: true}).use(
            body.json(),
            (req, res) => memberController.updateSubscription(req, res)
        ),
        wellKnown: Router()
            .get('/jwks.json',
                (req, res) => wellKnownController.getPublicKeys(req, res)
            )
    };

    const getPublicConfig = function () {
        return Promise.resolve({
            publicKey,
            issuer
        });
    };

    const bus = new (require('events').EventEmitter)();

    bus.emit('ready');

    return {
        middleware,
        getMemberDataFromMagicLinkToken,
        getMemberIdentityToken,
        getMemberIdentityData,
        setMemberGeolocationFromIp,
        getPublicConfig,
        bus,
        sendEmailWithMagicLink,
        getMagicLink,
        hasActiveStripeSubscriptions,
        members: users,
        memberBREADService,
        events: eventRepository,
        productRepository
    };
};
<commit_msg>Removed hasActiveStripeSubscriptions method (#396)<commit_after>const {Router} = require('express');
const body = require('body-parser');
const MagicLink = require('@tryghost/magic-link');
const errors = require('@tryghost/errors');

const MemberAnalyticsService = require('@tryghost/member-analytics-service');
const MembersAnalyticsIngress = require('@tryghost/members-analytics-ingress');
const PaymentsService = require('@tryghost/members-payments');

const TokenService = require('./services/token');
const GeolocationSerice = require('./services/geolocation');
const MemberBREADService = require('./services/member-bread');
const MemberRepository = require('./repositories/member');
const EventRepository = require('./repositories/event');
const ProductRepository = require('./repositories/product');
const RouterController = require('./controllers/router');
const MemberController = require('./controllers/member');
const WellKnownController = require('./controllers/well-known');

module.exports = function MembersAPI({
    tokenConfig: {
        issuer,
        privateKey,
        publicKey
    },
    auth: {
        allowSelfSignup = () => true,
        getSigninURL,
        tokenProvider
    },
    mail: {
        transporter,
        getText,
        getHTML,
        getSubject
    },
    models: {
        EmailRecipient,
        StripeCustomer,
        StripeCustomerSubscription,
        Member,
        MemberCancelEvent,
        MemberSubscribeEvent,
        MemberLoginEvent,
        MemberPaidSubscriptionEvent,
        MemberPaymentEvent,
        MemberStatusEvent,
        MemberProductEvent,
        MemberEmailChangeEvent,
        MemberAnalyticEvent,
        Offer,
        OfferRedemption,
        StripeProduct,
        StripePrice,
        Product,
        Settings
    },
    stripeAPIService,
    offersAPI,
    labsService,
    newslettersService
}) {
    const tokenService = new TokenService({
        privateKey,
        publicKey,
        issuer
    });

    const memberAnalyticsService = MemberAnalyticsService.create(MemberAnalyticEvent);
    memberAnalyticsService.eventHandler.setupSubscribers();

    const productRepository = new ProductRepository({
        Product,
        Settings,
        StripeProduct,
        StripePrice,
        stripeAPIService
    });

    const memberRepository = new MemberRepository({
        stripeAPIService,
        tokenService,
        newslettersService,
        labsService,
        productRepository,
        Member,
        MemberCancelEvent,
        MemberSubscribeEvent,
        MemberPaidSubscriptionEvent,
        MemberEmailChangeEvent,
        MemberStatusEvent,
        MemberProductEvent,
        OfferRedemption,
        StripeCustomer,
        StripeCustomerSubscription,
        offerRepository: offersAPI.repository
    });

    const eventRepository = new EventRepository({
        EmailRecipient,
        MemberSubscribeEvent,
        MemberPaidSubscriptionEvent,
        MemberPaymentEvent,
        MemberStatusEvent,
        MemberLoginEvent,
        labsService
    });

    const memberBREADService = new MemberBREADService({
        offersAPI,
        memberRepository,
        emailService: {
            async sendEmailWithMagicLink({email, requestedType}) {
                return sendEmailWithMagicLink({
                    email,
                    requestedType,
                    options: {
                        forceEmailType: true
                    }
                });
            }
        },
        labsService,
        stripeService: stripeAPIService
    });

    const geolocationService = new GeolocationSerice();

    const magicLinkService = new MagicLink({
        transporter,
        tokenProvider,
        getSigninURL,
        getText,
        getHTML,
        getSubject
    });

    const memberController = new MemberController({
        memberRepository,
        productRepository,
        StripePrice,
        tokenService,
        sendEmailWithMagicLink
    });

    const paymentsService = new PaymentsService({
        Offer,
        offersAPI,
        stripeAPIService
    });

    const routerController = new RouterController({
        offersAPI,
        paymentsService,
        productRepository,
        memberRepository,
        StripePrice,
        allowSelfSignup,
        magicLinkService,
        stripeAPIService,
        tokenService,
        sendEmailWithMagicLink,
        labsService
    });

    const wellKnownController = new WellKnownController({
        tokenService
    });

    const users = memberRepository;

    async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, requestSrc = ''}) {
        let type = requestedType;
        if (!options.forceEmailType) {
            const member = await users.get({email});
            if (member) {
                type = 'signin';
            } else if (type !== 'subscribe') {
                type = 'signup';
            }
        }
        return magicLinkService.sendMagicLink({email, type, requestSrc, tokenData: Object.assign({email}, tokenData)});
    }

    function getMagicLink(email) {
        return magicLinkService.getMagicLink({tokenData: {email}, type: 'signin'});
    }

    async function getMemberDataFromMagicLinkToken(token) {
        const {email, labels = [], name = '', oldEmail, newsletters} = await magicLinkService.getDataFromToken(token);
        if (!email) {
            return null;
        }

        const member = oldEmail ? await getMemberIdentityData(oldEmail) : await getMemberIdentityData(email);

        if (member) {
            await MemberLoginEvent.add({member_id: member.id});
            if (oldEmail) {
                // user exists but wants to change their email address
                await users.update({email}, {id: member.id});
                return getMemberIdentityData(email);
            }
            return member;
        }
        const newMember = await users.create({name, email, labels, newsletters});
        await MemberLoginEvent.add({member_id: newMember.id});
        return getMemberIdentityData(email);
    }

    async function getMemberIdentityData(email) {
        return memberBREADService.read({email});
    }

    async function getMemberIdentityToken(email) {
        const member = await getMemberIdentityData(email);
        if (!member) {
            return null;
        }
        return tokenService.encodeIdentityToken({sub: member.email});
    }

    async function setMemberGeolocationFromIp(email, ip) {
        if (!email || !ip) {
            throw new errors.IncorrectUsageError({
                message: 'setMemberGeolocationFromIp() expects email and ip arguments to be present'
            });
        }

        // toJSON() is needed here otherwise users.update() will pick methods off
        // the model object rather than data and fail to edit correctly
        const member = (await users.get({email})).toJSON();

        if (!member) {
            throw new errors.NotFoundError({
                message: `Member with email address ${email} does not exist`
            });
        }

        // max request time is 500ms so shouldn't slow requests down too much
        let geolocation = JSON.stringify(await geolocationService.getGeolocationFromIP(ip));
        if (geolocation) {
            await users.update({geolocation}, {id: member.id});
        }

        return getMemberIdentityData(email);
    }

    const middleware = {
        sendMagicLink: Router().use(
            body.json(),
            (req, res) => routerController.sendMagicLink(req, res)
        ),
        createCheckoutSession: Router().use(
            body.json(),
            (req, res) => routerController.createCheckoutSession(req, res)
        ),
        createCheckoutSetupSession: Router().use(
            body.json(),
            (req, res) => routerController.createCheckoutSetupSession(req, res)
        ),
        createEvents: Router().use(
            body.json(),
            (req, res) => MembersAnalyticsIngress.createEvents(req, res)
        ),
        updateEmailAddress: Router().use(
            body.json(),
            (req, res) => memberController.updateEmailAddress(req, res)
        ),
        updateSubscription: Router({mergeParams: true}).use(
            body.json(),
            (req, res) => memberController.updateSubscription(req, res)
        ),
        wellKnown: Router()
            .get('/jwks.json',
                (req, res) => wellKnownController.getPublicKeys(req, res)
            )
    };

    const getPublicConfig = function () {
        return Promise.resolve({
            publicKey,
            issuer
        });
    };

    const bus = new (require('events').EventEmitter)();

    bus.emit('ready');

    return {
        middleware,
        getMemberDataFromMagicLinkToken,
        getMemberIdentityToken,
        getMemberIdentityData,
        setMemberGeolocationFromIp,
        getPublicConfig,
        bus,
        sendEmailWithMagicLink,
        getMagicLink,
        members: users,
        memberBREADService,
        events: eventRepository,
        productRepository
    };
};
<|endoftext|>


<commit_before>/* eslint-disable no-console */

const {getQueryPrice} = require('./utils/helpers');

function handleDataAttributes({siteUrl, site, member}) {
    siteUrl = siteUrl.replace(/\/$/, '');
    Array.prototype.forEach.call(document.querySelectorAll('form[data-members-form]'), function (form) {
        let errorEl = form.querySelector('[data-members-error]');
        function submitHandler(event) {
            form.removeEventListener('submit', submitHandler);
            event.preventDefault();
            if (errorEl) {
                errorEl.innerText = '';
            }
            form.classList.remove('success', 'invalid', 'error');
            let emailInput = event.target.querySelector('input[data-members-email]');
            let nameInput = event.target.querySelector('input[data-members-name]');
            let email = emailInput.value;
            let name = (nameInput && nameInput.value) || undefined;
            let emailType = undefined;
            let labels = [];

            let labelInputs = event.target.querySelectorAll('input[data-members-label]') || [];
            for (let i = 0; i < labelInputs.length; ++i) {
                labels.push(labelInputs[i].value);
            }

            if (form.dataset.membersForm) {
                emailType = form.dataset.membersForm;
            }

            form.classList.add('loading');
            fetch(`${siteUrl}/members/api/send-magic-link/`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    email: email,
                    emailType: emailType,
                    labels: labels,
                    name: name
                })
            }).then(function (res) {
                form.addEventListener('submit', submitHandler);
                form.classList.remove('loading');
                if (res.ok) {
                    form.classList.add('success');
                } else {
                    if (errorEl) {
                        errorEl.innerText = 'There was an error sending the email, please try again';
                    }
                    form.classList.add('error');
                }
            });
        }
        form.addEventListener('submit', submitHandler);
    });

    Array.prototype.forEach.call(document.querySelectorAll('[data-members-plan]'), function (el) {
        let errorEl = el.querySelector('[data-members-error]');
        function clickHandler(event) {
            el.removeEventListener('click', clickHandler);
            event.preventDefault();
            let plan = el.dataset.membersPlan;
            let priceId = '';
            if (plan) {
                const price = getQueryPrice({site, priceId: plan.toLowerCase()});
                priceId = price ? price.id : plan;
            }
            let successUrl = el.dataset.membersSuccess;
            let cancelUrl = el.dataset.membersCancel;
            let checkoutSuccessUrl;
            let checkoutCancelUrl;

            if (successUrl) {
                checkoutSuccessUrl = (new URL(successUrl, window.location.href)).href;
            }

            if (cancelUrl) {
                checkoutCancelUrl = (new URL(cancelUrl, window.location.href)).href;
            }

            if (errorEl) {
                errorEl.innerText = '';
            }
            el.classList.add('loading');
            const metadata = member ? {
                checkoutType: 'upgrade'
            } : {};
            fetch(`${siteUrl}/members/api/session`, {
                credentials: 'same-origin'
            }).then(function (res) {
                if (!res.ok) {
                    return null;
                }
                return res.text();
            }).then(function (identity) {
                return fetch(`${siteUrl}/members/api/create-stripe-checkout-session/`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        priceId: priceId,
                        identity: identity,
                        successUrl: checkoutSuccessUrl,
                        cancelUrl: checkoutCancelUrl,
                        metadata
                    })
                }).then(function (res) {
                    if (!res.ok) {
                        throw new Error('Could not create stripe checkout session');
                    }
                    return res.json();
                });
            }).then(function (result) {
                let stripe = window.Stripe(result.publicKey);
                return stripe.redirectToCheckout({
                    sessionId: result.sessionId
                });
            }).then(function (result) {
                if (result.error) {
                    throw new Error(result.error.message);
                }
            }).catch(function (err) {
                console.error(err);
                el.addEventListener('click', clickHandler);
                el.classList.remove('loading');
                if (errorEl) {
                    errorEl.innerText = err.message;
                }
                el.classList.add('error');
            });
        }
        el.addEventListener('click', clickHandler);
    });

    Array.prototype.forEach.call(document.querySelectorAll('[data-members-edit-billing]'), function (el) {
        let errorEl = el.querySelector('[data-members-error]');
        let membersSuccess = el.dataset.membersSuccess;
        let membersCancel = el.dataset.membersCancel;
        let successUrl;
        let cancelUrl;

        if (membersSuccess) {
            successUrl = (new URL(membersSuccess, window.location.href)).href;
        }

        if (membersCancel) {
            cancelUrl = (new URL(membersCancel, window.location.href)).href;
        }

        function clickHandler(event) {
            el.removeEventListener('click', clickHandler);
            event.preventDefault();

            if (errorEl) {
                errorEl.innerText = '';
            }
            el.classList.add('loading');
            fetch(`${siteUrl}/members/api/session`, {
                credentials: 'same-origin'
            }).then(function (res) {
                if (!res.ok) {
                    return null;
                }
                return res.text();
            }).then(function (identity) {
                return fetch(`${siteUrl}/members/api/create-stripe-update-session/`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        identity: identity,
                        successUrl: successUrl,
                        cancelUrl: cancelUrl
                    })
                }).then(function (res) {
                    if (!res.ok) {
                        throw new Error('Could not create stripe checkout session');
                    }
                    return res.json();
                });
            }).then(function (result) {
                let stripe = window.Stripe(result.publicKey);
                return stripe.redirectToCheckout({
                    sessionId: result.sessionId
                });
            }).then(function (result) {
                if (result.error) {
                    throw new Error(result.error.message);
                }
            }).catch(function (err) {
                console.error(err);
                el.addEventListener('click', clickHandler);
                el.classList.remove('loading');
                if (errorEl) {
                    errorEl.innerText = err.message;
                }
                el.classList.add('error');
            });
        }
        el.addEventListener('click', clickHandler);
    });

    Array.prototype.forEach.call(document.querySelectorAll('[data-members-signout]'), function (el) {
        function clickHandler(event) {
            el.removeEventListener('click', clickHandler);
            event.preventDefault();
            el.classList.remove('error');
            el.classList.add('loading');
            fetch(`${siteUrl}/members/api/session`, {
                method: 'DELETE'
            }).then(function (res) {
                if (res.ok) {
                    window.location.replace(siteUrl);
                } else {
                    el.addEventListener('click', clickHandler);
                    el.classList.remove('loading');
                    el.classList.add('error');
                }
            });
        }
        el.addEventListener('click', clickHandler);
    });

    Array.prototype.forEach.call(document.querySelectorAll('[data-members-cancel-subscription]'), function (el) {
        let errorEl = el.parentElement.querySelector('[data-members-error]');
        function clickHandler(event) {
            el.removeEventListener('click', clickHandler);
            event.preventDefault();
            el.classList.remove('error');
            el.classList.add('loading');

            let subscriptionId = el.dataset.membersCancelSubscription;

            if (errorEl) {
                errorEl.innerText = '';
            }

            return fetch(`${siteUrl}/members/api/session`, {
                credentials: 'same-origin'
            }).then(function (res) {
                if (!res.ok) {
                    return null;
                }

                return res.text();
            }).then(function (identity) {
                return fetch(`${siteUrl}/members/api/subscriptions/${subscriptionId}/`, {
                    method: 'PUT',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        identity: identity,
                        smart_cancel: true
                    })
                });
            }).then(function (res) {
                if (res.ok) {
                    window.location.reload();
                } else {
                    el.addEventListener('click', clickHandler);
                    el.classList.remove('loading');
                    el.classList.add('error');

                    if (errorEl) {
                        errorEl.innerText = 'There was an error cancelling your subscription, please try again.';
                    }
                }
            });
        }
        el.addEventListener('click', clickHandler);
    });

    Array.prototype.forEach.call(document.querySelectorAll('[data-members-continue-subscription]'), function (el) {
        let errorEl = el.parentElement.querySelector('[data-members-error]');
        function clickHandler(event) {
            el.removeEventListener('click', clickHandler);
            event.preventDefault();
            el.classList.remove('error');
            el.classList.add('loading');

            let subscriptionId = el.dataset.membersContinueSubscription;

            if (errorEl) {
                errorEl.innerText = '';
            }

            return fetch(`${siteUrl}/members/api/session`, {
                credentials: 'same-origin'
            }).then(function (res) {
                if (!res.ok) {
                    return null;
                }

                return res.text();
            }).then(function (identity) {
                return fetch(`${siteUrl}/members/api/subscriptions/${subscriptionId}/`, {
                    method: 'PUT',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        identity: identity,
                        cancel_at_period_end: false
                    })
                });
            }).then(function (res) {
                if (res.ok) {
                    window.location.reload();
                } else {
                    el.addEventListener('click', clickHandler);
                    el.classList.remove('loading');
                    el.classList.add('error');

                    if (errorEl) {
                        errorEl.innerText = 'There was an error continuing your subscription, please try again.';
                    }
                }
            });
        }
        el.addEventListener('click', clickHandler);
    });
}

module.exports = handleDataAttributes;
<commit_msg>Handled empty email input in data attributes<commit_after>/* eslint-disable no-console */

const {getQueryPrice} = require('./utils/helpers');

function handleDataAttributes({siteUrl, site, member}) {
    siteUrl = siteUrl.replace(/\/$/, '');
    Array.prototype.forEach.call(document.querySelectorAll('form[data-members-form]'), function (form) {
        let errorEl = form.querySelector('[data-members-error]');
        function submitHandler(event) {
            form.removeEventListener('submit', submitHandler);
            event.preventDefault();
            if (errorEl) {
                errorEl.innerText = '';
            }
            form.classList.remove('success', 'invalid', 'error');
            let emailInput = event.target.querySelector('input[data-members-email]');
            let nameInput = event.target.querySelector('input[data-members-name]');
            let email = emailInput?.value;
            let name = (nameInput && nameInput.value) || undefined;
            let emailType = undefined;
            let labels = [];

            let labelInputs = event.target.querySelectorAll('input[data-members-label]') || [];
            for (let i = 0; i < labelInputs.length; ++i) {
                labels.push(labelInputs[i].value);
            }

            if (form.dataset.membersForm) {
                emailType = form.dataset.membersForm;
            }

            form.classList.add('loading');
            fetch(`${siteUrl}/members/api/send-magic-link/`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    email: email,
                    emailType: emailType,
                    labels: labels,
                    name: name
                })
            }).then(function (res) {
                form.addEventListener('submit', submitHandler);
                form.classList.remove('loading');
                if (res.ok) {
                    form.classList.add('success');
                } else {
                    if (errorEl) {
                        errorEl.innerText = 'There was an error sending the email, please try again';
                    }
                    form.classList.add('error');
                }
            });
        }
        form.addEventListener('submit', submitHandler);
    });

    Array.prototype.forEach.call(document.querySelectorAll('[data-members-plan]'), function (el) {
        let errorEl = el.querySelector('[data-members-error]');
        function clickHandler(event) {
            el.removeEventListener('click', clickHandler);
            event.preventDefault();
            let plan = el.dataset.membersPlan;
            let priceId = '';
            if (plan) {
                const price = getQueryPrice({site, priceId: plan.toLowerCase()});
                priceId = price ? price.id : plan;
            }
            let successUrl = el.dataset.membersSuccess;
            let cancelUrl = el.dataset.membersCancel;
            let checkoutSuccessUrl;
            let checkoutCancelUrl;

            if (successUrl) {
                checkoutSuccessUrl = (new URL(successUrl, window.location.href)).href;
            }

            if (cancelUrl) {
                checkoutCancelUrl = (new URL(cancelUrl, window.location.href)).href;
            }

            if (errorEl) {
                errorEl.innerText = '';
            }
            el.classList.add('loading');
            const metadata = member ? {
                checkoutType: 'upgrade'
            } : {};
            fetch(`${siteUrl}/members/api/session`, {
                credentials: 'same-origin'
            }).then(function (res) {
                if (!res.ok) {
                    return null;
                }
                return res.text();
            }).then(function (identity) {
                return fetch(`${siteUrl}/members/api/create-stripe-checkout-session/`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        priceId: priceId,
                        identity: identity,
                        successUrl: checkoutSuccessUrl,
                        cancelUrl: checkoutCancelUrl,
                        metadata
                    })
                }).then(function (res) {
                    if (!res.ok) {
                        throw new Error('Could not create stripe checkout session');
                    }
                    return res.json();
                });
            }).then(function (result) {
                let stripe = window.Stripe(result.publicKey);
                return stripe.redirectToCheckout({
                    sessionId: result.sessionId
                });
            }).then(function (result) {
                if (result.error) {
                    throw new Error(result.error.message);
                }
            }).catch(function (err) {
                console.error(err);
                el.addEventListener('click', clickHandler);
                el.classList.remove('loading');
                if (errorEl) {
                    errorEl.innerText = err.message;
                }
                el.classList.add('error');
            });
        }
        el.addEventListener('click', clickHandler);
    });

    Array.prototype.forEach.call(document.querySelectorAll('[data-members-edit-billing]'), function (el) {
        let errorEl = el.querySelector('[data-members-error]');
        let membersSuccess = el.dataset.membersSuccess;
        let membersCancel = el.dataset.membersCancel;
        let successUrl;
        let cancelUrl;

        if (membersSuccess) {
            successUrl = (new URL(membersSuccess, window.location.href)).href;
        }

        if (membersCancel) {
            cancelUrl = (new URL(membersCancel, window.location.href)).href;
        }

        function clickHandler(event) {
            el.removeEventListener('click', clickHandler);
            event.preventDefault();

            if (errorEl) {
                errorEl.innerText = '';
            }
            el.classList.add('loading');
            fetch(`${siteUrl}/members/api/session`, {
                credentials: 'same-origin'
            }).then(function (res) {
                if (!res.ok) {
                    return null;
                }
                return res.text();
            }).then(function (identity) {
                return fetch(`${siteUrl}/members/api/create-stripe-update-session/`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        identity: identity,
                        successUrl: successUrl,
                        cancelUrl: cancelUrl
                    })
                }).then(function (res) {
                    if (!res.ok) {
                        throw new Error('Could not create stripe checkout session');
                    }
                    return res.json();
                });
            }).then(function (result) {
                let stripe = window.Stripe(result.publicKey);
                return stripe.redirectToCheckout({
                    sessionId: result.sessionId
                });
            }).then(function (result) {
                if (result.error) {
                    throw new Error(result.error.message);
                }
            }).catch(function (err) {
                console.error(err);
                el.addEventListener('click', clickHandler);
                el.classList.remove('loading');
                if (errorEl) {
                    errorEl.innerText = err.message;
                }
                el.classList.add('error');
            });
        }
        el.addEventListener('click', clickHandler);
    });

    Array.prototype.forEach.call(document.querySelectorAll('[data-members-signout]'), function (el) {
        function clickHandler(event) {
            el.removeEventListener('click', clickHandler);
            event.preventDefault();
            el.classList.remove('error');
            el.classList.add('loading');
            fetch(`${siteUrl}/members/api/session`, {
                method: 'DELETE'
            }).then(function (res) {
                if (res.ok) {
                    window.location.replace(siteUrl);
                } else {
                    el.addEventListener('click', clickHandler);
                    el.classList.remove('loading');
                    el.classList.add('error');
                }
            });
        }
        el.addEventListener('click', clickHandler);
    });

    Array.prototype.forEach.call(document.querySelectorAll('[data-members-cancel-subscription]'), function (el) {
        let errorEl = el.parentElement.querySelector('[data-members-error]');
        function clickHandler(event) {
            el.removeEventListener('click', clickHandler);
            event.preventDefault();
            el.classList.remove('error');
            el.classList.add('loading');

            let subscriptionId = el.dataset.membersCancelSubscription;

            if (errorEl) {
                errorEl.innerText = '';
            }

            return fetch(`${siteUrl}/members/api/session`, {
                credentials: 'same-origin'
            }).then(function (res) {
                if (!res.ok) {
                    return null;
                }

                return res.text();
            }).then(function (identity) {
                return fetch(`${siteUrl}/members/api/subscriptions/${subscriptionId}/`, {
                    method: 'PUT',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        identity: identity,
                        smart_cancel: true
                    })
                });
            }).then(function (res) {
                if (res.ok) {
                    window.location.reload();
                } else {
                    el.addEventListener('click', clickHandler);
                    el.classList.remove('loading');
                    el.classList.add('error');

                    if (errorEl) {
                        errorEl.innerText = 'There was an error cancelling your subscription, please try again.';
                    }
                }
            });
        }
        el.addEventListener('click', clickHandler);
    });

    Array.prototype.forEach.call(document.querySelectorAll('[data-members-continue-subscription]'), function (el) {
        let errorEl = el.parentElement.querySelector('[data-members-error]');
        function clickHandler(event) {
            el.removeEventListener('click', clickHandler);
            event.preventDefault();
            el.classList.remove('error');
            el.classList.add('loading');

            let subscriptionId = el.dataset.membersContinueSubscription;

            if (errorEl) {
                errorEl.innerText = '';
            }

            return fetch(`${siteUrl}/members/api/session`, {
                credentials: 'same-origin'
            }).then(function (res) {
                if (!res.ok) {
                    return null;
                }

                return res.text();
            }).then(function (identity) {
                return fetch(`${siteUrl}/members/api/subscriptions/${subscriptionId}/`, {
                    method: 'PUT',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        identity: identity,
                        cancel_at_period_end: false
                    })
                });
            }).then(function (res) {
                if (res.ok) {
                    window.location.reload();
                } else {
                    el.addEventListener('click', clickHandler);
                    el.classList.remove('loading');
                    el.classList.add('error');

                    if (errorEl) {
                        errorEl.innerText = 'There was an error continuing your subscription, please try again.';
                    }
                }
            });
        }
        el.addEventListener('click', clickHandler);
    });
}

module.exports = handleDataAttributes;
<|endoftext|>


<commit_before>// ==UserScript==
// @name        GitHub Code Show Whitespace
// @version     1.2.10
// @description A userscript that shows whitespace (space, tabs and carriage returns) in code blocks
// @license     MIT
// @author      Rob Garrison
// @namespace   https://github.com/Mottie
// @include     https://github.com/*
// @include     https://gist.github.com/*
// @run-at      document-idle
// @grant       GM_registerMenuCommand
// @grant       GM.registerMenuCommand
// @grant       GM.addStyle
// @grant       GM_addStyle
// @grant       GM.getValue
// @grant       GM_getValue
// @grant       GM.setValue
// @grant       GM_setValue
// @require     https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?updated=20180103
// @require     https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=666427
// @icon        https://github.githubassets.com/pinned-octocat.svg
// @updateURL   https://raw.githubusercontent.com/Mottie/Github-userscripts/master/github-code-show-whitespace.user.js
// @downloadURL https://raw.githubusercontent.com/Mottie/Github-userscripts/master/github-code-show-whitespace.user.js
// ==/UserScript==
(async () => {
	"use strict";

	let showWhiteSpace = await GM.getValue("show-whitespace", "false");

	// include em-space & en-space?
	const whitespace = {
		// Applies \xb7 (·) to every space
		"%20" : " ",
		// Applies \xb7 (·) to every non-breaking space (alternative: \u2423 (␣))
		"%A0" : " ",
		// Applies \xbb (») to every tab
		"%09" : "\x09",
		// non-matching key; applied manually
		// Applies \u231d (⌝) to the end of every line
		// (alternatives: \u21b5 (↵) or \u2938 (⤸))
		"CRLF" : "\n"
	};
	const span = document.createElement("span");
	// ignore +/- in diff code blocks
	const regexWS = /(\x20| |\x09)/g;
	const regexCR = /\r*\n$/;
	const regexExceptions = /(\.md)$/i;

	const toggleButton = document.createElement("div");
	toggleButton.className = "ghcw-toggle btn btn-sm tooltipped tooltipped-s";
	toggleButton.setAttribute("aria-label", "Toggle Whitespace");
	toggleButton.innerHTML = "";

	GM.addStyle(`
		div.file-actions > div,
		.ghcw-active .ghcw-whitespace,
		.gist-content-wrapper .file-actions .btnGroup {
			position: relative;
			display: inline;
		}
		.gist-content-wrapper .ghcw-toggle {
			padding: 5px 10px; /* gist only */
		}
		.ghcw-toggle + .BtnGroup {
			margin-left: 4px;
		}
		.ghcw-active .ghcw-whitespace:before {
			position: absolute;
			opacity: .5;
			user-select: none;
			font-weight: bold;
			color: #777 !important;
			top: -.25em;
			left: 0;
		}
		.ghcw-toggle .pl-tab {
			pointer-events: none;
		}
		.ghcw-active .pl-space:before {
			content: "\\b7";
		}
		.ghcw-active .pl-nbsp:before {
			content: "\\b7";
		}
		.ghcw-active .pl-tab:before,
		.ghcw-toggle .pl-tab:before {
			content: "\\bb";
		}
		.ghcw-active .pl-crlf:before {
			content: "\\231d";
			top: .1em;
		}
		/* weird tweak for diff markdown files - see #27 */
		.ghcw-adjust .ghcw-active .ghcw-whitespace:before {
			left: .6em;
		}
		/* hide extra leading space added to diffs - see #27 */
		.diff-table tr.blob-expanded td > span:first-child .pl-space:first-child {
			visibility: hidden;
		}
		.blob-code-inner > br {
			display: none !important;
		}
	`);

	function addFileActions() {
		// file-actions removed from repo file view;
		// still used in gists & file diffs
		if (!$(".file-actions")) {
			const rawBtn = $("#raw-url");
			if (rawBtn) {
				const group = rawBtn.closest(".BtnGroup");
				const fileActionWrap = group && group.parentNode;
				if (fileActionWrap) {
					fileActionWrap.classList.add("file-actions");
				}
			}
		}
	}

	function addToggle() {
		addFileActions();
		$$(".file-actions").forEach(el => {
			if (!$(".ghcw-toggle", el)) {
				const dropdown = $(".dropdown", el);
				// (* + sibling) Indicates where the whitespace toggle is added
				// PR Layout: div.file-actions > div.flex-items-stretch > (details.dropdown + *)
				// Repo file: div.file-actions > (* + div.BtnGroup) > a#raw-url
				// Gist: div.file-actions > (* + a)
				if (dropdown) {
					el = dropdown.parentNode; // Fixes #91
				}
				el.insertBefore(toggleButton.cloneNode(true), el.childNodes[0]);
			}
			if (showWhiteSpace === "true") {
				// Let the page render a bit before going nuts
				setTimeout(show(el, true), 200);
			}
		});
	}

	function getNodes(line) {
		const nodeIterator = document.createNodeIterator(
			line,
			NodeFilter.SHOW_TEXT,
			() => NodeFilter.FILTER_ACCEPT
		);
		let currentNode,
			nodes = [];
		while ((currentNode = nodeIterator.nextNode())) {
			nodes.push(currentNode);
		}
		return nodes;
	}

	function escapeHTML(html) {
		return html.replace(/[<>"'&]/g, m => ({
			"<": "<",
			">": ">",
			"&": "&",
			"'": "'",
			"\"": """
		}[m]));
	}

	function replaceWhitespace(html) {
		return escapeHTML(html).replace(regexWS, s => {
			let idx = 0,
				ln = s.length,
				result = "";
			for (idx = 0; idx < ln; idx++) {
				result += whitespace[encodeURI(s[idx])] || s[idx] || "";
			}
			return result;
		});
	}

	function replaceTextNode(nodes) {
		let node, indx, el,
			ln = nodes.length;
		for (indx = 0; indx < ln; indx++) {
			node = nodes[indx];
			if (
				node &&
				node.nodeType === 3 &&
				node.textContent &&
				node.textContent.search(regexWS) > -1
			) {
				el = span.cloneNode();
				el.innerHTML = replaceWhitespace(node.textContent.replace(regexCR, ""));
				node.parentNode.insertBefore(el, node);
				node.parentNode.removeChild(node);
			}
		}
	}

	function* modifyLine(lines) {
		while (lines.length) {
			const line = lines.shift();
			// first node is a syntax string and may have leading whitespace
			replaceTextNode(getNodes(line));
			// remove end CRLF if it exists; then add a line ending
			const html = line.innerHTML;
			const update = html.replace(regexCR, "") + whitespace.CRLF;
			if (update !== html) {
				line.innerHTML = update;
			}
		}
		yield lines;
	}

	function addWhitespace(block) {
		if (block && !block.classList.contains("ghcw-processed")) {
			block.classList.add("ghcw-processed");
			let status;

			// class name of each code row
			const lines = $$(".blob-code-inner:not(.blob-code-hunk)", block);
			const iter = modifyLine(lines);

			// loop with delay to allow user interaction
			const loop = () => {
				for (let i = 0; i < 40; i++) {
					status = iter.next();
				}
				if (!status.done) {
					requestAnimationFrame(loop);
				}
			};
			loop();
		}
	}

	function detectDiff(wrap) {
		const header = $(".file-header", wrap);
		if ($(".diff-table", wrap) && header) {
			const file = header.getAttribute("data-path");
			if (
				// File Exceptions that need tweaking (e.g. ".md")
				regexExceptions.test(file) ||
				// files with no extension (e.g. LICENSE)
				file.indexOf(".") === -1
			) {
				// This class is added to adjust the position of the whitespace
				// markers for specific files; See issue #27
				wrap.classList.add("ghcw-adjust");
			}
		}
	}

	function showAll() {
		$$(".blob-wrapper .highlight, .file .highlight").forEach(target => {
			show(target, true);
		});
	}

	function show(target, state) {
		const wrap = target.closest(".file, .Box");
		const block = $(".highlight", wrap);
		if (block) {
			wrap.querySelector(".ghcw-toggle").classList.toggle("selected", state);
			block.classList.toggle("ghcw-active", state);
			detectDiff(wrap);
			addWhitespace(block);
		}
	}

	function $(selector, el) {
		return (el || document).querySelector(selector);
	}

	function $$(selector, el) {
		return [...(el || document).querySelectorAll(selector)];
	}

	// bind whitespace toggle button
	document.addEventListener("click", event => {
		const target = event.target;
		if (
			target.nodeName === "DIV" &&
			target.classList.contains("ghcw-toggle")
		) {
			show(target);
		}
	});

	GM.registerMenuCommand("Set GitHub Code White Space", async () => {
		let val = prompt("Always show on page load (true/false)?", showWhiteSpace);
		if (val !== null) {
			val = (val || "").toLowerCase();
			await GM.setValue("show-whitespace", val);
			showWhiteSpace = val;
			showAll();
		}
	});

	document.addEventListener("ghmo:container", addToggle);
	document.addEventListener("ghmo:diff", addToggle);
	// toggle added to diff & file view
	addToggle();

})();
<commit_msg>Show-whitespace: No button for new gists<commit_after>// ==UserScript==
// @name        GitHub Code Show Whitespace
// @version     1.2.10
// @description A userscript that shows whitespace (space, tabs and carriage returns) in code blocks
// @license     MIT
// @author      Rob Garrison
// @namespace   https://github.com/Mottie
// @include     https://github.com/*
// @include     https://gist.github.com/*
// @run-at      document-idle
// @grant       GM_registerMenuCommand
// @grant       GM.registerMenuCommand
// @grant       GM.addStyle
// @grant       GM_addStyle
// @grant       GM.getValue
// @grant       GM_getValue
// @grant       GM.setValue
// @grant       GM_setValue
// @require     https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?updated=20180103
// @require     https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=666427
// @icon        https://github.githubassets.com/pinned-octocat.svg
// @updateURL   https://raw.githubusercontent.com/Mottie/Github-userscripts/master/github-code-show-whitespace.user.js
// @downloadURL https://raw.githubusercontent.com/Mottie/Github-userscripts/master/github-code-show-whitespace.user.js
// ==/UserScript==
(async () => {
	"use strict";

	let showWhiteSpace = await GM.getValue("show-whitespace", "false");

	// include em-space & en-space?
	const whitespace = {
		// Applies \xb7 (·) to every space
		"%20" : " ",
		// Applies \xb7 (·) to every non-breaking space (alternative: \u2423 (␣))
		"%A0" : " ",
		// Applies \xbb (») to every tab
		"%09" : "\x09",
		// non-matching key; applied manually
		// Applies \u231d (⌝) to the end of every line
		// (alternatives: \u21b5 (↵) or \u2938 (⤸))
		"CRLF" : "\n"
	};
	const span = document.createElement("span");
	// ignore +/- in diff code blocks
	const regexWS = /(\x20| |\x09)/g;
	const regexCR = /\r*\n$/;
	const regexExceptions = /(\.md)$/i;

	const toggleButton = document.createElement("div");
	toggleButton.className = "ghcw-toggle btn btn-sm tooltipped tooltipped-s";
	toggleButton.setAttribute("aria-label", "Toggle Whitespace");
	toggleButton.innerHTML = "";

	GM.addStyle(`
		div.file-actions > div,
		.ghcw-active .ghcw-whitespace,
		.gist-content-wrapper .file-actions .btnGroup {
			position: relative;
			display: inline;
		}
		.gist-content-wrapper .ghcw-toggle {
			padding: 5px 10px; /* gist only */
		}
		.ghcw-toggle + .BtnGroup {
			margin-left: 4px;
		}
		.ghcw-active .ghcw-whitespace:before {
			position: absolute;
			opacity: .5;
			user-select: none;
			font-weight: bold;
			color: #777 !important;
			top: -.25em;
			left: 0;
		}
		.ghcw-toggle .pl-tab {
			pointer-events: none;
		}
		.ghcw-active .pl-space:before {
			content: "\\b7";
		}
		.ghcw-active .pl-nbsp:before {
			content: "\\b7";
		}
		.ghcw-active .pl-tab:before,
		.ghcw-toggle .pl-tab:before {
			content: "\\bb";
		}
		.ghcw-active .pl-crlf:before {
			content: "\\231d";
			top: .1em;
		}
		/* weird tweak for diff markdown files - see #27 */
		.ghcw-adjust .ghcw-active .ghcw-whitespace:before {
			left: .6em;
		}
		/* hide extra leading space added to diffs - see #27 */
		.diff-table tr.blob-expanded td > span:first-child .pl-space:first-child {
			visibility: hidden;
		}
		.blob-code-inner > br {
			display: none !important;
		}
	`);

	function addFileActions() {
		// file-actions removed from repo file view;
		// still used in gists & file diffs
		if (!$(".file-actions")) {
			const rawBtn = $("#raw-url");
			if (rawBtn) {
				const group = rawBtn.closest(".BtnGroup");
				const fileActionWrap = group && group.parentNode;
				if (fileActionWrap) {
					fileActionWrap.classList.add("file-actions");
				}
			}
		}
	}

	function addToggle() {
		addFileActions();
		$$(".file-actions").forEach(el => {
			// Don't add a toggle for new gists (editor showing)
			if (!$(".ghcw-toggle", el) && !$("#indent-mode", el)) {
				const dropdown = $(".dropdown", el);
				// (* + sibling) Indicates where the whitespace toggle is added
				// PR Layout: div.file-actions > div.flex-items-stretch > (details.dropdown + *)
				// Repo file: div.file-actions > (* + div.BtnGroup) > a#raw-url
				// Gist: div.file-actions > (* + a)
				if (dropdown) {
					el = dropdown.parentNode; // Fixes #91
				}
				el.insertBefore(toggleButton.cloneNode(true), el.childNodes[0]);
			}
			if (showWhiteSpace === "true") {
				// Let the page render a bit before going nuts
				setTimeout(show(el, true), 200);
			}
		});
	}

	function getNodes(line) {
		const nodeIterator = document.createNodeIterator(
			line,
			NodeFilter.SHOW_TEXT,
			() => NodeFilter.FILTER_ACCEPT
		);
		let currentNode,
			nodes = [];
		while ((currentNode = nodeIterator.nextNode())) {
			nodes.push(currentNode);
		}
		return nodes;
	}

	function escapeHTML(html) {
		return html.replace(/[<>"'&]/g, m => ({
			"<": "<",
			">": ">",
			"&": "&",
			"'": "'",
			"\"": """
		}[m]));
	}

	function replaceWhitespace(html) {
		return escapeHTML(html).replace(regexWS, s => {
			let idx = 0,
				ln = s.length,
				result = "";
			for (idx = 0; idx < ln; idx++) {
				result += whitespace[encodeURI(s[idx])] || s[idx] || "";
			}
			return result;
		});
	}

	function replaceTextNode(nodes) {
		let node, indx, el,
			ln = nodes.length;
		for (indx = 0; indx < ln; indx++) {
			node = nodes[indx];
			if (
				node &&
				node.nodeType === 3 &&
				node.textContent &&
				node.textContent.search(regexWS) > -1
			) {
				el = span.cloneNode();
				el.innerHTML = replaceWhitespace(node.textContent.replace(regexCR, ""));
				node.parentNode.insertBefore(el, node);
				node.parentNode.removeChild(node);
			}
		}
	}

	function* modifyLine(lines) {
		while (lines.length) {
			const line = lines.shift();
			// first node is a syntax string and may have leading whitespace
			replaceTextNode(getNodes(line));
			// remove end CRLF if it exists; then add a line ending
			const html = line.innerHTML;
			const update = html.replace(regexCR, "") + whitespace.CRLF;
			if (update !== html) {
				line.innerHTML = update;
			}
		}
		yield lines;
	}

	function addWhitespace(block) {
		if (block && !block.classList.contains("ghcw-processed")) {
			block.classList.add("ghcw-processed");
			let status;

			// class name of each code row
			const lines = $$(".blob-code-inner:not(.blob-code-hunk)", block);
			const iter = modifyLine(lines);

			// loop with delay to allow user interaction
			const loop = () => {
				for (let i = 0; i < 40; i++) {
					status = iter.next();
				}
				if (!status.done) {
					requestAnimationFrame(loop);
				}
			};
			loop();
		}
	}

	function detectDiff(wrap) {
		const header = $(".file-header", wrap);
		if ($(".diff-table", wrap) && header) {
			const file = header.getAttribute("data-path");
			if (
				// File Exceptions that need tweaking (e.g. ".md")
				regexExceptions.test(file) ||
				// files with no extension (e.g. LICENSE)
				file.indexOf(".") === -1
			) {
				// This class is added to adjust the position of the whitespace
				// markers for specific files; See issue #27
				wrap.classList.add("ghcw-adjust");
			}
		}
	}

	function showAll() {
		$$(".blob-wrapper .highlight, .file .highlight").forEach(target => {
			show(target, true);
		});
	}

	function show(target, state) {
		const wrap = target.closest(".file, .Box");
		const block = $(".highlight", wrap);
		if (block) {
			wrap.querySelector(".ghcw-toggle").classList.toggle("selected", state);
			block.classList.toggle("ghcw-active", state);
			detectDiff(wrap);
			addWhitespace(block);
		}
	}

	function $(selector, el) {
		return (el || document).querySelector(selector);
	}

	function $$(selector, el) {
		return [...(el || document).querySelectorAll(selector)];
	}

	// bind whitespace toggle button
	document.addEventListener("click", event => {
		const target = event.target;
		if (
			target.nodeName === "DIV" &&
			target.classList.contains("ghcw-toggle")
		) {
			show(target);
		}
	});

	GM.registerMenuCommand("Set GitHub Code White Space", async () => {
		let val = prompt("Always show on page load (true/false)?", showWhiteSpace);
		if (val !== null) {
			val = (val || "").toLowerCase();
			await GM.setValue("show-whitespace", val);
			showWhiteSpace = val;
			showAll();
		}
	});

	document.addEventListener("ghmo:container", addToggle);
	document.addEventListener("ghmo:diff", addToggle);
	// toggle added to diff & file view
	addToggle();

})();
<|endoftext|>


<commit_before>// ==UserScript==
// @name        GitHub Code Show Whitespace
// @version     0.1.0
// @description A userscript that shows whitespace (space, tabs and carriage returns) in code blocks
// @license     https://opensource.org/licenses/MIT
// @namespace   https://github.com/Mottie
// @include     https://github.com/*
// @run-at      document-idle
// @grant       GM_addStyle
// @author      Rob Garrison
// @updateURL   https://raw.githubusercontent.com/Mottie/Github-userscripts/master/github-code-show-whitespace.user.js
// @downloadURL https://raw.githubusercontent.com/Mottie/Github-userscripts/master/github-code-show-whitespace.user.js
// ==/UserScript==
(() => {
	"use strict";

	// include em-space & en-space?
	const whitespace = {
			"%20"  : " ",
			"%A0"  : " ",
			"%09"  : "\x09",
			// non-matching key; applied manually
			"CRLF" : "\n"
		},
		// ignore +/- in diff code blocks
		regexWS = /^(?:[+-]*)(\x20| |\x09)+/g,
		regexCR = /\r*\n$/,
		regexTabSize = /\btab-size-\d\b/g,

		toggleButton = document.createElement("div");
	toggleButton.className = "ghcw-toggle btn btn-sm tooltipped tooltipped-n";
	toggleButton.setAttribute("aria-label", "Toggle Whitespace");
	toggleButton.innerHTML = "";

	GM_addStyle(`
		.highlight .blob-code-inner { tab-size: 2; }
		/* GitHub-Dark overrides the above setting */
		.highlight.ghcw-active.tab-size-2 .pl-tab { width: 1.1em; }
		.highlight.ghcw-active.tab-size-4 .pl-tab { width: 2.2em; }
		.highlight.ghcw-active.tab-size-6 .pl-tab { width: 3.3em; }
		.highlight.ghcw-active.tab-size-8 .pl-tab { width: 4.4em; }

		.ghcw-active .ghcw-whitespace {
			position: relative;
			display: inline-block;
		}
		.ghcw-active .ghcw-whitespace:before {
			position: absolute;
			overflow: hidden;
			opacity: .4;
			user-select: none;
		}
		.ghcw-toggle .pl-tab {
			pointer-events: none;
		}
		.ghcw-active .pl-space:before {
			content: "\\b7";
		}
		.ghcw-active .pl-nbsp:before {
			content: "\\b7";
		}
		.ghcw-active .pl-tab:before,
		.ghcw-toggle .pl-tab:before {
			content: "\\bb";
		}
		.ghcw-active .pl-crlf:before {
			content: "\\231d";
			top: -.75em;
		}
	`);

	let debounce,
		busy = false,
		observers = [];

	function addToggle() {
		$$(".file-actions").forEach(el => {
			if (!$(".ghcw-toggle", el)) {
				el.insertBefore(toggleButton.cloneNode(true), el.childNodes[0]);
			}
		});
	}

	function addWhitespace(block) {
		let lines, indx, len;
		if (block && !block.classList.contains("ghcw-processed")) {
			block.classList.add("ghcw-processed");
			updateTabSize(block);
			indx = 0;

			// class name of each code row
			lines = $$(".blob-code-inner:not(.blob-code-hunk)", block);
			len = lines.length;

			// loop with delay to allow user interaction
			const loop = () => {
				let line,
					// max number of DOM insertions per loop
					max = 0;
				while (max < 50 && indx < len) {
					if (indx >= len) {
						return;
					}
					line = lines[indx];
					line.innerHTML = line.innerHTML
						.replace(regexWS, s => {
							let idx = 0,
								ln = s.length,
								result = "";
							for (idx = 0; idx < ln; idx++) {
								result += whitespace[encodeURI(s[idx])] || s[idx] || "";
							}
							return result;
						})
						// remove end CRLF if it exists
						.replace(regexCR, "") +
						whitespace.CRLF;
					max++;
					indx++;
				}
				if (indx < len) {
					setTimeout(() => {
						loop();
					}, 200);
				}
			};
			loop();
		}
	}

	function updateTabSize(block) {
		// remove previous tab-size setting
		block.className = block.className.replace(regexTabSize, " ");
		// calculate tab-size; GitHub-Dark allows user modification
		const len = window.getComputedStyle($(".blob-code-inner", block)).tabSize;
		block.classList.add(`tab-size-${len}`);
	}

	function removeObservers() {
		// remove saved observers
		observers.forEach(observer => {
			if (observer) {
				observer.disconnect();
			}
		});
		observers = [];
	}

	function addObservers() {
		// Observe progressively loaded content
		$$(`
			.js-diff-progressive-container,
			.js-diff-load-container`
		).forEach(target => {
			const obsrvr = new MutationObserver(mutations => {
				mutations.forEach(mutation => {
					// preform checks before adding code wrap to minimize function calls
					const tar = mutation.target;
					if (!busy && tar && (
							tar.classList.contains("js-diff-progressive-container") ||
							tar.classList.contains("js-diff-load-container") ||
							tar.classList.contains("blob-wrapper")
						)
					) {
						clearTimeout(debounce);
						debounce = setTimeout(() => {
							addToggle();
						}, 500);
					}
				});
			});
			obsrvr.observe(target, {
				childList: true,
				subtree: true
			});
			observers.push(obsrvr);
		});
	}

	function init() {
		// toggle added to diff & file view
		addToggle();
		removeObservers();
		if (document.querySelector("#files.diff-view")) {
			addObservers();
		}
	}

	function $(selector, el) {
		return (el || document).querySelector(selector);
	}

	function $$(selector, el) {
		return Array.from((el || document).querySelectorAll(selector));
	}

	function closest(selector, el) {
		while (el && el.nodeType === 1) {
			if (el.matches(selector)) {
				return el;
			}
			el = el.parentNode;
		}
		return null;
	}

	// bind whitespace toggle button
	document.addEventListener("click", event => {
		const target = event.target;
		if (
			target.nodeName === "DIV" &&
			target.classList.contains("ghcw-toggle")
		) {
			let block = $(".highlight", closest(".file", target));
			if (block) {
				target.classList.toggle("selected");
				block.classList.toggle("ghcw-active");
				updateTabSize(block);
				addWhitespace(block);
			}
		}
	});
	document.addEventListener("pjax:end", init);

	setTimeout(() => {
		init();
	}, 500);

})();
<commit_msg>Code-show-whitespace: Add to gist pages<commit_after>// ==UserScript==
// @name        GitHub Code Show Whitespace
// @version     0.1.1
// @description A userscript that shows whitespace (space, tabs and carriage returns) in code blocks
// @license     https://opensource.org/licenses/MIT
// @namespace   https://github.com/Mottie
// @include     https://github.com/*
// @include     https://gist.github.com/*
// @run-at      document-idle
// @grant       GM_addStyle
// @author      Rob Garrison
// @updateURL   https://raw.githubusercontent.com/Mottie/Github-userscripts/master/github-code-show-whitespace.user.js
// @downloadURL https://raw.githubusercontent.com/Mottie/Github-userscripts/master/github-code-show-whitespace.user.js
// ==/UserScript==
(() => {
	"use strict";

	// include em-space & en-space?
	const whitespace = {
			"%20"  : " ",
			"%A0"  : " ",
			"%09"  : "\x09",
			// non-matching key; applied manually
			"CRLF" : "\n"
		},
		// ignore +/- in diff code blocks
		regexWS = /^(?:[+-]*)(\x20| |\x09)+/g,
		regexCR = /\r*\n$/,
		regexTabSize = /\btab-size-\d\b/g,

		toggleButton = document.createElement("div");
	toggleButton.className = "ghcw-toggle btn btn-sm tooltipped tooltipped-n";
	toggleButton.setAttribute("aria-label", "Toggle Whitespace");
	toggleButton.innerHTML = "";

	GM_addStyle(`
		.highlight .blob-code-inner { tab-size: 2; }
		/* GitHub-Dark overrides the above setting */
		.highlight.ghcw-active.tab-size-2 .pl-tab { width: 1.1em; }
		.highlight.ghcw-active.tab-size-4 .pl-tab { width: 2.2em; }
		.highlight.ghcw-active.tab-size-6 .pl-tab { width: 3.3em; }
		.highlight.ghcw-active.tab-size-8 .pl-tab { width: 4.4em; }

		.ghcw-active .ghcw-whitespace,
		.gist-content-wrapper .file-actions .btn-group {
			position: relative;
			display: inline-block;
		}
		.ghcw-active .ghcw-whitespace:before {
			position: absolute;
			overflow: hidden;
			opacity: .4;
			user-select: none;
		}
		.ghcw-toggle .pl-tab {
			pointer-events: none;
		}
		.ghcw-active .pl-space:before {
			content: "\\b7";
		}
		.ghcw-active .pl-nbsp:before {
			content: "\\b7";
		}
		.ghcw-active .pl-tab:before,
		.ghcw-toggle .pl-tab:before {
			content: "\\bb";
		}
		.ghcw-active .pl-crlf:before {
			content: "\\231d";
			top: -.75em;
		}
	`);

	let debounce,
		busy = false,
		observers = [];

	function addToggle() {
		$$(".file-actions").forEach(el => {
			if (!$(".ghcw-toggle", el)) {
				el.insertBefore(toggleButton.cloneNode(true), el.childNodes[0]);
			}
		});
	}

	function addWhitespace(block) {
		let lines, indx, len;
		if (block && !block.classList.contains("ghcw-processed")) {
			block.classList.add("ghcw-processed");
			updateTabSize(block);
			indx = 0;

			// class name of each code row
			lines = $$(".blob-code-inner:not(.blob-code-hunk)", block);
			len = lines.length;

			// loop with delay to allow user interaction
			const loop = () => {
				let line,
					// max number of DOM insertions per loop
					max = 0;
				while (max < 50 && indx < len) {
					if (indx >= len) {
						return;
					}
					line = lines[indx];
					line.innerHTML = line.innerHTML
						.replace(regexWS, s => {
							let idx = 0,
								ln = s.length,
								result = "";
							for (idx = 0; idx < ln; idx++) {
								result += whitespace[encodeURI(s[idx])] || s[idx] || "";
							}
							return result;
						})
						// remove end CRLF if it exists
						.replace(regexCR, "") +
						whitespace.CRLF;
					max++;
					indx++;
				}
				if (indx < len) {
					setTimeout(() => {
						loop();
					}, 200);
				}
			};
			loop();
		}
	}

	function updateTabSize(block) {
		// remove previous tab-size setting
		block.className = block.className.replace(regexTabSize, " ");
		// calculate tab-size; GitHub-Dark allows user modification
		const len = window.getComputedStyle($(".blob-code-inner", block)).tabSize;
		block.classList.add(`tab-size-${len}`);
	}

	function removeObservers() {
		// remove saved observers
		observers.forEach(observer => {
			if (observer) {
				observer.disconnect();
			}
		});
		observers = [];
	}

	function addObservers() {
		// Observe progressively loaded content
		$$(`
			.js-diff-progressive-container,
			.js-diff-load-container`
		).forEach(target => {
			const obsrvr = new MutationObserver(mutations => {
				mutations.forEach(mutation => {
					// preform checks before adding code wrap to minimize function calls
					const tar = mutation.target;
					if (!busy && tar && (
							tar.classList.contains("js-diff-progressive-container") ||
							tar.classList.contains("js-diff-load-container") ||
							tar.classList.contains("blob-wrapper")
						)
					) {
						clearTimeout(debounce);
						debounce = setTimeout(() => {
							addToggle();
						}, 500);
					}
				});
			});
			obsrvr.observe(target, {
				childList: true,
				subtree: true
			});
			observers.push(obsrvr);
		});
	}

	function init() {
		// toggle added to diff & file view
		addToggle();
		removeObservers();
		if (document.querySelector("#files.diff-view")) {
			addObservers();
		}
	}

	function $(selector, el) {
		return (el || document).querySelector(selector);
	}

	function $$(selector, el) {
		return Array.from((el || document).querySelectorAll(selector));
	}

	function closest(selector, el) {
		while (el && el.nodeType === 1) {
			if (el.matches(selector)) {
				return el;
			}
			el = el.parentNode;
		}
		return null;
	}

	// bind whitespace toggle button
	document.addEventListener("click", event => {
		const target = event.target;
		if (
			target.nodeName === "DIV" &&
			target.classList.contains("ghcw-toggle")
		) {
			let block = $(".highlight", closest(".file", target));
			if (block) {
				target.classList.toggle("selected");
				block.classList.toggle("ghcw-active");
				updateTabSize(block);
				addWhitespace(block);
			}
		}
	});
	document.addEventListener("pjax:end", init);

	setTimeout(() => {
		init();
	}, 500);

})();
<|endoftext|>