diff --git a/src/audit-logs/scripts/sync.js b/src/audit-logs/scripts/sync.js index 6606bdba7cd6..54b842b27695 100755 --- a/src/audit-logs/scripts/sync.js +++ b/src/audit-logs/scripts/sync.js @@ -24,7 +24,6 @@ const AUDIT_LOG_DATA_DIR = 'src/audit-logs/data' // the 3 different audit log types which correspond to audit log event pages const auditLogTypes = ['user', 'organization', 'enterprise'] -const versions = ['fpt', 'ghec', 'ghes'] // store an array of audit log event data keyed by version and audit log type, // array will look like: @@ -37,15 +36,6 @@ const versions = ['fpt', 'ghec', 'ghes'] // }, // ... // ] -const auditLogData = {} -versions.forEach((version) => { - auditLogData[version] = {} - - auditLogTypes.forEach((type) => { - auditLogData[version][type] = [] - }) -}) - async function main() { // get latest audit log data // @@ -65,11 +55,26 @@ async function main() { const schemaEvents = JSON.parse(await getContents(owner, repo, ref, schemaFilePath)) const mainSha = await getCommitSha(owner, repo, `heads/${ref}`) - // update pipeline config data const configFilepath = `src/audit-logs/lib/config.json` - const configData = JSON.parse(await readFile(configFilepath, 'utf8')) - configData.sha = mainSha - await writeFile(configFilepath, JSON.stringify(configData, null, 2)) + const pipelineConfig = JSON.parse(await readFile(configFilepath, 'utf8')) + pipelineConfig.sha = mainSha + await writeFile(configFilepath, JSON.stringify(pipelineConfig, null, 2)) + + const auditLogData = {} + // Wrapper around filterByAllowlistValues() because we always need all the + // schema events and pipeline config data. + const filter = ( + allowListValues, + filterConfig = { filterFn: filterOr, ghesOnly: false }, + currentEvents = [], + ) => + filterByAllowlistValues( + schemaEvents, + currentEvents, + allowListValues, + pipelineConfig, + filterConfig, + ) // translate allowLists values into the versions and audit log page the event // belongs to -- for versions: @@ -84,101 +89,70 @@ async function main() { // * user => user page // * business_server => enterprise page // * organization => organization page - schemaEvents - .filter((event) => event._allowlists !== null) - .forEach((event) => { - const minimalEvent = { - action: event.action, - description: event.description, - docs_reference_links: event.docs_reference_links, - } - - // FPT events - if (event._allowlists.includes('user')) { - auditLogData.fpt.user.push(minimalEvent) - } - if (event._allowlists.includes('organization')) { - auditLogData.fpt.organization.push(minimalEvent) - } - - // GHES events - if (event._allowlists.includes('business_server')) { - auditLogData.ghes.enterprise.push(minimalEvent) - - if (event._allowlists.includes('user')) { - auditLogData.ghes.user.push(minimalEvent) - } - - if (event._allowlists.includes('organization')) { - auditLogData.ghes.organization.push(minimalEvent) - } - } - - // GHEC events - if (event._allowlists.includes('business')) { - auditLogData.ghec.enterprise.push(minimalEvent) - - if (event._allowlists.includes('organization')) { - auditLogData.ghec.organization.push(minimalEvent) - } - - if (event._allowlists.includes('user')) { - auditLogData.ghec.user.push(minimalEvent) - } - } - - // API only events have either `business_api_only` or `org_api_only` - // allowlist values. - // - // * business_api_only: goes to the enterprise events page and is versioned - // for GHEC. If the event has any `ghes` versions, also verisoned for GHES. - // * org_api_only: goes to the organzation events page and is versioned - // for fpt and GHEC. If the event has any `ghes` versions, also versioned - // for GHES. - // - // There's currently one case (`org.sso_response`) where an event is an - // api only event but also has a non-api-only allowlist value so that's - // why we have the checks to prevent adding the event twice. - if (event._allowlists.includes('business_api_only')) { - if (!auditLogData.ghec.enterprise.find((e) => e.action === event.action)) { - auditLogData.ghec.enterprise.push(minimalEvent) - } - - if ( - Object.keys(event.ghes).length > 0 && - !auditLogData.ghes.enterprise.find((e) => e.action === event.action) - ) { - auditLogData.ghes.enterprise.push(minimalEvent) - } - } - - if (event._allowlists.includes('org_api_only')) { - if (!auditLogData.ghec.organization.find((e) => e.action === event.action)) { - auditLogData.fpt.organization.push(minimalEvent) - auditLogData.ghec.organization.push(minimalEvent) - } + // + // API only events have either `business_api_only` or `org_api_only` + // allowlist values. + // + // * business_api_only: goes to the enterprise events page and is versioned + // for GHEC. If the event has any `ghes` versions, also verisoned for GHES. + // * org_api_only: goes to the organzation events page and is versioned + // for fpt and GHEC. If the event has any `ghes` versions, also versioned + // for GHES. + // + // There's currently one case (`org.sso_response`) where an event is an + // api only event but also has a non-api-only allowlist value so that's + // why we have the checks to prevent adding the event twice. + auditLogData.fpt = {} + auditLogData.fpt.user = filter('user') + auditLogData.fpt.organization = filter(['organization', 'org_api_only']) - if ( - Object.keys(event.ghes).length > 0 && - !auditLogData.ghes.organization.find((e) => e.action === event.action) - ) { - auditLogData.ghes.organization.push(minimalEvent) - } - } + auditLogData.ghes = {} + auditLogData.ghes.enterprise = filter('business_server') + auditLogData.ghes.enterprise = filter( + 'business_api_only', + { + filterFn: filterOr, + ghesOnly: true, + }, + auditLogData.ghes.enterprise, + ) + auditLogData.ghes.user = filter(['business_server', 'user'], { + filterFn: filterAnd, + ghesOnly: false, + }) + auditLogData.ghes.organization = filter(['business_server', 'organization'], { + filterFn: filterAnd, + ghesOnly: false, + }) + auditLogData.ghes.organization = filter( + 'org_api_only', + { + filterFn: filterOr, + ghesOnly: true, + }, + auditLogData.ghes.organization, + ) - // For the API only events we append an extra note to the description - // that explains the event is api only. It's better to do it here instead - // of in the schema where it would have to be updated and maintained manually. - // - // Some events have both API only allowlist values so we make sure to append - // to the description only once. - if ( - event._allowlists.includes('org_api_only') || - event._allowlists.includes('business_api_only') - ) { - minimalEvent.description += ` ${configData.apiOnlyEventsAdditionalDescription}` - } - }) + auditLogData.ghec = {} + auditLogData.ghec.user = filter(['business', 'user'], { + filterFn: filterAnd, + ghesOnly: false, + }) + auditLogData.ghec.organization = filter(['business', 'organization'], { + filterFn: filterAnd, + ghesOnly: false, + }) + auditLogData.ghec.organization = filter( + 'org_api_only', + { filterFn: filterOr, ghesOnly: false }, + auditLogData.ghec.organization, + ) + auditLogData.ghec.enterprise = filter('business') + auditLogData.ghec.enterprise = filter( + 'business_api_only', + { filterFn: filterOr, ghesOnly: false }, + auditLogData.ghec.enterprise, + ) // We don't maintain the order of events as we process them so after filtering // all the events based on their allowlist values, we sort them so they're in @@ -227,4 +201,69 @@ async function main() { } } +// Filters audit log events based on allowlist values. +// +// * eventsToCheck: events to consider +// * currentEvents: events already collected +// * allowListvalues: allowlist values to filter by +// * pipelineConfig: audit log pipeline config data +// * filterFn: callback to filter an event by allowlist values +// * ghesOnly: whether or not we should check an event is versioned for ghes +// as an additional filter check +function filterByAllowlistValues( + eventsToCheck, + currentEvents, + allowListValues, + pipelineConfig, + filterConfig = { + filterFn: filterOr, + ghesOnly: false, + }, +) { + if (!Array.isArray(allowListValues)) allowListValues = [allowListValues] + if (!currentEvents) currentEvents = [] + + const seen = new Set(currentEvents.map((event) => event.action)) + const minimalEvents = [] + + for (const event of eventsToCheck) { + if (event._allowlists === null) continue + + if (filterConfig.filterFn(event._allowlists, allowListValues)) { + if (seen.has(event.action)) continue + seen.add(event.action) + + const minimal = { + action: event.action, + description: event.description, + docs_reference_links: event.docs_reference_links, + } + + if ( + event._allowlists.includes('org_api_only') || + event._allowlists.includes('business_api_only') + ) { + minimal.description += ` ${pipelineConfig.apiOnlyEventsAdditionalDescription}` + } + + if (filterConfig.ghesOnly) { + if (Object.keys(event.ghes).length > 0) { + minimalEvents.push(minimal) + } + } else { + minimalEvents.push(minimal) + } + } + } + return [...minimalEvents, ...currentEvents] +} + +function filterOr(array, conditions) { + return conditions.some((condition) => array.includes(condition)) +} + +function filterAnd(array, conditions) { + return conditions.every((condition) => array.includes(condition)) +} + main() diff --git a/src/content-render/stylesheets/alerts.scss b/src/content-render/stylesheets/alerts.scss new file mode 100644 index 000000000000..ef79e7f73bc0 --- /dev/null +++ b/src/content-render/stylesheets/alerts.scss @@ -0,0 +1,35 @@ +// Largely identical styling from the monolith, +// but the color names match the Primer variables +// and we had to directly state a few props instead of using variables +// that are only in the monolith. + +$colors: "default", "muted", "subtle", "accent", "success", "attention", + "severe", "danger", "open", "closed", "done", "sponsors"; + +.ghd-alert { + padding: var(--base-size-8, 0.5rem) var(--base-size-16, 1rem); + border-left: 0.25em solid + var(--borderColor-default, var(--color-border-default)); + margin-bottom: 1rem; +} + +.ghd-alert > :last-child { + margin-bottom: 0; +} + +.ghd-alert-title { + display: flex; + font-weight: var(--base-text-weight-medium, 500); + align-items: center; + line-height: 1; +} + +@each $color in $colors { + .ghd-alert-#{$color} { + border-left-color: var(--fgColor-#{$color}, var(--color-#{$color}-fg)); + + .ghd-alert-title { + color: var(--fgColor-#{$color}, var(--color-#{$color}-fg)); + } + } +} diff --git a/src/content-render/stylesheets/index.scss b/src/content-render/stylesheets/index.scss index 9a2f5b29bc5f..047b4cd0538e 100644 --- a/src/content-render/stylesheets/index.scss +++ b/src/content-render/stylesheets/index.scss @@ -4,3 +4,4 @@ @import "markdown-overrides.scss"; @import "spotlight.scss"; @import "syntax-highlighting.scss"; +@import "alerts.scss"; diff --git a/src/content-render/unified/alerts.js b/src/content-render/unified/alerts.js new file mode 100644 index 000000000000..7f2a74368051 --- /dev/null +++ b/src/content-render/unified/alerts.js @@ -0,0 +1,77 @@ +/* +Custom "Alerts", based on similar filter/styling in the monolith code. +*/ + +import { visit } from 'unist-util-visit' +import { h } from 'hastscript' +import octicons from '@primer/octicons' + +const alertTypes = { + NOTE: { icon: 'info', color: 'accent', title: 'Note' }, + IMPORTANT: { icon: 'report', color: 'done', title: 'Important' }, + WARNING: { icon: 'alert', color: 'attention', title: 'Warning' }, + TIP: { icon: 'light-bulb', color: 'success', title: 'Tip' }, + CAUTION: { icon: 'stop', color: 'danger', title: 'Caution' }, +} + +// Must contain one of [!NOTE], [!IMPORTANT], ... +const ALERT_REGEXP = new RegExp(`\\[!(${Object.keys(alertTypes).join('|')})\\]`, 'gi') + +const matcher = (node) => + node.type === 'element' && + node.tagName === 'blockquote' && + ALERT_REGEXP.test(JSON.stringify(node.children)) + +export default function alerts() { + return (tree) => { + visit(tree, matcher, (node) => { + const key = getAlertKey(node) + if (!(key in alertTypes)) { + console.warn( + `Alert key '${key}' should be all uppercase (change it to '${key.toUpperCase()}')`, + ) + } + const alertType = alertTypes[getAlertKey(node).toUpperCase()] + node.tagName = 'div' + node.properties.className = 'ghd-alert ghd-alert-' + alertType.color + node.children = [ + h('p', { className: 'ghd-alert-title' }, getOcticonSVG(alertType.icon), alertType.title), + ...removeAlertSyntax(node.children), + ] + }) + } +} + +function getAlertKey(node) { + const body = JSON.stringify(node.children) + const matches = body.match(ALERT_REGEXP) + return matches[0].slice(2, -1) +} + +function removeAlertSyntax(node) { + if (Array.isArray(node)) { + return node.map(removeAlertSyntax) + } + if (node.children) { + node.children = node.children.map(removeAlertSyntax) + } + if (node.value) { + node.value = node.value.replace(ALERT_REGEXP, '') + } + return node +} + +function getOcticonSVG(name) { + return h( + 'svg', + { + version: '1.1', + width: 16, + height: 16, + viewBox: '0 0 16 16', + className: 'octicon mr-2', + ariaHidden: true, + }, + h('path', { d: octicons[name].heights[16].path.match(/d="(.*)"/)[1] }), + ) +} diff --git a/src/content-render/unified/processor.js b/src/content-render/unified/processor.js index 98caaa15ccd0..6753bb40d230 100644 --- a/src/content-render/unified/processor.js +++ b/src/content-render/unified/processor.js @@ -25,6 +25,7 @@ import rewriteForRowheaders from './rewrite-for-rowheaders.js' import wrapProceduralImages from './wrap-procedural-images.js' import parseInfoString from './parse-info-string.js' import annotate from './annotate.js' +import alerts from './alerts.js' export function createProcessor(context) { return ( @@ -53,6 +54,7 @@ export function createProcessor(context) { .use(rewriteImgSources) .use(rewriteAssetImgTags) .use(rewriteLocalLinks, context) + .use(alerts) // HTML AST above ^^^ .use(html) // String below vvv diff --git a/src/fixtures/fixtures/content/get-started/markdown/alerts.md b/src/fixtures/fixtures/content/get-started/markdown/alerts.md new file mode 100644 index 000000000000..85769b84b97d --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/markdown/alerts.md @@ -0,0 +1,35 @@ +--- +title: Alerts Markdown +shortTitle: Alerts +intro: Demonstrates the use of special alerts Markdown syntax +versions: + fpt: '*' + ghes: '*' + ghec: '*' +type: how_to +--- + +## Tip + +> [!TIP] +> Here's a free tip + +## Note + +> [!NOTE] +> A note. + +## Important + +> [!IMPORTANT] +> This is important + +## Warning + +> [!WARNING] +> Just a warning + +## Caution + +> [!CAUTION] +> Be careful! diff --git a/src/fixtures/fixtures/content/get-started/markdown/index.md b/src/fixtures/fixtures/content/get-started/markdown/index.md index e8f19ed567ca..6acdd6d617fd 100644 --- a/src/fixtures/fixtures/content/get-started/markdown/index.md +++ b/src/fixtures/fixtures/content/get-started/markdown/index.md @@ -10,4 +10,5 @@ children: - /intro - /permissions - /code-annotations + - /alerts --- diff --git a/src/fixtures/tests/markdown.js b/src/fixtures/tests/markdown.js index 4dd8c1afd849..1f87671f80e4 100644 --- a/src/fixtures/tests/markdown.js +++ b/src/fixtures/tests/markdown.js @@ -20,3 +20,30 @@ describe('markdown rendering', () => { expect(html).toMatch('HubGit Pages site') }) }) + +describe('alerts', () => { + test('basic rendering', async () => { + const $ = await getDOM('/get-started/markdown/alerts') + const alerts = $('#article-contents .ghd-alert') + // See src/fixtures/fixtures/content/get-started/markdown/alerts.md + // to be this confident in the assertions. + expect(alerts.length).toBe(5) + const svgs = $('svg', alerts) + expect(svgs.length).toBe(5) + const titles = $('.ghd-alert-title', alerts) + .map((_, el) => $(el).text()) + .get() + expect(titles).toEqual(['Tip', 'Note', 'Important', 'Warning', 'Caution']) + const bodies = $('p:nth-child(2)', alerts) + .map((_, el) => $(el).text()) + .get() + .map((s) => s.trim()) + expect(bodies).toEqual([ + "Here's a free tip", + 'A note.', + 'This is important', + 'Just a warning', + 'Be careful!', + ]) + }) +})