diff --git a/themes/arm-design-system-hugo-theme/static/js/anonymous-analytics.js b/themes/arm-design-system-hugo-theme/static/js/anonymous-analytics.js index 5cc1fd65b8..d8e39d99a4 100644 --- a/themes/arm-design-system-hugo-theme/static/js/anonymous-analytics.js +++ b/themes/arm-design-system-hugo-theme/static/js/anonymous-analytics.js @@ -12,8 +12,10 @@ - list-card --> learning path and tool list cards should have these types - learning-path-nav --> navigation buttons in the application: 'next' button, 'prev' button, left-hand navigation - metadata --> any metadata links; above LPs and installs, and in 'next steps' page - - content --> all links from user-generated markdown should have this. the 'render-link.html' should implement this tracker + - content --> all links from user-generated markdown should have this, INCLUDING CODE. the 'render-link.html' should implement this tracker - data-track-name --> specific name of the element, human readable to link behavior. A click on a learning path should render its title, etc. This is dynamic + - data-track-identifier --> Prism language of a code block + - data-track-url --> full page URL for code copy events - facet-interaction Attributes tracked: @@ -28,6 +30,12 @@ Attributes tracked: - feedback-type --> either 'star-rating' or 'reason' - feedback-content --> specifies the feedback. Star-rating will be 1-5, Reason will be a string (from a limited choice set, not free text) + + - copy-type + Attributes tracked: + - data-track-type --> copy-type + - data-track-location --> manual-copy or button-copy + - data-track-name --> current page number and name */ @@ -168,6 +176,90 @@ function attachPageFindSearchTracker() { } +function getSelectedCodeBlock() { + const selection = window.getSelection(); + + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return null; + } + + const range = selection.getRangeAt(0); + + function getContainingPre(node) { + const element = node.nodeType === Node.ELEMENT_NODE + ? node + : node.parentElement; + + return element?.closest('.code-toolbar pre') || null; + } + + const start_pre = getContainingPre(range.startContainer); + const end_pre = getContainingPre(range.endContainer); + + // Only track selections contained within one Prism code block. + if (!start_pre || start_pre !== end_pre) { + return null; + } + + return start_pre; +} + + +function getCodeBlockLanguage(pre_element) { + const code_element = pre_element.querySelector('code'); + const language_class = Array.from(code_element?.classList || []) + .find((class_name) => class_name.startsWith('language-')); + + return language_class + ? language_class.replace('language-', '') + : 'unknown'; +} + + +function getCodeCopyTrackName() { + const active_learning_path_step = document.getElementById('learning-path-step-active'); + + if (active_learning_path_step) { + const page_number = parseInt(active_learning_path_step.getAttribute('data-step-num')) + 1; + const page_name = (active_learning_path_step.innerText || active_learning_path_step.textContent).trim(); + + return 'page_number:'+page_number+','+'page_name:'+page_name; + } + + const install_guide_title = document.getElementById('install-guide-title'); + + if (install_guide_title) { + const page_name = (install_guide_title.innerText || install_guide_title.textContent).trim(); + + return 'page_number:1,'+'page_name:'+page_name; + } + + return window.location.pathname; +} + + +function trackCodeCopy(copy_location, pre_element) { + const code_block_language = getCodeBlockLanguage(pre_element); + const track_str = getCodeCopyTrackName(); + + if (window._satellite?.track) { + window._satellite.track('content-interaction', { + 'data-track-type' : 'copy', + 'data-track-location' : 'content', + 'data-track-name' : track_str, + 'data-track-identifier' : code_block_language, + 'data-track-url' : window.location.href + }); + + window._satellite.track('content-interaction', { + 'data-track-type' : 'copy-type', + 'data-track-location' : copy_location, + 'data-track-name' : track_str + }); + } +} + + // Go page by page, and assign the analytics tracker event component to appropriate ares. @@ -177,6 +269,46 @@ function attachPageFindSearchTracker() { let depth_of_path= current_path.split('/').length - 1 // Get number of '/' in the string; will help identify where we are in the heirarcy + // + // Prism code blocks + // =================== + // Use delegated listeners because Prism creates its toolbar buttons after + // the page DOM is ready. These listeners cover Learning Paths and install + // guides and identify each copied code block by its Prism language. + + // Track keyboard and context-menu copies of a selection in a code block. + document.addEventListener('copy', function() { + const pre_element = getSelectedCodeBlock(); + + if (pre_element) { + trackCodeCopy('manual-copy', pre_element); + } + }); + + // Track use of Prism's copy-to-clipboard toolbar button. + document.addEventListener('click', function(event) { + if (!(event.target instanceof Element)) { + return; + } + + const copy_button = event.target.closest('button.copy-to-clipboard-button'); + + if (!copy_button) { + return; + } + + const pre_element = copy_button + .closest('.code-toolbar') + ?.querySelector('pre'); + + if (!pre_element) { + return; + } + + trackCodeCopy('button-copy', pre_element); + }); + + // // Header // =================== diff --git a/tools/social_card_generation/.gitignore b/tools/social_card_generation/.gitignore new file mode 100644 index 0000000000..d31b42517d --- /dev/null +++ b/tools/social_card_generation/.gitignore @@ -0,0 +1,2 @@ +# Commercial fonts - do not commit +fonts/*.otf diff --git a/tools/social_card_generation/README.md b/tools/social_card_generation/README.md new file mode 100644 index 0000000000..abdbf3413d --- /dev/null +++ b/tools/social_card_generation/README.md @@ -0,0 +1,23 @@ +# Social Card Prototype + +Prototype generator for learn.arm.com social/Open Graph cards with updated branding and name of the content directly in the image. It reads a Learning Path or install guide Markdown file, pulls the `title` from front matter, applies the content type label, and writes `social_image.webp`. + +To implement this we need to put this script in our CI pipeline for each content (and when its title is updated). Each content needs to generate a unique webp image for each learning path for social sharing, as opposed to the current one social image approach. The Hugo template needs to be updated for each content to source its webp. + + +You'll need these python files installed: +```bash +pip install playwright Pillow +python -m playwright install --only-shell chromium +``` + +Test from the repo root: + +```bash +python tools/social_card_prototype/generate.py content/install-guides/ambaviz.md +python tools/social_card_prototype/generate.py content/learning-paths/embedded-and-microcontrollers/advanced_soc +``` + +Open `tools/social_card_prototype/social_image.webp` to inspect the result. + +Exact brand rendering still needs a font solution for `fonts/Aeonik-Medium.otf` and `fonts/AeonikFono-Regular.otf`, which I have from our branding team but we are unable to host in the OSS repo on GitHub due to licensing. The current system font fallbacks are near-identical and OK for social sharing. diff --git a/tools/social_card_generation/fonts/.gitkeep b/tools/social_card_generation/fonts/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/social_card_generation/generate.py b/tools/social_card_generation/generate.py new file mode 100644 index 0000000000..3442e86cd8 --- /dev/null +++ b/tools/social_card_generation/generate.py @@ -0,0 +1,448 @@ +from pathlib import Path +from io import BytesIO +import argparse +import base64 +import html + +import yaml +from PIL import Image, ImageChops +from playwright.sync_api import sync_playwright + + +# ------------------------------------------------------------ +# Configuration +# ------------------------------------------------------------ + +WIDTH = 1200 +HEIGHT = 630 + +# Rectangle in which the title is allowed to appear. +TITLE_X = 70 +TITLE_Y = 205 +TITLE_WIDTH = 800 +TITLE_HEIGHT = 250 + +MAX_FONT_SIZE = 60 +MIN_FONT_SIZE = 36 +FONT_WEIGHT = 500 +LINE_HEIGHT = 1.08 + +TEXT_ALIGN = "left" +VERTICAL_ALIGN = "start" + + +# ------------------------------------------------------------ +# Files +# ------------------------------------------------------------ + +BASE_DIR = Path(__file__).resolve().parent +REPO_ROOT = BASE_DIR.parents[1] + +SVG_PATH = BASE_DIR / "learn-social-og-template.svg" +TITLE_FONT_PATH = BASE_DIR / "fonts" / "Aeonik-Medium.otf" +META_FONT_PATH = BASE_DIR / "fonts" / "AeonikFono-Regular.otf" +OUTPUT_PATH = BASE_DIR / "social_image.webp" + +LEARNING_PATH_ROOT = REPO_ROOT / "content" / "learning-paths" +INSTALL_GUIDE_ROOT = REPO_ROOT / "content" / "install-guides" +INSTALL_GUIDE_TITLE_TEMPLATE = "Quickly install the\n{title}\ntool" + + +def file_to_data_url(path: Path, mime_type: str) -> str: + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + return f"data:{mime_type};base64,{encoded}" + + +def vertical_alignment_css(value: str) -> str: + mapping = { + "start": "flex-start", + "center": "center", + "end": "flex-end", + } + + if value not in mapping: + raise ValueError( + f"VERTICAL_ALIGN must be one of {list(mapping)}, got {value!r}" + ) + + return mapping[value] + + +def parse_args(): + parser = argparse.ArgumentParser( + description=( + "Generate a social card from Learning Path or install guide metadata." + ) + ) + parser.add_argument( + "content_path", + help=( + "Path to a Learning Path directory/_index.md or an install guide .md file." + ), + ) + return parser.parse_args() + + +def resolve_content_path(value: str) -> Path: + raw_path = Path(value).expanduser() + + if raw_path.is_absolute(): + path = raw_path + else: + cwd_path = Path.cwd() / raw_path + repo_path = REPO_ROOT / raw_path + path = cwd_path if cwd_path.exists() else repo_path + + if path.is_dir(): + path = path / "_index.md" + + if not path.exists(): + raise FileNotFoundError(f"Content file not found: {path}") + + if path.suffix != ".md": + raise ValueError(f"Content path must be a Markdown file: {path}") + + return path.resolve() + + +def content_type_for_path(path: Path) -> str: + resolved_path = path.resolve() + + try: + resolved_path.relative_to(LEARNING_PATH_ROOT.resolve()) + return "LEARNING PATH" + except ValueError: + pass + + try: + resolved_path.relative_to(INSTALL_GUIDE_ROOT.resolve()) + return "INSTALL GUIDE" + except ValueError: + pass + + raise ValueError( + "Content path must be under content/learning-paths or content/install-guides: " + f"{path}" + ) + + +def read_front_matter(path: Path) -> dict: + lines = path.read_text(encoding="utf-8").splitlines() + + if not lines or lines[0].strip() != "---": + raise ValueError(f"Missing YAML front matter delimiter at start of {path}") + + for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + front_matter = "\n".join(lines[1:index]) + data = yaml.safe_load(front_matter) or {} + + if not isinstance(data, dict): + raise ValueError(f"YAML front matter must be a mapping in {path}") + + return data + + raise ValueError(f"Missing closing YAML front matter delimiter in {path}") + + +def title_for_path(path: Path) -> str: + front_matter = read_front_matter(path) + title = front_matter.get("title") + + if title is None or not str(title).strip(): + raise ValueError(f"Missing required title in front matter: {path}") + + return str(title).strip() + + +def render_title(raw_title: str, content_type: str) -> str: + if content_type != "INSTALL GUIDE": + return raw_title + + return INSTALL_GUIDE_TITLE_TEMPLATE.replace("{title}", raw_title) + + +def font_face_css(path: Path, family: str, weight: int, fallback: str) -> str: + if not path.exists(): + print( + f"Warning: {path.name} not found. " + f"Using system {fallback} fallback." + ) + return "" + + font_url = file_to_data_url(path, "font/otf") + + return f""" + @font-face {{ + font-family: "{family}"; + src: url("{font_url}") format("opentype"); + font-style: normal; + font-weight: {weight}; + font-display: block; + }} +""" + + +def main(): + args = parse_args() + content_path = resolve_content_path(args.content_path) + raw_title = title_for_path(content_path) + content_type = content_type_for_path(content_path) + title = render_title(raw_title, content_type) + + for path in ( + SVG_PATH, + ): + if not path.exists(): + raise FileNotFoundError(f"Missing required file: {path}") + + + title_font_css = font_face_css(TITLE_FONT_PATH, "Aeonik", 500, "sans-serif") + meta_font_css = font_face_css(META_FONT_PATH, "Aeonik Fono", 400, "monospace") + svg_url = file_to_data_url(SVG_PATH, "image/svg+xml") + + safe_title = html.escape(title) + safe_content_type = html.escape(content_type) + + document = f""" + + + + + + + + + + + + +
+ + +
{safe_content_type}
+
+
{safe_title}
+
+ +
+ + + + +""" + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + + page = browser.new_page( + viewport={ + "width": WIDTH, + "height": HEIGHT, + }, + device_scale_factor=4, + ) + + page.set_content(document) + + page.wait_for_function("window.__READY__ === true") + + result = page.evaluate("window.__TITLE_RESULT__") + + if not result["fits"]: + browser.close() + raise RuntimeError( + "Title could not fit inside the configured title box " + f"even at {result['fontSize']}px. " + "Increase TITLE_WIDTH/TITLE_HEIGHT, lower MIN_FONT_SIZE, " + "or shorten the title." + ) + + print( + f"Title fitted at {result['fontSize']}px " + f"inside {TITLE_WIDTH}x{TITLE_HEIGHT}px box." + ) + + # Playwright returns PNG bytes directly. + # No temporary PNG file is required. + png_bytes = page.locator("#card").screenshot(type="png") + + browser.close() + + with Image.open(BytesIO(png_bytes)) as image: + image = image.convert("RGB") + + image = image.resize( + (WIDTH, HEIGHT), + Image.Resampling.LANCZOS, + ) + + # Very subtle dithering to break up gradient banding. + noise = Image.effect_noise( + image.size, + sigma=1.5, # 0.6 → extremely subtle. 1.0 → my starting point 1.5 → stronger anti-banding 2.0 → probably more grain than you need + ).convert("RGB") + + dithered = ImageChops.add( + image, + noise, + scale=1.0, + offset=-128, + ) + + # Only dither darker pixels. + # White logo/title remain untouched. + luminance = image.convert("L") + + mask = luminance.point( + lambda p: 255 if p < 190 else 0 + ) + + image = Image.composite( + dithered, + image, + mask, + ) + + + image.save( + OUTPUT_PATH, + format="WEBP", + quality=96, + method=6, + ) + + print(f"Wrote: {OUTPUT_PATH}") + + +if __name__ == "__main__": + main() diff --git a/tools/social_card_generation/learn-social-og-template.svg b/tools/social_card_generation/learn-social-og-template.svg new file mode 100644 index 0000000000..49966345f4 --- /dev/null +++ b/tools/social_card_generation/learn-social-og-template.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/tools/social_card_generation/output_sample.webp b/tools/social_card_generation/output_sample.webp new file mode 100644 index 0000000000..e41b535a8f Binary files /dev/null and b/tools/social_card_generation/output_sample.webp differ